What changed, and why
Glassbox was a dark-only collection: one warm near-black paper, eighteen lessons each with a bespoke palette. This change makes the whole thing dual-mode without flattening any of that bespoke identity.
A System / Light / Dark switch in the nav sets a data-theme attribute on <html>. It follows the OS until you pick a mode, then your choice persists and wins — the friendly precedence pattern. Every lesson keeps its native palette and gains a complementary mode as a [data-theme='…'] .<root>{} override block in its own stylesheet; the light grounds are deliberately varied and anchored to each lesson's content, not a single beige.
Three things carry the weight, and they're what to read: the theme infrastructure (a pure precedence model, a tiny store, a no-flash script), the per-lesson override pattern (shown once — every lesson follows it), and three new Many Hands Engineering loops that guard the result. The lesson CSS itself is large but mechanical; the system is the part you vouch for.
| Light character | Lessons |
|---|---|
| cool blueprint / instrument | concurrency, udp, tls, grpc, sha, cap-pacelc |
| warm kraft / sediment / ledger | swim, lsm-trees, acid-lab |
| porcelain / newsprint | cuckoo-filter, bloom-filters |
| champagne / sea-glass / oat | bloom-clock, index, trie, memory, merkle-trees |
The precedence model — pure, framework-free
theme.js holds the whole precedence model with no React and no DOM, so it unit-tests in node and the inline pre-paint script can mirror it exactly. Three states (system / light / dark); resolveTheme collapses a preference plus the OS signal into the one theme that actually paints; nextPref is the toggle's cycle. coercePref makes every input safe — an unknown stored value falls back to system.
coercePref → resolveTheme (explicit wins, else follow OS) → nextPref (the cycle).
src/shared/theme.js · 49 lines
⋯ 25 lines hidden (lines 1–25)
⋯ 1 line hidden (lines 49–49)
The store — apply, track the OS, persist
useTheme.js is the DOM + React binding: one module-level store is the single source of truth. applyDom writes data-theme + color-scheme on <html> and syncs the theme-color meta. recompute re-resolves on any change and fans out to subscribers. A matchMedia listener tracks the OS live (wired once at module scope), so in system mode flipping the OS flips the page; an explicit pick ignores it.
applyDom · recompute (cached snapshot) · the live OS listener · one apply at import.
src/shared/useTheme.js · 105 lines
⋯ 40 lines hidden (lines 1–40)
⋯ 36 lines hidden (lines 70–105)
The public API is small: setPref (persist + repaint), cyclePref (advance one step), and useTheme() — a useSyncExternalStore read that hands components { pref, theme }. The cached snapshot is what keeps getSnapshot stable, so the store doesn't loop.
setPref / cyclePref / useTheme — the imperative API + the hook.
src/shared/useTheme.js · 105 lines
⋯ 80 lines hidden (lines 1–80)
⋯ 1 line hidden (lines 105–105)
No flash — the pre-paint script + the token flip
The one thing a theme switch must not do is flash the wrong mode on load. An inline script in <head> resolves the theme from localStorage (+ the OS for system) and sets data-theme before first paint. It mirrors resolveTheme; the store takes over after hydration.
Pre-paint resolution — runs before the body renders, so there is no light/dark flash.
index.html · 52 lines
⋯ 10 lines hidden (lines 1–10)
⋯ 17 lines hidden (lines 36–52)
The shell's family-glue tokens then carry two values. Dark is the unattributed default (the historical look survives even if the script never runs); the light values layer on under :root[data-theme='light']. The nav chrome and the active-pill ink are tokenised here too, so they flip with everything else.
Dark defaults in :root; the warm-paper light variant keyed on the attribute.
src/shared/tokens.css · 73 lines
⋯ 32 lines hidden (lines 1–32)
⋯ 1 line hidden (lines 73–73)
The per-lesson pattern (shown once)
Every lesson follows the same shape, so read it once here. The lesson keeps its bespoke palette as its NATIVE mode; the complementary mode is a [data-theme='…'] .<root>{} override block appended to its <slug>.css that redefines the palette tokens (and any hardcoded ::before/gradient/grain literals) for the other ground. The extra attribute raises specificity by one, so the override reliably wins. TLS is a clean example — a dark-native cipher switchboard gaining a warm-paper "cryptographer's ledger" light mode.
The light complement: redefine the whole palette under [data-theme='light'] .tls-root.
src/lessons/tls/tls.css · 741 lines
⋯ 646 lines hidden (lines 1–646)
⋯ 61 lines hidden (lines 681–741)
Two inversions of this: b-trees and merkle-trees were light-native (card catalog, engraved certificate), so their override block is the dark complement (a lamplit after-hours catalog; a blacklight vault). And the bar across all of them: functional information text clears WCAG AA in both themes (retuned in OKLCH, hue held), while intentional decorative low-contrast is preserved — faint eyebrows, ghost/placeholder chips, labels on data bars/swatches, dimmed code comments stay soft by design.
Loop 1 — theme-parity (light/dark coverage)
The first of three new MHE loops, modeled on the existing style-isolation mapper: a read-only guardrail that verifies every lesson ships BOTH modes and that no override leaks out of its lesson root. The harness parses each stylesheet with a brace-aware walker; complementPresent is true once the file ships any [data-theme]-keyed rules, and a leak is any theme rule that matches globally after stripping the attribute.
Per-rule: count theme rules, flag global-after-strip leaks; complement = any data-theme rules.
agents/theme-parity.ts · 480 lines
⋯ 283 lines hidden (lines 1–283)
⋯ 29 lines hidden (lines 301–329)
⋯ 143 lines hidden (lines 338–480)
The agent (opus, read-only) turns the harness signal into a three-bucket map: missing/incomplete complement, verified parity, or judgment-heavy (theme-invariant by design). Across the collection it reports 0 missing / 0 thin / 0 leaks.
Loop 2 — token-hygiene (the var() knip can't see)
knip (the dead-code loop) only reads JS, so dead and dangling CSS custom properties are invisible to it — exactly the residue a token rename or a dropped widget leaves. token-hygiene diffs declarations (--x:) against references (var(--x), including inline style={{'--x':…}}), with the shell layer and the lesson-kit always reachable. It is fallback-aware: a var(--x, default) tolerates a missing --x by design, so only fallback-less undefined refs count as dangling.
The fallback skip (line 192) + the two fault sets: dangling refs and dead in-scope defs.
agents/token-hygiene.ts · 349 lines
⋯ 185 lines hidden (lines 1–185)
⋯ 133 lines hidden (lines 217–349)
Loop 3 — contrast (rendered WCAG, self-contained)
Color-contrast can only be judged on the rendered page (real cascade, computed colors, the data-theme switch). So this loop is self-contained like console-runtime (which boots vitest): it builds the app, boots vite preview, runs the axe sweep, tears the server down, and collapses failing DOM nodes into distinct fg/bg pairs for the agent.
runAudit(): build → spawn preview → wait → axe sweep → kill server (in finally).
agents/contrast.ts · 254 lines
⋯ 72 lines hidden (lines 1–72)
⋯ 164 lines hidden (lines 91–254)
The audit script is reveal-aware — the subtle bug that made an early pass chase phantoms. Lessons reveal content on scroll; below-fold elements sit at opacity 0 until scrolled into view, so axe was measuring them mid-fade. The auditor now emulates reduced-motion and scrolls the whole page first, so it reads true colors.
reducedMotion + scroll-reveal the whole page, THEN run axe — no mid-fade phantoms.
scripts/contrast-audit.js · 120 lines
⋯ 53 lines hidden (lines 1–53)
⋯ 40 lines hidden (lines 81–120)
Verification
The change is green end to end: npm run ci (lint, format, 359 vitest tests including the new theme-engine + toggle suites, and vite build); the Playwright e2e suite (101 passing, including a new theme spec that checks pre-paint, live OS-follow, and persisted precedence); agents typecheck; and the three loops themselves — theme-parity 0/0/0, token-hygiene 0 dangling, contrast light themes clean with dark functional text at AA and the decorative floor preserved.
Net: the theme system is small and pure, every lesson follows one reviewable pattern, and three standing loops keep coverage, variable integrity, and legibility honest as the collection grows.