/** * Which colour scheme the site is drawn in, and the masthead control that * changes it. * * The control used to be a row in the account menu, which meant the only * people who could reach it were the ones who had signed in. Nothing about a * colour scheme is about an account — a reader of the blog has as much of an * opinion as a player does — so it is its own control on the bar now, mounted * by the layout on every page and never waiting on /api/session. * * There are three settings, not two. The stylesheet already followed the * browser's own preference, and a two-way toggle would have taken that away * from anyone who touched it once: System is the default and the way back. * * All of this is one attribute. `data-theme` on narrows `color-scheme` * to a single value, and every light-dark() pair in styles.css resolves * against it; nothing else in the app reads the setting. The choice is * remembered in localStorage rather than on the account, because it is about * this browser on this machine — the same player on a phone wants their own * answer, and there is nowhere on the blog's pages to ask the API anyway. * * The inline script in Base.astro reads the same key before the first paint, * so a light-mode reader never sees a dark page flash past. That duplication * is deliberate — an inline script cannot import this file — and * scripts/theme.test.mjs is what keeps the two spellings of the key together. */ import { el } from "./dom"; import { attachMenu } from "./menu"; export type Theme = "system" | "light" | "dark"; /** The empty box the layout leaves in the masthead, beside the account's. */ const SLOT = "#theme"; /** Also spelled out in Base.astro's inline script. Change both. */ export const STORE_KEY = "lance.blue:theme"; const CHOICES: { theme: Theme; label: string }[] = [ { theme: "system", label: "System" }, { theme: "light", label: "Light" }, { theme: "dark", label: "Dark" }, ]; /** What is saved, or System — including for anything unrecognised. */ export function currentTheme(): Theme { const saved = read(); return saved === "light" || saved === "dark" ? saved : "system"; } /** Draw the page in this scheme from now on, and remember it. */ export function setTheme(theme: Theme): void { applyTheme(theme); write(theme); } /** * The attribute, and nothing else. System removes it rather than writing a * third value, so the `color-scheme: dark light` on :root is what is left and * the browser decides again. */ export function applyTheme(theme: Theme): void { const root = document.documentElement; if (theme === "system") root.removeAttribute("data-theme"); else root.dataset.theme = theme; } /** Called by the layout's script, on every page. */ export function mountTheme(): void { const slot = document.querySelector(SLOT); if (!slot) return; slot.replaceChildren(themeMenu()); } /** * The control on the bar: a button that opens a panel holding the choices. * * A panel rather than three buttons out on the masthead itself. The bar * already carries five destinations and the account, and a setting nobody * changes twice does not earn a permanent seat among them. */ export function themeMenu(): HTMLElement { const button = el("button", { type: "button", className: "theme-button" }, [ icon(), ]); // The button is an icon, so it has no text to be named by. button.setAttribute("aria-label", "Theme"); button.setAttribute("aria-haspopup", "true"); button.setAttribute("aria-expanded", "false"); const panel = el("div", { className: "theme-menu", hidden: true }, [ themeControl(), ]); const control = el("div", { className: "theme-control" }, [button, panel]); attachMenu(control, button, panel); return control; } /** * The choices themselves: a label and three buttons, one of them pressed. * * Buttons rather than a