/** * @file useTheme.ts * @description Appearance state: a mode (`dark` | `light` | `system`) plus one * named theme remembered PER mode, all backed by `localStorage`. Applies the * `dark` class Tailwind's `darkMode: "class"` reads, `data-mode`, and a * `data-theme` attribute (absent for `default`) on ``; the palettes * themselves are CSS-variable blocks in `index.css`. State is never cached in * the module — every instance reads storage + `matchMedia` through * `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and * another tab's change arrives through the `storage` event. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useLayoutEffect, useSyncExternalStore } from "react"; export type Theme = "dark" | "light"; export type ThemeMode = Theme | "system"; /** Named themes per mode, in picker order. `default` is the stock palette. */ export const THEMES = { dark: ["default", "darcula", "apple-dark", "orca-dark"], light: ["default", "apple-light", "canvas", "orca-light"], } as const satisfies Record; const MODE_KEY = "theme"; const themeKey = (mode: Theme) => `agenthub.theme.${mode}`; const CHANGE_EVENT = "agenthub-theme"; const SYSTEM_QUERY = "(prefers-color-scheme: dark)"; function read(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } } function write(key: string, value: string): void { try { localStorage.setItem(key, value); } catch { /* ignore quota / disabled storage */ } window.dispatchEvent(new Event(CHANGE_EVENT)); } function systemQuery(): MediaQueryList | undefined { return typeof window.matchMedia === "function" ? window.matchMedia(SYSTEM_QUERY) : undefined; } function readMode(): ThemeMode { const v = read(MODE_KEY); return v === "light" || v === "system" ? v : "dark"; } function readThemeFor(mode: Theme): string { const v = read(themeKey(mode)); return v && (THEMES[mode] as readonly string[]).includes(v) ? v : "default"; } // A primitive snapshot: stable by value, so useSyncExternalStore never loops. function getSnapshot(): string { const mode = readMode(); const resolved: Theme = mode === "system" ? (systemQuery()?.matches === false ? "light" : "dark") : mode; return [mode, resolved, readThemeFor("dark"), readThemeFor("light")].join("|"); } function subscribe(onChange: () => void): () => void { const mq = systemQuery(); window.addEventListener(CHANGE_EVENT, onChange); window.addEventListener("storage", onChange); mq?.addEventListener?.("change", onChange); return () => { window.removeEventListener(CHANGE_EVENT, onChange); window.removeEventListener("storage", onChange); mq?.removeEventListener?.("change", onChange); }; } function apply(resolved: Theme, name: string): void { const root = document.documentElement; root.classList.toggle("dark", resolved === "dark"); root.dataset.mode = resolved; if (name === "default") root.removeAttribute("data-theme"); else root.dataset.theme = name; } /** * Read/write the dashboard's appearance. * @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what * the user picked; `themeFor` — the named theme each mode remembers; setters * for each, and `toggleTheme`, which sets the explicit opposite of `theme`. */ export function useTheme(): { theme: Theme; mode: ThemeMode; themeFor: Record; setTheme: (theme: Theme) => void; setMode: (mode: ThemeMode) => void; setThemeFor: (mode: Theme, name: string) => void; toggleTheme: () => void; } { const [mode, resolved, dark, light] = useSyncExternalStore(subscribe, getSnapshot).split("|") as [ ThemeMode, Theme, string, string, ]; const active = resolved === "dark" ? dark : light; useLayoutEffect(() => { apply(resolved, active); }, [resolved, active]); const setMode = useCallback((next: ThemeMode) => write(MODE_KEY, next), []); const setThemeFor = useCallback((forMode: Theme, name: string) => { if ((THEMES[forMode] as readonly string[]).includes(name)) write(themeKey(forMode), name); }, []); const toggleTheme = useCallback(() => { setMode(resolved === "dark" ? "light" : "dark"); }, [resolved, setMode]); return { theme: resolved, mode, themeFor: { dark, light }, setTheme: setMode, setMode, setThemeFor, toggleTheme, }; }