feat(appearance): add accessible display preferences

This commit is contained in:
Maofeng
2026-09-29 20:58:35 +08:00
parent 75d8cbbfc3
commit aae02d1502
23 changed files with 786 additions and 221 deletions
-1
View File
@@ -96,7 +96,6 @@
"name": "@workspace/appearance",
"version": "0.0.0",
"dependencies": {
"@icones/react": "^0.0.1",
"@tanstack/react-hotkeys": "^0.10.0",
"@workspace/i18n": "workspace:*",
"@workspace/preferences": "workspace:*",
+2 -1
View File
@@ -8,7 +8,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@icones/react": "^0.0.1",
"@tanstack/react-hotkeys": "^0.10.0",
"@workspace/i18n": "workspace:*",
"@workspace/preferences": "workspace:*",
@@ -26,7 +25,9 @@
},
"exports": {
".": "./src/index.ts",
"./color-vision": "./src/color-vision.ts",
"./components/*": "./src/components/*.tsx",
"./display": "./src/display.ts",
"./effects": "./src/effects.ts",
"./globals.css": "./src/styles/globals.css",
"./locales": "./src/locales/catalogs.ts",
@@ -5,7 +5,10 @@ import { I18nProvider } from "@workspace/i18n"
import { afterEach, describe, expect, it, vi } from "vitest"
import { messages as englishMessages } from "../locales/en-US"
import { CompactLayoutField } from "./compact-layout-field"
import { BrightnessField } from "./brightness-field"
import { ColorVisionModeField } from "./color-vision-mode-field"
import { MotionModeField } from "./motion-mode-field"
import { TextScaleField } from "./text-scale-field"
import { accentColorSwatches, ThemeColorPicker } from "./theme-color-picker"
afterEach(cleanup)
@@ -18,16 +21,51 @@ function renderLocalized(node: React.ReactNode) {
)
}
function selectOption(triggerName: string, optionName: string) {
fireEvent.click(screen.getByRole("combobox", { name: triggerName }))
const option = screen.getByRole("option", { name: optionName })
fireEvent.pointerDown(option, { pointerType: "mouse" })
fireEvent.click(option, { detail: 1 })
}
describe("appearance atoms", () => {
it("reports compact layout changes without owning state", () => {
const onCheckedChange = vi.fn()
it("reports color-vision mode changes without owning state", () => {
const onValueChange = vi.fn()
renderLocalized(
<CompactLayoutField checked={false} onCheckedChange={onCheckedChange} />
<ColorVisionModeField value="standard" onValueChange={onValueChange} />
)
fireEvent.click(screen.getByRole("switch"))
expect(onCheckedChange).toHaveBeenCalledWith(true)
selectOption("Color-vision assistance", "Green-weak assistance (deutan)")
expect(onValueChange).toHaveBeenCalledWith("deutan")
})
it("reports text scale, motion, and brightness changes", () => {
const setTextScale = vi.fn()
const setMotionMode = vi.fn()
const setBrightness = vi.fn()
const { rerender } = renderLocalized(
<TextScaleField value="100" onValueChange={setTextScale} />
)
selectOption("Text size", "150%")
expect(setTextScale).toHaveBeenCalledWith("150")
rerender(
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
<MotionModeField value="system" onValueChange={setMotionMode} />
</I18nProvider>
)
selectOption("Motion", "Reduce motion")
expect(setMotionMode).toHaveBeenCalledWith("reduce")
rerender(
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
<BrightnessField value="normal" onValueChange={setBrightness} />
</I18nProvider>
)
selectOption("Brightness", "Dim")
expect(setBrightness).toHaveBeenCalledWith("dim")
})
it("reports the selected theme color", () => {
@@ -48,7 +48,7 @@ export function AppearanceDialogHeader() {
<LocalizedText message={appearanceMessages.appearance} />
</DialogTitle>
<DialogDescription>
<LocalizedText message={appearanceMessages.themeAndLayoutPreferences} />
<LocalizedText message={appearanceMessages.appearanceDescription} />
</DialogDescription>
</DialogHeader>
)
@@ -2,7 +2,10 @@ import { cn } from "@workspace/ui/lib/utils"
import { usePreference } from "@workspace/preferences"
import { useResolvedTheme } from "../use-resolved-theme"
import { CompactLayoutField } from "./compact-layout-field"
import { BrightnessField } from "./brightness-field"
import { ColorVisionModeField } from "./color-vision-mode-field"
import { MotionModeField } from "./motion-mode-field"
import { TextScaleField } from "./text-scale-field"
import { ThemeConfigCard } from "./theme-config-card"
import { ThemeModeField } from "./theme-mode-field"
@@ -12,36 +15,36 @@ export interface AppearanceSettingsProps {
export function AppearanceSettings({ className }: AppearanceSettingsProps) {
const [themeMode, setThemeMode] = usePreference("theme-mode")
const [compacted, setCompacted] = usePreference("main-compacted")
const [lightTheme, setLightTheme] = usePreference("theme-light")
const [darkTheme, setDarkTheme] = usePreference("theme-dark")
const [accentColor, setAccentColor] = usePreference("accent-color")
const [baseColor, setBaseColor] = usePreference("base-color")
const [brightness, setBrightness] = usePreference("brightness")
const [colorVisionMode, setColorVisionMode] =
usePreference("color-vision-mode")
const [motionMode, setMotionMode] = usePreference("motion-mode")
const [textScale, setTextScale] = usePreference("text-scale")
const resolvedTheme = useResolvedTheme()
const themeConfig = { accentColor, baseColor }
return (
<div className={cn("space-y-10", className)}>
<div className="flex flex-col gap-6 lg:flex-row">
<div className="grid gap-6 lg:grid-cols-2">
<ThemeModeField value={themeMode} onValueChange={setThemeMode} />
<CompactLayoutField
checked={compacted}
onCheckedChange={setCompacted}
/>
</div>
<div className="flex flex-col gap-6 lg:flex-row">
<ThemeConfigCard
active={resolvedTheme === "light"}
compacted={compacted}
config={lightTheme}
onConfigChange={setLightTheme}
scheme="light"
/>
<ThemeConfigCard
active={resolvedTheme === "dark"}
compacted={compacted}
config={darkTheme}
onConfigChange={setDarkTheme}
scheme="dark"
<ColorVisionModeField
value={colorVisionMode}
onValueChange={setColorVisionMode}
/>
<TextScaleField value={textScale} onValueChange={setTextScale} />
<MotionModeField value={motionMode} onValueChange={setMotionMode} />
<BrightnessField value={brightness} onValueChange={setBrightness} />
</div>
<ThemeConfigCard
config={themeConfig}
onConfigChange={(config) => {
setBaseColor(config.baseColor)
setAccentColor(config.accentColor)
}}
scheme={resolvedTheme}
/>
</div>
)
}
@@ -0,0 +1,66 @@
import { useMessage } from "@workspace/i18n"
import { SunIcon } from "@workspace/ui/components/icon"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
import type { Brightness } from "../display"
import { appearanceMessages } from "../messages"
export interface BrightnessFieldProps {
className?: string
onValueChange: (value: Brightness) => void
value: Brightness
}
export function BrightnessField({
className,
onValueChange,
value,
}: BrightnessFieldProps) {
const title = useMessage(appearanceMessages.brightness)
const dim = useMessage(appearanceMessages.brightnessDim)
const normal = useMessage(appearanceMessages.brightnessNormal)
const bright = useMessage(appearanceMessages.brightnessBright)
const description = useMessage(appearanceMessages.brightnessDescription)
const items: ReadonlyArray<{ label: string; value: Brightness }> = [
{ label: dim, value: "dim" },
{ label: normal, value: "normal" },
{ label: bright, value: "bright" },
]
return (
<div className={cn("min-w-0 lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Select
items={items}
value={value}
onValueChange={(brightness) => {
if (brightness) onValueChange(brightness)
}}
>
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
<SunIcon />
<SelectValue>
{(selectedValue: Brightness | null) =>
items.find((item) => item.value === selectedValue)?.label
}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,75 @@
import { useMessage } from "@workspace/i18n"
import { EyeIcon } from "@workspace/ui/components/icon"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
import type { ColorVisionMode } from "../color-vision"
import { appearanceMessages } from "../messages"
export interface ColorVisionModeFieldProps {
className?: string
onValueChange: (value: ColorVisionMode) => void
value: ColorVisionMode
}
export function ColorVisionModeField({
className,
onValueChange,
value,
}: ColorVisionModeFieldProps) {
const title = useMessage(appearanceMessages.colorVisionMode)
const standard = useMessage(appearanceMessages.colorVisionStandard)
const protan = useMessage(appearanceMessages.colorVisionProtan)
const deutan = useMessage(appearanceMessages.colorVisionDeutan)
const tritan = useMessage(appearanceMessages.colorVisionTritan)
const standardDescription = useMessage(
appearanceMessages.colorVisionStandardDescription
)
const assistedDescription = useMessage(
appearanceMessages.colorVisionAssistedDescription
)
const items: ReadonlyArray<{ label: string; value: ColorVisionMode }> = [
{ label: standard, value: "standard" },
{ label: protan, value: "protan" },
{ label: deutan, value: "deutan" },
{ label: tritan, value: "tritan" },
]
return (
<div className={cn("min-w-0 lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{value === "standard" ? standardDescription : assistedDescription}
</p>
<Select
items={items}
value={value}
onValueChange={(mode) => {
if (mode) onValueChange(mode)
}}
>
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-56">
<EyeIcon />
<SelectValue>
{(selectedValue: ColorVisionMode | null) =>
items.find((item) => item.value === selectedValue)?.label
}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -1,33 +0,0 @@
import { LocalizedText } from "@workspace/i18n"
import { Switch } from "@workspace/ui/components/switch"
import { cn } from "@workspace/ui/lib/utils"
import { appearanceMessages } from "../messages"
export interface CompactLayoutFieldProps {
checked: boolean
className?: string
onCheckedChange: (checked: boolean) => void
}
export function CompactLayoutField({
checked,
className,
onCheckedChange,
}: CompactLayoutFieldProps) {
return (
<div className={cn("lg:flex-1", className)}>
<h2 className="font-semibold">
<LocalizedText message={appearanceMessages.compactLayout} />
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<LocalizedText message={appearanceMessages.compactDescription} />
</p>
<Switch
className="mt-2"
checked={checked}
onCheckedChange={(nextChecked) => onCheckedChange(nextChecked)}
/>
</div>
)
}
@@ -0,0 +1,70 @@
import { useMessage } from "@workspace/i18n"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
import type { MotionMode } from "../display"
import { appearanceMessages } from "../messages"
export interface MotionModeFieldProps {
className?: string
onValueChange: (value: MotionMode) => void
value: MotionMode
}
export function MotionModeField({
className,
onValueChange,
value,
}: MotionModeFieldProps) {
const title = useMessage(appearanceMessages.motionMode)
const system = useMessage(appearanceMessages.motionSystem)
const reduce = useMessage(appearanceMessages.motionReduce)
const full = useMessage(appearanceMessages.motionFull)
const descriptions = {
full: useMessage(appearanceMessages.motionFullDescription),
reduce: useMessage(appearanceMessages.motionReduceDescription),
system: useMessage(appearanceMessages.motionSystemDescription),
}
const items: ReadonlyArray<{ label: string; value: MotionMode }> = [
{ label: system, value: "system" },
{ label: reduce, value: "reduce" },
{ label: full, value: "full" },
]
return (
<div className={cn("min-w-0 lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{descriptions[value]}
</p>
<Select
items={items}
value={value}
onValueChange={(mode) => {
if (mode) onValueChange(mode)
}}
>
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-56">
<SelectValue>
{(selectedValue: MotionMode | null) =>
items.find((item) => item.value === selectedValue)?.label
}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,64 @@
import { useMessage } from "@workspace/i18n"
import { ALargeSmall } from "@workspace/ui/components/icon"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
import type { TextScale } from "../display"
import { appearanceMessages } from "../messages"
export interface TextScaleFieldProps {
className?: string
onValueChange: (value: TextScale) => void
value: TextScale
}
export function TextScaleField({
className,
onValueChange,
value,
}: TextScaleFieldProps) {
const title = useMessage(appearanceMessages.textScale)
const description = useMessage(appearanceMessages.textScaleDescription)
const items: ReadonlyArray<{ label: string; value: TextScale }> = [
{ label: "100%", value: "100" },
{ label: "125%", value: "125" },
{ label: "150%", value: "150" },
{ label: "200%", value: "200" },
]
return (
<div className={cn("min-w-0 lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Select
items={items}
value={value}
onValueChange={(scale) => {
if (scale) onValueChange(scale)
}}
>
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
<ALargeSmall />
<SelectValue>
{(selectedValue: TextScale | null) =>
items.find((item) => item.value === selectedValue)?.label
}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -1,5 +1,4 @@
import { LocalizedText } from "@workspace/i18n"
import { Badge } from "@workspace/ui/components/badge"
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
import { cn } from "@workspace/ui/lib/utils"
@@ -13,33 +12,20 @@ import {
import { ThemePreview } from "./theme-preview"
export interface ThemeConfigCardProps {
active?: boolean
className?: string
compacted?: boolean
config: ThemeColorConfig
onConfigChange: (config: ThemeColorConfig) => void
scheme: ThemeScheme
}
export function ThemeConfigCard({
active = false,
className,
compacted = false,
config,
onConfigChange,
scheme,
}: ThemeConfigCardProps) {
const titleMessage =
scheme === "light"
? appearanceMessages.lightTheme
: appearanceMessages.darkTheme
const descriptionMessage =
scheme === "light"
? appearanceMessages.lightThemeDescription
: appearanceMessages.darkThemeDescription
return (
<section className={cn("w-md rounded-xl border", className)}>
<section className={cn("w-full max-w-md rounded-xl border", className)}>
<header className="flex gap-4 border-b p-3">
{scheme === "light" ? (
<SunIcon className="size-5" />
@@ -47,19 +33,14 @@ export function ThemeConfigCard({
<MoonIcon className="size-5" />
)}
<h2>
<LocalizedText message={titleMessage} />
<LocalizedText message={appearanceMessages.themeColors} />
</h2>
{active && (
<Badge className="ml-auto">
<LocalizedText message={appearanceMessages.active} />
</Badge>
)}
</header>
<div className="space-y-4 p-4">
<p>
<LocalizedText message={descriptionMessage} />
<LocalizedText message={appearanceMessages.themeColorsDescription} />
</p>
<ThemePreview compacted={compacted} config={config} scheme={scheme} />
<ThemePreview config={config} scheme={scheme} />
<ThemeColorPicker
swatches={baseColorSwatches}
title={<LocalizedText message={appearanceMessages.baseColor} />}
@@ -70,7 +51,7 @@ export function ThemeConfigCard({
/>
<ThemeColorPicker
swatches={accentColorSwatches}
title={<LocalizedText message={appearanceMessages.primaryColor} />}
title={<LocalizedText message={appearanceMessages.accentColor} />}
value={config.accentColor}
onValueChange={(accentColor) =>
onConfigChange({ ...config, accentColor })
@@ -50,7 +50,7 @@ export function ThemeModeField({
}[value]
return (
<div className={cn("lg:flex-1", className)}>
<div className={cn("min-w-0 lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Select
@@ -60,7 +60,7 @@ export function ThemeModeField({
if (mode) onValueChange(mode)
}}
>
<SelectTrigger className="mt-2 w-40">
<SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
<SelectValue>
{(selectedValue: ThemeMode | null) => (
<>
@@ -9,17 +9,11 @@ import { cn } from "@workspace/ui/lib/utils"
export interface ThemePreviewProps {
className?: string
compacted?: boolean
config: ThemeColorConfig
scheme: ThemeScheme
}
export function ThemePreview({
scheme,
config,
className,
compacted = false,
}: ThemePreviewProps) {
export function ThemePreview({ scheme, config, className }: ThemePreviewProps) {
const themeVariables = getThemeCssVariables(scheme, config)
const baseTheme = getTheme(config.baseColor) ?? getTheme("neutral")!
const style = {
@@ -47,7 +41,7 @@ export function ThemePreview({
<div className="mt-auto h-2.5 w-full rounded-r-full" />
</div>
<div className="flex-1 py-5">
<div className={cn("space-y-2.5", { "px-7.5": compacted })}>
<div className="space-y-2.5">
<div className="flex h-2.5 items-center gap-2 [&>div]:bg-foreground/10">
<div className="h-2.5 w-3 rounded-sm" />
<div className="ml-auto size-2.5 rounded-sm" />
@@ -55,7 +49,7 @@ export function ThemePreview({
<div className="h-2.5 w-5 rounded-sm bg-accent!" />
</div>
<div className="grid grid-cols-3 gap-2 [&>div]:rounded-sm [&>div]:bg-foreground/10">
<div className="flex h-18 items-end gap-2 p-2 compacted:gap-1">
<div className="flex h-18 items-end gap-2 p-2">
<div className="h-6 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-4 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-11 w-2 rounded-t-sm bg-(--preview-base)/55" />
@@ -73,18 +67,18 @@ export function ThemePreview({
<div className="flex flex-wrap gap-1">
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55 compacted:w-4" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-2 rounded-xs bg-(--preview-base)/55 compacted:w-1" />
<div className="size-1 rounded-xs bg-(--preview-base)/55 compacted:w-5" />
<div className="h-1 w-2 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55 compacted:w-3" />
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55 compacted:hidden" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55" />
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-6 rounded-sm bg-accent" />
+20
View File
@@ -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:")
+67 -9
View File
@@ -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,
}
+41 -3
View File
@@ -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 {
+28 -10
View File
@@ -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": "切换为深色模式",
+93 -29
View File
@@ -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",
+24 -15
View File
@@ -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)
})
})
+68 -44
View File
@@ -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<AppearancePreferences> =
{
"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<AppearancePr
parse: (value) => (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(
@@ -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;
}
+6 -1
View File
@@ -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:")
})
})
+26 -4
View File
@@ -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, string>): 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}}`,