feat(ui): a light theme, following the browser, with a toggle in the masthead
deploy / build (push) Successful in 1m12s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 4s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-10 12:22:30 +03:00
co-authored by Claude Opus 5
parent 9def0c8b6a
commit 4774b8cfaa
6 changed files with 402 additions and 18 deletions
+105
View File
@@ -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<ThemePreference, string> = {
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 (
<svg {...common}>
<path d="M13.5 9.6A5.8 5.8 0 0 1 6.4 2.5a5.8 5.8 0 1 0 7.1 7.1Z" />
</svg>
)
}
if (preference === 'light') {
return (
<svg {...common}>
<circle cx="8" cy="8" r="3.1" />
<path d="M8 1.4v1.5M8 13.1v1.5M14.6 8h-1.5M2.9 8H1.4M12.7 3.3l-1.1 1.1M4.4 11.6l-1.1 1.1M12.7 12.7l-1.1-1.1M4.4 4.4 3.3 3.3" />
</svg>
)
}
// 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 (
<svg {...common}>
<circle cx="8" cy="8" r="5.6" />
<path d="M8 2.4a5.6 5.6 0 0 1 0 11.2Z" fill="currentColor" stroke="none" />
</svg>
)
}
export function ThemeToggle() {
const [preference, setPreference] = useState<ThemePreference>(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 (
<button
type="button"
className="theme-toggle"
onClick={() => setPreference(next)}
title={`${LABEL[preference]}${showing}. Switch to ${LABEL[next].toLowerCase()}.`}
// The button's own name carries the state, because the icon cannot: a
// screen reader gets "Theme: follows your browser", not "button".
aria-label={`Theme: ${LABEL[preference].toLowerCase()}${showing}. Switch to ${LABEL[next].toLowerCase()}.`}
>
<Icon preference={preference} />
</button>
)
}