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