@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.
@@ -1,36 +1,39 @@
1
1
  // primer.js — the primer reader page (V3.R13-followup #406).
2
2
  //
3
3
  // Fetches /builders/primer.md (canonical markdown from docs/onboarding/),
4
- // parses it with a small hand-rolled parser covering the subset the primer
5
- // uses (headings, paragraphs, lists, tables, fenced code, hr, inline
6
- // **bold** / *italic* / `code` / [link](url)), inlines the five onboarding
7
- // diagrams at matching sections, builds a TOC from h2s with a scrollspy,
8
- // drives a top reading-progress bar, and soft-gates the footer ack button
9
- // until the reader has scrolled past ~90% of the article. Acking POSTs to
10
- // /api/gds/builders/me/onboarding/primer-read (same endpoint /builder-
11
- // primer-read uses).
4
+ // renders it through the SHARED reader (md-reader.js / window.OTBMdReader),
5
+ // inlines the five onboarding diagrams at matching sections, builds a TOC from
6
+ // h2s with a scrollspy, drives a reading-progress hairline, and soft-gates the
7
+ // footer ack button until the reader has scrolled past ~90% of the article.
8
+ // Acking POSTs to /api/gds/builders/me/onboarding/primer-read (same endpoint
9
+ // /builder-primer-read uses).
12
10
  //
13
- // No build step. No external markdown dep. The parser is purpose-built for
14
- // the primer's syntax, not a general-purpose markdown engine — extending it
15
- // for new constructs is straightforward but should match what the primer
16
- // actually contains.
11
+ // ONE PARSER, TWO READERS (task 1003306). This file used to carry its own copy
12
+ // of the markdown parser — the same grammar as md-reader.js minus blockquotes,
13
+ // which meant every `> aside` in the primer silently collapsed into a
14
+ // paragraph. md-reader.js was always the fuller of the two, so the copy is gone
15
+ // and the difference that is genuinely the primer's — the diagram injected
16
+ // after a named h2 — is all that is left here. Anything the primer's markdown
17
+ // needs that the grammar lacks belongs in md-reader.js, where the economy
18
+ // reader gets it too.
17
19
 
