@bongos/core 1.19.1055 → 1.19.1057

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.
@@ -0,0 +1,674 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/page-reader.js — the PAGE READER: every string a rendered page
5
+ // shows, in page order, grouped by section, each placed against the copy
6
+ // registry (BV2.TW04, task 1004315; ADR 0341 D3; goal 1000095, the Tweak Mode
7
+ // chain — docs/specs/bongos-v2-tweak-mode.md).
8
+ //
9
+ // WHY. The copy registry (docs/copy-registry.json) is keyed by FILE: it lists
10
+ // comment text nobody sees, and misses anything a script builds at run time or a
11
+ // server sends. Tweak Mode's unit is the PAGE (ADR 0341 D1), so an artist needs
12
+ // the other view — "what does this page actually show". This reader renders each
13
+ // page of docs/page-inventory.json in each of its states, lists every visible
14
+ // string, and says for each line where it lives: PLACED (a registry row: file,
15
+ // line, string_id), SHARED (shell text — the top bar, sidebar and footer every
16
+ // page of a surface carries, the planning session's "same UI element") or
17
+ // UNPLACED (a string the registry cannot place; ADR 0341 D4 turns a rewrite of
18
+ // one into its own engineer task). An unplaced string is LISTED, never dropped.
19
+ //
20
+ // THE OUTPUT CONTRACT (ADR 0341 D3). docs/page-readings.json, generated and
21
+ // committed: one entry per inventory page, its lines in page order. Each line has
22
+ // key L0001, L0002, … in page order — stable only within one reading;
23
+ // section the heading the line sits under (or the shell region for shared);
24
+ // text the string, with {…} holes where the registry row has a hole or a
25
+ // run-time value (data from the page's own reads) was filled in;
26
+ // placement placed | shared | unplaced, with file, line and string_id
27
+ // whenever the registry places the string (shared lines too, so a
28
+ // shared edit can travel in a batch like a placed one, D4).
29
+ // Each page carries reading_hash (over its NON-shared lines' key, section and
30
+ // text — so a shell change does not move every page's hash) and files_hash (over
31
+ // the contents of its inventory `files`, line endings normalised).
32
+ //
33
+ // FRESHNESS WITHOUT A BROWSER. --check compares files_hash only — pure fs, no
34
+ // browser, no network — and names the command that re-reads the stale pages.
35
+ // ship-check runs it; nothing inside ship-check or fitness launches a browser
36
+ // (D3). The browser runs only when a person or session runs this reader.
37
+ //
38
+ // HOW IT RENDERS. The hall through the hall-preview harness
39
+ // (scripts/hall-preview/server.js --fixture-me: the real pages, the checked-in
40
+ // fixtures, one pinned account), landing and status through the ui-design kit's
41
+ // stub (modules/ui-design/kit/serve.js: the real serving transforms, the kit's
42
+ // public feeds), the browser through the kit's launchBrowser (system Edge or
43
+ // Chrome via playwright-core; nothing is downloaded). Each state is rendered at
44
+ // 1280x800, light (or the page's one pinned mode), with the clock pinned so a
45
+ // relative date reads the same on every run, then its states-file actions run.
46
+ // Four renders run at once. Measured on the owner's Windows box (2026-09-27): the
47
+ // whole inventory, 37 pages and 156 renders, in 128 seconds; one page in 5-40
48
+ // seconds (--page). Two back-to-back full runs wrote byte-identical files.
49
+ //
50
+ // Run: node scripts/gds/page-reader.js (re-read every page, write the readings)
51
+ // node scripts/gds/page-reader.js --stale (re-read only the pages --check calls stale)
52
+ // node scripts/gds/page-reader.js --page builders:studio,landing:index
53
+ // node scripts/gds/page-reader.js --check (no browser: exit 1 naming each stale page)
54
+ // node scripts/gds/page-reader.js --page <id> --print (read one page, print it, write nothing)
55
+
56
+ const fs = require('node:fs');
57
+ const path = require('node:path');
58
+ const crypto = require('node:crypto');
59
+ const { spawn, spawnSync } = require('node:child_process');
60
+
61
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
62
+ const INVENTORY_REL = 'docs/page-inventory.json';
63
+ const REGISTRY_REL = 'docs/copy-registry.json';
64
+ const READINGS_REL = 'docs/page-readings.json';
65
+ const REREAD_COMMAND = 'node scripts/gds/page-reader.js --stale';
66
+ const PLACEMENTS = Object.freeze(['placed', 'shared', 'unplaced']);
67
+ const HOLE = '{…}';
68
+ const VIEWPORT = Object.freeze({ width: 1280, height: 800 });
69
+ // A fixed instant for every render, so "3 days ago" is the same on every run. Only
70
+ // Date is pinned; timers keep running, so the pages still boot.
71
+ const PINNED_CLOCK = '2026-09-27T12:00:00Z';
72
+ const CONCURRENCY = 4;
73
+ const EXTRA_SETTLE_MS = 600; // on top of the kit's SETTLE_MS: late fetches, the harness's SSE nudge
74
+ const ACTION_TIMEOUT_MS = 6000;
75
+ const RENDER_CEILING_MS = 30000;
76
+
77
+ // Locale-independent order (see copy-inventory.js: localeCompare depends on ICU).
78
+ function cmp(a, b) {
79
+ if (a === b) return 0;
80
+ return a < b ? -1 : 1;
81
+ }
82
+
83
+ function sha(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
84
+
85
+ // ── the hashes ────────────────────────────────────────────────────────────────
86
+
87
+ // files_hash — over the contents of a page's inventory files, CRLF folded to LF
88
+ // (a Windows checkout and CI must agree). A missing file hashes as absent, so
89
+ // deleting one moves the hash too.
90
+ function filesHash(files, { root = REPO_ROOT, fsImpl = fs } = {}) {
91
+ const h = crypto.createHash('sha256');
92
+ for (const f of files) {
93
+ let body;
94
+ try { body = fsImpl.readFileSync(path.join(root, f), 'utf8').replace(/\r\n/g, '\n'); } catch { body = '\u0000absent'; }
95
+ h.update(`${f}\n${body.length}\n${body}\n`);
96
+ }
97
+ return h.digest('hex').slice(0, 16);
98
+ }
99
+
100
+ // reading_hash — over the non-shared lines only (ADR 0341 D3). The keys are in it,
101
+ // so a shell line added above the page's own text (which renumbers them) still
102
+ // moves the hash, and a draft keyed on the old numbering is refused, not misread.
103
+ function readingHash(lines) {
104
+ const own = lines.filter((l) => l.placement !== 'shared').map((l) => [l.key, l.section, l.text]);
105
+ return sha(JSON.stringify(own)).slice(0, 16);
106
+ }
107
+
108
+ // ── the registry matcher (pure) ──────────────────────────────────────────────
109
+
110
+ function decodeEntities(s) {
111
+ return String(s)
112
+ .replace(/&nbsp;/g, ' ').replace(/&mdash;/g, '—').replace(/&ndash;/g, '–').replace(/&hellip;/g, '…')
113
+ .replace(/&rsquo;/g, '’').replace(/&lsquo;/g, '‘').replace(/&rdquo;/g, '”').replace(/&ldquo;/g, '“')
114
+ .replace(/&middot;/g, '·').replace(/&times;/g, '×').replace(/&rarr;/g, '→').replace(/&larr;/g, '←')
115
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
116
+ .replace(/&#(\d+);/g, (m, n) => String.fromCodePoint(Number(n)))
117
+ .replace(/&amp;/g, '&');
118
+ }
119
+ function norm(s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); }
120
+ const HAS_LETTER = /\p{L}/u;
121
+ // The registry's holes: ${…} (a template expression), {…} (the same, written
122
+ // plain) and {{token}} (a branding placeholder the server fills).
123
+ const REGISTRY_HOLE = /\$\{…\}|\{\{[A-Za-z0-9_.]+\}\}|\{…\}/g;
124
+ function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
125
+ // The reading's own text for a registry row: ${…} written as the registry's plain
126
+ // {…}; a {{token}} is kept by name (it is a brand value, and says which).
127
+ function registryText(text) { return norm(decodeEntities(text)).replace(/\$\{…\}/g, HOLE); }
128
+
129
+ // buildMatcher — index the registry once: exact texts, holed patterns, and the
130
+ // literal segments between holes (a hole filled with markup splits one row into
131
+ // several text nodes, each a segment of it).
132
+ function buildMatcher(registry) {
133
+ const entries = (registry && registry.entries) || [];
134
+ const exact = new Map();
135
+ const segments = new Map();
136
+ const patterns = [];
137
+ const push = (map, k, v) => { if (!map.has(k)) map.set(k, []); map.get(k).push(v); };
138
+ entries.forEach((e, order) => {
139
+ const text = norm(decodeEntities(e.text));
140
+ if (!text || !HAS_LETTER.test(text)) return;
141
+ const row = { id: e.id, file: e.file, line: e.line, text: registryText(e.text), order };
142
+ const parts = text.split(REGISTRY_HOLE);
143
+ if (parts.length === 1) { push(exact, text, row); return; }
144
+ const literal = parts.join('');
145
+ if ((literal.match(/\p{L}/gu) || []).length < 2) return; // a row that is all holes places nothing
146
+ patterns.push({ row, re: new RegExp(`^${parts.map((p) => escapeRe(p)).join('(.*?)')}$`, 'su') });
147
+ for (const p of parts) { const seg = norm(p); if (seg.length >= 2 && HAS_LETTER.test(seg)) push(segments, seg, row); }
148
+ });
149
+ return { exact, segments, patterns, size: entries.length };
150
+ }
151
+
152
+ // placeText — the registry row that renders this string on this page, or null.
153
+ // Tiers in order: the exact text, a holed row whose pattern matches it whole, a
154
+ // literal segment of a holed row. Within a tier, a row in one of the page's own
155
+ // files wins (in the page's file order: its html first), then the lowest line; a
156
+ // row in a script or sheet elsewhere on the same surface is the fallback (one the
157
+ // page loads late, which the inventory's script tags cannot see). Anything else is
158
+ // unplaced — the caller lists it, it is never dropped.
159
+ function placeText(text, matcher, { files = [], surfaceDir = null } = {}) {
160
+ const t = norm(text);
161
+ if (!t) return null;
162
+ const rank = new Map(files.map((f, i) => [f, i]));
163
+ const pick = (rows) => {
164
+ if (!rows || !rows.length) return null;
165
+ const own = rows.filter((r) => rank.has(r.file)).sort((a, b) => rank.get(a.file) - rank.get(b.file) || a.line - b.line || a.order - b.order);
166
+ if (own.length) return own[0];
167
+ // Never another page's html: a page file renders only itself, so a row there
168
+ // is the same words on a different page, and an applier would edit the wrong one.
169
+ const near = surfaceDir ? rows.filter((r) => r.file.startsWith(`${surfaceDir}/`) && !/\.html?$/i.test(r.file)).sort((a, b) => cmp(a.file, b.file) || a.line - b.line) : [];
170
+ return near[0] || null;
171
+ };
172
+ const tiers = [
173
+ () => ({ row: pick(matcher.exact.get(t)), how: 'exact' }),
174
+ () => ({ row: pick(matcher.patterns.filter((p) => p.re.test(t)).map((p) => p.row)), how: 'pattern' }),
175
+ () => ({ row: pick(matcher.segments.get(t)), how: 'segment' }),
176
+ ];
177
+ for (const tier of tiers) {
178
+ const { row, how } = tier();
179
+ if (row) return { file: row.file, line: row.line, string_id: row.id, text: how === 'pattern' ? row.text : t, how };
180
+ }
181
+ return null;
182
+ }
183
+
184
+ // ── data values: what the page READ, not what it SAYS ─────────────────────────
185
+
186
+ // A page shows its data too — task titles, names, counts from its own API reads.
187
+ // Those are not copy: an artist cannot rewrite a fixture's task title. A string
188
+ // the registry cannot place is compared against the values the page actually
189
+ // received: every such value in it becomes a {…} hole, and a string that is
190
+ // nothing BUT data once holed (no letter left) is data, counted and not listed.
191
+ // The registry is always asked first, so a placed string is never taken for data.
192
+ function collectStrings(value, out = new Set()) {
193
+ if (typeof value === 'string') { const s = norm(value); if (s.length >= 2 && s.length <= 400 && HAS_LETTER.test(s)) out.add(s); }
194
+ else if (Array.isArray(value)) for (const v of value) collectStrings(v, out);
195
+ else if (value && typeof value === 'object') for (const v of Object.values(value)) collectStrings(v, out);
196
+ return out;
197
+ }
198
+ const TIME_RE = /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp]\.?[Mm]\.?)?(?![\p{L}\p{N}])/gu;
199
+ const DATE_RE = /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.? \d{1,2}(?:, \d{4})?\b|\b\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+Z?)?\b/g;
200
+ function holeData(text, dataValues) {
201
+ const t = norm(text);
202
+ if (dataValues.has(t)) return { text: HOLE, data: true };
203
+ let out = t;
204
+ const vals = [...dataValues].filter((v) => v.length >= 4 && out.includes(v)).sort((a, b) => b.length - a.length || cmp(a, b));
205
+ for (const v of vals) {
206
+ const re = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(v)}(?=$|[^\\p{L}\\p{N}])`, 'gu');
207
+ out = out.replace(re, (m, pre) => `${pre}${HOLE}`);
208
+ }
209
+ // Clock times and calendar dates are run-time values too, and a stub may stamp
210
+ // its fixtures relative to its own clock, which the page's pinned Date cannot
211
+ // reach — holed, so a re-read of an unchanged page reads the same.
212
+ out = out.replace(TIME_RE, HOLE).replace(DATE_RE, HOLE);
213
+ const residue = out.split(HOLE).join('');
214
+ return { text: out, data: !HAS_LETTER.test(residue) };
215
+ }
216
+
217
+ // ── assembling one page's reading (pure) ─────────────────────────────────────
218
+
219
+ // mergeStates — the default state's strings in page order, then each other
220
+ // state's strings not already there, inserted after the last string the two
221
+ // share, so a panel a state opens reads where it sits on the page.
222
+ function mergeStates(captures) {
223
+ const merged = [];
224
+ const id = (s) => `${s.shared || ''}\u0000${s.section}\u0000${s.text}`;
225
+ captures.forEach(({ state, strings }, si) => {
226
+ let at = 0;
227
+ const seen = new Map();
228
+ for (const s of strings) {
229
+ const k = id(s);
230
+ const n = (seen.get(k) || 0) + 1;
231
+ seen.set(k, n);
232
+ // the nth occurrence of this identity in the merged list, at or after `at`
233
+ let hit = -1, count = 0;
234
+ for (let i = 0; i < merged.length; i += 1) {
235
+ if (id(merged[i]) !== k) continue;
236
+ count += 1;
237
+ if (count === n) { hit = i; break; }
238
+ }
239
+ if (hit >= 0) { at = Math.max(at, hit + 1); continue; }
240
+ merged.splice(at, 0, { ...s, state: si === 0 ? null : state });
241
+ at += 1;
242
+ }
243
+ });
244
+ return merged;
245
+ }
246
+
247
+ // assembleReading — one page's strings (per state, as captured) into its reading.
248
+ function assembleReading(page, captures, matcher, { dataValues = new Set(), surfaceDir = null, root = REPO_ROOT, fsImpl = fs } = {}) {
249
+ const files = page.files || [];
250
+ const placeCache = new Map();
251
+ const place = (t) => {
252
+ if (!placeCache.has(t)) placeCache.set(t, placeText(t, matcher, { files, surfaceDir }));
253
+ return placeCache.get(t);
254
+ };
255
+ const sectionName = (raw, shared) => {
256
+ if (shared) return shared;
257
+ const t = norm(raw);
258
+ if (!t) return 'page top';
259
+ const hit = place(t);
260
+ const text = hit ? hit.text : holeData(t, dataValues).text;
261
+ return text.length > 80 ? `${text.slice(0, 79)}…` : text;
262
+ };
263
+ const merged = mergeStates(captures.map((c) => ({ state: c.state, strings: c.strings.map((s) => ({ ...s, text: norm(s.text), section: norm(s.section) })) })));
264
+ const lines = [];
265
+ const index = new Map();
266
+ let omittedData = 0;
267
+ for (const s of merged) {
268
+ if (!s.text || !HAS_LETTER.test(s.text)) continue; // a number, a glyph, punctuation: nothing to reword
269
+ const section = sectionName(s.section, s.shared);
270
+ const hit = place(s.text);
271
+ let line;
272
+ if (hit) {
273
+ line = { section, text: hit.text, placement: s.shared ? 'shared' : 'placed', file: hit.file, line: hit.line, string_id: hit.string_id };
274
+ } else {
275
+ const held = holeData(s.text, dataValues);
276
+ if (held.data) { omittedData += 1; continue; }
277
+ line = { section, text: held.text, placement: s.shared ? 'shared' : 'unplaced' };
278
+ }
279
+ if (s.attr) line.attr = s.attr;
280
+ if (s.state) line.state = s.state;
281
+ const k = `${line.section}\u0000${line.text}\u0000${line.placement}\u0000${line.string_id || ''}\u0000${line.attr || ''}`;
282
+ if (index.has(k)) { const first = index.get(k); first.occurrences = (first.occurrences || 1) + 1; continue; }
283
+ index.set(k, line);
284
+ lines.push(line);
285
+ }
286
+ const keyed = lines.map((l, i) => ({ key: `L${String(i + 1).padStart(4, '0')}`, ...l }));
287
+ const counts = { lines: keyed.length };
288
+ for (const p of PLACEMENTS) counts[p] = keyed.filter((l) => l.placement === p).length;
289
+ return {
290
+ id: page.id,
291
+ surface: page.surface,
292
+ title: page.title,
293
+ states_read: captures.map((c) => c.state),
294
+ files_hash: filesHash(files, { root, fsImpl }),
295
+ reading_hash: readingHash(keyed),
296
+ counts,
297
+ omitted: { data: omittedData },
298
+ notes: [...new Set(captures.flatMap((c) => c.notes || []))],
299
+ lines: keyed,
300
+ };
301
+ }
302
+
303
+ // ── the artifact ─────────────────────────────────────────────────────────────
304
+
305
+ const HOW = [
306
+ 'Generated by node scripts/gds/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.',
307
+ 'One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.',
308
+ 'placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).',
309
+ 'text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.',
310
+ 'reading_hash is over the non-shared lines (key, section, text); files_hash is over the page\'s inventory files. --check compares files_hash only and never opens a browser.',
311
+ 'Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world\'s page.',
312
+ ];
313
+
314
+ function summaryCounts(pages) {
315
+ const c = { pages: pages.length, lines: 0, placed: 0, shared: 0, unplaced: 0, omitted_data: 0 };
316
+ for (const p of pages) {
317
+ c.lines += p.counts.lines;
318
+ for (const k of PLACEMENTS) c[k] += p.counts[k];
319
+ c.omitted_data += p.omitted.data;
320
+ }
321
+ return c;
322
+ }
323
+
324
+ // One line per reading line, so a re-read of one page is a diff of that page's
325
+ // block, and two branches re-reading different pages merge cleanly.
326
+ function serialize(doc) {
327
+ const out = [];
328
+ out.push('{');
329
+ for (const k of ['schema', 'generator', 'task', 'adr', 'reread', 'how', 'counts']) out.push(` ${JSON.stringify(k)}: ${JSON.stringify(doc[k])},`);
330
+ out.push(' "pages": [');
331
+ doc.pages.forEach((p, pi) => {
332
+ out.push(' {');
333
+ for (const k of ['id', 'surface', 'title', 'states_read', 'files_hash', 'reading_hash', 'counts', 'omitted', 'notes']) out.push(` ${JSON.stringify(k)}: ${JSON.stringify(p[k])},`);
334
+ out.push(' "lines": [');
335
+ p.lines.forEach((l, li) => out.push(` ${JSON.stringify(l)}${li < p.lines.length - 1 ? ',' : ''}`));
336
+ out.push(' ]');
337
+ out.push(` }${pi < doc.pages.length - 1 ? ',' : ''}`);
338
+ });
339
+ out.push(' ]');
340
+ out.push('}');
341
+ return `${out.join('\n')}\n`;
342
+ }
343
+
344
+ function buildDoc(pages) {
345
+ return {
346
+ schema: 1,
347
+ generator: 'scripts/gds/page-reader.js',
348
+ task: 1004315,
349
+ adr: '0341 D3',
350
+ reread: REREAD_COMMAND,
351
+ how: HOW,
352
+ counts: summaryCounts(pages),
353
+ pages,
354
+ };
355
+ }
356
+
357
+ function readJsonFile(rel, root = REPO_ROOT) {
358
+ return JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
359
+ }
360
+
361
+ // checkReadings — the browser-free freshness check (ADR 0341 D3): a page is stale
362
+ // when it has no reading, or its files_hash no longer matches its files. A reading
363
+ // for a page the inventory no longer lists is stale too (an extra).
364
+ function checkReadings(inventory, readings, { root = REPO_ROOT, fsImpl = fs } = {}) {
365
+ const byId = new Map(((readings && readings.pages) || []).map((p) => [p.id, p]));
366
+ const stale = [];
367
+ for (const page of inventory.pages) {
368
+ const r = byId.get(page.id);
369
+ if (!r) { stale.push({ id: page.id, why: 'no reading' }); continue; }
370
+ if (r.files_hash !== filesHash(page.files, { root, fsImpl })) stale.push({ id: page.id, why: 'its files changed' });
371
+ }
372
+ const listed = new Set(inventory.pages.map((p) => p.id));
373
+ for (const id of byId.keys()) if (!listed.has(id)) stale.push({ id, why: 'not in the inventory' });
374
+ return stale;
375
+ }
376
+
377
+ // ── rendering (the browser half; never reached by --check) ───────────────────
378
+
379
+ // Runs IN THE PAGE: every visible string in document order, each with the heading
380
+ // it sits under and, when it is shell text, the shell region. Self-contained (it is
381
+ // serialised into the page).
382
+ function captureInPage() {
383
+ const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
384
+ const main = document.querySelector('main');
385
+ const SHELL = [['#app-topbar', 'top bar'], ['#app-sidebar', 'sidebar'], ['#app-foot', 'footer'], ['a.skip', 'skip link']];
386
+ const sharedOf = (el) => {
387
+ for (const [sel, name] of SHELL) if (el.closest(sel)) return name;
388
+ if (main && main.contains(el)) return null;
389
+ const lm = el.closest('header, footer, nav, [role="banner"], [role="contentinfo"], [role="navigation"]');
390
+ if (!lm || (main && lm.contains(main))) return null;
391
+ const tag = lm.tagName.toLowerCase(), role = lm.getAttribute('role');
392
+ if (tag === 'footer' || role === 'contentinfo') return 'footer';
393
+ if (tag === 'nav' || role === 'navigation') return 'navigation';
394
+ return 'top bar';
395
+ };
396
+ const shown = (el) => {
397
+ if (!el || !el.isConnected) return false;
398
+ if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, opacityProperty: true, visibilityProperty: true })) return false;
399
+ const r = el.getBoundingClientRect();
400
+ return r.width > 1 && r.height > 1; // a 1px box is screen-reader-only text
401
+ };
402
+ const textShown = (node) => {
403
+ const el = node.parentElement;
404
+ const select = el && el.closest('select');
405
+ if (select) return shown(select);
406
+ if (!shown(el)) return false;
407
+ const range = document.createRange();
408
+ range.selectNodeContents(node);
409
+ return [...range.getClientRects()].some((r) => r.width > 1 && r.height > 1);
410
+ };
411
+ const out = [];
412
+ let section = '';
413
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
414
+ acceptNode(n) {
415
+ if (n.nodeType === 1 && /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(n.tagName)) return NodeFilter.FILTER_REJECT;
416
+ return NodeFilter.FILTER_ACCEPT;
417
+ },
418
+ });
419
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
420
+ if (n.nodeType === 1) {
421
+ if (/^H[1-3]$/.test(n.tagName) && shown(n) && !sharedOf(n)) section = clean(n.textContent);
422
+ if ((n.tagName === 'INPUT' || n.tagName === 'TEXTAREA') && shown(n)) {
423
+ const ph = clean(n.getAttribute('placeholder'));
424
+ if (ph) out.push({ text: ph, section, shared: sharedOf(n), attr: 'placeholder' });
425
+ if (n.tagName === 'INPUT' && /^(submit|button|reset)$/i.test(n.type) && clean(n.value)) out.push({ text: clean(n.value), section, shared: sharedOf(n), attr: 'value' });
426
+ }
427
+ continue;
428
+ }
429
+ const t = clean(n.nodeValue);
430
+ if (!t || !textShown(n)) continue;
431
+ out.push({ text: t, section, shared: sharedOf(n.parentElement) });
432
+ }
433
+ let branding = null;
434
+ try { branding = window.__BRANDING__ || null; } catch { branding = null; }
435
+ return { strings: out, branding };
436
+ }
437
+
438
+ // Every render the reader makes for one page: the states of its states files, in
439
+ // file order, or one "default" render at its own path when it has none.
440
+ function planRenders(page, { root = REPO_ROOT } = {}) {
441
+ if (!page.states_files || !page.states_files.length) {
442
+ return [{ page: page.id, state: 'default', url: page.path, auth: page.surface !== 'landing', actions: [], stub: {}, mode: 'light', modeQuery: false }];
443
+ }
444
+ const plans = [];
445
+ const seen = new Set();
446
+ for (const rel of page.states_files) {
447
+ const json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
448
+ const mode = (json.modes && json.modes.includes('light')) || !json.modes ? 'light' : json.modes[0];
449
+ for (const [name, st] of Object.entries(json.states || {})) {
450
+ const state = seen.has(name) ? `${path.basename(rel, '.states.json')}:${name}` : name;
451
+ seen.add(name);
452
+ plans.push({
453
+ page: page.id, state, url: st.url || json.page, auth: !!st.auth, actions: st.actions || [],
454
+ stub: json.stub || {}, mode, modeQuery: json.modeQuery, ignore: json.ignoreRequests || [],
455
+ });
456
+ }
457
+ }
458
+ return plans;
459
+ }
460
+
461
+ const DETACH = process.platform !== 'win32';
462
+ function killTree(child) {
463
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
464
+ try {
465
+ if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
466
+ else process.kill(-child.pid, 'SIGKILL');
467
+ } catch { /* already gone */ }
468
+ }
469
+
470
+ // The hall-preview harness, with the pinned fixture account (--fixture-me), so
471
+ // two runs on two machines read the same hall.
472
+ async function startHarness(kit) {
473
+ const port = await kit.freePort();
474
+ const proc = spawn(process.execPath, [path.join(REPO_ROOT, 'scripts', 'hall-preview', 'server.js'), '--port', String(port), '--fixture-me'], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], detached: DETACH });
475
+ await new Promise((resolve, reject) => {
476
+ let said = '', ready = false;
477
+ const timer = setTimeout(() => { killTree(proc); reject(new Error(`the hall-preview harness did not start within 30s: ${said.trim()}`)); }, 30000);
478
+ const hear = (d) => { if (ready) return; said += d; if (/hall-preview: http/.test(said)) { ready = true; clearTimeout(timer); resolve(); } };
479
+ proc.stdout.on('data', hear); // read for the whole run: an unread pipe fills and stalls the harness
480
+ proc.stderr.on('data', hear);
481
+ proc.on('exit', (code) => { if (!ready) { clearTimeout(timer); reject(new Error(`the hall-preview harness exited ${code}: ${said.trim()}`)); } });
482
+ });
483
+ return { base: `http://localhost:${port}`, stop: () => killTree(proc) };
484
+ }
485
+
486
+ const SURFACE_MODULE = { landing: 'public-landing', status: 'status-ui' };
487
+
488
+ async function renderOne(browser, kit, plan, base) {
489
+ const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1, colorScheme: plan.mode });
490
+ const notes = [];
491
+ const data = new Set();
492
+ const pending = [];
493
+ try {
494
+ if (ctx.clock && typeof ctx.clock.setFixedTime === 'function') await ctx.clock.setFixedTime(new Date(PINNED_CLOCK));
495
+ const page = await ctx.newPage();
496
+ // A states file's click on an element this fixture world never draws would
497
+ // otherwise wait out Playwright's 30s default; the reading notes it and moves on.
498
+ page.setDefaultTimeout(ACTION_TIMEOUT_MS);
499
+ // A native confirm()/alert() a state's click opens would hold the page; the
500
+ // reading is of the page, so the dialog is dismissed.
501
+ page.on('dialog', (d) => { d.dismiss().catch(() => {}); });
502
+ page.on('response', (r) => {
503
+ if (!/\/api\//.test(r.url())) return;
504
+ if (!/json/i.test(r.headers()['content-type'] || '')) return;
505
+ pending.push(r.json().then((j) => collectStrings(j, data)).catch(() => {}));
506
+ });
507
+ const url = kit.withMode(plan.url, plan.mode, plan.modeQuery);
508
+ await page.goto(base + url, { waitUntil: 'load', timeout: 45000 });
509
+ await page.evaluate(() => document.fonts.ready).catch(() => {});
510
+ await page.waitForTimeout(kit.SETTLE_MS + EXTRA_SETTLE_MS);
511
+ try { await kit.runActions(page, plan.actions); } catch (e) { notes.push(`${plan.state}: an action failed (${String(e.message).split('\n')[0].slice(0, 120)}); read as it stood`); }
512
+ // A body read on a response from a page that has since navigated away never
513
+ // settles, so the wait for the data is bounded.
514
+ await Promise.race([Promise.all(pending), new Promise((r) => setTimeout(r, 2000))]);
515
+ const got = await page.evaluate(captureInPage);
516
+ if (got.branding) collectStrings(got.branding, data);
517
+ return { state: plan.state, strings: got.strings, notes, data };
518
+ } catch (e) {
519
+ notes.push(`${plan.state}: not rendered (${String(e.message).split('\n')[0].slice(0, 160)})`);
520
+ return { state: plan.state, strings: [], notes, data };
521
+ } finally {
522
+ await ctx.close().catch(() => {});
523
+ }
524
+ }
525
+
526
+ async function readPages(inventory, ids, { log = console.log, progress = true } = {}) {
527
+ const kit = require('../../modules/ui-design/kit/lib');
528
+ const registry = readJsonFile(REGISTRY_REL);
529
+ const matcher = buildMatcher(registry);
530
+ const pages = inventory.pages.filter((p) => ids.includes(p.id));
531
+ const surfaceDir = new Map(inventory.surfaces.map((s) => [s.id, s.at]));
532
+ const jobs = pages.flatMap((p) => planRenders(p).map((plan, i) => ({ ...plan, order: i })));
533
+ const stubs = new Map();
534
+ const stoppers = [];
535
+ let harness = null;
536
+ let browser;
537
+ try {
538
+ browser = await kit.launchBrowser();
539
+ } catch (e) {
540
+ const err = new Error(`page-reader: ${e.message}`);
541
+ err.noBrowser = e instanceof kit.NoBrowserError;
542
+ throw err;
543
+ }
544
+ const baseFor = async (plan, surface) => {
545
+ if (surface === 'builders') {
546
+ if (!harness) { harness = await startHarness(kit); stoppers.push(harness.stop); }
547
+ return harness.base;
548
+ }
549
+ const opts = { surface: SURFACE_MODULE[surface] || surface, auth: plan.auth, prefix: plan.stub.prefix, child: plan.stub.child, fixtures: plan.stub.fixtures };
550
+ const k = JSON.stringify(opts);
551
+ if (!stubs.has(k)) stubs.set(k, kit.startStub(opts).then((s) => { stoppers.push(s.stop); return s; }));
552
+ return (await stubs.get(k)).base;
553
+ };
554
+ const results = new Map();
555
+ const byId = new Map(pages.map((p) => [p.id, p]));
556
+ try {
557
+ let next = 0, done = 0;
558
+ const worker = async () => {
559
+ while (next < jobs.length) {
560
+ const job = jobs[next];
561
+ next += 1;
562
+ const page = byId.get(job.page);
563
+ const base = await baseFor(job, page.surface);
564
+ const t0 = Date.now();
565
+ // A hard ceiling per render: a page that never settles is a note on its
566
+ // reading, never a run that hangs.
567
+ let timer;
568
+ const r = await Promise.race([
569
+ renderOne(browser, kit, job, base),
570
+ new Promise((resolve) => { timer = setTimeout(() => resolve({ state: job.state, strings: [], notes: [`${job.state}: not rendered (no answer within ${RENDER_CEILING_MS / 1000}s)`], data: new Set() }), RENDER_CEILING_MS); }),
571
+ ]);
572
+ clearTimeout(timer);
573
+ done += 1;
574
+ if (progress) log(` [${done}/${jobs.length}] ${job.page} ${job.state} (${((Date.now() - t0) / 1000).toFixed(1)}s)${r.notes.length ? ' !' : ''}`);
575
+ if (!results.has(job.page)) results.set(job.page, []);
576
+ results.get(job.page).push({ ...r, order: job.order });
577
+ }
578
+ };
579
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, jobs.length) }, worker));
580
+ } finally {
581
+ await browser.close().catch(() => {});
582
+ for (const stop of stoppers) stop();
583
+ }
584
+ const out = [];
585
+ for (const page of pages) {
586
+ const caps = (results.get(page.id) || []).sort((a, b) => a.order - b.order);
587
+ const data = new Set(caps.flatMap((c) => [...c.data]));
588
+ const reading = assembleReading(page, caps, matcher, { dataValues: data, surfaceDir: surfaceDir.get(page.surface) });
589
+ log(` ${page.id}: ${reading.counts.lines} lines (${reading.counts.placed} placed, ${reading.counts.shared} shared, ${reading.counts.unplaced} unplaced) from ${caps.length} state(s)${reading.notes.length ? ` ! ${reading.notes.length} note(s)` : ''}`);
590
+ out.push(reading);
591
+ }
592
+ return out;
593
+ }
594
+
595
+ // Merge fresh readings into the committed ones, in inventory order; a page the
596
+ // inventory no longer lists drops out.
597
+ function mergeReadings(inventory, previous, fresh) {
598
+ const byId = new Map(((previous && previous.pages) || []).map((p) => [p.id, p]));
599
+ for (const r of fresh) byId.set(r.id, r);
600
+ return inventory.pages.map((p) => byId.get(p.id)).filter(Boolean);
601
+ }
602
+
603
+ function loadPrevious() {
604
+ try { return readJsonFile(READINGS_REL); } catch { return null; }
605
+ }
606
+
607
+ async function main(args) {
608
+ const inventory = readJsonFile(INVENTORY_REL);
609
+ if (args.includes('--check')) {
610
+ const stale = checkReadings(inventory, loadPrevious());
611
+ if (stale.length) {
612
+ console.error(`page-reader: STALE — ${stale.length} page reading(s) do not match the tree:`);
613
+ for (const s of stale) console.error(` ✗ ${s.id} (${s.why})`);
614
+ console.error(`Re-read them (opens a browser): ${REREAD_COMMAND}`);
615
+ return 1;
616
+ }
617
+ console.log(`page-reader: all ${inventory.pages.length} page readings match their files.`);
618
+ return 0;
619
+ }
620
+ const i = args.indexOf('--page');
621
+ let ids;
622
+ if (i > -1) {
623
+ ids = String(args[i + 1] || '').split(',').map((s) => s.trim()).filter(Boolean);
624
+ const unknown = ids.filter((id) => !inventory.pages.some((p) => p.id === id));
625
+ if (!ids.length || unknown.length) { console.error(`page-reader: --page needs inventory page ids${unknown.length ? ` (unknown: ${unknown.join(', ')})` : ''}`); return 2; }
626
+ } else if (args.includes('--stale')) {
627
+ ids = [...new Set(checkReadings(inventory, loadPrevious()).map((s) => s.id))].filter((id) => inventory.pages.some((p) => p.id === id));
628
+ if (!ids.length) {
629
+ const prev = loadPrevious();
630
+ const merged = mergeReadings(inventory, prev, []);
631
+ if (prev && merged.length !== ((prev.pages || []).length)) fs.writeFileSync(path.join(REPO_ROOT, READINGS_REL), serialize(buildDoc(merged)));
632
+ console.log('page-reader: nothing stale — no page re-read.');
633
+ return 0;
634
+ }
635
+ } else {
636
+ ids = inventory.pages.map((p) => p.id);
637
+ }
638
+ const started = Date.now();
639
+ console.log(`page-reader: reading ${ids.length} page(s)…`);
640
+ let fresh;
641
+ try { fresh = await readPages(inventory, ids); } catch (e) {
642
+ console.error(e.message);
643
+ return e.noBrowser ? 3 : 1;
644
+ }
645
+ const secs = Math.round((Date.now() - started) / 1000);
646
+ if (args.includes('--print')) {
647
+ for (const r of fresh) {
648
+ console.log(`\n# ${r.id} — ${r.title} (reading ${r.reading_hash}, files ${r.files_hash})`);
649
+ let sec = null;
650
+ for (const l of r.lines) {
651
+ if (l.section !== sec) { sec = l.section; console.log(`## ${sec}`); }
652
+ console.log(` ${l.key} [${l.placement}${l.file ? ` ${l.file}:${l.line}` : ''}${l.state ? ` @${l.state}` : ''}] ${l.text}`);
653
+ }
654
+ for (const n of r.notes) console.log(` ! ${n}`);
655
+ }
656
+ console.log(`\npage-reader: read in ${secs}s; --print writes nothing.`);
657
+ return 0;
658
+ }
659
+ const merged = mergeReadings(inventory, loadPrevious(), fresh);
660
+ fs.writeFileSync(path.join(REPO_ROOT, READINGS_REL), serialize(buildDoc(merged)));
661
+ console.log(`page-reader: ${fresh.length} page(s) read in ${secs}s -> wrote ${READINGS_REL} (${merged.length} readings)`);
662
+ return 0;
663
+ }
664
+
665
+ if (require.main === module) {
666
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(`page-reader: ${e.stack || e.message}`); process.exitCode = 1; });
667
+ }
668
+
669
+ module.exports = {
670
+ INVENTORY_REL, REGISTRY_REL, READINGS_REL, REREAD_COMMAND, PLACEMENTS, HOLE,
671
+ filesHash, readingHash, buildMatcher, placeText, collectStrings, holeData,
672
+ mergeStates, assembleReading, serialize, buildDoc, checkReadings, planRenders,
673
+ captureInPage, mergeReadings,
674
+ };