Graphite replaces the signal-green accent as the default web palette; data-palette on <html> selects ice-cyan, periwinkle, amber-phosphor, ember or orchid (each with dark + light token sets), picked from a new sidebar-footer dropdown and persisted in localStorage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
3.9 KiB
TypeScript
124 lines
3.9 KiB
TypeScript
// 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 <html> 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 <html> 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 = `<span class="moon" aria-hidden="true">☾</span><span class="sun" aria-hidden="true">☀</span>`;
|
|
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;
|
|
}
|