18
20
  (() => {
19
21
  'use strict';
20
22
 
21
23
  const API = '/api/gds';
22
24
  const ACK_SCROLL_THRESHOLD = 0.90; // 90% of article scrolled → button enables
23
- // V3.R13-followup-2 (#411): the canonical repo on GitHub. Relative file
24
- // paths in the primer markdown (../../CLAUDE.md, ../recipes/, etc.) are
25
- // rewritten to URLs under these bases so the rendered links go somewhere
26
- // useful instead of 404-ing under /builders/*. The bases are NOT hardcoded
27
- // here — that would violate the V3.R12 (#236) authorship-attribution audit.
28
- // Instead, primer.js fetches /api/gds/public/repo-info at boot; the server
29
- // derives the owner/repo at process start from `git remote get-url origin`
30
- // (src/bongos/repo-info.js). When the bases are unavailable (network glitch
31
- // pre-render, or the server can't reach git), the link rewriter degrades
32
- // gracefully — repo-relative hrefs are passed through unchanged so they
33
- // 404 instead of crashing the page.
25
+ // The primer markdown lives at docs/onboarding/primer.md, so its relative
26
+ // links resolve against docs/onboarding/.
27
+ const PRIMER_DIR = 'docs/onboarding/';
28
+ // V3.R13-followup-2 (#411): the canonical repo on GitHub. Relative file paths
29
+ // in the primer markdown (../../CLAUDE.md, ../recipes/, etc.) are rewritten to
30
+ // URLs under these bases so the rendered links go somewhere useful instead of
31
+ // 404-ing under /builders/*. The bases are NOT hardcoded here — that would
32
+ // violate the V3.R12 (#236) authorship-attribution audit. They are fetched
33
+ // from /api/gds/public/repo-info at boot; the server derives owner/repo at
34
+ // process start from `git remote get-url origin` (src/bongos/repo-info.js).
35
+ // When they are unavailable the rewriter degrades gracefully: repo-relative
36
+ // hrefs pass through unchanged and 404 instead of crashing the page.
34
37
  let repoBlobBase = null;
35
38
  let repoTreeBase = null;
36
39
 
@@ -52,27 +55,21 @@
52
55
 
53
56
  function $(sel) { return document.querySelector(sel); }
54
57
 
55
- function escapeHtml(s) {
56
- return String(s)
57
- .replace(/&/g, '&')
58
- .replace(/</g, '&lt;')
59
- .replace(/>/g, '&gt;')
60
- .replace(/"/g, '&quot;')
61
- .replace(/'/g, '&#39;');
62
- }
63
-
64
- // Stable slug for heading anchors. Lowercases, strips non-alphanum, joins
65
- // with dashes. Matches what most static-site generators do, so anchor
66
- // links in the primer's prose ([§foo](#foo)) work the same way.
67
- function slugify(text) {
68
- return String(text)
69
- .toLowerCase()
70
- .replace(/[—–]/g, '-') // em/en dashes
71
- .replace(/[^a-z0-9\s-]/g, '') // strip punctuation
72
- .trim()
73
- .replace(/\s+/g, '-')
74
- .replace(/-+/g, '-');
75
- }
58
+ const MD = window.OTBMdReader;
59
+ const escapeHtml = (MD && MD.escapeHtml) || ((s) => String(s));
60
+
61
+ // The shared resolver, plus the one rewrite that is the primer's alone: the
62
+ // economy doc has an in-hall reader page (task 1472), so a link to it stays in
63
+ // the hall instead of bouncing the reader out to GitHub.
64
+ const resolveLinkHref = (() => {
65
+ if (!MD) return (h) => h;
66
+ const base = MD.makeResolveLinkHref({
67
+ baseDir: PRIMER_DIR,
68
+ getBlobBase: () => repoBlobBase,
69
+ getTreeBase: () => repoTreeBase,
70
+ });
71
+ return (href) => (href === 'drachmae-and-karma.md' ? '/builders/drachmae' : base(href));
72
+ })();
76
73
 
77
74
  function toast(msg, type) {
78
75
  // Shared toast mechanism (style.css .toast[data-visible="1"]) — same
@@ -86,253 +83,11 @@
86
83
  toast._t = setTimeout(() => { el.dataset.visible = '0'; }, 3000);
87
84
  }
88
85
 
89
- // V3.R13-followup-2 (#411): rewrite relative repo paths in primer-markdown
90
- // links so they land somewhere usable in the browser rather than 404-ing
91
- // under /builders/*. The primer markdown was written for the repo view
92
- // (relative paths to other repo files), but it's rendered at
93
- // /builders/primer where those paths don't resolve.
94
- //
95
- // Rules (most-specific first):
96
- // - http(s)://… absolute URL → keep as-is
97
- // - #anchor in-page → keep as-is
98
- // - 'diagrams/' or 'diagrams' → /builders/diagrams
99
- // (the diagrams page lives in the hall; trailing slash falls through
100
- // to the game, so we always rewrite to the no-slash form)
101
- // - paths starting with ./ or ../ or any / → resolve against the
102
- // primer's repo location (docs/onboarding/) and link to GitHub.
103
- // We use the 'blob/main' base for files and 'tree/main' for dirs
104
- // (heuristic: trailing slash or no '.' in basename = dir).
105
- // - anything else → keep as-is
106
- function resolveLinkHref(href) {
107
- if (!href) return href;
108
- if (/^https?:\/\//i.test(href)) return href;
109
- if (href.startsWith('#')) return href;
110
- if (href.startsWith('mailto:') || href.startsWith('tel:')) return href;
111
- if (href === 'diagrams/' || href === 'diagrams') return '/builders/diagrams';
112
- // task 1472: the drachmae-and-karma doc now has an in-hall reader page, so
113
- // keep the reader in the hall instead of bouncing to GitHub. (The repo /
114
- // GitHub view still resolves the plain relative link.)
115
- if (href === 'drachmae-and-karma.md') return '/builders/drachmae';
116
- // The primer.md lives at docs/onboarding/primer.md, so relative paths
117
- // resolve against docs/onboarding/.
118
- const PRIMER_DIR = 'docs/onboarding/';
119
- let resolved;
120
- if (href.startsWith('/')) {
121
- // Absolute repo path. Strip the leading slash.
122
- resolved = href.replace(/^\/+/, '');
123
- } else {
124
- // Relative. Join with PRIMER_DIR and normalize.
125
- resolved = normalizePath(PRIMER_DIR + href);
126
- }
127
- // Decide blob (file) vs tree (directory). Heuristic: trailing slash =
128
- // dir; no '.' in last segment = dir; else file.
129
- const trailingSlash = resolved.endsWith('/');
130
- const lastSeg = resolved.replace(/\/+$/, '').split('/').pop() || '';
131
- const isDir = trailingSlash || !lastSeg.includes('.');
132
- const cleanResolved = resolved.replace(/^\/+/, '').replace(/\/+$/, '');
133
- const base = isDir ? repoTreeBase : repoBlobBase;
134
- // Graceful degradation: if the server-side repo-info fetch failed or
135
- // hasn't completed before this render, leave the href as the original
136
- // (which 404s harmlessly) rather than producing a broken /undefined/ URL.
137
- if (!base) return href;
138
- return `${base}/${cleanResolved}`;
139
- }
140
-
141
- // Resolve '../' and './' segments in a path. Defensive against edge cases
142
- // (no leading slash, repeated slashes); the input is already trusted
143
- // because it came from the primer markdown we ship.
144
- function normalizePath(p) {
145
- const parts = p.split('/');
146
- const out = [];
147
- for (const part of parts) {
148
- if (part === '' || part === '.') continue;
149
- if (part === '..') { out.pop(); continue; }
150
- out.push(part);
151
- }
152
- // Preserve a trailing slash if the original had one (matters for the
153
- // file/dir heuristic in resolveLinkHref).
154
- return out.join('/') + (p.endsWith('/') ? '/' : '');
155
- }
156
-
157
- // -----------------------------------------------------------------------
158
- // Markdown parser (purpose-built for the primer's subset)
159
- //
160
- // Block grammar (line-by-line):
161
- // ^# title → h1
162
- // ^## section → h2
163
- // ^### subsection → h3
164
- // ^---$ → hr
165
- // ^``` → fenced code (closes on next ```)
166
- // ^- item or ^[0-9]+\. item → list (single level)
167
- // ^| col | col | → table (separator row '|---|---|' follows)
168
- // blank line → paragraph break
169
- // anything else → paragraph (run inline)
170
- //
171
- // Inline grammar (in order; substitution avoids re-matching):
172
- // `code` → <code>…</code>
173
- // **bold** → <strong>…</strong>
174
- // *italic* → <em>…</em>
175
- // [text](url) → <a href="url">text</a>
176
- // -----------------------------------------------------------------------
177
-
178
- // Inline substitutions. We process them on already-escaped text so we
179
- // don't have to defend against html-injection inside literal text — code
180
- // spans are escape-safe because the content is the matched group (which
181
- // we re-escape). Bold + italic + link use the captured group as-is
182
- // (already escaped by the block-level pass).
183
- function renderInline(escapedText) {
184
- // Code spans first so their contents aren't touched by other rules.
185
- // Use a placeholder pass to avoid re-matching.
186
- const codeSpans = [];
187
- let out = escapedText.replace(/`([^`]+)`/g, (_m, g1) => {
188
- codeSpans.push(g1);
189
- return `CODE${codeSpans.length - 1}`;
190
- });
191
- // Links — [text](url). url must be the next paren-group, no nested parens.
192
- out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, url) => {
193
- // url is already html-escaped by the block-level escape; that's fine
194
- // for href because we use the raw string and let the browser handle
195
- // %-encoding. We strip the quotes that the escape replaced with &quot;.
196
- const safeUrl = url.replace(/&amp;/g, '&').replace(/&#39;/g, "'");
197
- const resolved = resolveLinkHref(safeUrl);
198
- // External (off-instance) links open in a new tab so the reader
199
- // doesn't lose their place in the primer.
200
- const instApex = location.hostname.split('.').slice(-2).join('.');
201
- let linkHost = '';
202
- try { linkHost = new URL(resolved, location.href).hostname; } catch (_) { /* relative/opaque href */ }
203
- const sameInstance = !!linkHost && (linkHost === location.hostname || linkHost === instApex || linkHost.endsWith('.' + instApex));
204
- const external = /^https?:\/\//.test(resolved) && !sameInstance;
205
- const attrs = external ? ' target="_blank" rel="noopener"' : '';
206
- return `<a href="${escapeHtml(resolved)}"${attrs}>${text}</a>`;
207
- });
208
- // Bold (run before italic so **x** doesn't match as *(*x*)*).
209
- out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
210
- // Italic.
211
- out = out.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>');
212
- // Restore code spans.
213
- out = out.replace(/CODE(\d+)/g, (_m, idx) => {
214
- return `<code>${escapeHtml(codeSpans[Number(idx)])}</code>`;
215
- });
216
- return out;
217
- }
218
-
219
- function parseMarkdown(md) {
220
- // Normalize line endings.
221
- const lines = String(md).replace(/\r\n?/g, '\n').split('\n');
222
- const out = [];
223
- const headings = []; // { level, text, id } — used to build TOC + scrollspy
224
- let i = 0;
225
-
226
- while (i < lines.length) {
227
- const line = lines[i];
228
- const raw = line;
229
-
230
- // Fenced code block.
231
- if (raw.startsWith('```')) {
232
- i += 1;
233
- const codeLines = [];
234
- while (i < lines.length && !lines[i].startsWith('```')) {
235
- codeLines.push(lines[i]);
236
- i += 1;
237
- }
238
- i += 1; // consume closing ```
239
- out.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
240
- continue;
241
- }
242
-
243
- // Headings.
244
- let hm;
245
- if ((hm = raw.match(/^(#{1,3})\s+(.+?)\s*$/))) {
246
- const level = hm[1].length;
247
- const text = hm[2];
248
- const id = slugify(text);
249
- headings.push({ level, text, id });
250
- out.push(`<h${level} id="${escapeHtml(id)}">${renderInline(escapeHtml(text))}</h${level}>`);
251
- i += 1;
252
- continue;
253
- }
254
-
255
- // Horizontal rule.
256
- if (/^---+\s*$/.test(raw)) {
257
- out.push('<hr>');
258
- i += 1;
259
- continue;
260
- }
261
-
262
- // Blank line — paragraph separator.
263
- if (/^\s*$/.test(raw)) {
264
- i += 1;
265
- continue;
266
- }
267
-
268
- // Table: header row '| ... |' followed by separator row '| --- | --- |'.
269
- if (/^\|/.test(raw) && i + 1 < lines.length && /^\|[\s\-:|]+\|\s*$/.test(lines[i + 1])) {
270
- const headerCells = splitTableRow(raw);
271
- i += 2;
272
- const bodyRows = [];
273
- while (i < lines.length && /^\|/.test(lines[i])) {
274
- bodyRows.push(splitTableRow(lines[i]));
275
- i += 1;
276
- }
277
- let html = '<table><thead><tr>';
278
- for (const c of headerCells) html += `<th>${renderInline(escapeHtml(c))}</th>`;
279
- html += '</tr></thead><tbody>';
280
- for (const row of bodyRows) {
281
- html += '<tr>';
282
- for (const c of row) html += `<td>${renderInline(escapeHtml(c))}</td>`;
283
- html += '</tr>';
284
- }
285
- html += '</tbody></table>';
286
- out.push(html);
287
- continue;
288
- }
289
-
290
- // Lists (unordered '- ' or ordered '1. '). Single-level; consecutive
291
- // list items collapse into one <ul>/<ol>.
292
- if (/^(-|\d+\.)\s+/.test(raw)) {
293
- const ordered = /^\d+\.\s+/.test(raw);
294
- const items = [];
295
- while (i < lines.length && /^(-|\d+\.)\s+/.test(lines[i])) {
296
- const content = lines[i].replace(/^(-|\d+\.)\s+/, '');
297
- items.push(`<li>${renderInline(escapeHtml(content))}</li>`);
298
- i += 1;
299
- }
300
- out.push(`<${ordered ? 'ol' : 'ul'}>${items.join('')}</${ordered ? 'ol' : 'ul'}>`);
301
- continue;
302
- }
303
-
304
- // Paragraph — collect consecutive non-blank, non-special lines.
305
- const paraLines = [raw];
306
- i += 1;
307
- while (i < lines.length &&
308
- !/^\s*$/.test(lines[i]) &&
309
- !/^(#{1,3})\s/.test(lines[i]) &&
310
- !/^---+\s*$/.test(lines[i]) &&
311
- !/^```/.test(lines[i]) &&
312
- !/^\|/.test(lines[i]) &&
313
- !/^(-|\d+\.)\s+/.test(lines[i])) {
314
- paraLines.push(lines[i]);
315
- i += 1;
316
- }
317
- const text = paraLines.join(' ');
318
- out.push(`<p>${renderInline(escapeHtml(text))}</p>`);
319
- }
320
-
321
- return { html: out.join('\n'), headings };
322
- }
323
-
324
- function splitTableRow(line) {
325
- // '| a | b | c |' → ['a', 'b', 'c']. Trims, drops the leading/trailing empty cells.
326
- const cells = line.split('|').map((c) => c.trim());
327
- // Drop the empty strings introduced by the leading/trailing pipes.
328
- if (cells.length && cells[0] === '') cells.shift();
329
- if (cells.length && cells[cells.length - 1] === '') cells.pop();
330
- return cells;
331
- }
332
-
333
86
  // -----------------------------------------------------------------------
334
87
  // Diagram inlining — after the HTML is in the DOM, walk every <h2> and
335
88
  // append a <figure> right after it if the heading matches a known diagram.
89
+ // This is the ONLY parser-adjacent work that is the primer's own; everything
90
+ // above it is md-reader.js's.
336
91
  // -----------------------------------------------------------------------
337
92
 
338
93
  function inlineDiagrams(articleEl) {
@@ -364,7 +119,8 @@
364
119
  }
365
120
 
366
121
  // -----------------------------------------------------------------------
367
- // TOC — built from h2 headings.
122
+ // TOC — built from h2 headings. The numeral is drawn by room.css's counter,
123
+ // so the markup carries only the title.
368
124
  // -----------------------------------------------------------------------
369
125
 
370
126
  function renderToc(headings) {
@@ -372,7 +128,7 @@
372
128
  if (!list) return [];
373
129
  const h2s = headings.filter((h) => h.level === 2);
374
130
  if (h2s.length === 0) {
375
- list.innerHTML = '<li class="primer-toc__placeholder">(no sections)</li>';
131
+ list.innerHTML = '<li class="primer-toc__placeholder">No sections yet</li>';
376
132
  return [];
377
133
  }
378
134
  list.innerHTML = h2s.map((h) =>
@@ -428,7 +184,7 @@
428
184
  }
429
185
 
430
186
  // -----------------------------------------------------------------------
431
- // Reading-progress bar — fraction of the article scrolled past.
187
+ // Reading-progress hairline — fraction of the article scrolled past.
432
188
  // -----------------------------------------------------------------------
433
189
 
434
190
  function setupProgressBar(articleEl, onProgress) {
@@ -437,13 +193,8 @@
437
193
  function update() {
438
194
  const rect = articleEl.getBoundingClientRect();
439
195
  const viewport = window.innerHeight || document.documentElement.clientHeight;
440
- // 0 when the top of the article is at/below the viewport top;
441
- // 1 when the bottom of the article is at/above the viewport top.
442
- // We use article.bottom — viewportTop as "how much has scrolled past
443
- // me" then normalize by article.height.
444
- const total = rect.height + viewport * 0; // article only
445
196
  const scrolled = Math.max(0, -rect.top);
446
- const frac = total > 0 ? Math.min(1, scrolled / Math.max(1, rect.height - viewport)) : 0;
197
+ const frac = rect.height > 0 ? Math.min(1, scrolled / Math.max(1, rect.height - viewport)) : 0;
447
198
  bar.style.width = `${Math.round(frac * 100)}%`;
448
199
  if (typeof onProgress === 'function') onProgress(frac);
449
200
  }
@@ -454,18 +205,30 @@
454
205
 
455
206
  // -----------------------------------------------------------------------
456
207
  // Footer ack module — soft gate on the 'I have read it' button.
208
+ //
209
+ // The gate is aria-disabled, NOT disabled (task 1003306). A real `disabled`
210
+ // button leaves the tab order entirely, so a keyboard reader can never land
211
+ // on it and never hears the note saying WHY it is locked. aria-disabled keeps
212
+ // it reachable and describable; the handler below is what actually refuses
213
+ // the press.
457
214
  // -----------------------------------------------------------------------
458
215
 
459
216
  let ackEnabled = false;
217
+ function setLocked(btn, locked) {
218
+ btn.setAttribute('aria-disabled', locked ? 'true' : 'false');
219
+ }
220
+ function isLocked(btn) {
221
+ return btn.getAttribute('aria-disabled') !== 'false';
222
+ }
223
+
460
224
  function setupAck() {
461
225
  const ackSection = $('#primer-ack');
462
226
  const btn = $('#primer-ack-btn');
463
- const lede = $('#primer-ack-lede');
464
227
  if (!ackSection || !btn) return;
465
228
  ackSection.hidden = false;
466
229
  btn.addEventListener('click', async () => {
467
- if (!ackEnabled || btn.disabled) return;
468
- btn.disabled = true;
230
+ if (!ackEnabled || isLocked(btn)) return;
231
+ setLocked(btn, true);
469
232
  btn.textContent = 'Marking…';
470
233
  try {
471
234
  const resp = await fetch(`${API}/builders/me/onboarding/primer-read`, {
@@ -492,7 +255,7 @@
492
255
  toast('Primer acknowledged.', 'success');
493
256
  } catch (err) {
494
257
  console.error('[primer] ack failed', err);
495
- btn.disabled = false;
258
+ setLocked(btn, false);
496
259
  btn.textContent = 'I have read it';
497
260
  toast('Could not mark the primer just now. Try again.', 'error');
498
261
  }
@@ -506,11 +269,15 @@
506
269
  function showAckedState(message) {
507
270
  const btn = $('#primer-ack-btn');
508
271
  const lede = $('#primer-ack-lede');
272
+ const meter = $('#primer-ack-meter');
273
+ const locked = $('#primer-ack-note-locked');
509
274
  const note = $('#primer-ack-note');
510
- const inner = document.querySelector('.primer-ack__inner');
275
+ const section = $('#primer-ack');
511
276
  if (btn) btn.hidden = true;
512
277
  if (lede) lede.hidden = true;
513
- if (inner) inner.classList.add('primer-ack__inner--acked');
278
+ if (meter) meter.hidden = true;
279
+ if (locked) locked.hidden = true;
280
+ if (section) section.classList.add('primer-ack--acked');
514
281
  if (note) {
515
282
  note.hidden = false;
516
283
  note.innerHTML = `<span class="primer-ack__check" aria-hidden="true">✓</span> ${escapeHtml(message)} <a href="/builders">Back to home</a>.`;
@@ -518,12 +285,26 @@
518
285
  }
519
286
 
520
287
  function onArticleProgress(frac) {
288
+ const btn = $('#primer-ack-btn');
289
+ const meter = $('#primer-ack-meter');
290
+ // The meter reads the reader's own progress against the gate, so "locked"
291
+ // is a measurement they can watch move rather than a refusal.
292
+ if (meter && !ackEnabled && !(btn && btn.dataset.alreadyAcked)) {
293
+ const pct = Math.min(99, Math.round(frac * 100));
294
+ const need = Math.round(ACK_SCROLL_THRESHOLD * 100);
295
+ meter.innerHTML = `Read <span class="primer-ack__num">${pct}%</span> of the ${need}% needed`;
296
+ }
521
297
  if (frac >= ACK_SCROLL_THRESHOLD && !ackEnabled) {
522
- const btn = $('#primer-ack-btn');
523
298
  const lede = $('#primer-ack-lede');
299
+ const locked = $('#primer-ack-note-locked');
524
300
  if (btn && !btn.dataset.alreadyAcked) {
525
- btn.disabled = false;
301
+ setLocked(btn, false);
302
+ // the note explained the lock; with the lock gone it would describe a
303
+ // control that is no longer locked, so it goes with it
304
+ btn.removeAttribute('aria-describedby');
526
305
  ackEnabled = true;
306
+ if (meter) meter.hidden = true;
307
+ if (locked) locked.hidden = true;
527
308
  }
528
309
  if (lede) {
529
310
  lede.textContent = 'You have reached the end. The button below is now active.';
@@ -601,9 +382,27 @@
601
382
  }
602
383
  }
603
384
 
385
+ // The three panels the article can resolve into. Each is the same card at the
386
+ // same width, so the column never changes shape as the document arrives.
387
+ function show(which) {
388
+ for (const [id, on] of [['#primer-skeleton', which === 'loading'],
389
+ ['#primer-article', which === 'article'],
390
+ ['#primer-error', which === 'error']]) {
391
+ const el = $(id);
392
+ if (el) el.hidden = !on;
393
+ }
394
+ }
395
+
604
396
  async function boot() {
605
397
  const article = $('#primer-article');
606
398
  if (!article) return;
399
+ if (!MD) {
400
+ // md-reader.js is loaded before this file; without it there is no parser
401
+ // at all, so say so rather than rendering an empty room.
402
+ console.error('[primer] md-reader.js did not load — no parser available');
403
+ show('error');
404
+ return;
405
+ }
607
406
  let md;
608
407
  try {
609
408
  // Fetch markdown + repo bases in parallel. The bases must be populated
@@ -617,13 +416,13 @@
617
416
  md = await mdRes.text();
618
417
  } catch (err) {
619
418
  console.error('[primer] failed to load markdown', err);
620
- article.innerHTML = `<p class="primer-loading">Could not load the primer. <a href="/builders">Go back</a> and try again.</p>`;
621
- article.removeAttribute('aria-busy');
419
+ show('error');
622
420
  return;
623
421
  }
624
- const { html, headings } = parseMarkdown(md);
422
+ const { html, headings } = MD.parseMarkdown(md, resolveLinkHref);
625
423
  article.innerHTML = html;
626
424
  article.removeAttribute('aria-busy');
425
+ show('article');
627
426
  inlineDiagrams(article);
628
427
  const tocSections = renderToc(headings);
629
428
  setupTocToggle();
@@ -0,0 +1,38 @@
1
+ {
2
+ "_": "The hall's primer through modules/ui-design/kit/render.js (task 1003306). Written with the reading-room re-skin so the room's failure state and its table of contents are MEASURED at 1440/390/320 in both modes, not just looked at.",
3
+ "page": "/builders/primer",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/" },
6
+ "modeQuery": false,
7
+ "_feeds": "The kit's stub carries the public-landing fixtures, not the hall's, and it serves no /builders/primer.md at all — so the room renders its SKELETON and then its FAILURE note here, which is the point: the two states before the document are the ones nobody looks at, and they have to hold the floors too. The populated document is the hall-preview harness's job: node scripts/hall-preview/server.js --port 4612 --fixture-me, then this same file with --base-in/--base-out pointed at it.",
8
+ "ignoreRequests": [
9
+ "/api/(gds|bongos)/me$",
10
+ "/api/(gds|bongos)/me/",
11
+ "/api/(gds|bongos)/goals",
12
+ "/api/(gds|bongos)/inbox/",
13
+ "/api/(gds|bongos)/public/repo-info",
14
+ "/builders/primer.md"
15
+ ],
16
+ "ignoreConsole": [
17
+ "/api/(gds|bongos)/",
18
+ "\\b401\\b",
19
+ "\\b404\\b",
20
+ "Unauthorized",
21
+ "Not Found",
22
+ "primer.md",
23
+ "failed to load markdown",
24
+ "load failed"
25
+ ],
26
+ "_expect": "The expectations name only what is true under BOTH bases. Under the kit stub there is no primer.md, so the failure note renders; under the harness the real document does. Pinning either one would make this file pass in one place and fail in the other, which is how a states file stops being run against the harness at all.",
27
+ "states": {
28
+ "in": { "auth": true, "expect": { "visible": ["#hall-main", "#primer-toc"] } },
29
+ "toc-open": {
30
+ "auth": true,
31
+ "actions": [["clickIfVisible", "#primer-toc-toggle"], ["wait", 250]],
32
+ "_": "the room's one disclosure. Below 760px the contents collapse behind this toggle; above it the toggle is not rendered at all, so clickIfVisible is the right verb and the state is a no-op at 1440.",
33
+ "expect": { "visible": ["#primer-toc"] }
34
+ },
35
+ "out": { "auth": false }
36
+ },
37
+ "reducedMotion": { "auth": true }
38
+ }
@@ -18,7 +18,15 @@
18
18
  <div class="app-body">
19
19
  <header class="app-topbar" id="app-topbar"></header>
20
20
  <div class="app-content">
21
- <div class="page-head">
21
+ <!-- The page had no <h1> at all before task 1003306, so it failed
22
+ one-header-main-h1 on every render — the finding ranks.states.json
23
+ has been carrying in the open since task 1003320. Ranks is a reading
24
+ room by archetype (docs/design/hall-direction-v2.md §1), so it takes
25
+ the room's head: the label voice on the eyebrow, the room's name in
26
+ the display face. -->
27
+ <div class="page-head page-head--room">
28
+ <p class="page-head__eyebrow">Docs</p>
29
+ <h1 class="page-head__h">Ranks</h1>
22
30
  <p class="page-head__sub" id="ranks-sub">Loading your rank…</p>
23
31
  </div>
24
32
 
@@ -4,7 +4,7 @@
4
4
  "surface": "hall-ui",
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
- "_feeds": "The hall's shell polls goals + inbox + profile feeds on every page; the stub fixtures only the public feeds, so those 404s are declared here, not hidden by the kit. The 24px-floor and h1 findings the render reports are REAL (the hall predates the floor) and are deliberately not ignored.",
7
+ "_feeds": "The hall's shell polls goals + inbox + profile feeds on every page; the stub fixtures only the public feeds, so those 404s are declared here, not hidden by the kit. (The 24px-floor and h1 findings this file used to carry in the open are FIXED, not silenced: the badge grew to 24px in task 1003304, and the page got its <h1> with the reading-room re-skin in task 1003306. The render is clean; nothing here ignores a floor.)",
8
8
  "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/me/"],
9
9
  "ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized", "404 \\(Not Found\\)", "goals load failed"],
10
10
  "states": {