Light theme, following the browser by default, with a three-state toggle in the masthead #12

Closed
opened 2026-09-10 09:21:58 +00:00 by grenade · 0 comments
Owner

The site is dark-only. Add a light theme, default to whatever the browser asks for, and let a reader override it from the masthead: dark, light, or auto.

The part that is not obvious

A light theme is not the 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 2.77:1 against paper — the dataviz validator says so, and 2.77 is below the 3:1 floor a mark has to clear. So light mode needs its own step of the same hue, and its emphasis step has to move down rather than up, because on paper darker is louder and lighter is a smudge.

Same for the washes. rgba(189, 136, 41, 0.08) is a glow on warm-black and a smear on paper at the same alpha.

Every value gets chosen by the validator against the surface it actually sits on, per CLAUDE.md:

node <skill>/scripts/validate_palette.js "#bd8829" --mode dark  --surface "#14110d"
node <skill>/scripts/validate_palette.js "<light>" --mode light --surface "<light surface>"

The single-hue rule is unchanged and still load-bearing in both themes: bronze and crimson are adjacent hues that fail CVD separation as a categorical pair.

Three preferences, two palettes

auto is not a third set of values — it is a standing instruction to follow the browser. Resolving it to a concrete light/dark before the stylesheet ever sees it is what keeps index.css to one definition per palette rather than one per palette per media query.

It has to resolve before first paint. Anything that runs 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. So a small inline script in index.html, deliberately duplicating the resolution in the module.

Two things that will bite whoever writes it:

  • localStorage throws rather than returning null in a browser set to block site data. Every read and write needs a guard, and the browser's own preference is the right fallback — it is what auto would have chosen anyway.
  • auto has to keep following the system after load. A reader whose machine turns dark at sunset should not keep the daylight palette until they reload; that looks like the toggle being broken rather than like a missing matchMedia listener.

Toggle

One button, cycling auto → light → dark, showing the state it is in rather than the state it would move to — a control that displays its destination is why light-mode toggles get guessed at. Its accessible name carries the state, because the icon cannot.

Also worth checking while in here

Whether any hardcoded colour is left outside the token block. There were nine rgba(...) literals in the body of the stylesheet, and each one is a colour the light theme cannot override.

The site is dark-only. Add a light theme, default to whatever the browser asks for, and let a reader override it from the masthead: dark, light, or auto. ## The part that is not obvious **A light theme is not the 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 **2.77:1** against paper — the dataviz validator says so, and 2.77 is below the 3:1 floor a mark has to clear. So light mode needs its own step of the same hue, and its emphasis step has to move *down* rather than up, because on paper darker is louder and lighter is a smudge. Same for the washes. `rgba(189, 136, 41, 0.08)` is a glow on warm-black and a smear on paper at the same alpha. Every value gets chosen by the validator against the surface it actually sits on, per `CLAUDE.md`: ```sh node <skill>/scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d" node <skill>/scripts/validate_palette.js "<light>" --mode light --surface "<light surface>" ``` The single-hue rule is unchanged and still load-bearing in both themes: bronze and crimson are adjacent hues that fail CVD separation as a categorical pair. ## Three preferences, two palettes `auto` is not a third set of values — it is a standing instruction to follow the browser. Resolving it to a concrete `light`/`dark` before the stylesheet ever sees it is what keeps `index.css` to one definition per palette rather than one per palette per media query. It has to resolve **before first paint**. Anything that runs 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. So a small inline script in `index.html`, deliberately duplicating the resolution in the module. Two things that will bite whoever writes it: - `localStorage` **throws** rather than returning null in a browser set to block site data. Every read and write needs a guard, and the browser's own preference is the right fallback — it is what `auto` would have chosen anyway. - `auto` has to keep following the system *after* load. A reader whose machine turns dark at sunset should not keep the daylight palette until they reload; that looks like the toggle being broken rather than like a missing `matchMedia` listener. ## Toggle One button, cycling `auto → light → dark`, showing the state it is **in** rather than the state it would move to — a control that displays its destination is why light-mode toggles get guessed at. Its accessible name carries the state, because the icon cannot. ## Also worth checking while in here Whether any hardcoded colour is left outside the token block. There were nine `rgba(...)` literals in the body of the stylesheet, and each one is a colour the light theme cannot override.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: blackbeard/observer#12