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 characterLessons
cool blueprint / instrumentconcurrency, udp, tls, grpc, sha, cap-pacelc
warm kraft / sediment / ledgerswim, lsm-trees, acid-lab
porcelain / newsprintcuckoo-filter, bloom-filters
champagne / sea-glass / oatbloom-clock, index, trie, memory, merkle-trees
Light grounds are content-anchored and varied — as varied as the dark originals.

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
src/shared/theme.js49 lines · JavaScript
⋯ 25 lines hidden (lines 1–25)
1/* The precedence model behind the site-wide light/dark switch — kept pure
2 (no React, no DOM) so it can be unit-tested in node (tests/theme.test.js) and
3 so the pre-paint inline script in index.html can mirror it exactly without
4 importing anything.
5 
6 The "friendly precedence" pattern: three preference states, where an explicit
7 pick always wins and 'system' tracks the OS live.
8 
9 pref = 'system' → follow `prefers-color-scheme`, live (the default)
10 pref = 'light' → forced light, persisted
11 pref = 'dark' → forced dark, persisted
12 
13 The toggle cycles system → light → dark → system. resolveTheme() collapses a
14 preference + the OS signal down to the one theme actually painted. */
15 
16/** localStorage key holding the user's explicit preference. */
17export const THEME_STORAGE_KEY = 'glassbox-theme';
18 
19/** The three preference states (also the toggle's cycle order). */
20export const THEME_PREFS = ['system', 'light', 'dark'];
21 
22/** The two resolved themes that actually get painted onto `<html data-theme>`. */
23export const THEMES = ['light', 'dark'];
24 
25/** Coerce any stored / raw value into a valid preference. Unknown → 'system'. */
26export function coercePref(raw) {
27 return THEME_PREFS.includes(raw) ? raw : 'system';
29 
30/**
31 * Resolve a preference + the OS signal into the theme to paint.
32 * Explicit 'light' / 'dark' win; 'system' (or anything unknown) follows the OS.
33 */
34export function resolveTheme(pref, systemPrefersDark) {
35 const p = coercePref(pref);
36 if (p === 'light' || p === 'dark') return p;
37 return systemPrefersDark ? 'dark' : 'light';
39 
40/** Cycle order for the toggle: system → light → dark → system. */
41export function nextPref(pref) {
42 const i = THEME_PREFS.indexOf(coercePref(pref));
43 return THEME_PREFS[(i + 1) % THEME_PREFS.length];
45 
46/** Human-readable label for a preference (used in the toggle's accessible name). */
47export function prefLabel(pref) {
48 return { system: 'System', light: 'Light', dark: 'Dark' }[coercePref(pref)];
⋯ 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
src/shared/useTheme.js105 lines · JavaScript
⋯ 40 lines hidden (lines 1–40)
1import { useSyncExternalStore } from 'react';
2import { THEME_STORAGE_KEY, coercePref, nextPref, resolveTheme } from './theme.js';
3 
4/* The DOM + React binding for the pure precedence model in theme.js.
5 *
6 * A single module-level store is the source of truth: it reads the persisted
7 * preference, tracks the OS `prefers-color-scheme` live, applies the resolved
8 * theme to `<html data-theme>` (+ `color-scheme` + the theme-color meta), and
9 * fans changes out to every useTheme() consumer via useSyncExternalStore.
10 *
11 * First paint is handled by the inline script in index.html (no flash); this
12 * store mirrors the same resolution and then owns every change after hydration. */
13 
14const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)';
15 
16// The painted background per theme. MUST match --paper in src/shared/tokens.css
17// so the mobile browser chrome (theme-color) agrees with the page.
18const META_COLOR = { dark: '#0a0a0f', light: '#f4efe4' };
19 
20function systemPrefersDark() {
21 if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
22 return window.matchMedia(COLOR_SCHEME_QUERY).matches;
24 
25function readStoredPref() {
26 try {
27 return coercePref(
28 typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_STORAGE_KEY) : null,
29 );
30 } catch {
31 return 'system'; // storage blocked (private mode, etc.) → follow the OS
32 }
34 
35// ── Module store: one instance shared by every consumer ─────────────────────
36let pref = readStoredPref();
37let theme = resolveTheme(pref, systemPrefersDark());
38let snapshot = { pref, theme }; // cached so getSnapshot returns a stable ref
39const listeners = new Set();
40 
41function applyDom() {
42 if (typeof document === 'undefined') return;
43 const root = document.documentElement;
44 root.setAttribute('data-theme', theme);
45 root.style.colorScheme = theme;
46 const meta = document.querySelector('meta[name="theme-color"]');
47 if (meta) meta.setAttribute('content', META_COLOR[theme]);
49 
50function recompute() {
51 const nextTheme = resolveTheme(pref, systemPrefersDark());
52 if (nextTheme === theme && snapshot.pref === pref) return; // nothing changed
53 theme = nextTheme;
54 snapshot = { pref, theme };
55 applyDom();
56 for (const listener of listeners) listener();
58 
59// Track the OS preference live (wired once at module scope). When in 'system'
60// mode this flips the painted theme; an explicit pick simply ignores it.
61if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
62 const mql = window.matchMedia(COLOR_SCHEME_QUERY);
63 if (mql.addEventListener) mql.addEventListener('change', recompute);
64 else if (mql.addListener) mql.addListener(recompute); // older Safari
66 
67// Reconcile the DOM with the store once at import (idempotent with the inline
68// script; also covers any context where that script didn't run).
69applyDom();
⋯ 36 lines hidden (lines 70–105)
70 
71function subscribe(listener) {
72 listeners.add(listener);
73 return () => listeners.delete(listener);
75 
76function getSnapshot() {
77 return snapshot;
79 
80/** Set the explicit preference ('system' | 'light' | 'dark'), persist it, repaint. */
81export function setPref(next) {
82 pref = coercePref(next);
83 try {
84 if (typeof localStorage !== 'undefined') localStorage.setItem(THEME_STORAGE_KEY, pref);
85 } catch {
86 // ignore storage failures — the in-memory pref still drives this session
87 }
88 recompute();
90 
91/** Advance the preference one step: system → light → dark → system. */
92export function cyclePref() {
93 setPref(nextPref(pref));
95 
96/**
97 * Subscribe to the site-wide theme.
98 *
99 * pref — 'system' | 'light' | 'dark' (the user's choice; 'system' tracks OS)
100 * theme — 'light' | 'dark' (what is actually painted right now)
101 */
102export function useTheme() {
103 const snap = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
104 return { pref: snap.pref, theme: snap.theme, cyclePref, setPref };

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
src/shared/useTheme.js105 lines · JavaScript
⋯ 80 lines hidden (lines 1–80)
1import { useSyncExternalStore } from 'react';
2import { THEME_STORAGE_KEY, coercePref, nextPref, resolveTheme } from './theme.js';
3 
4/* The DOM + React binding for the pure precedence model in theme.js.
5 *
6 * A single module-level store is the source of truth: it reads the persisted
7 * preference, tracks the OS `prefers-color-scheme` live, applies the resolved
8 * theme to `<html data-theme>` (+ `color-scheme` + the theme-color meta), and
9 * fans changes out to every useTheme() consumer via useSyncExternalStore.
10 *
11 * First paint is handled by the inline script in index.html (no flash); this
12 * store mirrors the same resolution and then owns every change after hydration. */
13 
14const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)';
15 
16// The painted background per theme. MUST match --paper in src/shared/tokens.css
17// so the mobile browser chrome (theme-color) agrees with the page.
18const META_COLOR = { dark: '#0a0a0f', light: '#f4efe4' };
19 
20function systemPrefersDark() {
21 if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
22 return window.matchMedia(COLOR_SCHEME_QUERY).matches;
24 
25function readStoredPref() {
26 try {
27 return coercePref(
28 typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_STORAGE_KEY) : null,
29 );
30 } catch {
31 return 'system'; // storage blocked (private mode, etc.) → follow the OS
32 }
34 
35// ── Module store: one instance shared by every consumer ─────────────────────
36let pref = readStoredPref();
37let theme = resolveTheme(pref, systemPrefersDark());
38let snapshot = { pref, theme }; // cached so getSnapshot returns a stable ref
39const listeners = new Set();
40 
41function applyDom() {
42 if (typeof document === 'undefined') return;
43 const root = document.documentElement;
44 root.setAttribute('data-theme', theme);
45 root.style.colorScheme = theme;
46 const meta = document.querySelector('meta[name="theme-color"]');
47 if (meta) meta.setAttribute('content', META_COLOR[theme]);
49 
50function recompute() {
51 const nextTheme = resolveTheme(pref, systemPrefersDark());
52 if (nextTheme === theme && snapshot.pref === pref) return; // nothing changed
53 theme = nextTheme;
54 snapshot = { pref, theme };
55 applyDom();
56 for (const listener of listeners) listener();
58 
59// Track the OS preference live (wired once at module scope). When in 'system'
60// mode this flips the painted theme; an explicit pick simply ignores it.
61if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
62 const mql = window.matchMedia(COLOR_SCHEME_QUERY);
63 if (mql.addEventListener) mql.addEventListener('change', recompute);
64 else if (mql.addListener) mql.addListener(recompute); // older Safari
66 
67// Reconcile the DOM with the store once at import (idempotent with the inline
68// script; also covers any context where that script didn't run).
69applyDom();
70 
71function subscribe(listener) {
72 listeners.add(listener);
73 return () => listeners.delete(listener);
75 
76function getSnapshot() {
77 return snapshot;
79 
80/** Set the explicit preference ('system' | 'light' | 'dark'), persist it, repaint. */
81export function setPref(next) {
82 pref = coercePref(next);
83 try {
84 if (typeof localStorage !== 'undefined') localStorage.setItem(THEME_STORAGE_KEY, pref);
85 } catch {
86 // ignore storage failures — the in-memory pref still drives this session
87 }
88 recompute();
90 
91/** Advance the preference one step: system → light → dark → system. */
92export function cyclePref() {
93 setPref(nextPref(pref));
95 
96/**
97 * Subscribe to the site-wide theme.
98 *
99 * pref — 'system' | 'light' | 'dark' (the user's choice; 'system' tracks OS)
100 * theme — 'light' | 'dark' (what is actually painted right now)
101 */
102export function useTheme() {
103 const snap = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
104 return { pref: snap.pref, theme: snap.theme, cyclePref, setPref };
⋯ 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
index.html52 lines · HTML
⋯ 10 lines hidden (lines 1–10)
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <meta
7 name="description"
8 content="Glassbox: the black box, made of glass. A small library of interactive systems lessons you can poke, prod, and see straight through."
9 />
10 <meta name="theme-color" content="#0a0a0f" />
11 <!-- Pre-paint theme resolution: set <html data-theme> from the stored
12 preference (+ the OS for 'system') BEFORE first paint so there is no
13 light/dark flash. This mirrors resolveTheme() in src/shared/theme.js;
14 src/shared/useTheme.js owns every change after hydration. -->
15 <script>
16 (function () {
17 try {
18 var pref = localStorage.getItem('glassbox-theme');
19 if (pref !== 'light' && pref !== 'dark' && pref !== 'system') pref = 'system';
20 var dark =
21 pref === 'dark' ||
22 (pref === 'system' &&
23 typeof window.matchMedia === 'function' &&
24 window.matchMedia('(prefers-color-scheme: dark)').matches);
25 var theme = dark ? 'dark' : 'light';
26 var root = document.documentElement;
27 root.setAttribute('data-theme', theme);
28 root.style.colorScheme = theme;
29 var meta = document.querySelector('meta[name="theme-color"]');
30 if (meta) meta.setAttribute('content', dark ? '#0a0a0f' : '#f4efe4');
31 } catch (e) {
32 /* storage/matchMedia blocked → leave the dark default in place */
33 }
34 })();
35 </script>
⋯ 17 lines hidden (lines 36–52)
36 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
37 <link rel="preconnect" href="https://fonts.googleapis.com" />
38 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
39 <!-- JetBrains Mono is the shared family-glue monospace (--font-mono), used by
40 the shell and every lesson for numerics/credits/eyebrows. Loaded once here
41 (full italic + weight axis) instead of redundantly re-@imported per lesson. -->
42 <link
43 rel="stylesheet"
44 href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300..800;1,300..800&display=swap"
45 />
46 <title>Glassbox</title>
47 </head>
48 <body>
49 <div id="root"></div>
50 <script type="module" src="/src/main.jsx"></script>
51 </body>
52</html>

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
src/shared/tokens.css73 lines · CSS
⋯ 32 lines hidden (lines 1–32)
1/* Family-glue design tokens — the connective tissue across the lesson
2 collection (per AGENTS.md). The shell uses these directly; lessons opt
3 in for the parts of their visual identity that match the family glue,
4 and keep their own variants for the parts that don't.
5 
6 Some lessons override --ink, --bg, etc. for their own identity by
7 declaring them inside their root selector (.cap-root, .udp-root,
8 .lesson-root, .mw, .sha-root). That scope contains the override:
9 elements outside any lesson root resolve to the :root tokens here;
10 elements inside a lesson resolve to that lesson's overrides through
11 the cascade.
12 
13 The JetBrains Mono stack is the single strongest piece of shared
14 visual identity across the collection. Standardised here so every
15 lesson and the shell render numerics, credits, and eyebrows in the
16 same metal.
17 
18 Light / dark. The whole collection is dual-mode. The shell's family-glue
19 tokens have two values, switched by the `data-theme` attribute the inline
20 script in index.html sets on <html> before first paint (the site-wide
21 light/dark switch lives in src/shared/useTheme.js / ThemeToggle.jsx). Dark is
22 the unattributed default so the historical look survives even if that script
23 never runs; the light values layer on under :root[data-theme='light'].
24 
25 Each lesson keeps its own bespoke palette and ships the complementary mode as
26 a `[data-theme='…'] .<root>{}` override block in its own <slug>.css. The
27 theme-parity MHE loop (agents/theme-parity.ts) audits that every lesson has
28 both. */
29 
30:root {
31 /* Dark warm-paper background used by the shell + the lessons whose
32 identity matches the family glue directly (acid-lab, bloom-filters).
33 Most lessons override with their own paper variant in their root. */
34 --paper: #0a0a0f;
35 
36 /* Warm parchment ink (#e8dec8) — the family-glue text colour. */
37 --ink: #e8dec8;
38 
39 /* The two parchment-ink rgba opacities the shell uses for divider
40 lines and panel backgrounds. Other opacities stay as literals. */
41 --rule: rgba(232, 222, 200, 0.16);
42 --rule-soft: rgba(232, 222, 200, 0.06);
43 
44 /* Sticky-nav chrome (frosted bar). Tokenised so it flips with the theme. */
45 --nav-bg: rgba(10, 10, 15, 0.94);
46 --nav-border: rgba(232, 222, 200, 0.12);
47 /* Ink for the active nav pill's label — a dark ink that sits on the bright
48 lesson accent in BOTH modes (in dark this equals --paper). */
49 --nav-active-ink: var(--paper);
50 
51 /* The single shared monospace, applied uniformly across the collection. */
52 --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
53 
54 color-scheme: dark;
56 
57/* Light family glue — the shell's complementary mode. Warm paper / warm-black
58 ink, the same temperature as the dark default rather than a clinical white.
59 Mirrors the already-light lessons (b-trees #e8e2d2 / merkle-trees #ece4d0). */
60:root[data-theme='light'] {
61 --paper: #f4efe4;
62 --ink: #2a2620;
63 --rule: rgba(42, 38, 32, 0.18);
64 --rule-soft: rgba(42, 38, 32, 0.06);
65 
66 --nav-bg: rgba(244, 239, 228, 0.9);
67 --nav-border: rgba(42, 38, 32, 0.14);
68 /* The lesson accents are bright pastels, so the active pill keeps a DARK
69 label on the accent in light mode too (not a flip to the light --paper). */
70 --nav-active-ink: #1a1712;
71 
72 color-scheme: light;
⋯ 1 line hidden (lines 73–73)

The switch — and the one nav-contrast trap

ThemeToggle is icon-only (Monitor / Sun / Moon), parked in the sticky nav. It reads the store and cycles on click; the accessible name announces the current mode and, in system, what the OS resolved to.

src/shared/ThemeToggle.jsx · 29 lines
src/shared/ThemeToggle.jsx29 lines · JSX
1import { Monitor, Moon, Sun } from 'lucide-react';
2import { prefLabel } from './theme.js';
3import { useTheme } from './useTheme.js';
4 
5// One icon per preference. 'system' shows a monitor; the explicit picks show
6// the sun/moon they force.
7const ICON = { system: Monitor, light: Sun, dark: Moon };
8 
9// Icon-only by design: a fixed footprint that drops into the sticky nav's
10// corner in both the desktop pill bar and the mobile compact bar. The cycle
11// (System → Light → Dark) is announced via the accessible name + title.
12export default function ThemeToggle() {
13 const { pref, theme, cyclePref } = useTheme();
14 const Icon = ICON[pref];
15 const label = prefLabel(pref);
16 const following = pref === 'system' ? ` (following system: ${theme})` : '';
17 
18 return (
19 <button
20 type="button"
21 className="theme-toggle"
22 onClick={cyclePref}
23 aria-label={`Theme: ${label}${following}. Activate to switch theme.`}
24 title={`Theme: ${label}${following} — click to cycle System / Light / Dark`}
25 >
26 <Icon size={16} aria-hidden="true" />
27 </button>
28 );
⋯ 1 line hidden (lines 29–29)

One subtle trap lived in the nav. The active pill paints --nav-active-ink on the lesson's accent — fine for the bright lesson accents in both modes. But the index page has no accent, so the pill fell back to --ink, which is dark in light mode → dark ink on dark = invisible. Giving the index its own warm-tan accent fixes it cleanly, in both themes.

accentFor(): the index gets a real accent so its active pill always contrasts.

src/shared/Nav.jsx · 108 lines
src/shared/Nav.jsx108 lines · JSX
⋯ 6 lines hidden (lines 1–6)
1import { useEffect, useId, useRef, useState } from 'react';
2import { Menu, X } from 'lucide-react';
3import { getLessonById, indexPage, pages } from '../lesson-catalog.js';
4import ThemeToggle from './ThemeToggle.jsx';
5import './nav.css';
6 
7const navLabelFor = (page) => (page.id === indexPage.id ? 'Index' : page.label);
8 
9// The index page has no lesson accent; give it a warm tan so the active "Index"
10// pill keeps a legible dark label in BOTH themes (without it, the pill falls back
11// to --ink, which is dark in light mode → invisible dark-on-dark text).
12const INDEX_ACCENT = '#c2a878';
13const accentFor = (id) =>
14 getLessonById(id)?.accent || (id === indexPage.id ? INDEX_ACCENT : undefined);
15 
16// The lesson switcher. On desktop it's the familiar wrapping pill bar; below
⋯ 92 lines hidden (lines 17–108)
17// 768px (driven entirely by nav.css) it collapses to a hamburger + the current
18// lesson, toggling a dropdown of every lesson. The open/closed state only does
19// anything on mobile — the list is always laid out inline on wide screens.
20export default function Nav({ activePage, onSelect }) {
21 const [open, setOpen] = useState(false);
22 const navRef = useRef(null);
23 const menuId = useId();
24 
25 const activeAccent = accentFor(activePage.id);
26 const currentLabel = navLabelFor(activePage);
27 
28 // While the mobile dropdown is open, close it on Escape or an outside tap.
29 useEffect(() => {
30 if (!open) return undefined;
31 
32 const onKeyDown = (event) => {
33 if (event.key === 'Escape') setOpen(false);
34 };
35 const onPointerDown = (event) => {
36 if (navRef.current && !navRef.current.contains(event.target)) setOpen(false);
37 };
38 
39 document.addEventListener('keydown', onKeyDown);
40 document.addEventListener('pointerdown', onPointerDown);
41 return () => {
42 document.removeEventListener('keydown', onKeyDown);
43 document.removeEventListener('pointerdown', onPointerDown);
44 };
45 }, [open]);
46 
47 const handleSelect = (pageId) => {
48 setOpen(false);
49 onSelect(pageId);
50 };
51 
52 return (
53 <nav ref={navRef} className="lesson-nav" aria-label="Lesson navigation">
54 <ThemeToggle />
55 <div className="lesson-nav__bar">
56 <button
57 type="button"
58 className="lesson-nav__toggle"
59 aria-expanded={open}
60 aria-controls={menuId}
61 aria-label={`Lessons menu — current lesson: ${currentLabel}`}
62 onClick={() => setOpen((value) => !value)}
63 >
64 {open ? <X size={18} aria-hidden="true" /> : <Menu size={18} aria-hidden="true" />}
65 <span className="lesson-nav__current">
66 <span
67 className="lesson-nav__dot"
68 style={{ '--accent': activeAccent || 'var(--ink)' }}
69 aria-hidden="true"
70 />
71 <span className="lesson-nav__current-label">{currentLabel}</span>
72 </span>
73 </button>
74 </div>
75 
76 <ul id={menuId} className="lesson-nav__list" data-open={open}>
77 {pages.map((page) => {
78 const isActive = page.id === activePage.id;
79 const accent = accentFor(page.id);
80 
81 return (
82 <li key={page.id}>
83 <button
84 type="button"
85 className="lesson-nav__link"
86 aria-current={isActive ? 'page' : undefined}
87 style={{ '--accent': accent || 'var(--ink)' }}
88 onClick={() => handleSelect(page.id)}
89 >
90 {navLabelFor(page)}
91 </button>
92 </li>
93 );
94 })}
95 </ul>
96 
97 {activeAccent && (
98 <span
99 aria-hidden="true"
100 className="lesson-nav__underline"
101 style={{
102 background: `linear-gradient(90deg, transparent, ${activeAccent}, transparent)`,
103 }}
104 />
105 )}
106 </nav>
107 );

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
src/lessons/tls/tls.css741 lines · CSS
⋯ 646 lines hidden (lines 1–646)
1@import url('https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500&family=Schibsted+Grotesk:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&display=swap');
2 
3.tls-root {
4 --ink: #0a1416;
5 --ink-2: #0d1c1f;
6 --panel: #102528;
7 --panel-2: #0c1d20;
8 --line: #1d3a3d;
9 --line-soft: #16302f;
10 --bone: #ece6d8;
11 --bone-dim: #a9b3ad;
12 /* the functional small-mono tier: party/role labels, value & status mono,
13 step counters, trust-store chips, column captions, nav numbers. Lifted in
14 OKLCH (hue + chroma held, lightness raised from the original #6f817d) so
15 every use — including the sibling chips/mono a per-selector pass missed —
16 clears WCAG AA (>=4.5) on every dark ground (ink-2/panel/panel-2/ink). */
17 --bone-faint: #7e908c;
18 /* functional small-mono labels (step/role captions, cert "issued by"): now
19 identical to the lifted --bone-faint above — the two tiers are uniformly
20 functional in dark, so the label token simply tracks it. */
21 --bone-label: #7e908c;
22 --aqua: #46d6c6;
23 --aqua-bright: #7ff0e2;
24 --aqua-deep: #1d8e84;
25 --brass: #e0ad4e;
26 --brass-bright: #f4cd7a;
27 --brass-deep: #9c7424;
28 --verm: #f0644d;
29 --verm-bright: #ff8a76;
30 --verm-deep: #a83422;
31 --steel: #7e9aa0;
32 /* inline accent washes (tints of the signature accents over panel/inset
33 surfaces) — flipped in the [data-theme='light'] block so JSX inline
34 styles recolor with the theme */
35 --wash-aqua-03: rgba(70, 214, 198, 0.03);
36 --wash-aqua-06: rgba(70, 214, 198, 0.06);
37 --wash-aqua-08: rgba(70, 214, 198, 0.08);
38 --wash-aqua-10: rgba(70, 214, 198, 0.1);
39 --wash-aqua-14: rgba(70, 214, 198, 0.14);
40 --wash-verm-06: rgba(240, 100, 77, 0.06);
41 --wash-verm-08: rgba(240, 100, 77, 0.08);
42 --wash-verm-12: rgba(240, 100, 77, 0.12);
43 --wash-verm-14: rgba(240, 100, 77, 0.14);
44 background: var(--ink);
45 color: var(--bone);
46 font-family: 'Schibsted Grotesk', system-ui, sans-serif;
47 -webkit-font-smoothing: antialiased;
48 line-height: 1.6;
49 position: relative;
50 overflow-x: hidden;
52.tls-root * {
53 box-sizing: border-box;
55.tls-root::before {
56 content: '';
57 position: fixed;
58 inset: 0;
59 pointer-events: none;
60 z-index: 0;
61 background:
62 radial-gradient(900px 600px at 78% -8%, rgba(70, 214, 198, 0.1), transparent 60%),
63 radial-gradient(800px 700px at 8% 12%, rgba(224, 173, 78, 0.06), transparent 55%),
64 linear-gradient(180deg, #0b1618, #0a1315 60%, #081011);
66.tls-root::after {
67 content: '';
68 position: fixed;
69 inset: 0;
70 pointer-events: none;
71 z-index: 0;
72 opacity: 0.35;
73 background-image:
74 linear-gradient(var(--line-soft) 1px, transparent 1px),
75 linear-gradient(90deg, var(--line-soft) 1px, transparent 1px);
76 background-size: 46px 46px;
77 mask-image: radial-gradient(circle at 50% 30%, #000 0%, transparent 78%);
78 -webkit-mask-image: radial-gradient(circle at 50% 30%, #000 0%, transparent 78%);
80.tls-stage {
81 position: relative;
82 z-index: 1;
84 
85.tls-wrap {
86 max-width: 880px;
87 margin: 0 auto;
88 padding: 0 24px;
90@media (max-width: 640px) {
91 .tls-wrap {
92 padding: 0 18px;
93 }
95 
96.tls-mono {
97 font-family: var(--font-mono);
99.tls-serif {
100 font-family: 'Spectral', Georgia, serif;
102 
103.tls-display {
104 font-family: 'Spectral', Georgia, serif;
105 font-weight: 300;
106 letter-spacing: -0.02em;
107 line-height: 1.02;
108 color: var(--bone);
109 margin: 0;
111.tls-eyebrow {
112 font-family: var(--font-mono);
113 font-size: 11.5px;
114 letter-spacing: 0.26em;
115 text-transform: uppercase;
116 color: var(--aqua);
117 display: flex;
118 align-items: center;
119 gap: 12px;
121.tls-eyebrow .tls-dash {
122 width: 30px;
123 height: 1px;
124 background: var(--aqua);
125 opacity: 0.7;
126 display: inline-block;
128.tls-lede {
129 font-size: clamp(17px, 2.4vw, 20px);
130 line-height: 1.62;
131 color: var(--bone-dim);
132 font-weight: 400;
134.tls-prose {
135 font-size: 16.5px;
136 line-height: 1.72;
137 color: var(--bone-dim);
139.tls-prose strong {
140 color: var(--bone);
141 font-weight: 600;
143.tls-prose em {
144 color: var(--aqua-bright);
145 font-style: italic;
147.tls-h2 {
148 font-family: 'Spectral', serif;
149 font-weight: 400;
150 font-size: clamp(30px, 5.2vw, 46px);
151 line-height: 1.06;
152 letter-spacing: -0.02em;
153 margin: 0;
154 color: var(--bone);
156.tls-h3 {
157 font-family: 'Spectral', serif;
158 font-weight: 500;
159 font-size: 20px;
160 margin: 0;
161 color: var(--bone);
163 
164.tls-section {
165 padding: 74px 0;
166 border-top: 1px solid var(--line-soft);
167 position: relative;
169@media (max-width: 640px) {
170 .tls-section {
171 padding: 54px 0;
172 }
174 
175.tls-panel {
176 background: linear-gradient(180deg, var(--panel), var(--panel-2));
177 border: 1px solid var(--line);
178 border-radius: 10px;
180.tls-inset {
181 background: var(--ink-2);
182 border: 1px solid var(--line-soft);
183 border-radius: 8px;
185 
186/* reveal */
187.tls-rv {
188 opacity: 0;
189 transform: translateY(20px);
190 transition:
191 opacity 0.8s cubic-bezier(0.2, 0.7, 0.2, 1),
192 transform 0.8s cubic-bezier(0.2, 0.7, 0.2, 1);
194.tls-rv.tls-in {
195 opacity: 1;
196 transform: none;
198 
199/* responsive grids */
200.tls-grid2 {
201 display: grid;
202 grid-template-columns: 1fr 1fr;
203 gap: 16px;
205.tls-grid3 {
206 display: grid;
207 grid-template-columns: repeat(3, 1fr);
208 gap: 14px;
210@media (max-width: 760px) {
211 .tls-grid2 {
212 grid-template-columns: 1fr;
213 }
214 .tls-grid3 {
215 grid-template-columns: 1fr;
216 }
218 
219/* segmented control */
220.tls-seg {
221 display: flex;
222 flex-wrap: wrap;
223 gap: 7px;
225.tls-segbtn {
226 font-family: var(--font-mono);
227 font-size: 12px;
228 letter-spacing: 0.04em;
229 padding: 8px 13px;
230 border-radius: 7px;
231 cursor: pointer;
232 transition: all 0.18s;
233 background: var(--ink-2);
234 border: 1px solid var(--line);
235 color: var(--bone-dim);
236 white-space: nowrap;
238.tls-segbtn:hover {
239 border-color: var(--steel);
240 color: var(--bone);
242.tls-segbtn.on {
243 background: rgba(70, 214, 198, 0.13);
244 border-color: var(--aqua);
245 color: var(--aqua-bright);
247.tls-segbtn.on-brass {
248 background: rgba(224, 173, 78, 0.13);
249 border-color: var(--brass);
250 color: var(--brass-bright);
252.tls-segbtn.on-verm {
253 background: rgba(240, 100, 77, 0.14);
254 border-color: var(--verm);
255 color: var(--verm-bright);
257 
258.tls-btn {
259 font-family: var(--font-mono);
260 font-size: 12.5px;
261 letter-spacing: 0.05em;
262 text-transform: uppercase;
263 padding: 10px 16px;
264 border-radius: 7px;
265 cursor: pointer;
266 transition: all 0.18s;
267 display: inline-flex;
268 align-items: center;
269 gap: 8px;
270 background: rgba(70, 214, 198, 0.12);
271 border: 1px solid var(--aqua);
272 color: var(--aqua-bright);
274.tls-btn:hover {
275 background: rgba(70, 214, 198, 0.2);
277.tls-btn:disabled {
278 opacity: 0.4;
279 cursor: not-allowed;
281.tls-btn.ghost {
282 background: var(--ink-2);
283 border-color: var(--line);
284 color: var(--bone-dim);
286.tls-btn.ghost:hover {
287 border-color: var(--steel);
288 color: var(--bone);
290 
291.tls-tag {
292 font-family: var(--font-mono);
293 font-size: 10.5px;
294 letter-spacing: 0.16em;
295 text-transform: uppercase;
296 padding: 3px 9px;
297 border-radius: 20px;
298 border: 1px solid var(--line);
299 color: var(--bone-faint);
301.tls-chip {
302 display: inline-flex;
303 align-items: center;
304 gap: 6px;
305 font-family: var(--font-mono);
306 font-size: 11.5px;
307 padding: 5px 10px;
308 border-radius: 6px;
309 background: var(--ink-2);
310 border: 1px solid var(--line);
312 
313.tls-root input[type='range'].tls-range {
314 -webkit-appearance: none;
315 appearance: none;
316 width: 100%;
317 height: 4px;
318 border-radius: 3px;
319 background: var(--line);
320 outline: none;
321 margin: 10px 0;
323.tls-root input[type='range'].tls-range::-webkit-slider-thumb {
324 -webkit-appearance: none;
325 appearance: none;
326 width: 18px;
327 height: 18px;
328 border-radius: 50%;
329 background: var(--aqua);
330 border: 2px solid var(--ink);
331 cursor: pointer;
332 box-shadow: 0 0 0 1px var(--aqua);
334.tls-root input[type='range'].tls-range::-moz-range-thumb {
335 width: 18px;
336 height: 18px;
337 border-radius: 50%;
338 background: var(--aqua);
339 border: 2px solid var(--ink);
340 cursor: pointer;
342.tls-root input[type='range'].tls-range.brass {
343 background: var(--line);
345.tls-root input[type='range'].tls-range.brass::-webkit-slider-thumb {
346 background: var(--brass);
347 box-shadow: 0 0 0 1px var(--brass);
349.tls-root input[type='range'].tls-range.brass::-moz-range-thumb {
350 background: var(--brass);
352 
353.tls-input {
354 width: 100%;
355 background: var(--ink-2);
356 border: 1px solid var(--line);
357 border-radius: 7px;
358 color: var(--bone);
359 font-family: 'Schibsted Grotesk', sans-serif;
360 font-size: 15px;
361 padding: 11px 13px;
362 outline: none;
364.tls-input:focus {
365 border-color: var(--aqua);
367 
368@keyframes tls-click {
369 0% {
370 transform: scale(1);
371 }
372 40% {
373 transform: scale(1.22);
374 }
375 100% {
376 transform: scale(1);
377 }
379.tls-clickshut {
380 animation: tls-click 0.5s ease;
382@keyframes tls-flow {
383 to {
384 stroke-dashoffset: -18;
385 }
387@keyframes tls-pulse {
388 0%,
389 100% {
390 opacity: 0.5;
391 }
392 50% {
393 opacity: 1;
394 }
396 
397/* lock badge */
398.tls-lockbadge {
399 display: inline-flex;
400 align-items: center;
401 justify-content: center;
402 width: 46px;
403 height: 46px;
404 border-radius: 11px;
405 border: 1px solid;
406 transition: all 0.3s;
408.tls-lockbadge.sealed {
409 background: rgba(70, 214, 198, 0.12);
410 border-color: var(--aqua);
411 color: var(--aqua-bright);
413.tls-lockbadge.broken {
414 background: rgba(240, 100, 77, 0.12);
415 border-color: var(--verm);
416 color: var(--verm-bright);
418.tls-lockbadge.open {
419 background: var(--ink-2);
420 border-color: var(--line);
421 color: var(--bone-faint);
423 
424/* pull quote */
425.tls-pull {
426 font-family: 'Spectral', serif;
427 font-weight: 300;
428 font-style: italic;
429 font-size: clamp(22px, 3.6vw, 32px);
430 line-height: 1.28;
431 color: var(--bone);
432 letter-spacing: -0.01em;
434.tls-pull b {
435 font-style: normal;
436 font-weight: 500;
437 color: var(--aqua-bright);
439 
440/* node card (party) */
441.tls-party {
442 background: linear-gradient(180deg, var(--panel), var(--panel-2));
443 border: 1px solid var(--line);
444 border-radius: 10px;
445 padding: 16px;
447.tls-party .nm {
448 font-family: var(--font-mono);
449 font-size: 11px;
450 letter-spacing: 0.16em;
451 text-transform: uppercase;
452 color: var(--bone-faint);
453 display: flex;
454 align-items: center;
455 gap: 8px;
456 margin-bottom: 12px;
458 
459/* swatch */
460.tls-swatch {
461 border-radius: 9px;
462 border: 1px solid rgba(255, 255, 255, 0.14);
463 box-shadow: inset 0 0 22px rgba(0, 0, 0, 0.35);
465 
466/* ───────── NAVIGATION ───────── */
467.tls-rail {
468 position: fixed;
469 left: max(18px, calc(50% - 600px));
470 top: 50%;
471 transform: translateY(-50%);
472 z-index: 40;
473 display: flex;
474 flex-direction: column;
475 gap: 3px;
477.tls-rail a {
478 display: flex;
479 align-items: center;
480 gap: 11px;
481 text-decoration: none;
482 padding: 5px 6px;
483 border-radius: 7px;
484 color: var(--bone-faint);
485 transition: all 0.2s;
487.tls-rail a .dot {
488 width: 9px;
489 height: 9px;
490 border-radius: 50%;
491 border: 1.5px solid var(--line);
492 transition: all 0.25s;
493 flex: none;
495.tls-rail a .lbl {
496 font-family: var(--font-mono);
497 font-size: 11px;
498 letter-spacing: 0.04em;
499 opacity: 0;
500 transform: translateX(-6px);
501 transition: all 0.2s;
502 white-space: nowrap;
504.tls-rail a:hover .lbl {
505 opacity: 1;
506 transform: none;
507 color: var(--bone);
509.tls-rail a:hover .dot {
510 border-color: var(--steel);
512.tls-rail a.active .dot {
513 background: var(--aqua);
514 border-color: var(--aqua);
515 box-shadow: 0 0 10px var(--aqua);
517.tls-rail a.active .lbl {
518 opacity: 1;
519 transform: none;
520 color: var(--aqua-bright);
522@media (max-width: 1180px) {
523 .tls-rail {
524 display: none;
525 }
527 
528.tls-topbar {
529 position: fixed;
530 top: 0;
531 left: 0;
532 right: 0;
533 z-index: 40;
534 display: none;
535 align-items: center;
536 justify-content: space-between;
537 padding: 11px 18px;
538 background: rgba(8, 16, 18, 0.86);
539 backdrop-filter: blur(10px);
540 border-bottom: 1px solid var(--line);
542.tls-topbar .ttl {
543 font-family: var(--font-mono);
544 font-size: 11px;
545 letter-spacing: 0.14em;
546 text-transform: uppercase;
547 color: var(--aqua);
548 display: flex;
549 align-items: center;
550 gap: 9px;
551 min-width: 0;
553.tls-topbar .ttl span {
554 overflow: hidden;
555 text-overflow: ellipsis;
556 white-space: nowrap;
558.tls-menubtn {
559 background: var(--ink-2);
560 border: 1px solid var(--line);
561 color: var(--bone);
562 border-radius: 7px;
563 padding: 7px 11px;
564 cursor: pointer;
565 display: flex;
566 align-items: center;
567 gap: 7px;
568 font-family: var(--font-mono);
569 font-size: 11px;
570 flex: none;
572@media (max-width: 1180px) {
573 .tls-topbar {
574 display: flex;
575 }
577 
578.tls-sheet {
579 position: fixed;
580 inset: 0;
581 z-index: 50;
582 background: rgba(6, 12, 13, 0.97);
583 backdrop-filter: blur(6px);
584 padding: 64px 22px 30px;
585 overflow-y: auto;
586 display: flex;
587 flex-direction: column;
588 gap: 4px;
590.tls-sheet a {
591 display: flex;
592 align-items: center;
593 gap: 13px;
594 text-decoration: none;
595 padding: 14px 12px;
596 border-radius: 9px;
597 color: var(--bone-dim);
598 border: 1px solid transparent;
600.tls-sheet a:active,
601.tls-sheet a.active {
602 background: rgba(70, 214, 198, 0.08);
603 border-color: var(--line);
604 color: var(--aqua-bright);
606.tls-sheet a .n {
607 font-family: var(--font-mono);
608 font-size: 11px;
609 color: var(--bone-faint);
610 width: 26px;
612.tls-sheet a .t {
613 font-family: 'Spectral', serif;
614 font-size: 18px;
616.tls-sheetclose {
617 position: absolute;
618 top: 16px;
619 right: 18px;
620 background: var(--ink-2);
621 border: 1px solid var(--line);
622 color: var(--bone);
623 border-radius: 8px;
624 padding: 8px 11px;
625 cursor: pointer;
626 display: flex;
627 gap: 7px;
628 align-items: center;
629 font-family: var(--font-mono);
630 font-size: 11px;
632@media (min-width: 1181px) {
633 .tls-topbar,
634 .tls-sheet {
635 display: none !important;
636 }
638.tls-padtop {
639 padding-top: 0;
641@media (max-width: 1180px) {
642 .tls-padtop {
643 padding-top: 52px;
644 }
646 
647/* ─── Light theme — complementary to the dark default ─── */
648/* Warm paper switchboard: inked aqua channel, struck brass, deepened vermilion. */
649[data-theme='light'] .tls-root {
650 --ink: #ece4d4;
651 --ink-2: #e3d9c4;
652 --panel: #f4eee0;
653 --panel-2: #ece3d1;
654 --line: #968969;
655 --line-soft: #c2b69a;
656 --bone: #1b2826;
657 --bone-dim: #3c4944;
658 /* foreground tokens deepened in OKLCH (hue held) so small mono/label/value
659 text clears WCAG AA on the warm tinted panels/insets it sits on */
660 --bone-faint: #505952;
661 /* functional-label token: in paper mode --bone-faint already clears AA, so
662 the label token tracks it (parity, no light-mode visual change) */
663 --bone-label: #505952;
664 --aqua: #006a62;
665 --aqua-bright: #11635c;
666 --aqua-deep: #0e6b63;
667 --brass: #a87c1f;
668 --brass-bright: #7a580b;
669 --brass-deep: #c79a3e;
670 --verm: #ab1c01;
671 --verm-bright: #9c2814;
672 --verm-deep: #e0644d;
673 --steel: #4d625e;
674 /* inline accent washes recast as tints of the darkened paper-mode accents */
675 --wash-aqua-03: rgba(27, 141, 131, 0.05);
676 --wash-aqua-06: rgba(27, 141, 131, 0.08);
677 --wash-aqua-08: rgba(27, 141, 131, 0.1);
678 --wash-aqua-10: rgba(27, 141, 131, 0.12);
679 --wash-aqua-14: rgba(27, 141, 131, 0.14);
680 --wash-verm-06: rgba(198, 58, 35, 0.07);
⋯ 61 lines hidden (lines 681–741)
681 --wash-verm-08: rgba(198, 58, 35, 0.09);
682 --wash-verm-12: rgba(198, 58, 35, 0.12);
683 --wash-verm-14: rgba(198, 58, 35, 0.14);
685[data-theme='light'] .tls-root::before {
686 background:
687 radial-gradient(900px 600px at 78% -8%, rgba(27, 141, 131, 0.12), transparent 60%),
688 radial-gradient(800px 700px at 8% 12%, rgba(168, 124, 31, 0.08), transparent 55%),
689 linear-gradient(180deg, #f1eadb, #ece4d3 60%, #e6ddc9);
691[data-theme='light'] .tls-root::after {
692 opacity: 0.5;
694 
695/* segmented control + button washes (hardcoded accent rgba) */
696[data-theme='light'] .tls-segbtn.on {
697 background: rgba(27, 141, 131, 0.12);
699[data-theme='light'] .tls-segbtn.on-brass {
700 background: rgba(168, 124, 31, 0.13);
702[data-theme='light'] .tls-segbtn.on-verm {
703 background: rgba(198, 58, 35, 0.12);
705[data-theme='light'] .tls-btn {
706 background: rgba(27, 141, 131, 0.12);
708[data-theme='light'] .tls-btn:hover {
709 background: rgba(27, 141, 131, 0.2);
711 
712/* lock badge washes */
713[data-theme='light'] .tls-lockbadge.sealed {
714 background: rgba(27, 141, 131, 0.12);
716[data-theme='light'] .tls-lockbadge.broken {
717 background: rgba(198, 58, 35, 0.12);
719 
720/* rail active glow (aqua now darker — keep a soft halo) */
721[data-theme='light'] .tls-rail a.active .dot {
722 box-shadow: 0 0 8px rgba(27, 141, 131, 0.55);
724 
725/* topbar / sheet scrims become warm paper */
726[data-theme='light'] .tls-topbar {
727 background: rgba(241, 234, 219, 0.86);
729[data-theme='light'] .tls-sheet {
730 background: rgba(241, 234, 219, 0.97);
732[data-theme='light'] .tls-sheet a:active,
733[data-theme='light'] .tls-sheet a.active {
734 background: rgba(27, 141, 131, 0.1);
736 
737/* swatch frame: dark hairline + lighter inset on paper */
738[data-theme='light'] .tls-swatch {
739 border: 1px solid rgba(0, 0, 0, 0.16);
740 box-shadow: inset 0 0 22px rgba(0, 0, 0, 0.18);

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
agents/theme-parity.ts480 lines · TypeScript
⋯ 283 lines hidden (lines 1–283)
1/**
2 * Theme-parity loop — Many Hands Engineering, light/dark dual-mode coverage.
3 *
4 * A READ-ONLY mapper of whether each lesson ships BOTH a light and a dark
5 * version of its unique design. The collection is dual-mode: the shell sets a
6 * `data-theme` attribute on <html> (see src/shared/useTheme.js / tokens.css), and
7 * each lesson keeps its bespoke palette as its NATIVE mode while shipping the
8 * complementary mode as a `[data-theme='…'] .<root>{}` override block in its own
9 * <slug>.css. This loop maps the lessons whose complementary mode is MISSING or
10 * INCOMPLETE — the exact gap a new lesson (or a freshly-touched palette) leaves.
11 *
12 * It is the theming sibling of `style-isolation` (and shares its discipline): the
13 * harness parses each CSS file with a brace-aware walker and reports per-file
14 * facts — native mode, how many `[data-theme]` override rules exist per mode, a
15 * literal-color coverage proxy, and any override selector that LEAKS globally
16 * (the same `:root`/`*`/bare-element hazard, now also `[data-theme='light']`
17 * with nothing scoped after it). The agent Reads each and emits a strict
18 * three-bucket map. Visual parity is not something `vitest`/`eslint`/`vite build`
19 * can assert, so — like content-accuracy — it only ever MAPS; the human crafts
20 * the complementary skin.
21 *
22 * Permissions: Read / Grep / Glob ONLY — structurally cannot edit a file.
23 *
24 * Usage: tsx theme-parity.ts <scope> (scope REQUIRED — file or dir)
25 * e.g.: src/lessons/ src/lessons/tls/tls.css src/lessons/swim/
26 */
27import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
28import { relative, resolve } from "node:path";
29import { APP_ROOT, report, runLoop } from "./lib.js";
30 
31const ALLOWED_TOOLS = ["Read", "Grep", "Glob"];
32 
33const SKIP_DIRS = new Set([
34 "node_modules",
35 ".git",
36 ".claude",
37 "coverage",
38 "dist",
39 "test-results",
40 "playwright-report",
41]);
42 
43const SCAN_EXT = /\.css$/;
44 
45// The shell layer is the family-glue global by design (it OWNS the data-theme
46// switch). It is never a per-lesson parity gap. lesson-kit.css is the shared,
47// token-driven UI kit — it flips through each lesson's redefined tokens, so it
48// has no [data-theme] block of its own and is not a gap either.
49const SHELL_FILES = new Set([
50 "src/shared/tokens.css",
51 "src/shared/utilities.css",
52 "src/shared/nav.css",
53 "src/shared/lesson-kit/lesson-kit.css",
54]);
55 
56function getScope(): string {
57 const scope = process.argv[2];
58 if (!scope) {
59 report([
60 "RESULT: CANNOT RUN — a scope is required (no `--all` option by design).",
61 "",
62 "Usage: tsx theme-parity.ts <scope>",
63 " e.g.: src/lessons/ src/lessons/tls/tls.css src/lessons/swim/",
64 "",
65 "(Scoping each run keeps the map actionable.)",
66 ]);
67 process.exit(1);
68 }
69 if (!existsSync(resolve(APP_ROOT, scope))) {
70 report([`RESULT: CANNOT RUN — scope path '${scope}' does not exist relative to the repo root.`]);
71 process.exit(1);
72 }
73 return scope;
75 
76function gatherSources(scope: string): string[] {
77 const scopeAbs = resolve(APP_ROOT, scope);
78 const st = statSync(scopeAbs);
79 if (st.isFile()) {
80 return SCAN_EXT.test(scopeAbs) ? [scopeAbs] : [];
81 }
82 const files: string[] = [];
83 const walk = (dir: string) => {
84 for (const entry of readdirSync(dir, { withFileTypes: true })) {
85 const full = resolve(dir, entry.name);
86 if (entry.isDirectory()) {
87 if (SKIP_DIRS.has(entry.name)) continue;
88 walk(full);
89 } else if (SCAN_EXT.test(entry.name)) {
90 files.push(full);
91 }
92 }
93 };
94 walk(scopeAbs);
95 files.sort();
96 return files;
98 
99// ── Comment-strip that preserves line numbers (same as style-isolation) ─────
100function stripComments(css: string): string {
101 let out = "";
102 let i = 0;
103 while (i < css.length) {
104 if (css[i] === "/" && css[i + 1] === "*") {
105 i += 2;
106 while (i < css.length && !(css[i] === "*" && css[i + 1] === "/")) {
107 out += css[i] === "\n" ? "\n" : " ";
108 i++;
109 }
110 out += " ";
111 i += 2;
112 } else {
113 out += css[i];
114 i++;
115 }
116 }
117 return out;
119 
120// ── Color helpers ───────────────────────────────────────────────────────────
121const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/;
122const THEME_ATTR = /\[\s*data-theme\s*[~|^$*]?=\s*['"]?(light|dark)['"]?\s*\]/gi;
123 
124/** Parse a hex / rgb() background literal into 0–1 relative luminance, or null. */
125function luminanceOf(value: string): number | null {
126 const hex = value.match(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
127 let r: number, g: number, b: number;
128 if (hex) {
129 let h = hex[1];
130 if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
131 r = parseInt(h.slice(0, 2), 16);
132 g = parseInt(h.slice(2, 4), 16);
133 b = parseInt(h.slice(4, 6), 16);
134 } else {
135 const rgb = value.match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
136 if (!rgb) return null;
137 r = +rgb[1];
138 g = +rgb[2];
139 b = +rgb[3];
140 }
141 // Perceptual-ish luminance, good enough to call a ground dark vs light.
142 return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
144 
145/**
146 * Luminance of a CSS value, following one or two levels of `var(--x[, fallback])`
147 * through the lesson's own token definitions. Lesson roots commonly set
148 * `background: var(--bg)` with `--bg` a literal defined just above, so a literal-only
149 * reader would miss the ground; this resolves it. Returns null if unresolvable.
150 */
151function resolveLuminance(value: string, tokenDefs: Map<string, string>, depth: number): number | null {
152 const direct = luminanceOf(value);
153 if (direct !== null) return direct;
154 if (depth >= 3) return null;
155 const v = value.match(/var\(\s*(--[a-zA-Z0-9-]+)\s*(?:,\s*([^)]+))?\)/);
156 if (!v) return null;
157 const resolved = tokenDefs.get(v[1]);
158 if (resolved !== undefined) return resolveLuminance(resolved, tokenDefs, depth + 1);
159 if (v[2]) return resolveLuminance(v[2].trim(), tokenDefs, depth + 1); // the fallback
160 return null;
162 
163// ── The walker: per-rule prelude + body + line ──────────────────────────────
164interface Rule {
165 prelude: string;
166 body: string;
167 line: number;
169 
170function scanRules(text: string): Rule[] {
171 const css = stripComments(text);
172 const rules: Rule[] = [];
173 let depth = 0;
174 let buf = "";
175 let line = 1;
176 let bufStartLine = 1;
177 let bufStarted = false;
178 let keyframesDepth = -1;
179 let blockStartIdx = -1;
180 let pendingPrelude = "";
181 let pendingLine = 1;
182 
183 for (let i = 0; i < css.length; i++) {
184 const ch = css[i];
185 if (ch === "\n") line++;
186 if (ch === "{") {
187 const prelude = buf.trim();
188 const isAtRule = prelude.startsWith("@");
189 const insideKeyframes = keyframesDepth !== -1 && depth > keyframesDepth;
190 if (isAtRule) {
191 if (/^@keyframes\b/i.test(prelude) && keyframesDepth === -1) keyframesDepth = depth;
192 } else if (!insideKeyframes && prelude) {
193 // A normal rule opening — remember where its body starts so we can slice it.
194 pendingPrelude = prelude;
195 pendingLine = bufStartLine;
196 blockStartIdx = i + 1;
197 }
198 depth++;
199 buf = "";
200 bufStarted = false;
201 } else if (ch === "}") {
202 if (blockStartIdx !== -1 && pendingPrelude) {
203 rules.push({ prelude: pendingPrelude, body: css.slice(blockStartIdx, i), line: pendingLine });
204 blockStartIdx = -1;
205 pendingPrelude = "";
206 }
207 depth--;
208 if (keyframesDepth !== -1 && depth <= keyframesDepth) keyframesDepth = -1;
209 buf = "";
210 bufStarted = false;
211 } else if (ch === ";" && depth === 0) {
212 buf = "";
213 bufStarted = false;
214 } else {
215 if (!bufStarted && !/\s/.test(ch)) {
216 bufStarted = true;
217 bufStartLine = line;
218 }
219 buf += ch;
220 }
221 }
222 return rules;
224 
225// A comma-part is "scoped" if it contains a class (.) or id (#). Reused for the
226// leak check on the REMAINDER of a [data-theme] selector after the attr is removed.
227function hasClassOrId(part: string): boolean {
228 return part.includes(".") || part.includes("#");
230 
231function countColorDecls(body: string): number {
232 // Count declarations whose VALUE carries a literal color. var(--token) refs
233 // don't count — they flow from a (possibly redefined) token, not a hardcoded
234 // value, so they're already theme-reactive.
235 let n = 0;
236 for (const decl of body.split(";")) {
237 const idx = decl.indexOf(":");
238 if (idx === -1) continue;
239 const value = decl.slice(idx + 1);
240 if (COLOR_LITERAL.test(value)) n++;
241 }
242 return n;
244 
245interface FileReport {
246 file: string; // relative
247 isShell: boolean;
248 rootSelector: string | null;
249 nativeMode: "light" | "dark" | "unknown";
250 themeLightRules: number;
251 themeDarkRules: number;
252 baseColorDecls: number;
253 themeColorDecls: number;
254 complementMode: "light" | "dark" | "unknown";
255 complementPresent: boolean;
256 leaks: { selector: string; line: number }[];
258 
259function analyze(file: string): FileReport {
260 const fileRel = relative(APP_ROOT, file);
261 const isShell = SHELL_FILES.has(fileRel);
262 const rules = scanRules(readFileSync(file, "utf8"));
263 
264 let rootSelector: string | null = null;
265 let nativeMode: FileReport["nativeMode"] = "unknown";
266 let themeLightRules = 0;
267 let themeDarkRules = 0;
268 let baseColorDecls = 0;
269 let themeColorDecls = 0;
270 const leaks: FileReport["leaks"] = [];
271 // Custom-property literals from non-theme rules, for resolving a var() ground.
272 const tokenDefs = new Map<string, string>();
273 // Non-theme rules that set BOTH background and color — native-ground candidates.
274 const rootCandidates: { prelude: string; bg: string }[] = [];
275 
276 for (const rule of rules) {
277 const themeModes = new Set<string>();
278 let m: RegExpExecArray | null;
279 THEME_ATTR.lastIndex = 0;
280 while ((m = THEME_ATTR.exec(rule.prelude)) !== null) themeModes.add(m[1].toLowerCase());
281 const isThemeRule = themeModes.size > 0;
282 const colorDecls = countColorDecls(rule.body);
283 
284 if (isThemeRule) {
285 if (themeModes.has("light")) themeLightRules++;
286 if (themeModes.has("dark")) themeDarkRules++;
287 themeColorDecls += colorDecls;
288 // Leak check (lesson files only — the shell's :root[data-theme]{} IS the
289 // global switch, by design). Strip the [data-theme] tokens; if a comma-part
290 // has no class/id left, the override matches globally and leaks into the
291 // shell + every other lesson. Mirrors style-isolation, on the theme remainder.
292 if (!isShell) {
293 for (const part of rule.prelude.split(",")) {
294 if (!THEME_ATTR.test(part)) continue;
295 THEME_ATTR.lastIndex = 0;
296 const remainder = part.replace(THEME_ATTR, " ").trim();
297 if (!hasClassOrId(remainder)) {
298 leaks.push({ selector: rule.prelude.replace(/\s+/g, " ").slice(0, 120), line: rule.line });
299 break;
300 }
⋯ 29 lines hidden (lines 301–329)
301 }
302 }
303 } else {
304 baseColorDecls += colorDecls;
305 for (const decl of rule.body.split(";")) {
306 const cm = decl.match(/^\s*(--[a-zA-Z0-9-]+)\s*:\s*(.+)$/);
307 if (cm && !tokenDefs.has(cm[1])) tokenDefs.set(cm[1], cm[2].trim());
308 }
309 if (/(^|[\s;{])background(-color)?\s*:/.test(rule.body) && /(^|[\s;])color\s*:/.test(rule.body)) {
310 const bgMatch = rule.body.match(/background(?:-color)?\s*:\s*([^;]+)/);
311 if (bgMatch) {
312 rootCandidates.push({ prelude: rule.prelude.replace(/\s+/g, " ").slice(0, 80), bg: bgMatch[1].trim() });
313 }
314 }
315 }
316 }
317 
318 // Native ground: first candidate whose background luminance resolves (through
319 // the token map for a var() ground). Informational — the agent confirms by Read.
320 for (const cand of rootCandidates) {
321 const lum = resolveLuminance(cand.bg, tokenDefs, 0);
322 if (lum !== null) {
323 rootSelector = cand.prelude;
324 nativeMode = lum < 0.4 ? "dark" : "light";
325 break;
326 }
327 }
328 
329 const complementMode: FileReport["complementMode"] =
330 nativeMode === "dark" ? "light" : nativeMode === "light" ? "dark" : "unknown";
331 // A lesson HAS its complement once it ships ANY data-theme-keyed rules: the
332 // unkeyed rules paint one mode and the [data-theme] block paints the other.
333 // (Which mode the block targets is informational — requiring a *specific* mode
334 // here produced false "missing" flags whenever the native-ground guess was off.)
335 // The truly-missing case this catches is a lesson with no [data-theme] block.
336 const complementPresent = themeLightRules + themeDarkRules > 0;
337 
⋯ 143 lines hidden (lines 338–480)
338 return {
339 file: fileRel,
340 isShell,
341 rootSelector,
342 nativeMode,
343 themeLightRules,
344 themeDarkRules,
345 baseColorDecls,
346 themeColorDecls,
347 complementMode,
348 complementPresent,
349 leaks,
350 };
352 
353function statusOf(r: FileReport): "missing" | "thin" | "present" {
354 if (!r.complementPresent) return "missing";
355 // Coverage proxy: a complement that redefines far fewer literal colors than the
356 // native mode carries is probably partial. Threshold is intentionally loose —
357 // the agent makes the real call by Reading.
358 if (r.baseColorDecls >= 6 && r.themeColorDecls < r.baseColorDecls * 0.35) return "thin";
359 return "present";
361 
362function formatForAgent(reports: FileReport[]): string {
363 const lessons = reports.filter((r) => !r.isShell);
364 const shell = reports.filter((r) => r.isShell);
365 
366 const line = (r: FileReport) => {
367 const st = statusOf(r).toUpperCase();
368 const leakNote = r.leaks.length ? ` · ⚠ ${r.leaks.length} possibly-global override selector(s)` : "";
369 return ` - ${r.file} · native:${r.nativeMode} → needs:${r.complementMode} · light-rules:${r.themeLightRules} dark-rules:${r.themeDarkRules} · color-literals base:${r.baseColorDecls}/theme:${r.themeColorDecls} · HARNESS:${st}${leakNote}`;
370 };
371 
372 const leakLines = reports
373 .flatMap((r) => r.leaks.map((l) => ` - ${r.file}:${l.line} · \`${l.selector}\``))
374 .join("\n");
375 
376 return `CSS files in scope (${reports.length}; ${lessons.length} lesson/page, ${shell.length} shell):
377 
378LESSON / PAGE files — does each ship BOTH modes?
379${lessons.map(line).join("\n") || " (none)"}
380 
381SHELL files (the global data-theme layer — owning both modes here is BY DESIGN, never a gap):
382${shell.map(line).join("\n") || " (none)"}
383 
384${leakLines ? `Possibly-global override selectors (a [data-theme] rule with nothing scoped after the attribute — verify each; in a LESSON file this leaks into the shell + every other lesson):\n${leakLines}\n` : "Possibly-global override selectors: NONE detected.\n"}
385The HARNESS status is a heuristic from a literal-color count + root-luminance guess — NOT a verdict. For each lesson file, Read it and confirm: (a) is the complementary mode actually present and (b) does every color-bearing declaration in the native mode have a counterpart in the override (directly, or via a redefined custom property it cascades from — a token redefined on the root covers everything that uses it). Classify each into ONE of the three buckets. A bucket (1) finding requires a quoted selector/declaration from your Read.`;
387 
388function systemPrompt(scope: string): string {
389 return `You are the theme-parity mapper for the Glassbox repository (React 19 + Vite — a collection of self-contained lessons, each lazily loaded and each shipping its OWN <slug>.css scoped under a lesson root class: .tls-root, .hll, .bt-root, .lesson-root, .idx-root, .cap-root, .udp-root, .mw, .sha-root, .mk-root, .vp-root, …). The whole collection is DUAL-MODE: the shell sets a \`data-theme="light"|"dark"\` attribute on <html> before first paint (the switch lives in src/shared/useTheme.js + ThemeToggle.jsx; the shell's family-glue light/dark tokens are in src/shared/tokens.css). Each lesson keeps its bespoke palette as its NATIVE mode and ships the COMPLEMENTARY mode as a \`[data-theme='…'] .<root>{}\` override block in its own <slug>.css. The harness has parsed \`${scope}\` and reported, per file, the native mode, the count of \`[data-theme]\` override rules per mode, a literal-color coverage proxy, and any override selector that may match globally. Your job: turn that into a curated map of which lessons are MISSING or have an INCOMPLETE complementary version of their design.
390 
391Your only tools are Read / Grep / Glob — you can investigate but CANNOT edit any file. The human crafts the complementary skin you map.
392 
393WHY THIS MATTERS: a lesson with no (or a half-finished) \`[data-theme]\` block looks broken in the other mode — dark text on a dark ground, an un-recolored hardcoded gradient glowing through a light page, an accent that vanishes. Parity means BOTH modes are intentional and complete. The two light-native lessons (b-trees, merkle-trees) need a DARK complement; every dark-native lesson needs a LIGHT complement.
394 
395For EACH lesson file, classify into EXACTLY ONE of three buckets:
396 
397(1) MISSING or INCOMPLETE complementary variant — ADD / EXTEND IT. Include ONLY when you can answer all THREE in one sentence each:
398 (a) WHAT — quote the EXACT native-mode declaration(s) with NO counterpart in the override (a hardcoded \`background: linear-gradient(...)\` in \`.x::before\`, a \`color: #…\`, a \`box-shadow\`, a CSS-set \`fill\`/\`stroke\`), in backticks, with file:line and the lesson. Or, if the whole \`[data-theme]\` block is absent, say so and name the native mode + the complement it needs. Cite or omit.
399 (b) WHY it is a gap — it is a color-bearing declaration in the native mode that neither the override block re-states NOR inherits from a custom property the override redefines.
400 (c) ACTION — the literal next step: "add \`[data-theme='light'] .<root>{ … }\` redefining --ink/--panel/--bg…" / "override \`[data-theme='light'] .x::before{ background: … }\` for the hardcoded gradient".
401 
402(2) COMPLETE — VERIFIED PARITY. The lesson ships both modes and every color-bearing native declaration has a counterpart (directly, or via a redefined token it cascades from — a token redefined on the root covers all its \`var()\` users). State briefly what you checked (e.g. "all 14 palette tokens redefined under [data-theme='light'] .swim-root; the two ::before/::after gradients are overridden").
403 
404(3) JUDGMENT-HEAVY (your call). A color deliberately the SAME in both modes (a semantic danger red / attacker vermilion meant to stay constant; an accent that reads on both grounds by design; a decorative element that works either way), OR heavy reliance on inline-styled colors in the lesson's JSX that CSS overrides can't reach (Grep the lesson dir — note it as a softer concern, since the fix is in JSX, not CSS). Name the trade-off.
405 
406SCOPE / LEAK AWARENESS:
407- Every \`[data-theme]\` override MUST stay under the lesson root (\`[data-theme='light'] .tls-root …\`). A \`[data-theme='light']\` with nothing scoped after it — or \`[data-theme='light'] body/*\` — sets tokens/styles GLOBALLY on <html> and leaks into the shell and every other lazily-loaded lesson (the same hazard \`style-isolation\` guards). If the harness flagged one in a LESSON file, treat it as a bucket (1) defect: "scope the override under the lesson root". Quote it.
408- The SHELL files (src/shared/tokens.css, utilities.css, nav.css) OWN the global data-theme layer — their \`:root[data-theme='light']{}\` and global rules are BY DESIGN. Never flag them as gaps or leaks; they are bucket (2).
409- @import / @keyframes / @font-face are not themed rules. Keyframes that use \`var(--token)\` already flip via the redefined token.
410 
411WHEN THE HARNESS REPORTS every lesson PRESENT (clean scan): sample 2-3 files to confirm both blocks really exist and the override redefines the palette, then output the default clean map. A bucket (1) finding requires a quoted declaration from your Read — do not confabulate.
412 
413Output a structured map with exactly these three sections in this order:
414 
415## Missing / incomplete complementary mode — add or extend (review & act)
416 
417(per item — file:line · WHAT (quoted) · WHY · ACTION)
418 
419## Complete — verified parity
420 
421(list — lesson · what you checked)
422 
423## Judgment-heavy (your call)
424 
425(list — finding · the trade-off)
426 
427End with a final summary line: "<X> missing/incomplete · <Y> complete · <Z> judgment". Nothing after.`;
429 
430async function main(): Promise<void> {
431 const scope = getScope();
432 console.log(`Theme-parity loop — scope: ${scope}\n`);
433 
434 const files = gatherSources(scope);
435 if (files.length === 0) {
436 report([
437 `Scope: ${scope}`,
438 `CSS files in scope: 0`,
439 "",
440 "RESULT: PASS — no scannable CSS files in scope. Nothing to map.",
441 ]);
442 return;
443 }
444 
445 console.log(`── Parsing ${files.length} CSS file(s) for light/dark coverage ──`);
446 const reports = files.map(analyze);
447 const lessons = reports.filter((r) => !r.isShell);
448 const missing = lessons.filter((r) => statusOf(r) === "missing").length;
449 const thin = lessons.filter((r) => statusOf(r) === "thin").length;
450 const leaks = reports.reduce((n, r) => n + r.leaks.length, 0);
451 console.log(` Lesson/page files: ${lessons.length} · harness MISSING: ${missing} · THIN: ${thin} · possibly-global overrides: ${leaks}\n`);
452 
453 const { agentRun, agentSummary } = await runLoop({
454 systemPrompt: systemPrompt(scope),
455 allowedTools: ALLOWED_TOOLS,
456 prompt: formatForAgent(reports),
457 // opus: deciding whether a token redefinition truly covers a declaration —
458 // and separating a deliberate theme-invariant color from a real gap — is the
459 // kind of judgment that does not survive sonnet's pattern-matching priors.
460 model: "opus",
461 });
462 
463 report([
464 `Scope: ${scope}`,
465 `CSS files in scope: ${files.length} (${lessons.length} lesson/page)`,
466 `Harness MISSING / THIN: ${missing} / ${thin}`,
467 `Possibly-global overrides: ${leaks}`,
468 `Agent run: ${agentRun}`,
469 "(read-only — the working tree was not modified)",
470 "",
471 agentSummary || "(no map produced — see streamed agent output above)",
472 "",
473 `RESULT: PASS — map above for \`${scope}\`; review the Missing/incomplete section and craft the complementary modes you choose.`,
474 ]);
476 
477main().catch((err) => {
478 report([`RESULT: ERROR — ${err instanceof Error ? err.message : String(err)}`]);
479 process.exitCode = 1;
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
agents/token-hygiene.ts349 lines · TypeScript
⋯ 185 lines hidden (lines 1–185)
1/**
2 * Token-hygiene loop — Many Hands Engineering, CSS custom-property integrity.
3 *
4 * A READ-ONLY mapper of dangling and dead CSS custom properties — the gap knip
5 * (the dead-code loop) cannot see, since it only understands JS. After a big
6 * theme/token refactor a lesson tends to accrue two faults:
7 *
8 * • REFERENCED-BUT-UNDEFINED — a `var(--x)` whose `--x` is defined nowhere
9 * reachable (a rename typo, a cross-lesson leak, a deleted token). It renders
10 * as nothing (or the fallback), silently.
11 * • DEFINED-BUT-UNUSED — a `--x:` declared on a lesson root that no rule or
12 * inline style ever reads. Dead weight left by a rename or a dropped widget.
13 *
14 * The harness scans every `.css` + `.jsx`/`.js` in scope (plus the shell layer
15 * and the lesson-kit, which are always reachable) for declarations (`--x:`) and
16 * references (`var(--x)`, incl. inline `style={{'--x': …}}`), diffs the two sets,
17 * and hands the agent the dangling refs + the unused defs with file:line. The
18 * agent Reads each and emits a strict three-bucket map. Custom properties can be
19 * read by JS (`getComputedStyle`) or kept as a deliberate fallback, so — like
20 * the other mappers — it only ever PROPOSES; the human prunes.
21 *
22 * Permissions: Read / Grep / Glob ONLY — structurally cannot edit a file.
23 *
24 * Usage: tsx token-hygiene.ts <scope> (scope REQUIRED — file or dir)
25 * e.g.: src/lessons/ src/lessons/tls/ src/lessons/bloom-clock/bloom-clock.css
26 */
27import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
28import { relative, resolve } from "node:path";
29import { APP_ROOT, report, runLoop } from "./lib.js";
30 
31const ALLOWED_TOOLS = ["Read", "Grep", "Glob"];
32 
33const SKIP_DIRS = new Set([
34 "node_modules",
35 ".git",
36 ".claude",
37 "coverage",
38 "dist",
39 "test-results",
40 "playwright-report",
41 "agents",
42]);
43 
44const SCAN_EXT = /\.(css|jsx|js|mjs|cjs)$/;
45 
46// The shell layer + the lesson-kit are ALWAYS reachable: their tokens are the
47// shared API every lesson can consume, and lesson-kit.css reads the `--lk-*`
48// contract each lesson sets on its root. Always folded into both sets so a
49// lesson's `--lk-accent: …` is seen as USED, and a `var(--ink)` as DEFINED.
50const SHELL_FILES = [
51 "src/shared/tokens.css",
52 "src/shared/utilities.css",
53 "src/shared/nav.css",
54 "src/shared/lesson-kit/lesson-kit.css",
55];
56 
57// A handful of tokens are set/read across the React boundary (inline style props
58// the static scan can't pair) — `--accent` is set by Nav.jsx on each pill.
59const KNOWN_DYNAMIC = new Set(["--accent"]);
60 
61function getScope(): string {
62 const scope = process.argv[2];
63 if (!scope) {
64 report([
65 "RESULT: CANNOT RUN — a scope is required (no `--all` option by design).",
66 "",
67 "Usage: tsx token-hygiene.ts <scope>",
68 " e.g.: src/lessons/ src/lessons/tls/ src/lessons/bloom-clock/bloom-clock.css",
69 "",
70 "(Scoping each run keeps the map actionable.)",
71 ]);
72 process.exit(1);
73 }
74 if (!existsSync(resolve(APP_ROOT, scope))) {
75 report([`RESULT: CANNOT RUN — scope path '${scope}' does not exist relative to the repo root.`]);
76 process.exit(1);
77 }
78 return scope;
80 
81function gatherSources(scope: string): string[] {
82 const scopeAbs = resolve(APP_ROOT, scope);
83 const st = statSync(scopeAbs);
84 if (st.isFile()) return SCAN_EXT.test(scopeAbs) ? [scopeAbs] : [];
85 const files: string[] = [];
86 const walk = (dir: string) => {
87 for (const entry of readdirSync(dir, { withFileTypes: true })) {
88 const full = resolve(dir, entry.name);
89 if (entry.isDirectory()) {
90 if (SKIP_DIRS.has(entry.name)) continue;
91 walk(full);
92 } else if (SCAN_EXT.test(entry.name)) {
93 files.push(full);
94 }
95 }
96 };
97 walk(scopeAbs);
98 files.sort();
99 return files;
101 
102// ── Comment-strip (CSS + JS line/block), preserving line numbers ───────────
103function stripComments(src: string): string {
104 let out = "";
105 let i = 0;
106 while (i < src.length) {
107 if (src[i] === "/" && src[i + 1] === "*") {
108 i += 2;
109 while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
110 out += src[i] === "\n" ? "\n" : " ";
111 i++;
112 }
113 out += " ";
114 i += 2;
115 } else if (src[i] === "/" && src[i + 1] === "/") {
116 i += 2;
117 while (i < src.length && src[i] !== "\n") {
118 out += " ";
119 i++;
120 }
121 } else {
122 out += src[i];
123 i++;
124 }
125 }
126 return out;
128 
129interface Loc {
130 file: string;
131 line: number;
133 
134// Definitions: a custom-property declaration `--x:` (CSS rules) or an inline
135// style key `'--x':` / `"--x":` (JSX). References: `var(--x …)`.
136const DEF_CSS = /(?:^|[;{}\s])(--[a-zA-Z0-9-]+)\s*:/g;
137const DEF_JSX = /['"](--[a-zA-Z0-9-]+)['"]\s*:/g;
138// Group 2 captures a `,` when the var() carries a FALLBACK — `var(--x, default)`.
139// A reference WITH a fallback is intentionally tolerant of a missing definition
140// (the fallback is the design), so only fallback-less undefined refs are dangling.
141const REF_VAR = /var\(\s*(--[a-zA-Z0-9-]+)\s*(,)?/g;
142 
143function lineOf(text: string, index: number): number {
144 let line = 1;
145 for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
146 return line;
148 
149function collect(file: string, defs: Map<string, Loc[]>, refs: Set<string>): void {
150 const fileRel = relative(APP_ROOT, file);
151 const raw = stripComments(readFileSync(file, "utf8"));
152 const isCss = /\.css$/.test(file);
153 
154 const defRe = isCss ? DEF_CSS : DEF_JSX;
155 let m: RegExpExecArray | null;
156 defRe.lastIndex = 0;
157 while ((m = defRe.exec(raw)) !== null) {
158 const name = m[1];
159 if (!defs.has(name)) defs.set(name, []);
160 defs.get(name)!.push({ file: fileRel, line: lineOf(raw, m.index) });
161 }
162 REF_VAR.lastIndex = 0;
163 while ((m = REF_VAR.exec(raw)) !== null) refs.add(m[1]);
165 
166interface Analysis {
167 undefinedRefs: { name: string; locs: Loc[] }[]; // referenced, defined nowhere reachable
168 unusedDefs: { name: string; locs: Loc[] }[]; // defined in scope, referenced nowhere
170 
171function analyze(files: string[]): Analysis {
172 const defs = new Map<string, Loc[]>(); // name → where defined (scope + shell)
173 const refs = new Set<string>(); // every var(--x) seen (scope + shell)
174 
175 // Shell + kit are always reachable.
176 for (const rel of SHELL_FILES) {
177 const abs = resolve(APP_ROOT, rel);
178 if (existsSync(abs)) collect(abs, defs, refs);
179 }
180 // Track which references appear specifically inside the scope, so we can also
181 // record where an undefined ref is used (re-scan scope files for ref sites).
182 // refSites records only FALLBACK-LESS reference sites — a `var(--x, default)`
183 // tolerates a missing --x by design, so it is never a dangling-ref defect.
184 const refSites = new Map<string, Loc[]>();
185 for (const file of files) {
186 collect(file, defs, refs);
187 const fileRel = relative(APP_ROOT, file);
188 const raw = stripComments(readFileSync(file, "utf8"));
189 let m: RegExpExecArray | null;
190 REF_VAR.lastIndex = 0;
191 while ((m = REF_VAR.exec(raw)) !== null) {
192 if (m[2]) continue; // has a fallback → intentional, not a dangling ref
193 if (!refSites.has(m[1])) refSites.set(m[1], []);
194 refSites.get(m[1])!.push({ file: fileRel, line: lineOf(raw, m.index) });
195 }
196 }
197 
198 const scopeRel = new Set(files.map((f) => relative(APP_ROOT, f)));
199 const shellRel = new Set(SHELL_FILES);
200 
201 const undefinedRefs: Analysis["undefinedRefs"] = [];
202 for (const [name, locs] of refSites) {
203 if (defs.has(name) || KNOWN_DYNAMIC.has(name)) continue;
204 undefinedRefs.push({ name, locs: locs.slice(0, 4) });
205 }
206 
207 const unusedDefs: Analysis["unusedDefs"] = [];
208 for (const [name, locs] of defs) {
209 if (refs.has(name) || KNOWN_DYNAMIC.has(name)) continue;
210 // Only flag defs that live INSIDE the scope (a shell token unused by this
211 // scope is not dead — it's API). And ignore the kit's internal `--_x` aliases.
212 const inScope = locs.filter((l) => scopeRel.has(l.file) && !shellRel.has(l.file));
213 if (inScope.length === 0) continue;
214 if (name.startsWith("--_")) continue;
215 unusedDefs.push({ name, locs: inScope.slice(0, 4) });
216 }
⋯ 133 lines hidden (lines 217–349)
217 
218 undefinedRefs.sort((a, b) => a.name.localeCompare(b.name));
219 unusedDefs.sort((a, b) => a.name.localeCompare(b.name));
220 return { undefinedRefs, unusedDefs };
222 
223function fmtLocs(locs: Loc[]): string {
224 return locs.map((l) => `${l.file}:${l.line}`).join(", ");
226 
227function formatForAgent(a: Analysis, files: string[], scope: string): string {
228 const counts = `${files.length} file(s) in scope`;
229 if (a.undefinedRefs.length === 0 && a.unusedDefs.length === 0) {
230 return `Scanned ${counts} under \`${scope}\` (+ the shell layer & lesson-kit as always-reachable).
231 
232Custom-property hygiene: CLEAN — every \`var(--x)\` resolves to a definition, and every \`--x:\` declared in scope is referenced.
233 
234This is the clean-scan case. Sample 2-3 files to confirm the scan didn't miss something, then output the default clean map. A bucket (1)/(2) finding requires a quoted token from your actual Read.`;
235 }
236 const undef = a.undefinedRefs.length
237 ? a.undefinedRefs.map((u) => ` - \`${u.name}\` — referenced at ${fmtLocs(u.locs)}, defined NOWHERE reachable`).join("\n")
238 : " (none)";
239 const unused = a.unusedDefs.length
240 ? a.unusedDefs.map((u) => ` - \`${u.name}\` — defined at ${fmtLocs(u.locs)}, referenced NOWHERE in scope`).join("\n")
241 : " (none)";
242 return `Scanned ${counts} under \`${scope}\` (+ the shell layer & lesson-kit as always-reachable). Two candidate fault sets — scanned by the harness, NOT yet verified:
243 
244REFERENCED-BUT-UNDEFINED (${a.undefinedRefs.length}) — a \`var(--x)\` whose \`--x\` is declared nowhere reachable:
245${undef}
246 
247DEFINED-BUT-UNUSED (${a.unusedDefs.length}) — a \`--x:\` declared in scope that no rule/inline-style reads:
248${unused}
249 
250For EACH, Read the surrounding code to confirm and classify. Cite or omit.`;
252 
253function systemPrompt(scope: string): string {
254 return `You are the token-hygiene mapper for the Glassbox repository (React 19 + Vite — self-contained lessons, each with its own \`<slug>.css\` and a bespoke palette of CSS custom properties scoped under a lesson root: .tls-root, .bc-root, .iso-root, .lsm, .mw, .hll, …; the shell layer in src/shared/{tokens,utilities,nav}.css + lesson-kit defines the shared \`--paper\`/\`--ink\`/\`--rule\`/\`--lk-*\` API). The harness has scanned \`${scope}\` (plus the always-reachable shell + kit) for custom-property DECLARATIONS (\`--x:\`) and REFERENCES (\`var(--x)\`, including inline \`style={{'--x': …}}\`), and diffed them. Your job: turn the two candidate lists into a curated map.
255 
256Your only tools are Read / Grep / Glob — you can investigate but CANNOT edit any file. The human prunes/repairs what you map.
257 
258WHY THIS MATTERS: a \`var(--typo)\` with no definition renders as nothing (or silently falls back), and dead \`--x:\` declarations are exactly the residue a token rename or a dropped widget leaves behind — invisible to knip, which only reads JS. This loop is the CSS-variable complement to the dead-code loop.
259 
260For EACH candidate, classify into EXACTLY ONE of three buckets:
261 
262(1) DEFECT — FIX. A genuine fault. Include ONLY when you can answer in one sentence each:
263 (a) WHAT — quote the token + the file:line from your Read (a \`var(--x)\` with no reachable \`--x:\`, or a \`--x:\` no one reads). Cite or omit.
264 (b) WHY — for a dangling ref: name where you looked for the definition and confirm it is absent (a rename typo, a cross-lesson reference to another lesson's token, a deleted token). For a dead def: confirm no rule, no inline style, and no other lesson/shell file reads it.
265 (c) ACTION — "define \`--x\` on \`.<root>\` (or fix the ref to \`--y\`)" / "remove the dead \`--x:\` declaration".
266 
267(2) NOT-A-FAULT — verified. State the specific reason:
268 - The token IS defined/used, just somewhere the per-file scan attributed differently (quote what you Read).
269 - A deliberate \`var(--x, fallback)\` where the fallback is the design and \`--x\` is intentionally optional (the ref is fine even with no definition).
270 - A token read across the React boundary (set/consumed via inline style props or \`getComputedStyle\` in JS) that the static scan can't pair.
271 - A \`--lk-*\` contract token a lesson sets for the kit to consume (used by lesson-kit.css), or a shell token — these are API, not dead.
272 
273(3) JUDGMENT-HEAVY (your call). A token plausibly kept on purpose (a documented future-use slot, a full palette tier where some steps are unused today but belong to the set) vs. genuinely dead. Name the trade-off.
274 
275KNOWN-CONTEXT AWARENESS:
276- \`var(--x, fallback)\` references \`--x\` AND carries a fallback — a missing \`--x\` here is often intentional, not a bug. Check the fallback.
277- The lesson-kit's internal \`--_x\` aliases and the \`--lk-*\` contract are a system; don't flag a lesson's \`--lk-accent:\` as dead (lesson-kit.css reads it).
278- A lesson that sets the same token in its base block AND its \`[data-theme='…']\` block is fine (one def "wins" per theme); that is theme-parity's domain, not a hygiene fault.
279 
280WHEN THE HARNESS REPORTS a CLEAN scan: sample 2-3 files to confirm, then output the default clean map:
281 
282> ## Defect — fix (review & act)
283> *(none — every var() resolves and every in-scope token is referenced; brief sample of N files confirmed)*
284 
285A bucket (1) finding requires a quoted token from your Read. Do not confabulate.
286 
287Output a structured map with exactly these three sections in this order:
288 
289## Defect — fix (review & act)
290 
291(per item — token · file:line · WHAT · WHY · ACTION)
292 
293## Not-a-fault — verified
294 
295(list — token · reason)
296 
297## Judgment-heavy (your call)
298 
299(list — token · the trade-off)
300 
301End with a final summary line: "<X> defect · <Y> not-a-fault · <Z> judgment". Nothing after.`;
303 
304async function main(): Promise<void> {
305 const scope = getScope();
306 console.log(`Token-hygiene loop — scope: ${scope}\n`);
307 
308 const files = gatherSources(scope);
309 if (files.length === 0) {
310 report([
311 `Scope: ${scope}`,
312 `Files in scope: 0`,
313 "",
314 "RESULT: PASS — no scannable .css/.jsx/.js files in scope. Nothing to map.",
315 ]);
316 return;
317 }
318 
319 console.log(`── Scanning ${files.length} file(s) for custom-property defs/refs ──`);
320 const a = analyze(files);
321 console.log(` Referenced-but-undefined: ${a.undefinedRefs.length} · Defined-but-unused: ${a.unusedDefs.length}\n`);
322 
323 const { agentRun, agentSummary } = await runLoop({
324 systemPrompt: systemPrompt(scope),
325 allowedTools: ALLOWED_TOOLS,
326 prompt: formatForAgent(a, files, scope),
327 // opus: separating a deliberate var(--x, fallback) / cross-boundary token from
328 // a genuine dangling ref or dead declaration is exactly the judgment worth it.
329 model: "opus",
330 });
331 
332 report([
333 `Scope: ${scope}`,
334 `Files in scope: ${files.length}`,
335 `Referenced-but-undefined: ${a.undefinedRefs.length}`,
336 `Defined-but-unused: ${a.unusedDefs.length}`,
337 `Agent run: ${agentRun}`,
338 "(read-only — the working tree was not modified)",
339 "",
340 agentSummary || "(no map produced — see streamed agent output above)",
341 "",
342 `RESULT: PASS — map above for \`${scope}\`; review the Defect section and prune/repair what you choose.`,
343 ]);
345 
346main().catch((err) => {
347 report([`RESULT: ERROR — ${err instanceof Error ? err.message : String(err)}`]);
348 process.exitCode = 1;
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
agents/contrast.ts254 lines · TypeScript
⋯ 72 lines hidden (lines 1–72)
1/**
2 * Contrast loop — Many Hands Engineering, rendered WCAG color-contrast.
3 *
4 * The legibility gate the collection lacked. `a11y-source` (the runtime axe gate)
5 * deliberately EXCLUDES color-contrast because the lessons use intentional artistic
6 * low-contrast; this loop is the one that DOES measure it — but as a curated map,
7 * not a pass/fail, so intentional decoration is kept and information text is fixed.
8 *
9 * Unlike the static mappers, contrast can only be judged on the RENDERED page (it
10 * needs the real cascade + computed colors + the data-theme switch), so the harness
11 * is self-contained like `console-runtime` (which boots vitest): it builds the app,
12 * boots `vite preview`, runs the reveal-aware axe sweep (scripts/contrast-audit.js —
13 * every lesson × light/dark, scroll-revealed so below-fold content isn't measured
14 * mid-fade), tears the server down, and hands the agent the distinct failing
15 * foreground/background pairs. The agent Reads each in context and classifies into
16 * **Fix to AA / Intentional decorative / Judgment-heavy**.
17 *
18 * Permissions: Read / Grep / Glob ONLY — structurally cannot edit a file. Visual
19 * fixes are the human's (or a workflow's) job; this only ever MAPS.
20 *
21 * Usage: tsx contrast.ts [lesson-ids…] (default: ALL lessons + the index)
22 * e.g.: tsx contrast.ts (whole site, both themes)
23 * tsx contrast.ts swim tls (just those two)
24 */
25import { spawn } from "node:child_process";
26import { execFileSync } from "node:child_process";
27import { existsSync, readFileSync, rmSync } from "node:fs";
28import { get } from "node:http";
29import { resolve } from "node:path";
30import { APP_ROOT, report, runLoop } from "./lib.js";
31 
32const ALLOWED_TOOLS = ["Read", "Grep", "Glob"];
33const PORT = 5191;
34const BASE = `http://127.0.0.1:${PORT}`;
35const AUDIT_OUT = "/tmp/glassbox-contrast-loop.json";
36 
37interface Fail {
38 selector: string;
39 fg?: string;
40 bg?: string;
41 ratio?: number;
42 required?: number;
43 fontSize?: string;
45interface PageReport {
46 lesson: string;
47 theme: string;
48 fails: Fail[];
50 
51function ids(): string[] {
52 return process.argv.slice(2).filter((a) => !a.startsWith("--"));
54 
55function waitForServer(url: string, timeoutMs: number): Promise<boolean> {
56 const start = Date.now();
57 return new Promise((resolveP) => {
58 const tick = () => {
59 const req = get(url, (res) => {
60 res.resume();
61 resolveP(true);
62 });
63 req.on("error", () => {
64 if (Date.now() - start > timeoutMs) resolveP(false);
65 else setTimeout(tick, 300);
66 });
67 };
68 tick();
69 });
71 
72/** Build, boot preview, run the axe sweep, tear down. Returns the per-page report. */
73async function runAudit(): Promise<PageReport[]> {
74 console.log("── Building app (vite build) ──");
75 execFileSync("npx", ["vite", "build"], { cwd: APP_ROOT, stdio: "inherit" });
76 
77 console.log(`── Booting vite preview on :${PORT} ──`);
78 const server = spawn("npx", ["vite", "preview", "--port", String(PORT), "--host", "127.0.0.1"], {
79 cwd: APP_ROOT,
80 stdio: "ignore",
81 });
82 try {
83 const up = await waitForServer(BASE, 30_000);
84 if (!up) throw new Error(`preview server did not come up on ${BASE} within 30s`);
85 
86 console.log("── Running reveal-aware axe color-contrast sweep (every lesson × light/dark) ──");
87 if (existsSync(AUDIT_OUT)) rmSync(AUDIT_OUT);
88 execFileSync("node", ["scripts/contrast-audit.js", BASE, AUDIT_OUT], { cwd: APP_ROOT, stdio: "inherit" });
89 return JSON.parse(readFileSync(AUDIT_OUT, "utf8")) as PageReport[];
90 } finally {
⋯ 164 lines hidden (lines 91–254)
91 server.kill("SIGTERM");
92 }
94 
95// Distinct failing foreground/background pairs per (lesson, theme), worst-first —
96// collapses the many DOM nodes that share one color pair into one finding.
97interface Finding {
98 lesson: string;
99 theme: string;
100 fg?: string;
101 bg?: string;
102 ratio?: number;
103 required?: number;
104 fontSize?: string;
105 count: number;
106 sampleSelector: string;
108 
109function distinctFindings(report: PageReport[], scopeIds: string[]): Finding[] {
110 const want = new Set(scopeIds);
111 const out: Finding[] = [];
112 for (const page of report) {
113 if (want.size && !want.has(page.lesson)) continue;
114 const seen = new Map<string, Finding>();
115 for (const f of page.fails) {
116 const key = `${f.fg}|${f.bg}`;
117 const cur = seen.get(key);
118 if (cur) {
119 cur.count++;
120 if ((f.ratio ?? 99) < (cur.ratio ?? 99)) {
121 cur.ratio = f.ratio;
122 cur.sampleSelector = f.selector;
123 }
124 } else {
125 seen.set(key, {
126 lesson: page.lesson,
127 theme: page.theme,
128 fg: f.fg,
129 bg: f.bg,
130 ratio: f.ratio,
131 required: f.required,
132 fontSize: f.fontSize,
133 count: 1,
134 sampleSelector: f.selector,
135 });
136 }
137 }
138 out.push(...seen.values());
139 }
140 out.sort((a, b) => (a.ratio ?? 99) - (b.ratio ?? 99));
141 return out;
143 
144function formatForAgent(findings: Finding[], scopeIds: string[]): string {
145 const scopeNote = scopeIds.length ? `lessons: ${scopeIds.join(", ")}` : "ALL lessons + the index";
146 if (findings.length === 0) {
147 return `Rendered axe color-contrast sweep over ${scopeNote}, both themes: NO failing text/UI pairs.
148 
149Clean scan. Confirm by sampling 2-3 lesson CSS files for any text on a tinted panel, then output the default clean map. A bucket (1) finding requires a quoted declaration from your Read.`;
150 }
151 const cap = 70;
152 const shown = findings.slice(0, cap);
153 const lines = shown
154 .map(
155 (f) =>
156 ` - ${f.lesson} · ${f.theme} · \`${f.fg}\` on \`${f.bg}\` = ${f.ratio}:1 (need ${f.required}) · ${f.fontSize ?? "?"} · ×${f.count} · e.g. \`${f.sampleSelector.replace(/\s+/g, " ").slice(0, 70)}\``,
157 )
158 .join("\n");
159 const more = findings.length > cap ? `\n…and ${findings.length - cap} more distinct pairs (lift the worst first).` : "";
160 return `Rendered axe color-contrast sweep over ${scopeNote}, both themes (reveal-aware: below-fold content was scrolled into view first, so these are TRUE rendered colors, not mid-fade). ${findings.length} DISTINCT failing fg/bg pairs — scanned by the harness, NOT yet verified:
161 
162${lines}${more}
163 
164For EACH, find that foreground color in the lesson (a CSS token/literal, an inline style={{}}, or an SVG fill/stroke — grep the hex) and decide what it IS. Classify each into ONE of the three buckets. AA: ≥4.5:1 normal text, ≥3:1 for large (≥18.66px bold or ≥24px).`;
166 
167function systemPrompt(scopeNote: string): string {
168 return `You are the contrast mapper for the Glassbox repository (React 19 + Vite — a DUAL-THEME collection: the shell sets \`data-theme="light"|"dark"\` on <html>; each lesson ships a bespoke palette + a \`[data-theme='…'] .<root>{}\` complement). The harness rendered ${scopeNote} in BOTH themes and ran axe-core's \`color-contrast\` rule against the live DOM (scroll-revealing each page first so colors are measured at full opacity, not mid-fade), then collapsed the failing DOM nodes into DISTINCT foreground/background pairs. Your job: turn that list into a curated map of which pairs are real legibility DEFECTS vs. intentional artistic low-contrast.
169 
170Your only tools are Read / Grep / Glob — you can investigate but CANNOT edit a file. The human (or a redesign workflow) fixes what you map.
171 
172WHY THIS IS A MAP, NOT A PASS/FAIL: the lessons deliberately use artistic low-contrast for DECORATION (faint eyebrows, ghost/placeholder chips, ambient watermarks, the dark originals' soft star-labels). Forcing those to AA would flatten the art. But INFORMATION a reader must read — body prose, headings, code/mono, data values, axis/node labels, TOC entries, button/UI text — must clear AA on the ground it actually sits on. The dark themes are the loved reference; lean toward preserving their intentional faintness and focus defects on the LIGHT complements and on anything genuinely unreadable (≲3:1) that carries meaning.
173 
174For EACH failing pair, classify into EXACTLY ONE of three buckets:
175 
176(1) FIX — sub-AA INFORMATION text. Include ONLY when you can answer in one sentence each:
177 (a) WHAT — the rendered pair (\`fg\` on \`bg\` = ratio) and the EXACT source color you found (the token/literal/inline value), quoted with file:line from your Read, plus what the text says/does (so it's clearly information, not decoration).
178 (b) WHY it's a defect — it conveys meaning the reader must parse, and sits below AA (≥4.5 normal / ≥3 large) on its real ground.
179 (c) ACTION — "deepen the light-block token \`--x\` (OKLCH, hue held) to ~Y" / "raise the inline alpha" / "darken the on-bar label". Name the source to change.
180 
181(2) INTENTIONAL DECORATIVE — verified, keep. State the SPECIFIC reason: a faint eyebrow/kicker, a ghost/empty/placeholder element, an ambient watermark, a deliberately-dim supporting micro-caption, or the dark reference's intentional soft labeling — ornamental, not information. Quote what you Read that shows it's decorative.
182 
183(3) JUDGMENT-HEAVY (your call). Borderline (≈3–4.5 small text), or a label that sits ON a saturated data swatch/bar where the trade-off is real, or a semantic constant meant to hold across modes. Name the trade-off.
184 
185KNOWN-CONTEXT AWARENESS:
186- A pair like \`#fff\`/\`var(--ink)\` reported as fg-on-bg may be a SWATCH label sitting on a color SAMPLE (not the page ground) — near-black/white on a sample is by design; bucket (2) or (3).
187- Text on a colored data bar/chip (e.g. LSM strata labels): pushing the LABEL to the contrast extreme (near-black on light bands, near-white on dark) is the fix; the bar's hue stays.
188- The dark originals carry intentional faint star-labels / dim captions; do not mass-flag them — reserve bucket (1) for dark only when truly unreadable information.
189 
190WHEN THE SWEEP IS CLEAN: sample 2-3 files, then output the default clean map. A bucket (1) finding requires a quoted source color from your Read — do not confabulate.
191 
192Output a structured map with exactly these three sections in this order:
193 
194## Fix — sub-AA information text (review & act)
195 
196(per item — lesson · theme · pair · WHAT (quoted source) · WHY · ACTION)
197 
198## Intentional decorative — keep (verified)
199 
200(list — lesson · pair · reason)
201 
202## Judgment-heavy (your call)
203 
204(list — lesson · pair · the trade-off)
205 
206End with a final summary line: "<X> fix · <Y> intentional · <Z> judgment". Nothing after.`;
208 
209async function main(): Promise<void> {
210 const scopeIds = ids();
211 const scopeNote = scopeIds.length ? `lessons: ${scopeIds.join(", ")}` : "ALL lessons + the index";
212 console.log(`Contrast loop — scope: ${scopeNote}\n`);
213 
214 let report_: PageReport[];
215 try {
216 report_ = await runAudit();
217 } catch (err) {
218 report([`RESULT: CANNOT RUN — ${err instanceof Error ? err.message : String(err)}`]);
219 process.exitCode = 1;
220 return;
221 }
222 
223 const findings = distinctFindings(report_, scopeIds);
224 const darkN = findings.filter((f) => f.theme === "dark").reduce((n, f) => n + f.count, 0);
225 const lightN = findings.filter((f) => f.theme === "light").reduce((n, f) => n + f.count, 0);
226 console.log(`\n── Distinct failing pairs: ${findings.length} (raw nodes: light ${lightN} / dark ${darkN}) ──\n`);
227 
228 const { agentRun, agentSummary } = await runLoop({
229 systemPrompt: systemPrompt(scopeNote),
230 allowedTools: ALLOWED_TOOLS,
231 prompt: formatForAgent(findings, scopeIds),
232 // opus: separating intentional artistic low-contrast from a real defect is the
233 // crux judgment of this loop; the strong model resists "flag everything axe flags".
234 model: "opus",
235 maxTurns: 300,
236 maxBudgetUsd: 5,
237 });
238 
239 report([
240 `Scope: ${scopeNote}`,
241 `Distinct failing pairs: ${findings.length} (raw nodes: light ${lightN} / dark ${darkN})`,
242 `Agent run: ${agentRun}`,
243 "(read-only — the working tree was not modified)",
244 "",
245 agentSummary || "(no map produced — see streamed agent output above)",
246 "",
247 `RESULT: PASS — map above for ${scopeNote}; review the Fix section and retune the information text you choose (decoration stays).`,
248 ]);
250 
251main().catch((err) => {
252 report([`RESULT: ERROR — ${err instanceof Error ? err.message : String(err)}`]);
253 process.exitCode = 1;
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
scripts/contrast-audit.js120 lines · JavaScript
⋯ 53 lines hidden (lines 1–53)
1/**
2 * Rendered color-contrast auditor — the objective gate for the dual-theme work.
3 *
4 * Loads every lesson + the index in BOTH themes and runs axe-core's
5 * `color-contrast` rule against the live DOM (the only reliable oracle, since it
6 * resolves the real cascade + computed colors). Emits a per-lesson JSON report of
7 * every failing text/background pair (selector, fg, bg, ratio, required) so the
8 * artisan pass works from data, not vibes.
9 *
10 * Usage: node scripts/contrast-audit.mjs [baseURL] [outfile]
11 * defaults: http://127.0.0.1:5180 /tmp/gb-contrast.json
12 *
13 * Note: axe color-contrast also catches INTENTIONAL decorative low-contrast
14 * (faint eyebrows, ghost captions). The report is candidates; the human/agent
15 * judges decorative-vs-defect. We rank by how far below threshold each pair is.
16 */
17import AxeBuilder from '@axe-core/playwright';
18import { chromium } from '@playwright/test';
19import { writeFileSync } from 'node:fs';
20 
21const BASE = process.argv[2] || 'http://127.0.0.1:5180';
22const OUT = process.argv[3] || '/tmp/gb-contrast.json';
23const THEMES = ['dark', 'light'];
24// Inlined (avoids importing lesson-catalog.js → React in plain node).
25const LESSON_IDS = [
26 'concurrency-foundations',
27 'acid-lab',
28 'cap-pacelc',
29 'swim',
30 'udp',
31 'bloom-filters',
32 'bloom-clock',
33 'cuckoo-filter',
34 'lsm-trees',
35 'memory',
36 'merkle-trees',
37 'sha',
38 'trie',
39 'grpc',
40 'b-trees',
41 'hyperloglog',
42 'vp-tree',
43 'tls',
44];
45const pages = [
46 { id: 'index', title: 'index', path: '/' },
47 ...LESSON_IDS.map((id) => ({ id, title: id, path: `/?lesson=${id}` })),
48];
49 
50const browser = await chromium.launch();
51const report = [];
52 
53for (const theme of THEMES) {
54 // reducedMotion: reveal-on-scroll snaps to opacity:1 instantly (no mid-fade),
55 // so once an element scrolls into view axe sees its TRUE color.
56 const ctx = await browser.newContext({
57 viewport: { width: 1280, height: 1400 },
58 deviceScaleFactor: 1,
59 reducedMotion: 'reduce',
60 });
61 await ctx.addInitScript((t) => localStorage.setItem('glassbox-theme', t), theme);
62 const page = await ctx.newPage();
63 for (const p of pages) {
64 await page.goto(`${BASE}${p.path}`, { waitUntil: 'load' });
65 await page.waitForLoadState('networkidle').catch(() => {});
66 await page.waitForTimeout(300);
67 // Scroll the whole page so every reveal-on-scroll section fires its
68 // IntersectionObserver (→ opacity:1), then return to top. Without this, axe
69 // measures below-fold content mid-reveal and reports phantom low-contrast.
70 await page.evaluate(async () => {
71 const h = document.body.scrollHeight;
72 for (let y = 0; y < h; y += Math.round(window.innerHeight * 0.8)) {
73 window.scrollTo(0, y);
74 await new Promise((r) => setTimeout(r, 70));
75 }
76 window.scrollTo(0, 0);
77 await new Promise((r) => setTimeout(r, 250));
78 });
79 const resolved = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
80 const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
⋯ 40 lines hidden (lines 81–120)
81 
82 const fails = [];
83 for (const v of results.violations) {
84 for (const node of v.nodes) {
85 const d = (node.any && node.any[0] && node.any[0].data) || {};
86 fails.push({
87 selector: Array.isArray(node.target) ? node.target.join(' ') : String(node.target),
88 fg: d.fgColor,
89 bg: d.bgColor,
90 ratio: d.contrastRatio,
91 required: d.expectedContrastRatio,
92 fontSize: d.fontSize,
93 fontWeight: d.fontWeight,
94 });
95 }
96 }
97 fails.sort((a, b) => (a.ratio ?? 99) - (b.ratio ?? 99)); // worst first
98 report.push({ lesson: p.id, title: p.title, theme, resolved, fails });
99 const worst = fails[0] ? ` worst ${fails[0].ratio}:1 (need ${fails[0].required})` : '';
100 console.log(
101 `${theme.padEnd(5)} ${p.id.padEnd(24)} ${String(fails.length).padStart(3)} fail${worst}`,
102 );
103 }
104 await ctx.close();
106await browser.close();
107 
108writeFileSync(OUT, JSON.stringify(report, null, 2));
109 
110// Per-lesson rollup across both themes
111const byLesson = {};
112for (const r of report) {
113 byLesson[r.lesson] = byLesson[r.lesson] || { dark: 0, light: 0 };
114 byLesson[r.lesson][r.theme] = r.fails.length;
116const total = report.reduce((n, r) => n + r.fails.length, 0);
117console.log('\n── per-lesson (dark / light) ──');
118for (const [id, c] of Object.entries(byLesson))
119 console.log(` ${id.padEnd(24)} ${String(c.dark).padStart(3)} / ${String(c.light).padStart(3)}`);
120console.log(`\nTOTAL contrast failures: ${total}${OUT}`);

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.