@bongos/core 1.19.993 → 1.19.995

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,206 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // scripts/gds/render-check.js — render the pages a branch changed and report text wider than its box and
4
+ // overlapping siblings, naming the element and the page (task 1004203, ADR 0329).
5
+ //
6
+ // Why: nothing else in the pipeline renders a page. The grader reads the diff, so a title clipped by character
7
+ // count that still ran past its card (task 1003480) and a second grid child landing on the first (task 1003078)
8
+ // both shipped looking correct in code and were found only by opening a browser.
9
+ //
10
+ // A SIGNAL, never a gate (ADR 0162): /ship-check shows it as an advisory row; ship.js and the grader never
11
+ // read it. The measurement is the kit's `text-fit` + `sibling-overlap` floors (modules/ui-design/kit/lib.js);
12
+ // this file only picks the pages, runs the kit's renderer at 1280 + 390, and keeps those two floors.
13
+ //
14
+ // node scripts/gds/render-check.js the pages this branch changed against origin/main
15
+ // node scripts/gds/render-check.js --base <ref> ... against another base
16
+ // node scripts/gds/render-check.js --files a,b the pages these repo paths reach
17
+ //
18
+ // Exit 0 nothing found or nothing to render · 1 findings · 3 no system browser.
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { spawn, spawnSync } = require('node:child_process');
23
+ const { isUiSurfacePath } = require('../../modules/lifecycle/cascade');
24
+ const kit = require('../../modules/ui-design/kit/lib');
25
+
26
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
27
+ const RENDER_JS = path.join(REPO_ROOT, 'modules', 'ui-design', 'kit', 'render.js');
28
+ const HARNESS_JS = path.join(REPO_ROOT, 'scripts', 'hall-preview', 'server.js');
29
+ const FIT_FLOORS = Object.freeze(['text-fit', 'sibling-overlap']);
30
+ const SIZES = Object.freeze([1280, 390]);
31
+ const MAX_STATES_FILES = 6; // a shared sheet reaches every page; past this the builder runs render.js by hand
32
+ // The whole run fits inside ship-check's 15-minute cap on a slow step, so ship-check never has to kill it
33
+ // (a killed parent cannot stop its harness and renderers). Files that would start past it are listed instead.
34
+ const RUN_BUDGET_MS = 12 * 60 * 1000;
35
+ const MIN_RENDER_MS = 60 * 1000;
36
+ const HARNESS_BOOT_MS = 30 * 1000;
37
+ const rel = (file) => path.relative(REPO_ROOT, file).split(path.sep).join('/');
38
+
39
+ // statesFilesFor — the states files a list of changed repo paths reaches. A page, or a file named like one
40
+ // (goal-map.js), reaches its own <page>.states.json; any other script, sheet or image reaches every page in
41
+ // the same public dir whose HTML names it (text-fit.js → goal-map, goals). Own pages come first, so the
42
+ // MAX_STATES_FILES cut never drops the page the builder changed for pages a shared sheet reaches.
43
+ function statesFilesFor(paths, root = REPO_ROOT) {
44
+ const own = new Set(), reached = new Set();
45
+ for (const p of paths) {
46
+ if (!isUiSurfacePath(p)) continue;
47
+ const dir = p.slice(0, p.indexOf('/public/') + '/public'.length);
48
+ const file = p.slice(dir.length + 1);
49
+ const beside = path.join(root, dir, `${file.replace(/\.[^./]+$/, '')}.states.json`);
50
+ if (!file.includes('/') && fs.existsSync(beside)) { own.add(beside); continue; }
51
+ if (/\.html?$/i.test(file) || !fs.existsSync(path.join(root, dir))) continue;
52
+ const named = new RegExp(`(^|[/"'=\\s])${path.basename(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
53
+ for (const html of fs.readdirSync(path.join(root, dir)).filter((f) => f.endsWith('.html'))) {
54
+ const states = path.join(root, dir, html.replace(/\.html$/, '.states.json'));
55
+ if (fs.existsSync(states) && named.test(fs.readFileSync(path.join(root, dir, html), 'utf8'))) reached.add(states);
56
+ }
57
+ }
58
+ return [...[...own].sort(), ...[...reached].filter((f) => !own.has(f)).sort()];
59
+ }
60
+
61
+ // fitFindings — every element the two fit floors named in one render.js report.json, read from the
62
+ // structured facts (auditFloors' problem line stops at five). One finding per element across all the
63
+ // renders it appeared in, with its largest measure — a title past its card at 1280 and 390 is one finding.
64
+ const MEASURE = / (\d+)px past its (?:box|card)$| (\d+)x(\d+)$/;
65
+ function fitFindings(report) {
66
+ const byElement = new Map();
67
+ for (const [render, r] of Object.entries((report && report.renders) || {})) {
68
+ const facts = (r && r.facts) || {};
69
+ for (const [floor, items] of [['text-fit', facts.textFit], ['sibling-overlap', facts.overlaps]]) {
70
+ for (const item of items || []) {
71
+ const m = MEASURE.exec(item);
72
+ const key = `${floor}: ${m ? item.slice(0, m.index) : item}`;
73
+ const size = m ? (m[1] ? Number(m[1]) : Number(m[2]) * Number(m[3])) : 0;
74
+ if (!byElement.has(key)) byElement.set(key, { key, measure: '', size: -1, renders: [] });
75
+ const f = byElement.get(key);
76
+ f.renders.push(render);
77
+ if (size > f.size) { f.size = size; f.measure = m ? m[0].trim() : ''; }
78
+ }
79
+ }
80
+ }
81
+ return [...byElement.values()].map((f) => ({ page: report.page, problem: f.measure ? `${f.key} — ${f.measure}` : f.key, renders: f.renders }));
82
+ }
83
+
84
+ // Two pages of one name (hall-ui/index, public-landing/index) must never share a shots folder.
85
+ function shotsDir(outRoot, file) {
86
+ return path.join(outRoot, rel(file).replace(/\.states\.json$/, '').replace(/[\\/]/g, '_'));
87
+ }
88
+
89
+ function changedFiles(base) {
90
+ const git = (...args) => spawnSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' });
91
+ const mergeBase = git('merge-base', base, 'HEAD').stdout.trim();
92
+ if (!mergeBase) return null;
93
+ const listed = `${git('diff', '--name-only', mergeBase).stdout}\n${git('ls-files', '--others', '--exclude-standard').stdout}`;
94
+ return [...new Set(listed.split(/\r?\n/).filter(Boolean))];
95
+ }
96
+
97
+ // Kill a child and everything it started (render.js starts two stubs and a browser).
98
+ const DETACH = process.platform !== 'win32';
99
+ function killTree(child) {
100
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
101
+ try {
102
+ if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
103
+ else process.kill(-child.pid, 'SIGKILL');
104
+ } catch { /* already gone */ }
105
+ }
106
+
107
+ // The hall's states files are written against the hall-preview harness (their notes say so): its fixtures
108
+ // carry the populated pages, where the kit's own stub serves the hall its signed-out, empty reads. Its
109
+ // output is read for the whole run, since a pipe left unread fills and stalls it mid-render.
110
+ async function startHarness({ script = HARNESS_JS, bootMs = HARNESS_BOOT_MS } = {}) {
111
+ const port = await kit.freePort();
112
+ const proc = spawn(process.execPath, [script, '--port', String(port), '--fixture-me'], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], detached: DETACH });
113
+ const stop = () => killTree(proc);
114
+ await new Promise((resolve, reject) => {
115
+ let said = '', ready = false;
116
+ const fail = (why) => { clearTimeout(timer); stop(); reject(new Error(`${why}: ${said.trim()}`)); };
117
+ const timer = setTimeout(() => fail(`the hall-preview harness did not start within ${bootMs / 1000}s`), bootMs);
118
+ const hear = (d) => {
119
+ if (ready) return;
120
+ said += d;
121
+ if (/hall-preview: http/.test(said)) { ready = true; clearTimeout(timer); resolve(); }
122
+ };
123
+ proc.stdout.on('data', hear);
124
+ proc.stderr.on('data', hear);
125
+ proc.on('exit', (code) => { if (!ready) fail(`the hall-preview harness exited ${code}`); });
126
+ });
127
+ return { base: `http://localhost:${port}`, pid: proc.pid, stop };
128
+ }
129
+
130
+ // Run a node script without blocking the event loop (the harness's pipes keep draining meanwhile);
131
+ // past timeoutMs the script and everything it started are killed.
132
+ function runNode(args, timeoutMs) {
133
+ return new Promise((resolve) => {
134
+ const child = spawn(process.execPath, args, { cwd: REPO_ROOT, stdio: ['ignore', 'ignore', 'pipe'], detached: DETACH });
135
+ let stderr = '';
136
+ child.stderr.on('data', (d) => { if (stderr.length < 4000) stderr += d; });
137
+ const timer = setTimeout(() => killTree(child), timeoutMs);
138
+ child.on('error', (e) => { clearTimeout(timer); resolve({ status: null, signal: null, stderr: e.message }); });
139
+ child.on('close', (status, signal) => { clearTimeout(timer); resolve({ status, signal, stderr }); });
140
+ });
141
+ }
142
+
143
+ // One states file through the kit's renderer: { findings, note?, noBrowser? }. ONE colour mode (the file's
144
+ // first): the fit floors measure geometry, and the pack's two font stacks are the same in both modes. No
145
+ // reduced-motion pass either — only the fit floors are read. render.js still runs everything when asked.
146
+ async function renderStatesFile(file, { outRoot, harness, timeoutMs }) {
147
+ const json = kit.readJson(file);
148
+ const out = shotsDir(outRoot, file);
149
+ const mode = (json.modes || kit.MODES)[0];
150
+ const args = [RENDER_JS, '--states', file, '--sizes', SIZES.join(','), '--modes', mode, '--no-reduced-motion', '--out', out];
151
+ if (json.surface === 'hall-ui' && harness) args.push('--base-in', harness.base, '--base-out', harness.base);
152
+ const r = await runNode(args, timeoutMs);
153
+ if (r.status === kit.EXIT.NO_BROWSER) return { findings: [], noBrowser: true, note: String(r.stderr || '').trim().split('\n')[0] };
154
+ const reportFile = path.join(out, 'report.json');
155
+ if (r.status === kit.EXIT.USAGE || !fs.existsSync(reportFile)) {
156
+ const why = String(r.stderr || `exit ${r.status}${r.signal ? ` (${r.signal})` : ''}`).trim().split('\n').slice(0, 2).join(' ');
157
+ return { findings: [], note: `${rel(file)}: the renderer produced no report — ${why}` };
158
+ }
159
+ return { findings: fitFindings(kit.readJson(reportFile)), out };
160
+ }
161
+
162
+ async function main() {
163
+ const started = Date.now();
164
+ const args = process.argv.slice(2);
165
+ const explicit = kit.listArg(args, '--files', null);
166
+ const paths = explicit || changedFiles(kit.argOf(args, '--base', 'origin/main'));
167
+ if (!paths) { console.log('render-check: no merge base with origin/main — nothing to compare against, nothing rendered'); return 0; }
168
+ const all = statesFilesFor(paths);
169
+ const uiCount = paths.filter(isUiSurfacePath).length;
170
+ if (!all.length) {
171
+ console.log(uiCount
172
+ ? `render-check: ${uiCount} rendered-surface file(s) changed, but no <page>.states.json reaches them — nothing to measure (docs/recipes/ui-look-before-you-ship.md)`
173
+ : 'render-check: no rendered surface changed — nothing to render');
174
+ return 0;
175
+ }
176
+ const outRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'render-check-'));
177
+ console.log(`render-check — text past its box and overlapping siblings at ${SIZES.join(' + ')} (a signal, never a gate)`);
178
+ let harness = null;
179
+ const findings = [], notes = [], unrendered = all.slice(MAX_STATES_FILES).map((f) => [f, `past ${MAX_STATES_FILES}`]);
180
+ try {
181
+ const files = all.slice(0, MAX_STATES_FILES);
182
+ if (files.some((f) => kit.readJson(f).surface === 'hall-ui')) harness = await startHarness();
183
+ for (const [i, file] of files.entries()) {
184
+ const left = RUN_BUDGET_MS - (Date.now() - started);
185
+ if (left < MIN_RENDER_MS) { unrendered.unshift(...files.slice(i).map((f) => [f, 'out of time'])); break; }
186
+ const res = await renderStatesFile(file, { outRoot, harness, timeoutMs: left });
187
+ if (res.noBrowser) { console.log(`render-check: no system browser, nothing rendered — ${res.note}`); return kit.EXIT.NO_BROWSER; }
188
+ if (res.note) notes.push(res.note);
189
+ findings.push(...res.findings.map((x) => ({ ...x, shots: res.out })));
190
+ console.log(` ${rel(file)}: ${res.findings.length ? `${res.findings.length} finding(s)` : res.note ? 'no report' : 'clean'}${res.out ? ` (shots: ${res.out})` : ''}`);
191
+ }
192
+ } finally {
193
+ if (harness) harness.stop();
194
+ }
195
+ for (const [f, why] of unrendered) console.log(` not rendered (${why}): node modules/ui-design/kit/render.js --states ${rel(f)} --sizes 1280,390`);
196
+ for (const n of notes) console.log(`✗ ${n}`);
197
+ for (const f of findings) console.log(`✗ ${f.page} ${f.problem} [${f.renders.join(', ')}] shots: ${f.shots}`);
198
+ const unmeasured = notes.length ? `; ${notes.length} states file(s) not measured` : '';
199
+ console.log(findings.length || notes.length ? `${findings.length} finding(s)${unmeasured} — look at the shots, then fix the element or say in the ship notes why it is right` : 'nothing past its box, no overlapping siblings');
200
+ return findings.length || notes.length ? 1 : 0;
201
+ }
202
+
203
+ module.exports = { FIT_FLOORS, SIZES, statesFilesFor, fitFindings, shotsDir, startHarness };
204
+ if (require.main === module) {
205
+ main().then((code) => { process.exitCode = code; }, (e) => { console.error(`render-check: ${e.stack || e.message}`); process.exitCode = 1; });
206
+ }
@@ -10,7 +10,9 @@
10
10
  // call with one table. It CHANGES NOTHING: every step is a --check; the "fix" column is what to run.
