/* * Theme controller. * * Three preferences: "system" (default), "light", "dark". The choice is * stored in localStorage and applied as `data-theme` on ; "system" * removes the attribute so the `prefers-color-scheme` block in tokens.css * takes over. A small inline snippet in each page's applies the stored * value before first paint, so this module only has to own the control. */ export const THEME_STORAGE_KEY = "jw-theme"; const THEMES = ["system", "light", "dark"]; /* Must match --jw-bg in tokens.css for each theme. */ const BROWSER_CHROME = { light: "#fafafa", dark: "#0b100f" }; const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); const listeners = new Set(); function readStored() { try { const stored = localStorage.getItem(THEME_STORAGE_KEY); return THEMES.includes(stored) ? stored : "system"; } catch { /* Private mode or blocked storage: fall back to the system preference. */ return "system"; } } /** The preference the user chose ("system" | "light" | "dark"). */ export function getPreference() { return readStored(); } /** The theme actually on screen ("light" | "dark"), resolving "system". */ export function getResolvedTheme(preference = readStored()) { if (preference !== "system") return preference; return darkQuery.matches ? "dark" : "light"; } export function applyTheme(preference) { const next = THEMES.includes(preference) ? preference : "system"; const root = document.documentElement; if (next === "system") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", next); const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.setAttribute("content", BROWSER_CHROME[getResolvedTheme(next)]); try { localStorage.setItem(THEME_STORAGE_KEY, next); } catch { /* Not fatal: the theme still applies for this page view. */ } for (const listener of listeners) listener(next, getResolvedTheme(next)); return next; } /** Subscribe to preference changes. Returns an unsubscribe function. */ export function onThemeChange(listener) { listeners.add(listener); return () => listeners.delete(listener); } /* Keep the browser chrome correct when the OS flips while set to "system". */ darkQuery.addEventListener("change", () => { if (readStored() === "system") applyTheme("system"); }); const ICONS = { system: '', light: '', dark: '' }; const LABELS = { system: "Match system theme", light: "Light theme", dark: "Dark theme" }; /** * Renders the segmented theme control into `container` and keeps its pressed * state in sync with the current preference. */ export function mountThemeToggle(container) { if (!container) return null; container.classList.add("theme-toggle"); container.setAttribute("role", "group"); container.setAttribute("aria-label", "Color theme"); container.innerHTML = THEMES.map((theme) => ` `).join(""); const buttons = [...container.querySelectorAll("[data-theme-option]")]; function sync(preference = readStored()) { for (const button of buttons) { button.setAttribute("aria-pressed", String(button.dataset.themeOption === preference)); } } container.addEventListener("click", (event) => { const button = event.target.closest("[data-theme-option]"); if (button) applyTheme(button.dataset.themeOption); }); onThemeChange(sync); sync(); return { sync }; } /** * The snippet that must run before first paint. Kept here so the inline copy * in each page's has a single documented source; if you change it, * update index.html and design/index.html to match. */ export const PREPAINT_SNIPPET = `(function(){try{var t=localStorage.getItem("${THEME_STORAGE_KEY}");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}})()`;