From f4ab7ebe2144a4854d531102d0366d9ea12ccdf8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 11:49:29 -0700 Subject: [PATCH 1/3] Add appearance settings and theme system - Move theme control into a dedicated appearance settings panel - Add built-in themes, accent overrides, and desktop IPC sync - Migrate settings and update components to use unified appearance state --- apps/desktop/src/main.ts | 12 + apps/desktop/src/preload.ts | 2 + apps/web/src/components/ChatMarkdown.tsx | 4 +- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/DiffPanel.tsx | 4 +- .../src/components/DiffWorkerPoolProvider.tsx | 4 +- .../components/KeybindingsToast.browser.tsx | 3 + .../settings/AppearanceSettingsPanel.tsx | 289 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 73 +---- .../settings/SettingsSidebarNav.tsx | 8 +- apps/web/src/hooks/useAppearance.ts | 169 ++++++++++ apps/web/src/hooks/useSettings.ts | 16 +- apps/web/src/hooks/useTheme.ts | 121 -------- apps/web/src/lib/themes.ts | 206 +++++++++++++ apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/__root.tsx | 5 + apps/web/src/routes/settings.appearance.tsx | 7 + packages/contracts/src/ipc.ts | 8 + packages/contracts/src/settings.ts | 18 ++ 19 files changed, 780 insertions(+), 194 deletions(-) create mode 100644 apps/web/src/components/settings/AppearanceSettingsPanel.tsx create mode 100644 apps/web/src/hooks/useAppearance.ts delete mode 100644 apps/web/src/hooks/useTheme.ts create mode 100644 apps/web/src/lib/themes.ts create mode 100644 apps/web/src/routes/settings.appearance.tsx diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f1086e9c29..8444a7f82e 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,17 @@ 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 and accentHue are stored for future Electron shell styling + // (title bar tint, vibrancy, etc.) + }); + 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/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..cb10e91bbf 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"; @@ -165,7 +165,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/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 5d6ec94a50..db2498043c 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -60,6 +60,9 @@ function createBaseServerConfig(): ServerConfig { enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + colorMode: "system" as const, + activeThemeId: "t3code", + accentHue: null, providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx new file mode 100644 index 0000000000..2840072cc0 --- /dev/null +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -0,0 +1,289 @@ +import { CheckIcon } from "lucide-react"; +import { useCallback, useRef } from "react"; +import type { ColorMode } from "@t3tools/contracts/settings"; +import { useAppearance } from "../../hooks/useAppearance"; +import { + ACCENT_PRESETS, + BUILT_IN_THEMES, + DEFAULT_THEME_ID, + type ThemeDefinition, + type ThemeTokenMap, +} from "../../lib/themes"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./SettingsPanels"; + +// ── Constants ──────────────────────────────────────────────────── + +const COLOR_MODE_OPTIONS = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +] as const; + +// ── Swatch preview ────────────────────────────────────────────── + +const SWATCH_TOKENS: Array = [ + "primary", + "destructive", + "success", + "warning", + "info", +]; + +/** Render 5 small color dots for a theme preview. */ +function ThemeSwatch({ tokens }: { tokens: ThemeTokenMap }) { + return ( +
+ {SWATCH_TOKENS.map((token) => { + const color = tokens[token]; + return color ? ( + + ) : ( + + ); + })} +
+ ); +} + +// ── Theme card ─────────────────────────────────────────────────── + +function ThemeCard({ + theme, + isSelected, + onSelect, +}: { + theme: ThemeDefinition; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +// ── Accent preset circle ───────────────────────────────────────── + +function AccentCircle({ + hue, + isSelected, + label, + onClick, +}: { + hue: number; + isSelected: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +// ── Main panel ─────────────────────────────────────────────────── + +export function AppearanceSettingsPanel() { + const { colorMode, activeTheme, accentHue, setColorMode, setThemeId, setAccentHue } = + useAppearance(); + + const colorInputRef = useRef(null); + + const handleCustomAccentChange = useCallback( + (e: React.ChangeEvent) => { + // Convert hex to approximate oklch hue via HSL + const hex = e.target.value; + const hue = hexToApproxHue(hex); + setAccentHue(hue); + }, + [setAccentHue], + ); + + return ( + + {/* Section 1: Color Mode */} + + setColorMode("system")} /> + ) : null + } + control={ + + } + /> + + + {/* Section 2: Theme */} + +
+
+ {BUILT_IN_THEMES.map((theme) => ( + setThemeId(theme.id)} + /> + ))} +
+ {activeTheme.id !== DEFAULT_THEME_ID ? ( +
+ setThemeId(DEFAULT_THEME_ID)} + /> +
+ ) : null} +
+
+ + {/* Section 3: Accent Color */} + +
+

