@bongos/core 1.19.449 → 1.19.451

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,14 +78,21 @@ function removeBreadcrumb(fileOrId) {
78
78
  }
79
79
 
80
80
  // PURE decision — exported so the truth table is unit-tested with no fs/network.
81
- // 'emit' → the task has landed (shipped): render the shipped card, then clear.
82
- // 'drop' → give up watching: stale, OR it left the land path (grade-failed back to
83
- // completed, released, …) so the confirmed→shipped transition won't happen.
81
+ // 'emit' → the task landed inside the watch window: render the shipped card, then clear.
82
+ // 'drop' → give up watching: past the window, OR it left the land path (grade-failed back
83
+ // to completed, released, …) so the confirmed→shipped transition won't happen.
84
84
  // 'keep' → still landing (confirmed), or a transient read failure — retry next turn.
85
- // emit wins over stale so a just-landed breadcrumb still shows even if old.
85
+ //
86
+ // The window wins over EVERY status, 'shipped' included (task 1003466). It used not to:
87
+ // 'emit' was checked first, so a breadcrumb for a task that landed days ago still rendered a
88
+ // "Shipped" banner. That went unnoticed until a dead publish-reconciler (task 1002419) let a
89
+ // backlog build — eleven tasks flipped to shipped at once and the builder paid a prompt-turn
90
+ // of stale news for each. A land the builder has already moved past is not news; it is spam.
91
+ // An unknown/unparseable age is treated as past the window for the same reason: we cannot
92
+ // prove the crumb is fresh, and the safe direction is always silence.
86
93
  function classifyLand(task, ageMs, { staleMs = DEFAULT_STALE_MS } = {}) {
94
+ if (!Number.isFinite(ageMs) || ageMs > staleMs) return 'drop';
87
95
  if (task && task.status === 'shipped') return 'emit';
88
- if (typeof ageMs === 'number' && ageMs > staleMs) return 'drop';
89
96
  if (!task || typeof task.status !== 'string') return 'keep'; // couldn't read → retry
90
97
  if (task.status === 'confirmed') return 'keep'; // still landing
91
98
  return 'drop'; // no longer on the land path
@@ -126,21 +133,33 @@ function buildShippedEnvelope(crumb, task) {
126
133
 
127
134
  // Walk the breadcrumbs; emit AT MOST ONE shipped card per run (avoid flooding a turn),
128
135
  // dropping the give-ups. Returns the envelope to inject, or null. Deps injected for tests.
136
+ //
137
+ // The backlog drains in THIS pass (task 1003466): the newest land gets the card and every
138
+ // other landed crumb is folded into one "also landed" line and cleared. Holding the extras
139
+ // for later turns is what turned a backlog into a card per prompt — the builder had to ask
140
+ // why the session kept showing cards instead of working.
129
141
  async function watch({ nowMs = Date.now(), staleMs = DEFAULT_STALE_MS, getTask = fetchTask, readCrumbs = readBreadcrumbs, removeCrumb = removeBreadcrumb } = {}) {
130
142
  const crumbs = readCrumbs().sort((a, b) => String(b.at).localeCompare(String(a.at))); // newest first
131
143
  let envelope = null;
144
+ const alsoLanded = [];
132
145
  for (const crumb of crumbs) {
133
- const ageMs = nowMs - Date.parse(crumb.at || '') || 0;
146
+ const at = Date.parse(crumb.at || '');
147
+ const ageMs = Number.isFinite(at) ? nowMs - at : Infinity; // unparseable → past the window
134
148
  const task = await getTask(crumb.taskId);
135
- const verdict = classifyLand(task, Number.isFinite(ageMs) ? ageMs : 0, { staleMs });
149
+ const verdict = classifyLand(task, ageMs, { staleMs });
136
150
  if (verdict === 'emit') {
137
- if (!envelope) { envelope = buildShippedEnvelope(crumb, task); removeCrumb(crumb._file); }
138
- // a 2nd shipped crumb waits for the next turn (one card at a time)
151
+ if (!envelope) envelope = buildShippedEnvelope(crumb, task);
152
+ else alsoLanded.push(crumb.taskId);
153
+ removeCrumb(crumb._file);
139
154
  } else if (verdict === 'drop') {
140
155
  removeCrumb(crumb._file);
141
156
  }
142
157
  // 'keep' → leave it
143
158
  }
159
+ // Written as `task N`, never `#N` — a bare #NNN autolinks to a GitHub issue and 404s.
160
+ if (envelope && alsoLanded.length) {
161
+ envelope.warnings = [`Also landed: ${alsoLanded.map((id) => `task ${id}`).join(', ')}.`];
162
+ }
144
163
  return envelope;
145
164
  }
146
165
 
@@ -232,6 +232,35 @@ app.get(/^\/([a-z-]+)$/, (req, res, next) => {
232
232
  if (!sendPage(res, `${req.params[0]}.html`)) next();
233
233
  });
234
234
 
235
+ // What the READING ROOMS read (task 1003306): the two long-form documents and
236
+ // the generated diagrams. All three live outside modules/hall-ui/public and are
237
+ // served by serve-internal.js from docs/onboarding/ — but the hall's page gate
238
+ // sits in front of them, so in the harness every one of those fetches 302'd to
239
+ // sign-in and the primer could only ever be previewed in its FAILURE state and
240
+ // the diagrams index with five broken images. Same gap task 1003305 closed for
241
+ // the record pages' own routes. Served here, ahead of the gate, from the same
242
+ // canonical files prod reads.
243
+ // NEVER hand-edit a diagram: the .svg/.png pair and assertions.json are written
244
+ // in lockstep by scripts/gds/gen-diagrams.js.
245
+ // Both prefixes, and that is not belt-and-braces. The diagrams page references
246
+ // its figures RELATIVELY (src="diagrams/01-….png"), which prod resolves under
247
+ // /builders/diagrams because the page is served at /builders/diagrams; the
248
+ // harness serves its pages at the root, so the same markup resolves at
249
+ // /diagrams. Mount one and the populated page renders five broken images.
250
+ for (const prefix of ['/builders/diagrams', '/diagrams']) {
251
+ app.use(prefix, express.static(path.join(ROOT, 'docs/onboarding/diagrams')));
252
+ }
253
+ for (const [route, file] of [
254
+ ['/builders/primer.md', 'docs/onboarding/primer.md'],
255
+ ['/builders/drachmae-and-karma.md', 'docs/onboarding/drachmae-and-karma.md'],
256
+ ]) {
257
+ app.get(route, (req, res, next) => {
258
+ const abs = path.join(ROOT, file);
259
+ if (!fs.existsSync(abs)) return next();
260
+ res.type('text/markdown; charset=utf-8').send(fs.readFileSync(abs, 'utf8'));
261
+ });
262
+ }
263
+
235
264
  // Everything else — /builders/*.css|js assets, /lib/bongos-client.js, diagram
236
265
  // PNGs — falls through to the real internal-surface mounting. Our handlers
237
266
  // above registered first, so the hall's auth gates in there never fire for
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.449'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.451'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -35,10 +35,11 @@ const INLINE_STYLE_BUDGET = {
35
35
  // task.html now carries none and passes on the default.)
36
36
  // (my-projects + scouting were carried here as standalone surfaces until task
37
37
  // 1002868 adopted them into the shell; their exceptions are gone with them.)
38
- // Genuinely page-local, and already token-derived. One line over the default;
39
- // a whole stylesheet for the legend strip is not worth the extra request, and
40
- // diagram-viewer.css is shared with primer.html so it is the wrong home.
41
- 'diagrams.html': 16,
38
+ // (diagrams.html's 16-line legend + figure block was the hall's last inline
39
+ // style. Task 1003306 promoted it: the figure frame became the reading-room
40
+ // archetype's, shared with the primer, and the legend got diagrams.css — so
41
+ // the "not worth an extra request" reasoning stopped holding the moment the
42
+ // page needed a sheet anyway. No page carries an exception now.)
42
43
  };
43
44
  const DEFAULT_INLINE_BUDGET = 15;
44
45
 
@@ -150,18 +151,21 @@ test('the phone-width overflow guards are all still in place', () => {
150
151
  // top level, so finding one means the nearest @media is not the enclosing
151
152
  // one and the rule is really live everywhere.
152
153
  assert.ok(!/\n\}/.test(between), `${re} sits AFTER its nearest @media closed — it is live at every width`);
153
- // Pin the EXACT breakpoint, not a band: style.css guards at 640, primer.css
154
- // at 760 (its only breakpoint, the one primer.js's matchMedia mirrors).
155
- // Narrowing primer's to 640 "for consistency" would silently reopen the
156
- // 641–760px band the rule exists to cover, so a tolerant regex would hide
157
- // the very drift this test is here to catch.
154
+ // Pin the EXACT breakpoint, not a band: style.css guards at 640, the
155
+ // reading room at 760 (its only breakpoint, the one primer.js's matchMedia
156
+ // mirrors). Narrowing the room's to 640 "for consistency" would silently
157
+ // reopen the 641–760px band the rule exists to cover, so a tolerant regex
158
+ // would hide the very drift this test is here to catch.
159
+ // The room's rule moved sheets in task 1003306 — primer.css → room.css,
160
+ // when the archetype was factored out for the diagrams index to share. The
161
+ // ASSERTION is unchanged; only the file it reads moved with the rule.
158
162
  assert.match(between, new RegExp(`max-width:\\s*${breakpoint}px`),
159
163
  `${re} must sit in the max-width: ${breakpoint}px media query`);
160
164
  };
161
165
  scoped(css, /:has\(>\s*\.ledger\)\s*\{[^}]*overflow-x:\s*auto/, 640);
162
166
  scoped(css, /\.ledger th\s*\{[^}]*position:\s*static/, 640);
163
167
  scoped(css, /\.rank-tip\s*\{\s*display:\s*none/, 640);
164
- scoped(read('primer.css'), /\.primer-article table\s*\{[^}]*overflow-x:\s*auto/, 760);
168
+ scoped(read('room.css'), /\.primer-article table\s*\{[^}]*overflow-x:\s*auto/, 760);
165
169
  });
166
170
 
167
171
  // The strip has to hold BOTH invariants, and the second one is the easy one to
@@ -0,0 +1,430 @@
1
+ // tests/hall_reading_world.mjs — the READING ROOMS in the chrome world: the
2
+ // primer, the diagrams index, the roadmap deck and the ranks ladder
3
+ // (task 1003306, goal 1000081).
4
+ //
5
+ // tests/hall_tokens.mjs pins the TIER these pages spend, hall_board_world.mjs
6
+ // pins how the board spends it and hall_record_world.mjs the record family;
7
+ // this is the same job for the reading rooms. Everything here is a fact that is
8
+ // SILENT in a browser: the page still renders, it just renders wrong, or it
9
+ // renders right here and fails a floor on the reviewer's machine.
10
+ //
11
+ // What it locks:
12
+ // * THE PAGE-LOCAL `:root` STAYS GONE. roadmap.css declared one — the only
13
+ // one in the hall, and forbidden both ways: the branding pack's :root is
14
+ // spliced at the END of <head> at (0,1,0), so a page-local one is outranked
15
+ // one way and defeats re-branding the other. Page tokens live on `body`.
16
+ // * ONE MARKDOWN PARSER. primer.js carried its own copy of md-reader.js's
17
+ // grammar minus blockquotes, so every `> aside` in the primer silently
18
+ // collapsed into a paragraph. The copy is gone; the pin is that it stays
19
+ // gone AND that the room has a blockquote rule to render what now arrives.
20
+ // * THE SOFT GATE IS aria-disabled. A real `disabled` button leaves the tab
21
+ // order, so a keyboard reader can never land on the acknowledge control and
22
+ // never hears the note saying why it is locked.
23
+ // * THE `ch` TRAP. primer.css capped the article at `72ch` under a comment
24
+ // claiming 60-72 characters; a `ch` is the font's ZERO glyph and Manrope's
25
+ // is wide, so it rendered about 90. The measure is --measure, and it is
26
+ // spent on PROSE only — on a display heading it is the inverse mistake
27
+ // hall_record_world §8 pins.
28
+ // * THE DRIFT FALLBACKS. diagram-viewer.css carried eleven
29
+ // `var(--x, LITERAL)` pairs whose tokens all exist, so the literal never
30
+ // rendered and only encoded drift (its --ink fallback said #2a2a2a where
31
+ // the sheet ships #17181a). Plus two raw shadows and a hand-written
32
+ // z-index: 9999.
33
+ // * THE SCROLL LOCK IS ON THE ROOT. The diagram's fullscreen locked `body`
34
+ // where the shell's own drawer locks `html`. Two overlays locking one page
35
+ // on two different elements is how one of them stops working.
36
+ // * THE ONE h1, ON EVERY ROOM. All four pages had none — the finding
37
+ // ranks.states.json had been carrying in the open since task 1003320.
38
+ // * THE COMPAT ALIASES. Each re-skinned sheet's removal list. The aliases
39
+ // still EXIST for the pages nobody has re-skinned, so spending one here is
40
+ // legal CSS that quietly opts the sheet back out of the world.
41
+ //
42
+ // Reproduce the renders these came from. The floors run against the kit's own
43
+ // stub; the POPULATED document needs the fixture harness, because neither the
44
+ // primer markdown nor the generated diagrams are the kit stub's to serve:
45
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/primer.states.json
46
+ // node scripts/hall-preview/server.js --port 4612 --fixture-me
47
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/primer.states.json \
48
+ // --base-in http://builders.localhost:4612 --base-out http://builders.localhost:4612
49
+ // (likewise diagrams.states.json, roadmap.states.json, ranks.states.json)
50
+ //
51
+ // §10 leaves static analysis behind and RUNS md-reader.js in a vm: the parser
52
+ // unification is a behaviour claim, and "primer.js no longer contains a
53
+ // parser" only proves half of it. The other half is that the parser it now
54
+ // calls emits what the primer's markdown needs.
55
+
56
+ import { test } from 'node:test';
57
+ import assert from 'node:assert/strict';
58
+ import fs from 'node:fs';
59
+ import path from 'node:path';
60
+ import vm from 'node:vm';
61
+ import { fileURLToPath } from 'node:url';
62
+
63
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
64
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
65
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
66
+
67
+ const styleCss = read('style.css');
68
+ const roomCss = read('room.css');
69
+ const primerCss = read('primer.css');
70
+ const diagramsCss = read('diagrams.css');
71
+ const roadmapCss = read('roadmap.css');
72
+ const viewerCss = read('diagram-viewer.css');
73
+ const primerJs = read('primer.js');
74
+ const viewerJs = read('diagram-viewer.js');
75
+ const mdReaderJs = read('md-reader.js');
76
+ const primerHtml = read('primer.html');
77
+ const diagramsHtml = read('diagrams.html');
78
+ const roadmapHtml = read('roadmap.html');
79
+ const ranksHtml = read('ranks.html');
80
+
81
+ const decomment = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
82
+ // The body of the first rule matching `selector {`, comments stripped. Naive on
83
+ // purpose: these are flat declaration blocks, not nested at-rules. Takes a PLAIN
84
+ // selector and escapes it here — pass a pre-escaped one and it is escaped twice.
85
+ const rule = (css, selector) => {
86
+ const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ const m = decomment(css).match(new RegExp(`(^|[},])\\s*${esc}\\s*\\{([^}]*)\\}`, 'm'));
88
+ return m ? m[2] : null;
89
+ };
90
+
91
+ // The sheets this task re-skinned. A sheet joins this list when its page family
92
+ // lands, and never leaves it.
93
+ const RESKINNED = [
94
+ ['room.css', roomCss],
95
+ ['primer.css', primerCss],
96
+ ['diagrams.css', diagramsCss],
97
+ ['roadmap.css', roadmapCss],
98
+ ['diagram-viewer.css', viewerCss],
99
+ ];
100
+
101
+ // ---- 1. no page declares its own :root -------------------------------------
102
+
103
+ test('no hall page sheet declares a :root of its own', () => {
104
+ const offenders = [];
105
+ for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.css') && n !== 'style.css')) {
106
+ if (/(^|[},])\s*:root\s*\{/.test(decomment(read(f)))) offenders.push(f);
107
+ }
108
+ assert.deepEqual(offenders, [],
109
+ 'the pack :root is spliced at the end of <head> — a page-local one is outranked one way and defeats re-branding the other. Put page tokens on body.');
110
+ });
111
+
112
+ test('roadmap.css puts its three helpers on the body, scoped to the page', () => {
113
+ const body = rule(roadmapCss, 'body[data-page="roadmap"]');
114
+ assert.ok(body, 'roadmap.css declares its tokens on body[data-page="roadmap"]');
115
+ for (const t of ['--rm-plan', '--rm-terra-weak']) {
116
+ assert.match(body, new RegExp(`${t}\\s*:`), `${t} survived the move to body`);
117
+ }
118
+ assert.doesNotMatch(decomment(roadmapCss), /--rm-shadow-soft/,
119
+ 'v3 separates by TONE — a panel is never a drawn box and never a shadow');
120
+ assert.doesNotMatch(decomment(roadmapCss), /box-shadow\s*:\s*(?!inset)[^;]*(?:rgba|0 )/,
121
+ 'no raw shadow came back with it');
122
+ });
123
+
124
+ test('the :root guard CATCHES a reintroduction and tolerates a lookalike', () => {
125
+ assert.match(decomment(':root { --x: 1px; }'), /(^|[},])\s*:root\s*\{/);
126
+ assert.doesNotMatch('html:root-ish { }', /(^|[},])\s*:root\s*\{/);
127
+ });
128
+
129
+ // ---- 2. the compat aliases are retired from the re-skinned sheets ----------
130
+
131
+ const ALIASES = ['--surface', '--surface-2', '--border', '--border-strong', '--accent-weak', '--accent-text'];
132
+
133
+ test('no re-skinned reading-room sheet spends a compat alias', () => {
134
+ const found = [];
135
+ for (const [name, css] of RESKINNED) {
136
+ for (const alias of ALIASES) {
137
+ // \b would match --surface inside --surface-2, so require the var() close
138
+ if (new RegExp(`var\\(\\s*${alias}\\s*[,)]`).test(decomment(css))) found.push(`${name}: ${alias}`);
139
+ }
140
+ }
141
+ assert.deepEqual(found, [],
142
+ 'the aliases exist for pages nobody has re-skinned; spending one here quietly opts the sheet back out of the world');
143
+ });
144
+
145
+ test('the ranks page section of style.css spends the real names too', () => {
146
+ const card = rule(styleCss, '.rank-card');
147
+ assert.ok(card, '.rank-card has a rule');
148
+ for (const alias of ALIASES) {
149
+ assert.doesNotMatch(card, new RegExp(`var\\(\\s*${alias}\\s*[,)]`), `.rank-card no longer spends ${alias}`);
150
+ }
151
+ assert.match(card, /border:\s*var\(--line\)\s+solid\s+transparent/,
152
+ 'the edge is reserved as transparent so a modifier can draw one — collab.css\'s .btn--quiet is the precedent');
153
+ });
154
+
155
+ test('the alias guard would CATCH --surface without matching --surface-2', () => {
156
+ const re = new RegExp('var\\(\\s*--surface\\s*[,)]');
157
+ assert.ok(re.test('background: var(--surface);'));
158
+ assert.ok(!re.test('background: var(--surface-2);'));
159
+ });
160
+
161
+ // ---- 3. --measure is the BODY measure, and nothing else --------------------
162
+
163
+ test('the room measures PROSE, and lets a table, a figure and a code block run wider', () => {
164
+ const measured = decomment(roomCss).match(/\.primer-article\s*>\s*p,[\s\S]*?\{([^}]*)\}/);
165
+ assert.ok(measured, 'room.css caps the prose children explicitly, not the card');
166
+ assert.match(measured[1], /max-width:\s*var\(--measure\)/);
167
+ const article = rule(roomCss, '.primer-article');
168
+ assert.ok(article, '.primer-article has a rule');
169
+ assert.doesNotMatch(article, /max-width/,
170
+ 'the CARD is not capped at the measure — a table and a diagram may take its whole width');
171
+ });
172
+
173
+ test('no reading sheet does fresh `ch` arithmetic in prose', () => {
174
+ for (const [name, css] of RESKINNED) {
175
+ // A `ch` is the ZERO glyph's width, not a character: primer.css's 72ch
176
+ // rendered ~90. --measure was measured; a fresh number is a guess.
177
+ const hits = [...decomment(css).matchAll(/max-width:\s*(\d+(?:\.\d+)?)ch/g)].map((m) => m[0]);
178
+ assert.deepEqual(hits, [], `${name} caps a measure in raw ch — spend var(--measure)`);
179
+ }
180
+ });
181
+
182
+ test('the room head takes the display voice, and NOT the body measure', () => {
183
+ const h = rule(styleCss, '.page-head--room .page-head__h');
184
+ assert.ok(h, '.page-head--room .page-head__h has a rule');
185
+ assert.match(h, /font-family:\s*var\(--font-display\)/);
186
+ assert.doesNotMatch(h, /var\(--measure\)/,
187
+ '--measure is the BODY measure; on a display line it caps near 950px and the title runs the column');
188
+ const sub = rule(styleCss, '.page-head--room .page-head__sub');
189
+ assert.match(sub, /max-width:\s*var\(--measure\)/, 'the sub IS body prose, so it takes the measure');
190
+ });
191
+
192
+ // ---- 4. the room's one breakpoint is 760, and the script mirrors it --------
193
+
194
+ test('the room and primer.js agree on 760px, exactly', () => {
195
+ assert.match(decomment(roomCss), /@media\s*\(max-width:\s*760px\)/,
196
+ 'the room keeps its own breakpoint — narrowing it to the shell\'s 640 reopens the 641-760 band');
197
+ assert.match(primerJs, /matchMedia\('\(max-width:\s*760px\)'\)/,
198
+ 'primer.js closes the contents at the same width the sheet collapses them');
199
+ assert.doesNotMatch(decomment(roomCss), /@media\s*\(max-width:\s*640px\)/,
200
+ 'a second, narrower breakpoint in the room is the drift hall_audit exists to catch');
201
+ });
202
+
203
+ // ---- 5. one markdown parser -----------------------------------------------
204
+
205
+ test('primer.js carries no parser of its own any more', () => {
206
+ for (const gone of ['function parseMarkdown', 'function splitTableRow', 'function renderInline', 'function slugify', 'function escapeHtml']) {
207
+ assert.ok(!primerJs.includes(gone),
208
+ `primer.js still defines ${gone} — that copy is md-reader.js's, and the copy is what dropped blockquotes`);
209
+ }
210
+ assert.match(primerJs, /window\.OTBMdReader/, 'primer.js renders through the shared reader');
211
+ assert.match(primerJs, /MD\.parseMarkdown\(md,\s*resolveLinkHref\)/, 'and passes its own link resolver into it');
212
+ });
213
+
214
+ test('primer.html loads the reader BEFORE the page script', () => {
215
+ // Positions of the SCRIPT TAGS, not of the filenames: the page's own comments
216
+ // name both files, and an indexOf over the raw HTML finds the prose first.
217
+ const srcs = [...primerHtml.matchAll(/<script[^>]+src="([^"?]+)/g)].map((m) => m[1]);
218
+ const reader = srcs.indexOf('/builders/md-reader.js');
219
+ const page = srcs.indexOf('/builders/primer.js');
220
+ assert.ok(reader > -1, 'primer.html loads md-reader.js');
221
+ assert.ok(reader < page, 'both are deferred, so document order is execution order — the reader has to be first');
222
+ });
223
+
224
+ test('the primer keeps the one rewrite that is its own', () => {
225
+ assert.match(primerJs, /drachmae-and-karma\.md.*\/builders\/drachmae/s,
226
+ 'the economy doc has an in-hall reader page (task 1472); a link to it must stay in the hall, not bounce to GitHub');
227
+ assert.match(primerJs, /baseDir:\s*PRIMER_DIR/, 'and resolves its relative links against docs/onboarding/');
228
+ });
229
+
230
+ test('the room styles what the shared parser emits that the old copy never did', () => {
231
+ assert.match(mdReaderJs, /<blockquote>/, 'md-reader.js emits blockquotes');
232
+ const bq = rule(roomCss, '.primer-article blockquote');
233
+ assert.ok(bq, 'and the room has a rule for them — the pair IS the fix; either half alone is a silent no-op');
234
+ assert.match(bq, /var\(--glass\)/);
235
+ });
236
+
237
+ test('primer.js still injects the diagrams — the one parser-adjacent job that is the primer\'s', () => {
238
+ assert.match(primerJs, /function inlineDiagrams/);
239
+ assert.match(primerJs, /DIAGRAM_AT_HEADING/);
240
+ assert.match(primerJs, /fig\.className = 'primer-figure'/,
241
+ 'the injected figure takes the class diagram-viewer.js upgrades and room.css frames');
242
+ });
243
+
244
+ // ---- 6. the soft gate is reachable ----------------------------------------
245
+
246
+ test('the acknowledge gate is aria-disabled, never disabled', () => {
247
+ assert.match(primerHtml, /id="primer-ack-btn"[^>]*aria-disabled="true"/,
248
+ 'a real disabled button leaves the tab order, so a keyboard reader never hears why it is locked');
249
+ assert.doesNotMatch(primerHtml, /id="primer-ack-btn"[^>]*\sdisabled(?:[=\s>])/, 'and carries no hard disabled attribute');
250
+ assert.doesNotMatch(primerJs, /btn\.disabled\s*=/, 'nor does the script set one');
251
+ assert.match(primerJs, /setAttribute\('aria-disabled'/, 'the lock is the aria state');
252
+ const locked = rule(primerCss, '.primer-ack__btn[aria-disabled="false"]');
253
+ assert.ok(locked, 'the sheet paints the OPEN state off the same attribute the script writes');
254
+ assert.match(locked, /background:\s*var\(--accent\)/, 'the accent lights only what is pressable');
255
+ });
256
+
257
+ test('the locked note is described to the control while it is locked, and dropped when it opens', () => {
258
+ assert.match(primerHtml, /aria-describedby="primer-ack-note-locked"/);
259
+ assert.match(primerJs, /removeAttribute\('aria-describedby'\)/,
260
+ 'a note explaining a lock that is gone describes nothing');
261
+ });
262
+
263
+ // ---- 7. the diagram viewer -------------------------------------------------
264
+
265
+ test('diagram-viewer.css carries no fallback for a token that cannot be missing', () => {
266
+ const hits = [...decomment(viewerCss).matchAll(/var\(\s*--[a-z-]+\s*,[^)]+\)/g)].map((m) => m[0]);
267
+ assert.deepEqual(hits, [],
268
+ 'every one of these tokens ships in style.css, so the literal never rendered — it only encoded drift (the --ink fallback said #2a2a2a where the sheet ships #17181a)');
269
+ });
270
+
271
+ test('the viewer spends the named z rung, not a hand-written 9999', () => {
272
+ const full = rule(viewerCss, '.dgv.is-fullscreen');
273
+ assert.ok(full, '.dgv.is-fullscreen has a rule');
274
+ assert.match(full, /z-index:\s*var\(--z-diagram-full\)/);
275
+ assert.doesNotMatch(decomment(viewerCss), /z-index:\s*\d/, 'no bare number is left in the sheet');
276
+ assert.match(decomment(styleCss), /--z-diagram-full:\s*\d+/, 'and the rung is named in the shell ladder');
277
+ });
278
+
279
+ test('the fullscreen scroll lock is on the root, where the shell already locks', () => {
280
+ assert.match(decomment(viewerCss), /html\.dgv-fullscreen-open\s*\{\s*overflow:\s*hidden/);
281
+ assert.doesNotMatch(decomment(viewerCss), /body\.dgv-fullscreen-open/);
282
+ assert.match(viewerJs, /document\.documentElement\.classList\.toggle\('dgv-fullscreen-open'/,
283
+ 'the script writes the class where the sheet reads it — the shell\'s own drawer uses html.drawer-open');
284
+ assert.match(decomment(styleCss), /html\.drawer-open/, 'and that is the convention it is matching');
285
+ });
286
+
287
+ test('the room frames BOTH figures the viewer upgrades', () => {
288
+ assert.match(viewerJs, /figure\.diagram img, figure\.primer-figure img/,
289
+ 'one selector, two classes — the viewer has always treated them as one object');
290
+ const frame = decomment(roomCss).match(/\.primer-figure,\s*figure\.diagram\s*\{([^}]*)\}/);
291
+ assert.ok(frame, 'and the room frames them in one rule, so they cannot drift apart');
292
+ assert.match(frame[1], /var\(--glass\)/);
293
+ // the canvas is inserted INSIDE the figure, so exactly one of the two draws
294
+ // the frame or the diagram lands in nested boxes
295
+ const inner = decomment(roomCss).match(/\.primer-figure \.dgv,\s*figure\.diagram \.dgv\s*\{([^}]*)\}/);
296
+ assert.ok(inner, 'the inserted canvas gives up its own frame');
297
+ assert.match(inner[1], /border:\s*0/);
298
+ });
299
+
300
+ // ---- 8. every room has exactly one h1 -------------------------------------
301
+
302
+ const ROOMS = [['primer.html', primerHtml], ['diagrams.html', diagramsHtml], ['roadmap.html', roadmapHtml], ['ranks.html', ranksHtml]];
303
+
304
+ // An HTML comment that mentions <h1> is prose, not markup — and these pages each
305
+ // carry one explaining why the heading is there at all.
306
+ const dehtmlcomment = (html) => html.replace(/<!--[\s\S]*?-->/g, '');
307
+
308
+ test('every reading room renders exactly one h1, and it is the page head\'s', () => {
309
+ for (const [name, html] of ROOMS.map(([n, h]) => [n, dehtmlcomment(h)])) {
310
+ const h1s = [...html.matchAll(/<h1\b/g)];
311
+ assert.equal(h1s.length, 1, `${name} has exactly one <h1> — one-header-main-h1 measures the rendered DOM, not the happy path`);
312
+ assert.match(html, /<h1 class="page-head__h">/, `${name}'s h1 is the page head's`);
313
+ }
314
+ });
315
+
316
+ test('the three DOCS rooms take the room voice; the deck takes the board\'s', () => {
317
+ for (const [name, html] of [['primer.html', primerHtml], ['diagrams.html', diagramsHtml], ['ranks.html', ranksHtml]]) {
318
+ assert.match(html, /class="page-head page-head--room"/, `${name} is a reading room`);
319
+ assert.match(html, /class="page-head__eyebrow"/, `${name} carries the room's eyebrow`);
320
+ }
321
+ assert.match(roadmapHtml, /class="page-head page-head--board"/,
322
+ 'the roadmap is a working surface whose figures are the thing to look at, so its own name stays quiet');
323
+ });
324
+
325
+ test('the document\'s own h1 is suppressed, or the article would render a second one', () => {
326
+ const h1 = rule(roomCss, '.primer-article h1');
327
+ assert.ok(h1, 'room.css handles the markdown h1');
328
+ assert.match(h1, /display:\s*none/);
329
+ });
330
+
331
+ // ---- 9. the archetype's shape ---------------------------------------------
332
+
333
+ test('the room opts in from its markup, and the archetype sheet is what carries it', () => {
334
+ for (const [name, html] of [['primer.html', primerHtml], ['diagrams.html', diagramsHtml]]) {
335
+ assert.match(html, /<body class="room[^"]*"/, `${name} opts into the room`);
336
+ assert.match(html, /href="\/builders\/room\.css"/, `${name} loads the archetype`);
337
+ }
338
+ const tokens = rule(roomCss, 'body.room');
339
+ assert.ok(tokens, 'the room\'s composition tokens are on the opt-in body class');
340
+ assert.doesNotMatch(tokens, /--measure\s*:/,
341
+ 'the measure is NOT redeclared: a ch scales with the font size, so one measured token reads the same at 15px and 17px');
342
+ });
343
+
344
+ test('the room\'s panels are the shell\'s three card levels, not a fourth', () => {
345
+ for (const id of ['primer-article', 'primer-skeleton', 'primer-error', 'primer-ack']) {
346
+ assert.match(primerHtml, new RegExp(`class="[^"]*scroll"[^>]*id="${id}"|id="${id}"[^>]*class="[^"]*scroll"`),
347
+ `#${id} is the shell's .scroll — v3 has exactly three card levels and a fourth is a design decision, not a local override`);
348
+ }
349
+ });
350
+
351
+ test('no page sheet reaches into shell layout', () => {
352
+ for (const [name, css] of RESKINNED) {
353
+ assert.doesNotMatch(decomment(css), /\.app-content\s*>\s*main/,
354
+ `${name} overrides shell layout — the rule tests/hall_record_world.mjs pins`);
355
+ }
356
+ });
357
+
358
+ test('no reading room carries an inline <style>, and none needs an audit exception', () => {
359
+ const withStyle = ROOMS.filter(([, html]) => /<style[^>]*>[\s\S]*?\S[\s\S]*?<\/style>/.test(html)).map(([n]) => n);
360
+ assert.deepEqual(withStyle, [],
361
+ 'diagrams.html held the hall\'s last SUBSTANTIVE one — 16 lines, one over the default budget, with its own hall_audit exception. The exception retired with it.');
362
+ const audit = fs.readFileSync(path.join(ROOT, 'tests', 'hall_audit.mjs'), 'utf8');
363
+ const budgets = audit.match(/const INLINE_STYLE_BUDGET = \{([\s\S]*?)\};/);
364
+ assert.ok(budgets, 'hall_audit.mjs still declares the ratchet');
365
+ assert.doesNotMatch(budgets[1].replace(/\/\/[^\n]*/g, ''), /['"][a-z-]+\.html['"]\s*:/,
366
+ 'no page carries an inline-style exception any more');
367
+ // (scouting.html keeps a one-line block, comfortably inside the DEFAULT
368
+ // budget — an exception is what this asserts is gone, not every <style>.)
369
+ });
370
+
371
+ test('roadmap\'s two sub-24px controls clear the floor, on both axes', () => {
372
+ const t = rule(roadmapCss, '.rm-goal__t');
373
+ assert.ok(t, '.rm-goal__t has a rule');
374
+ assert.match(t, /min-height:\s*24px/, 'measured 20px: a 13px line box in a 7px row');
375
+ const more = rule(roadmapCss, '.rm-gmore');
376
+ assert.ok(more, '.rm-gmore has a rule');
377
+ assert.match(more, /min-height:\s*24px/, 'measured 19px');
378
+ // the WIDTH half is the one that gets missed: at 320 the four fixed items in
379
+ // the row ate it and `flex: 1; min-width: 0` resolved the title to 0x26
380
+ assert.match(decomment(roadmapCss), /@media\s*\(max-width:400px\)[\s\S]*?\.rm-goal__t\s*\{[^}]*flex:\s*1 0 100%/,
381
+ 'and the row wraps before the title can resolve to zero width');
382
+ });
383
+
384
+ // ---- 10. the parser, actually run -----------------------------------------
385
+
386
+ // md-reader.js is a browser IIFE that only touches `window` and `location`, so
387
+ // a two-property sandbox is the whole DOM it needs.
388
+ function loadReader() {
389
+ const sandbox = { window: {}, location: { hostname: 'builders.example.com', href: 'https://builders.example.com/primer' } };
390
+ vm.createContext(sandbox);
391
+ vm.runInContext(mdReaderJs, sandbox, { filename: 'md-reader.js' });
392
+ return sandbox.window.OTBMdReader;
393
+ }
394
+
395
+ test('the shared parser renders the blockquote the primer\'s old copy dropped', () => {
396
+ const MD = loadReader();
397
+ const { html } = MD.parseMarkdown('> a note the primer used to lose\n', (h) => h);
398
+ assert.match(html, /<blockquote><p>a note the primer used to lose<\/p><\/blockquote>/);
399
+ });
400
+
401
+ test('it slugs h2s the way the table of contents and the in-doc anchors expect', () => {
402
+ const MD = loadReader();
403
+ const { html, headings } = MD.parseMarkdown('## The claim → ship lifecycle\n', (h) => h);
404
+ assert.equal(headings.length, 1);
405
+ assert.equal(headings[0].level, 2);
406
+ assert.equal(headings[0].id, MD.slugify('The claim → ship lifecycle'));
407
+ assert.match(html, new RegExp(`<h2 id="${headings[0].id}">`),
408
+ 'the anchor in the markup and the id the TOC links to come from one call, so they cannot drift');
409
+ });
410
+
411
+ test('the primer\'s link resolver sends a repo-relative doc to GitHub and leaves the rest alone', () => {
412
+ const MD = loadReader();
413
+ const resolve = MD.makeResolveLinkHref({
414
+ baseDir: 'docs/onboarding/',
415
+ getBlobBase: () => 'https://github.com/o/r/blob/main',
416
+ getTreeBase: () => 'https://github.com/o/r/tree/main',
417
+ });
418
+ assert.equal(resolve('../../CLAUDE.md'), 'https://github.com/o/r/blob/main/CLAUDE.md');
419
+ assert.equal(resolve('../recipes/'), 'https://github.com/o/r/tree/main/docs/recipes');
420
+ assert.equal(resolve('#a-section'), '#a-section', 'an in-page anchor is left alone');
421
+ assert.equal(resolve('https://example.com/x'), 'https://example.com/x', 'so is an absolute URL');
422
+ assert.equal(resolve('diagrams'), '/builders/diagrams', 'and the diagrams index stays in the hall');
423
+ });
424
+
425
+ test('with no repo bases the resolver degrades instead of crashing the page', () => {
426
+ const MD = loadReader();
427
+ const resolve = MD.makeResolveLinkHref({ baseDir: 'docs/onboarding/' });
428
+ assert.equal(resolve('../../CLAUDE.md'), '../../CLAUDE.md',
429
+ 'the bases are fetched at boot; before they land a relative href passes through and 404s harmlessly');
430
+ });