Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
6.6 kB · 193 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194/** * 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 <html> 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<HTMLElement>(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 <select>, so the whole thing is one click deep once * the panel is open. They are a group with a name of its own because "System" * on its own says nothing about what it sets. */function themeControl(): HTMLElement { const buttons = CHOICES.map(({ theme, label }) => { const button = el("button", { type: "button", className: "theme-choice", textContent: label, }); button.dataset.theme = theme; button.addEventListener("click", () => { setTheme(theme); mark(); }); return button; });
const mark = (): void => { const chosen = currentTheme(); for (const button of buttons) { button.setAttribute( "aria-pressed", String(button.dataset.theme === chosen), ); } }; mark();
const group = el("div", { className: "theme-choices" }, buttons); group.setAttribute("role", "group"); group.setAttribute("aria-label", "Theme");
return el("div", { className: "theme-rows" }, [ el("span", { className: "theme-label", textContent: "Theme" }), group, ]);}
const SVG_NS = "http://www.w3.org/2000/svg";
/** * A circle with one half filled — the same glyph whichever scheme is on. * * Deliberately not a sun or a moon: with System in the list there are three * answers and no icon says the third one, and an icon that changed would be * claiming to report the setting rather than to open it. What the setting is * is written inside the panel, where it is pressed. */function icon(): SVGSVGElement { const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("class", "theme-icon"); svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false");
const ring = document.createElementNS(SVG_NS, "circle"); ring.setAttribute("cx", "8"); ring.setAttribute("cy", "8"); ring.setAttribute("r", "6.25"); ring.setAttribute("fill", "none"); ring.setAttribute("stroke", "currentColor"); ring.setAttribute("stroke-width", "1.5");
const half = document.createElementNS(SVG_NS, "path"); half.setAttribute("d", "M8 1.75a6.25 6.25 0 0 0 0 12.5z"); half.setAttribute("fill", "currentColor");
svg.append(ring, half); return svg;}
/** localStorage throws in private modes and when full; never let that matter. */function read(): string | null { try { return localStorage.getItem(STORE_KEY); } catch { return null; }}
function write(theme: Theme): void { try { if (theme === "system") localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, theme); } catch { // The page is already in the right scheme. It will not be after a reload. }}