// The theme controller. // // The kit is themed by TOKENS, not by a `dark:` variant on every component (see // styles/theme.css). Components say bg-surface / text-ink / border-line and never // name a colour; a `.dark` class on re-points what those tokens mean. So all // this module does is put a class on an element — the four hundred class strings in // the kit are none the wiser. // // The storage key is deliberately the SAME one the Go/WASM kit uses // (webui.ThemeBootScript, webui.themeStorageKey). Both halves of kjol-web are served // from one origin, so they share a localStorage: choose dark in the /wasm section, // walk over to /js, and it is still dark. Two front-ends, one preference. import { createSignal, onCleanup } from "solid-js"; import { Icon } from "./Icons.tsx"; export type ThemeMode = "system" | "light" | "dark"; export const THEME_STORAGE_KEY = "kjol-theme"; const prefersDark = (): boolean => { try { return window.matchMedia("(prefers-color-scheme: dark)").matches; } catch { return false; // SSR: no window. The boot script settles it in the browser. } }; const readMode = (): ThemeMode => { try { const m = localStorage.getItem(THEME_STORAGE_KEY); if (m === "light" || m === "dark") return m; } catch { // Private mode, or SSR. "system" is the right answer in both. } return "system"; }; const [mode, setModeSignal] = createSignal(readMode()); // isDark is the RESOLVED answer — what "system" actually means right now — as opposed // to `mode`, which is what the user asked for. The toggle needs the former; the // three-way picker needs the latter. They are not the same question. const [isDark, setIsDark] = createSignal(false); const resolve = (m: ThemeMode): boolean => m === "dark" || (m === "system" && prefersDark()); function apply(m: ThemeMode) { const dark = resolve(m); setIsDark(dark); try { document.documentElement.classList.toggle("dark", dark); } catch { // SSR. Nothing to toggle; the boot script has already done it for real. } } /** Set the theme and remember it. "system" forgets, rather than storing the word. */ export function setTheme(m: ThemeMode) { setModeSignal(m); try { if (m === "system") localStorage.removeItem(THEME_STORAGE_KEY); else localStorage.setItem(THEME_STORAGE_KEY, m); } catch { // Not fatal — the theme still applies for this page. } apply(m); } /** Flip between light and dark, resolving "system" to whatever it currently means. */ export function toggleTheme() { setTheme(isDark() ? "light" : "dark"); } // initTheme syncs this module's signals with the class the BOOT SCRIPT already put on // , and starts following the OS while the mode is "system". // // It does not cause the first paint — that already happened, correctly, before any of // this code existed. Calling it late is therefore harmless; not calling it at all just // means the toggle button starts out showing the wrong icon. // // Call it once, from a component (it registers a cleanup). export function initTheme() { apply(mode()); try { const mq = window.matchMedia("(prefers-color-scheme: dark)"); const onChange = () => { // Only while the user has expressed no preference. Once they have picked a // side, the OS changing its mind is not an instruction. if (mode() === "system") apply("system"); }; mq.addEventListener("change", onChange); onCleanup(() => mq.removeEventListener("change", onChange)); } catch { // SSR, or a browser too old for matchMedia events. Neither is worth a crash. } } export function useTheme() { return { mode, isDark, setTheme, toggleTheme }; } interface ThemeToggleProps { small?: boolean; class?: string; } export function ThemeToggle(props: ThemeToggleProps) { const size = () => (props.small ? 14 : 16); return ( ); } // THEME_BOOT_SCRIPT is the inline script an app puts in its , BEFORE any // stylesheet or markup. // // It exists to prevent the flash. The server cannot read localStorage, so it cannot // know which theme to render; if the class were applied by this module after the // bundle loads, every dark-mode user would be shown a white page for as long as the // JavaScript takes to arrive, and then have it yanked out from under them. This runs // first, synchronously, and so the first paint is already correct. // // It is byte-for-byte equivalent to the Go/WASM kit's webui.ThemeBootScript, and reads // the same key. A server that already emits that one does not need this. export const THEME_BOOT_SCRIPT = ``;