From 4774b8cfaaa63e5df6c76c5850bc43ddcc89a65e Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Thu, 10 Sep 2026 12:22:30 +0300 Subject: [PATCH] feat(ui): a light theme, following the browser, with a toggle in the masthead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dark, light, or whatever the browser asks for. Auto is the default, so a reader who has never touched it gets their own system's answer rather than ours. The light palette is selected, not inverted. `#bd8829` carries every magnitude on this site at 6.6:1 against the warm-black and **2.77:1** against paper — the validator says so, and 2.77 is under the 3:1 floor a mark has to clear. So light mode gets its own step of the same bronze, and its `--data-bright` sits *darker* than `--data`, because emphasis on paper is weight rather than glare. Same for the washes: a glow at 0.08 alpha on warm-black is a smear at 0.08 on paper, so the nine colour literals that were still loose in the stylesheet became tokens — each one was a colour the second theme could not have overridden. Every value was chosen by running the dataviz validator against the surface it actually sits on, both modes. The single-hue rule is untouched and still load-bearing in both: bronze and crimson fail CVD separation as a categorical pair whichever ground they are on. `auto` is a preference rather than a third palette. It resolves to a concrete `light` or `dark` before the stylesheet ever sees it, which is what keeps this to one definition per palette instead of one per palette per media query — and it has to resolve before the *first paint*, because anything running after the bundle loads runs after the page has been painted once, and on a light preference that is a full-screen flash of warm-black. Hence the inline script, whose duplication of `lib/theme.ts` is the cheaper of the two costs. Two things that would otherwise bite: `localStorage` throws rather than returning null where site data is blocked, so every access is guarded and falls back to what the browser wants; and `auto` keeps listening, so a machine that turns dark at sunset does not leave a reader on the daylight palette until they reload. The toggle shows the state it is in, never the state it would move to — a control that displays its own destination is why these get guessed at — and its accessible name carries that state, since the icon cannot. Checked in a browser, both themes, on the standings, a miner page and the share chart. Worth recording what that turned up: dark carries 46 text elements under 4.5:1 and light carries 3, each beating its dark counterpart. The gap is `--text-muted` at 3.7, the deliberate existing value CLAUDE.md has always documented — not introduced here, and not something to change without deciding to change the dark design. Closes #12 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- CLAUDE.md | 31 ++++++- web/index.html | 54 ++++++++++-- web/src/App.tsx | 6 ++ web/src/components/ThemeToggle.tsx | 105 ++++++++++++++++++++++ web/src/index.css | 135 ++++++++++++++++++++++++++--- web/src/lib/theme.ts | 89 +++++++++++++++++++ 6 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 web/src/components/ThemeToggle.tsx create mode 100644 web/src/lib/theme.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8bb4d60..3d930c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -442,12 +442,39 @@ floating a tooltip: five overlays in a row of 180px tiles is not a hover layer, it is a pile. `web/src/index.css` documents the validated values. Re-run the validator after -touching them: +touching them — **both modes, each against its own surface**: ```sh -node /scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d" +node /scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d" +node /scripts/validate_palette.js "#a8741c" --mode light --surface "#f5f1e8" ``` +**A light theme is not the dark theme inverted.** `#bd8829` reads at 6.6:1 on the +warm-black and **2.77:1** on paper — under the 3:1 floor a mark has to clear — so +light mode carries its own step of the same hue, and its `--data-bright` is +*darker* than `--data` because on paper emphasis is weight rather than glare. The +washes are tokens for the same reason: a glow at 0.08 alpha on warm-black is a +smear at 0.08 on paper. There are no colour literals left outside the two token +blocks; a new one is a colour the other theme cannot override. + +**`auto` is a preference, not a palette.** The stylesheet only ever sees +`data-theme="light"` or `data-theme="dark"`, because a small inline script in +`index.html` resolves the stored preference against `prefers-color-scheme` +*before first paint* — anything that runs after the bundle loads runs after one +paint, which on a light preference is a full-screen flash of warm-black. That +resolution is deliberately duplicated between `index.html` and `lib/theme.ts`; +the alternative is either the flash or a second copy of every token inside a +media query. `localStorage` **throws** rather than returning null where site data +is blocked, so every access is guarded and falls back to the browser's own +answer. + +Measured after the light theme landed: dark carries **46** text elements under +4.5:1 and light carries **3**, all of which beat their dark counterparts. That +gap is `--text-muted: #7a6c59` at 3.7 against `--surface-1`, which is the +existing deliberate value this file has always documented — not something the +light theme introduced, and not something to "fix" without deciding to change the +dark design. + ## Deployment gotchas learned the hard way **A sudoers grant matches the whole argument vector.** The `restorecon` grant diff --git a/web/index.html b/web/index.html index 831ca33..34a5cce 100644 --- a/web/index.html +++ b/web/index.html @@ -8,22 +8,64 @@ name="description" content="Live miner leaderboard and network hashrate for the Quantus blockchain and its Planck testnet. Every author decoded from block headers — no registration, no opt-in." /> - - + + - + +
diff --git a/web/src/App.tsx b/web/src/App.tsx index e791ee3..cfdaf12 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -26,6 +26,7 @@ import { RuntimePanel } from './components/RuntimePanel' import { RuntimesIndex } from './components/RuntimesIndex' import { ReversibleIndex } from './components/ReversibleIndex' import { SectionNav } from './components/SectionNav' +import { ThemeToggle } from './components/ThemeToggle' import { StateIndex } from './components/StateIndex' import { StatBar } from './components/StatBar' import { seconds, windowSpan } from './lib/format' @@ -155,6 +156,11 @@ export default function App() { {state.connection} + + {/* Last, and outside the window selector's conditional: the theme + applies to every route, including the ones where a window means + nothing. */} + diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..99ee909 --- /dev/null +++ b/web/src/components/ThemeToggle.tsx @@ -0,0 +1,105 @@ +/** + * Dark, light, or whatever the browser says. + * + * One button rather than three, because the three states are a cycle and a + * segmented control of three icons would take the width of the window selector + * to express a preference most readers set once and never touch. The icon shows + * the state it is *in*, not the state it would move to — a control that + * displays its own destination is the reason light-mode toggles are guessed at. + * + * `auto` is first in the cycle and is where a reader who has never touched it + * already sits, so the first press is always a deliberate move away from the + * browser's answer rather than an accidental one. + */ + +import { useEffect, useState } from 'react' + +import { + applyPreference, + resolve, + storedPreference, + watchSystem, + type ThemePreference, +} from '../lib/theme' + +const CYCLE: ThemePreference[] = ['auto', 'light', 'dark'] + +const LABEL: Record = { + auto: 'Theme follows your browser', + light: 'Light theme', + dark: 'Dark theme', +} + +function Icon({ preference }: { preference: ThemePreference }) { + // 16px, 1.5px strokes, currentColor: the same weight as the rest of the + // masthead's chrome, so it reads as a control rather than as an illustration. + const common = { + width: 16, + height: 16, + viewBox: '0 0 16 16', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.5, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + 'aria-hidden': true, + } + if (preference === 'dark') { + return ( + + + + ) + } + if (preference === 'light') { + return ( + + + + + ) + } + // Auto: one disc, half filled. The same circle as the sun with the dark half + // painted in — "either of these, whichever you are using". + return ( + + + + + ) +} + +export function ThemeToggle() { + const [preference, setPreference] = useState(storedPreference) + + // Apply on mount too, not only on change: the inline script in `index.html` + // set the attribute from storage, and this keeps React's idea of the + // preference and the document's attribute from drifting apart if either + // changes without the other. + useEffect(() => { + applyPreference(preference) + }, [preference]) + + // Only `auto` cares what the system is doing, and only `auto` re-resolves. + useEffect(() => { + if (preference !== 'auto') return + return watchSystem(() => applyPreference('auto')) + }, [preference]) + + const next = CYCLE[(CYCLE.indexOf(preference) + 1) % CYCLE.length]! + const showing = preference === 'auto' ? ` — currently ${resolve('auto')}` : '' + + return ( + + ) +} diff --git a/web/src/index.css b/web/src/index.css index c08ecab..44b9da1 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -16,7 +16,30 @@ * labels and row treatment, never by a second colour a reader has to tell apart. */ -:root { +/* Two palettes, both selected rather than computed from each other. + * + * A light theme is not a dark theme with the lightness inverted. The bronze + * that carries every magnitude on this site reads at 6.6:1 against the + * warm-black and only **2.77:1** against paper — the validator says so — so + * light mode gets its own step of the same hue, dark enough to hold the + * surface, and the emphasis step moves *down* rather than up because on paper + * darker is louder. Every value below was chosen by the dataviz validator + * against the surface it actually sits on: + * + * node /scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d" + * node /scripts/validate_palette.js "#a8741c" --mode light --surface "#f5f1e8" + * + * Re-run both after touching any of them. The single-hue rule is unchanged and + * still load-bearing: bronze and crimson are adjacent hues that fail CVD + * separation as a categorical pair, in either theme. + * + * `data-theme` is always a concrete `dark` or `light`, resolved before first + * paint by the inline script in `index.html`. "Auto" is a stored *preference*, + * not a third set of values — which is what keeps this file to one definition + * per palette instead of one per palette per media query. + */ +:root, +:root[data-theme='dark'] { color-scheme: dark; /* Surfaces, warm-black — a cold grey reads as a developer tool, not a arena. */ @@ -49,10 +72,69 @@ --font-body: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; + /* Washes and glows, kept as tokens because each one is a colour and the + light theme needs its own. A wash that works as a glow on warm-black is a + smear on paper at the same alpha. */ + --glow: rgba(189, 136, 41, 0.08); + --good-halo: rgba(63, 157, 118, 0.18); + --data-chip: rgba(189, 136, 41, 0.18); + --row-rule: rgba(47, 39, 25, 0.55); + --mine-hover: rgba(216, 69, 58, 0.16); + --warn-border: rgba(217, 164, 65, 0.4); + --failed-chip: rgba(216, 69, 58, 0.12); + --rule: 1px solid var(--border); --shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset; } +/* Light. Contrast against --surface-1: 15.8, 6.9, 4.2 — the ink; then 3.6 for + * the data hue, 4.8 for the accent, 3.9 and 3.8 for the two status colours. + * All above the 3:1 the validator holds marks to, and the ink above 4.5. + */ +:root[data-theme='light'] { + color-scheme: light; + + /* Warm paper, for the same reason the dark surfaces are warm-black: a cold + grey reads as a developer tool. Elevation runs the other way here — the + page is the lightest thing and each step above it is *darker*, which keeps + the ordering of visual weight the dark theme has. */ + --surface-0: #fbf8f2; + --surface-1: #f5f1e8; + --surface-2: #ece7da; + --surface-3: #e2dbc9; + --border: #d9d0ba; + --border-strong: #b8ab8d; + + --text-primary: #1b1710; + --text-secondary: #5b5142; + --text-muted: #7c7263; + + /* The bronze, re-stepped. `--data-bright` is *darker* than `--data`: it is + the emphasis step, and emphasis on paper is weight, not glare. */ + --data: #a8741c; + --data-bright: #8a5f10; + --data-wash: rgba(168, 116, 28, 0.12); + + --accent: #c3382d; + --accent-bright: #a82c20; + --accent-wash: rgba(195, 56, 45, 0.1); + + --good: #0f8a58; + --warn: #b8651a; + + --glow: rgba(168, 116, 28, 0.09); + --good-halo: rgba(15, 138, 88, 0.16); + --data-chip: rgba(168, 116, 28, 0.16); + --row-rule: rgba(184, 171, 141, 0.5); + --mine-hover: rgba(195, 56, 45, 0.1); + --warn-border: rgba(184, 101, 26, 0.45); + --failed-chip: rgba(195, 56, 45, 0.09); + + /* Down rather than up: an inset white highlight is how a dark surface catches + the light, and on paper the same gesture is a shadow. */ + --shadow: 0 1px 0 rgba(27, 23, 16, 0.04) inset; +} + * { box-sizing: border-box; } @@ -68,8 +150,7 @@ body { background: /* A faint warm glow behind the masthead, so the page has a top rather than being a flat field. Fixed so it does not travel with the scroll. */ - radial-gradient(120% 60% at 50% -10%, rgba(189, 136, 41, 0.08), transparent 60%), - var(--surface-0); + radial-gradient(120% 60% at 50% -10%, var(--glow), transparent 60%), var(--surface-0); background-attachment: fixed; color: var(--text-primary); font-family: var(--font-body); @@ -192,6 +273,40 @@ a { flex-wrap: wrap; } +/* ---- theme toggle -------------------------------------------------------- */ + +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + /* 32px, which is the smallest a lone icon control can be and still be a + comfortable target on a touch screen. The icon inside is 16. */ + width: 32px; + height: 32px; + padding: 0; + border: var(--rule); + border-radius: 999px; + background: var(--surface-1); + color: var(--text-secondary); + cursor: pointer; + /* Not the colour: `color-scheme` repaints the scrollbar and the form chrome + at the same instant, and a 150ms fade on the page while the gutter snaps is + worse than both moving together. */ + transition: + border-color 0.15s ease, + color 0.15s ease; +} + +.theme-toggle:hover { + color: var(--data-bright); + border-color: var(--border-strong); +} + +.theme-toggle:focus-visible { + outline: 2px solid var(--data); + outline-offset: 2px; +} + /* ---- connection light ---------------------------------------------------- */ .status { @@ -214,7 +329,7 @@ a { .status-live .status-dot { background: var(--good); - box-shadow: 0 0 0 3px rgba(63, 157, 118, 0.18); + box-shadow: 0 0 0 3px var(--good-halo); } .status-reconnecting .status-dot, @@ -343,7 +458,7 @@ a { .chain-chip.is-active .chain-nodes { color: var(--data-bright); - background: rgba(189, 136, 41, 0.18); + background: var(--data-chip); } /* ---- stat tiles ---------------------------------------------------------- */ @@ -619,7 +734,7 @@ a.ticker-height:hover { .board td { padding: 9px 14px; - border-bottom: 1px solid rgba(47, 39, 25, 0.55); + border-bottom: 1px solid var(--row-rule); text-align: right; white-space: nowrap; } @@ -641,7 +756,7 @@ a.ticker-height:hover { } .board tbody tr.mine:hover { - background: rgba(216, 69, 58, 0.16); + background: var(--mine-hover); } .rank { @@ -791,7 +906,7 @@ tr.mine .share-bar > i { gap: 12px; align-items: baseline; padding: 8px 18px; - border-bottom: 1px solid rgba(47, 39, 25, 0.55); + border-bottom: 1px solid var(--row-rule); font-size: 13px; } @@ -907,7 +1022,7 @@ tr.mine .share-bar > i { } .banner-warn { - border-color: rgba(217, 164, 65, 0.4); + border-color: var(--warn-border); color: var(--warn); } @@ -1280,7 +1395,7 @@ tr.mine .share-bar > i { .chip-failed { color: var(--accent-bright); border-color: var(--accent); - background: rgba(216, 69, 58, 0.12); + background: var(--failed-chip); } /* A hashrate and how far to trust it. The error rides beside the figure rather diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts new file mode 100644 index 0000000..a21d7e3 --- /dev/null +++ b/web/src/lib/theme.ts @@ -0,0 +1,89 @@ +/** + * Which palette the page wears. + * + * Three preferences, two palettes. `auto` is not a third set of colours — it is + * a standing instruction to follow the browser, resolved to a concrete `light` + * or `dark` and re-resolved whenever the system flips. That is what keeps + * `index.css` to one definition per palette: the stylesheet only ever sees + * `data-theme="light"` or `data-theme="dark"`, never the preference itself. + * + * The same resolution runs inline in `index.html` before the bundle loads. The + * duplication is deliberate and small: a theme applied after first paint is a + * flash of the wrong palette, and on a page whose whole design is warm-black, + * a white flash is the most visible bug on the site. + */ + +export type ThemePreference = 'auto' | 'light' | 'dark' +export type Theme = 'light' | 'dark' + +/** Shared with the inline script in `index.html`. Changing it orphans the + * stored preference of everyone who has ever set one, so don't. */ +export const THEME_KEY = 'blackbeard.theme' + +const QUERY = '(prefers-color-scheme: light)' + +function isPreference(value: unknown): value is ThemePreference { + return value === 'auto' || value === 'light' || value === 'dark' +} + +/** What the browser is asking for right now. */ +export function systemTheme(): Theme { + return window.matchMedia(QUERY).matches ? 'light' : 'dark' +} + +/** + * The stored preference, defaulting to `auto`. + * + * Storage throws rather than returning null in a browser set to block site + * data, so every read and write here is guarded: a reader with cookies off gets + * the browser's own choice and a toggle that works for the session, which is a + * better answer than a blank page. + */ +export function storedPreference(): ThemePreference { + try { + const raw = window.localStorage.getItem(THEME_KEY) + if (isPreference(raw)) return raw + } catch { + /* blocked; auto is the right default anyway */ + } + return 'auto' +} + +/** Resolve a preference to the palette it means at this moment. */ +export function resolve(preference: ThemePreference): Theme { + return preference === 'auto' ? systemTheme() : preference +} + +/** Put a preference into effect, and remember it. */ +export function applyPreference(preference: ThemePreference): void { + const theme = resolve(preference) + document.documentElement.setAttribute('data-theme', theme) + // The browser paints its own chrome — the address bar on mobile, the + // scrollbar gutter — from this rather than from the stylesheet, so a page + // that switches without it keeps a strip of the other theme at the edge. + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute('content', theme === 'light' ? '#fbf8f2' : '#0d0b09') + + try { + // `auto` is stored explicitly rather than by removing the key: "I have not + // chosen" and "I chose to follow the browser" are the same behaviour today + // and would stop being if a future default were anything but auto. + window.localStorage.setItem(THEME_KEY, preference) + } catch { + /* the preference still applies for this session */ + } +} + +/** + * Follow the system while the preference is `auto`. + * + * Returns its own teardown. Without this, a reader on `auto` whose machine + * turns dark at sunset keeps the daylight palette until they reload — which + * looks like the toggle not working rather than like a missing listener. + */ +export function watchSystem(onChange: () => void): () => void { + const media = window.matchMedia(QUERY) + media.addEventListener('change', onChange) + return () => media.removeEventListener('change', onChange) +}