"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;
}