// Theme: dark (default) + light, persisted. When the user hasn't pinned a // theme we follow the OS via CSS `prefers-color-scheme`; pinning sets an // explicit `data-theme` on that overrides the media query. // // Palette: orthogonal to light/dark. Each palette ships both a dark and a // light token set in styles.css; `data-palette` on selects it, and // graphite — the default — is the bare document with no attribute. type Theme = "light" | "dark"; const KEY = "maestro-theme"; /** Every `data-palette` value, in picker order. Graphite is the default. */ export const PALETTES = [ "graphite", "ice-cyan", "periwinkle", "amber-phosphor", "ember", "orchid", ] as const; export type Palette = (typeof PALETTES)[number]; const PALETTE_KEY = "maestro-palette"; function stored(): Theme | null { const v = localStorage.getItem(KEY); return v === "light" || v === "dark" ? v : null; } function systemTheme(): Theme { return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; } /** The theme actually showing right now (pinned or system). */ export function resolvedTheme(): Theme { return stored() ?? systemTheme(); } function storedPalette(): Palette | null { const v = localStorage.getItem(PALETTE_KEY); return (PALETTES as readonly string[]).includes(v ?? "") ? (v as Palette) : null; } /** The palette in force (stored or the graphite default). */ export function resolvedPalette(): Palette { return document.documentElement.dataset.palette as Palette | undefined ?? storedPalette() ?? "graphite"; } /** Pin a palette. Graphite is the bare document, so it clears the attribute. */ export function setPalette(p: Palette): void { localStorage.setItem(PALETTE_KEY, p); if (p === "graphite") delete document.documentElement.dataset.palette; else document.documentElement.dataset.palette = p; } /** Apply the pinned theme + palette at boot (no-ops when unpinned). */ export function initTheme(): void { // Dev-only `?theme=light|dark` override for visual review; stripped in prod. if (import.meta.env.DEV) { const q = new URLSearchParams(location.search).get("theme"); if (q === "light" || q === "dark") { document.documentElement.dataset.theme = q; applyStoredPalette(); return; } } const pinned = stored(); if (pinned) document.documentElement.dataset.theme = pinned; applyStoredPalette(); } function applyStoredPalette(): void { const p = storedPalette(); if (p && p !== "graphite") document.documentElement.dataset.palette = p; } function setTheme(t: Theme): void { localStorage.setItem(KEY, t); document.documentElement.dataset.theme = t; } /** A header button that flips between light and dark. */ export function createThemeToggle(): HTMLButtonElement { const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn btn-ghost btn-icon theme-toggle"; btn.innerHTML = ``; const sync = () => { const next = resolvedTheme() === "dark" ? "light" : "dark"; btn.setAttribute("aria-label", `Switch to ${next} theme`); btn.title = `Switch to ${next} theme`; }; sync(); btn.addEventListener("click", () => { setTheme(resolvedTheme() === "dark" ? "light" : "dark"); sync(); }); return btn; } /** A footer dropdown that picks the colour palette. */ export function createPaletteSelect(): HTMLSelectElement { const select = document.createElement("select"); select.className = "palette-select"; select.setAttribute("aria-label", "Colour palette"); select.title = "Colour palette"; for (const p of PALETTES) { const opt = document.createElement("option"); opt.value = p; opt.textContent = p; select.appendChild(opt); } select.value = resolvedPalette(); select.addEventListener("change", () => { setPalette(select.value as Palette); }); return select; }