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", "name": "@workspace/appearance",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@icones/react": "^0.0.1",
"@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-hotkeys": "^0.10.0",
"@workspace/i18n": "workspace:*", "@workspace/i18n": "workspace:*",
"@workspace/preferences": "workspace:*", "@workspace/preferences": "workspace:*",
+2 -1
View File
@@ -8,7 +8,6 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@icones/react": "^0.0.1",
"@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-hotkeys": "^0.10.0",
"@workspace/i18n": "workspace:*", "@workspace/i18n": "workspace:*",
"@workspace/preferences": "workspace:*", "@workspace/preferences": "workspace:*",
@@ -26,7 +25,9 @@
}, },
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./color-vision": "./src/color-vision.ts",
"./components/*": "./src/components/*.tsx", "./components/*": "./src/components/*.tsx",
"./display": "./src/display.ts",
"./effects": "./src/effects.ts", "./effects": "./src/effects.ts",
"./globals.css": "./src/styles/globals.css", "./globals.css": "./src/styles/globals.css",
"./locales": "./src/locales/catalogs.ts", "./locales": "./src/locales/catalogs.ts",
@@ -5,7 +5,10 @@ import { I18nProvider } from "@workspace/i18n"
import { afterEach, describe, expect, it, vi } from "vitest" import { afterEach, describe, expect, it, vi } from "vitest"
import { messages as englishMessages } from "../locales/en-US" 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" import { accentColorSwatches, ThemeColorPicker } from "./theme-color-picker"
afterEach(cleanup) 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", () => { describe("appearance atoms", () => {
it("reports compact layout changes without owning state", () => { it("reports color-vision mode changes without owning state", () => {
const onCheckedChange = vi.fn() const onValueChange = vi.fn()
renderLocalized( renderLocalized(
<CompactLayoutField checked={false} onCheckedChange={onCheckedChange} /> <ColorVisionModeField value="standard" onValueChange={onValueChange} />
) )
fireEvent.click(screen.getByRole("switch")) selectOption("Color-vision assistance", "Green-weak assistance (deutan)")
expect(onCheckedChange).toHaveBeenCalledWith(true) 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", () => { it("reports the selected theme color", () => {
@@ -48,7 +48,7 @@ export function AppearanceDialogHeader() {
<LocalizedText message={appearanceMessages.appearance} /> <LocalizedText message={appearanceMessages.appearance} />
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<LocalizedText message={appearanceMessages.themeAndLayoutPreferences} /> <LocalizedText message={appearanceMessages.appearanceDescription} />
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
) )
@@ -2,7 +2,10 @@ import { cn } from "@workspace/ui/lib/utils"
import { usePreference } from "@workspace/preferences" import { usePreference } from "@workspace/preferences"
import { useResolvedTheme } from "../use-resolved-theme" 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 { ThemeConfigCard } from "./theme-config-card"
import { ThemeModeField } from "./theme-mode-field" import { ThemeModeField } from "./theme-mode-field"
@@ -12,36 +15,36 @@ export interface AppearanceSettingsProps {
export function AppearanceSettings({ className }: AppearanceSettingsProps) { export function AppearanceSettings({ className }: AppearanceSettingsProps) {
const [themeMode, setThemeMode] = usePreference("theme-mode") const [themeMode, setThemeMode] = usePreference("theme-mode")
const [compacted, setCompacted] = usePreference("main-compacted") const [accentColor, setAccentColor] = usePreference("accent-color")
const [lightTheme, setLightTheme] = usePreference("theme-light") const [baseColor, setBaseColor] = usePreference("base-color")
const [darkTheme, setDarkTheme] = usePreference("theme-dark") 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 resolvedTheme = useResolvedTheme()
const themeConfig = { accentColor, baseColor }
return ( return (
<div className={cn("space-y-10", className)}> <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} /> <ThemeModeField value={themeMode} onValueChange={setThemeMode} />
<CompactLayoutField <ColorVisionModeField
checked={compacted} value={colorVisionMode}
onCheckedChange={setCompacted} onValueChange={setColorVisionMode}
/>
</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"
/> />
<TextScaleField value={textScale} onValueChange={setTextScale} />
<MotionModeField value={motionMode} onValueChange={setMotionMode} />
<BrightnessField value={brightness} onValueChange={setBrightness} />
</div> </div>
<ThemeConfigCard
config={themeConfig}
onConfigChange={(config) => {
setBaseColor(config.baseColor)
setAccentColor(config.accentColor)
}}
scheme={resolvedTheme}
/>
</div> </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 { LocalizedText } from "@workspace/i18n"
import { Badge } from "@workspace/ui/components/badge"
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon" import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
@@ -13,33 +12,20 @@ import {
import { ThemePreview } from "./theme-preview" import { ThemePreview } from "./theme-preview"
export interface ThemeConfigCardProps { export interface ThemeConfigCardProps {
active?: boolean
className?: string className?: string
compacted?: boolean
config: ThemeColorConfig config: ThemeColorConfig
onConfigChange: (config: ThemeColorConfig) => void onConfigChange: (config: ThemeColorConfig) => void
scheme: ThemeScheme scheme: ThemeScheme
} }
export function ThemeConfigCard({ export function ThemeConfigCard({
active = false,
className, className,
compacted = false,
config, config,
onConfigChange, onConfigChange,
scheme, scheme,
}: ThemeConfigCardProps) { }: ThemeConfigCardProps) {
const titleMessage =
scheme === "light"
? appearanceMessages.lightTheme
: appearanceMessages.darkTheme
const descriptionMessage =
scheme === "light"
? appearanceMessages.lightThemeDescription
: appearanceMessages.darkThemeDescription
return ( 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"> <header className="flex gap-4 border-b p-3">
{scheme === "light" ? ( {scheme === "light" ? (
<SunIcon className="size-5" /> <SunIcon className="size-5" />
@@ -47,19 +33,14 @@ export function ThemeConfigCard({
<MoonIcon className="size-5" /> <MoonIcon className="size-5" />
)} )}
<h2> <h2>
<LocalizedText message={titleMessage} /> <LocalizedText message={appearanceMessages.themeColors} />
</h2> </h2>
{active && (
<Badge className="ml-auto">
<LocalizedText message={appearanceMessages.active} />
</Badge>
)}
</header> </header>
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<p> <p>
<LocalizedText message={descriptionMessage} /> <LocalizedText message={appearanceMessages.themeColorsDescription} />
</p> </p>
<ThemePreview compacted={compacted} config={config} scheme={scheme} /> <ThemePreview config={config} scheme={scheme} />
<ThemeColorPicker <ThemeColorPicker
swatches={baseColorSwatches} swatches={baseColorSwatches}
title={<LocalizedText message={appearanceMessages.baseColor} />} title={<LocalizedText message={appearanceMessages.baseColor} />}
@@ -70,7 +51,7 @@ export function ThemeConfigCard({
/> />
<ThemeColorPicker <ThemeColorPicker
swatches={accentColorSwatches} swatches={accentColorSwatches}
title={<LocalizedText message={appearanceMessages.primaryColor} />} title={<LocalizedText message={appearanceMessages.accentColor} />}
value={config.accentColor} value={config.accentColor}
onValueChange={(accentColor) => onValueChange={(accentColor) =>
onConfigChange({ ...config, accentColor }) onConfigChange({ ...config, accentColor })
@@ -50,7 +50,7 @@ export function ThemeModeField({
}[value] }[value]
return ( return (
<div className={cn("lg:flex-1", className)}> <div className={cn("min-w-0 lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2> <h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p> <p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Select <Select
@@ -60,7 +60,7 @@ export function ThemeModeField({
if (mode) onValueChange(mode) if (mode) onValueChange(mode)
}} }}
> >
<SelectTrigger className="mt-2 w-40"> <SelectTrigger aria-label={title} className="mt-2 w-full max-w-40">
<SelectValue> <SelectValue>
{(selectedValue: ThemeMode | null) => ( {(selectedValue: ThemeMode | null) => (
<> <>
@@ -9,17 +9,11 @@ import { cn } from "@workspace/ui/lib/utils"
export interface ThemePreviewProps { export interface ThemePreviewProps {
className?: string className?: string
compacted?: boolean
config: ThemeColorConfig config: ThemeColorConfig
scheme: ThemeScheme scheme: ThemeScheme
} }
export function ThemePreview({ export function ThemePreview({ scheme, config, className }: ThemePreviewProps) {
scheme,
config,
className,
compacted = false,
}: ThemePreviewProps) {
const themeVariables = getThemeCssVariables(scheme, config) const themeVariables = getThemeCssVariables(scheme, config)
const baseTheme = getTheme(config.baseColor) ?? getTheme("neutral")! const baseTheme = getTheme(config.baseColor) ?? getTheme("neutral")!
const style = { const style = {
@@ -47,7 +41,7 @@ export function ThemePreview({
<div className="mt-auto h-2.5 w-full rounded-r-full" /> <div className="mt-auto h-2.5 w-full rounded-r-full" />
</div> </div>
<div className="flex-1 py-5"> <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="flex h-2.5 items-center gap-2 [&>div]:bg-foreground/10">
<div className="h-2.5 w-3 rounded-sm" /> <div className="h-2.5 w-3 rounded-sm" />
<div className="ml-auto size-2.5 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 className="h-2.5 w-5 rounded-sm bg-accent!" />
</div> </div>
<div className="grid grid-cols-3 gap-2 [&>div]:rounded-sm [&>div]:bg-foreground/10"> <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-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-4 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-11 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="flex flex-wrap gap-1">
<div className="size-1 rounded-xs bg-(--preview-base)/55" /> <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-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="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="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-5 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-2 rounded-xs bg-(--preview-base)/55 compacted:w-1" /> <div className="h-1 w-2 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55 compacted:w-5" /> <div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 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="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 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="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55 compacted:hidden" /> <div className="h-1 w-6 rounded-xs bg-(--preview-base)/55" />
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="h-2 w-6 rounded-sm bg-accent" /> <div className="h-2 w-6 rounded-sm bg-accent" />
+20
View File
@@ -14,6 +14,12 @@ import {
import { appearancePreferenceEffect } from "./effects" import { appearancePreferenceEffect } from "./effects"
import { defaultAppearancePreferences } from "./preferences" 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" import { THEME_STYLE_ELEMENT_ID } from "./theme"
beforeAll(() => { beforeAll(() => {
@@ -43,6 +49,10 @@ afterEach(() => {
cleanup() cleanup()
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove() document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
document.documentElement.className = "" 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 = "" document.documentElement.style.colorScheme = ""
}) })
@@ -71,6 +81,16 @@ describe("appearancePreferenceEffect", () => {
) )
expect(document.documentElement.classList.contains("ui:light")).toBe(true) 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( expect(
document.getElementById(THEME_STYLE_ELEMENT_ID)?.textContent document.getElementById(THEME_STYLE_ELEMENT_ID)?.textContent
).toContain("--background:") ).toContain("--background:")
+67 -9
View File
@@ -4,6 +4,13 @@ import {
} from "@workspace/preferences" } from "@workspace/preferences"
import type { AppearancePreferences } from "./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 { import {
buildThemeStyleSheet, buildThemeStyleSheet,
THEME_STYLE_ELEMENT_ID, THEME_STYLE_ELEMENT_ID,
@@ -11,10 +18,15 @@ import {
} from "./theme" } from "./theme"
const systemThemeQuery = "(prefers-color-scheme: dark)" 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-mode",
"theme-light",
"theme-dark",
] as const satisfies readonly (keyof AppearancePreferences)[] ] as const satisfies readonly (keyof AppearancePreferences)[]
let restoreThemeTransitionFrame: number | undefined let restoreThemeTransitionFrame: number | undefined
@@ -41,6 +53,10 @@ function getSystemTheme(): ThemeScheme {
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light" return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
} }
function getSystemMotion(): ResolvedMotionMode {
return window.matchMedia(reducedMotionQuery).matches ? "reduce" : "full"
}
export function applyAppearancePreferences(preferences: AppearancePreferences) { export function applyAppearancePreferences(preferences: AppearancePreferences) {
if (typeof document === "undefined") return if (typeof document === "undefined") return
@@ -49,19 +65,29 @@ export function applyAppearancePreferences(preferences: AppearancePreferences) {
preferences["theme-mode"] === "system" preferences["theme-mode"] === "system"
? getSystemTheme() ? getSystemTheme()
: preferences["theme-mode"] : preferences["theme-mode"]
const resolvedMotion =
preferences["motion-mode"] === "system"
? getSystemMotion()
: preferences["motion-mode"]
const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID) const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID)
if (themeStyle) { if (themeStyle) {
themeStyle.textContent = buildThemeStyleSheet( 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:dark", resolvedTheme === "dark")
root.classList.toggle("ui:light", resolvedTheme === "light") 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 root.style.colorScheme = resolvedTheme
} }
@@ -75,7 +101,7 @@ export const applyAppearancePreferenceEffect: PreferenceEffectCallback = ({
previousPreferences as AppearancePreferences previousPreferences as AppearancePreferences
if ( if (
themePreferenceKeys.some( appearancePreferenceKeys.some(
(key) => (key) =>
!Object.is( !Object.is(
previousAppearancePreferences[key], previousAppearancePreferences[key],
@@ -107,8 +133,40 @@ export const subscribeSystemThemePreferenceEffect: PreferenceEffectCallback = ({
return () => mediaQuery.removeEventListener("change", applySystemTheme) 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`. */ /** Register this effect with the application's shared `PreferencesProvider`. */
export const appearancePreferenceEffect: PreferenceEffect = { export const appearancePreferenceEffect: PreferenceEffect = {
layoutEffect: applyAppearancePreferenceEffect, layoutEffect: applyAppearancePreferenceEffect,
effect: subscribeSystemThemePreferenceEffect, effect: subscribeSystemAppearancePreferenceEffect,
} }
+41 -3
View File
@@ -24,9 +24,21 @@ export {
type AppearanceSettingsProps, type AppearanceSettingsProps,
} from "./components/appearance-settings" } from "./components/appearance-settings"
export { export {
CompactLayoutField, BrightnessField,
type CompactLayoutFieldProps, type BrightnessFieldProps,
} from "./components/compact-layout-field" } 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 { export {
ThemeColorPicker, ThemeColorPicker,
ThemeColorSwatchButton, ThemeColorSwatchButton,
@@ -58,10 +70,36 @@ export {
type ThemeToggleButtonProps, type ThemeToggleButtonProps,
type ThemeToggleProps, type ThemeToggleProps,
} from "./components/theme-toggle-button" } 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 { export {
applyAppearancePreferenceEffect, applyAppearancePreferenceEffect,
applyAppearancePreferences, applyAppearancePreferences,
appearancePreferenceEffect, appearancePreferenceEffect,
subscribeSystemAppearancePreferenceEffect,
subscribeSystemMotionPreferenceEffect,
subscribeSystemThemePreferenceEffect, subscribeSystemThemePreferenceEffect,
} from "./effects" } from "./effects"
export { export {
+28 -10
View File
@@ -4,8 +4,13 @@ export const locale = "zh-Hans"
export const languageTag = "zh-CN" export const languageTag = "zh-CN"
export const messages = { export const messages = {
"appearance.active": "使用中", "appearance.accentColor": "强调色",
"appearance.baseColor": "基础颜色", "appearance.baseColor": "基础颜色",
"appearance.brightness.bright": "明亮",
"appearance.brightness.description": "调整界面表面,不改变图片和视频",
"appearance.brightness.dim": "柔暗",
"appearance.brightness.normal": "标准",
"appearance.brightness.title": "界面亮度",
"appearance.color.amber": "琥珀色", "appearance.color.amber": "琥珀色",
"appearance.color.blue": "蓝色", "appearance.color.blue": "蓝色",
"appearance.color.cyan": "青色", "appearance.color.cyan": "青色",
@@ -30,10 +35,16 @@ export const messages = {
"appearance.color.violet": "紫罗兰色", "appearance.color.violet": "紫罗兰色",
"appearance.color.yellow": "黄色", "appearance.color.yellow": "黄色",
"appearance.color.zinc": "锌灰", "appearance.color.zinc": "锌灰",
"appearance.command.description": "打开主题与布局偏好", "appearance.colorVision.assisted.description":
"appearance.compact.description": "启用后,页面主内容将使用紧凑宽度", "使用更容易相互区分的语义色板;关键信息仍应搭配文字、图标或形状",
"appearance.compact.title": "紧凑布局", "appearance.colorVision.deutan": "绿色辨识辅助(Deutan)",
"appearance.description": "主题与布局偏好", "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": "深色",
"appearance.mode.dark.description": "界面将始终使用深色主题", "appearance.mode.dark.description": "界面将始终使用深色主题",
"appearance.mode.light": "浅色", "appearance.mode.light": "浅色",
@@ -41,11 +52,18 @@ export const messages = {
"appearance.mode.system": "系统", "appearance.mode.system": "系统",
"appearance.mode.system.description": "界面主题将跟随系统外观设置", "appearance.mode.system.description": "界面主题将跟随系统外观设置",
"appearance.mode.title": "主题模式", "appearance.mode.title": "主题模式",
"appearance.primaryColor": "主要颜色", "appearance.motion.full": "完整动效",
"appearance.theme.dark": "深色主题", "appearance.motion.full.description": "允许所有界面动画和过渡效果",
"appearance.theme.dark.description": "当系统设置为深色模式时,将使用此主题", "appearance.motion.reduce": "减少动态效果",
"appearance.theme.light": "浅色主题", "appearance.motion.reduce.description": "减少非必要动画和平滑滚动",
"appearance.theme.light.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.title": "界面外观",
"appearance.toggleTheme": "切换主题", "appearance.toggleTheme": "切换主题",
"appearance.toggleTheme.dark": "切换为深色模式", "appearance.toggleTheme.dark": "切换为深色模式",
+93 -29
View File
@@ -1,9 +1,9 @@
import type { MessageDescriptor } from "@workspace/i18n" import type { MessageDescriptor } from "@workspace/i18n"
export const appearanceMessages = { export const appearanceMessages = {
active: /* i18n */ { accentColor: /* i18n */ {
id: "appearance.active", id: "appearance.accentColor",
message: "Active", message: "Accent color",
}, },
appearance: /* i18n */ { appearance: /* i18n */ {
id: "appearance.title", id: "appearance.title",
@@ -11,19 +11,59 @@ export const appearanceMessages = {
}, },
appearanceCommandDescription: /* i18n */ { appearanceCommandDescription: /* i18n */ {
id: "appearance.command.description", id: "appearance.command.description",
message: "Open theme and layout preferences", message: "Open appearance and accessibility preferences",
}, },
baseColor: /* i18n */ { baseColor: /* i18n */ {
id: "appearance.baseColor", id: "appearance.baseColor",
message: "Base color", message: "Base color",
}, },
compactDescription: /* i18n */ { brightness: /* i18n */ {
id: "appearance.compact.description", id: "appearance.brightness.title",
message: "Use a compact width for the main page content", message: "Brightness",
}, },
compactLayout: /* i18n */ { brightnessBright: /* i18n */ {
id: "appearance.compact.title", id: "appearance.brightness.bright",
message: "Compact layout", 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 */ { darkMode: /* i18n */ {
id: "appearance.mode.dark", id: "appearance.mode.dark",
@@ -33,14 +73,6 @@ export const appearanceMessages = {
id: "appearance.mode.dark.description", id: "appearance.mode.dark.description",
message: "The interface will always use the dark theme", 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 */ { followSystem: /* i18n */ {
id: "appearance.mode.system", id: "appearance.mode.system",
message: "System", message: "System",
@@ -57,26 +89,58 @@ export const appearanceMessages = {
id: "appearance.mode.light.description", id: "appearance.mode.light.description",
message: "The interface will always use the light theme", message: "The interface will always use the light theme",
}, },
lightTheme: /* i18n */ { motionFull: /* i18n */ {
id: "appearance.theme.light", id: "appearance.motion.full",
message: "Light theme", message: "Full motion",
}, },
lightThemeDescription: /* i18n */ { motionFullDescription: /* i18n */ {
id: "appearance.theme.light.description", id: "appearance.motion.full.description",
message: "This theme is used when the system is in light mode", message: "Allows all interface motion and transitions",
}, },
primaryColor: /* i18n */ { motionMode: /* i18n */ {
id: "appearance.primaryColor", id: "appearance.motion.title",
message: "Primary color", 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", 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 */ { themeMode: /* i18n */ {
id: "appearance.mode.title", id: "appearance.mode.title",
message: "Theme mode", 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 */ { toggleTheme: /* i18n */ {
id: "appearance.toggleTheme", id: "appearance.toggleTheme",
message: "Toggle theme", message: "Toggle theme",
+24 -15
View File
@@ -9,32 +9,41 @@ import {
describe("appearance preferences", () => { describe("appearance preferences", () => {
it("creates stable defaults", () => { it("creates stable defaults", () => {
expect(defaultAppearancePreferences).toEqual({ expect(defaultAppearancePreferences).toEqual({
"main-compacted": false, "accent-color": "blue",
"sidebar-collapsed": false, "base-color": "neutral",
"theme-dark": { accentColor: "blue", baseColor: "neutral" }, brightness: "normal",
"theme-light": { accentColor: "blue", baseColor: "neutral" }, "color-vision-mode": "standard",
"motion-mode": "system",
"text-scale": "100",
"theme-mode": "system", "theme-mode": "system",
}) })
}) })
it("serializes and validates preference updates", () => { it("serializes and validates preference updates", () => {
const compacted = appearancePreferenceDefinitions["main-compacted"] const accentColor = appearancePreferenceDefinitions["accent-color"]
const theme = appearancePreferenceDefinitions["theme-light"] const colorVisionMode = appearancePreferenceDefinitions["color-vision-mode"]
expect(compacted.parse(compacted.serialize(true))).toBe(true) expect(accentColor.parse(accentColor.serialize("violet"))).toBe("violet")
expect( expect(colorVisionMode.parse("deutan")).toBe("deutan")
theme.parse( expect(colorVisionMode.parse("unknown")).toBe("standard")
theme.serialize({ expect(appearancePreferenceDefinitions.brightness.parse("dim")).toBe("dim")
accentColor: "violet", expect(appearancePreferenceDefinitions["motion-mode"].parse("bad")).toBe(
baseColor: "zinc", "system"
}) )
) expect(appearancePreferenceDefinitions["text-scale"].parse("200")).toBe(
).toEqual({ accentColor: "violet", baseColor: "zinc" }) "200"
)
expect( expect(
isAppearancePreferenceUpdate({ key: "theme-mode", value: "dark" }) isAppearancePreferenceUpdate({ key: "theme-mode", value: "dark" })
).toBe(true) ).toBe(true)
expect( expect(
isAppearancePreferenceUpdate({ key: "theme-mode", value: "unknown" }) isAppearancePreferenceUpdate({ key: "theme-mode", value: "unknown" })
).toBe(false) ).toBe(false)
expect(
isAppearancePreferenceUpdate({
key: "color-vision-mode",
value: "tritan",
})
).toBe(true)
}) })
}) })
+68 -44
View File
@@ -5,16 +5,33 @@ import {
type PreferenceUpdateFor, type PreferenceUpdateFor,
} from "@workspace/preferences" } 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 type ThemeMode = "light" | "dark" | "system"
export interface AppearancePreferences { export interface AppearancePreferences {
"main-compacted": boolean "accent-color": AccentColor
"sidebar-collapsed": boolean "base-color": BaseColor
brightness: Brightness
"color-vision-mode": ColorVisionMode
"motion-mode": MotionMode
"text-scale": TextScale
"theme-mode": ThemeMode "theme-mode": ThemeMode
"theme-light": ThemeColorConfig
"theme-dark": ThemeColorConfig
} }
declare module "@workspace/preferences" { declare module "@workspace/preferences" {
@@ -48,40 +65,61 @@ export function isThemeColorConfig(value: unknown): value is ThemeColorConfig {
) )
} }
function isBoolean(value: unknown): value is boolean { function isAccentColor(value: unknown): value is AccentColor {
return typeof value === "boolean" 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 { function isThemeMode(value: unknown): value is ThemeMode {
return value === "light" || value === "dark" || value === "system" 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> = export const appearancePreferenceDefinitions: PreferenceDefinitions<AppearancePreferences> =
{ {
"main-compacted": { "accent-color": {
cookie: "ui-main-compacted", cookie: "ui-accent-color",
defaultValue: false, defaultValue: defaultThemeColorConfig.accentColor,
is: isBoolean, is: isAccentColor,
parse: (value) => value === "1", parse: (value) => (isAccentColor(value) ? value : "blue"),
serialize: (value) => (value ? "1" : "0"), serialize: (value) => value,
}, },
"sidebar-collapsed": { "base-color": {
cookie: "ui-sidebar-collapsed", cookie: "ui-base-color",
defaultValue: false, defaultValue: defaultThemeColorConfig.baseColor,
is: isBoolean, is: isBaseColor,
parse: (value) => value === "true" || value === "1", parse: (value) => (isBaseColor(value) ? value : "neutral"),
serialize: (value) => (value ? "1" : "0"), 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": { "theme-mode": {
cookie: "ui-theme", cookie: "ui-theme",
@@ -90,20 +128,6 @@ export const appearancePreferenceDefinitions: PreferenceDefinitions<AppearancePr
parse: (value) => (isThemeMode(value) ? value : "system"), parse: (value) => (isThemeMode(value) ? value : "system"),
serialize: (value) => value, 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( export const defaultAppearancePreferences = createDefaultPreferences(
@@ -1 +1,50 @@
@source "../"; @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", () => { it("builds explicit and system color-scheme rules", () => {
const styleSheet = buildThemeStyleSheet( const styleSheet = buildThemeStyleSheet(
{ accentColor: "blue", baseColor: "neutral" }, { accentColor: "blue", baseColor: "neutral" },
{ accentColor: "violet", baseColor: "zinc" } "deutan",
"dim"
) )
expect(styleSheet).toContain(":root,.ui\\:light{") expect(styleSheet).toContain(":root,.ui\\:light{")
@@ -43,5 +44,9 @@ describe("appearance themes", () => {
expect(styleSheet).toContain("--background:") expect(styleSheet).toContain("--background:")
expect(styleSheet).toContain("--accent:") expect(styleSheet).toContain("--accent:")
expect(styleSheet).toContain("--highlight:") 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 ThemeColorConfig,
type ThemeScheme, type ThemeScheme,
} from "./themes" } from "./themes"
import {
getColorVisionCssVariables,
type ColorVisionMode,
} from "./color-vision"
import { applyBrightnessToTheme, type Brightness } from "./display"
export { export {
accentColors, accentColors,
@@ -42,13 +47,30 @@ function serializeThemeVariables(theme: Record<string, string>): string {
} }
export function buildThemeStyleSheet( export function buildThemeStyleSheet(
lightConfig: ThemeColorConfig, config: ThemeColorConfig,
darkConfig: ThemeColorConfig colorVisionMode: ColorVisionMode = "standard",
brightness: Brightness = "normal"
) { ) {
const light = serializeThemeVariables( 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 [ return [
`:root,.ui\\:light{${light}}`, `:root,.ui\\:light{${light}}`,