feat(ui): a light theme, following the browser, with a toggle in the masthead
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user