diff --git a/packages/appearance/src/effects.test.tsx b/packages/appearance/src/effects.test.tsx
index 0f659d3..b1f3564 100644
--- a/packages/appearance/src/effects.test.tsx
+++ b/packages/appearance/src/effects.test.tsx
@@ -14,6 +14,12 @@ import {
import { appearancePreferenceEffect } from "./effects"
import { defaultAppearancePreferences } from "./preferences"
+import { COLOR_VISION_ATTRIBUTE } from "./color-vision"
+import {
+ BRIGHTNESS_ATTRIBUTE,
+ MOTION_ATTRIBUTE,
+ TEXT_SCALE_ATTRIBUTE,
+} from "./display"
import { THEME_STYLE_ELEMENT_ID } from "./theme"
beforeAll(() => {
@@ -43,6 +49,10 @@ afterEach(() => {
cleanup()
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
document.documentElement.className = ""
+ document.documentElement.removeAttribute(COLOR_VISION_ATTRIBUTE)
+ document.documentElement.removeAttribute(BRIGHTNESS_ATTRIBUTE)
+ document.documentElement.removeAttribute(MOTION_ATTRIBUTE)
+ document.documentElement.removeAttribute(TEXT_SCALE_ATTRIBUTE)
document.documentElement.style.colorScheme = ""
})
@@ -71,6 +81,16 @@ describe("appearancePreferenceEffect", () => {
)
expect(document.documentElement.classList.contains("ui:light")).toBe(true)
+ expect(document.documentElement.getAttribute(COLOR_VISION_ATTRIBUTE)).toBe(
+ "standard"
+ )
+ expect(document.documentElement.getAttribute(BRIGHTNESS_ATTRIBUTE)).toBe(
+ "normal"
+ )
+ expect(document.documentElement.getAttribute(MOTION_ATTRIBUTE)).toBe("full")
+ expect(document.documentElement.getAttribute(TEXT_SCALE_ATTRIBUTE)).toBe(
+ "100"
+ )
expect(
document.getElementById(THEME_STYLE_ELEMENT_ID)?.textContent
).toContain("--background:")
diff --git a/packages/appearance/src/effects.ts b/packages/appearance/src/effects.ts
index e1353eb..14f917e 100644
--- a/packages/appearance/src/effects.ts
+++ b/packages/appearance/src/effects.ts
@@ -4,6 +4,13 @@ import {
} from "@workspace/preferences"
import type { AppearancePreferences } from "./preferences"
+import { COLOR_VISION_ATTRIBUTE } from "./color-vision"
+import {
+ BRIGHTNESS_ATTRIBUTE,
+ MOTION_ATTRIBUTE,
+ TEXT_SCALE_ATTRIBUTE,
+ type ResolvedMotionMode,
+} from "./display"
import {
buildThemeStyleSheet,
THEME_STYLE_ELEMENT_ID,
@@ -11,10 +18,15 @@ import {
} from "./theme"
const systemThemeQuery = "(prefers-color-scheme: dark)"
-const themePreferenceKeys = [
+const reducedMotionQuery = "(prefers-reduced-motion: reduce)"
+const appearancePreferenceKeys = [
+ "accent-color",
+ "base-color",
+ "brightness",
+ "color-vision-mode",
+ "motion-mode",
+ "text-scale",
"theme-mode",
- "theme-light",
- "theme-dark",
] as const satisfies readonly (keyof AppearancePreferences)[]
let restoreThemeTransitionFrame: number | undefined
@@ -41,6 +53,10 @@ function getSystemTheme(): ThemeScheme {
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
}
+function getSystemMotion(): ResolvedMotionMode {
+ return window.matchMedia(reducedMotionQuery).matches ? "reduce" : "full"
+}
+
export function applyAppearancePreferences(preferences: AppearancePreferences) {
if (typeof document === "undefined") return
@@ -49,19 +65,29 @@ export function applyAppearancePreferences(preferences: AppearancePreferences) {
preferences["theme-mode"] === "system"
? getSystemTheme()
: preferences["theme-mode"]
+ const resolvedMotion =
+ preferences["motion-mode"] === "system"
+ ? getSystemMotion()
+ : preferences["motion-mode"]
const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID)
if (themeStyle) {
themeStyle.textContent = buildThemeStyleSheet(
- preferences["theme-light"],
- preferences["theme-dark"]
+ {
+ accentColor: preferences["accent-color"],
+ baseColor: preferences["base-color"],
+ },
+ preferences["color-vision-mode"],
+ preferences.brightness
)
}
- root.classList.toggle("ui:compacted", preferences["main-compacted"])
- root.classList.toggle("ui:collapsed", preferences["sidebar-collapsed"])
root.classList.toggle("ui:dark", resolvedTheme === "dark")
root.classList.toggle("ui:light", resolvedTheme === "light")
+ root.setAttribute(BRIGHTNESS_ATTRIBUTE, preferences.brightness)
+ root.setAttribute(COLOR_VISION_ATTRIBUTE, preferences["color-vision-mode"])
+ root.setAttribute(MOTION_ATTRIBUTE, resolvedMotion)
+ root.setAttribute(TEXT_SCALE_ATTRIBUTE, preferences["text-scale"])
root.style.colorScheme = resolvedTheme
}
@@ -75,7 +101,7 @@ export const applyAppearancePreferenceEffect: PreferenceEffectCallback = ({
previousPreferences as AppearancePreferences
if (
- themePreferenceKeys.some(
+ appearancePreferenceKeys.some(
(key) =>
!Object.is(
previousAppearancePreferences[key],
@@ -107,8 +133,40 @@ export const subscribeSystemThemePreferenceEffect: PreferenceEffectCallback = ({
return () => mediaQuery.removeEventListener("change", applySystemTheme)
}
+/** Keeps system-mode motion synchronized with the operating system setting. */
+export const subscribeSystemMotionPreferenceEffect: PreferenceEffectCallback =
+ ({ preferences, store }) => {
+ const appearancePreferences = preferences as AppearancePreferences
+ if (appearancePreferences["motion-mode"] !== "system") return
+
+ const mediaQuery = window.matchMedia(reducedMotionQuery)
+ const applySystemMotion = () => {
+ applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
+ }
+
+ mediaQuery.addEventListener("change", applySystemMotion)
+ return () => mediaQuery.removeEventListener("change", applySystemMotion)
+ }
+
+/** Subscribes to every operating-system preference used by Appearance. */
+export const subscribeSystemAppearancePreferenceEffect: PreferenceEffectCallback =
+ (context) => {
+ const cleanups = [
+ subscribeSystemThemePreferenceEffect(context),
+ subscribeSystemMotionPreferenceEffect(context),
+ ].filter(
+ (cleanup): cleanup is VoidFunction => typeof cleanup === "function"
+ )
+
+ if (cleanups.length === 0) return
+
+ return () => {
+ for (const cleanup of cleanups) cleanup()
+ }
+ }
+
/** Register this effect with the application's shared `PreferencesProvider`. */
export const appearancePreferenceEffect: PreferenceEffect = {
layoutEffect: applyAppearancePreferenceEffect,
- effect: subscribeSystemThemePreferenceEffect,
+ effect: subscribeSystemAppearancePreferenceEffect,
}
diff --git a/packages/appearance/src/index.ts b/packages/appearance/src/index.ts
index d6808cc..bc3249a 100644
--- a/packages/appearance/src/index.ts
+++ b/packages/appearance/src/index.ts
@@ -24,9 +24,21 @@ export {
type AppearanceSettingsProps,
} from "./components/appearance-settings"
export {
- CompactLayoutField,
- type CompactLayoutFieldProps,
-} from "./components/compact-layout-field"
+ BrightnessField,
+ type BrightnessFieldProps,
+} from "./components/brightness-field"
+export {
+ ColorVisionModeField,
+ type ColorVisionModeFieldProps,
+} from "./components/color-vision-mode-field"
+export {
+ MotionModeField,
+ type MotionModeFieldProps,
+} from "./components/motion-mode-field"
+export {
+ TextScaleField,
+ type TextScaleFieldProps,
+} from "./components/text-scale-field"
export {
ThemeColorPicker,
ThemeColorSwatchButton,
@@ -58,10 +70,36 @@ export {
type ThemeToggleButtonProps,
type ThemeToggleProps,
} from "./components/theme-toggle-button"
+export {
+ COLOR_VISION_ATTRIBUTE,
+ colorVisionModes,
+ colorVisionPalettes,
+ getColorVisionCssVariables,
+ isColorVisionMode,
+ type ColorVisionMode,
+} from "./color-vision"
+export {
+ BRIGHTNESS_ATTRIBUTE,
+ MOTION_ATTRIBUTE,
+ TEXT_SCALE_ATTRIBUTE,
+ applyBrightnessToTheme,
+ brightnessModes,
+ isBrightness,
+ isMotionMode,
+ isTextScale,
+ motionModes,
+ textScales,
+ type Brightness,
+ type MotionMode,
+ type ResolvedMotionMode,
+ type TextScale,
+} from "./display"
export {
applyAppearancePreferenceEffect,
applyAppearancePreferences,
appearancePreferenceEffect,
+ subscribeSystemAppearancePreferenceEffect,
+ subscribeSystemMotionPreferenceEffect,
subscribeSystemThemePreferenceEffect,
} from "./effects"
export {
diff --git a/packages/appearance/src/locales/zh-Hans.ts b/packages/appearance/src/locales/zh-Hans.ts
index d93d098..384f40c 100644
--- a/packages/appearance/src/locales/zh-Hans.ts
+++ b/packages/appearance/src/locales/zh-Hans.ts
@@ -4,8 +4,13 @@ export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
- "appearance.active": "使用中",
+ "appearance.accentColor": "强调色",
"appearance.baseColor": "基础颜色",
+ "appearance.brightness.bright": "明亮",
+ "appearance.brightness.description": "调整界面表面,不改变图片和视频",
+ "appearance.brightness.dim": "柔暗",
+ "appearance.brightness.normal": "标准",
+ "appearance.brightness.title": "界面亮度",
"appearance.color.amber": "琥珀色",
"appearance.color.blue": "蓝色",
"appearance.color.cyan": "青色",
@@ -30,10 +35,16 @@ export const messages = {
"appearance.color.violet": "紫罗兰色",
"appearance.color.yellow": "黄色",
"appearance.color.zinc": "锌灰",
- "appearance.command.description": "打开主题与布局偏好",
- "appearance.compact.description": "启用后,页面主内容将使用紧凑宽度",
- "appearance.compact.title": "紧凑布局",
- "appearance.description": "主题与布局偏好",
+ "appearance.colorVision.assisted.description":
+ "使用更容易相互区分的语义色板;关键信息仍应搭配文字、图标或形状",
+ "appearance.colorVision.deutan": "绿色辨识辅助(Deutan)",
+ "appearance.colorVision.protan": "红色辨识辅助(Protan)",
+ "appearance.colorVision.standard": "标准颜色",
+ "appearance.colorVision.standard.description": "使用所选强调色和默认语义颜色",
+ "appearance.colorVision.title": "色彩辨识辅助",
+ "appearance.colorVision.tritan": "蓝黄色辨识辅助(Tritan)",
+ "appearance.command.description": "打开外观与无障碍偏好",
+ "appearance.description": "主题、颜色、文字、动态效果与界面亮度偏好",
"appearance.mode.dark": "深色",
"appearance.mode.dark.description": "界面将始终使用深色主题",
"appearance.mode.light": "浅色",
@@ -41,11 +52,18 @@ export const messages = {
"appearance.mode.system": "系统",
"appearance.mode.system.description": "界面主题将跟随系统外观设置",
"appearance.mode.title": "主题模式",
- "appearance.primaryColor": "主要颜色",
- "appearance.theme.dark": "深色主题",
- "appearance.theme.dark.description": "当系统设置为深色模式时,将使用此主题",
- "appearance.theme.light": "浅色主题",
- "appearance.theme.light.description": "当系统设置为浅色模式时,将使用此主题",
+ "appearance.motion.full": "完整动效",
+ "appearance.motion.full.description": "允许所有界面动画和过渡效果",
+ "appearance.motion.reduce": "减少动态效果",
+ "appearance.motion.reduce.description": "减少非必要动画和平滑滚动",
+ "appearance.motion.system": "跟随系统",
+ "appearance.motion.system.description": "跟随操作系统的动态效果偏好",
+ "appearance.motion.title": "动态效果",
+ "appearance.textScale.description":
+ "同步缩放文字、控件与间距,使内容能够重新排版",
+ "appearance.textScale.title": "文字大小",
+ "appearance.themeColors": "主题颜色",
+ "appearance.themeColors.description": "为所有主题模式选择一组基础色和强调色",
"appearance.title": "界面外观",
"appearance.toggleTheme": "切换主题",
"appearance.toggleTheme.dark": "切换为深色模式",
diff --git a/packages/appearance/src/messages.ts b/packages/appearance/src/messages.ts
index dce1d0b..b206abb 100644
--- a/packages/appearance/src/messages.ts
+++ b/packages/appearance/src/messages.ts
@@ -1,9 +1,9 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const appearanceMessages = {
- active: /* i18n */ {
- id: "appearance.active",
- message: "Active",
+ accentColor: /* i18n */ {
+ id: "appearance.accentColor",
+ message: "Accent color",
},
appearance: /* i18n */ {
id: "appearance.title",
@@ -11,19 +11,59 @@ export const appearanceMessages = {
},
appearanceCommandDescription: /* i18n */ {
id: "appearance.command.description",
- message: "Open theme and layout preferences",
+ message: "Open appearance and accessibility preferences",
},
baseColor: /* i18n */ {
id: "appearance.baseColor",
message: "Base color",
},
- compactDescription: /* i18n */ {
- id: "appearance.compact.description",
- message: "Use a compact width for the main page content",
+ brightness: /* i18n */ {
+ id: "appearance.brightness.title",
+ message: "Brightness",
},
- compactLayout: /* i18n */ {
- id: "appearance.compact.title",
- message: "Compact layout",
+ brightnessBright: /* i18n */ {
+ id: "appearance.brightness.bright",
+ message: "Bright",
+ },
+ brightnessDescription: /* i18n */ {
+ id: "appearance.brightness.description",
+ message: "Adjusts interface surfaces without changing media",
+ },
+ brightnessDim: /* i18n */ {
+ id: "appearance.brightness.dim",
+ message: "Dim",
+ },
+ brightnessNormal: /* i18n */ {
+ id: "appearance.brightness.normal",
+ message: "Normal",
+ },
+ colorVisionAssistedDescription: /* i18n */ {
+ id: "appearance.colorVision.assisted.description",
+ message: "Uses a palette with more distinguishable semantic colors",
+ },
+ colorVisionDeutan: /* i18n */ {
+ id: "appearance.colorVision.deutan",
+ message: "Green-weak assistance (deutan)",
+ },
+ colorVisionMode: /* i18n */ {
+ id: "appearance.colorVision.title",
+ message: "Color-vision assistance",
+ },
+ colorVisionProtan: /* i18n */ {
+ id: "appearance.colorVision.protan",
+ message: "Red-weak assistance (protan)",
+ },
+ colorVisionStandard: /* i18n */ {
+ id: "appearance.colorVision.standard",
+ message: "Standard colors",
+ },
+ colorVisionStandardDescription: /* i18n */ {
+ id: "appearance.colorVision.standard.description",
+ message: "Uses the selected accent and the default semantic colors",
+ },
+ colorVisionTritan: /* i18n */ {
+ id: "appearance.colorVision.tritan",
+ message: "Blue-yellow assistance (tritan)",
},
darkMode: /* i18n */ {
id: "appearance.mode.dark",
@@ -33,14 +73,6 @@ export const appearanceMessages = {
id: "appearance.mode.dark.description",
message: "The interface will always use the dark theme",
},
- darkTheme: /* i18n */ {
- id: "appearance.theme.dark",
- message: "Dark theme",
- },
- darkThemeDescription: /* i18n */ {
- id: "appearance.theme.dark.description",
- message: "This theme is used when the system is in dark mode",
- },
followSystem: /* i18n */ {
id: "appearance.mode.system",
message: "System",
@@ -57,26 +89,58 @@ export const appearanceMessages = {
id: "appearance.mode.light.description",
message: "The interface will always use the light theme",
},
- lightTheme: /* i18n */ {
- id: "appearance.theme.light",
- message: "Light theme",
+ motionFull: /* i18n */ {
+ id: "appearance.motion.full",
+ message: "Full motion",
},
- lightThemeDescription: /* i18n */ {
- id: "appearance.theme.light.description",
- message: "This theme is used when the system is in light mode",
+ motionFullDescription: /* i18n */ {
+ id: "appearance.motion.full.description",
+ message: "Allows all interface motion and transitions",
},
- primaryColor: /* i18n */ {
- id: "appearance.primaryColor",
- message: "Primary color",
+ motionMode: /* i18n */ {
+ id: "appearance.motion.title",
+ message: "Motion",
},
- themeAndLayoutPreferences: /* i18n */ {
+ motionReduce: /* i18n */ {
+ id: "appearance.motion.reduce",
+ message: "Reduce motion",
+ },
+ motionReduceDescription: /* i18n */ {
+ id: "appearance.motion.reduce.description",
+ message: "Minimizes non-essential animation and smooth scrolling",
+ },
+ motionSystem: /* i18n */ {
+ id: "appearance.motion.system",
+ message: "System",
+ },
+ motionSystemDescription: /* i18n */ {
+ id: "appearance.motion.system.description",
+ message: "Follows the operating system motion preference",
+ },
+ appearanceDescription: /* i18n */ {
id: "appearance.description",
- message: "Theme and layout preferences",
+ message: "Theme, color, text, motion, and brightness preferences",
+ },
+ themeColors: /* i18n */ {
+ id: "appearance.themeColors",
+ message: "Theme colors",
+ },
+ themeColorsDescription: /* i18n */ {
+ id: "appearance.themeColors.description",
+ message: "Choose one base color and accent color for every theme mode",
},
themeMode: /* i18n */ {
id: "appearance.mode.title",
message: "Theme mode",
},
+ textScale: /* i18n */ {
+ id: "appearance.textScale.title",
+ message: "Text size",
+ },
+ textScaleDescription: /* i18n */ {
+ id: "appearance.textScale.description",
+ message: "Scales text, controls, and spacing so content can reflow",
+ },
toggleTheme: /* i18n */ {
id: "appearance.toggleTheme",
message: "Toggle theme",
diff --git a/packages/appearance/src/preferences.test.ts b/packages/appearance/src/preferences.test.ts
index 2ce1cba..d3e1ab2 100644
--- a/packages/appearance/src/preferences.test.ts
+++ b/packages/appearance/src/preferences.test.ts
@@ -9,32 +9,41 @@ import {
describe("appearance preferences", () => {
it("creates stable defaults", () => {
expect(defaultAppearancePreferences).toEqual({
- "main-compacted": false,
- "sidebar-collapsed": false,
- "theme-dark": { accentColor: "blue", baseColor: "neutral" },
- "theme-light": { accentColor: "blue", baseColor: "neutral" },
+ "accent-color": "blue",
+ "base-color": "neutral",
+ brightness: "normal",
+ "color-vision-mode": "standard",
+ "motion-mode": "system",
+ "text-scale": "100",
"theme-mode": "system",
})
})
it("serializes and validates preference updates", () => {
- const compacted = appearancePreferenceDefinitions["main-compacted"]
- const theme = appearancePreferenceDefinitions["theme-light"]
+ const accentColor = appearancePreferenceDefinitions["accent-color"]
+ const colorVisionMode = appearancePreferenceDefinitions["color-vision-mode"]
- expect(compacted.parse(compacted.serialize(true))).toBe(true)
- expect(
- theme.parse(
- theme.serialize({
- accentColor: "violet",
- baseColor: "zinc",
- })
- )
- ).toEqual({ accentColor: "violet", baseColor: "zinc" })
+ expect(accentColor.parse(accentColor.serialize("violet"))).toBe("violet")
+ expect(colorVisionMode.parse("deutan")).toBe("deutan")
+ expect(colorVisionMode.parse("unknown")).toBe("standard")
+ expect(appearancePreferenceDefinitions.brightness.parse("dim")).toBe("dim")
+ expect(appearancePreferenceDefinitions["motion-mode"].parse("bad")).toBe(
+ "system"
+ )
+ expect(appearancePreferenceDefinitions["text-scale"].parse("200")).toBe(
+ "200"
+ )
expect(
isAppearancePreferenceUpdate({ key: "theme-mode", value: "dark" })
).toBe(true)
expect(
isAppearancePreferenceUpdate({ key: "theme-mode", value: "unknown" })
).toBe(false)
+ expect(
+ isAppearancePreferenceUpdate({
+ key: "color-vision-mode",
+ value: "tritan",
+ })
+ ).toBe(true)
})
})
diff --git a/packages/appearance/src/preferences.ts b/packages/appearance/src/preferences.ts
index bdf3863..79ca829 100644
--- a/packages/appearance/src/preferences.ts
+++ b/packages/appearance/src/preferences.ts
@@ -5,16 +5,33 @@ import {
type PreferenceUpdateFor,
} from "@workspace/preferences"
-import { accentColors, baseColors, type ThemeColorConfig } from "./theme"
+import { isColorVisionMode, type ColorVisionMode } from "./color-vision"
+import {
+ isBrightness,
+ isMotionMode,
+ isTextScale,
+ type Brightness,
+ type MotionMode,
+ type TextScale,
+} from "./display"
+import {
+ accentColors,
+ baseColors,
+ type AccentColor,
+ type BaseColor,
+ type ThemeColorConfig,
+} from "./theme"
export type ThemeMode = "light" | "dark" | "system"
export interface AppearancePreferences {
- "main-compacted": boolean
- "sidebar-collapsed": boolean
+ "accent-color": AccentColor
+ "base-color": BaseColor
+ brightness: Brightness
+ "color-vision-mode": ColorVisionMode
+ "motion-mode": MotionMode
+ "text-scale": TextScale
"theme-mode": ThemeMode
- "theme-light": ThemeColorConfig
- "theme-dark": ThemeColorConfig
}
declare module "@workspace/preferences" {
@@ -48,40 +65,61 @@ export function isThemeColorConfig(value: unknown): value is ThemeColorConfig {
)
}
-function isBoolean(value: unknown): value is boolean {
- return typeof value === "boolean"
+function isAccentColor(value: unknown): value is AccentColor {
+ return accentColors.some((color) => color === value)
+}
+
+function isBaseColor(value: unknown): value is BaseColor {
+ return baseColors.some((color) => color === value)
}
function isThemeMode(value: unknown): value is ThemeMode {
return value === "light" || value === "dark" || value === "system"
}
-function parseThemeColorConfig(value: string | undefined): ThemeColorConfig {
- if (!value) return { ...defaultThemeColorConfig }
-
- try {
- const parsed: unknown = JSON.parse(value)
- return isThemeColorConfig(parsed) ? parsed : { ...defaultThemeColorConfig }
- } catch {
- return { ...defaultThemeColorConfig }
- }
-}
-
export const appearancePreferenceDefinitions: PreferenceDefinitions
=
{
- "main-compacted": {
- cookie: "ui-main-compacted",
- defaultValue: false,
- is: isBoolean,
- parse: (value) => value === "1",
- serialize: (value) => (value ? "1" : "0"),
+ "accent-color": {
+ cookie: "ui-accent-color",
+ defaultValue: defaultThemeColorConfig.accentColor,
+ is: isAccentColor,
+ parse: (value) => (isAccentColor(value) ? value : "blue"),
+ serialize: (value) => value,
},
- "sidebar-collapsed": {
- cookie: "ui-sidebar-collapsed",
- defaultValue: false,
- is: isBoolean,
- parse: (value) => value === "true" || value === "1",
- serialize: (value) => (value ? "1" : "0"),
+ "base-color": {
+ cookie: "ui-base-color",
+ defaultValue: defaultThemeColorConfig.baseColor,
+ is: isBaseColor,
+ parse: (value) => (isBaseColor(value) ? value : "neutral"),
+ serialize: (value) => value,
+ },
+ brightness: {
+ cookie: "ui-brightness",
+ defaultValue: "normal",
+ is: isBrightness,
+ parse: (value) => (isBrightness(value) ? value : "normal"),
+ serialize: (value) => value,
+ },
+ "color-vision-mode": {
+ cookie: "ui-color-vision",
+ defaultValue: "standard",
+ is: isColorVisionMode,
+ parse: (value) => (isColorVisionMode(value) ? value : "standard"),
+ serialize: (value) => value,
+ },
+ "motion-mode": {
+ cookie: "ui-motion",
+ defaultValue: "system",
+ is: isMotionMode,
+ parse: (value) => (isMotionMode(value) ? value : "system"),
+ serialize: (value) => value,
+ },
+ "text-scale": {
+ cookie: "ui-text-scale",
+ defaultValue: "100",
+ is: isTextScale,
+ parse: (value) => (isTextScale(value) ? value : "100"),
+ serialize: (value) => value,
},
"theme-mode": {
cookie: "ui-theme",
@@ -90,20 +128,6 @@ export const appearancePreferenceDefinitions: PreferenceDefinitions (isThemeMode(value) ? value : "system"),
serialize: (value) => value,
},
- "theme-light": {
- cookie: "ui-theme-light",
- defaultValue: { ...defaultThemeColorConfig },
- is: isThemeColorConfig,
- parse: parseThemeColorConfig,
- serialize: JSON.stringify,
- },
- "theme-dark": {
- cookie: "ui-theme-dark",
- defaultValue: { ...defaultThemeColorConfig },
- is: isThemeColorConfig,
- parse: parseThemeColorConfig,
- serialize: JSON.stringify,
- },
}
export const defaultAppearancePreferences = createDefaultPreferences(
diff --git a/packages/appearance/src/styles/globals.css b/packages/appearance/src/styles/globals.css
index f3275b1..5aa40a2 100644
--- a/packages/appearance/src/styles/globals.css
+++ b/packages/appearance/src/styles/globals.css
@@ -1 +1,50 @@
@source "../";
+
+.ui\:theme-changing *,
+.ui\:theme-changing *::before,
+.ui\:theme-changing *::after {
+ transition: none !important;
+}
+
+html[data-ui-text-scale="100"] {
+ font-size: 100%;
+}
+
+html[data-ui-text-scale="125"] {
+ font-size: 125%;
+}
+
+html[data-ui-text-scale="150"] {
+ font-size: 150%;
+}
+
+html[data-ui-text-scale="200"] {
+ font-size: 200%;
+}
+
+html[data-ui-motion="reduce"] {
+ scroll-behavior: auto !important;
+}
+
+html[data-ui-motion="reduce"] *,
+html[data-ui-motion="reduce"] *::before,
+html[data-ui-motion="reduce"] *::after {
+ animation-delay: 0ms !important;
+ animation-duration: 1ms !important;
+ animation-iteration-count: 1 !important;
+ scroll-behavior: auto !important;
+ transition-delay: 0ms !important;
+ transition-duration: 1ms !important;
+}
+
+@keyframes ui-reduced-motion-status {
+ 50% {
+ opacity: 0.55;
+ }
+}
+
+html[data-ui-motion="reduce"] [data-slot="spinner"],
+html[data-ui-motion="reduce"] [data-slot="skeleton"],
+html[data-ui-motion="reduce"] [data-slot="toast-icon"] .animate-spin {
+ animation: ui-reduced-motion-status 2s ease-in-out infinite !important;
+}
diff --git a/packages/appearance/src/theme.test.ts b/packages/appearance/src/theme.test.ts
index 143c860..bce6e5f 100644
--- a/packages/appearance/src/theme.test.ts
+++ b/packages/appearance/src/theme.test.ts
@@ -34,7 +34,8 @@ describe("appearance themes", () => {
it("builds explicit and system color-scheme rules", () => {
const styleSheet = buildThemeStyleSheet(
{ accentColor: "blue", baseColor: "neutral" },
- { accentColor: "violet", baseColor: "zinc" }
+ "deutan",
+ "dim"
)
expect(styleSheet).toContain(":root,.ui\\:light{")
@@ -43,5 +44,9 @@ describe("appearance themes", () => {
expect(styleSheet).toContain("--background:")
expect(styleSheet).toContain("--accent:")
expect(styleSheet).toContain("--highlight:")
+ expect(styleSheet).toContain("--destructive:#a64b00")
+ expect(styleSheet).toContain("--destructive:#ff9d66")
+ expect(styleSheet).toContain("--background:color-mix(in oklch,")
+ expect(styleSheet).not.toContain("filter:")
})
})
diff --git a/packages/appearance/src/theme.ts b/packages/appearance/src/theme.ts
index 25bf25d..09bd12d 100644
--- a/packages/appearance/src/theme.ts
+++ b/packages/appearance/src/theme.ts
@@ -5,6 +5,11 @@ import {
type ThemeColorConfig,
type ThemeScheme,
} from "./themes"
+import {
+ getColorVisionCssVariables,
+ type ColorVisionMode,
+} from "./color-vision"
+import { applyBrightnessToTheme, type Brightness } from "./display"
export {
accentColors,
@@ -42,13 +47,30 @@ function serializeThemeVariables(theme: Record): string {
}
export function buildThemeStyleSheet(
- lightConfig: ThemeColorConfig,
- darkConfig: ThemeColorConfig
+ config: ThemeColorConfig,
+ colorVisionMode: ColorVisionMode = "standard",
+ brightness: Brightness = "normal"
) {
const light = serializeThemeVariables(
- getThemeCssVariables("light", lightConfig)
+ applyBrightnessToTheme(
+ {
+ ...getThemeCssVariables("light", config),
+ ...getColorVisionCssVariables(colorVisionMode, "light"),
+ },
+ brightness,
+ "light"
+ )
+ )
+ const dark = serializeThemeVariables(
+ applyBrightnessToTheme(
+ {
+ ...getThemeCssVariables("dark", config),
+ ...getColorVisionCssVariables(colorVisionMode, "dark"),
+ },
+ brightness,
+ "dark"
+ )
)
- const dark = serializeThemeVariables(getThemeCssVariables("dark", darkConfig))
return [
`:root,.ui\\:light{${light}}`,