feat(appearance): add composable theme package
This commit is contained in:
@@ -83,6 +83,27 @@
|
|||||||
"wrangler": "^4.135.0",
|
"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": {
|
"packages/blocks": {
|
||||||
"name": "@workspace/blocks",
|
"name": "@workspace/blocks",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -999,6 +1020,8 @@
|
|||||||
|
|
||||||
"@vue/shared": ["@vue/shared@3.5.40", "", {}, "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg=="],
|
"@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/blocks": ["@workspace/blocks@workspace:packages/blocks"],
|
||||||
|
|
||||||
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
|
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
>
|
||||||
|
>
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 "../";
|
||||||
@@ -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:")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user