What changed, and why

The 23 Glassbox lessons shipped as islands. Their prose was full of sibling references โ€” bloom-filters' coda names HyperLogLog and cuckoo, torrents leans on SHA-256 and Merkle trees, saga rests on ACID โ€” yet not one of them was a link. This change adds the connective tissue and the loop that keeps it honest.

Two new pieces carry the weight, and the rest is them applied. **LessonLink** is the cross-lesson primitive: a real, crawlable ?lesson=<id> anchor that soft-navigates inside the SPA. **constellation** is a new Many Hands Engineering loop โ€” but the odd one out: it launches no agent. It is a deterministic scan that prints RESULT: PASS/FAIL, the way a test does, and you drive it to green by wiring links. Then ~35 links were wired across the collection, and the mentions that should stay plain were recorded with reasons.

Read the loop first (it defines the contract), then the primitive, then one real link, then the test that pins it.

constellation: a loop you run like a test

Every other loop in agents/ reasons with a model. This one only measures. It builds a vocabulary of distinctive sibling terms from the catalog, scans each lesson's prose for a term that names another lesson, and reports every one that isn't already inside a <LessonLink>. The whole thing is pure Node.

The vocabulary is curated by hand, case-sensitive, matched on alnum boundaries โ€” so "trie" ignores "tries", and short ambiguous ids lean on the ignore file rather than firing on every common word.

The term vocabulary, the masking + boundary regex, the scan, and the PASS/FAIL report.

