feat(appearance): add composable theme package

This commit is contained in:
Maofeng
2026-09-21 16:05:05 +08:00
parent 71c416caab
commit e2dbbcb884
29 changed files with 2877 additions and 0 deletions
+23
View File
@@ -83,6 +83,27 @@
"wrangler": "^4.135.0",
},
},
"packages/appearance": {
"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:*",
"@workspace/ui": "workspace:*",
"react": "^19.2.6",
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10",
},
},
"packages/blocks": {
"name": "@workspace/blocks",
"version": "0.0.0",
@@ -999,6 +1020,8 @@
"@vue/shared": ["@vue/shared@3.5.40", "", {}, "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg=="],
"@workspace/appearance": ["@workspace/appearance@workspace:packages/appearance"],
"@workspace/blocks": ["@workspace/blocks@workspace:packages/blocks"],
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@workspace/appearance",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@icones/react": "^0.0.1",
"@tanstack/react-hotkeys": "^0.10.0",
"@workspace/i18n": "workspace:*",
"@workspace/preferences": "workspace:*",
"@workspace/ui": "workspace:*",
"react": "^19.2.6"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10"
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx",
"./effects": "./src/effects.ts",
"./globals.css": "./src/styles/globals.css",
"./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"
}
}
@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
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 { accentColorSwatches, ThemeColorPicker } from "./theme-color-picker"
afterEach(cleanup)
function renderLocalized(node: React.ReactNode) {
return render(
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
{node}
</I18nProvider>
)
}
describe("appearance atoms", () => {
it("reports compact layout changes without owning state", () => {
const onCheckedChange = vi.fn()
renderLocalized(
<CompactLayoutField checked={false} onCheckedChange={onCheckedChange} />
)
fireEvent.click(screen.getByRole("switch"))
expect(onCheckedChange).toHaveBeenCalledWith(true)
})
it("reports the selected theme color", () => {
const onValueChange = vi.fn()
renderLocalized(
<ThemeColorPicker
onValueChange={onValueChange}
swatches={accentColorSwatches}
title="Primary color"
value="blue"
/>
)
fireEvent.click(screen.getByRole("button", { name: "Rose" }))
expect(onValueChange).toHaveBeenCalledWith("rose")
})
})
@@ -0,0 +1,55 @@
import { useCallback, useState } from "react"
import { usePreference } from "@workspace/preferences"
import { useResolvedTheme } from "../use-resolved-theme"
import {
AppearanceDialog,
AppearanceDialogContent,
appearanceDialogHandle,
type AppearanceDialogHandle,
} from "./appearance-dialog"
import { AppearanceHotkeys } from "./appearance-hotkeys"
export interface AppearanceControllerProps {
handle?: AppearanceDialogHandle
onOpenChange?: (open: boolean) => void
}
export function AppearanceController({
handle = appearanceDialogHandle,
onOpenChange,
}: AppearanceControllerProps) {
const [isOpen, setIsOpen] = useState(false)
const [, setThemeMode] = usePreference("theme-mode")
const resolvedTheme = useResolvedTheme()
const toggleDialog = useCallback(() => {
if (isOpen) {
handle.close()
} else {
handle.open(null)
}
}, [handle, isOpen])
const toggleTheme = useCallback(() => {
setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setThemeMode])
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open)
onOpenChange?.(open)
},
[onOpenChange]
)
return (
<AppearanceDialog handle={handle} onOpenChange={handleOpenChange}>
<AppearanceHotkeys
onToggleDialog={toggleDialog}
onToggleTheme={toggleTheme}
/>
<AppearanceDialogContent />
</AppearanceDialog>
)
}
@@ -0,0 +1,93 @@
import type { ComponentProps, ReactNode } from "react"
import { LocalizedText } from "@workspace/i18n"
import {
createDialogHandle,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
type DialogHandle,
} from "@workspace/ui/components/dialog"
import { cn } from "@workspace/ui/lib/utils"
import { appearanceMessages } from "../messages"
import { AppearanceSettings } from "./appearance-settings"
export type AppearanceDialogHandle = DialogHandle<void>
export function createAppearanceDialogHandle(): AppearanceDialogHandle {
return createDialogHandle<void>()
}
export const appearanceDialogHandle = createAppearanceDialogHandle()
export interface AppearanceDialogProps {
children?: ReactNode
handle?: AppearanceDialogHandle
onOpenChange?: (open: boolean) => void
}
export function AppearanceDialog({
children,
handle = appearanceDialogHandle,
onOpenChange,
}: AppearanceDialogProps) {
return (
<Dialog handle={handle} onOpenChange={onOpenChange}>
{children ?? <AppearanceDialogContent />}
</Dialog>
)
}
export function AppearanceDialogHeader() {
return (
<DialogHeader>
<DialogTitle>
<LocalizedText message={appearanceMessages.appearance} />
</DialogTitle>
<DialogDescription>
<LocalizedText message={appearanceMessages.themeAndLayoutPreferences} />
</DialogDescription>
</DialogHeader>
)
}
export interface AppearanceDialogContentProps extends Omit<
ComponentProps<typeof DialogContent>,
"children"
> {
children?: ReactNode
}
export function AppearanceDialogContent({
children,
className,
...props
}: AppearanceDialogContentProps) {
return (
<DialogContent className={cn("sm:max-w-max", className)} {...props}>
<AppearanceDialogHeader />
<div className="-mx-6 max-h-[75vh] overflow-y-auto px-6">
{children ?? <AppearanceSettings />}
</div>
</DialogContent>
)
}
export interface AppearanceDialogTriggerProps extends Omit<
ComponentProps<typeof DialogTrigger>,
"handle"
> {
handle?: AppearanceDialogHandle
}
export function AppearanceDialogTrigger({
handle = appearanceDialogHandle,
...props
}: AppearanceDialogTriggerProps) {
return <DialogTrigger handle={handle} {...props} />
}
export const AppearanceTrigger = AppearanceDialogTrigger
@@ -0,0 +1,45 @@
import { useHotkey } from "@tanstack/react-hotkeys"
import { useMessage } from "@workspace/i18n"
import { appearanceMessages } from "../messages"
export interface AppearanceHotkeysProps {
onToggleDialog: VoidFunction
onToggleTheme: VoidFunction
}
export function AppearanceHotkeys({
onToggleDialog,
onToggleTheme,
}: AppearanceHotkeysProps) {
const appearanceTitle = useMessage(appearanceMessages.appearance)
const appearanceDescription = useMessage(
appearanceMessages.appearanceCommandDescription
)
const toggleThemeTitle = useMessage(appearanceMessages.toggleTheme)
const toggleThemeDescription = useMessage(
appearanceMessages.toggleThemeDescription
)
useHotkey("Mod+,", onToggleDialog, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: appearanceTitle,
description: appearanceDescription,
},
})
useHotkey("Mod+D", onToggleTheme, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: toggleThemeTitle,
description: toggleThemeDescription,
},
})
return null
}
@@ -0,0 +1,47 @@
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 { ThemeConfigCard } from "./theme-config-card"
import { ThemeModeField } from "./theme-mode-field"
export interface AppearanceSettingsProps {
className?: string
}
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 resolvedTheme = useResolvedTheme()
return (
<div className={cn("space-y-10", className)}>
<div className="flex flex-col gap-6 lg:flex-row">
<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"
/>
</div>
</div>
)
}
@@ -0,0 +1,33 @@
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,121 @@
import type { CSSProperties, ReactNode } from "react"
import { useMessage } from "@workspace/i18n"
import { cn } from "@workspace/ui/lib/utils"
import { themeColorMessages } from "../messages"
import {
accentColors,
baseColors,
getTheme,
type AccentColor,
type BaseColor,
} from "../theme"
export type ThemeColor = BaseColor | AccentColor
export interface ThemeColorSwatch<T extends ThemeColor = ThemeColor> {
dark: ThemeColorSwatchScheme
light: ThemeColorSwatchScheme
name: T
}
export interface ThemeColorSwatchScheme {
background: string
foreground: string
}
export function createThemeColorSwatches<T extends ThemeColor>(
colors: readonly T[]
): ThemeColorSwatch<T>[] {
return colors.map((name) => {
const theme = getTheme(name)!
return {
name,
light: {
background: theme.cssVars.light.primary,
foreground: theme.cssVars.light["primary-foreground"],
},
dark: {
background: theme.cssVars.dark.primary,
foreground: theme.cssVars.dark["primary-foreground"],
},
}
})
}
export const baseColorSwatches = createThemeColorSwatches(baseColors)
export const accentColorSwatches = createThemeColorSwatches(accentColors)
export interface ThemeColorPickerProps<T extends ThemeColor> {
className?: string
onValueChange: (color: T) => void
swatches: readonly ThemeColorSwatch<T>[]
title: ReactNode
value: T
}
export function ThemeColorPicker<T extends ThemeColor>({
className,
onValueChange,
swatches,
title,
value,
}: ThemeColorPickerProps<T>) {
return (
<fieldset className={cn("relative rounded-lg border p-4", className)}>
<legend className="absolute top-0 left-4 z-2 block -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground">
{title}
</legend>
<div className="flex flex-wrap items-center gap-2">
{swatches.map((swatch) => (
<ThemeColorSwatchButton
key={swatch.name}
swatch={swatch}
pressed={value === swatch.name}
onClick={() => onValueChange(swatch.name)}
/>
))}
</div>
</fieldset>
)
}
export interface ThemeColorSwatchButtonProps {
onClick: VoidFunction
pressed: boolean
swatch: ThemeColorSwatch
}
export function ThemeColorSwatchButton({
onClick,
pressed,
swatch,
}: ThemeColorSwatchButtonProps) {
const swatchTitle = useMessage(themeColorMessages[swatch.name])
const style = {
"--light-bg": swatch.light.background,
"--light-fg": swatch.light.foreground,
"--dark-bg": swatch.dark.background,
"--dark-fg": swatch.dark.foreground,
} as CSSProperties
return (
<button
type="button"
aria-label={swatchTitle}
aria-pressed={pressed}
className={cn(
"h-8 min-w-8 appearance-none truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
"border-(--light-bg) dark:border-(--dark-bg)",
"bg-(--light-bg) dark:bg-(--dark-bg)",
"text-(--light-fg) dark:text-(--dark-fg)",
"ring-0 aria-pressed:ring-2"
)}
style={style}
onClick={onClick}
>
{swatchTitle}
</button>
)
}
@@ -0,0 +1,82 @@
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"
import { appearanceMessages } from "../messages"
import type { ThemeColorConfig, ThemeScheme } from "../theme"
import {
accentColorSwatches,
baseColorSwatches,
ThemeColorPicker,
} from "./theme-color-picker"
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)}>
<header className="flex gap-4 border-b p-3">
{scheme === "light" ? (
<SunIcon className="size-5" />
) : (
<MoonIcon className="size-5" />
)}
<h2>
<LocalizedText message={titleMessage} />
</h2>
{active && (
<Badge className="ml-auto">
<LocalizedText message={appearanceMessages.active} />
</Badge>
)}
</header>
<div className="space-y-4 p-4">
<p>
<LocalizedText message={descriptionMessage} />
</p>
<ThemePreview compacted={compacted} config={config} scheme={scheme} />
<ThemeColorPicker
swatches={baseColorSwatches}
title={<LocalizedText message={appearanceMessages.baseColor} />}
value={config.baseColor}
onValueChange={(baseColor) =>
onConfigChange({ ...config, baseColor })
}
/>
<ThemeColorPicker
swatches={accentColorSwatches}
title={<LocalizedText message={appearanceMessages.primaryColor} />}
value={config.accentColor}
onValueChange={(accentColor) =>
onConfigChange({ ...config, accentColor })
}
/>
</div>
</section>
)
}
@@ -0,0 +1,84 @@
import { MonitorIcon, MoonIcon, SunIcon } from "@workspace/ui/components/icon"
import { useMessage } from "@workspace/i18n"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select"
import { cn } from "@workspace/ui/lib/utils"
import { appearanceMessages } from "../messages"
import type { ThemeMode } from "../preferences"
export interface ThemeModeFieldProps {
className?: string
onValueChange: (value: ThemeMode) => void
value: ThemeMode
}
export function ThemeModeIcon({ mode }: { mode: ThemeMode | null }) {
if (mode === "light") return <SunIcon />
if (mode === "dark") return <MoonIcon />
return <MonitorIcon />
}
export function ThemeModeField({
className,
onValueChange,
value,
}: ThemeModeFieldProps) {
const lightLabel = useMessage(appearanceMessages.lightMode)
const darkLabel = useMessage(appearanceMessages.darkMode)
const systemLabel = useMessage(appearanceMessages.followSystem)
const lightDescription = useMessage(appearanceMessages.lightModeDescription)
const darkDescription = useMessage(appearanceMessages.darkModeDescription)
const systemDescription = useMessage(
appearanceMessages.followSystemDescription
)
const title = useMessage(appearanceMessages.themeMode)
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
{ label: lightLabel, value: "light" },
{ label: darkLabel, value: "dark" },
{ label: systemLabel, value: "system" },
]
const description = {
light: lightDescription,
dark: darkDescription,
system: systemDescription,
}[value]
return (
<div className={cn("lg:flex-1", className)}>
<h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Select
items={modeItems}
value={value}
onValueChange={(mode) => {
if (mode) onValueChange(mode)
}}
>
<SelectTrigger className="mt-2 w-40">
<SelectValue>
{(selectedValue: ThemeMode | null) => (
<>
<ThemeModeIcon mode={selectedValue} />
{modeItems.find((item) => item.value === selectedValue)?.label}
</>
)}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{modeItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
<ThemeModeIcon mode={item.value} />
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,105 @@
import type { CSSProperties } from "react"
import {
getTheme,
getThemeCssVariables,
type ThemeColorConfig,
type ThemeScheme,
} from "../theme"
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) {
const themeVariables = getThemeCssVariables(scheme, config)
const baseTheme = getTheme(config.baseColor) ?? getTheme("neutral")!
const style = {
...Object.fromEntries(
Object.entries(themeVariables).map(([key, value]) => [`--${key}`, value])
),
"--preview-base": baseTheme.cssVars[scheme].primary,
} as CSSProperties
return (
<div className={cn(className, `ui:${scheme}`)} style={style}>
<div className="isolate h-52 max-w-103.5 rounded-lg bg-sidebar p-3 ring ring-border">
<div className="flex gap-2.5 [&>div]:bg-foreground/10">
<div className="size-5 rounded-sm" />
<div className="ml-auto size-5 rounded-sm" />
<div className="size-5 rounded-sm" />
<div className="size-5 rounded-sm" />
</div>
<div className="flex gap-3">
<div className="flex w-16 flex-col gap-1 py-5 [&>div]:bg-foreground/10">
<div className="h-2.5 w-full rounded-r-full" />
<div className="h-2.5 w-full rounded-r-full bg-primary!" />
<div className="h-2.5 w-full rounded-r-full" />
<div className="h-2.5 w-full rounded-r-full" />
<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="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" />
<div className="h-2.5 w-3.75 rounded-sm" />
<div className="h-2.5 w-5 rounded-sm bg-primary!" />
</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="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" />
<div className="h-8 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-9 w-2 rounded-t-sm bg-(--preview-base)/55" />
</div>
<div className="flex h-18 flex-col items-stretch justify-between gap-1 p-2">
<div className="flex items-center gap-1 opacity-55">
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="ml-auto h-1 w-6 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
</div>
<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-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-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>
<div className="flex items-center gap-1">
<div className="h-2 w-6 rounded-sm bg-primary" />
<div className="h-2 w-6 rounded-sm bg-primary/75" />
</div>
</div>
<div className="h-18" />
<div className="h-10 flex-1" />
<div className="h-10 flex-1" />
<div className="h-10 flex-1" />
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
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"
export interface ThemeToggleProps extends Omit<
ComponentProps<typeof Button>,
"children" | "onClick"
> {
onThemeChange: (theme: ThemeScheme) => void
theme: ThemeScheme
}
export function ThemeToggle({
className,
onThemeChange,
theme,
...props
}: ThemeToggleProps) {
const switchToDarkLabel = useMessage(appearanceMessages.switchToDark)
const switchToLightLabel = useMessage(appearanceMessages.switchToLight)
const nextTheme = theme === "dark" ? "light" : "dark"
return (
<Button
variant="ghost"
size="icon-lg"
aria-label={nextTheme === "dark" ? switchToDarkLabel : switchToLightLabel}
className={cn(
"rounded-lg [&_svg]:opacity-45 [&_svg]:transition-opacity hover:[&_svg]:opacity-100",
className
)}
{...props}
onClick={() => onThemeChange(nextTheme)}
>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</Button>
)
}
export type ThemeToggleButtonProps = Omit<
ThemeToggleProps,
"onThemeChange" | "theme"
>
export function ThemeToggleButton(props: ThemeToggleButtonProps) {
const [, setThemeMode] = usePreference("theme-mode")
const resolvedTheme = useResolvedTheme()
return (
<ThemeToggle
{...props}
theme={resolvedTheme}
onThemeChange={setThemeMode}
/>
)
}
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
import { PreferencesProvider, usePreference } from "@workspace/preferences"
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest"
import { appearancePreferenceEffect } from "./effects"
import { defaultAppearancePreferences } from "./preferences"
import { THEME_STYLE_ELEMENT_ID } from "./theme"
beforeAll(() => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
addEventListener: vi.fn(),
matches: false,
media: query,
removeEventListener: vi.fn(),
}))
)
vi.stubGlobal(
"requestAnimationFrame",
vi.fn(() => 1)
)
vi.stubGlobal("cancelAnimationFrame", vi.fn())
})
beforeEach(() => {
const style = document.createElement("style")
style.id = THEME_STYLE_ELEMENT_ID
document.head.append(style)
})
afterEach(() => {
cleanup()
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
document.documentElement.className = ""
document.documentElement.style.colorScheme = ""
})
function PreferenceProbe() {
const [themeMode, setThemeMode] = usePreference("theme-mode")
return (
<button type="button" onClick={() => setThemeMode("dark")}>
{themeMode}
</button>
)
}
describe("appearancePreferenceEffect", () => {
it("integrates with the shared PreferencesProvider", () => {
const onPreferenceChange = vi.fn()
render(
<PreferencesProvider
effects={[appearancePreferenceEffect]}
initialPreferences={defaultAppearancePreferences}
onPreferenceChange={onPreferenceChange}
>
<PreferenceProbe />
</PreferencesProvider>
)
expect(document.documentElement.classList.contains("ui:light")).toBe(true)
expect(
document.getElementById(THEME_STYLE_ELEMENT_ID)?.textContent
).toContain("--background:")
fireEvent.click(screen.getByRole("button"))
expect(document.documentElement.classList.contains("ui:dark")).toBe(true)
expect(onPreferenceChange).toHaveBeenCalledWith({
key: "theme-mode",
value: "dark",
})
})
})
+114
View File
@@ -0,0 +1,114 @@
import {
type PreferenceEffect,
type PreferenceEffectCallback,
} from "@workspace/preferences"
import type { AppearancePreferences } from "./preferences"
import {
buildThemeStyleSheet,
THEME_STYLE_ELEMENT_ID,
type ThemeScheme,
} from "./theme"
const systemThemeQuery = "(prefers-color-scheme: dark)"
const themePreferenceKeys = [
"theme-mode",
"theme-light",
"theme-dark",
] as const satisfies readonly (keyof AppearancePreferences)[]
let restoreThemeTransitionFrame: number | undefined
function disableThemeTransitionsTemporarily() {
if (typeof document === "undefined") return
const root = document.documentElement
root.classList.add("ui:theme-changing")
if (restoreThemeTransitionFrame !== undefined) {
cancelAnimationFrame(restoreThemeTransitionFrame)
}
restoreThemeTransitionFrame = requestAnimationFrame(() => {
restoreThemeTransitionFrame = requestAnimationFrame(() => {
root.classList.remove("ui:theme-changing")
restoreThemeTransitionFrame = undefined
})
})
}
function getSystemTheme(): ThemeScheme {
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
}
export function applyAppearancePreferences(preferences: AppearancePreferences) {
if (typeof document === "undefined") return
const root = document.documentElement
const resolvedTheme =
preferences["theme-mode"] === "system"
? getSystemTheme()
: preferences["theme-mode"]
const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID)
if (themeStyle) {
themeStyle.textContent = buildThemeStyleSheet(
preferences["theme-light"],
preferences["theme-dark"]
)
}
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.style.colorScheme = resolvedTheme
}
/** Applies appearance preferences before the browser paints the updated UI. */
export const applyAppearancePreferenceEffect: PreferenceEffectCallback = ({
previousPreferences,
preferences,
}) => {
const appearancePreferences = preferences as AppearancePreferences
const previousAppearancePreferences =
previousPreferences as AppearancePreferences
if (
themePreferenceKeys.some(
(key) =>
!Object.is(
previousAppearancePreferences[key],
appearancePreferences[key]
)
)
) {
disableThemeTransitionsTemporarily()
}
applyAppearancePreferences(appearancePreferences)
}
/** Keeps system-mode themes synchronized with the operating system setting. */
export const subscribeSystemThemePreferenceEffect: PreferenceEffectCallback = ({
preferences,
store,
}) => {
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)
}
/** Register this effect with the application's shared `PreferencesProvider`. */
export const appearancePreferenceEffect: PreferenceEffect = {
layoutEffect: applyAppearancePreferenceEffect,
effect: subscribeSystemThemePreferenceEffect,
}
+80
View File
@@ -0,0 +1,80 @@
export {
AppearanceController,
type AppearanceControllerProps,
} from "./components/appearance-controller"
export {
AppearanceDialog,
AppearanceDialogContent,
AppearanceDialogHeader,
AppearanceDialogTrigger,
AppearanceTrigger,
appearanceDialogHandle,
createAppearanceDialogHandle,
type AppearanceDialogContentProps,
type AppearanceDialogHandle,
type AppearanceDialogProps,
type AppearanceDialogTriggerProps,
} from "./components/appearance-dialog"
export {
AppearanceHotkeys,
type AppearanceHotkeysProps,
} from "./components/appearance-hotkeys"
export {
AppearanceSettings,
type AppearanceSettingsProps,
} from "./components/appearance-settings"
export {
CompactLayoutField,
type CompactLayoutFieldProps,
} from "./components/compact-layout-field"
export {
ThemeColorPicker,
ThemeColorSwatchButton,
accentColorSwatches,
baseColorSwatches,
createThemeColorSwatches,
type ThemeColor,
type ThemeColorPickerProps,
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 {
ThemePreview,
type ThemePreviewProps,
} from "./components/theme-preview"
export {
ThemeToggle,
ThemeToggleButton,
type ThemeToggleButtonProps,
type ThemeToggleProps,
} from "./components/theme-toggle-button"
export {
applyAppearancePreferenceEffect,
applyAppearancePreferences,
appearancePreferenceEffect,
subscribeSystemThemePreferenceEffect,
} from "./effects"
export {
appearancePreferenceDefinitions,
defaultAppearancePreferences,
defaultThemeColorConfig,
isAppearancePreferenceUpdate,
isThemeColorConfig,
type AppearancePreferenceKey,
type AppearancePreferences,
type AppearancePreferenceUpdate,
type AppearancePreferenceValue,
type ThemeMode,
} from "./preferences"
export * from "./theme"
export { useResolvedTheme } from "./use-resolved-theme"
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest"
import { appearanceMessages, themeColorMessages } from "../messages"
import { appearanceCatalogLocales } from "./catalogs"
import { messages as enUS } from "./en-US"
import { messages as zhHans } from "./zh-Hans"
describe("appearance locale catalogs", () => {
it("ships every message in every built-in locale", () => {
const ids = [
...Object.values(appearanceMessages),
...Object.values(themeColorMessages),
].map((descriptor) => descriptor.id)
const catalogs = { "en-US": enUS, "zh-Hans": zhHans }
expect(appearanceCatalogLocales).toEqual(["en-US", "zh-Hans"])
for (const locale of appearanceCatalogLocales) {
expect(Object.keys(catalogs[locale])).toHaveLength(ids.length)
expect(ids.every((id) => id in catalogs[locale])).toBe(true)
}
})
})
@@ -0,0 +1,19 @@
import type { appearanceMessages, themeColorMessages } from "../messages"
export const appearanceCatalogLocales = ["en-US", "zh-Hans"] as const
interface MessageDescriptorMap {
readonly [key: string]: {
readonly id: string
}
}
type MessageId<T extends MessageDescriptorMap> = T[keyof T]["id"]
export type AppearanceCatalogLocale = (typeof appearanceCatalogLocales)[number]
export type AppearanceMessageCatalog = Readonly<
Record<
MessageId<typeof appearanceMessages> | MessageId<typeof themeColorMessages>,
string
>
>
+25
View File
@@ -0,0 +1,25 @@
import type { MessageDescriptor } from "@workspace/i18n"
import { appearanceMessages, themeColorMessages } from "../messages.ts"
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "en-US"
export const languageTag = "en-US"
function createSourceCatalog(
...groups: readonly Record<string, MessageDescriptor>[]
): AppearanceMessageCatalog {
return Object.fromEntries(
groups.flatMap((group) =>
Object.values(group).map((descriptor) => [
descriptor.id,
descriptor.message,
])
)
) as AppearanceMessageCatalog
}
export const messages = createSourceCatalog(
appearanceMessages,
themeColorMessages
) satisfies AppearanceMessageCatalog
@@ -0,0 +1,54 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"appearance.active": "使用中",
"appearance.baseColor": "基础颜色",
"appearance.color.amber": "琥珀色",
"appearance.color.blue": "蓝色",
"appearance.color.cyan": "青色",
"appearance.color.emerald": "翠绿色",
"appearance.color.fuchsia": "紫红色",
"appearance.color.green": "绿色",
"appearance.color.indigo": "靛蓝色",
"appearance.color.lime": "青柠色",
"appearance.color.mauve": "紫灰",
"appearance.color.mist": "雾灰",
"appearance.color.neutral": "中性灰",
"appearance.color.olive": "橄榄灰",
"appearance.color.orange": "橙色",
"appearance.color.pink": "粉色",
"appearance.color.purple": "紫色",
"appearance.color.red": "红色",
"appearance.color.rose": "玫红色",
"appearance.color.sky": "天蓝色",
"appearance.color.stone": "石灰",
"appearance.color.taupe": "灰褐",
"appearance.color.teal": "蓝绿色",
"appearance.color.violet": "紫罗兰色",
"appearance.color.yellow": "黄色",
"appearance.color.zinc": "锌灰",
"appearance.command.description": "打开主题与布局偏好",
"appearance.compact.description": "启用后,页面主内容将使用紧凑宽度",
"appearance.compact.title": "紧凑布局",
"appearance.description": "主题与布局偏好",
"appearance.mode.dark": "深色",
"appearance.mode.dark.description": "界面将始终使用深色主题",
"appearance.mode.light": "浅色",
"appearance.mode.light.description": "界面将始终使用浅色主题",
"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.title": "界面外观",
"appearance.toggleTheme": "切换主题",
"appearance.toggleTheme.dark": "切换为深色模式",
"appearance.toggleTheme.description": "在浅色与深色主题之间切换",
"appearance.toggleTheme.light": "切换为浅色模式",
} as const satisfies AppearanceMessageCatalog
+195
View File
@@ -0,0 +1,195 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const appearanceMessages = {
active: /* i18n */ {
id: "appearance.active",
message: "Active",
},
appearance: /* i18n */ {
id: "appearance.title",
message: "Appearance",
},
appearanceCommandDescription: /* i18n */ {
id: "appearance.command.description",
message: "Open theme and layout 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",
},
compactLayout: /* i18n */ {
id: "appearance.compact.title",
message: "Compact layout",
},
darkMode: /* i18n */ {
id: "appearance.mode.dark",
message: "Dark",
},
darkModeDescription: /* i18n */ {
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",
},
followSystemDescription: /* i18n */ {
id: "appearance.mode.system.description",
message: "The interface theme will follow the system appearance",
},
lightMode: /* i18n */ {
id: "appearance.mode.light",
message: "Light",
},
lightModeDescription: /* i18n */ {
id: "appearance.mode.light.description",
message: "The interface will always use the light theme",
},
lightTheme: /* i18n */ {
id: "appearance.theme.light",
message: "Light theme",
},
lightThemeDescription: /* i18n */ {
id: "appearance.theme.light.description",
message: "This theme is used when the system is in light mode",
},
primaryColor: /* i18n */ {
id: "appearance.primaryColor",
message: "Primary color",
},
themeAndLayoutPreferences: /* i18n */ {
id: "appearance.description",
message: "Theme and layout preferences",
},
themeMode: /* i18n */ {
id: "appearance.mode.title",
message: "Theme mode",
},
toggleTheme: /* i18n */ {
id: "appearance.toggleTheme",
message: "Toggle theme",
},
toggleThemeDescription: /* i18n */ {
id: "appearance.toggleTheme.description",
message: "Switch between light and dark themes",
},
switchToDark: /* i18n */ {
id: "appearance.toggleTheme.dark",
message: "Switch to dark mode",
},
switchToLight: /* i18n */ {
id: "appearance.toggleTheme.light",
message: "Switch to light mode",
},
} as const satisfies Record<string, MessageDescriptor>
export const themeColorMessages = {
amber: /* i18n */ {
id: "appearance.color.amber",
message: "Amber",
},
blue: /* i18n */ {
id: "appearance.color.blue",
message: "Blue",
},
cyan: /* i18n */ {
id: "appearance.color.cyan",
message: "Cyan",
},
emerald: /* i18n */ {
id: "appearance.color.emerald",
message: "Emerald",
},
fuchsia: /* i18n */ {
id: "appearance.color.fuchsia",
message: "Fuchsia",
},
green: /* i18n */ {
id: "appearance.color.green",
message: "Green",
},
indigo: /* i18n */ {
id: "appearance.color.indigo",
message: "Indigo",
},
lime: /* i18n */ {
id: "appearance.color.lime",
message: "Lime",
},
mauve: /* i18n */ {
id: "appearance.color.mauve",
message: "Mauve",
},
mist: /* i18n */ {
id: "appearance.color.mist",
message: "Mist",
},
neutral: /* i18n */ {
id: "appearance.color.neutral",
message: "Neutral",
},
olive: /* i18n */ {
id: "appearance.color.olive",
message: "Olive",
},
orange: /* i18n */ {
id: "appearance.color.orange",
message: "Orange",
},
pink: /* i18n */ {
id: "appearance.color.pink",
message: "Pink",
},
purple: /* i18n */ {
id: "appearance.color.purple",
message: "Purple",
},
red: /* i18n */ {
id: "appearance.color.red",
message: "Red",
},
rose: /* i18n */ {
id: "appearance.color.rose",
message: "Rose",
},
sky: /* i18n */ {
id: "appearance.color.sky",
message: "Sky",
},
stone: /* i18n */ {
id: "appearance.color.stone",
message: "Stone",
},
taupe: /* i18n */ {
id: "appearance.color.taupe",
message: "Taupe",
},
teal: /* i18n */ {
id: "appearance.color.teal",
message: "Teal",
},
violet: /* i18n */ {
id: "appearance.color.violet",
message: "Violet",
},
yellow: /* i18n */ {
id: "appearance.color.yellow",
message: "Yellow",
},
zinc: /* i18n */ {
id: "appearance.color.zinc",
message: "Zinc",
},
} as const satisfies Record<string, MessageDescriptor>
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest"
import {
appearancePreferenceDefinitions,
defaultAppearancePreferences,
isAppearancePreferenceUpdate,
} from "./preferences"
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" },
"theme-mode": "system",
})
})
it("serializes and validates preference updates", () => {
const compacted = appearancePreferenceDefinitions["main-compacted"]
const theme = appearancePreferenceDefinitions["theme-light"]
expect(compacted.parse(compacted.serialize(true))).toBe(true)
expect(
theme.parse(
theme.serialize({
accentColor: "violet",
baseColor: "zinc",
})
)
).toEqual({ accentColor: "violet", baseColor: "zinc" })
expect(
isAppearancePreferenceUpdate({ key: "theme-mode", value: "dark" })
).toBe(true)
expect(
isAppearancePreferenceUpdate({ key: "theme-mode", value: "unknown" })
).toBe(false)
})
})
+115
View File
@@ -0,0 +1,115 @@
import {
createDefaultPreferences,
createPreferenceUpdateGuard,
type PreferenceDefinitions,
type PreferenceUpdateFor,
} from "@workspace/preferences"
import { accentColors, baseColors, type ThemeColorConfig } from "./theme"
export type ThemeMode = "light" | "dark" | "system"
export interface AppearancePreferences {
"main-compacted": boolean
"sidebar-collapsed": boolean
"theme-mode": ThemeMode
"theme-light": ThemeColorConfig
"theme-dark": ThemeColorConfig
}
declare module "@workspace/preferences" {
interface PreferencesCustom extends AppearancePreferences {}
}
export type AppearancePreferenceKey = keyof AppearancePreferences
export type AppearancePreferenceValue<Key extends AppearancePreferenceKey> =
AppearancePreferences[Key]
export type AppearancePreferenceUpdate =
PreferenceUpdateFor<AppearancePreferences>
export const defaultThemeColorConfig: ThemeColorConfig = {
accentColor: "blue",
baseColor: "neutral",
}
export function isThemeColorConfig(value: unknown): value is ThemeColorConfig {
if (!value || typeof value !== "object") return false
const config = value as {
accentColor?: unknown
baseColor?: unknown
}
return (
typeof config.baseColor === "string" &&
baseColors.some((color) => color === config.baseColor) &&
typeof config.accentColor === "string" &&
accentColors.some((color) => color === config.accentColor)
)
}
function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean"
}
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"),
},
"sidebar-collapsed": {
cookie: "ui-sidebar-collapsed",
defaultValue: false,
is: isBoolean,
parse: (value) => value === "true" || value === "1",
serialize: (value) => (value ? "1" : "0"),
},
"theme-mode": {
cookie: "ui-theme",
defaultValue: "system",
is: isThemeMode,
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(
appearancePreferenceDefinitions
)
export const isAppearancePreferenceUpdate = createPreferenceUpdateGuard(
appearancePreferenceDefinitions
)
@@ -0,0 +1 @@
@source "../";
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest"
import {
accentColors,
baseColors,
buildThemeStyleSheet,
getTheme,
getThemeCssVariables,
themes,
} from "./theme"
describe("appearance themes", () => {
it("defines every selectable base and accent color exactly once", () => {
const selectableColors = [...baseColors, ...accentColors]
expect(themes.map((theme) => theme.name)).toEqual(selectableColors)
expect(new Set(selectableColors).size).toBe(selectableColors.length)
expect(new Set(themes.map((theme) => theme.name)).size).toBe(themes.length)
})
it("combines base and accent palettes", () => {
const variables = getThemeCssVariables("light", {
accentColor: "rose",
baseColor: "stone",
})
expect(variables.background).toBe(
getTheme("stone")?.cssVars.light.background
)
expect(variables.primary).toBe(getTheme("rose")?.cssVars.light.primary)
})
it("builds explicit and system color-scheme rules", () => {
const styleSheet = buildThemeStyleSheet(
{ accentColor: "blue", baseColor: "neutral" },
{ accentColor: "violet", baseColor: "zinc" }
)
expect(styleSheet).toContain(":root,.ui\\:light{")
expect(styleSheet).toContain(".ui\\:dark{")
expect(styleSheet).toContain("@media (prefers-color-scheme:dark)")
expect(styleSheet).toContain("--background:")
expect(styleSheet).toContain("--primary:")
})
})
+58
View File
@@ -0,0 +1,58 @@
import {
themes,
type AccentColor,
type BaseColor,
type ThemeColorConfig,
type ThemeScheme,
} from "./themes"
export {
accentColors,
baseColors,
themes,
type AccentColor,
type BaseColor,
type Theme,
type ThemeColorConfig,
type ThemeScheme,
} from "./themes"
export const THEME_STYLE_ELEMENT_ID = "ui-theme-variables"
export const getTheme = (name: BaseColor | AccentColor) =>
themes.find((theme) => theme.name === name)
export function getThemeCssVariables(
scheme: ThemeScheme,
config: ThemeColorConfig
) {
const base = getTheme(config.baseColor) ?? getTheme("neutral")!
const accent = getTheme(config.accentColor) ?? getTheme("blue")!
return {
...base.cssVars[scheme],
...accent.cssVars[scheme],
}
}
function serializeThemeVariables(theme: Record<string, string>): string {
return Object.entries(theme)
.map(([key, value]) => `--${key}:${value}`)
.join(";")
}
export function buildThemeStyleSheet(
lightConfig: ThemeColorConfig,
darkConfig: ThemeColorConfig
) {
const light = serializeThemeVariables(
getThemeCssVariables("light", lightConfig)
)
const dark = serializeThemeVariables(getThemeCssVariables("dark", darkConfig))
return [
`:root,.ui\\:light{${light}}`,
`.ui\\:dark{${dark}}`,
`@media (prefers-color-scheme:dark){:root:not(.ui\\:light):not(.ui\\:dark){${dark}}}`,
].join("")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
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
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"paths": {
"@workspace/appearance": ["./src/index.ts"],
"@workspace/appearance/*": ["./src/*"],
"@workspace/i18n": ["../i18n/src/index.ts"],
"@workspace/preferences": ["../preferences/src/index.tsx"],
"@workspace/ui/*": ["../ui/src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}