11
11
  //
12
12
  // Usage:
13
- // node scripts/gds/ship-check.js checks only (fast; no tests)
13
+ // node scripts/gds/ship-check.js checks only (fast; no tests) — plus, when the branch changed a
14
+ // page, an ADVISORY render of it (render-check.js; never flips the exit;
15
+ // minutes on a big page — --no-render skips it)
14
16
  // node scripts/gds/ship-check.js --tests also run the DB-free unit lane (npm test) and list failures
15
17
  // node scripts/gds/ship-check.js --json
16
18
  // Exit 1 when any check is stale/failing, 0 when everything is fresh.
@@ -22,7 +24,9 @@ const path = require('node:path');
22
24
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
23
25
 
24
26
  // Each step: a label, the argv to run (node scripts run with THIS node), the command that heals it,
25
- // and how to read a non-zero exit. `optional` steps are skipped when their script is absent.
27
+ // and how to read a non-zero exit. `optional` steps are skipped when their script is absent. An `advisory`
28
+ // step is a signal, never a gate (ADR 0162): a non-zero exit reads `warn` (3 = no browser reads `skipped`).
29
+ // `onlyWith` names the flag that adds a step; `skipWith` the flag that leaves one out.
26
30
  const STEPS = [
27
31
  { key: 'fitness', label: 'fitness.js (architecture + budgets + freshness gate)', argv: ['scripts/gds/fitness.js'], fix: 'read the ✗ lines above; each names its fix' },
28
32
  { key: 'repo-map', label: 'nested CLAUDE.md symbol blocks fresh', argv: ['scripts/gds/gen-repo-map.js', '--check-block'], fix: 'node scripts/gds/gen-repo-map.js' },
@@ -32,6 +36,7 @@ const STEPS = [
32
36
  { key: 'copy-registry', label: 'docs/copy-registry.json fresh', argv: ['scripts/gds/copy-inventory.js', '--check'], fix: 'node scripts/gds/copy-inventory.js', optional: true },
33
37
  { key: 'linkify', label: 'no bare #NNN task refs in committed markdown', argv: ['scripts/gds/linkify-refs.js', '--check'], fix: 'node scripts/gds/linkify-refs.js', optional: true },
34
38
  { key: 'skill-lint', label: 'SKILL.md frontmatter parses', argv: ['scripts/gds/skill-lint.js'], fix: 'fix the ✗ lines (description: >- block scalars)' },
39
+ { key: 'render-fit', label: 'rendered text fit + sibling overlap on the pages this branch changed (advisory)', argv: ['scripts/gds/render-check.js'], fix: 'look at the shots it names; fix the element, or say in the ship notes why it is right', advisory: true, optional: true, skipWith: '--no-render', slow: true },
35
40
  { key: 'tests', label: 'DB-free unit lane (npm test)', argv: ['scripts/gds/run-unit-tests.js'], fix: 'read the FAIL lines; compare against origin/main before calling any of them environmental', onlyWith: '--tests', slow: true },
36
41
  ];
37
42
 
@@ -42,18 +47,18 @@ function runStep(step, { cwd = REPO_ROOT } = {}) {
42
47
  const r = spawnSync(process.execPath, [script, ...step.argv.slice(1)], { cwd, encoding: 'utf8', timeout: step.slow ? 15 * 60 * 1000 : 5 * 60 * 1000, env: { ...process.env, GDS_QUIET: '1' } });
43
48
  const out = ((r.stdout || '') + (r.stderr || '')).trim().split(/\r?\n/);
44
49
  const failing = out.filter((l) => /^\s*(✗|FAIL|not ok|STALE|✘)/.test(l) || /STALE|is stale|would update|failed/i.test(l));
45
- return { ...step, status: r.status === 0 ? 'ok' : 'fail', code: r.status, signal: r.signal, ms: Date.now() - t0, tail: (failing.length ? failing : out.slice(-4)).slice(0, 12).join('\n') };
50
+ return { ...step, status: r.status === 0 ? 'ok' : !step.advisory ? 'fail' : r.status === 3 ? 'skipped' : 'warn', code: r.status, signal: r.signal, ms: Date.now() - t0, tail: (failing.length ? failing : out.slice(-4)).slice(0, 12).join('\n') };
46
51
  }
47
52
 
48
53
  function summarize(results) {
49
54
  const stale = results.filter((r) => r.status === 'fail' || r.status === 'missing');
50
- return { ok: stale.length === 0, stale: stale.map((r) => r.key), results };
55
+ return { ok: stale.length === 0, stale: stale.map((r) => r.key), warned: results.filter((r) => r.status === 'warn').map((r) => r.key), results };
51
56
  }
52
57
 
53
58
  function format(summary) {
54
59
  const lines = ['ship-check — what ship.js will trip on', ''];
55
60
  for (const r of summary.results) {
56
- const mark = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '·' : '✗';
61
+ const mark = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '·' : r.status === 'warn' ? '!' : '✗';
57
62
  lines.push(`${mark} ${r.label} (${r.ms} ms)`);
58
63
  if (r.status !== 'ok' && r.status !== 'skipped') {
59
64
  for (const t of String(r.tail).split('\n')) lines.push(` ${t}`);
@@ -62,12 +67,13 @@ function format(summary) {
62
67
  }
63
68
  lines.push('');
64
69
  lines.push(summary.ok ? 'all fresh — ship.js preflight should pass; the grader and CI are the remaining gates.' : `${summary.stale.length} stale: ${summary.stale.join(', ')} — heal, commit, re-run.`);
70
+ if (summary.warned.length) lines.push(`advisory (a signal, never a gate): ${summary.warned.join(', ')}`);
65
71
  return lines.join('\n');
66
72
  }
67
73
 
68
74
  function main() {
69
75
  const argv = process.argv.slice(2);
70
- const steps = STEPS.filter((s) => !s.onlyWith || argv.includes(s.onlyWith));
76
+ const steps = STEPS.filter((s) => (!s.onlyWith || argv.includes(s.onlyWith)) && !(s.skipWith && argv.includes(s.skipWith)));
71
77
  const results = steps.map((s) => runStep(s));
72
78
  const summary = summarize(results);
73
79
  console.log(argv.includes('--json') ? JSON.stringify(summary, null, 1) : format(summary));
@@ -31,6 +31,7 @@ function visualReviewNudge(task) {
31
31
  '🎨 This task changes a page people see.',
32
32
  ' Build it with /design, and check it in a real browser at desktop and phone width, light and dark.',
33
33
  ' Before you ship: open the before/after for your human (node scripts/gds/review-sheet.js) and wait for their OK.',
34
+ ' /ship-check renders the pages you changed and names any text past its box or overlapping siblings (a signal, not a gate).',
34
35
  ];
35
36
  }
36
37
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.993'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.995'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,127 @@
1
+ // tests/render_check.mjs — the rendered fit check (task 1004203, ADR 0329): which pages a changed path
2
+ // reaches, which report rows it keeps, and that /ship-check shows it as a signal that never flips its exit
3
+ // (ADR 0162). DB-free and browser-free; the in-page measurement is pinned in tests/ui_design_kit_fit.mjs.
4
+ import assert from 'node:assert/strict';
5
+ import { test } from 'node:test';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { FIT_FLOORS, SIZES, statesFilesFor, fitFindings, shotsDir, startHarness } from '../scripts/gds/render-check.js';
12
+ import { STEPS, runStep, summarize, format } from '../scripts/gds/ship-check.js';
13
+
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const names = (paths) => statesFilesFor(paths, ROOT).map((f) => path.basename(f));
16
+
17
+ test('a changed page or its own script reaches the states file beside it', () => {
18
+ assert.deepEqual(names(['modules/hall-ui/public/goal-map.js']), ['goal-map.states.json']);
19
+ assert.deepEqual(names(['modules/hall-ui/public/goal-map.html']), ['goal-map.states.json']);
20
+ assert.deepEqual(names(['modules/public-landing/public/projects.html']), ['projects.states.json']);
21
+ });
22
+
23
+ test('a shared asset reaches every page in its dir whose HTML names it', () => {
24
+ assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goal-map.states.json', 'goals.states.json']);
25
+ assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['projects.states.json']);
26
+ });
27
+
28
+ test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
29
+ assert.deepEqual(names(['modules/hall-ui/public/text-fit.js', 'modules/hall-ui/public/work.js']), ['work.states.json', 'goal-map.states.json', 'goals.states.json']);
30
+ assert.deepEqual(names(['modules/hall-ui/public/text-fit.js', 'modules/hall-ui/public/goal-map.js']), ['goal-map.states.json', 'goals.states.json'], 'a page reached both ways is listed once, as its own');
31
+ });
32
+
33
+ test('a path that is not a rendered surface, or a page with no states file, reaches nothing', () => {
34
+ assert.deepEqual(names(['src/bongos/db.js', 'modules/hall-ui/public/goal-map.states.json', 'modules/hall-ui/routes/x.js']), []);
35
+ assert.deepEqual(names(['modules/hall-ui/public/deploy.html']), [], 'a page without a states file does not fall back to the pages that link to it');
36
+ });
37
+
38
+ test('fitFindings names every element from the structured facts, once per element across renders, with its largest measure', () => {
39
+ const title = (n, px) => `g.gm-nodes > g.gm-node:nth-of-type(${n}) > text.gm-name "Title ${n}…" ${px}px past its card`;
40
+ const report = {
41
+ page: '/builders/goal-map',
42
+ renders: {
43
+ // seven titles: more than auditFloors' five-item problem line names
44
+ 'dark-1280-map': { facts: { textFit: [1, 2, 3, 4, 5, 6, 7].map((n) => title(n, 10)), overlaps: [] }, problems: ['controls-24px: a.x "Docs" 40x17'] },
45
+ 'dark-390-map': { facts: { textFit: [title(1, 14)], overlaps: ['#mVisibility > div:nth-of-type(1) ∩ #mVisibility > div:nth-of-type(2) 640x48'] } },
46
+ 'dark-390-out': { facts: {} },
47
+ },
48
+ };
49
+ const found = fitFindings(report);
50
+ assert.equal(found.filter((f) => f.problem.startsWith('text-fit: ')).length, 7, 'every element is named, not the first five');
51
+ assert.deepEqual(found[0], {
52
+ page: '/builders/goal-map',
53
+ problem: 'text-fit: g.gm-nodes > g.gm-node:nth-of-type(1) > text.gm-name "Title 1…" — 14px past its card',
54
+ renders: ['dark-1280-map', 'dark-390-map'],
55
+ }, 'one finding across widths, carrying the largest overflow');
56
+ assert.deepEqual(found.at(-1), {
57
+ page: '/builders/goal-map',
58
+ problem: 'sibling-overlap: #mVisibility > div:nth-of-type(1) ∩ #mVisibility > div:nth-of-type(2) — 640x48',
59
+ renders: ['dark-390-map'],
60
+ });
61
+ assert.deepEqual(fitFindings({ page: '/x', renders: {} }), []);
62
+ assert.deepEqual(fitFindings(null), []);
63
+ assert.deepEqual([...FIT_FLOORS], ['text-fit', 'sibling-overlap']);
64
+ assert.deepEqual([...SIZES], [1280, 390]);
65
+ });
66
+
67
+ test('two pages of one name never share a shots folder', () => {
68
+ const out = path.join(os.tmpdir(), 'rc');
69
+ const hall = shotsDir(out, path.join(ROOT, 'modules', 'hall-ui', 'public', 'index.states.json'));
70
+ const landing = shotsDir(out, path.join(ROOT, 'modules', 'public-landing', 'public', 'index.states.json'));
71
+ assert.notEqual(hall, landing);
72
+ assert.equal(path.basename(hall), 'modules_hall-ui_public_index');
73
+ });
74
+
75
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
76
+ const waitDead = async (pid) => { for (let i = 0; i < 50 && alive(pid); i++) await new Promise((r) => setTimeout(r, 100)); return !alive(pid); };
77
+
78
+ test('a harness that never says it is up is killed on the boot timeout, not left holding its port', async () => {
79
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-harness-'));
80
+ const pidFile = path.join(dir, 'pid');
81
+ const script = path.join(dir, 'silent.js');
82
+ fs.writeFileSync(script, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`);
83
+ await assert.rejects(startHarness({ script, bootMs: 1500 }), /did not start within 1\.5s/);
84
+ assert.ok(await waitDead(Number(fs.readFileSync(pidFile, 'utf8'))), 'the silent harness was killed');
85
+ });
86
+
87
+ test('a harness that comes up is handed back with a stop that ends it', async () => {
88
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-harness-'));
89
+ const script = path.join(dir, 'up.js');
90
+ fs.writeFileSync(script, `console.log('hall-preview: http://localhost:' + process.argv[3] + '/'); setInterval(() => {}, 1000);`);
91
+ const harness = await startHarness({ script, bootMs: 5000 });
92
+ assert.match(harness.base, /^http:\/\/localhost:\d+$/);
93
+ assert.ok(alive(harness.pid));
94
+ harness.stop();
95
+ assert.ok(await waitDead(harness.pid), 'stop() ended the harness');
96
+ });
97
+
98
+ test('the CLI renders nothing, and says why, when no page a states file describes changed', () => {
99
+ const cli = (files) => spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'gds', 'render-check.js'), '--files', files], { cwd: ROOT, encoding: 'utf8' });
100
+ const none = cli('src/bongos/db.js');
101
+ assert.equal(none.status, 0);
102
+ assert.match(none.stdout, /no rendered surface changed/);
103
+ const noStates = cli('modules/hall-ui/public/deploy.html');
104
+ assert.equal(noStates.status, 0);
105
+ assert.match(noStates.stdout, /1 rendered-surface file\(s\) changed, but no <page>\.states\.json reaches them/);
106
+ });
107
+
108
+ test('ship-check runs the render check as an advisory row: findings warn, no browser skips, neither flips the exit', () => {
109
+ const step = STEPS.find((s) => s.key === 'render-fit');
110
+ assert.ok(step && step.advisory && step.argv[0] === 'scripts/gds/render-check.js');
111
+ assert.ok(step.optional, 'an absent script is skipped, never counted as stale');
112
+ assert.equal(step.skipWith, '--no-render');
113
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-check-advisory-'));
114
+ const script = (name, code) => { fs.writeFileSync(path.join(dir, name), `console.log('✗ a finding'); process.exit(${code});`); return name; };
115
+ const warn = runStep({ key: 'r', label: 'r', argv: [script('found.js', 1)], fix: 'f', advisory: true }, { cwd: dir });
116
+ const skip = runStep({ key: 's', label: 's', argv: [script('nobrowser.js', 3)], fix: 'f', advisory: true }, { cwd: dir });
117
+ const gate = runStep({ key: 'g', label: 'g', argv: [script('stale.js', 1)], fix: 'f' }, { cwd: dir });
118
+ assert.equal(warn.status, 'warn');
119
+ assert.match(warn.tail, /✗ a finding/);
120
+ assert.equal(skip.status, 'skipped');
121
+ assert.equal(gate.status, 'fail', 'a non-advisory step still fails');
122
+ const advisoryOnly = summarize([warn, skip]);
123
+ assert.equal(advisoryOnly.ok, true);
124
+ assert.deepEqual(advisoryOnly.warned, ['r']);
125
+ assert.match(format(advisoryOnly), /! r[\s\S]*advisory \(a signal, never a gate\): r/);
126
+ assert.equal(summarize([warn, gate]).ok, false);
127
+ });
@@ -27,9 +27,10 @@ const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8
27
27
 
28
28
  // ── the contract ──────────────────────────────────────────────────────────────
29
29
  test('the platform floors are a pinned list — a floor cannot vanish quietly', () => {
30
- assert.deepEqual([...lib.FLOORS], ['console-errors', 'failed-requests', 'horizontal-overflow', 'one-header-main-h1', 'controls-24px', 'top-bar-80px', 'reduced-motion']);
30
+ assert.deepEqual([...lib.FLOORS], ['console-errors', 'failed-requests', 'horizontal-overflow', 'text-fit', 'sibling-overlap', 'one-header-main-h1', 'controls-24px', 'top-bar-80px', 'reduced-motion']);
31
31
  assert.deepEqual(lib.EXIT, { CLEAN: 0, PROBLEMS: 1, USAGE: 2, NO_BROWSER: 3 });
32
- assert.deepEqual(Object.keys(lib.SIZES), ['320', '390', '1440']);
32
+ assert.deepEqual(Object.keys(lib.SIZES), ['320', '390', '1280', '1440']);
33
+ assert.deepEqual([...lib.DEFAULT_SIZES], [1440, 390, 320], 'render.js renders these unless --sizes names 1280');
33
34
  });
34
35
 
35
36
  test('the shipped states files validate and sit beside the page they name', () => {
@@ -95,6 +96,10 @@ test('auditFloors names each floor a render breaks, and says nothing about a cle
95
96
  assert.ok(p.some((x) => x.startsWith('top-bar-80px') && /96px/.test(x)));
96
97
  assert.ok(p.some((x) => /data-mode "light" under a dark scheme/.test(x)));
97
98
  assert.deepEqual(lib.auditFloors({ ...clean(), mode: null }, { mode: 'dark' }), [], 'a page that sets no data-mode is not held to one');
99
+ assert.deepEqual(lib.auditFloors({ ...clean(), textFit: [], overlaps: [] }, { width: 1440, mode: 'dark' }), []);
100
+ const fit = lib.auditFloors({ ...clean(), textFit: ['#t1 "Governance" 15px past its card', 'b', 'c', 'd', 'e', 'f', 'g'], overlaps: ['#mSec > div ∩ #mSec > div 640x48'] }, { width: 1280 });
101
+ assert.ok(fit.includes('text-fit: #t1 "Governance" 15px past its card | b | c | d | e (+2 more)'), fit.join(' / '));
102
+ assert.ok(fit.includes('sibling-overlap: #mSec > div ∩ #mSec > div 640x48'));
98
103
  });
99
104
 
100
105
  test('expectProblems compares every expectation the vocabulary allows', () => {
@@ -0,0 +1,75 @@
1
+ // tests/ui_design_kit_fit.mjs — the kit's two fit floors measured in a real page (task 1004203, ADR 0329):
2
+ // text past its box (HTML, and SVG text past its card) and overlapping text-bearing siblings, with the shapes
3
+ // that clip or layer ON PURPOSE staying silent. Needs the system Edge or Chrome the kit drives; with neither,
4
+ // the test skips — tests/ui_design_kit.mjs pins the pure audit without one.
5
+ import assert from 'node:assert/strict';
6
+ import { test, after } from 'node:test';
7
+ import { createRequire } from 'node:module';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
14
+
15
+ let browser = null;
16
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
17
+ after(() => browser && browser.close());
18
+
19
+ // Each shape the done-when names, beside the look-alikes that must NOT report: the .mSec one-cell grid with a
20
+ // second child (task 1003078), a card title past its rect (task 1003480), ellipsis, a scroller, an absolute
21
+ // overlay, a visually-hidden label, wrapped prose whose soft-wrap space hangs past the line, two wrapped
22
+ // inline siblings (their boxes span lines), and a sticky head over rows scrolled under it.
23
+ const PAGE = `<!doctype html><html><head><style>
24
+ body { margin: 0; font: 16px/1.4 monospace; }
25
+ .box { width: 120px; }
26
+ .nowrap { white-space: nowrap; }
27
+ .cell { display: grid; grid-template-columns: 100px 1fr; width: 400px; }
28
+ .cell > div { grid-column: 2; grid-row: 1; }
29
+ .vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
30
+ </style></head><body><header></header><main><h1>fit</h1>
31
+ <p id="prose" class="box">a paragraph of ordinary words that wraps across several lines inside its box</p>
32
+ <div id="spills" class="box nowrap">averyveryverylongunbreakabletitle</div>
33
+ <div id="ellipsis" class="box nowrap" style="overflow: hidden; text-overflow: ellipsis">averyveryverylongunbreakabletitle</div>
34
+ <div id="scroller" class="box nowrap" style="overflow-x: auto">averyveryverylongunbreakabletitle</div>
35
+ <span class="vh">a visually hidden label far wider than one pixel</span>
36
+ <section id="mSec" class="cell"><h2>Head</h2><div>the first child's text</div><div>a second child's text</div></section>
37
+ <section id="alone" class="cell"><h2>Head</h2><div>the only child</div></section>
38
+ <p id="inline" class="box"><code>the first inline code run</code> then <code>a second inline code run</code></p>
39
+ <div id="rows" style="height: 60px; overflow: auto"><div style="position: sticky; top: 0">a sticky head</div><div>row one</div><div>row two</div><div>row three</div><div>row four</div></div>
40
+ <div id="layer" style="position: relative"><div>the base text</div><div style="position: absolute; top: 0">an overlay</div></div>
41
+ <svg width="400" height="100">
42
+ <g id="fits"><rect width="200" height="40"/><text x="10" y="25">fits</text></g>
43
+ <g id="over" transform="translate(0 50)"><rect width="60" height="40"/><text x="10" y="25">WWWWWWWWWWWW</text></g>
44
+ </svg>
45
+ </main></body></html>`;
46
+
47
+ test('the fit floors report the overflow and the overlap by element, and nothing that clips or layers on purpose', { skip: !browser && 'no system Edge or Chrome' }, async () => {
48
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
49
+ const page = await ctx.newPage();
50
+ await page.setContent(PAGE);
51
+ await page.evaluate(() => { document.getElementById('rows').scrollTop = 30; });
52
+ const facts = await page.evaluate(lib.factsInPage, {});
53
+ await ctx.close();
54
+
55
+ assert.equal(facts.textFit.length, 2, facts.textFit.join('\n'));
56
+ assert.match(facts.textFit.find((f) => f.startsWith('#spills')) || '', /^#spills "averyvery.*" \d+px past its box$/);
57
+ assert.match(facts.textFit.find((f) => f.startsWith('#over')) || '', /^#over > text "WWWW.*" \d+px past its card$/);
58
+ assert.deepEqual(facts.overlaps.map((o) => o.replace(/ \d+x\d+$/, '')), ['#mSec > div:nth-of-type(1) ∩ #mSec > div:nth-of-type(2)']);
59
+
60
+ const problems = lib.auditFloors(facts, { width: 1280 });
61
+ assert.ok(problems.some((p) => p.startsWith('text-fit: ') && p.includes('#spills') && p.includes('#over > text')));
62
+ assert.ok(problems.some((p) => p.startsWith('sibling-overlap: #mSec > div:nth-of-type(1) ∩ #mSec > div:nth-of-type(2)')));
63
+ });
64
+
65
+ test('an overlap among <body>\'s own children is caught too — the one-cell grid one level up', { skip: !browser && 'no system Edge or Chrome' }, async () => {
66
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
67
+ const page = await ctx.newPage();
68
+ await page.setContent(`<!doctype html><html><head><style>
69
+ body { margin: 0; display: grid; grid-template-columns: 1fr; }
70
+ body > div { grid-column: 1; grid-row: 1; }
71
+ </style></head><body><div id="first">the first child's text</div><div id="second">a second child's text</div></body></html>`);
72
+ const facts = await page.evaluate(lib.factsInPage, {});
73
+ await ctx.close();
74
+ assert.deepEqual(facts.overlaps.map((o) => o.replace(/ \d+x\d+$/, '')), ['#first ∩ #second']);
75
+ });