diff --git a/packages/appearance/package.json b/packages/appearance/package.json
index 9853141..f9b15aa 100644
--- a/packages/appearance/package.json
+++ b/packages/appearance/package.json
@@ -26,14 +26,33 @@
"exports": {
".": "./src/index.ts",
"./color-vision": "./src/color-vision.ts",
+ "./components/appearance-option-list": "./src/components/option-lists/appearance-option-list.tsx",
+ "./components/brightness-field": "./src/components/fields/brightness-field.tsx",
+ "./components/color-vision-mode-field": "./src/components/fields/color-vision-mode-field.tsx",
+ "./components/motion-mode-field": "./src/components/fields/motion-mode-field.tsx",
+ "./components/text-scale-field": "./src/components/fields/text-scale-field.tsx",
+ "./components/text-scale-option-list": "./src/components/option-lists/text-scale-option-list.tsx",
+ "./components/theme-color-picker": "./src/components/theme/theme-color-picker.tsx",
+ "./components/theme-config-card": "./src/components/theme/theme-config-card.tsx",
+ "./components/theme-mode-field": "./src/components/fields/theme-mode-field.tsx",
+ "./components/theme-mode-option-list": "./src/components/option-lists/theme-mode-option-list.tsx",
+ "./components/theme-toggle-button": "./src/components/theme/theme-toggle-button.tsx",
+ "./components/fields": "./src/components/fields/index.ts",
+ "./components/fields/*": "./src/components/fields/*.tsx",
+ "./components/option-lists": "./src/components/option-lists/index.ts",
+ "./components/option-lists/*": "./src/components/option-lists/*.tsx",
+ "./components/theme": "./src/components/theme/index.ts",
+ "./components/theme/*": "./src/components/theme/*.tsx",
"./components/*": "./src/components/*.tsx",
"./display": "./src/display.ts",
"./effects": "./src/effects.ts",
"./globals.css": "./src/styles/globals.css",
+ "./hooks": "./src/hooks/index.ts",
+ "./hooks/*": "./src/hooks/*.ts",
"./locales": "./src/locales/catalogs.ts",
"./locales/*": "./src/locales/*.ts",
"./preferences": "./src/preferences.ts",
"./theme": "./src/theme.ts",
- "./use-resolved-theme": "./src/use-resolved-theme.ts"
+ "./use-resolved-theme": "./src/hooks/use-resolved-theme.ts"
}
}
\ No newline at end of file
diff --git a/packages/appearance/src/color-vision.ts b/packages/appearance/src/color-vision.ts
index bed37a8..1590eb4 100644
--- a/packages/appearance/src/color-vision.ts
+++ b/packages/appearance/src/color-vision.ts
@@ -1,4 +1,4 @@
-import type { ThemeScheme } from "./themes"
+import type { ThemeScheme } from "./internal/themes"
/**
* Color-vision assistance modes.
diff --git a/packages/appearance/src/components/appearance-atoms.test.tsx b/packages/appearance/src/components/appearance-atoms.test.tsx
index cfe7b75..a5a35ba 100644
--- a/packages/appearance/src/components/appearance-atoms.test.tsx
+++ b/packages/appearance/src/components/appearance-atoms.test.tsx
@@ -5,13 +5,16 @@ import { I18nProvider } from "@workspace/i18n"
import { afterEach, describe, expect, it, vi } from "vitest"
import { messages as englishMessages } from "../locales/en-US"
-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 { TextScaleOptionList } from "./text-scale-option-list"
-import { accentColorSwatches, ThemeColorPicker } from "./theme-color-picker"
-import { ThemeModeOptionList } from "./theme-mode-option-list"
+import { BrightnessField } from "./fields/brightness-field"
+import { ColorVisionModeField } from "./fields/color-vision-mode-field"
+import { MotionModeField } from "./fields/motion-mode-field"
+import { TextScaleField } from "./fields/text-scale-field"
+import { TextScaleOptionList } from "./option-lists/text-scale-option-list"
+import { ThemeModeOptionList } from "./option-lists/theme-mode-option-list"
+import {
+ accentColorSwatches,
+ ThemeColorPicker,
+} from "./theme/theme-color-picker"
afterEach(cleanup)
diff --git a/packages/appearance/src/components/appearance-controller.tsx b/packages/appearance/src/components/appearance-controller.tsx
index 484a1dd..e4f7b03 100644
--- a/packages/appearance/src/components/appearance-controller.tsx
+++ b/packages/appearance/src/components/appearance-controller.tsx
@@ -1,7 +1,6 @@
import { useCallback, useState } from "react"
-import { usePreference } from "@workspace/preferences"
-import { useResolvedTheme } from "../use-resolved-theme"
+import { useToggleTheme } from "../hooks/use-toggle-theme"
import {
AppearanceDialog,
AppearanceDialogContent,
@@ -20,8 +19,7 @@ export function AppearanceController({
onOpenChange,
}: AppearanceControllerProps) {
const [isOpen, setIsOpen] = useState(false)
- const [, setThemeMode] = usePreference("theme-mode")
- const resolvedTheme = useResolvedTheme()
+ const toggleTheme = useToggleTheme()
const toggleDialog = useCallback(() => {
if (isOpen) {
@@ -31,10 +29,6 @@ export function AppearanceController({
}
}, [handle, isOpen])
- const toggleTheme = useCallback(() => {
- setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
- }, [resolvedTheme, setThemeMode])
-
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open)
diff --git a/packages/appearance/src/components/appearance-settings.tsx b/packages/appearance/src/components/appearance-settings.tsx
index 244def2..32d3bf3 100644
--- a/packages/appearance/src/components/appearance-settings.tsx
+++ b/packages/appearance/src/components/appearance-settings.tsx
@@ -1,29 +1,33 @@
import { cn } from "@workspace/ui/lib/utils"
-import { usePreference } from "@workspace/preferences"
-import { useResolvedTheme } from "../use-resolved-theme"
-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"
+import {
+ useBrightness,
+ useColorVisionMode,
+ useMotionMode,
+ useTextScale,
+ useThemeConfig,
+ useThemeMode,
+} from "../hooks/use-appearance-preference"
+import { useResolvedTheme } from "../hooks/use-resolved-theme"
+import { BrightnessField } from "./fields/brightness-field"
+import { ColorVisionModeField } from "./fields/color-vision-mode-field"
+import { MotionModeField } from "./fields/motion-mode-field"
+import { TextScaleField } from "./fields/text-scale-field"
+import { ThemeModeField } from "./fields/theme-mode-field"
+import { ThemeConfigCard } from "./theme/theme-config-card"
export interface AppearanceSettingsProps {
className?: string
}
export function AppearanceSettings({ className }: AppearanceSettingsProps) {
- const [themeMode, setThemeMode] = usePreference("theme-mode")
- 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 [themeMode, setThemeMode] = useThemeMode()
+ const [themeConfig, setThemeConfig] = useThemeConfig()
+ const [brightness, setBrightness] = useBrightness()
+ const [colorVisionMode, setColorVisionMode] = useColorVisionMode()
+ const [motionMode, setMotionMode] = useMotionMode()
+ const [textScale, setTextScale] = useTextScale()
const resolvedTheme = useResolvedTheme()
- const themeConfig = { accentColor, baseColor }
return (
@@ -39,10 +43,7 @@ export function AppearanceSettings({ className }: AppearanceSettingsProps) {
{
- setBaseColor(config.baseColor)
- setAccentColor(config.accentColor)
- }}
+ onConfigChange={setThemeConfig}
scheme={resolvedTheme}
/>
diff --git a/packages/appearance/src/components/brightness-field.tsx b/packages/appearance/src/components/fields/brightness-field.tsx
similarity index 95%
rename from packages/appearance/src/components/brightness-field.tsx
rename to packages/appearance/src/components/fields/brightness-field.tsx
index 9984348..3f35e30 100644
--- a/packages/appearance/src/components/brightness-field.tsx
+++ b/packages/appearance/src/components/fields/brightness-field.tsx
@@ -9,8 +9,8 @@ import {
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
-import type { Brightness } from "../display"
-import { appearanceMessages } from "../messages"
+import type { Brightness } from "../../display"
+import { appearanceMessages } from "../../messages"
export interface BrightnessFieldProps {
className?: string
diff --git a/packages/appearance/src/components/color-vision-mode-field.tsx b/packages/appearance/src/components/fields/color-vision-mode-field.tsx
similarity index 95%
rename from packages/appearance/src/components/color-vision-mode-field.tsx
rename to packages/appearance/src/components/fields/color-vision-mode-field.tsx
index f7e0fde..8a7969e 100644
--- a/packages/appearance/src/components/color-vision-mode-field.tsx
+++ b/packages/appearance/src/components/fields/color-vision-mode-field.tsx
@@ -9,8 +9,8 @@ import {
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
-import type { ColorVisionMode } from "../color-vision"
-import { appearanceMessages } from "../messages"
+import type { ColorVisionMode } from "../../color-vision"
+import { appearanceMessages } from "../../messages"
export interface ColorVisionModeFieldProps {
className?: string
diff --git a/packages/appearance/src/components/fields/index.ts b/packages/appearance/src/components/fields/index.ts
new file mode 100644
index 0000000..62b0eb4
--- /dev/null
+++ b/packages/appearance/src/components/fields/index.ts
@@ -0,0 +1,12 @@
+export { BrightnessField, type BrightnessFieldProps } from "./brightness-field"
+export {
+ ColorVisionModeField,
+ type ColorVisionModeFieldProps,
+} from "./color-vision-mode-field"
+export { MotionModeField, type MotionModeFieldProps } from "./motion-mode-field"
+export { TextScaleField, type TextScaleFieldProps } from "./text-scale-field"
+export {
+ ThemeModeField,
+ ThemeModeIcon,
+ type ThemeModeFieldProps,
+} from "./theme-mode-field"
diff --git a/packages/appearance/src/components/motion-mode-field.tsx b/packages/appearance/src/components/fields/motion-mode-field.tsx
similarity index 95%
rename from packages/appearance/src/components/motion-mode-field.tsx
rename to packages/appearance/src/components/fields/motion-mode-field.tsx
index 553189a..741622c 100644
--- a/packages/appearance/src/components/motion-mode-field.tsx
+++ b/packages/appearance/src/components/fields/motion-mode-field.tsx
@@ -8,8 +8,8 @@ import {
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
-import type { MotionMode } from "../display"
-import { appearanceMessages } from "../messages"
+import type { MotionMode } from "../../display"
+import { appearanceMessages } from "../../messages"
export interface MotionModeFieldProps {
className?: string
diff --git a/packages/appearance/src/components/text-scale-field.tsx b/packages/appearance/src/components/fields/text-scale-field.tsx
similarity index 94%
rename from packages/appearance/src/components/text-scale-field.tsx
rename to packages/appearance/src/components/fields/text-scale-field.tsx
index 43799ce..23bf9ec 100644
--- a/packages/appearance/src/components/text-scale-field.tsx
+++ b/packages/appearance/src/components/fields/text-scale-field.tsx
@@ -9,8 +9,8 @@ import {
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
-import type { TextScale } from "../display"
-import { appearanceMessages } from "../messages"
+import type { TextScale } from "../../display"
+import { appearanceMessages } from "../../messages"
export interface TextScaleFieldProps {
className?: string
diff --git a/packages/appearance/src/components/theme-mode-field.tsx b/packages/appearance/src/components/fields/theme-mode-field.tsx
similarity index 96%
rename from packages/appearance/src/components/theme-mode-field.tsx
rename to packages/appearance/src/components/fields/theme-mode-field.tsx
index 7ed3eec..d09fd48 100644
--- a/packages/appearance/src/components/theme-mode-field.tsx
+++ b/packages/appearance/src/components/fields/theme-mode-field.tsx
@@ -9,8 +9,8 @@ import {
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
-import { appearanceMessages } from "../messages"
-import type { ThemeMode } from "../preferences"
+import { appearanceMessages } from "../../messages"
+import type { ThemeMode } from "../../preferences"
export interface ThemeModeFieldProps {
className?: string
diff --git a/packages/appearance/src/components/appearance-option-list.tsx b/packages/appearance/src/components/option-lists/appearance-option-list.tsx
similarity index 100%
rename from packages/appearance/src/components/appearance-option-list.tsx
rename to packages/appearance/src/components/option-lists/appearance-option-list.tsx
diff --git a/packages/appearance/src/components/option-lists/index.ts b/packages/appearance/src/components/option-lists/index.ts
new file mode 100644
index 0000000..9aac83f
--- /dev/null
+++ b/packages/appearance/src/components/option-lists/index.ts
@@ -0,0 +1,13 @@
+export {
+ AppearanceOptionList,
+ type AppearanceOption,
+ type AppearanceOptionListProps,
+} from "./appearance-option-list"
+export {
+ TextScaleOptionList,
+ type TextScaleOptionListProps,
+} from "./text-scale-option-list"
+export {
+ ThemeModeOptionList,
+ type ThemeModeOptionListProps,
+} from "./theme-mode-option-list"
diff --git a/packages/appearance/src/components/text-scale-option-list.tsx b/packages/appearance/src/components/option-lists/text-scale-option-list.tsx
similarity index 93%
rename from packages/appearance/src/components/text-scale-option-list.tsx
rename to packages/appearance/src/components/option-lists/text-scale-option-list.tsx
index 21aa0e4..3cf63ce 100644
--- a/packages/appearance/src/components/text-scale-option-list.tsx
+++ b/packages/appearance/src/components/option-lists/text-scale-option-list.tsx
@@ -1,8 +1,8 @@
import { useMessage } from "@workspace/i18n"
import { cn } from "@workspace/ui/lib/utils"
-import type { TextScale } from "../display"
-import { appearanceMessages } from "../messages"
+import type { TextScale } from "../../display"
+import { appearanceMessages } from "../../messages"
import {
AppearanceOptionList,
type AppearanceOption,
diff --git a/packages/appearance/src/components/theme-mode-option-list.tsx b/packages/appearance/src/components/option-lists/theme-mode-option-list.tsx
similarity index 94%
rename from packages/appearance/src/components/theme-mode-option-list.tsx
rename to packages/appearance/src/components/option-lists/theme-mode-option-list.tsx
index b9c3f0c..ae386f5 100644
--- a/packages/appearance/src/components/theme-mode-option-list.tsx
+++ b/packages/appearance/src/components/option-lists/theme-mode-option-list.tsx
@@ -2,8 +2,8 @@ import { useMessage } from "@workspace/i18n"
import { MonitorIcon, MoonIcon, SunIcon } from "@workspace/ui/components/icon"
import { cn } from "@workspace/ui/lib/utils"
-import { appearanceMessages } from "../messages"
-import type { ThemeMode } from "../preferences"
+import { appearanceMessages } from "../../messages"
+import type { ThemeMode } from "../../preferences"
import {
AppearanceOptionList,
type AppearanceOption,
diff --git a/packages/appearance/src/components/theme/index.ts b/packages/appearance/src/components/theme/index.ts
new file mode 100644
index 0000000..4384eef
--- /dev/null
+++ b/packages/appearance/src/components/theme/index.ts
@@ -0,0 +1,20 @@
+export {
+ ThemeColorPicker,
+ ThemeColorSwatchButton,
+ accentColorSwatches,
+ baseColorSwatches,
+ createThemeColorSwatches,
+ type ThemeColor,
+ type ThemeColorPickerProps,
+ type ThemeColorPickerVariant,
+ type ThemeColorSwatch,
+ type ThemeColorSwatchButtonProps,
+ type ThemeColorSwatchScheme,
+} from "./theme-color-picker"
+export { ThemeConfigCard, type ThemeConfigCardProps } from "./theme-config-card"
+export {
+ ThemeToggle,
+ ThemeToggleButton,
+ type ThemeToggleButtonProps,
+ type ThemeToggleProps,
+} from "./theme-toggle-button"
diff --git a/packages/appearance/src/components/theme-color-picker.tsx b/packages/appearance/src/components/theme/theme-color-picker.tsx
similarity index 98%
rename from packages/appearance/src/components/theme-color-picker.tsx
rename to packages/appearance/src/components/theme/theme-color-picker.tsx
index 380bda8..256a0a9 100644
--- a/packages/appearance/src/components/theme-color-picker.tsx
+++ b/packages/appearance/src/components/theme/theme-color-picker.tsx
@@ -3,14 +3,14 @@ import { useMessage } from "@workspace/i18n"
import { CheckIcon } from "@workspace/ui/components/icon"
import { cn } from "@workspace/ui/lib/utils"
-import { themeColorMessages } from "../messages"
+import { themeColorMessages } from "../../messages"
import {
accentColors,
baseColors,
getTheme,
type AccentColor,
type BaseColor,
-} from "../theme"
+} from "../../theme"
export type ThemeColor = BaseColor | AccentColor
export type ThemeColorPickerVariant = "pill" | "swatch"
diff --git a/packages/appearance/src/components/theme-config-card.tsx b/packages/appearance/src/components/theme/theme-config-card.tsx
similarity index 93%
rename from packages/appearance/src/components/theme-config-card.tsx
rename to packages/appearance/src/components/theme/theme-config-card.tsx
index 361dd0d..1cfb063 100644
--- a/packages/appearance/src/components/theme-config-card.tsx
+++ b/packages/appearance/src/components/theme/theme-config-card.tsx
@@ -2,8 +2,8 @@ import { LocalizedText } from "@workspace/i18n"
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
import { cn } from "@workspace/ui/lib/utils"
-import { appearanceMessages } from "../messages"
-import type { ThemeColorConfig, ThemeScheme } from "../theme"
+import { appearanceMessages } from "../../messages"
+import type { ThemeColorConfig, ThemeScheme } from "../../theme"
import {
accentColorSwatches,
baseColorSwatches,
diff --git a/packages/appearance/src/components/theme-toggle-button.tsx b/packages/appearance/src/components/theme/theme-toggle-button.tsx
similarity index 79%
rename from packages/appearance/src/components/theme-toggle-button.tsx
rename to packages/appearance/src/components/theme/theme-toggle-button.tsx
index aa16a6c..f65436f 100644
--- a/packages/appearance/src/components/theme-toggle-button.tsx
+++ b/packages/appearance/src/components/theme/theme-toggle-button.tsx
@@ -1,13 +1,13 @@
import type { ComponentProps } from "react"
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
import { useMessage } from "@workspace/i18n"
-import { usePreference } from "@workspace/preferences"
import { Button } from "@workspace/ui/components/button"
import { cn } from "@workspace/ui/lib/utils"
-import { appearanceMessages } from "../messages"
-import type { ThemeScheme } from "../theme"
-import { useResolvedTheme } from "../use-resolved-theme"
+import { useResolvedTheme } from "../../hooks/use-resolved-theme"
+import { useToggleTheme } from "../../hooks/use-toggle-theme"
+import { appearanceMessages } from "../../messages"
+import type { ThemeScheme } from "../../theme"
export interface ThemeToggleProps extends Omit<
ComponentProps,
@@ -50,14 +50,10 @@ export type ThemeToggleButtonProps = Omit<
>
export function ThemeToggleButton(props: ThemeToggleButtonProps) {
- const [, setThemeMode] = usePreference("theme-mode")
const resolvedTheme = useResolvedTheme()
+ const toggleTheme = useToggleTheme()
return (
-
+
)
}
diff --git a/packages/appearance/src/display.ts b/packages/appearance/src/display.ts
index af59c0d..ec29652 100644
--- a/packages/appearance/src/display.ts
+++ b/packages/appearance/src/display.ts
@@ -1,4 +1,4 @@
-import type { ThemeScheme } from "./themes"
+import type { ThemeScheme } from "./internal/themes"
export type Brightness = "dim" | "normal" | "bright"
export type MotionMode = "system" | "reduce" | "full"
diff --git a/packages/appearance/src/effects.ts b/packages/appearance/src/effects.ts
index 14f917e..bb80a75 100644
--- a/packages/appearance/src/effects.ts
+++ b/packages/appearance/src/effects.ts
@@ -9,16 +9,14 @@ import {
BRIGHTNESS_ATTRIBUTE,
MOTION_ATTRIBUTE,
TEXT_SCALE_ATTRIBUTE,
- type ResolvedMotionMode,
} from "./display"
+import { buildThemeStyleSheet, THEME_STYLE_ELEMENT_ID } from "./theme"
import {
- buildThemeStyleSheet,
- THEME_STYLE_ELEMENT_ID,
- type ThemeScheme,
-} from "./theme"
-
-const systemThemeQuery = "(prefers-color-scheme: dark)"
-const reducedMotionQuery = "(prefers-reduced-motion: reduce)"
+ getSystemMotion,
+ getSystemTheme,
+ subscribeToSystemMotion,
+ subscribeToSystemTheme,
+} from "./internal/system-preferences"
const appearancePreferenceKeys = [
"accent-color",
"base-color",
@@ -49,14 +47,6 @@ function disableThemeTransitionsTemporarily() {
})
}
-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
@@ -123,14 +113,12 @@ export const subscribeSystemThemePreferenceEffect: PreferenceEffectCallback = ({
const appearancePreferences = preferences as AppearancePreferences
if (appearancePreferences["theme-mode"] !== "system") return
- const mediaQuery = window.matchMedia(systemThemeQuery)
const applySystemTheme = () => {
disableThemeTransitionsTemporarily()
applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
}
- mediaQuery.addEventListener("change", applySystemTheme)
- return () => mediaQuery.removeEventListener("change", applySystemTheme)
+ return subscribeToSystemTheme(applySystemTheme)
}
/** Keeps system-mode motion synchronized with the operating system setting. */
@@ -139,13 +127,11 @@ export const subscribeSystemMotionPreferenceEffect: PreferenceEffectCallback =
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)
+ return subscribeToSystemMotion(applySystemMotion)
}
/** Subscribes to every operating-system preference used by Appearance. */
diff --git a/packages/appearance/src/hooks/hooks.test.tsx b/packages/appearance/src/hooks/hooks.test.tsx
new file mode 100644
index 0000000..278335f
--- /dev/null
+++ b/packages/appearance/src/hooks/hooks.test.tsx
@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react"
+import { PreferencesProvider } from "@workspace/preferences"
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
+
+import { defaultAppearancePreferences } from "../preferences"
+import {
+ useAccentColor,
+ useAppearancePreferences,
+ useThemeConfig,
+ useThemeMode,
+} from "./use-appearance-preference"
+import { useResolvedMotion } from "./use-resolved-motion"
+import { useResolvedTheme } from "./use-resolved-theme"
+import { useToggleTheme } from "./use-toggle-theme"
+
+beforeAll(() => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockImplementation((query: string) => ({
+ addEventListener: vi.fn(),
+ matches: false,
+ media: query,
+ removeEventListener: vi.fn(),
+ }))
+ )
+})
+
+afterEach(cleanup)
+
+function renderPreferences(node: React.ReactNode) {
+ return render(
+
+ {node}
+
+ )
+}
+
+function PreferenceProbe() {
+ const [accentColor, setAccentColor] = useAccentColor()
+ const [themeMode] = useThemeMode()
+ const preferences = useAppearancePreferences()
+
+ return (
+
+ )
+}
+
+function ThemeConfigProbe() {
+ const [config, setConfig] = useThemeConfig()
+
+ return (
+
+ )
+}
+
+function ResolvedPreferenceProbe() {
+ const resolvedTheme = useResolvedTheme()
+ const resolvedMotion = useResolvedMotion()
+ const toggleTheme = useToggleTheme()
+
+ return (
+
+ )
+}
+
+describe("appearance hooks", () => {
+ it("exposes typed preference-specific hooks and the complete snapshot", () => {
+ renderPreferences()
+
+ const button = screen.getByRole("button")
+ expect(button.textContent).toBe("blue:system:normal")
+
+ fireEvent.click(button)
+ expect(button.textContent).toBe("rose:system:normal")
+ })
+
+ it("updates accent and base colors through one theme config API", () => {
+ renderPreferences()
+
+ const button = screen.getByRole("button")
+ expect(button.textContent).toBe("blue:neutral")
+
+ fireEvent.click(button)
+ expect(button.textContent).toBe("violet:zinc")
+ })
+
+ it("resolves system preferences and toggles to an explicit theme", () => {
+ renderPreferences()
+
+ const button = screen.getByRole("button")
+ expect(button.textContent).toBe("light:full")
+
+ fireEvent.click(button)
+ expect(button.textContent).toBe("dark:full")
+ })
+})
diff --git a/packages/appearance/src/hooks/index.ts b/packages/appearance/src/hooks/index.ts
new file mode 100644
index 0000000..556c328
--- /dev/null
+++ b/packages/appearance/src/hooks/index.ts
@@ -0,0 +1,16 @@
+export {
+ useAccentColor,
+ useAppearancePreference,
+ useAppearancePreferences,
+ useBaseColor,
+ useBrightness,
+ useColorVisionMode,
+ useMotionMode,
+ useTextScale,
+ useThemeConfig,
+ useThemeMode,
+ type AppearancePreferenceState,
+} from "./use-appearance-preference"
+export { useResolvedMotion, useSystemMotion } from "./use-resolved-motion"
+export { useResolvedTheme, useSystemTheme } from "./use-resolved-theme"
+export { useToggleTheme } from "./use-toggle-theme"
diff --git a/packages/appearance/src/hooks/use-appearance-preference.ts b/packages/appearance/src/hooks/use-appearance-preference.ts
new file mode 100644
index 0000000..f8d9a5d
--- /dev/null
+++ b/packages/appearance/src/hooks/use-appearance-preference.ts
@@ -0,0 +1,112 @@
+import React from "react"
+import { usePreference, usePreferences } from "@workspace/preferences"
+
+import type {
+ AppearancePreferenceKey,
+ AppearancePreferences,
+ AppearancePreferenceValue,
+} from "../preferences"
+import type { ThemeColorConfig } from "../theme"
+
+export type AppearancePreferenceState = [
+ AppearancePreferenceValue,
+ React.Dispatch>>,
+]
+
+/** Reads and updates one preference owned by the Appearance package. */
+export function useAppearancePreference(
+ key: Key
+): AppearancePreferenceState {
+ return usePreference(key)
+}
+
+/** Returns the complete Appearance-owned portion of the shared preferences. */
+export function useAppearancePreferences(): AppearancePreferences {
+ const preferences = usePreferences()
+ const accentColor = preferences["accent-color"]
+ const baseColor = preferences["base-color"]
+ const brightness = preferences.brightness
+ const colorVisionMode = preferences["color-vision-mode"]
+ const motionMode = preferences["motion-mode"]
+ const textScale = preferences["text-scale"]
+ const themeMode = preferences["theme-mode"]
+
+ return React.useMemo(
+ () => ({
+ "accent-color": accentColor,
+ "base-color": baseColor,
+ brightness,
+ "color-vision-mode": colorVisionMode,
+ "motion-mode": motionMode,
+ "text-scale": textScale,
+ "theme-mode": themeMode,
+ }),
+ [
+ accentColor,
+ baseColor,
+ brightness,
+ colorVisionMode,
+ motionMode,
+ textScale,
+ themeMode,
+ ]
+ )
+}
+
+export function useAccentColor() {
+ return useAppearancePreference("accent-color")
+}
+
+export function useBaseColor() {
+ return useAppearancePreference("base-color")
+}
+
+export function useBrightness() {
+ return useAppearancePreference("brightness")
+}
+
+export function useColorVisionMode() {
+ return useAppearancePreference("color-vision-mode")
+}
+
+export function useMotionMode() {
+ return useAppearancePreference("motion-mode")
+}
+
+export function useTextScale() {
+ return useAppearancePreference("text-scale")
+}
+
+export function useThemeMode() {
+ return useAppearancePreference("theme-mode")
+}
+
+/** Treats the two color preferences as one controlled theme configuration. */
+export function useThemeConfig(): [
+ ThemeColorConfig,
+ React.Dispatch>,
+] {
+ const [accentColor, setAccentColor] = useAccentColor()
+ const [baseColor, setBaseColor] = useBaseColor()
+ const config = React.useMemo(
+ () => ({ accentColor, baseColor }),
+ [accentColor, baseColor]
+ )
+ const configRef = React.useRef(config)
+ configRef.current = config
+
+ const setConfig = React.useCallback<
+ React.Dispatch>
+ >(
+ (action) => {
+ const nextConfig =
+ typeof action === "function" ? action(configRef.current) : action
+ configRef.current = nextConfig
+ setAccentColor(nextConfig.accentColor)
+ setBaseColor(nextConfig.baseColor)
+ },
+ [setAccentColor, setBaseColor]
+ )
+
+ return React.useMemo(() => [config, setConfig], [config, setConfig])
+}
diff --git a/packages/appearance/src/hooks/use-resolved-motion.ts b/packages/appearance/src/hooks/use-resolved-motion.ts
new file mode 100644
index 0000000..9100d50
--- /dev/null
+++ b/packages/appearance/src/hooks/use-resolved-motion.ts
@@ -0,0 +1,26 @@
+import React from "react"
+
+import type { ResolvedMotionMode } from "../display"
+import {
+ getServerSystemMotion,
+ getSystemMotion,
+ subscribeToSystemMotion,
+} from "../internal/system-preferences"
+import { useMotionMode } from "./use-appearance-preference"
+
+/** Tracks the operating system reduced-motion preference. */
+export function useSystemMotion(): ResolvedMotionMode {
+ return React.useSyncExternalStore(
+ subscribeToSystemMotion,
+ getSystemMotion,
+ getServerSystemMotion
+ )
+}
+
+/** Resolves `system` to the current operating system motion preference. */
+export function useResolvedMotion(): ResolvedMotionMode {
+ const [motionMode] = useMotionMode()
+ const systemMotion = useSystemMotion()
+
+ return motionMode === "system" ? systemMotion : motionMode
+}
diff --git a/packages/appearance/src/hooks/use-resolved-theme.ts b/packages/appearance/src/hooks/use-resolved-theme.ts
new file mode 100644
index 0000000..230f7a4
--- /dev/null
+++ b/packages/appearance/src/hooks/use-resolved-theme.ts
@@ -0,0 +1,26 @@
+import React from "react"
+
+import {
+ getServerSystemTheme,
+ getSystemTheme,
+ subscribeToSystemTheme,
+} from "../internal/system-preferences"
+import type { ThemeScheme } from "../theme"
+import { useThemeMode } from "./use-appearance-preference"
+
+/** Tracks the operating system color scheme independently of theme mode. */
+export function useSystemTheme(): ThemeScheme {
+ return React.useSyncExternalStore(
+ subscribeToSystemTheme,
+ getSystemTheme,
+ getServerSystemTheme
+ )
+}
+
+/** Resolves `system` to the current operating system color scheme. */
+export function useResolvedTheme(): ThemeScheme {
+ const [themeMode] = useThemeMode()
+ const systemTheme = useSystemTheme()
+
+ return themeMode === "system" ? systemTheme : themeMode
+}
diff --git a/packages/appearance/src/hooks/use-toggle-theme.ts b/packages/appearance/src/hooks/use-toggle-theme.ts
new file mode 100644
index 0000000..080799b
--- /dev/null
+++ b/packages/appearance/src/hooks/use-toggle-theme.ts
@@ -0,0 +1,14 @@
+import React from "react"
+
+import { useThemeMode } from "./use-appearance-preference"
+import { useResolvedTheme } from "./use-resolved-theme"
+
+/** Returns a stable callback that toggles between explicit light and dark modes. */
+export function useToggleTheme(): VoidFunction {
+ const [, setThemeMode] = useThemeMode()
+ const resolvedTheme = useResolvedTheme()
+
+ return React.useCallback(() => {
+ setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
+ }, [resolvedTheme, setThemeMode])
+}
diff --git a/packages/appearance/src/index.ts b/packages/appearance/src/index.ts
index d1ac1f2..8109e76 100644
--- a/packages/appearance/src/index.ts
+++ b/packages/appearance/src/index.ts
@@ -23,34 +23,34 @@ export {
AppearanceSettings,
type AppearanceSettingsProps,
} from "./components/appearance-settings"
+export {
+ BrightnessField,
+ ColorVisionModeField,
+ MotionModeField,
+ TextScaleField,
+ ThemeModeField,
+ ThemeModeIcon,
+ type BrightnessFieldProps,
+ type ColorVisionModeFieldProps,
+ type MotionModeFieldProps,
+ type TextScaleFieldProps,
+ type ThemeModeFieldProps,
+} from "./components/fields"
export {
AppearanceOptionList,
+ TextScaleOptionList,
+ ThemeModeOptionList,
type AppearanceOption,
type AppearanceOptionListProps,
-} from "./components/appearance-option-list"
-export {
- 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 {
- TextScaleOptionList,
type TextScaleOptionListProps,
-} from "./components/text-scale-option-list"
+ type ThemeModeOptionListProps,
+} from "./components/option-lists"
export {
ThemeColorPicker,
ThemeColorSwatchButton,
+ ThemeConfigCard,
+ ThemeToggle,
+ ThemeToggleButton,
accentColorSwatches,
baseColorSwatches,
createThemeColorSwatches,
@@ -60,26 +60,10 @@ export {
type ThemeColorSwatch,
type ThemeColorSwatchButtonProps,
type ThemeColorSwatchScheme,
-} from "./components/theme-color-picker"
-export {
- ThemeConfigCard,
type ThemeConfigCardProps,
-} from "./components/theme-config-card"
-export {
- ThemeModeField,
- ThemeModeIcon,
- type ThemeModeFieldProps,
-} from "./components/theme-mode-field"
-export {
- ThemeModeOptionList,
- type ThemeModeOptionListProps,
-} from "./components/theme-mode-option-list"
-export {
- ThemeToggle,
- ThemeToggleButton,
type ThemeToggleButtonProps,
type ThemeToggleProps,
-} from "./components/theme-toggle-button"
+} from "./components/theme"
export {
COLOR_VISION_ATTRIBUTE,
colorVisionModes,
@@ -125,4 +109,4 @@ export {
type ThemeMode,
} from "./preferences"
export * from "./theme"
-export { useResolvedTheme } from "./use-resolved-theme"
+export * from "./hooks"
diff --git a/packages/appearance/src/internal/system-preferences.ts b/packages/appearance/src/internal/system-preferences.ts
new file mode 100644
index 0000000..6da3f7b
--- /dev/null
+++ b/packages/appearance/src/internal/system-preferences.ts
@@ -0,0 +1,39 @@
+import type { ResolvedMotionMode } from "../display"
+import type { ThemeScheme } from "../theme"
+
+export const SYSTEM_THEME_QUERY = "(prefers-color-scheme: dark)"
+export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
+
+function subscribeToMediaQuery(query: string, listener: VoidFunction) {
+ if (typeof window === "undefined") return () => {}
+
+ const mediaQuery = window.matchMedia(query)
+ mediaQuery.addEventListener("change", listener)
+ return () => mediaQuery.removeEventListener("change", listener)
+}
+
+export function subscribeToSystemTheme(listener: VoidFunction) {
+ return subscribeToMediaQuery(SYSTEM_THEME_QUERY, listener)
+}
+
+export function getSystemTheme(): ThemeScheme {
+ if (typeof window === "undefined") return getServerSystemTheme()
+ return window.matchMedia(SYSTEM_THEME_QUERY).matches ? "dark" : "light"
+}
+
+export function getServerSystemTheme(): ThemeScheme {
+ return "light"
+}
+
+export function subscribeToSystemMotion(listener: VoidFunction) {
+ return subscribeToMediaQuery(REDUCED_MOTION_QUERY, listener)
+}
+
+export function getSystemMotion(): ResolvedMotionMode {
+ if (typeof window === "undefined") return getServerSystemMotion()
+ return window.matchMedia(REDUCED_MOTION_QUERY).matches ? "reduce" : "full"
+}
+
+export function getServerSystemMotion(): ResolvedMotionMode {
+ return "full"
+}
diff --git a/packages/appearance/src/themes.ts b/packages/appearance/src/internal/themes.ts
similarity index 100%
rename from packages/appearance/src/themes.ts
rename to packages/appearance/src/internal/themes.ts
diff --git a/packages/appearance/src/theme.ts b/packages/appearance/src/theme.ts
index 09bd12d..185487c 100644
--- a/packages/appearance/src/theme.ts
+++ b/packages/appearance/src/theme.ts
@@ -4,7 +4,7 @@ import {
type BaseColor,
type ThemeColorConfig,
type ThemeScheme,
-} from "./themes"
+} from "./internal/themes"
import {
getColorVisionCssVariables,
type ColorVisionMode,
@@ -20,7 +20,7 @@ export {
type Theme,
type ThemeColorConfig,
type ThemeScheme,
-} from "./themes"
+} from "./internal/themes"
export const THEME_STYLE_ELEMENT_ID = "ui-theme-variables"
diff --git a/packages/appearance/src/use-resolved-theme.ts b/packages/appearance/src/use-resolved-theme.ts
deleted file mode 100644
index 4c3d153..0000000
--- a/packages/appearance/src/use-resolved-theme.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from "react"
-import { usePreference } from "@workspace/preferences"
-
-import type { ThemeMode } from "./preferences"
-import type { ThemeScheme } from "./theme"
-
-const systemThemeQuery = "(prefers-color-scheme: dark)"
-
-function subscribeToSystemTheme(listener: VoidFunction) {
- const mediaQuery = window.matchMedia(systemThemeQuery)
- mediaQuery.addEventListener("change", listener)
- return () => mediaQuery.removeEventListener("change", listener)
-}
-
-function getSystemTheme(): ThemeScheme {
- return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
-}
-
-function getServerSystemTheme(): ThemeScheme {
- return "light"
-}
-
-export function useResolvedTheme(): ThemeScheme {
- const themeMode: ThemeMode = usePreference("theme-mode")[0]
- const systemTheme = React.useSyncExternalStore(
- subscribeToSystemTheme,
- getSystemTheme,
- getServerSystemTheme
- )
-
- return themeMode === "system" ? systemTheme : themeMode
-}