From 4622b9ac691a459296183f8f43db1d71294825e3 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Mon, 14 Sep 2026 17:54:00 +0100 Subject: [PATCH] feat(data-explorer): give the archive browser a theme switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It has answered `prefers-color-scheme` since it was written, with a comment saying there is deliberately no switch — so someone on a light OS who wants dark, or on a dark OS reading in daylight, has no way to say so. The OS preference stays the default and gains two overrides. The dark values move onto the map's (#0d1b2a canvas, #132240 card, #38bdf8 accent, and the brightened semantics) from the slate set they had. That set was a third palette: the light tokens were already verbatim from the console, but the dark ones answered to neither the console nor the map, and the brand guide's §10 names drift as the estate's main failure mode. Anyone browsing in dark today will see the change; nobody loses a theme. `system` stamps no attribute and lets the media query answer, which is what keeps the OS preference working when it changes mid-session; `:root:not([data-theme="light"])` is what lets an explicit light choice win over a dark OS. Stamping a resolved value instead would pin the page to whatever the OS was at load. Two details the diff does not explain: - The pre-paint script is a file rather than an inline tag, for exactly the reason vendor/ exists: this vhost sends `script-src 'self'`, which does not cover inline code, while a laptop serving these files sends none — so an inline version works in every local check and silently never runs once deployed. - Merely opening the page persists nothing. This surface is public and unauthenticated, so `showTheme` and `chooseTheme` are separate and only a click on the control writes storage. `--accent-ink` is new because the dark accent is a bright sky blue: the white that inked the brand mark and the primary button is about 1.8:1 on it. The switch is three icons rather than three words: Feather's sun, monitor and moon, ordered light to dark with the neutral between them, inlined in the same house style the dashboard's sidebar uses (24-unit box, no fill, 2-unit round-capped stroke in currentColor). No icon package is installed anywhere in this repo, and three glyphs do not justify adding one. Drawing in currentColor is what lets the selected segment tint itself from the rule that already sets its ink. A glyph names nothing on its own, so each button carries an aria-label and a matching title, and the svg is aria-hidden so the name is not announced twice. Choosing role=radiogroup over three independent toggles is what obliges the keyboard half: the set is announced as one control with three options, so it is one tab stop rather than three (a roving tabindex, seeded in the markup for before app.js runs) and the arrows move within it, selecting as they go. Home and End reach the ends. Everything else passes through — without preventDefault the arrows scroll the page while the selection moves underneath. Verified in a browser: a first visit writes no storage and follows the OS, a click persists and survives a reload, and returning to system clears the attribute and hands control back to the media query. Co-Authored-By: Claude Opus 5 --- data-explorer/README.md | 4 +- data-explorer/app.css | 54 ++++++++++++++++++++++----- data-explorer/app.js | 73 +++++++++++++++++++++++++++++++++++++ data-explorer/index.html | 27 +++++++++++++- data-explorer/theme-boot.js | 19 ++++++++++ 5 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 data-explorer/theme-boot.js diff --git a/data-explorer/README.md b/data-explorer/README.md index bae173c4..602541a9 100644 --- a/data-explorer/README.md +++ b/data-explorer/README.md @@ -8,8 +8,10 @@ and the page talks to the same-origin API. ``` data-explorer/ index.html markup + the CommonJS shim that loads the vendored timeline - app.css tokens (light palette verbatim from dashboard/src/App.css) + layout + app.css tokens (light from dashboard/src/App.css, dark from the map) + layout app.js all behaviour, one classic script, plain ES2020 + theme-boot.js stamps the saved theme before first paint; a file, not inline, + because the vhost's CSP is `script-src 'self'` vendor/ react, react-dom, lodash, classnames, @edsc/timeline — see NOTICE.md ``` diff --git a/data-explorer/app.css b/data-explorer/app.css index 7653cd67..81316b9e 100644 --- a/data-explorer/app.css +++ b/data-explorer/app.css @@ -1,11 +1,22 @@ /* Retina Data Explorer — no build step, no framework. - Tokens: the light palette is verbatim from dashboard/src/App.css so the two - sites read as one product; dark is its counterpart, applied from the OS - preference only (there is no theme switch here). */ + + Tokens: the light palette is verbatim from dashboard/src/App.css and the dark + one is the map's (frontend/src/map-surface.css), so a surface of the estate is + recognisable in either theme. Light stays on the bare selector because it is + the default; dark arrives either from the OS preference or from the switch in + the topbar, and the two dark blocks are identical by hand — CSS cannot share a + declaration block across a media query boundary, and there is no build step + here to do it for us. */ :root{ --bg-primary:#f1f5f9;--bg-secondary:#ffffff;--bg-card:#ffffff;--bg-card-hover:#f8fafc;--bg-input:#f8fafc; + /* A light canvas is already darker than a card, so a recessed region is made + by letting it show through; dark inverts the ramp and needs its own. */ + --bg-sunk:#f1f5f9; --text-primary:#0f172a;--text-secondary:#475569;--text-muted:#94a3b8;--border:#e2e8f0;--border-light:#cbd5e1; --accent:#3b82f6;--accent-hover:#2563eb;--accent-light:rgba(59,130,246,.10); + /* Ink for what sits ON the accent. A token and not a literal white because + the dark accent is a bright sky blue, where white is about 1.8:1. */ + --accent-ink:#ffffff; --success:#10b981;--success-light:rgba(16,185,129,.10);--warning:#f59e0b;--warning-light:rgba(245,158,11,.10); --error:#ef4444;--error-light:rgba(239,68,68,.10); --shadow:rgba(15,23,42,.14);--scrim:rgba(15,23,42,.35); @@ -13,11 +24,26 @@ --mono:"SF Mono","Fira Code",ui-monospace,Menlo,Consolas,monospace; color-scheme:light; } -@media (prefers-color-scheme:dark){:root{ - --bg-primary:#0f172a;--bg-secondary:#131c2e;--bg-card:#131c2e;--bg-card-hover:#1b2740;--bg-input:#0b1220; - --text-primary:#e2e8f0;--text-secondary:#94a3b8;--text-muted:#64748b;--border:#1e293b;--border-light:#334155; - --accent-light:rgba(59,130,246,.14);--success-light:rgba(16,185,129,.12);--warning-light:rgba(245,158,11,.12);--error-light:rgba(239,68,68,.12); +/* `:not([data-theme="light"])` is what lets an explicit light choice win over a + dark OS. */ +@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){ + --bg-primary:#0d1b2a;--bg-secondary:#132240;--bg-card:#132240;--bg-card-hover:#1a2b4d;--bg-input:rgba(15,30,55,.9); + --bg-sunk:#0f2035; + --text-primary:#e2e8f0;--text-secondary:#94a3b8;--text-muted:#64748b; + --border:rgba(100,180,255,.14);--border-light:rgba(100,180,255,.28); + --accent:#38bdf8;--accent-hover:#7dd3fc;--accent-light:rgba(56,189,248,.16);--accent-ink:#082f49; + --success:#4ade80;--success-light:rgba(74,222,128,.15);--warning:#fbbf24;--warning-light:rgba(251,191,36,.15); + --error:#f43f5e;--error-light:rgba(244,63,94,.15); --shadow:rgba(0,0,0,.5);--scrim:rgba(2,6,23,.55);color-scheme:dark;}} +:root[data-theme="dark"]{ + --bg-primary:#0d1b2a;--bg-secondary:#132240;--bg-card:#132240;--bg-card-hover:#1a2b4d;--bg-input:rgba(15,30,55,.9); + --bg-sunk:#0f2035; + --text-primary:#e2e8f0;--text-secondary:#94a3b8;--text-muted:#64748b; + --border:rgba(100,180,255,.14);--border-light:rgba(100,180,255,.28); + --accent:#38bdf8;--accent-hover:#7dd3fc;--accent-light:rgba(56,189,248,.16);--accent-ink:#082f49; + --success:#4ade80;--success-light:rgba(74,222,128,.15);--warning:#fbbf24;--warning-light:rgba(251,191,36,.15); + --error:#f43f5e;--error-light:rgba(244,63,94,.15); + --shadow:rgba(0,0,0,.5);--scrim:rgba(2,6,23,.55);color-scheme:dark;} *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} /* Must outrank the display values set on .notice, .swatches and .btn below — the UA's own `[hidden]{display:none}` does not, so `hidden` on any of them @@ -34,12 +60,22 @@ a:hover{text-decoration:underline} .topbar{height:var(--header-height);border-bottom:1px solid var(--border);background:var(--bg-secondary); display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 24px;position:sticky;top:0;z-index:40} .brand{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600} -.brand-mark{width:28px;height:28px;border-radius:7px;background:var(--accent);color:#fff; +.brand-mark{width:28px;height:28px;border-radius:7px;background:var(--accent);color:var(--accent-ink); display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px} .brand .sep{color:var(--text-muted);font-weight:400} .topnav{display:flex;gap:6px;align-items:center;font-size:13px} .topnav a{padding:6px 10px;border-radius:var(--radius-sm);color:var(--text-secondary)} .topnav a:hover{background:var(--bg-card-hover);color:var(--text-primary);text-decoration:none} +.topbar-end{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end} + +/* ── Appearance switch ── */ +.theme-switch{display:flex;gap:2px;padding:2px;background:var(--bg-sunk); + border:1px solid var(--border);border-radius:var(--radius-sm)} +.theme-switch button{display:flex;align-items:center;justify-content:center;padding:5px 8px; + background:none;border:none;border-radius:3px;color:var(--text-secondary);cursor:pointer} +.theme-switch button svg{width:15px;height:15px;display:block} +.theme-switch button:hover{background:var(--bg-card-hover);color:var(--text-primary)} +.theme-switch button[aria-checked="true"]{background:var(--accent-light);color:var(--accent)} .content{max-width:1400px;margin:0 auto;padding:24px 24px 140px} .page-header{margin-bottom:20px} @@ -64,7 +100,7 @@ a:hover{text-decoration:underline} /* ── Controls ── */ .btn{padding:7px 14px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;cursor:pointer;border:none;font-family:inherit; display:inline-flex;align-items:center;gap:6px;text-decoration:none;white-space:nowrap} -.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover);text-decoration:none;color:#fff} +.btn-primary{background:var(--accent);color:var(--accent-ink)}.btn-primary:hover{background:var(--accent-hover);text-decoration:none;color:var(--accent-ink)} .btn-outline,.btn-secondary{background:transparent;border:1px solid var(--border-light);color:var(--text-secondary)} .btn-outline:hover,.btn-secondary:hover{border-color:var(--accent);color:var(--text-primary);text-decoration:none} .btn:disabled,.btn[aria-disabled="true"]{opacity:.4;cursor:not-allowed;pointer-events:none} diff --git a/data-explorer/app.js b/data-explorer/app.js index 28ce1ec0..7d2faff0 100644 --- a/data-explorer/app.js +++ b/data-explorer/app.js @@ -82,6 +82,79 @@ $("#topnav").innerHTML = 'Dashboard' + 'Live map'; +/* ══ Appearance ════════════════════════════════════════════════════════════ + * Three states. The `retina.theme` key and the `data-theme` attribute are the + * names the dashboard console uses for the same job, deliberately: they are + * separate origins, so a stored choice cannot travel between them, and matching + * names are the only thing making the two one idiom rather than two. + * + * `system` stamps nothing and lets app.css's own media query answer, which is + * why the OS preference keeps working when it changes mid-session. An explicit + * choice is stamped here and, for the next load, by theme-boot.js before the + * first paint. */ +const THEME_KEY = "retina.theme"; + +function storedTheme() { + try { + const t = localStorage.getItem(THEME_KEY); + return t === "light" || t === "dark" ? t : "system"; + } catch (e) { + return "system"; + } +} + +/* Stamp the attribute and mark the switch. Persisting is the caller's job, so + * that merely opening the page does not write storage for a visitor who never + * touched the control — this one is public and unauthenticated. */ +function showTheme(choice) { + if (choice === "system") document.documentElement.removeAttribute("data-theme"); + else document.documentElement.setAttribute("data-theme", choice); + $$("#theme-switch button").forEach((b) => { + const on = b.dataset.themeChoice === choice; + b.setAttribute("aria-checked", String(on)); + // A radio group is one tab stop, not three: Tab reaches whichever option is + // checked, and the arrows below move from there. + b.tabIndex = on ? 0 : -1; + }); +} + +function chooseTheme(choice) { + showTheme(choice); + try { + localStorage.setItem(THEME_KEY, choice); + } catch (e) { + /* private browsing — the choice still holds for this page */ + } +} + +$("#theme-switch").addEventListener("click", (e) => { + const btn = e.target.closest("button[data-theme-choice]"); + if (btn) chooseTheme(btn.dataset.themeChoice); +}); + +/* The keyboard half of the radio group. `role="radio"` promises the arrows move + * between the options and that they select as they go, so a keyboard user never + * has to Tab through all three. Without preventDefault the arrows scroll the + * page while the selection moves underneath. */ +$("#theme-switch").addEventListener("keydown", (e) => { + const btns = $$("#theme-switch button"); + const last = btns.length - 1; + const here = btns.findIndex((b) => b.getAttribute("aria-checked") === "true"); + let next; + if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = here <= 0 ? last : here - 1; + else if (e.key === "ArrowRight" || e.key === "ArrowDown") next = here >= last ? 0 : here + 1; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = last; + else return; + e.preventDefault(); + chooseTheme(btns[next].dataset.themeChoice); + btns[next].focus(); +}); + +// theme-boot.js has already stamped the attribute; this is what stops the +// switch claiming "System" when storage says otherwise. +showTheme(storedTheme()); + /* ══ State ═════════════════════════════════════════════════════════════════ */ const TODAY = todayUTC(); const S = { diff --git a/data-explorer/index.html b/data-explorer/index.html index 30233985..e4a5f07f 100644 --- a/data-explorer/index.html +++ b/data-explorer/index.html @@ -6,12 +6,37 @@ Retina · Data Explorer + +
RRetina · Data Explorer
- +
+ +
+ + + +
+
diff --git a/data-explorer/theme-boot.js b/data-explorer/theme-boot.js new file mode 100644 index 00000000..dc6180a1 --- /dev/null +++ b/data-explorer/theme-boot.js @@ -0,0 +1,19 @@ +/* Stamps the theme attribute before the first paint, so someone who chose dark + is not shown a white page while the deferred app.js loads. + + Its own file rather than an inline