feat(appearance): add accessible display preferences
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "切换为深色模式",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}}`,
|
||||
|
||||
Reference in New Issue
Block a user