"use client"; import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState, } from "react"; import { BASE_GROUNDS, DEFAULT_ACCENT } from "../lib/brand"; import { BASE_KEY, LEGACY_MODE_KEY, LEGACY_THEME_KEY, RETIRED_BASE, isThemeBase, migrateLegacy, nextBase, resolveBase, storedBase, type ResolvedBase, type ThemeAccent, type ThemeBase, } from "./themeConfig"; // Runtime theme controller: the reader's BASE (light | dark | system). The // ACCENT is the site's own (`siteAccent`), never the reader's: a stored pick // from before is neither read nor removed. // // The pre-paint colours the FIRST paint by setting attributes on // from localStorage before hydration. Hydration can reset 's // attributes to what the server rendered — the className without `.dark` — so // on mount this provider RE-ASSERTS the persisted, resolved theme (data-base, // .dark, data-accent, theme-color) in a useLayoutEffect, before paint. That is // idempotent: it recomputes what the script computed from the same storage, so // it never fights the script and never flashes. After that it writes to the // DOM only on a reader's change and on a live OS-preference change. type ThemeContextValue = { /** The reader's choice, "system" included. */ base: ThemeBase; /** The ground actually painted ("system" resolved against the OS). */ resolvedBase: ResolvedBase; /** Whether the dark base is applied. */ isDark: boolean; /** The accent applied: the site's own ("custom" for a site with its own * hex). */ accent: ThemeAccent; setBase: (b: ThemeBase) => void; /** system → light → dark → system. */ cycleBase: () => void; }; const ThemeContext = createContext(null); const DARK_QUERY = "(prefers-color-scheme: dark)"; function systemPrefersDark(): boolean { try { return window.matchMedia(DARK_QUERY).matches; } catch { return false; } } // The stored base, after the same one-time migrations the pre-paint script // runs (a no-op when the script already ran them): the legacy keys, and a // stored RETIRED_BASE rewritten to "light". function readStoredBase(): ThemeBase | null { try { const s = window.localStorage; let base = s.getItem(BASE_KEY); const theme = s.getItem(LEGACY_THEME_KEY); const mode = s.getItem(LEGACY_MODE_KEY); if (theme !== null || mode !== null) { if (base === null) { const migrated = migrateLegacy({ theme, mode }); if (migrated) { s.setItem(BASE_KEY, migrated); base = migrated; } } s.removeItem(LEGACY_THEME_KEY); s.removeItem(LEGACY_MODE_KEY); } if (base === RETIRED_BASE) s.setItem(BASE_KEY, "light"); return storedBase(base); } catch { return null; } } function applyToDom(resolved: ResolvedBase, accent: ThemeAccent) { const d = document.documentElement; d.setAttribute("data-base", resolved); d.classList.toggle("dark", resolved === "dark"); d.setAttribute("data-accent", accent); for (const m of document.querySelectorAll('meta[name="theme-color"]')) { m.setAttribute("content", BASE_GROUNDS[resolved]); } } function store(key: string, value: string) { try { window.localStorage.setItem(key, value); } catch { /* storage disabled: the choice holds for this page only */ } } export function ThemeProvider({ defaultBase = "system", siteAccent = DEFAULT_ACCENT, children, }: { defaultBase?: ThemeBase; siteAccent?: ThemeAccent; children: React.ReactNode; }) { // Initial state MUST equal what the server rendered (the defaults), so // hydration matches; the persisted base is adopted just below. const [base, setBaseState] = useState(defaultBase); const [systemDark, setSystemDark] = useState(false); const [adopted, setAdopted] = useState(false); const accent = siteAccent; // Adopt the persisted choice before paint. The state updates re-render // synchronously, and the effect below then re-asserts it to . useLayoutEffect(() => { setBaseState(readStoredBase() ?? defaultBase); setSystemDark(systemPrefersDark()); setAdopted(true); // Effectively once: the default is a stable prop from the layout. }, [defaultBase]); // A LIVE OS preference: "system" follows a change made while the page is // open (the pre-paint script can only read it once). useEffect(() => { let mql: MediaQueryList; try { mql = window.matchMedia(DARK_QUERY); } catch { return; } const onChange = () => setSystemDark(mql.matches); onChange(); mql.addEventListener("change", onChange); return () => mql.removeEventListener("change", onChange); }, []); const resolvedBase = resolveBase(base, systemDark); // Re-assert — once adopted, and on every change after that. Nothing is // written before adoption, so the server's defaults never overwrite what the // pre-paint script set. useLayoutEffect(() => { if (adopted) applyToDom(resolvedBase, accent); }, [adopted, resolvedBase, accent]); const setBase = useCallback((b: ThemeBase) => { if (!isThemeBase(b)) return; setBaseState(b); store(BASE_KEY, b); }, []); const cycleBase = useCallback(() => setBase(nextBase(base)), [base, setBase]); const value = useMemo( () => ({ base, resolvedBase, isDark: resolvedBase === "dark", accent, setBase, cycleBase, }), [base, resolvedBase, accent, setBase, cycleBase], ); return {children}; } export function useTheme(): ThemeContextValue { const ctx = useContext(ThemeContext); if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); return ctx; }