@bongos/core 1.19.448 → 1.19.450
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.
- package/.bongos-core.json +96 -36
- package/docs/copy-inventory.md +121 -114
- package/docs/copy-registry.json +377 -331
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/diagram-viewer.css +51 -34
- package/modules/hall-ui/public/diagram-viewer.js +4 -1
- package/modules/hall-ui/public/diagrams.css +45 -0
- package/modules/hall-ui/public/diagrams.html +12 -20
- package/modules/hall-ui/public/diagrams.states.json +28 -0
- package/modules/hall-ui/public/idea.css +138 -71
- package/modules/hall-ui/public/idea.html +13 -3
- package/modules/hall-ui/public/idea.js +17 -4
- package/modules/hall-ui/public/idea.states.json +14 -0
- package/modules/hall-ui/public/ideas.css +148 -107
- package/modules/hall-ui/public/ideas.html +15 -5
- package/modules/hall-ui/public/ideas.states.json +15 -0
- package/modules/hall-ui/public/primer.css +92 -400
- package/modules/hall-ui/public/primer.html +68 -26
- package/modules/hall-ui/public/primer.js +107 -308
- package/modules/hall-ui/public/primer.states.json +38 -0
- package/modules/hall-ui/public/ranks.html +9 -1
- package/modules/hall-ui/public/ranks.states.json +1 -1
- package/modules/hall-ui/public/record.css +324 -0
- package/modules/hall-ui/public/roadmap.css +152 -86
- package/modules/hall-ui/public/roadmap.html +6 -1
- package/modules/hall-ui/public/roadmap.states.json +31 -0
- package/modules/hall-ui/public/room.css +441 -0
- package/modules/hall-ui/public/style.css +269 -63
- package/modules/hall-ui/public/task.css +71 -0
- package/modules/hall-ui/public/task.html +15 -32
- package/modules/hall-ui/public/task.js +142 -39
- package/modules/hall-ui/public/task.states.json +14 -0
- package/modules/hall-ui/public/work.css +4 -15
- package/modules/ui-design/kit/serve.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +34 -0
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +18 -11
- package/tests/hall_reading_world.mjs +430 -0
- package/tests/hall_record_world.mjs +412 -0
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
14
|
-
// the
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// (
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
|
|
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 ".
|
|
196
|
-
const safeUrl = url.replace(/&/g, '&').replace(/'/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">
|
|
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
|
|
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 =
|
|
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
|
|
468
|
-
btn
|
|
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
|
|
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
|
|
275
|
+
const section = $('#primer-ack');
|
|
511
276
|
if (btn) btn.hidden = true;
|
|
512
277
|
if (lede) lede.hidden = true;
|
|
513
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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": {
|