+ Override the primary color across the app. +

+
+ {/* Default pill */} + + + {/* Preset circles */} + {ACCENT_PRESETS.map((preset) => ( + setAccentHue(preset.hue)} + /> + ))} + + {/* Custom color picker */} + +
+ + {accentHue !== null ? ( +
+ setAccentHue(null)} /> +
+ ) : null} +
+
+
+ ); +} + +// ── Utilities ──────────────────────────────────────────────────── + +/** + * Convert a hex color string to an approximate hue (0-360). + * Good enough for a color-picker UX — exact oklch conversion isn't needed. + */ +function hexToApproxHue(hex: string): number { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + if (delta === 0) return 0; + let hue: number; + if (max === r) hue = ((g - b) / delta) % 6; + else if (max === g) hue = (b - r) / delta + 2; + else hue = (r - g) / delta + 4; + hue = Math.round(hue * 60); + if (hue < 0) hue += 360; + return hue; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f9fdb1d615..e64eb01a6b 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,9 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ - ...(theme !== "system" ? ["Theme"] : []), + ...(settings.colorMode !== "system" ? ["Color mode"] : []), + ...(settings.activeThemeId !== "t3code" ? ["Appearance theme"] : []), + ...(settings.accentHue !== null ? ["Accent color"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -480,13 +465,15 @@ export function useSettingsRestore(onRestored?: () => void) { [ areProviderSettingsDirty, isGitWritingModelDirty, + settings.accentHue, + settings.activeThemeId, + settings.colorMode, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, settings.timestampFormat, - theme, ], ); @@ -500,10 +487,10 @@ export function useSettingsRestore(onRestored?: () => void) { ); if (!confirmed) return; - setTheme("system"); + updateSettings({ colorMode: "system", activeThemeId: "t3code", accentHue: null }); resetSettings(); onRestored?.(); - }, [changedSettingLabels, onRestored, resetSettings, setTheme]); + }, [changedSettingLabels, onRestored, resetSettings, updateSettings]); return { changedSettingLabels, @@ -512,7 +499,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 +724,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/hooks/useAppearance.ts b/apps/web/src/hooks/useAppearance.ts new file mode 100644 index 0000000000..bd31afd9ac --- /dev/null +++ b/apps/web/src/hooks/useAppearance.ts @@ -0,0 +1,169 @@ +/** + * Unified appearance hook — replaces `useTheme`. + * + * All appearance state (colorMode, activeThemeId, accentHue) is + * server-authoritative and pushed to every client via `serverConfigUpdated`. + * + * A localStorage write-through cache prevents FOUC: module-scope code below + * reads the cache synchronously and applies the `.dark` class + theme tokens + * before React mounts. + */ + +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import type { ColorMode } from "@t3tools/contracts/settings"; +import type { DesktopAppearance } from "@t3tools/contracts"; +import { applyThemeTokens, findThemeById, removeThemeTokens, BUILT_IN_THEMES } from "~/lib/themes"; +import { useSettings, useUpdateSettings } from "./useSettings"; + +// ── Constants ──────────────────────────────────────────────────── + +const APPEARANCE_CACHE_KEY = "t3code:appearance-cache"; +const MEDIA_QUERY = "(prefers-color-scheme: dark)"; + +// ── Helpers ────────────────────────────────────────────────────── + +function getSystemDark(): boolean { + return window.matchMedia(MEDIA_QUERY).matches; +} + +function suppressTransitions(fn: () => void) { + document.documentElement.classList.add("no-transitions"); + fn(); + // Force 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"); + }); +} + +// ── Desktop bridge relay ───────────────────────────────────────── + +let lastDesktopAppearance: string | null = null; + +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; + }); + } +} + +// ── FOUC prevention (module scope — runs before React mounts) ─── + +try { + const cached = JSON.parse(localStorage.getItem(APPEARANCE_CACHE_KEY) ?? "null") as { + colorMode?: string; + activeThemeId?: string; + accentHue?: number | null; + } | null; + if (cached) { + const isDark = + cached.colorMode === "dark" || (cached.colorMode === "system" && getSystemDark()); + document.documentElement.classList.toggle("dark", isDark); + const theme = findThemeById(cached.activeThemeId ?? "t3code"); + if (theme) applyThemeTokens(theme, cached.accentHue ?? null); + } +} catch { + // Cache unreadable — fall through to React reconciliation. +} + +// ── System-dark external store (for "system" mode live updates) ── + +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); + }; +} + +// ── Hook ───────────────────────────────────────────────────────── + +export function useAppearance() { + const { colorMode, activeThemeId, accentHue } = useSettings((s) => ({ + colorMode: s.colorMode, + activeThemeId: s.activeThemeId, + accentHue: s.accentHue, + })); + const { updateSettings } = useUpdateSettings(); + const activeTheme = findThemeById(activeThemeId) ?? BUILT_IN_THEMES[0]!; + + // Track system dark preference reactively + const systemDark = useSyncExternalStore(subscribeSystemDark, getSystemDarkSnapshot); + + const resolvedTheme: "light" | "dark" = useMemo( + () => (colorMode === "system" ? (systemDark ? "dark" : "light") : colorMode), + [colorMode, systemDark], + ); + + // Apply .dark class, theme tokens, cache, and relay to Electron + useEffect(() => { + suppressTransitions(() => { + document.documentElement.classList.toggle("dark", resolvedTheme === "dark"); + applyThemeTokens(activeTheme, accentHue); + }); + + // Write-through cache for FOUC prevention on next load + localStorage.setItem( + APPEARANCE_CACHE_KEY, + JSON.stringify({ colorMode, activeThemeId, accentHue }), + ); + + // Sync to Electron + syncDesktopAppearance({ mode: colorMode, themeId: activeThemeId, accentHue }); + + return () => removeThemeTokens(); + }, [resolvedTheme, activeTheme, accentHue, colorMode, activeThemeId]); + + const setColorMode = useCallback( + (mode: ColorMode) => updateSettings({ colorMode: mode }), + [updateSettings], + ); + + const setThemeId = useCallback( + (id: string) => updateSettings({ activeThemeId: id }), + [updateSettings], + ); + + const setAccentHue = useCallback( + (hue: number | null) => updateSettings({ accentHue: hue }), + [updateSettings], + ); + + return { + colorMode, + resolvedTheme, + activeTheme, + accentHue, + setColorMode, + setThemeId, + setAccentHue, + } 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/lib/themes.ts b/apps/web/src/lib/themes.ts new file mode 100644 index 0000000000..6ba4bb7792 --- /dev/null +++ b/apps/web/src/lib/themes.ts @@ -0,0 +1,206 @@ +/** + * Theme definitions, CSS injection, and accent color derivation. + * + * V1 themes override accent/semantic colors only: + * primary, primary-foreground, ring, + * destructive, destructive-foreground, + * info, info-foreground, + * success, success-foreground, + * warning, warning-foreground + * + * Background/surface tokens stay controlled by the CSS baseline in index.css. + */ + +// ── Token types ────────────────────────────────────────────────── + +/** + * The set of CSS custom-property names a theme may override. + * Uses the unprefixed form (e.g. "primary", not "--color-primary"). + */ +export type ThemeToken = + | "primary" + | "primary-foreground" + | "ring" + | "destructive" + | "destructive-foreground" + | "info" + | "info-foreground" + | "success" + | "success-foreground" + | "warning" + | "warning-foreground"; + +export type ThemeTokenMap = Partial>; + +export interface ThemeDefinition { + id: string; + name: string; + description: string; + builtIn: boolean; + light: ThemeTokenMap; + dark: ThemeTokenMap; +} + +// ── Built-in themes ────────────────────────────────────────────── + +const T3CODE_THEME: ThemeDefinition = { + id: "t3code", + name: "T3Code", + description: "Default color palette.", + builtIn: true, + light: {}, + dark: {}, +}; + +const HIGH_CONTRAST_THEME: ThemeDefinition = { + id: "high-contrast", + name: "High Contrast", + description: "Boosted contrast for WCAG AAA.", + builtIn: true, + light: { + primary: "oklch(0.35 0.25 264)", + "primary-foreground": "oklch(1 0 0)", + ring: "oklch(0.35 0.25 264)", + destructive: "oklch(0.45 0.28 25)", + "destructive-foreground": "oklch(0.30 0.22 25)", + info: "oklch(0.45 0.22 250)", + "info-foreground": "oklch(0.30 0.18 250)", + success: "oklch(0.42 0.20 155)", + "success-foreground": "oklch(0.28 0.16 155)", + warning: "oklch(0.55 0.22 70)", + "warning-foreground": "oklch(0.38 0.18 70)", + }, + dark: { + primary: "oklch(0.75 0.22 264)", + "primary-foreground": "oklch(0.15 0 0)", + ring: "oklch(0.75 0.22 264)", + destructive: "oklch(0.72 0.26 25)", + "destructive-foreground": "oklch(0.82 0.18 25)", + info: "oklch(0.72 0.20 250)", + "info-foreground": "oklch(0.82 0.14 250)", + success: "oklch(0.72 0.20 155)", + "success-foreground": "oklch(0.82 0.14 155)", + warning: "oklch(0.78 0.20 70)", + "warning-foreground": "oklch(0.86 0.14 70)", + }, +}; + +const COLOR_BLIND_THEME: ThemeDefinition = { + id: "color-blind", + name: "Color Blind", + description: "Deuteranopia-safe palette.", + builtIn: true, + light: { + success: "oklch(0.65 0.15 195)", + "success-foreground": "oklch(0.45 0.12 195)", + warning: "oklch(0.75 0.16 65)", + "warning-foreground": "oklch(0.50 0.14 65)", + destructive: "oklch(0.55 0.22 350)", + "destructive-foreground": "oklch(0.40 0.18 350)", + }, + dark: { + success: "oklch(0.70 0.14 195)", + "success-foreground": "oklch(0.82 0.10 195)", + warning: "oklch(0.78 0.15 65)", + "warning-foreground": "oklch(0.86 0.10 65)", + destructive: "oklch(0.68 0.20 350)", + "destructive-foreground": "oklch(0.82 0.14 350)", + }, +}; + +export const BUILT_IN_THEMES: readonly ThemeDefinition[] = [ + T3CODE_THEME, + HIGH_CONTRAST_THEME, + COLOR_BLIND_THEME, +]; + +export const DEFAULT_THEME_ID = "t3code"; + +// ── Accent presets ─────────────────────────────────────────────── + +export interface AccentPreset { + name: string; + hue: number; +} + +export const ACCENT_PRESETS: readonly AccentPreset[] = [ + { name: "Red", hue: 25 }, + { name: "Orange", hue: 55 }, + { name: "Yellow", hue: 85 }, + { name: "Green", hue: 145 }, + { name: "Teal", hue: 185 }, + { name: "Blue", hue: 240 }, + { name: "Purple", hue: 290 }, + { name: "Pink", hue: 340 }, +]; + +// ── Lookup ─────────────────────────────────────────────────────── + +export function findThemeById(id: string): ThemeDefinition | undefined { + return BUILT_IN_THEMES.find((t) => t.id === id); +} + +// ── Accent derivation (oklch) ──────────────────────────────────── + +export function deriveAccentColors(hue: number): { light: ThemeTokenMap; dark: ThemeTokenMap } { + return { + light: { + primary: `oklch(0.488 0.217 ${hue})`, + "primary-foreground": "oklch(1 0 0)", + ring: `oklch(0.488 0.217 ${hue})`, + }, + dark: { + primary: `oklch(0.588 0.217 ${hue})`, + "primary-foreground": "oklch(1 0 0)", + ring: `oklch(0.588 0.217 ${hue})`, + }, + }; +} + +// ── CSS injection ──────────────────────────────────────────────── + +const STYLE_ELEMENT_ID = "t3code-theme-tokens"; + +function buildCssBlock(selector: string, tokens: ThemeTokenMap): string { + const entries = Object.entries(tokens) as [ThemeToken, string][]; + if (entries.length === 0) return ""; + const declarations = entries.map(([token, value]) => ` --color-${token}: ${value};`).join("\n"); + return `${selector} {\n${declarations}\n}`; +} + +/** + * Inject or replace a `