agents/constellation.ts ยท 177 lines
agents/constellation.ts177 lines ยท TypeScript
โ‹ฏ 34 lines hidden (lines 1โ€“34)
1/**
2 * Constellation โ€” the cross-lesson link loop.
3 *
4 * A deterministic, test-suite-shaped loop (no agent, no SDK): it scans every
5 * lesson's prose for a passage that NAMES a sibling lesson by a distinctive
6 * catalog term but renders it as dead text instead of a <LessonLink>. The
7 * collection ships as 23 islands; these are its missing edges.
8 *
9 * You drive it to green: run it, wire the real pointers with
10 * <LessonLink to="<id>">โ€ฆ</LessonLink>, and record any intentionally-plain
11 * mention (an incidental or homonym use that should stay prose) in
12 * constellation-ignore.json. Re-run until it prints RESULT: PASS.
13 *
14 * Outside reference: the on-disk sibling set (parseCatalog() โˆฉ lessonDirsOnDisk()).
15 * A term only ever resolves to a link if that lesson's directory actually
16 * exists โ€” a filesystem fact the loop cannot fabricate. It will never propose a
17 * link to a topic that isn't a real lesson.
18 *
19 * Scope: sections/ and components/ prose (labs are interactive widgets, skipped).
20 * Already-wired <LessonLink> spans are masked out, so a wired mention stops
21 * firing and the candidate count drops monotonically as the constellation fills
22 * in โ€” the count is the progress meter.
23 */
24import { existsSync, readFileSync } from "node:fs";
25import { relative, resolve } from "node:path";
26import { APP_ROOT, report } from "./lib.js";
27import { gatherLessonFiles, lessonDir, lessonDirsOnDisk, parseCatalog } from "./per-lesson.js";
28 
29const IGNORE_FILE = resolve(APP_ROOT, "agents", "constellation-ignore.json");
30 
31// Distinctive terms that, appearing in ANOTHER lesson's prose, point at this
32// lesson's subject. Case-sensitive, matched on alnum boundaries. Curated to
33// dodge common-English collisions; residual false positives go in the ignore
34// file (so a generic "binary tree" aside can be kept as plain prose on record).
35const TERMS: Record<string, string[]> = {
36 "concurrency-foundations": ["Concurrency Foundations"],
37 "acid-lab": ["ACID"],
38 "cap-pacelc": ["PACELC", "CAP theorem", "CAP"],
39 swim: ["SWIM"],
40 udp: ["UDP"],
41 "bloom-filters": ["Bloom filters", "Bloom filter", "bloom filter"],
42 "bloom-clock": ["Bloom clock", "bloom clock"],
43 "cuckoo-filter": ["Cuckoo filter", "cuckoo filter", "cuckoo hashing", "cuckoo"],
44 "lsm-trees": ["LSM-tree", "LSM trees", "LSM tree", "LSM"],
45 memory: ["The Weight of Memory"],
46 "merkle-trees": ["Merkle trees", "Merkle tree", "Merkle root", "Merkle"],
47 sha: ["SHA-256", "SHA-1", "SHA-2", "SHA-3", "SHA"],
48 trie: ["Trie", "trie"],
49 grpc: ["gRPC"],
50 "b-trees": ["B-trees", "B-tree", "B+ tree", "B+ trees"],
51 hyperloglog: ["HyperLogLog"],
52 "vp-tree": ["Vantage-point tree", "vantage-point tree", "VP-tree", "VP tree"],
53 tls: ["TLS"],
54 "binary-trees": ["Binary search tree", "binary search tree", "Binary tree", "binary tree"],
55 sstables: ["SSTables", "SSTable"],
56 paxos: ["Paxos"],
57 saga: ["Saga pattern", "saga pattern", "Saga"],
58 torrents: ["BitTorrent", "bittorrent", "torrents", "torrent"],
โ‹ฏ 21 lines hidden (lines 59โ€“79)
59};
60 
61interface Candidate {
62 siblingId: string;
63 term: string;
64 file: string;
65 line: number;
67 
68function loadIgnore(): Record<string, Record<string, string>> {
69 if (!existsSync(IGNORE_FILE)) return {};
70 try {
71 const parsed: unknown = JSON.parse(readFileSync(IGNORE_FILE, "utf8"));
72 return parsed && typeof parsed === "object" ? (parsed as Record<string, Record<string, string>>) : {};
73 } catch {
74 return {};
75 }
77 
78/** Blank <LessonLink>โ€ฆ</LessonLink> spans and import lines (preserving newlines
79 * so line numbers stay accurate), so wired links and code don't fire. */
80function maskNonProse(text: string): string {
81 const blank = (m: string) => m.replace(/[^\n]/g, " ");
82 return text
83 .replace(/<LessonLink\b[\s\S]*?<\/LessonLink>/g, blank)
84 .replace(/^\s*import\b.*$/gm, blank);
86 
87function escapeRe(s: string): string {
88 return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
90 
91function termRegex(term: string): RegExp {
92 // Alnum-boundary match so "trie" ignores "tries"/"entries", while "SHA" still
93 // catches the "SHA" in "SHA-256" (the hyphen is a boundary; deduped below).
94 return new RegExp(`(?<![A-Za-z0-9])${escapeRe(term)}(?![A-Za-z0-9])`, "g");
96 
97function lineAt(text: string, index: number): number {
98 let line = 1;
99 for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) line++;
100 return line;
โ‹ฏ 1 line hidden (lines 102โ€“102)
102 
103function main(): void {
104 const catalog = parseCatalog();
105 const onDisk = new Set(lessonDirsOnDisk());
106 const lessons = catalog.filter((l) => l.id !== "index" && onDisk.has(l.id));
107 const ignore = loadIgnore();
108 
109 const byLesson: { id: string; title: string; hits: Candidate[] }[] = [];
110 let total = 0;
111 
112 for (const lesson of lessons) {
113 const files = gatherLessonFiles(lessonDir(lesson.id)).filter(
114 (f) => f.endsWith(".jsx") && !f.includes("/labs/"),
115 );
116 const ignored = ignore[lesson.id] ?? {};
117 const seen = new Set<string>(); // file:line:siblingId โ€” dedupe overlapping terms
118 const hits: Candidate[] = [];
119 
120 for (const file of files) {
121 const masked = maskNonProse(readFileSync(file, "utf8"));
122 for (const [siblingId, terms] of Object.entries(TERMS)) {
123 if (siblingId === lesson.id || !onDisk.has(siblingId)) continue;
124 for (const term of terms) {
125 if (Object.prototype.hasOwnProperty.call(ignored, term)) continue;
126 const re = termRegex(term);
127 let m: RegExpExecArray | null;
128 while ((m = re.exec(masked)) !== null) {
129 const line = lineAt(masked, m.index);
130 const key = `${file}:${line}:${siblingId}`;
131 if (seen.has(key)) continue;
132 seen.add(key);
133 hits.push({ siblingId, term, file: relative(APP_ROOT, file), line });
134 }
135 }
136 }
137 }
138 
139 hits.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1));
140 if (hits.length) {
141 byLesson.push({ id: lesson.id, title: lesson.title, hits });
142 total += hits.length;
143 }
144 }
โ‹ฏ 1 line hidden (lines 145โ€“145)
145 
146 if (total === 0) {
147 report([
148 "Constellation โ€” cross-lesson link map",
149 "(read-only โ€” every named sibling lesson in prose is wired or recorded as intentionally plain)",
150 "",
151 "RESULT: PASS โ€” no unlinked cross-lesson mentions.",
152 ]);
153 return;
154 }
155 
156 const blocks: string[] = [];
157 for (const l of byLesson) {
158 blocks.push("", "โ”€".repeat(64), `## ${l.id} โ€” ${l.title}`, "");
159 for (const h of l.hits) {
160 blocks.push(` - ${h.file}:${h.line} ยท "${h.term}" โ†’ <LessonLink to="${h.siblingId}">`);
161 }
162 }
163 
164 report([
165 "Constellation โ€” cross-lesson link map",
166 `Unlinked sibling mentions: ${total} across ${byLesson.length} lesson(s)`,
167 'Wire each deliberate pointer with <LessonLink to="<id>">โ€ฆ</LessonLink>,',
168 "or record an intentionally-plain mention in agents/constellation-ignore.json",
169 'as { "<lesson-id>": { "<term>": "why it stays prose" } }.',
170 ...blocks,
171 "",
172 `RESULT: FAIL โ€” ${total} unlinked cross-lesson mention(s). Wire or ignore each, then re-run.`,
173 ]);
174 process.exitCode = 1;
176 
โ‹ฏ 1 line hidden (lines 177โ€“177)
177main();

The intentionally-plain record

Not every sibling mention should be a link. Repeats clutter, homonyms mislead, and some prose can't host a link at all. Those decisions live in a committed JSON file, keyed by lesson then term, each with a reason โ€” the persistent, auditable version of "keep this plain". Drive the loop to green by either wiring a link or adding an entry here.

The honest cases: a dangerouslySetInnerHTML block, a flood-not-BitTorrent homonym, the SHA construction.

agents/constellation-ignore.json ยท 75 lines
agents/constellation-ignore.json75 lines ยท JSON
1{
2 "acid-lab": {
3 "CAP": "genuine CAP-theorem reference but rendered via dangerouslySetInnerHTML(renderProseMarkdown) โ€” a JSX link can't be embedded; kept plain",
4 "Paxos": "rendered via dangerouslySetInnerHTML(renderProseMarkdown) โ€” can't host a JSX link; kept plain",
5 "Saga": "rendered via dangerouslySetInnerHTML(renderProseMarkdown) โ€” can't host a JSX link; kept plain"
6 },
โ‹ฏ 24 lines hidden (lines 7โ€“30)
7 "b-trees": {
8 "LSM": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
9 "LSM-tree": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
10 "binary search tree": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
11 "binary tree": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
12 },
13 "bloom-clock": {
14 "Bloom filter": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
15 "Bloom filters": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
16 },
17 "bloom-filters": {
18 "HyperLogLog": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
19 "LSM": "kept plain (constellation): repeat, incidental, or short-alias of an already-handled mention โ€” not a deliberate navigational pointer",
20 "LSM tree": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
21 "LSM trees": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
22 "SSTable": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
23 "SSTables": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
24 "cuckoo": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
25 },
26 "cap-pacelc": {
27 "Paxos": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
28 },
29 "cuckoo-filter": {
30 "Bloom filters": "string-prose whose title doubles as the React key โ€” JSX-node conversion would break keying; kept plain"
31 },
32 "hyperloglog": {
33 "torrent": "homonym โ€” \"a torrent of billions\" means a flood, not the BitTorrent (torrents) lesson"
โ‹ฏ 16 lines hidden (lines 34โ€“49)
34 },
35 "lsm-trees": {
36 "B-tree": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
37 "B-trees": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
38 "Bloom filter": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
39 "SSTable": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
40 "SSTables": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
41 },
42 "merkle-trees": {
43 "SHA": "kept plain (constellation): repeat, incidental, or short-alias of an already-handled mention โ€” not a deliberate navigational pointer",
44 "SHA-1": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
45 "SHA-256": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
46 "Trie": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
47 "binary tree": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
48 },
49 "saga": {
50 "ACID": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
51 },
52 "sha": {
โ‹ฏ 23 lines hidden (lines 53โ€“75)
53 "Merkle": "mostly \"Merkleโ€“Damgรฅrd\" (the SHA construction, a homonym); the one genuine Merkle reference is wired at Variants.jsx โ€” others kept plain"
54 },
55 "sstables": {
56 "B-tree": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
57 "LSM": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
58 "bloom filter": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
59 },
60 "swim": {
61 "Merkle": "kept plain (constellation): repeat, incidental, or short-alias of an already-handled mention โ€” not a deliberate navigational pointer",
62 "Merkle trees": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
63 "Paxos": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
64 },
65 "torrents": {
66 "Merkle": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
67 "Merkle tree": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
68 "SHA": "kept plain (constellation): repeat, incidental, or short-alias of an already-handled mention โ€” not a deliberate navigational pointer",
69 "SHA-1": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer",
70 "SHA-256": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
71 },
72 "udp": {
73 "TLS": "kept plain (constellation): repeat, incidental, or unlinkable string-prose mention โ€” not a deliberate navigational pointer"
74 }

Wiring it into the shell

App.jsx already owned the navigation: selectPage(id) sets state and pushes the ?lesson= URL. The change provides that function through NavContext so a LessonLink deep inside a section can reach it without prop-drilling, and exposes the active lesson's accent as --lesson-link-color on <main>.

Provider around the active page; the accent var on <main>.

src/App.jsx ยท 111 lines
src/App.jsx111 lines ยท JSX
โ‹ฏ 68 lines hidden (lines 1โ€“68)
1import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
2import {
3 defaultPageId,
4 getLessonById,
5 getPageIdFromUrl,
6 indexPage,
7 pages,
8} from './lesson-catalog.js';
9import Nav from './shared/Nav.jsx';
10import { NavContext } from './shared/NavContext.js';
11import './shared/tokens.css';
12import './shared/utilities.css';
13 
14// Lazy-load the landing index (and its Glyph) so it ships in its own chunk
15// rather than the shared entry bundle, which the index page would otherwise
16// dominate. It renders inside the same <Suspense> as the lazy lessons.
17const IndexPage = lazy(() => import('./index-page/IndexPage.jsx'));
18 
19export default function App() {
20 const [activePageId, setActivePageId] = useState(getPageIdFromUrl);
21 const activePage = useMemo(
22 () => pages.find((page) => page.id === activePageId) || indexPage,
23 [activePageId],
24 );
25 const ActivePage = activePage.id === indexPage.id ? IndexPage : activePage.Component;
26 const mainRef = useRef(null);
27 const isFirstRender = useRef(true);
28 
29 useEffect(() => {
30 document.title = activePage.title;
31 }, [activePage.title]);
32 
33 // After a user-initiated lesson change, move focus to the new content
34 // region so screen-reader and keyboard users land on the right place.
35 // Skip the very first mount to avoid stealing focus on page load.
36 useEffect(() => {
37 if (isFirstRender.current) {
38 isFirstRender.current = false;
39 return;
40 }
41 if (mainRef.current) {
42 mainRef.current.focus({ preventScroll: true });
43 }
44 }, [activePageId]);
45 
46 const selectPage = (pageId) => {
47 setActivePageId(pageId);
48 
49 const url = new URL(window.location.href);
50 url.hash = '';
51 if (pageId === defaultPageId) {
52 url.searchParams.delete('lesson');
53 } else {
54 url.searchParams.set('lesson', pageId);
55 }
56 
57 window.history.pushState(null, '', `${url.pathname}${url.search}`);
58 window.scrollTo({ top: 0, behavior: 'smooth' });
59 };
60 
61 // Honor browser back/forward โ€” single source of truth is the URL.
62 useEffect(() => {
63 const onPop = () => setActivePageId(getPageIdFromUrl());
64 window.addEventListener('popstate', onPop);
65 return () => window.removeEventListener('popstate', onPop);
66 }, []);
67 
68 const activeAccent = getLessonById(activePageId)?.accent;
69 
70 return (
71 <div className="lesson-shell" style={{ minHeight: '100vh', background: 'var(--paper)' }}>
72 <Nav activePage={activePage} onSelect={selectPage} />
73 <main
74 ref={mainRef}
75 tabIndex={-1}
76 aria-live="polite"
77 aria-atomic="false"
78 aria-label={activePage.title}
79 style={{ outline: 'none', '--lesson-link-color': activeAccent || undefined }}
80 >
81 <NavContext.Provider value={selectPage}>
82 <Suspense fallback={<LessonLoading accent={activeAccent} />}>
83 <ActivePage onSelectLesson={selectPage} />
84 </Suspense>
85 </NavContext.Provider>
86 </main>
โ‹ฏ 25 lines hidden (lines 87โ€“111)
87 </div>
88 );
90 
91function LessonLoading({ accent }) {
92 return (
93 <div
94 role="status"
95 aria-live="polite"
96 style={{
97 minHeight: '60vh',
98 display: 'flex',
99 alignItems: 'center',
100 justifyContent: 'center',
101 fontFamily: 'var(--font-mono)',
102 fontSize: 11,
103 letterSpacing: '0.24em',
104 textTransform: 'uppercase',
105 color: accent || 'rgba(232, 222, 200, 0.5)',
106 }}
107 >
108 Loading lessonโ€ฆ
109 </div>
110 );

Ink text, accent-tinted underline, a visible focus ring.

src/shared/utilities.css ยท 243 lines
src/shared/utilities.css243 lines ยท CSS
โ‹ฏ 185 lines hidden (lines 1โ€“185)
1/* Shared utility classes scoped to .lesson-shell.
2 Extracted from src/App.jsx so it can be cached, linted, and edited as CSS. */
3 
4html,
5body,
6#root {
7 margin: 0;
8 min-height: 100%;
9 background: var(--paper);
11body {
12 overflow-x: hidden;
14 
15.lesson-shell .relative {
16 position: relative;
18.lesson-shell .absolute {
19 position: absolute;
21.lesson-shell .fixed {
22 position: fixed;
24.lesson-shell .inset-0 {
25 inset: 0;
27.lesson-shell .z-10 {
28 z-index: 10;
30.lesson-shell .z-20 {
31 z-index: 20;
33.lesson-shell .pointer-events-none {
34 pointer-events: none;
36.lesson-shell .overflow-hidden {
37 overflow: hidden;
39.lesson-shell .overflow-x-auto {
40 overflow-x: auto;
42.lesson-shell .hidden {
43 display: none;
45.lesson-shell .block {
46 display: block;
48.lesson-shell .grid {
49 display: grid;
51.lesson-shell .flex {
52 display: flex;
54.lesson-shell .flex-wrap {
55 flex-wrap: wrap;
57.lesson-shell .flex-1 {
58 flex: 1 1 0%;
60.lesson-shell .items-center {
61 align-items: center;
63.lesson-shell .items-baseline {
64 align-items: baseline;
66.lesson-shell .items-stretch {
67 align-items: stretch;
69.lesson-shell .justify-between {
70 justify-content: space-between;
72.lesson-shell .text-center {
73 text-align: center;
75.lesson-shell .mx-auto {
76 margin-left: auto;
77 margin-right: auto;
79.lesson-shell .ml-auto {
80 margin-left: auto;
82.lesson-shell .max-w-3xl {
83 max-width: 48rem;
85.lesson-shell .max-w-4xl {
86 max-width: 56rem;
88.lesson-shell .max-w-6xl {
89 max-width: 72rem;
91.lesson-shell .px-2 {
92 padding-left: 0.5rem;
93 padding-right: 0.5rem;
95.lesson-shell .px-6 {
96 padding-left: 1.5rem;
97 padding-right: 1.5rem;
99.lesson-shell .py-20 {
100 padding-top: 5rem;
101 padding-bottom: 5rem;
103.lesson-shell .pb-2 {
104 padding-bottom: 0.5rem;
106.lesson-shell .mt-0\.5 {
107 margin-top: 0.125rem;
109.lesson-shell .mt-1 {
110 margin-top: 0.25rem;
112.lesson-shell .mt-2 {
113 margin-top: 0.5rem;
115.lesson-shell .mt-3 {
116 margin-top: 0.75rem;
118.lesson-shell .mt-4 {
119 margin-top: 1rem;
121.lesson-shell .mt-8 {
122 margin-top: 2rem;
124.lesson-shell .mt-10 {
125 margin-top: 2.5rem;
127.lesson-shell .mt-12 {
128 margin-top: 3rem;
130.lesson-shell .mt-16 {
131 margin-top: 4rem;
133.lesson-shell .mt-20 {
134 margin-top: 5rem;
136.lesson-shell .mb-1 {
137 margin-bottom: 0.25rem;
139.lesson-shell .mb-2 {
140 margin-bottom: 0.5rem;
142.lesson-shell .mb-3 {
143 margin-bottom: 0.75rem;
145.lesson-shell .mb-4 {
146 margin-bottom: 1rem;
148.lesson-shell .mb-5 {
149 margin-bottom: 1.25rem;
151.lesson-shell .mb-6 {
152 margin-bottom: 1.5rem;
154.lesson-shell .my-4 {
155 margin-top: 1rem;
156 margin-bottom: 1rem;
158.lesson-shell .gap-1 {
159 gap: 0.25rem;
161.lesson-shell .gap-1\.5 {
162 gap: 0.375rem;
164.lesson-shell .gap-2 {
165 gap: 0.5rem;
167.lesson-shell .gap-3 {
168 gap: 0.75rem;
170.lesson-shell .gap-4 {
171 gap: 1rem;
173.lesson-shell .gap-6 {
174 gap: 1.5rem;
176.lesson-shell .gap-8 {
177 gap: 2rem;
179.lesson-shell .space-y-1\.5 > * + * {
180 margin-top: 0.375rem;
182.lesson-shell [id] {
183 scroll-margin-top: 64px;
185 
186/* Cross-lesson links (<LessonLink>). Text stays in the surrounding ink so it
187 always clears AA contrast in both themes; the active lesson's accent (set on
188 <main> as --lesson-link-color) only tints the underline, so the link never
189 relies on color alone. Hover/focus lift the whole word to the accent. */
190.lesson-shell .lesson-link {
191 color: inherit;
192 text-decoration: underline;
193 text-decoration-color: var(--lesson-link-color, currentColor);
194 text-decoration-thickness: 0.07em;
195 text-underline-offset: 0.16em;
196 transition:
197 color 0.15s ease,
198 text-decoration-color 0.15s ease;
200.lesson-shell .lesson-link:hover,
201.lesson-shell .lesson-link:focus-visible {
202 color: var(--lesson-link-color, currentColor);
204.lesson-shell .lesson-link:focus-visible {
205 outline: 2px solid var(--lesson-link-color, currentColor);
206 outline-offset: 2px;
207 border-radius: 2px;
209 
210@media (min-width: 768px) {
211 .lesson-shell .md\:px-12 {
212 padding-left: 3rem;
โ‹ฏ 31 lines hidden (lines 213โ€“243)
213 padding-right: 3rem;
214 }
215 .lesson-shell .md\:py-24 {
216 padding-top: 6rem;
217 padding-bottom: 6rem;
218 }
219 .lesson-shell .md\:grid-cols-2 {
220 grid-template-columns: repeat(2, minmax(0, 1fr));
221 }
222 .lesson-shell .md\:grid-cols-3 {
223 grid-template-columns: repeat(3, minmax(0, 1fr));
224 }
226 
227@media (min-width: 1280px) {
228 .lesson-shell .xl\:block {
229 display: block;
230 }
232 
233/* Respect users who request less motion across the shell. */
234@media (prefers-reduced-motion: reduce) {
235 *,
236 *::before,
237 *::after {
238 animation-duration: 0.001ms !important;
239 animation-iteration-count: 1 !important;
240 transition-duration: 0.001ms !important;
241 scroll-behavior: auto !important;
242 }

Pinning it, and the docs

A jsdom spec pins the three behaviors a reviewer would worry about: the anchor's href and default text, the soft-navigation through context, and the fail-soft on an unknown id. The loop's own contract is documented where the other loops are โ€” agents/README.md and AGENTS.md โ€” flagged as the deterministic, no-agent member you drive to green.

tests/lesson-link.test.jsx ยท 34 lines
tests/lesson-link.test.jsx34 lines ยท JSX