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 @@