diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f1086e9c29..3a4107b194 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -50,6 +50,7 @@ syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const SET_APPEARANCE_CHANNEL = "desktop:set-appearance"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -1160,6 +1161,16 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); + ipcMain.removeHandler(SET_APPEARANCE_CHANNEL); + ipcMain.handle(SET_APPEARANCE_CHANNEL, async (_event, raw: unknown) => { + if (typeof raw !== "object" || raw === null) return; + const appearance = raw as Record; + const mode = getSafeTheme(appearance.mode); + if (!mode) return; + nativeTheme.themeSource = mode; + // themeId is available for future Electron shell styling work. + }); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle( CONTEXT_MENU_CHANNEL, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3d59db1714..9bf5b12590 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,7 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const SET_APPEARANCE_CHANNEL = "desktop:set-appearance"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -22,6 +23,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), + setAppearance: (appearance) => ipcRenderer.invoke(SET_APPEARANCE_CHANNEL, appearance), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index f26fece246..6565e6b4e0 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -179,4 +179,91 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("persists custom themes as part of server-authoritative appearance settings", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const next = yield* serverSettings.updateSettings({ + activeLightThemeId: "custom-codex-light", + activeDarkThemeId: "custom-codex-dark", + customThemes: [ + { + id: "custom-codex-light", + name: "Custom Codex Light", + version: 1, + origin: "custom", + mode: "light", + radius: "0.75rem", + fontSize: "15px", + accent: "#0169cc", + background: "#ffffff", + foreground: "#0d0d0d", + uiFontFamily: '"IBM Plex Sans", sans-serif', + codeFontFamily: '"IBM Plex Mono", monospace', + contrast: 46, + }, + { + id: "custom-codex-dark", + name: "Custom Codex Dark", + version: 1, + origin: "custom", + mode: "dark", + radius: "0.75rem", + fontSize: "15px", + accent: "#0169cc", + background: "#111111", + foreground: "#fcfcfc", + uiFontFamily: '"IBM Plex Sans", sans-serif', + codeFontFamily: '"IBM Plex Mono", monospace', + contrast: 41, + }, + ], + }); + + assert.equal(next.activeLightThemeId, "custom-codex-light"); + assert.equal(next.activeDarkThemeId, "custom-codex-dark"); + assert.equal(next.customThemes.length, 2); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.deepEqual(JSON.parse(raw), { + activeLightThemeId: "custom-codex-light", + activeDarkThemeId: "custom-codex-dark", + customThemes: [ + { + id: "custom-codex-light", + name: "Custom Codex Light", + version: 1, + origin: "custom", + mode: "light", + radius: "0.75rem", + fontSize: "15px", + accent: "#0169cc", + background: "#ffffff", + foreground: "#0d0d0d", + uiFontFamily: '"IBM Plex Sans", sans-serif', + codeFontFamily: '"IBM Plex Mono", monospace', + contrast: 46, + }, + { + id: "custom-codex-dark", + name: "Custom Codex Dark", + version: 1, + origin: "custom", + mode: "dark", + radius: "0.75rem", + fontSize: "15px", + accent: "#0169cc", + background: "#111111", + foreground: "#fcfcfc", + uiFontFamily: '"IBM Plex Sans", sans-serif', + codeFontFamily: '"IBM Plex Mono", monospace', + contrast: 41, + }, + ], + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b364a8e3a1..6fe940fe84 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -20,7 +20,7 @@ import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; -import { useTheme } from "../hooks/useTheme"; +import { useAppearance } from "../hooks/useAppearance"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; @@ -236,7 +236,7 @@ function SuspenseShikiCodeBlock({ } function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { - const { resolvedTheme } = useTheme(); + const { resolvedTheme } = useAppearance(); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownComponents = useMemo( () => ({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..056d1ed511 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -79,7 +79,7 @@ import { type TurnDiffSummary, } from "../types"; import { basenameOfPath } from "../vscode-icons"; -import { useTheme } from "../hooks/useTheme"; +import { useAppearance } from "../hooks/useAppearance"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -262,7 +262,7 @@ export default function ChatView({ threadId }: ChatViewProps) { strict: false, select: (params) => parseDiffRouteSearch(params), }); - const { resolvedTheme } = useTheme(); + const { resolvedTheme } = useAppearance(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index fadb8cb69d..50cc93492e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -25,7 +25,7 @@ import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; -import { useTheme } from "../hooks/useTheme"; +import { useAppearance } from "../hooks/useAppearance"; import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -55,18 +55,20 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); - --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); + --diffs-addition-color-override: var(--diff-addition); + --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--diff-addition)); + --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--diff-addition)); + --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--diff-addition)); + --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--diff-addition)); - --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); - --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); + --diffs-deletion-color-override: var(--diff-deletion); + --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--diff-deletion)); + --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--diff-deletion)); + --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--diff-deletion)); --diffs-bg-deletion-emphasis-override: color-mix( in srgb, var(--background) 80%, - var(--destructive) + var(--diff-deletion) ); background-color: var(--diffs-bg) !important; @@ -165,7 +167,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); - const { resolvedTheme } = useTheme(); + const { resolvedTheme } = useAppearance(); const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 5babd4248a..eab5ed5454 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -1,7 +1,7 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import { useEffect, useMemo, type ReactNode } from "react"; -import { useTheme } from "../hooks/useTheme"; +import { useAppearance } from "../hooks/useAppearance"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { @@ -29,7 +29,7 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { } export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { - const { resolvedTheme } = useTheme(); + const { resolvedTheme } = useAppearance(); const diffThemeName = resolveDiffThemeName(resolvedTheme); const workerPoolSize = useMemo(() => { const cores = diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1593a151da..32669e9329 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -970,9 +970,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -983,11 +983,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 5d6ec94a50..19f162abe3 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -60,6 +60,10 @@ function createBaseServerConfig(): ServerConfig { enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + colorMode: "system" as const, + activeLightThemeId: "t3code-light", + activeDarkThemeId: "t3code-dark", + customThemes: [], providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 2dda06fd9d..f25450463a 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -13,9 +13,9 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { return ( <> {showParentheses && (} - +{additions} + +{additions} / - -{deletions} + -{deletions} {showParentheses && )} ); diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx new file mode 100644 index 0000000000..1faaf5a3bd --- /dev/null +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -0,0 +1,677 @@ +import { + ChevronDownIcon, + CopyIcon, + MonitorIcon, + MoonIcon, + RotateCcwIcon, + SunIcon, + Trash2Icon, + UploadIcon, +} from "lucide-react"; +import { Schema, SchemaIssue } from "effect"; +import { type ChangeEvent, useCallback, useMemo, useRef, useState } from "react"; +import { + ThemeDocumentSchema, + type ColorMode, + type ThemeDerivedOverrides, + type ThemeDocument, + type ThemeMode, +} from "@t3tools/contracts"; +import { + BUILTIN_THEME_PRESETS, + canonicalizeThemeDocument, + getDefaultThemeId, + getThemeDocumentsForMode, + serializeThemeDocument, +} from "@t3tools/shared/appearance/registry"; +import { cn } from "../../lib/utils"; +import { useAppearance } from "../../hooks/useAppearance"; +import { useUpdateSettings } from "../../hooks/useSettings"; +import { toastManager } from "../ui/toast"; +import { Button } from "../ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; +import { Input } from "../ui/input"; +import { SettingsPageContainer, SettingsSection } from "./SettingsPanels"; +import { + createDuplicateTheme, + normalizeImportedThemeDocument, + replaceCustomTheme, + updateThemeDocument, +} from "./appearance.logic"; + +const COLOR_MODE_OPTIONS = [ + { value: "system", label: "System", icon: MonitorIcon }, + { value: "light", label: "Light", icon: SunIcon }, + { value: "dark", label: "Dark", icon: MoonIcon }, +] as const satisfies ReadonlyArray<{ + value: ColorMode; + label: string; + icon: typeof MonitorIcon; +}>; + +const MODE_COPY = { + light: { + label: "Light Theme", + modeLabel: "Light", + importTitle: "Import Light Theme", + emptyDescription: "Imported or duplicated light theme.", + }, + dark: { + label: "Dark Theme", + modeLabel: "Dark", + importTitle: "Import Dark Theme", + emptyDescription: "Imported or duplicated dark theme.", + }, +} as const satisfies Record< + ThemeMode, + { + label: string; + modeLabel: string; + importTitle: string; + emptyDescription: string; + } +>; + +const OVERRIDE_FIELDS: ReadonlyArray<{ + key: keyof ThemeDerivedOverrides; + label: string; + placeholder: string; +}> = [ + { key: "background", label: "Background", placeholder: "Auto" }, + { key: "foreground", label: "Foreground", placeholder: "Auto" }, + { key: "card", label: "Card", placeholder: "Auto" }, + { key: "cardForeground", label: "Card text", placeholder: "Auto" }, + { key: "popover", label: "Popover", placeholder: "Auto" }, + { key: "popoverForeground", label: "Popover text", placeholder: "Auto" }, + { key: "primary", label: "Primary", placeholder: "Auto" }, + { key: "primaryForeground", label: "Primary text", placeholder: "Auto" }, + { key: "secondary", label: "Secondary", placeholder: "Auto" }, + { key: "secondaryForeground", label: "Secondary text", placeholder: "Auto" }, + { key: "muted", label: "Muted", placeholder: "Auto" }, + { key: "mutedForeground", label: "Muted text", placeholder: "Auto" }, + { key: "accentSurface", label: "Accent surface", placeholder: "Auto" }, + { key: "accentForeground", label: "Accent text", placeholder: "Auto" }, + { key: "border", label: "Border", placeholder: "Auto" }, + { key: "input", label: "Input", placeholder: "Auto" }, + { key: "ring", label: "Ring", placeholder: "Auto" }, + { key: "destructive", label: "Destructive", placeholder: "Auto" }, + { key: "destructiveForeground", label: "Destructive text", placeholder: "Auto" }, + { key: "info", label: "Info", placeholder: "Auto" }, + { key: "infoForeground", label: "Info text", placeholder: "Auto" }, + { key: "success", label: "Success", placeholder: "Auto" }, + { key: "successForeground", label: "Success text", placeholder: "Auto" }, + { key: "warning", label: "Warning", placeholder: "Auto" }, + { key: "warningForeground", label: "Warning text", placeholder: "Auto" }, + { key: "diffAddition", label: "Diff addition", placeholder: "Auto" }, + { key: "diffDeletion", label: "Diff deletion", placeholder: "Auto" }, + { key: "sidebar", label: "Sidebar", placeholder: "Auto" }, + { key: "sidebarForeground", label: "Sidebar text", placeholder: "Auto" }, + { key: "sidebarAccent", label: "Sidebar accent", placeholder: "Auto" }, + { key: "sidebarAccentForeground", label: "Sidebar accent text", placeholder: "Auto" }, + { key: "sidebarBorder", label: "Sidebar border", placeholder: "Auto" }, +]; + +function ThemeCard({ + theme, + isSelected, + onSelect, +}: { + theme: ThemeDocument; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function ColorField({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function ThemeEditor({ + theme, + onChange, +}: { + theme: ThemeDocument; + onChange: (updater: (theme: ThemeDocument) => ThemeDocument) => void; +}) { + return ( +
+ {/* Colors row */} +
+ onChange((currentTheme) => ({ ...currentTheme, accent }))} + /> + onChange((currentTheme) => ({ ...currentTheme, background }))} + /> + onChange((currentTheme) => ({ ...currentTheme, foreground }))} + /> +
+ + {/* Scale + typography */} +
+ + + + +
+ + {/* Contrast */} + + + {/* Advanced overrides */} + + + + + Advanced token overrides + + + +
+ {OVERRIDE_FIELDS.map((field) => ( + + ))} +
+
+
+
+ ); +} + +export function AppearanceSettingsPanel() { + const { colorMode, activeLightTheme, activeDarkTheme, customThemes, setColorMode, setThemeId } = + useAppearance(); + const { updateSettings } = useUpdateSettings(); + const lightImportInputRef = useRef(null); + const darkImportInputRef = useRef(null); + const [importErrors, setImportErrors] = useState>({ + light: null, + dark: null, + }); + + const themeLibraryByMode = useMemo( + () => ({ + light: getThemeDocumentsForMode("light", customThemes).map((theme) => ({ + theme, + description: + BUILTIN_THEME_PRESETS.find((preset) => preset.theme.id === theme.id)?.description ?? + MODE_COPY.light.emptyDescription, + })), + dark: getThemeDocumentsForMode("dark", customThemes).map((theme) => ({ + theme, + description: + BUILTIN_THEME_PRESETS.find((preset) => preset.theme.id === theme.id)?.description ?? + MODE_COPY.dark.emptyDescription, + })), + }), + [customThemes], + ); + + const activeThemeByMode = useMemo( + () => + ({ + light: activeLightTheme, + dark: activeDarkTheme, + }) as const satisfies Record, + [activeLightTheme, activeDarkTheme], + ); + + const updateModeSelection = useCallback( + (mode: ThemeMode, themeId: string) => { + setThemeId(mode, themeId); + }, + [setThemeId], + ); + + const saveTheme = useCallback( + (themeDocument: ThemeDocument) => { + updateSettings({ + ...(themeDocument.mode === "dark" + ? { activeDarkThemeId: themeDocument.id } + : { activeLightThemeId: themeDocument.id }), + customThemes: replaceCustomTheme( + customThemes, + canonicalizeThemeDocument(themeDocument, "custom"), + ), + }); + }, + [customThemes, updateSettings], + ); + + const getEditableTheme = useCallback( + (mode: ThemeMode, announceDuplicate: boolean): ThemeDocument => { + const activeTheme = activeThemeByMode[mode]; + if (activeTheme.origin === "custom") { + return activeTheme; + } + + const duplicate = createDuplicateTheme(activeTheme, customThemes); + if (announceDuplicate) { + toastManager.add({ + type: "info", + title: "Created editable copy", + description: `${activeTheme.name} was duplicated into ${duplicate.name}.`, + }); + } + return duplicate; + }, + [activeThemeByMode, customThemes], + ); + + const handleThemeChange = useCallback( + (mode: ThemeMode, updater: (themeDocument: ThemeDocument) => ThemeDocument) => { + const baseTheme = getEditableTheme(mode, true); + const nextTheme = updateThemeDocument(baseTheme, updater); + saveTheme(nextTheme); + }, + [getEditableTheme, saveTheme], + ); + + const handleDuplicateTheme = useCallback( + (mode: ThemeMode) => { + const duplicate = createDuplicateTheme(activeThemeByMode[mode], customThemes); + updateSettings({ + ...(mode === "dark" + ? { activeDarkThemeId: duplicate.id } + : { activeLightThemeId: duplicate.id }), + customThemes: [...customThemes, duplicate], + }); + toastManager.add({ + type: "success", + title: "Theme duplicated", + description: `${duplicate.name} is now selected and editable.`, + }); + }, + [activeThemeByMode, customThemes, updateSettings], + ); + + const handleDeleteTheme = useCallback( + (mode: ThemeMode) => { + const activeTheme = activeThemeByMode[mode]; + if (activeTheme.origin !== "custom") return; + + updateSettings({ + ...(mode === "dark" + ? { activeDarkThemeId: getDefaultThemeId("dark") } + : { activeLightThemeId: getDefaultThemeId("light") }), + customThemes: customThemes.filter((theme) => theme.id !== activeTheme.id), + }); + toastManager.add({ + type: "success", + title: "Custom theme deleted", + description: `${activeTheme.name} was removed from your library.`, + }); + }, + [activeThemeByMode, customThemes, updateSettings], + ); + + const handleResetCurrent = useCallback( + (mode: ThemeMode) => { + const activeTheme = activeThemeByMode[mode]; + if (activeTheme.origin === "builtin") { + updateModeSelection(mode, getDefaultThemeId(mode)); + return; + } + + const builtinDefault = BUILTIN_THEME_PRESETS.find( + (preset) => preset.theme.id === getDefaultThemeId(mode), + )!.theme; + const resetTheme = canonicalizeThemeDocument( + { + ...builtinDefault, + id: activeTheme.id, + name: activeTheme.name, + origin: "custom", + }, + "custom", + ); + saveTheme(resetTheme); + toastManager.add({ + type: "success", + title: "Theme reset", + description: `${activeTheme.name} now matches the default ${MODE_COPY[mode].modeLabel.toLowerCase()} preset.`, + }); + }, + [activeThemeByMode, saveTheme, updateModeSelection], + ); + + const handleCopyTheme = useCallback( + async (mode: ThemeMode) => { + const activeTheme = activeThemeByMode[mode]; + try { + await navigator.clipboard.writeText(serializeThemeDocument(activeTheme)); + toastManager.add({ + type: "success", + title: "Theme JSON copied", + description: `${activeTheme.name} is ready to paste or share.`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not copy theme JSON", + description: error instanceof Error ? error.message : "Clipboard access failed.", + }); + } + }, + [activeThemeByMode], + ); + + const handleImportTheme = useCallback( + (mode: ThemeMode) => async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + + setImportErrors((current) => ({ ...current, [mode]: null })); + + let rawJson: unknown; + try { + rawJson = JSON.parse(await file.text()); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid JSON."; + setImportErrors((current) => ({ ...current, [mode]: message })); + return; + } + + let decoded: ThemeDocument; + try { + decoded = Schema.decodeUnknownSync(ThemeDocumentSchema)(rawJson); + } catch (error) { + const message = + typeof error === "object" && error !== null && "issue" in error + ? SchemaIssue.makeFormatterDefault()((error as { issue: never }).issue) + : error instanceof Error + ? error.message + : "Invalid theme document."; + setImportErrors((current) => ({ ...current, [mode]: message })); + return; + } + + if (decoded.mode !== mode) { + setImportErrors((current) => ({ + ...current, + [mode]: `Imported theme mode mismatch. Expected ${mode}, received ${decoded.mode}.`, + })); + return; + } + + const existingIds = new Set([ + ...BUILTIN_THEME_PRESETS.map((preset) => preset.theme.id), + ...customThemes.map((theme) => theme.id), + ]); + const normalized = normalizeImportedThemeDocument(decoded, existingIds); + + updateSettings({ + ...(mode === "dark" + ? { activeDarkThemeId: normalized.id } + : { activeLightThemeId: normalized.id }), + customThemes: [...customThemes, normalized], + }); + toastManager.add({ + type: "success", + title: MODE_COPY[mode].importTitle, + description: + normalized.id === decoded.id + ? `${normalized.name} is now available in your custom ${mode} theme library.` + : `${normalized.name} was imported as ${normalized.id} to avoid an id conflict.`, + }); + }, + [customThemes, updateSettings], + ); + + return ( + + +
+ {COLOR_MODE_OPTIONS.map((option) => { + const Icon = option.icon; + const selected = colorMode === option.value; + + return ( + + ); + })} +
+
+ + {(["light", "dark"] as const satisfies ReadonlyArray).map((mode) => { + const activeTheme = activeThemeByMode[mode]; + const importInputRef = mode === "light" ? lightImportInputRef : darkImportInputRef; + + return ( + + + + + + {activeTheme.origin === "custom" ? ( + + ) : null} + + } + > +
+ {/* Theme grid */} +
+ {themeLibraryByMode[mode].map((entry) => ( + updateModeSelection(mode, entry.theme.id)} + /> + ))} +
+ + + {importErrors[mode] ? ( +
+

Import failed

+

{importErrors[mode]}

+
+ ) : null} + + {/* Inline editor */} +
+ handleThemeChange(mode, updater)} + /> +
+
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f9fdb1d615..81c110400a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -33,7 +33,6 @@ import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { isElectron } from "../../env"; -import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { @@ -60,21 +59,6 @@ import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ProjectFavicon } from "../ProjectFavicon"; -const THEME_OPTIONS = [ - { - value: "system", - label: "System", - }, - { - value: "light", - label: "Light", - }, - { - value: "dark", - label: "Dark", - }, -] as const; - const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -213,7 +197,7 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } ); } -function SettingsSection({ +export function SettingsSection({ title, icon, headerAction, @@ -240,7 +224,7 @@ function SettingsSection({ ); } -function SettingsRow({ +export function SettingsRow({ title, description, status, @@ -279,7 +263,7 @@ function SettingsRow({ ); } -function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { +export function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { return ( ); } -function SettingsPageContainer({ children }: { children: ReactNode }) { +export function SettingsPageContainer({ children }: { children: ReactNode }) { return (
{children}
@@ -439,9 +423,8 @@ function AboutVersionSection() { } export function useSettingsRestore(onRestored?: () => void) { - const { theme, setTheme } = useTheme(); const settings = useSettings(); - const { resetSettings } = useUpdateSettings(); + const { updateSettings, resetSettings } = useUpdateSettings(); const isGitWritingModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, @@ -455,7 +438,10 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ - ...(theme !== "system" ? ["Theme"] : []), + ...(settings.colorMode !== "system" ? ["Color mode"] : []), + ...(settings.activeLightThemeId !== "t3code-light" ? ["Light appearance theme"] : []), + ...(settings.activeDarkThemeId !== "t3code-dark" ? ["Dark appearance theme"] : []), + ...(settings.customThemes.length > 0 ? ["Custom themes"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -480,13 +466,16 @@ export function useSettingsRestore(onRestored?: () => void) { [ areProviderSettingsDirty, isGitWritingModelDirty, + settings.activeLightThemeId, + settings.activeDarkThemeId, + settings.colorMode, settings.confirmThreadArchive, settings.confirmThreadDelete, + settings.customThemes.length, settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, settings.timestampFormat, - theme, ], ); @@ -500,10 +489,15 @@ export function useSettingsRestore(onRestored?: () => void) { ); if (!confirmed) return; - setTheme("system"); + updateSettings({ + colorMode: "system", + activeLightThemeId: "t3code-light", + activeDarkThemeId: "t3code-dark", + customThemes: [], + }); resetSettings(); onRestored?.(); - }, [changedSettingLabels, onRestored, resetSettings, setTheme]); + }, [changedSettingLabels, onRestored, resetSettings, updateSettings]); return { changedSettingLabels, @@ -512,7 +506,6 @@ export function useSettingsRestore(onRestored?: () => void) { } export function GeneralSettingsPanel() { - const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); @@ -738,39 +731,6 @@ export function GeneralSettingsPanel() { return ( - setTheme("system")} /> - ) : null - } - control={ - - } - /> - ; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Appearance", to: "/settings/appearance", icon: PaletteIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/settings/appearance.logic.test.ts b/apps/web/src/components/settings/appearance.logic.test.ts new file mode 100644 index 0000000000..8700fb395d --- /dev/null +++ b/apps/web/src/components/settings/appearance.logic.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { BUILTIN_THEME_DOCUMENTS } from "@t3tools/shared/appearance/registry"; +import { + createDuplicateTheme, + ensureUniqueThemeId, + normalizeImportedThemeDocument, + updateThemeDocument, +} from "./appearance.logic"; + +describe("appearance.logic", () => { + it("deduplicates conflicting theme ids", () => { + expect(ensureUniqueThemeId("codex", new Set(["codex", "codex-2"]))).toBe("codex-3"); + }); + + it("normalizes imported themes into custom documents with unique ids", () => { + const imported = normalizeImportedThemeDocument( + BUILTIN_THEME_DOCUMENTS[0]!, + new Set(["t3code-light"]), + ); + expect(imported.origin).toBe("custom"); + expect(imported.id).toBe("t3code-light-2"); + }); + + it("duplicates builtin themes before editing", () => { + const duplicate = createDuplicateTheme(BUILTIN_THEME_DOCUMENTS[0]!, []); + expect(duplicate.origin).toBe("custom"); + expect(duplicate.id).toBe("t3code-light-copy"); + expect(duplicate.radius).toBe(BUILTIN_THEME_DOCUMENTS[0]!.radius); + expect(duplicate.fontSize).toBe(BUILTIN_THEME_DOCUMENTS[0]!.fontSize); + }); + + it("updates flat theme documents", () => { + const updated = updateThemeDocument(BUILTIN_THEME_DOCUMENTS[0]!, (themeDocument) => ({ + ...themeDocument, + contrast: 99, + })); + + expect(updated.contrast).toBe(99); + expect(updated.mode).toBe(BUILTIN_THEME_DOCUMENTS[0]!.mode); + }); +}); diff --git a/apps/web/src/components/settings/appearance.logic.ts b/apps/web/src/components/settings/appearance.logic.ts new file mode 100644 index 0000000000..c278e92a07 --- /dev/null +++ b/apps/web/src/components/settings/appearance.logic.ts @@ -0,0 +1,80 @@ +import type { ThemeDocument } from "@t3tools/contracts"; +import { + canonicalizeThemeDocument, + duplicateThemeDocument, + getReservedThemeIds, +} from "@t3tools/shared/appearance/registry"; + +function toKebabCase(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function slugifyThemeId(value: string): string { + return toKebabCase(value) || "custom-theme"; +} + +export function ensureUniqueThemeId(baseId: string, existingIds: ReadonlySet): string { + if (!existingIds.has(baseId)) { + return baseId; + } + + let index = 2; + while (existingIds.has(`${baseId}-${index}`)) { + index += 1; + } + return `${baseId}-${index}`; +} + +export function normalizeImportedThemeDocument( + themeDocument: ThemeDocument, + existingThemeIds: ReadonlySet, +): ThemeDocument { + const baseId = slugifyThemeId(themeDocument.id || themeDocument.name); + const uniqueId = ensureUniqueThemeId(baseId, existingThemeIds); + + return canonicalizeThemeDocument( + { + ...themeDocument, + id: uniqueId, + origin: "custom", + }, + "custom", + ); +} + +export function createDuplicateTheme( + themeDocument: ThemeDocument, + customThemes: ReadonlyArray, +) { + const existingThemeIds = new Set([ + ...getReservedThemeIds(), + ...customThemes.map((theme) => theme.id), + ]); + const baseId = slugifyThemeId(`${themeDocument.id}-copy`); + const nextId = ensureUniqueThemeId(baseId, existingThemeIds); + const nextName = + nextId === `${themeDocument.id}-copy` + ? `${themeDocument.name} Copy` + : `${themeDocument.name} Copy ${nextId.split("-").at(-1)}`; + return duplicateThemeDocument(themeDocument, nextId, nextName); +} + +export function replaceCustomTheme( + customThemes: ReadonlyArray, + nextTheme: ThemeDocument, +): Array { + const next = customThemes.filter((theme) => theme.id !== nextTheme.id); + next.push(canonicalizeThemeDocument(nextTheme, "custom")); + return next; +} + +export function updateThemeDocument( + themeDocument: ThemeDocument, + updater: (themeDocument: ThemeDocument) => ThemeDocument, +): ThemeDocument { + return updater(themeDocument); +} diff --git a/apps/web/src/hooks/useAppearance.test.ts b/apps/web/src/hooks/useAppearance.test.ts new file mode 100644 index 0000000000..30743e5b31 --- /dev/null +++ b/apps/web/src/hooks/useAppearance.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { parseAppearanceCache, resolveAppearanceMode } from "./useAppearance"; + +describe("resolveAppearanceMode", () => { + it("resolves explicit modes without consulting system preference", () => { + expect(resolveAppearanceMode("light", true)).toBe("light"); + expect(resolveAppearanceMode("dark", false)).toBe("dark"); + }); + + it("resolves system mode from the current platform preference", () => { + expect(resolveAppearanceMode("system", true)).toBe("dark"); + expect(resolveAppearanceMode("system", false)).toBe("light"); + }); +}); + +describe("parseAppearanceCache", () => { + it("decodes valid cached appearance snapshots", () => { + const parsed = parseAppearanceCache( + JSON.stringify({ + colorMode: "system", + activeLightThemeId: "custom-codex-light", + activeDarkThemeId: "custom-codex-dark", + customThemes: [ + { + id: "custom-codex-light", + name: "Custom Codex Light", + version: 1, + origin: "custom", + mode: "light", + radius: "0.75rem", + fontSize: "15px", + accent: "#0169cc", + background: "#ffffff", + foreground: "#0d0d0d", + uiFontFamily: '"IBM Plex Sans", sans-serif', + codeFontFamily: '"IBM Plex Mono", monospace', + contrast: 46, + }, + { + id: "custom-codex-dark", + name: "Custom Codex Dark", + version: 1, + origin: "custom", + mode: "dark", + radius: "0.75rem", + fontSize: "15px", + accent: "#0169cc", + background: "#111111", + foreground: "#fcfcfc", + uiFontFamily: '"IBM Plex Sans", sans-serif', + codeFontFamily: '"IBM Plex Mono", monospace', + contrast: 41, + }, + ], + }), + ); + + expect(parsed?.activeLightThemeId).toBe("custom-codex-light"); + expect(parsed?.activeDarkThemeId).toBe("custom-codex-dark"); + expect(parsed?.customThemes[0]?.id).toBe("custom-codex-light"); + expect(parsed?.customThemes[0]?.radius).toBe("0.75rem"); + expect(parsed?.customThemes[0]?.fontSize).toBe("15px"); + }); + + it("drops invalid cache payloads", () => { + expect( + parseAppearanceCache( + JSON.stringify({ + colorMode: "system", + activeLightThemeId: "custom-codex-light", + activeDarkThemeId: "custom-codex-dark", + customThemes: [{ id: "bad" }], + }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useAppearance.ts b/apps/web/src/hooks/useAppearance.ts new file mode 100644 index 0000000000..b6ec43dd62 --- /dev/null +++ b/apps/web/src/hooks/useAppearance.ts @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { Schema } from "effect"; +import type { DesktopAppearance } from "@t3tools/contracts"; +import { + ColorMode, + ThemeDocumentSchema, + type ThemeMode, + type ColorMode as AppearanceColorMode, +} from "@t3tools/contracts"; +import { + applyThemeDocumentStyles, + applyThemeVariant, + clearThemeCssVariables, +} from "@t3tools/shared/appearance/apply"; +import { + type ResolvedThemeMode, + type ThemeCssVariableMap, +} from "@t3tools/shared/appearance/derive"; +import { + DEFAULT_DARK_THEME_ID, + DEFAULT_LIGHT_THEME_ID, + resolveThemeDocument, + serializeAppearanceSnapshot, +} from "@t3tools/shared/appearance/registry"; +import { useSettings, useUpdateSettings } from "./useSettings"; + +const APPEARANCE_CACHE_KEY = "t3code:appearance-cache"; +const MEDIA_QUERY = "(prefers-color-scheme: dark)"; +const AppearanceCacheSchema = Schema.Struct({ + colorMode: ColorMode, + activeLightThemeId: Schema.String, + activeDarkThemeId: Schema.String, + customThemes: Schema.Array(ThemeDocumentSchema), +}); +type AppearanceCache = typeof AppearanceCacheSchema.Type; + +let appliedVariableNames: ReadonlyArray = []; +let lastDesktopAppearance: string | null = null; + +function getSystemDark(): boolean { + return typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY).matches : false; +} + +export function resolveAppearanceMode( + colorMode: AppearanceColorMode, + systemDark: boolean, +): ResolvedThemeMode { + if (colorMode === "system") { + return systemDark ? "dark" : "light"; + } + return colorMode; +} + +export function parseAppearanceCache(raw: string | null): AppearanceCache | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw); + return Schema.decodeUnknownSync(AppearanceCacheSchema)(parsed); + } catch { + return null; + } +} + +function resolveActiveThemeId(appearance: AppearanceCache, resolvedTheme: ThemeMode): string { + return resolvedTheme === "dark" ? appearance.activeDarkThemeId : appearance.activeLightThemeId; +} + +function applyResolvedAppearance( + appearance: AppearanceCache, + resolvedTheme: ResolvedThemeMode, +): void { + const themeDocument = resolveThemeDocument( + resolveActiveThemeId(appearance, resolvedTheme), + appearance.customThemes, + resolvedTheme, + ); + const root = document.documentElement; + + root.classList.toggle("dark", resolvedTheme === "dark"); + root.style.colorScheme = resolvedTheme; + applyThemeDocumentStyles(root.style, themeDocument); + + clearThemeCssVariables(root.style, appliedVariableNames); + appliedVariableNames = applyThemeVariant(root.style, themeDocument); +} + +function suppressTransitions(fn: () => void) { + document.documentElement.classList.add("no-transitions"); + fn(); + void document.documentElement.offsetHeight; + requestAnimationFrame(() => { + document.documentElement.classList.remove("no-transitions"); + }); +} + +function syncDesktopAppearance(appearance: DesktopAppearance): void { + const bridge = window.desktopBridge; + if (!bridge) return; + + const key = JSON.stringify(appearance); + if (lastDesktopAppearance === key) return; + lastDesktopAppearance = key; + + if (typeof bridge.setAppearance === "function") { + void bridge.setAppearance(appearance).catch(() => { + if (lastDesktopAppearance === key) lastDesktopAppearance = null; + }); + } else { + // Fallback for older Electron builds that only expose setTheme + void bridge.setTheme(appearance.mode).catch(() => { + if (lastDesktopAppearance === key) lastDesktopAppearance = null; + }); + } +} + +if (typeof window !== "undefined" && typeof document !== "undefined") { + const cachedAppearance = parseAppearanceCache(localStorage.getItem(APPEARANCE_CACHE_KEY)); + const initialAppearance = + cachedAppearance ?? + ({ + colorMode: "system", + activeLightThemeId: DEFAULT_LIGHT_THEME_ID, + activeDarkThemeId: DEFAULT_DARK_THEME_ID, + customThemes: [], + } satisfies AppearanceCache); + const resolvedTheme = resolveAppearanceMode(initialAppearance.colorMode, getSystemDark()); + + try { + applyResolvedAppearance(initialAppearance, resolvedTheme); + } catch { + // The cache is best-effort. React will reconcile once settings load. + } +} + +let systemDarkListeners: Array<() => void> = []; +let cachedSystemDark: boolean | null = null; + +function getSystemDarkSnapshot(): boolean { + if (cachedSystemDark === null) cachedSystemDark = getSystemDark(); + return cachedSystemDark; +} + +function subscribeSystemDark(listener: () => void): () => void { + systemDarkListeners.push(listener); + const mq = window.matchMedia(MEDIA_QUERY); + const handler = () => { + cachedSystemDark = mq.matches; + for (const l of systemDarkListeners) l(); + }; + mq.addEventListener("change", handler); + return () => { + systemDarkListeners = systemDarkListeners.filter((l) => l !== listener); + mq.removeEventListener("change", handler); + }; +} + +export function useAppearance() { + const { colorMode, activeLightThemeId, activeDarkThemeId, customThemes } = useSettings((s) => ({ + colorMode: s.colorMode, + activeLightThemeId: s.activeLightThemeId, + activeDarkThemeId: s.activeDarkThemeId, + customThemes: s.customThemes, + })); + const { updateSettings } = useUpdateSettings(); + const activeLightTheme = useMemo( + () => resolveThemeDocument(activeLightThemeId, customThemes, "light"), + [activeLightThemeId, customThemes], + ); + const activeDarkTheme = useMemo( + () => resolveThemeDocument(activeDarkThemeId, customThemes, "dark"), + [activeDarkThemeId, customThemes], + ); + + const systemDark = useSyncExternalStore(subscribeSystemDark, getSystemDarkSnapshot); + + const resolvedTheme = useMemo( + () => resolveAppearanceMode(colorMode, systemDark), + [colorMode, systemDark], + ); + const activeResolvedTheme = resolvedTheme === "dark" ? activeDarkTheme : activeLightTheme; + + useEffect(() => { + suppressTransitions(() => { + applyResolvedAppearance( + { colorMode, activeLightThemeId, activeDarkThemeId, customThemes }, + resolvedTheme, + ); + }); + + localStorage.setItem( + APPEARANCE_CACHE_KEY, + serializeAppearanceSnapshot({ + colorMode, + activeLightThemeId, + activeDarkThemeId, + customThemes, + }), + ); + + syncDesktopAppearance({ mode: colorMode, themeId: activeResolvedTheme.id }); + }, [ + resolvedTheme, + activeResolvedTheme, + activeLightThemeId, + activeDarkThemeId, + colorMode, + customThemes, + ]); + + const setColorMode = useCallback( + (mode: AppearanceColorMode) => updateSettings({ colorMode: mode }), + [updateSettings], + ); + + const setThemeId = useCallback( + (mode: ThemeMode, id: string) => + updateSettings(mode === "dark" ? { activeDarkThemeId: id } : { activeLightThemeId: id }), + [updateSettings], + ); + + const setCustomThemes = useCallback( + (themes: AppearanceCache["customThemes"]) => updateSettings({ customThemes: themes }), + [updateSettings], + ); + + return { + colorMode, + resolvedTheme, + activeLightTheme, + activeDarkTheme, + activeResolvedTheme, + activeLightThemeId, + activeDarkThemeId, + customThemes, + setColorMode, + setThemeId, + setCustomThemes, + } as const; +} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3f804bc48b..35a93bfe1b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -70,9 +70,7 @@ function splitPatch(patch: Partial): { * only re-render when the slice they care about changes. */ -export function useSettings( - selector?: (s: UnifiedSettings) => T, -): T { +export function useSettings(selector?: (s: UnifiedSettings) => T): T { const { data: serverConfig } = useQuery(serverConfigQueryOptions()); const [clientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, @@ -240,6 +238,18 @@ export function migrateLocalSettingsToServer(): void { const raw = localStorage.getItem(OLD_SETTINGS_KEY); if (!raw) return; + // Migrate old t3code:theme localStorage key to server colorMode + try { + const oldTheme = localStorage.getItem("t3code:theme"); + if (oldTheme === "light" || oldTheme === "dark" || oldTheme === "system") { + const api = ensureNativeApi(); + void api.server.updateSettings({ colorMode: oldTheme }); + localStorage.removeItem("t3code:theme"); + } + } catch { + // Best-effort — don't block startup + } + try { const old = JSON.parse(raw); if (!Predicate.isObject(old)) return; diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts deleted file mode 100644 index 6afe83dfe3..0000000000 --- a/apps/web/src/hooks/useTheme.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { useCallback, useEffect, useSyncExternalStore } from "react"; - -type Theme = "light" | "dark" | "system"; -type ThemeSnapshot = { - theme: Theme; - systemDark: boolean; -}; - -const STORAGE_KEY = "t3code:theme"; -const MEDIA_QUERY = "(prefers-color-scheme: dark)"; - -let listeners: Array<() => void> = []; -let lastSnapshot: ThemeSnapshot | null = null; -let lastDesktopTheme: Theme | null = null; -function emitChange() { - for (const listener of listeners) listener(); -} - -function getSystemDark(): boolean { - return window.matchMedia(MEDIA_QUERY).matches; -} - -function getStored(): Theme { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw === "light" || raw === "dark" || raw === "system") return raw; - return "system"; -} - -function applyTheme(theme: Theme, suppressTransitions = false) { - if (suppressTransitions) { - document.documentElement.classList.add("no-transitions"); - } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); - document.documentElement.classList.toggle("dark", isDark); - syncDesktopTheme(theme); - if (suppressTransitions) { - // Force a reflow so the no-transitions class takes effect before removal - // oxlint-disable-next-line no-unused-expressions - document.documentElement.offsetHeight; - requestAnimationFrame(() => { - document.documentElement.classList.remove("no-transitions"); - }); - } -} - -function syncDesktopTheme(theme: Theme) { - const bridge = window.desktopBridge; - if (!bridge || lastDesktopTheme === theme) { - return; - } - - lastDesktopTheme = theme; - void bridge.setTheme(theme).catch(() => { - if (lastDesktopTheme === theme) { - lastDesktopTheme = null; - } - }); -} - -// Apply immediately on module load to prevent flash -applyTheme(getStored()); - -function getSnapshot(): ThemeSnapshot { - const theme = getStored(); - const systemDark = theme === "system" ? getSystemDark() : false; - - if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) { - return lastSnapshot; - } - - lastSnapshot = { theme, systemDark }; - return lastSnapshot; -} - -function subscribe(listener: () => void): () => void { - listeners.push(listener); - - // Listen for system preference changes - const mq = window.matchMedia(MEDIA_QUERY); - const handleChange = () => { - if (getStored() === "system") applyTheme("system", true); - emitChange(); - }; - mq.addEventListener("change", handleChange); - - // Listen for storage changes from other tabs - const handleStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY) { - applyTheme(getStored(), true); - emitChange(); - } - }; - window.addEventListener("storage", handleStorage); - - return () => { - listeners = listeners.filter((l) => l !== listener); - mq.removeEventListener("change", handleChange); - window.removeEventListener("storage", handleStorage); - }; -} - -export function useTheme() { - const snapshot = useSyncExternalStore(subscribe, getSnapshot); - const theme = snapshot.theme; - - const resolvedTheme: "light" | "dark" = - theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; - - const setTheme = useCallback((next: Theme) => { - localStorage.setItem(STORAGE_KEY, next); - applyTheme(next, true); - emitChange(); - }, []); - - // Keep DOM in sync on mount/change - useEffect(() => { - applyTheme(theme); - }, [theme]); - - return { theme, setTheme, resolvedTheme } as const; -} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea76f24fac..f89f48d27f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -4,6 +4,13 @@ @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-diff-deletion: var(--diff-deletion); + --color-diff-addition: var(--diff-addition); --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); --color-success-foreground: var(--success-foreground); @@ -62,72 +69,19 @@ } :root { - color-scheme: light; --radius: 0.625rem; - --background: var(--color-white); - --foreground: var(--color-neutral-800); - --card: var(--color-white); - --card-foreground: var(--color-neutral-800); - --popover: var(--color-white); - --popover-foreground: var(--color-neutral-800); - --primary: oklch(0.488 0.217 264); - --primary-foreground: var(--color-white); - --secondary: --alpha(var(--color-black) / 4%); - --secondary-foreground: var(--color-neutral-800); - --muted: --alpha(var(--color-black) / 4%); - --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black)); - --accent: --alpha(var(--color-black) / 4%); - --accent-foreground: var(--color-neutral-800); - --destructive: var(--color-red-500); - --border: --alpha(var(--color-black) / 8%); - --input: --alpha(var(--color-black) / 10%); - --ring: oklch(0.488 0.217 264); - --destructive-foreground: var(--color-red-700); - --info: var(--color-blue-500); - --info-foreground: var(--color-blue-700); - --success: var(--color-emerald-500); - --success-foreground: var(--color-emerald-700); - --warning: var(--color-amber-500); - --warning-foreground: var(--color-amber-700); - - @variant dark { - color-scheme: dark; - --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); - --foreground: var(--color-neutral-100); - --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); - --card-foreground: var(--color-neutral-100); - --popover: color-mix(in srgb, var(--background) 98%, var(--color-white)); - --popover-foreground: var(--color-neutral-100); - --primary: oklch(0.588 0.217 264); - --primary-foreground: var(--color-white); - --secondary: --alpha(var(--color-white) / 4%); - --secondary-foreground: var(--color-neutral-100); - --muted: --alpha(var(--color-white) / 4%); - --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); - --accent: --alpha(var(--color-white) / 4%); - --accent-foreground: var(--color-neutral-100); - --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); - --border: --alpha(var(--color-white) / 6%); - --input: --alpha(var(--color-white) / 8%); - --ring: oklch(0.588 0.217 264); - --destructive-foreground: var(--color-red-400); - --info: var(--color-blue-500); - --info-foreground: var(--color-blue-400); - --success: var(--color-emerald-500); - --success-foreground: var(--color-emerald-400); - --warning: var(--color-amber-500); - --warning-foreground: var(--color-amber-400); - } } body { - font-family: + font-family: var( + --ui-font-family, "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; + sans-serif + ); margin: 0; padding: 0; min-height: 100vh; @@ -160,7 +114,15 @@ pre, code, textarea, input { - font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-family: var( + --code-font-family, + "SF Mono", + "SFMono-Regular", + Consolas, + "Liberation Mono", + Menlo, + monospace + ); } /* Window drag region (frameless titlebar) */ diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 77b1b15842..cbd342e55a 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' const SettingsRoute = SettingsRouteImport.update({ @@ -40,6 +41,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({ + id: '/appearance', + path: '/appearance', + getParentRoute: () => SettingsRoute, +} as any) const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ id: '/$threadId', path: '/$threadId', @@ -50,12 +56,14 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute + '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute } export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute + '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute '/': typeof ChatIndexRoute @@ -65,6 +73,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/settings': typeof SettingsRouteWithChildren '/_chat/$threadId': typeof ChatThreadIdRoute + '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute '/_chat/': typeof ChatIndexRoute @@ -75,12 +84,14 @@ export interface FileRouteTypes { | '/' | '/settings' | '/$threadId' + | '/settings/appearance' | '/settings/archived' | '/settings/general' fileRoutesByTo: FileRoutesByTo to: | '/settings' | '/$threadId' + | '/settings/appearance' | '/settings/archived' | '/settings/general' | '/' @@ -89,6 +100,7 @@ export interface FileRouteTypes { | '/_chat' | '/settings' | '/_chat/$threadId' + | '/settings/appearance' | '/settings/archived' | '/settings/general' | '/_chat/' @@ -136,6 +148,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/settings/appearance': { + id: '/settings/appearance' + path: '/appearance' + fullPath: '/settings/appearance' + preLoaderRoute: typeof SettingsAppearanceRouteImport + parentRoute: typeof SettingsRoute + } '/_chat/$threadId': { id: '/_chat/$threadId' path: '/$threadId' @@ -159,11 +178,13 @@ const ChatRouteChildren: ChatRouteChildren = { const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { + SettingsAppearanceRoute: typeof SettingsAppearanceRoute SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsGeneralRoute: typeof SettingsGeneralRoute } const SettingsRouteChildren: SettingsRouteChildren = { + SettingsAppearanceRoute: SettingsAppearanceRoute, SettingsArchivedRoute: SettingsArchivedRoute, SettingsGeneralRoute: SettingsGeneralRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e99ec50226..4a7778409c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,6 +22,7 @@ import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; +import { useAppearance } from "../hooks/useAppearance"; import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; @@ -38,6 +39,11 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { + // Mount appearance system — drives .dark class, theme tokens, and Electron sync. + // Must be called before any early returns to satisfy React's Rules of Hooks. + // The import also triggers module-scope FOUC prevention. + useAppearance(); + if (!readNativeApi()) { return (
diff --git a/apps/web/src/routes/settings.appearance.tsx b/apps/web/src/routes/settings.appearance.tsx new file mode 100644 index 0000000000..ebff8b2e8e --- /dev/null +++ b/apps/web/src/routes/settings.appearance.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { AppearanceSettingsPanel } from "../components/settings/AppearanceSettingsPanel"; + +export const Route = createFileRoute("/settings/appearance")({ + component: AppearanceSettingsPanel, +}); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index fe03c205a5..b4216ae06d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,6 +10,11 @@ "module": "./dist/index.mjs", "types": "./src/index.ts", "exports": { + "./appearanceTheme": { + "types": "./src/appearanceTheme.ts", + "import": "./src/appearanceTheme.ts", + "require": "./src/appearanceTheme.ts" + }, "./settings": { "types": "./src/settings.ts", "import": "./src/settings.ts", diff --git a/packages/contracts/src/appearanceTheme.ts b/packages/contracts/src/appearanceTheme.ts new file mode 100644 index 0000000000..e79328b78d --- /dev/null +++ b/packages/contracts/src/appearanceTheme.ts @@ -0,0 +1,304 @@ +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/i; + +const HexColor = TrimmedNonEmptyString.check(Schema.isPattern(HEX_COLOR_PATTERN)).annotate({ + title: "Hex color", + description: "A 6-digit or 8-digit hex color string.", + examples: ["#0169cc", "#f5f7fb", "#0d0d0d"], +}); + +const CssColorValue = TrimmedNonEmptyString.annotate({ + title: "CSS color value", + description: "Any valid CSS color string or color-mix expression.", + examples: ["#0b6bcb", "rgb(10 20 30 / 80%)", "color-mix(in srgb, #111 92%, #fff)"], +}); + +const FontFamilyValue = TrimmedNonEmptyString.annotate({ + title: "Font family", + description: "A CSS font-family value.", + examples: [ + '"Fraunces", "Iowan Old Style", serif', + '"IBM Plex Sans", "Segoe UI", sans-serif', + '"SF Mono", Menlo, monospace', + ], +}); + +const CssLengthValue = TrimmedNonEmptyString.annotate({ + title: "CSS length", + description: "A CSS length value such as px, rem, or em.", + examples: ["16px", "0.625rem", "1em"], +}); + +const ThemeContrast = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + .check(Schema.isLessThanOrEqualTo(100)) + .annotate({ + title: "Contrast", + description: "A normalized contrast modifier from 0 to 100.", + examples: [24, 46, 72], + }); + +export const DEFAULT_THEME_RADIUS = "0.625rem"; +export const DEFAULT_THEME_FONT_SIZE = "16px"; + +export const ThemeRadiusSchema = CssLengthValue.pipe( + Schema.withDecodingDefault(() => DEFAULT_THEME_RADIUS), +).annotate({ + title: "Theme radius", + description: "Global corner radius used by the theme.", + examples: ["0.625rem", "0.5rem", "0.875rem"], +}); +export type ThemeRadius = typeof ThemeRadiusSchema.Type; + +export const ThemeFontSizeSchema = CssLengthValue.pipe( + Schema.withDecodingDefault(() => DEFAULT_THEME_FONT_SIZE), +).annotate({ + title: "Theme font size", + description: "Base root font size used by the theme.", + examples: ["16px", "15px", "17px"], +}); +export type ThemeFontSize = typeof ThemeFontSizeSchema.Type; + +const OverrideTokenValue = CssColorValue.annotate({ + title: "Override token", + description: "An explicit override for a derived appearance token.", +}); + +function makeOverrideField(title: string, description: string, examples?: ReadonlyArray) { + return Schema.optionalKey( + OverrideTokenValue.annotate({ + title, + description, + ...(examples ? { examples } : {}), + }), + ).annotateKey({ title, description }); +} + +export const ThemeSeedColorsSchema = Schema.Struct({ + accent: HexColor.annotate({ + title: "Accent", + description: "Primary brand or highlight color used for actions and focus states.", + examples: ["#0169cc", "#d95f2b", "#00827a"], + }).annotateKey({ + title: "Accent", + description: "Primary brand or highlight color used for actions and focus states.", + }), + background: HexColor.annotate({ + title: "Background", + description: "Primary canvas color for the app surface.", + examples: ["#ffffff", "#111111", "#f7f0e8"], + }).annotateKey({ + title: "Background", + description: "Primary canvas color for the app surface.", + }), + foreground: HexColor.annotate({ + title: "Foreground", + description: "Primary text color drawn over the background.", + examples: ["#0d0d0d", "#fcfcfc", "#1a120f"], + }).annotateKey({ + title: "Foreground", + description: "Primary text color drawn over the background.", + }), +}).annotate({ + title: "Theme seed colors", + description: "The base palette from which the rest of the appearance tokens are derived.", +}); +export type ThemeSeedColors = typeof ThemeSeedColorsSchema.Type; + +export const ThemeDerivedOverridesSchema = Schema.Struct({ + background: makeOverrideField("Background", "Override the primary app background."), + foreground: makeOverrideField("Foreground", "Override the primary app foreground color."), + card: makeOverrideField("Card", "Override the card background."), + cardForeground: makeOverrideField("Card foreground", "Override the text color used on cards."), + popover: makeOverrideField("Popover", "Override the popover background."), + popoverForeground: makeOverrideField( + "Popover foreground", + "Override the text color used on popovers.", + ), + primary: makeOverrideField("Primary", "Override the primary accent fill."), + primaryForeground: makeOverrideField( + "Primary foreground", + "Override the text color used on the primary accent fill.", + ), + secondary: makeOverrideField("Secondary", "Override the secondary surface."), + secondaryForeground: makeOverrideField( + "Secondary foreground", + "Override the text color used on the secondary surface.", + ), + muted: makeOverrideField("Muted", "Override the muted surface."), + mutedForeground: makeOverrideField( + "Muted foreground", + "Override the text color used for muted copy.", + ), + accentSurface: makeOverrideField("Accent surface", "Override the accent-tinted surface token."), + accentForeground: makeOverrideField( + "Accent foreground", + "Override the text color used on accent-tinted surfaces.", + ), + border: makeOverrideField("Border", "Override the default border color."), + input: makeOverrideField("Input", "Override the default input border or fill color."), + ring: makeOverrideField("Ring", "Override the focus ring color."), + destructive: makeOverrideField("Destructive", "Override the destructive fill color."), + destructiveForeground: makeOverrideField( + "Destructive foreground", + "Override the text color used on destructive fills.", + ), + info: makeOverrideField("Info", "Override the informational fill color."), + infoForeground: makeOverrideField( + "Info foreground", + "Override the text color used on informational fills.", + ), + success: makeOverrideField("Success", "Override the success fill color."), + successForeground: makeOverrideField( + "Success foreground", + "Override the text color used on success fills.", + ), + warning: makeOverrideField("Warning", "Override the warning fill color."), + warningForeground: makeOverrideField( + "Warning foreground", + "Override the text color used on warning fills.", + ), + diffAddition: makeOverrideField("Diff addition", "Override the addition color used in diffs.", [ + "#0969da", + ]), + diffDeletion: makeOverrideField("Diff deletion", "Override the deletion color used in diffs.", [ + "#bc4c00", + ]), + sidebar: makeOverrideField("Sidebar", "Override the sidebar surface."), + sidebarForeground: makeOverrideField( + "Sidebar foreground", + "Override the default sidebar text color.", + ), + sidebarAccent: makeOverrideField("Sidebar accent", "Override hover and active sidebar surfaces."), + sidebarAccentForeground: makeOverrideField( + "Sidebar accent foreground", + "Override text color on sidebar hover and active surfaces.", + ), + sidebarBorder: makeOverrideField("Sidebar border", "Override sidebar border and divider color."), +}).annotate({ + title: "Theme overrides", + description: "Optional advanced overrides for exact control over derived appearance tokens.", +}); +export type ThemeDerivedOverrides = typeof ThemeDerivedOverridesSchema.Type; + +export const ThemeVariantSchema = Schema.Struct({ + ...ThemeSeedColorsSchema.fields, + uiFontFamily: FontFamilyValue.annotate({ + title: "UI font family", + description: "Font family used for the application interface.", + }).annotateKey({ + title: "UI font family", + description: "Font family used for the application interface.", + }), + codeFontFamily: FontFamilyValue.annotate({ + title: "Code font family", + description: "Font family used for code, terminal, and monospace surfaces.", + }).annotateKey({ + title: "Code font family", + description: "Font family used for code, terminal, and monospace surfaces.", + }), + contrast: ThemeContrast.annotateKey({ + title: "Contrast", + description: "A normalized contrast modifier from 0 to 100.", + }), + overrides: Schema.optionalKey(ThemeDerivedOverridesSchema).annotateKey({ + title: "Overrides", + description: "Optional advanced overrides for exact control over derived appearance tokens.", + }), +}).annotate({ + title: "Theme variant", + description: + "A light or dark appearance variant with seed colors, fonts, and optional overrides.", +}); +export type ThemeVariant = typeof ThemeVariantSchema.Type; + +export const ThemeOriginSchema = Schema.Literals(["builtin", "custom"]).annotate({ + title: "Theme origin", + description: "Whether a theme ships with the app or was created/imported by the user.", + examples: ["builtin", "custom"], +}); +export type ThemeOrigin = typeof ThemeOriginSchema.Type; + +export const ThemeModeSchema = Schema.Literals(["light", "dark"]).annotate({ + title: "Theme mode", + description: "Whether this theme targets light mode or dark mode.", + examples: ["light", "dark"], +}); +export type ThemeMode = typeof ThemeModeSchema.Type; + +export const ThemeVersionSchema = Schema.Literal(1).annotate({ + title: "Theme document version", + description: "Version of the theme document schema.", + examples: [1], +}); +export type ThemeVersion = typeof ThemeVersionSchema.Type; + +export const ThemeMetadataSchema = Schema.Struct({ + id: TrimmedNonEmptyString.annotate({ + title: "Theme id", + description: "Stable identifier used for selection and persistence.", + examples: ["t3code", "warm-editorial", "custom-midnight-ledger"], + }).annotateKey({ + title: "Theme id", + description: "Stable identifier used for selection and persistence.", + }), + name: TrimmedNonEmptyString.annotate({ + title: "Theme name", + description: "Human-readable theme label shown in the UI.", + examples: ["T3Code", "Warm Ledger", "Cool Current"], + }).annotateKey({ + title: "Theme name", + description: "Human-readable theme label shown in the UI.", + }), + version: ThemeVersionSchema.annotateKey({ + title: "Version", + description: "Version of the theme document schema.", + }), + origin: ThemeOriginSchema.annotateKey({ + title: "Origin", + description: "Whether the theme is builtin or custom.", + }), +}).annotate({ + title: "Theme metadata", + description: "Metadata shared by every appearance theme document.", +}); +export type ThemeMetadata = typeof ThemeMetadataSchema.Type; + +export const ThemeDocumentSchema = Schema.Struct({ + ...ThemeMetadataSchema.fields, + mode: ThemeModeSchema.annotateKey({ + title: "Mode", + description: "Whether this theme targets light mode or dark mode.", + }), + radius: ThemeRadiusSchema.annotateKey({ + title: "Radius", + description: "Global corner radius used across the interface.", + }), + fontSize: ThemeFontSizeSchema.annotateKey({ + title: "Font size", + description: "Base root font size used across the interface.", + }), + ...ThemeVariantSchema.fields, +}).annotate({ + title: "Theme document", + description: "A complete single-mode appearance theme document.", + examples: [ + { + id: "codex-light", + name: "Codex Light", + version: 1, + origin: "custom", + mode: "light", + radius: "0.625rem", + fontSize: "16px", + accent: "#0169cc", + background: "#ffffff", + foreground: "#0d0d0d", + uiFontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + codeFontFamily: '"SF Mono", Menlo, monospace', + contrast: 46, + }, + ], +}); +export type ThemeDocument = typeof ThemeDocumentSchema.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 248b3a04f9..01af22da4b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./appearanceTheme"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5585e7f309..a4585bfdb3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -73,6 +73,11 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; +export type DesktopAppearance = { + mode: DesktopTheme; + themeId: string; +}; + export interface DesktopRuntimeInfo { hostArch: DesktopRuntimeArch; appArch: DesktopRuntimeArch; @@ -110,7 +115,9 @@ export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; confirm: (message: string) => Promise; + /** @deprecated Use `setAppearance` instead. */ setTheme: (theme: DesktopTheme) => Promise; + setAppearance: (appearance: DesktopAppearance) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e7f638c7f3..086ab6d63c 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,8 +1,86 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_CLIENT_SETTINGS } from "./settings"; +import { Schema } from "effect"; +import { ThemeDocumentSchema } from "./appearanceTheme"; +import { DEFAULT_CLIENT_SETTINGS, ServerSettingsPatch } from "./settings"; describe("DEFAULT_CLIENT_SETTINGS", () => { it("includes archive confirmation with a false default", () => { expect(DEFAULT_CLIENT_SETTINGS.confirmThreadArchive).toBe(false); }); }); + +describe("ThemeDocumentSchema", () => { + it("decodes a valid custom theme document", () => { + const theme = Schema.decodeUnknownSync(ThemeDocumentSchema)({ + id: "custom-codex-light", + name: "Custom Codex Light", + version: 1, + origin: "custom", + mode: "light", + radius: "0.75rem", + fontSize: "15px", + accent: "#0169cc", + background: "#ffffff", + foreground: "#0d0d0d", + uiFontFamily: '"IBM Plex Sans", sans-serif', + codeFontFamily: '"IBM Plex Mono", monospace', + contrast: 44, + overrides: { + diffAddition: "#0969da", + diffDeletion: "#bc4c00", + }, + }); + + expect(theme.overrides?.diffAddition).toBe("#0969da"); + expect(theme.mode).toBe("light"); + expect(theme.radius).toBe("0.75rem"); + expect(theme.fontSize).toBe("15px"); + }); + + it("rejects invalid seed colors", () => { + expect(() => + Schema.decodeUnknownSync(ThemeDocumentSchema)({ + id: "bad-theme", + name: "Bad Theme", + version: 1, + origin: "custom", + mode: "light", + accent: "blue", + background: "#ffffff", + foreground: "#0d0d0d", + uiFontFamily: "sans-serif", + codeFontFamily: "monospace", + contrast: 40, + }), + ).toThrow(); + }); +}); + +describe("ServerSettingsPatch", () => { + it("accepts customThemes updates", () => { + const patch = Schema.decodeUnknownSync(ServerSettingsPatch)({ + activeLightThemeId: "custom-codex-light", + activeDarkThemeId: "t3code-dark", + customThemes: [ + { + id: "custom-codex-light", + name: "Custom Codex Light", + version: 1, + origin: "custom", + mode: "light", + radius: "0.75rem", + fontSize: "15px", + accent: "#0169cc", + background: "#ffffff", + foreground: "#0d0d0d", + uiFontFamily: '"IBM Plex Sans", sans-serif', + codeFontFamily: '"IBM Plex Mono", monospace', + contrast: 38, + }, + ], + }); + + expect(patch.activeLightThemeId).toBe("custom-codex-light"); + expect(patch.customThemes?.[0]?.id).toBe("custom-codex-light"); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9cd8a5f251..2b43f5f94e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { ThemeDocumentSchema } from "./appearanceTheme"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; import { ClaudeModelOptions, @@ -39,6 +40,15 @@ export type ClientSettings = typeof ClientSettingsSchema.Type; export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({}); +// ── Appearance ────────────────────────────────────────────────── + +export const ColorMode = Schema.Literals(["light", "dark", "system"]); +export type ColorMode = typeof ColorMode.Type; +export const DEFAULT_COLOR_MODE: ColorMode = "system"; + +export const DEFAULT_ACTIVE_LIGHT_THEME_ID = "t3code-light"; +export const DEFAULT_ACTIVE_DARK_THEME_ID = "t3code-dark"; + // ── Server Settings (server-authoritative) ──────────────────── export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); @@ -83,6 +93,18 @@ export const ServerSettings = Schema.Struct({ })), ), + // Appearance + colorMode: ColorMode.pipe( + Schema.withDecodingDefault(() => "system" as const satisfies ColorMode), + ), + activeLightThemeId: Schema.String.pipe( + Schema.withDecodingDefault(() => DEFAULT_ACTIVE_LIGHT_THEME_ID), + ), + activeDarkThemeId: Schema.String.pipe( + Schema.withDecodingDefault(() => DEFAULT_ACTIVE_DARK_THEME_ID), + ), + customThemes: Schema.Array(ThemeDocumentSchema).pipe(Schema.withDecodingDefault(() => [])), + // Provider specific settings providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), @@ -144,6 +166,10 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), + colorMode: Schema.optionalKey(ColorMode), + activeLightThemeId: Schema.optionalKey(Schema.String), + activeDarkThemeId: Schema.optionalKey(Schema.String), + customThemes: Schema.optionalKey(Schema.Array(ThemeDocumentSchema)), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), diff --git a/packages/shared/package.json b/packages/shared/package.json index 40ffbf35c2..4893e59a75 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -4,6 +4,18 @@ "private": true, "type": "module", "exports": { + "./appearance/registry": { + "types": "./src/appearance/registry.ts", + "import": "./src/appearance/registry.ts" + }, + "./appearance/derive": { + "types": "./src/appearance/derive.ts", + "import": "./src/appearance/derive.ts" + }, + "./appearance/apply": { + "types": "./src/appearance/apply.ts", + "import": "./src/appearance/apply.ts" + }, "./model": { "types": "./src/model.ts", "import": "./src/model.ts" diff --git a/packages/shared/src/appearance/apply.ts b/packages/shared/src/appearance/apply.ts new file mode 100644 index 0000000000..674d59fe64 --- /dev/null +++ b/packages/shared/src/appearance/apply.ts @@ -0,0 +1,45 @@ +import type { ThemeDocument, ThemeVariant } from "@t3tools/contracts/appearanceTheme"; +import { deriveThemeCssVariables, type ThemeCssVariableMap } from "./derive"; + +export interface CssVariableTarget { + setProperty(name: string, value: string): void; + removeProperty(name: string): void; +} + +export function applyThemeCssVariables( + target: CssVariableTarget, + variables: ThemeCssVariableMap, +): ReadonlyArray { + const applied: Array = []; + for (const [name, value] of Object.entries(variables) as Array< + [keyof ThemeCssVariableMap, string] + >) { + target.setProperty(name, value); + applied.push(name); + } + return applied; +} + +export function applyThemeVariant( + target: CssVariableTarget, + themeVariant: ThemeVariant, +): ReadonlyArray { + return applyThemeCssVariables(target, deriveThemeCssVariables(themeVariant)); +} + +export function applyThemeDocumentStyles( + target: CssVariableTarget, + themeDocument: ThemeDocument, +): void { + target.setProperty("--radius", themeDocument.radius); + target.setProperty("font-size", themeDocument.fontSize); +} + +export function clearThemeCssVariables( + target: CssVariableTarget, + variableNames: ReadonlyArray, +): void { + for (const variableName of variableNames) { + target.removeProperty(variableName); + } +} diff --git a/packages/shared/src/appearance/derive.test.ts b/packages/shared/src/appearance/derive.test.ts new file mode 100644 index 0000000000..ad4a801883 --- /dev/null +++ b/packages/shared/src/appearance/derive.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { deriveThemeCssVariables, deriveThemeTokens } from "./derive"; +import { BUILTIN_THEME_DOCUMENTS } from "./registry"; + +const t3codeLightTheme = BUILTIN_THEME_DOCUMENTS.find((theme) => theme.id === "t3code-light")!; +const t3codeDarkTheme = BUILTIN_THEME_DOCUMENTS.find((theme) => theme.id === "t3code-dark")!; +const warmLedgerDarkTheme = BUILTIN_THEME_DOCUMENTS.find( + (theme) => theme.id === "warm-ledger-dark", +)!; +const coolCurrentLightTheme = BUILTIN_THEME_DOCUMENTS.find( + (theme) => theme.id === "cool-current-light", +)!; + +describe("deriveThemeTokens", () => { + it("derives the full semantic token set from a light theme", () => { + const tokens = deriveThemeTokens(t3codeLightTheme); + + expect(tokens.background).toBe("#fff"); + expect(tokens.foreground).toBe("oklch(26.9% 0 0)"); + expect(tokens.primary).toBe("oklch(0.488 0.217 264)"); + expect(tokens.accent).toBe("color-mix(in oklab, #000 4%, transparent)"); + expect(tokens.border).toBe("color-mix(in oklab, #000 8%, transparent)"); + expect(tokens["sidebar-border"]).toBe("transparent"); + expect(tokens["sidebar-blur"]).toBe("0px"); + }); + + it("preserves the exact main-branch dark baseline for the default theme", () => { + const tokens = deriveThemeTokens(t3codeDarkTheme); + + expect(tokens.background).toBe("color-mix(in srgb, oklch(14.5% 0 0) 95%, #fff)"); + expect(tokens.foreground).toBe("oklch(97% 0 0)"); + expect(tokens.primary).toBe("oklch(0.588 0.217 264)"); + expect(tokens.accent).toBe("color-mix(in oklab, #fff 4%, transparent)"); + expect(tokens.border).toBe("color-mix(in oklab, #fff 6%, transparent)"); + expect(tokens["sidebar-border"]).toBe("transparent"); + expect(tokens["sidebar-blur"]).toBe("0px"); + }); + + it("strengthens borders and inputs at higher contrast", () => { + const lowContrast = deriveThemeTokens({ + ...warmLedgerDarkTheme, + contrast: 10, + }); + const highContrast = deriveThemeTokens({ + ...warmLedgerDarkTheme, + contrast: 90, + }); + + expect(highContrast.border).not.toBe(lowContrast.border); + expect(highContrast.input).not.toBe(lowContrast.input); + }); + + it("applies overrides after derivation", () => { + const tokens = deriveThemeTokens({ + ...coolCurrentLightTheme, + overrides: { + border: "#222222", + diffAddition: "#123456", + }, + }); + + expect(tokens.border).toBe("#222222"); + expect(tokens["diff-addition"]).toBe("#123456"); + }); + + it("exports css variable names in the expected format", () => { + const variables = deriveThemeCssVariables(t3codeDarkTheme); + + expect(variables["--background"]).toBe("color-mix(in srgb, oklch(14.5% 0 0) 95%, #fff)"); + expect(variables["--ui-font-family"]).toContain("DM Sans"); + }); +}); diff --git a/packages/shared/src/appearance/derive.ts b/packages/shared/src/appearance/derive.ts new file mode 100644 index 0000000000..72a9fd4837 --- /dev/null +++ b/packages/shared/src/appearance/derive.ts @@ -0,0 +1,322 @@ +import type { ThemeDerivedOverrides, ThemeVariant } from "@t3tools/contracts/appearanceTheme"; + +export type ResolvedThemeMode = "light" | "dark"; + +export const THEME_TOKEN_ORDER = [ + "background", + "foreground", + "card", + "card-foreground", + "popover", + "popover-foreground", + "primary", + "primary-foreground", + "secondary", + "secondary-foreground", + "muted", + "muted-foreground", + "accent", + "accent-foreground", + "border", + "input", + "ring", + "destructive", + "destructive-foreground", + "info", + "info-foreground", + "success", + "success-foreground", + "warning", + "warning-foreground", + "diff-addition", + "diff-deletion", + "sidebar", + "sidebar-foreground", + "sidebar-accent", + "sidebar-accent-foreground", + "sidebar-border", + "ui-font-family", + "code-font-family", + "sidebar-blur", +] as const; + +export type ThemeTokenName = (typeof THEME_TOKEN_ORDER)[number]; +export type ThemeTokenMap = Record; +export type ThemeCssVariableMap = Record<`--${ThemeTokenName}`, string>; + +type Rgb = { + r: number; + g: number; + b: number; +}; + +function transformGammaChannel(channel: number): number { + const normalized = channel / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +const STATUS_PALETTE = { + light: { + destructive: "#c53b2c", + info: "#0b6bcb", + success: "#0a7d5d", + warning: "#a95a00", + }, + dark: { + destructive: "#ff7b72", + info: "#58a6ff", + success: "#3fb950", + warning: "#d29922", + }, +} as const; + +const OVERRIDE_TOKEN_MAP: Record = { + background: "background", + foreground: "foreground", + card: "card", + cardForeground: "card-foreground", + popover: "popover", + popoverForeground: "popover-foreground", + primary: "primary", + primaryForeground: "primary-foreground", + secondary: "secondary", + secondaryForeground: "secondary-foreground", + muted: "muted", + mutedForeground: "muted-foreground", + accentSurface: "accent", + accentForeground: "accent-foreground", + border: "border", + input: "input", + ring: "ring", + destructive: "destructive", + destructiveForeground: "destructive-foreground", + info: "info", + infoForeground: "info-foreground", + success: "success", + successForeground: "success-foreground", + warning: "warning", + warningForeground: "warning-foreground", + diffAddition: "diff-addition", + diffDeletion: "diff-deletion", + sidebar: "sidebar", + sidebarForeground: "sidebar-foreground", + sidebarAccent: "sidebar-accent", + sidebarAccentForeground: "sidebar-accent-foreground", + sidebarBorder: "sidebar-border", +}; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function parseHexColor(hex: string): Rgb { + const normalized = hex.replace("#", ""); + const expanded = + normalized.length === 8 ? normalized.slice(0, 6) : normalized.length === 6 ? normalized : ""; + if (!expanded) { + throw new Error(`Invalid hex color: ${hex}`); + } + return { + r: Number.parseInt(expanded.slice(0, 2), 16), + g: Number.parseInt(expanded.slice(2, 4), 16), + b: Number.parseInt(expanded.slice(4, 6), 16), + }; +} + +function toHexColor({ r, g, b }: Rgb): string { + const channel = (value: number) => + Math.round(clamp(value, 0, 255)) + .toString(16) + .padStart(2, "0"); + return `#${channel(r)}${channel(g)}${channel(b)}`; +} + +function mixColors(left: string, right: string, ratio: number): string { + const from = parseHexColor(left); + const to = parseHexColor(right); + const amount = clamp(ratio, 0, 1); + return toHexColor({ + r: from.r + (to.r - from.r) * amount, + g: from.g + (to.g - from.g) * amount, + b: from.b + (to.b - from.b) * amount, + }); +} + +function withAlpha(color: string, alpha: number): string { + const { r, g, b } = parseHexColor(color); + return `rgb(${r} ${g} ${b} / ${clamp(alpha, 0, 1).toFixed(3)})`; +} + +function getRelativeLuminance(color: string): number { + const { r, g, b } = parseHexColor(color); + return ( + 0.2126 * transformGammaChannel(r) + + 0.7152 * transformGammaChannel(g) + + 0.0722 * transformGammaChannel(b) + ); +} + +function getContrastRatio(left: string, right: string): number { + const leftLuminance = getRelativeLuminance(left); + const rightLuminance = getRelativeLuminance(right); + const lighter = Math.max(leftLuminance, rightLuminance); + const darker = Math.min(leftLuminance, rightLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +function pickReadableText(background: string, candidates: ReadonlyArray): string { + return ( + candidates + .toSorted( + (left, right) => getContrastRatio(background, right) - getContrastRatio(background, left), + ) + .at(0) ?? candidates[0]! + ); +} + +function stripUndefinedKeys>( + value: T, +): Record { + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ); + return Object.fromEntries(entries); +} + +export function deriveThemeTokens(themeVariant: ThemeVariant): ThemeTokenMap { + const contrastFactor = clamp(themeVariant.contrast / 100, 0, 1); + const isDark = + getRelativeLuminance(themeVariant.background) < getRelativeLuminance(themeVariant.foreground); + + const card = mixColors( + themeVariant.background, + themeVariant.foreground, + isDark ? 0.02 + contrastFactor * 0.04 : 0.006 + contrastFactor * 0.016, + ); + const popover = mixColors( + themeVariant.background, + themeVariant.foreground, + isDark ? 0.035 + contrastFactor * 0.05 : 0.012 + contrastFactor * 0.02, + ); + const secondary = withAlpha(themeVariant.foreground, 0.03 + contrastFactor * 0.035); + const muted = withAlpha(themeVariant.foreground, 0.04 + contrastFactor * 0.03); + const accentSurface = mixColors( + themeVariant.background, + themeVariant.accent, + 0.11 + contrastFactor * 0.09, + ); + const border = withAlpha(themeVariant.foreground, 0.08 + contrastFactor * 0.18); + const input = withAlpha(themeVariant.foreground, 0.1 + contrastFactor * 0.2); + const mutedForeground = mixColors( + themeVariant.foreground, + themeVariant.background, + 0.38 - contrastFactor * 0.14, + ); + const sidebarBase = mixColors( + themeVariant.background, + themeVariant.foreground, + isDark ? 0.055 + contrastFactor * 0.055 : 0.045 + contrastFactor * 0.05, + ); + + const statusPalette = isDark ? STATUS_PALETTE.dark : STATUS_PALETTE.light; + const primaryForeground = pickReadableText(themeVariant.accent, [ + themeVariant.foreground, + themeVariant.background, + "#0d0d0d", + "#ffffff", + ]); + const accentForeground = pickReadableText(accentSurface, [ + themeVariant.foreground, + themeVariant.background, + "#0d0d0d", + "#ffffff", + ]); + const destructiveForeground = pickReadableText(statusPalette.destructive, [ + themeVariant.background, + themeVariant.foreground, + "#ffffff", + "#0d0d0d", + ]); + const infoForeground = pickReadableText(statusPalette.info, [ + themeVariant.background, + themeVariant.foreground, + "#ffffff", + "#0d0d0d", + ]); + const successForeground = pickReadableText(statusPalette.success, [ + themeVariant.background, + themeVariant.foreground, + "#ffffff", + "#0d0d0d", + ]); + const warningForeground = pickReadableText(statusPalette.warning, [ + themeVariant.background, + themeVariant.foreground, + "#ffffff", + "#0d0d0d", + ]); + + const tokens: ThemeTokenMap = { + background: themeVariant.background, + foreground: themeVariant.foreground, + card, + "card-foreground": themeVariant.foreground, + popover, + "popover-foreground": themeVariant.foreground, + primary: themeVariant.accent, + "primary-foreground": primaryForeground, + secondary, + "secondary-foreground": themeVariant.foreground, + muted, + "muted-foreground": mutedForeground, + accent: accentSurface, + "accent-foreground": accentForeground, + border, + input, + ring: mixColors(themeVariant.accent, themeVariant.foreground, isDark ? 0.12 : 0.08), + destructive: statusPalette.destructive, + "destructive-foreground": destructiveForeground, + info: statusPalette.info, + "info-foreground": infoForeground, + success: statusPalette.success, + "success-foreground": successForeground, + warning: statusPalette.warning, + "warning-foreground": warningForeground, + "diff-addition": statusPalette.success, + "diff-deletion": statusPalette.destructive, + sidebar: sidebarBase, + "sidebar-foreground": themeVariant.foreground, + "sidebar-accent": mixColors(sidebarBase, themeVariant.accent, 0.16 + contrastFactor * 0.1), + "sidebar-accent-foreground": accentForeground, + "sidebar-border": border, + "ui-font-family": themeVariant.uiFontFamily, + "code-font-family": themeVariant.codeFontFamily, + "sidebar-blur": "0px", + }; + + if (themeVariant.overrides) { + for (const [overrideKey, overrideValue] of Object.entries(themeVariant.overrides) as Array< + [keyof ThemeDerivedOverrides, string | undefined] + >) { + if (!overrideValue) continue; + const tokenName = OVERRIDE_TOKEN_MAP[overrideKey]; + tokens[tokenName] = overrideValue; + } + } + + return tokens; +} + +export function deriveThemeCssVariables(themeVariant: ThemeVariant): ThemeCssVariableMap { + const tokens = deriveThemeTokens(themeVariant); + const entries = THEME_TOKEN_ORDER.map((tokenName) => [`--${tokenName}`, tokens[tokenName]]); + return Object.fromEntries(entries) as ThemeCssVariableMap; +} + +export function serializeThemeDerivedOverrides( + overrides: ThemeDerivedOverrides | undefined, +): ThemeDerivedOverrides | undefined { + if (!overrides) return undefined; + const next = stripUndefinedKeys(overrides); + return Object.keys(next).length === 0 ? undefined : (next as ThemeDerivedOverrides); +} diff --git a/packages/shared/src/appearance/registry.test.ts b/packages/shared/src/appearance/registry.test.ts new file mode 100644 index 0000000000..888f4b94ea --- /dev/null +++ b/packages/shared/src/appearance/registry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + BUILTIN_THEME_DOCUMENTS, + DEFAULT_DARK_THEME_ID, + DEFAULT_LIGHT_THEME_ID, + duplicateThemeDocument, + resolveThemeDocument, + serializeAppearanceSnapshot, + serializeThemeDocument, +} from "./registry"; + +describe("appearance registry", () => { + it("resolves builtin themes before falling back to custom themes", () => { + const resolved = resolveThemeDocument( + DEFAULT_LIGHT_THEME_ID, + [duplicateThemeDocument(BUILTIN_THEME_DOCUMENTS[0]!, DEFAULT_LIGHT_THEME_ID, "Custom Clash")], + "light", + ); + + expect(resolved.origin).toBe("builtin"); + expect(resolved.id).toBe(DEFAULT_LIGHT_THEME_ID); + }); + + it("duplicates builtin themes into custom theme documents", () => { + const duplicated = duplicateThemeDocument( + BUILTIN_THEME_DOCUMENTS[0]!, + "custom-copy", + "Custom Copy", + ); + + expect(duplicated.origin).toBe("custom"); + expect(duplicated.id).toBe("custom-copy"); + expect(duplicated.accent).toBe(BUILTIN_THEME_DOCUMENTS[0]!.accent); + expect(duplicated.mode).toBe(BUILTIN_THEME_DOCUMENTS[0]!.mode); + }); + + it("serializes themes and appearance snapshots deterministically", () => { + const theme = duplicateThemeDocument(BUILTIN_THEME_DOCUMENTS[0]!, "custom-copy", "Custom Copy"); + const themeJson = serializeThemeDocument(theme); + const snapshotJson = serializeAppearanceSnapshot({ + colorMode: "system", + activeLightThemeId: "custom-copy", + activeDarkThemeId: DEFAULT_DARK_THEME_ID, + customThemes: [theme], + }); + + expect(themeJson).toContain('"id": "custom-copy"'); + expect(themeJson).toContain('"radius": "0.625rem"'); + expect(themeJson).toContain('"fontSize": "16px"'); + expect(snapshotJson).toContain('"activeLightThemeId":"custom-copy"'); + expect(snapshotJson).toContain(`"activeDarkThemeId":"${DEFAULT_DARK_THEME_ID}"`); + }); +}); diff --git a/packages/shared/src/appearance/registry.ts b/packages/shared/src/appearance/registry.ts new file mode 100644 index 0000000000..1155a839dd --- /dev/null +++ b/packages/shared/src/appearance/registry.ts @@ -0,0 +1,362 @@ +import { + DEFAULT_ACTIVE_DARK_THEME_ID, + DEFAULT_ACTIVE_LIGHT_THEME_ID, + DEFAULT_THEME_FONT_SIZE, + DEFAULT_THEME_RADIUS, + type ThemeDocument, + type ThemeMode, + type ThemeOrigin, +} from "@t3tools/contracts"; +import { serializeThemeDerivedOverrides } from "./derive"; + +export interface BuiltinThemePreset { + readonly description: string; + readonly theme: ThemeDocument; +} + +const DEFAULT_UI_FONT = '"DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; +const DEFAULT_CODE_FONT = + '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace'; + +type ThemeDocumentInput = { + id: string; + name: string; + description: string; + mode: ThemeMode; + radius?: string; + fontSize?: string; + accent: string; + background: string; + foreground: string; + uiFontFamily: string; + codeFontFamily: string; + contrast: number; + overrides?: ThemeDocument["overrides"]; +}; + +function makeThemeDocument(input: ThemeDocumentInput): BuiltinThemePreset { + return { + description: input.description, + theme: { + id: input.id, + name: input.name, + version: 1, + origin: "builtin", + mode: input.mode, + radius: input.radius ?? DEFAULT_THEME_RADIUS, + fontSize: input.fontSize ?? DEFAULT_THEME_FONT_SIZE, + accent: input.accent, + background: input.background, + foreground: input.foreground, + uiFontFamily: input.uiFontFamily, + codeFontFamily: input.codeFontFamily, + contrast: input.contrast, + ...(input.overrides ? { overrides: input.overrides } : {}), + }, + }; +} + +export const BUILTIN_THEME_PRESETS: readonly BuiltinThemePreset[] = [ + makeThemeDocument({ + id: DEFAULT_ACTIVE_LIGHT_THEME_ID, + name: "T3Code Light", + mode: "light", + description: "Clean neutral foundations with a sharper blue accent.", + accent: "#0169cc", + background: "#ffffff", + foreground: "#262626", + uiFontFamily: DEFAULT_UI_FONT, + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 46, + overrides: { + background: "#fff", + foreground: "oklch(26.9% 0 0)", + card: "#fff", + cardForeground: "oklch(26.9% 0 0)", + popover: "#fff", + popoverForeground: "oklch(26.9% 0 0)", + primary: "oklch(0.488 0.217 264)", + primaryForeground: "#fff", + secondary: "color-mix(in oklab, #000 4%, transparent)", + secondaryForeground: "oklch(26.9% 0 0)", + muted: "color-mix(in oklab, #000 4%, transparent)", + mutedForeground: "color-mix(in srgb, oklch(55.6% 0 0) 90%, #000)", + accentSurface: "color-mix(in oklab, #000 4%, transparent)", + accentForeground: "oklch(26.9% 0 0)", + border: "color-mix(in oklab, #000 8%, transparent)", + input: "color-mix(in oklab, #000 10%, transparent)", + ring: "oklch(0.488 0.217 264)", + destructive: "oklch(63.7% 0.237 25.331)", + destructiveForeground: "oklch(50.5% 0.213 27.518)", + info: "oklch(62.3% 0.214 259.815)", + infoForeground: "oklch(48.8% 0.243 264.376)", + success: "oklch(69.6% 0.17 162.48)", + successForeground: "oklch(50.8% 0.118 165.612)", + warning: "oklch(76.9% 0.188 70.08)", + warningForeground: "oklch(55.5% 0.163 48.998)", + sidebar: "transparent", + sidebarForeground: "oklch(26.9% 0 0)", + sidebarAccent: "color-mix(in oklab, #000 4%, transparent)", + sidebarAccentForeground: "oklch(26.9% 0 0)", + sidebarBorder: "transparent", + }, + }), + makeThemeDocument({ + id: DEFAULT_ACTIVE_DARK_THEME_ID, + name: "T3Code Dark", + mode: "dark", + description: "Clean neutral foundations with a sharper blue accent.", + accent: "#0169cc", + background: "#17171a", + foreground: "#f5f5f5", + uiFontFamily: DEFAULT_UI_FONT, + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 41, + overrides: { + background: "color-mix(in srgb, oklch(14.5% 0 0) 95%, #fff)", + foreground: "oklch(97% 0 0)", + card: "color-mix(in srgb, color-mix(in srgb, oklch(14.5% 0 0) 95%, #fff) 98%, #fff)", + cardForeground: "oklch(97% 0 0)", + popover: "color-mix(in srgb, color-mix(in srgb, oklch(14.5% 0 0) 95%, #fff) 98%, #fff)", + popoverForeground: "oklch(97% 0 0)", + primary: "oklch(0.588 0.217 264)", + primaryForeground: "#fff", + secondary: "color-mix(in oklab, #fff 4%, transparent)", + secondaryForeground: "oklch(97% 0 0)", + muted: "color-mix(in oklab, #fff 4%, transparent)", + mutedForeground: "color-mix(in srgb, oklch(55.6% 0 0) 90%, #fff)", + accentSurface: "color-mix(in oklab, #fff 4%, transparent)", + accentForeground: "oklch(97% 0 0)", + border: "color-mix(in oklab, #fff 6%, transparent)", + input: "color-mix(in oklab, #fff 8%, transparent)", + ring: "oklch(0.588 0.217 264)", + destructive: "color-mix(in srgb, oklch(63.7% 0.237 25.331) 90%, #fff)", + destructiveForeground: "oklch(70.4% 0.191 22.216)", + info: "oklch(62.3% 0.214 259.815)", + infoForeground: "oklch(70.7% 0.165 254.624)", + success: "oklch(69.6% 0.17 162.48)", + successForeground: "oklch(76.5% 0.177 163.223)", + warning: "oklch(76.9% 0.188 70.08)", + warningForeground: "oklch(82.8% 0.189 84.429)", + sidebar: "transparent", + sidebarForeground: "oklch(97% 0 0)", + sidebarAccent: "color-mix(in oklab, #fff 4%, transparent)", + sidebarAccentForeground: "oklch(97% 0 0)", + sidebarBorder: "transparent", + }, + }), + makeThemeDocument({ + id: "warm-ledger-light", + name: "Warm Ledger Light", + mode: "light", + description: "Editorial warmth with paper-like light mode.", + accent: "#b35b2c", + background: "#f7f0e8", + foreground: "#201814", + uiFontFamily: '"Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif', + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 56, + }), + makeThemeDocument({ + id: "warm-ledger-dark", + name: "Warm Ledger Dark", + mode: "dark", + description: "Editorial warmth with a coffee-toned dark mode.", + accent: "#e58d54", + background: "#1b1411", + foreground: "#f5ede6", + uiFontFamily: '"Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif', + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 52, + }), + makeThemeDocument({ + id: "cool-current-light", + name: "Cool Current Light", + mode: "light", + description: "Crystalline teals with a cooler edge.", + accent: "#00827a", + background: "#f2fbfb", + foreground: "#11232b", + uiFontFamily: '"Avenir Next", "Segoe UI", "Helvetica Neue", Arial, sans-serif', + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 40, + }), + makeThemeDocument({ + id: "cool-current-dark", + name: "Cool Current Dark", + mode: "dark", + description: "Crystalline teals on deep slate surfaces.", + accent: "#19b8b0", + background: "#09161d", + foreground: "#ebfbff", + uiFontFamily: '"Avenir Next", "Segoe UI", "Helvetica Neue", Arial, sans-serif', + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 48, + }), + makeThemeDocument({ + id: "editorial-signal-light", + name: "Editorial Signal Light", + mode: "light", + description: "Minimal black-and-cream contrast with sharper serif typography.", + accent: "#7f1933", + background: "#fffaf2", + foreground: "#121212", + uiFontFamily: '"Charter", "Bitstream Charter", "Sitka Text", Cambria, serif', + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 64, + }), + makeThemeDocument({ + id: "editorial-signal-dark", + name: "Editorial Signal Dark", + mode: "dark", + description: "Minimal black-and-cream contrast with sharper serif typography.", + accent: "#f05d7a", + background: "#111114", + foreground: "#f7f4ef", + uiFontFamily: '"Charter", "Bitstream Charter", "Sitka Text", Cambria, serif', + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 58, + }), + makeThemeDocument({ + id: "accessibility-spectrum-light", + name: "Accessibility Spectrum Light", + mode: "light", + description: "High-legibility neutrals with color-blind friendly diff colors.", + accent: "#245dce", + background: "#ffffff", + foreground: "#101828", + uiFontFamily: DEFAULT_UI_FONT, + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 68, + overrides: { + diffAddition: "#0969da", + diffDeletion: "#bc4c00", + }, + }), + makeThemeDocument({ + id: "accessibility-spectrum-dark", + name: "Accessibility Spectrum Dark", + mode: "dark", + description: "High-legibility neutrals with color-blind friendly diff colors.", + accent: "#58a6ff", + background: "#0d1117", + foreground: "#f0f6fc", + uiFontFamily: DEFAULT_UI_FONT, + codeFontFamily: DEFAULT_CODE_FONT, + contrast: 64, + overrides: { + diffAddition: "#388bfd", + diffDeletion: "#db6d28", + }, + }), +] as const; + +export const DEFAULT_LIGHT_THEME_ID = DEFAULT_ACTIVE_LIGHT_THEME_ID; +export const DEFAULT_DARK_THEME_ID = DEFAULT_ACTIVE_DARK_THEME_ID; +export const BUILTIN_THEME_DOCUMENTS: readonly ThemeDocument[] = BUILTIN_THEME_PRESETS.map( + (preset) => preset.theme, +); + +const BUILTIN_THEME_ID_SET = new Set(BUILTIN_THEME_DOCUMENTS.map((theme) => theme.id)); + +export function getDefaultThemeId(mode: ThemeMode): string { + return mode === "dark" ? DEFAULT_DARK_THEME_ID : DEFAULT_LIGHT_THEME_ID; +} + +export function isBuiltinThemeId(themeId: string): boolean { + return BUILTIN_THEME_ID_SET.has(themeId); +} + +export function getBuiltinThemePreset(themeId: string): BuiltinThemePreset | undefined { + return BUILTIN_THEME_PRESETS.find((preset) => preset.theme.id === themeId); +} + +export function getBuiltinThemeDocument(themeId: string): ThemeDocument | undefined { + return getBuiltinThemePreset(themeId)?.theme; +} + +export function getThemeDocumentsForMode( + mode: ThemeMode, + customThemes: ReadonlyArray, +): ReadonlyArray { + return [...BUILTIN_THEME_DOCUMENTS, ...customThemes].filter((theme) => theme.mode === mode); +} + +export function resolveThemeDocument( + themeId: string, + customThemes: ReadonlyArray, + mode: ThemeMode, +): ThemeDocument { + const resolved = + getBuiltinThemeDocument(themeId) ?? customThemes.find((theme) => theme.id === themeId); + + if (resolved && resolved.mode === mode) { + return resolved; + } + + return ( + getBuiltinThemeDocument(getDefaultThemeId(mode)) ?? + BUILTIN_THEME_DOCUMENTS.find((theme) => theme.mode === mode)! + ); +} + +export function canonicalizeThemeDocument( + themeDocument: ThemeDocument, + origin: ThemeOrigin = themeDocument.origin, +): ThemeDocument { + const overrides = serializeThemeDerivedOverrides(themeDocument.overrides); + return { + id: themeDocument.id, + name: themeDocument.name, + version: 1, + origin, + mode: themeDocument.mode, + radius: themeDocument.radius, + fontSize: themeDocument.fontSize, + accent: themeDocument.accent, + background: themeDocument.background, + foreground: themeDocument.foreground, + uiFontFamily: themeDocument.uiFontFamily, + codeFontFamily: themeDocument.codeFontFamily, + contrast: themeDocument.contrast, + ...(overrides ? { overrides } : {}), + }; +} + +export function serializeThemeDocument(themeDocument: ThemeDocument): string { + return `${JSON.stringify(canonicalizeThemeDocument(themeDocument), null, 2)}\n`; +} + +export function serializeAppearanceSnapshot(snapshot: { + colorMode: "light" | "dark" | "system"; + activeLightThemeId: string; + activeDarkThemeId: string; + customThemes: ReadonlyArray; +}): string { + return JSON.stringify({ + colorMode: snapshot.colorMode, + activeLightThemeId: snapshot.activeLightThemeId, + activeDarkThemeId: snapshot.activeDarkThemeId, + customThemes: snapshot.customThemes.map((theme) => canonicalizeThemeDocument(theme, "custom")), + }); +} + +export function duplicateThemeDocument( + themeDocument: ThemeDocument, + nextId: string, + nextName: string, +): ThemeDocument { + return canonicalizeThemeDocument( + { + ...themeDocument, + id: nextId, + name: nextName, + origin: "custom", + }, + "custom", + ); +} + +export function getReservedThemeIds(): ReadonlySet { + return BUILTIN_THEME_ID_SET; +}