diff --git a/bun.lock b/bun.lock index b0324e9..da43ada 100644 --- a/bun.lock +++ b/bun.lock @@ -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:*", diff --git a/packages/appearance/package.json b/packages/appearance/package.json index 3575dca..9853141 100644 --- a/packages/appearance/package.json +++ b/packages/appearance/package.json @@ -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", diff --git a/packages/appearance/src/components/appearance-atoms.test.tsx b/packages/appearance/src/components/appearance-atoms.test.tsx index d162b0e..e06b186 100644 --- a/packages/appearance/src/components/appearance-atoms.test.tsx +++ b/packages/appearance/src/components/appearance-atoms.test.tsx @@ -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( - + ) - 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( + + ) + selectOption("Text size", "150%") + expect(setTextScale).toHaveBeenCalledWith("150") + + rerender( + + + + ) + selectOption("Motion", "Reduce motion") + expect(setMotionMode).toHaveBeenCalledWith("reduce") + + rerender( + + + + ) + selectOption("Brightness", "Dim") + expect(setBrightness).toHaveBeenCalledWith("dim") }) it("reports the selected theme color", () => { diff --git a/packages/appearance/src/components/appearance-dialog.tsx b/packages/appearance/src/components/appearance-dialog.tsx index 6d6e571..8a74d1a 100644 --- a/packages/appearance/src/components/appearance-dialog.tsx +++ b/packages/appearance/src/components/appearance-dialog.tsx @@ -48,7 +48,7 @@ export function AppearanceDialogHeader() { - + ) diff --git a/packages/appearance/src/components/appearance-settings.tsx b/packages/appearance/src/components/appearance-settings.tsx index d38d66b..244def2 100644 --- a/packages/appearance/src/components/appearance-settings.tsx +++ b/packages/appearance/src/components/appearance-settings.tsx @@ -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 (
-
+
- -
-
- - + + +
+ { + setBaseColor(config.baseColor) + setAccentColor(config.accentColor) + }} + scheme={resolvedTheme} + />
) } diff --git a/packages/appearance/src/components/brightness-field.tsx b/packages/appearance/src/components/brightness-field.tsx new file mode 100644 index 0000000..9984348 --- /dev/null +++ b/packages/appearance/src/components/brightness-field.tsx @@ -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 ( +
+

{title}

+

{description}

+ +
+ ) +} diff --git a/packages/appearance/src/components/color-vision-mode-field.tsx b/packages/appearance/src/components/color-vision-mode-field.tsx new file mode 100644 index 0000000..f7e0fde --- /dev/null +++ b/packages/appearance/src/components/color-vision-mode-field.tsx @@ -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 ( +
+

{title}

+

+ {value === "standard" ? standardDescription : assistedDescription} +

+ +
+ ) +} diff --git a/packages/appearance/src/components/compact-layout-field.tsx b/packages/appearance/src/components/compact-layout-field.tsx deleted file mode 100644 index fbee7d3..0000000 --- a/packages/appearance/src/components/compact-layout-field.tsx +++ /dev/null @@ -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 ( -
-

- -

-

- -

- onCheckedChange(nextChecked)} - /> -
- ) -} diff --git a/packages/appearance/src/components/motion-mode-field.tsx b/packages/appearance/src/components/motion-mode-field.tsx new file mode 100644 index 0000000..553189a --- /dev/null +++ b/packages/appearance/src/components/motion-mode-field.tsx @@ -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 ( +
+

{title}

+

+ {descriptions[value]} +

+ +
+ ) +} diff --git a/packages/appearance/src/components/text-scale-field.tsx b/packages/appearance/src/components/text-scale-field.tsx new file mode 100644 index 0000000..43799ce --- /dev/null +++ b/packages/appearance/src/components/text-scale-field.tsx @@ -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 ( +
+

{title}

+

{description}

+ +
+ ) +} diff --git a/packages/appearance/src/components/theme-config-card.tsx b/packages/appearance/src/components/theme-config-card.tsx index 8917f8b..b4cf2d9 100644 --- a/packages/appearance/src/components/theme-config-card.tsx +++ b/packages/appearance/src/components/theme-config-card.tsx @@ -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 ( -
+
{scheme === "light" ? ( @@ -47,19 +33,14 @@ export function ThemeConfigCard({ )}

- +

- {active && ( - - - - )}

- +

- + } @@ -70,7 +51,7 @@ export function ThemeConfigCard({ /> } + title={} value={config.accentColor} onValueChange={(accentColor) => onConfigChange({ ...config, accentColor }) diff --git a/packages/appearance/src/components/theme-mode-field.tsx b/packages/appearance/src/components/theme-mode-field.tsx index b5a114a..7ed3eec 100644 --- a/packages/appearance/src/components/theme-mode-field.tsx +++ b/packages/appearance/src/components/theme-mode-field.tsx @@ -50,7 +50,7 @@ export function ThemeModeField({ }[value] return ( -
+

{title}

{description}