@bongos/core 1.19.395 → 1.19.397

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.
Files changed (40) hide show
  1. package/.bongos-core.json +139 -24
  2. package/.claude/skills/design/SKILL.md +1 -1
  3. package/clients/bongos-client/index.d.ts +1 -1
  4. package/docs/api/openapi.json +4 -0
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +10 -8
  7. package/docs/copy-registry.json +29 -11
  8. package/docs/file-map.md +1 -1
  9. package/docs/recipes/ui-look-before-you-ship.md +113 -0
  10. package/modules/hall-ui/public/my-projects.css +22 -0
  11. package/modules/hall-ui/public/my-projects.html +7 -1
  12. package/modules/hall-ui/public/my-projects.js +17 -11
  13. package/modules/hall-ui/public/ranks.states.json +15 -0
  14. package/modules/platform-identity/routes/my-projects.js +50 -3
  15. package/modules/ui-design/kit/README.md +12 -2
  16. package/modules/ui-design/kit/check-mock.js +147 -0
  17. package/modules/ui-design/kit/contrast.js +156 -0
  18. package/modules/ui-design/kit/fixtures/cost-summary.json +1 -0
  19. package/modules/ui-design/kit/fixtures/featured.json +1 -0
  20. package/modules/ui-design/kit/fixtures/leaderboard.json +1 -0
  21. package/modules/ui-design/kit/fixtures/me.json +9 -0
  22. package/modules/ui-design/kit/fixtures/my-projects.json +1 -0
  23. package/modules/ui-design/kit/fixtures/progress.json +1 -0
  24. package/modules/ui-design/kit/fixtures/recent-shipped.json +1 -0
  25. package/modules/ui-design/kit/lib.js +389 -0
  26. package/modules/ui-design/kit/png.js +51 -0
  27. package/modules/ui-design/kit/probe.js +100 -0
  28. package/modules/ui-design/kit/render.js +137 -0
  29. package/modules/ui-design/kit/serve.js +178 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/public-landing/index.probes.json +37 -0
  33. package/public-landing/index.states.json +16 -0
  34. package/public-landing/projects.probes.json +79 -0
  35. package/public-landing/projects.states.json +25 -0
  36. package/scripts/gds/ui-kit-transform.js +43 -0
  37. package/src/bongos/module-scope-map.js +1 -0
  38. package/src/module-api.js +5 -1
  39. package/tests/join_application_note.mjs +112 -0
  40. package/tests/ui_design_kit.mjs +217 -0
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+ // lib.js — the ui-design kit's shared floor (task 1003320, ADR 0197): the repo
3
+ // root, the browser launch on the SYSTEM channel, the platform FLOORS every
4
+ // render is audited against, the states-file and probes-file contracts, the
5
+ // action + expectation vocabularies a JSON file may use, the WCAG maths, and the
6
+ // stub spawner. Pure wherever it can be — tests/ui_design_kit.mjs pins the pure
7
+ // parts without a browser. Instance-agnostic: nothing here names a page, a
8
+ // module or a host; a surface describes itself in a JSON file beside it.
9
+ //
10
+ // Exit codes shared by every kit CLI: 0 clean · 1 problems/FAILs · 2 usage or a
11
+ // contract error (a bad file, a missing page) · 3 no browser / no playwright-core.
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const net = require('node:net');
15
+ const { spawn } = require('node:child_process');
16
+
17
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
18
+ const KIT_DIR = __dirname;
19
+ const EXIT = Object.freeze({ CLEAN: 0, PROBLEMS: 1, USAGE: 2, NO_BROWSER: 3 });
20
+
21
+ // The floors (modules/ui-design/CLAUDE.md "The platform's floors"). The names are
22
+ // the contract the test pins; render.js reports a problem under one of them.
23
+ const FLOORS = Object.freeze([
24
+ 'console-errors', // no console.error and no uncaught page error
25
+ 'failed-requests', // no 4xx/5xx response the states file did not declare expected
26
+ 'horizontal-overflow', // documentElement.scrollWidth fits the viewport
27
+ 'one-header-main-h1', // exactly one <header>, one <main>, one <h1>
28
+ 'controls-24px', // every visible control clears 24px on both sides
29
+ 'top-bar-80px', // the page's <header> is at most 80px tall
30
+ 'reduced-motion', // under prefers-reduced-motion nothing runs and no rAF loop
31
+ ]);
32
+
33
+ const SIZES = Object.freeze({ 1440: [1440, 900], 390: [390, 844], 320: [320, 700] });
34
+ const MODES = Object.freeze(['dark', 'light']);
35
+ const CHANNELS = Object.freeze(['msedge', 'chrome']);
36
+ const SETTLE_MS = 900; // fonts + the pages' own boot fetches; the scratchpad value that never flaked
37
+
38
+ // ── argv ──────────────────────────────────────────────────────────────────────
39
+ function argOf(args, flag, dflt) { const i = args.indexOf(flag); return i > -1 && i + 1 < args.length ? args[i + 1] : dflt; }
40
+ function hasFlag(args, flag) { return args.includes(flag); }
41
+ function listArg(args, flag, dflt) { const v = argOf(args, flag, null); return v == null ? dflt : v.split(',').map((s) => s.trim()).filter(Boolean); }
42
+
43
+ // ── pages + surfaces ─────────────────────────────────────────────────────────
44
+ // '/' → index · '/projects' → projects · '/builders/ranks' → ranks · 'x.html' → x
45
+ function pageName(urlPath) {
46
+ const last = String(urlPath || '/').split('?')[0].replace(/\/+$/, '').split('/').pop();
47
+ return (last || 'index').replace(/\.html$/, '');
48
+ }
49
+ // A `--page /` typed in Git Bash on Windows arrives as the MSYS root
50
+ // ("C:/Program Files/Git/") — the shell's path conversion, not the user. Read it
51
+ // back as the root; `MSYS_NO_PATHCONV=1` is the shell-side cure the recipe names.
52
+ function normalizePageArg(page) {
53
+ if (typeof page === 'string' && /^[A-Za-z]:[\\/]/.test(page) && /[\\/]$/.test(page)) return '/';
54
+ return page;
55
+ }
56
+ // A surface is a directory of the checkout: 'public-landing', 'modules/<key>/public',
57
+ // or a module KEY whose manifest declares a webSurfaces dir. Nothing is hardcoded.
58
+ function resolveSurfaceDir(surface, root = REPO_ROOT) {
59
+ if (!surface) return null;
60
+ const direct = path.resolve(root, surface);
61
+ if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) return direct;
62
+ const manifest = path.join(root, 'modules', surface, 'module.json');
63
+ if (fs.existsSync(manifest)) {
64
+ try {
65
+ const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
66
+ const ws = (m.contributes && m.contributes.webSurfaces || [])[0];
67
+ const dir = path.join(root, 'modules', surface, (ws && ws.dir) || 'public');
68
+ if (fs.existsSync(dir)) return dir;
69
+ } catch { /* fall through */ }
70
+ }
71
+ return null;
72
+ }
73
+ // The states/probes file for a page lives BESIDE the surface: <dir>/<page>.states.json.
74
+ function sidecarPath(surfaceDir, urlPath, kind) { return path.join(surfaceDir, `${pageName(urlPath)}.${kind}.json`); }
75
+ function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
76
+
77
+ // ── the action vocabulary (states + probes) ───────────────────────────────────
78
+ const ACTIONS = Object.freeze(['click', 'focus', 'press', 'fill', 'wait', 'clickAt', 'clickIfVisible', 'dispatchClick', 'check', 'back', 'forward']);
79
+ function validateActions(actions, at) {
80
+ if (actions === undefined) return [];
81
+ if (!Array.isArray(actions)) return [`${at}: an array of [op, ...args]`];
82
+ const errors = [];
83
+ actions.forEach((step, i) => {
84
+ const arr = Array.isArray(step) ? step : [step];
85
+ const [op, a, b] = arr;
86
+ if (!ACTIONS.includes(op)) { errors.push(`${at}[${i}]: unknown action "${op}" (one of ${ACTIONS.join(', ')})`); return; }
87
+ if (['click', 'focus', 'fill', 'clickIfVisible', 'dispatchClick', 'check'].includes(op) && typeof a !== 'string') errors.push(`${at}[${i}]: ${op} needs a selector`);
88
+ if (op === 'press' && typeof a !== 'string') errors.push(`${at}[${i}]: press needs a key`);
89
+ if (op === 'wait' && !(Number(a) >= 0)) errors.push(`${at}[${i}]: wait needs milliseconds`);
90
+ if (op === 'clickAt' && !(Number(a) >= 0 && Number(b) >= 0)) errors.push(`${at}[${i}]: clickAt needs x y`);
91
+ });
92
+ return errors;
93
+ }
94
+ async function runActions(page, actions = []) {
95
+ for (const step of actions) {
96
+ const [op, a, b] = Array.isArray(step) ? step : [step];
97
+ switch (op) {
98
+ case 'click': await page.click(a); break;
99
+ case 'focus': await page.focus(a); break;
100
+ case 'press': await page.keyboard.press(a); break;
101
+ case 'fill': await page.fill(a, b == null ? '' : String(b)); break;
102
+ case 'wait': await page.waitForTimeout(Number(a) || 0); break;
103
+ case 'clickAt': await page.mouse.click(Number(a), Number(b)); break;
104
+ case 'clickIfVisible': {
105
+ const vis = await page.evaluate((s) => { const el = document.querySelector(s); return !!el && getComputedStyle(el).display !== 'none' && !el.closest('[hidden]'); }, a);
106
+ if (vis) await page.click(a);
107
+ break;
108
+ }
109
+ case 'dispatchClick':
110
+ await page.evaluate((s) => { const el = document.querySelector(s); if (!el) throw new Error('dispatchClick: nothing matches ' + s); el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); }, a);
111
+ break;
112
+ case 'check':
113
+ await page.evaluate(([s, on]) => { const el = document.querySelector(s); if (!el) throw new Error('check: nothing matches ' + s); el.checked = on !== false; el.dispatchEvent(new Event('change', { bubbles: true })); }, [a, b]);
114
+ break;
115
+ case 'back': await page.goBack(); break;
116
+ case 'forward': await page.goForward(); break;
117
+ default: throw new Error(`unknown action "${op}"`);
118
+ }
119
+ }
120
+ }
121
+
122
+ // ── the expectation vocabulary ────────────────────────────────────────────────
123
+ // A state may EXPECT (render.js) and a probe must EXPECT (probe.js). Selectors are
124
+ // evaluated in the page; scalars compared here. `open`/`visible` = present and not
125
+ // hidden; `closed`/`hidden` = absent or hidden.
126
+ const EXPECT_KEYS = Object.freeze(['expanded', 'dialog', 'visible', 'hidden', 'open', 'closed', 'activeId', 'activeText', 'expandedIds', 'param', 'attr', 'text', 'href', 'count', 'checked', 'value', 'hasClass', 'noOverflow']);
127
+ function validateExpect(expect, at) {
128
+ if (expect === undefined) return [];
129
+ if (!expect || typeof expect !== 'object' || Array.isArray(expect)) return [`${at}: an object`];
130
+ const errors = [];
131
+ for (const k of Object.keys(expect)) if (!EXPECT_KEYS.includes(k)) errors.push(`${at}.${k}: unknown expectation (one of ${EXPECT_KEYS.join(', ')})`);
132
+ for (const k of ['visible', 'hidden']) if (expect[k] !== undefined && !(Array.isArray(expect[k]) && expect[k].every((s) => typeof s === 'string'))) errors.push(`${at}.${k}: an array of selectors`);
133
+ for (const k of ['open', 'closed', 'activeId', 'activeText', 'checked']) if (expect[k] !== undefined && typeof expect[k] !== 'string') errors.push(`${at}.${k}: a string`);
134
+ for (const k of ['expanded', 'dialog', 'noOverflow']) if (expect[k] !== undefined && typeof expect[k] !== 'boolean') errors.push(`${at}.${k}: a boolean`);
135
+ if (expect.expandedIds !== undefined && !Array.isArray(expect.expandedIds)) errors.push(`${at}.expandedIds: an array of ids`);
136
+ if (expect.param !== undefined && (typeof expect.param !== 'object' || Array.isArray(expect.param))) errors.push(`${at}.param: an object of name → value (null = absent)`);
137
+ for (const k of ['attr', 'text', 'href', 'count', 'value', 'hasClass']) if (expect[k] !== undefined && !(Array.isArray(expect[k]) && expect[k].every(Array.isArray))) errors.push(`${at}.${k}: an array of [selector, ...]`);
138
+ return errors;
139
+ }
140
+ // Runs IN THE PAGE: collects the facts the expectations compare against, plus the
141
+ // floor facts every render needs. Self-contained (page.evaluate serialises it).
142
+ function factsInPage(spec) {
143
+ const describe = (el) => el.id ? '#' + el.id : el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.split(' ')[0] : '');
144
+ const shown = (el) => {
145
+ if (!el || el.hidden || el.closest('[hidden]')) return false;
146
+ let a = el;
147
+ while (a && a !== document.body) { const s = getComputedStyle(a); if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false; a = a.parentElement; }
148
+ return true;
149
+ };
150
+ const q = (s) => { try { return document.querySelector(s); } catch { return null; } };
151
+ const qa = (s) => { try { return [...document.querySelectorAll(s)]; } catch { return []; } };
152
+ const de = document.documentElement;
153
+ const out = { scrollWidth: de.scrollWidth, clientWidth: de.clientWidth, mode: de.getAttribute('data-mode') };
154
+ // RENDERED landmarks: a page that keeps one <h1> per view and shows one view at a
155
+ // time has one h1 on stage, which is what the floor means.
156
+ const shownCount = (s) => qa(s).filter(shown).length;
157
+ out.headers = shownCount('header'); out.mains = shownCount('main'); out.h1s = shownCount('h1');
158
+ const bar = document.querySelector('header');
159
+ out.bar = bar ? { h: Math.round(bar.getBoundingClientRect().height), top: Math.round(bar.getBoundingClientRect().top) } : null;
160
+ const ignore = spec.ignoreControls || [];
161
+ const small = [];
162
+ document.querySelectorAll('a[href], button, [role="button"], input, select, textarea, summary').forEach((el) => {
163
+ if (ignore.some((s) => { try { return el.matches(s) || !!el.closest(s); } catch { return false; } })) return;
164
+ const r = el.getBoundingClientRect();
165
+ if ((r.width === 0 && r.height === 0) || !shown(el)) return;
166
+ if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio') && el.closest('label')) return; // the label is the target
167
+ if (r.width < 24 || r.height < 24) small.push(`${describe(el)} "${(el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28)}" ${Math.round(r.width)}x${Math.round(r.height)}`);
168
+ });
169
+ out.smallControls = small;
170
+ out.expanded = [...document.querySelectorAll('[aria-expanded="true"]')].map((el) => el.id || describe(el));
171
+ out.dialog = qa('[role="dialog"], dialog[open]').some(shown);
172
+ out.running = document.getAnimations().filter((a) => a.playState === 'running').length;
173
+ const ae = document.activeElement;
174
+ out.activeId = ae ? ae.id || '' : ''; out.activeText = ae ? (ae.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40) : '';
175
+ const e = spec.expect || {};
176
+ out.sel = {};
177
+ for (const s of [...(e.visible || []), ...(e.hidden || []), ...(e.open ? [e.open] : []), ...(e.closed ? [e.closed] : [])]) out.sel[s] = shown(q(s));
178
+ if (e.param) { const u = new URLSearchParams(location.search); out.param = {}; for (const k of Object.keys(e.param)) out.param[k] = u.get(k); }
179
+ if (e.attr) out.attr = e.attr.map(([s, n]) => { const el = q(s); return el ? el.getAttribute(n) : null; });
180
+ if (e.text) out.text = e.text.map(([s]) => { const el = q(s); return el ? (el.textContent || '').trim() : null; });
181
+ if (e.href) out.href = e.href.map(([s]) => { const el = q(s); return el ? el.getAttribute('href') : null; });
182
+ if (e.count) out.count = e.count.map(([s]) => qa(s).length);
183
+ if (e.checked) { const el = q(e.checked); out.checked = el ? !!el.checked : null; }
184
+ if (e.value) out.value = e.value.map(([s]) => { const el = q(s); return el ? el.value : null; });
185
+ if (e.hasClass) out.hasClass = e.hasClass.map(([s, c]) => { const el = q(s); return el ? el.classList.contains(c) : null; });
186
+ return out;
187
+ }
188
+ // Pure: compare collected facts against an expectation. Returns problem strings.
189
+ function expectProblems(facts, expect = {}) {
190
+ const p = [];
191
+ const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
192
+ if (expect.expanded === true && !facts.expanded.length) p.push('expected an [aria-expanded="true"] trigger');
193
+ if (expect.expanded === false && facts.expanded.length) p.push(`expected no expanded trigger, found ${facts.expanded.join(', ')}`);
194
+ if (expect.dialog === true && !facts.dialog) p.push('expected a visible dialog');
195
+ if (expect.dialog === false && facts.dialog) p.push('expected no visible dialog');
196
+ for (const s of expect.visible || []) if (!facts.sel[s]) p.push(`expected visible: ${s}`);
197
+ for (const s of expect.hidden || []) if (facts.sel[s]) p.push(`expected hidden: ${s}`);
198
+ if (expect.open && !facts.sel[expect.open]) p.push(`expected open: ${expect.open}`);
199
+ if (expect.closed && facts.sel[expect.closed]) p.push(`expected closed: ${expect.closed}`);
200
+ if (expect.activeId !== undefined && facts.activeId !== expect.activeId) p.push(`active element #${facts.activeId || '(none)'}, expected #${expect.activeId}`);
201
+ if (expect.activeText !== undefined && facts.activeText !== expect.activeText) p.push(`active text "${facts.activeText}", expected "${expect.activeText}"`);
202
+ if (expect.expandedIds && !eq([...facts.expanded].sort(), [...expect.expandedIds].sort())) p.push(`expanded [${facts.expanded.join(',')}], expected [${expect.expandedIds.join(',')}]`);
203
+ if (expect.param) for (const [k, v] of Object.entries(expect.param)) if ((facts.param || {})[k] !== v) p.push(`?${k}=${(facts.param || {})[k]}, expected ${v}`);
204
+ (expect.attr || []).forEach(([s, n, v], i) => { if (facts.attr[i] !== v) p.push(`${s}[${n}]="${facts.attr[i]}", expected "${v}"`); });
205
+ (expect.text || []).forEach(([s, v], i) => { if (facts.text[i] !== v) p.push(`${s} text "${facts.text[i]}", expected "${v}"`); });
206
+ (expect.href || []).forEach(([s, v], i) => { if (facts.href[i] !== v) p.push(`${s} href "${facts.href[i]}", expected "${v}"`); });
207
+ (expect.count || []).forEach(([s, n], i) => { if (facts.count[i] !== n) p.push(`${facts.count[i]} × ${s}, expected ${n}`); });
208
+ if (expect.checked !== undefined && facts.checked !== true) p.push(`${expect.checked} is not checked`);
209
+ (expect.value || []).forEach(([s, v], i) => { if (facts.value[i] !== v) p.push(`${s} value "${facts.value[i]}", expected "${v}"`); });
210
+ (expect.hasClass || []).forEach(([s, c, want = true], i) => { if (facts.hasClass[i] !== want) p.push(`${s} ${want ? 'lacks' : 'still has'} class "${c}"`); });
211
+ if (expect.noOverflow && facts.scrollWidth > facts.clientWidth + 1) p.push(`horizontal overflow ${facts.scrollWidth} > ${facts.clientWidth}`);
212
+ return p;
213
+ }
214
+ // Pure: the floor problems in one render. `mode` is the requested colour scheme;
215
+ // a page that carries data-mode must agree with it.
216
+ function auditFloors(facts, { width, mode } = {}) {
217
+ const p = [];
218
+ if (facts.scrollWidth > facts.clientWidth + 1) p.push(`horizontal-overflow: scrollWidth ${facts.scrollWidth} > ${width || facts.clientWidth}`);
219
+ if (facts.headers !== 1 || facts.mains !== 1 || facts.h1s !== 1) p.push(`one-header-main-h1: header ${facts.headers} main ${facts.mains} h1 ${facts.h1s}`);
220
+ if (facts.smallControls && facts.smallControls.length) p.push(`controls-24px: ${facts.smallControls.join(' | ')}`);
221
+ if (facts.bar && facts.bar.h > 80) p.push(`top-bar-80px: header is ${facts.bar.h}px`);
222
+ if (mode && facts.mode && facts.mode !== mode) p.push(`data-mode "${facts.mode}" under a ${mode} scheme`);
223
+ return p;
224
+ }
225
+ function reducedMotionProblems({ running = [], frames = 0 } = {}) {
226
+ const p = [];
227
+ if (running.length) p.push(`reduced-motion: ${running.length} animation(s) still running (${running.slice(0, 5).join(', ')})`);
228
+ if (frames > 2) p.push(`reduced-motion: a requestAnimationFrame loop (${frames} frames in 800ms)`);
229
+ return p;
230
+ }
231
+
232
+ // ── the states file ───────────────────────────────────────────────────────────
233
+ // { "page": "/projects", "surface"?: "public-landing", "stub"?: { prefix?, child? },
234
+ // "modeQuery"?: "mode" | false, "ignoreRequests"?: [regex strings], "ignoreConsole"?: [...],
235
+ // "ignoreControls"?: [selectors], "states": { name: { url?, auth?, actions?, expect? } },
236
+ // "reducedMotion"?: { auth?, url?, actions? } }
237
+ function validateStatesFile(json) {
238
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return { ok: false, errors: ['a states file is a JSON object'] };
239
+ const errors = [];
240
+ if (typeof json.page !== 'string' || !json.page.startsWith('/')) errors.push('page: required — the root-relative path the states render (e.g. "/projects")');
241
+ if (json.surface !== undefined && typeof json.surface !== 'string') errors.push('surface: a string (a checkout dir or a module key)');
242
+ errors.push(...validateStub(json.stub, 'stub'));
243
+ if (json.modeQuery !== undefined && json.modeQuery !== false && typeof json.modeQuery !== 'string') errors.push('modeQuery: the query param the page reads for its colour mode (default "mode"), or false');
244
+ for (const k of ['ignoreRequests', 'ignoreConsole', 'ignoreControls']) if (json[k] !== undefined && !(Array.isArray(json[k]) && json[k].every((s) => typeof s === 'string'))) errors.push(`${k}: an array of strings`);
245
+ const states = json.states;
246
+ if (!states || typeof states !== 'object' || Array.isArray(states) || !Object.keys(states).length) errors.push('states: required — a non-empty object of name → { url?, auth?, actions?, expect? }');
247
+ else {
248
+ for (const [name, s] of Object.entries(states)) {
249
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) errors.push(`states.${name}: names are lowercase kebab-case (they name the screenshots)`);
250
+ if (!s || typeof s !== 'object' || Array.isArray(s)) { errors.push(`states.${name}: an object`); continue; }
251
+ if (s.url !== undefined && (typeof s.url !== 'string' || !s.url.startsWith('/'))) errors.push(`states.${name}.url: a root-relative path`);
252
+ if (s.auth !== undefined && typeof s.auth !== 'boolean') errors.push(`states.${name}.auth: true (signed-in stub) or false`);
253
+ errors.push(...validateActions(s.actions, `states.${name}.actions`));
254
+ errors.push(...validateExpect(s.expect, `states.${name}.expect`));
255
+ }
256
+ }
257
+ if (json.reducedMotion !== undefined) {
258
+ const r = json.reducedMotion;
259
+ if (!r || typeof r !== 'object' || Array.isArray(r)) errors.push('reducedMotion: an object { auth?, url?, actions? }');
260
+ else { if (r.auth !== undefined && typeof r.auth !== 'boolean') errors.push('reducedMotion.auth: boolean'); errors.push(...validateActions(r.actions, 'reducedMotion.actions')); }
261
+ }
262
+ return { ok: !errors.length, errors };
263
+ }
264
+ function validateStub(stub, at) {
265
+ if (stub === undefined) return [];
266
+ if (!stub || typeof stub !== 'object' || Array.isArray(stub)) return [`${at}: an object { prefix?, child? }`];
267
+ const errors = [];
268
+ if (stub.prefix !== undefined && (typeof stub.prefix !== 'string' || !stub.prefix.startsWith('/') || !stub.prefix.endsWith('/'))) errors.push(`${at}.prefix: a URL prefix like "/builders/"`);
269
+ if (stub.child !== undefined && typeof stub.child !== 'boolean') errors.push(`${at}.child: boolean (serve the child-instance shape)`);
270
+ return errors;
271
+ }
272
+
273
+ // ── the probes file ───────────────────────────────────────────────────────────
274
+ // { "page": "/projects", "surface"?, "stub"?, "probes": [ { "name", "auth"?, "width"?,
275
+ // "url"?, "continue"?: true, "steps": [...], "expect": {...} } ] }
276
+ function validateProbesFile(json) {
277
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return { ok: false, errors: ['a probes file is a JSON object'] };
278
+ const errors = [];
279
+ if (typeof json.page !== 'string' || !json.page.startsWith('/')) errors.push('page: required — the root-relative path the probes open');
280
+ errors.push(...validateStub(json.stub, 'stub'));
281
+ if (!Array.isArray(json.probes) || !json.probes.length) errors.push('probes: required — a non-empty array');
282
+ else json.probes.forEach((pr, i) => {
283
+ const at = `probes[${i}]`;
284
+ if (!pr || typeof pr !== 'object') { errors.push(`${at}: an object`); return; }
285
+ if (typeof pr.name !== 'string' || !pr.name) errors.push(`${at}.name: required`);
286
+ if (pr.auth !== undefined && typeof pr.auth !== 'boolean') errors.push(`${at}.auth: boolean`);
287
+ if (pr.width !== undefined && !SIZES[pr.width]) errors.push(`${at}.width: one of ${Object.keys(SIZES).join(', ')}`);
288
+ if (pr.url !== undefined && (typeof pr.url !== 'string' || !pr.url.startsWith('/'))) errors.push(`${at}.url: a root-relative path`);
289
+ if (pr.continue !== undefined && typeof pr.continue !== 'boolean') errors.push(`${at}.continue: boolean (reuse the previous probe's page)`);
290
+ if (pr.continue && i === 0) errors.push(`${at}.continue: the first probe has nothing to continue`);
291
+ errors.push(...validateActions(pr.steps, `${at}.steps`));
292
+ if (!pr.expect || typeof pr.expect !== 'object' || !Object.keys(pr.expect).length) errors.push(`${at}.expect: required — a probe with nothing to expect proves nothing`);
293
+ else errors.push(...validateExpect(pr.expect, `${at}.expect`));
294
+ });
295
+ return { ok: !errors.length, errors };
296
+ }
297
+
298
+ // ── browser ───────────────────────────────────────────────────────────────────
299
+ class NoBrowserError extends Error {}
300
+ async function launchBrowser() {
301
+ let pw;
302
+ try { pw = require('playwright-core'); }
303
+ catch { throw new NoBrowserError('playwright-core is not installed — run `npm install` (a devDependency; the kit never downloads a browser, it drives the system Edge or Chrome).'); }
304
+ const tried = [];
305
+ for (const channel of CHANNELS) {
306
+ try { return await pw.chromium.launch({ channel, headless: true }); }
307
+ catch (e) { tried.push(`${channel}: ${String(e.message).split('\n')[0]}`); }
308
+ }
309
+ throw new NoBrowserError(`no system browser: neither Microsoft Edge nor Google Chrome launched. Install one; the kit does not download browsers. (${tried.join(' | ')})`);
310
+ }
311
+ // Wire the listeners a render needs: console errors, uncaught errors, failed
312
+ // responses — each filtered by the file's ignore lists (regex sources).
313
+ function attachCollectors(page, { ignoreRequests = [], ignoreConsole = [] } = {}) {
314
+ const logs = [];
315
+ const req = ignoreRequests.map((s) => new RegExp(s));
316
+ const con = ignoreConsole.map((s) => new RegExp(s));
317
+ page.on('console', (m) => { if (m.type() === 'error' && !con.some((r) => r.test(m.text()))) logs.push('console-errors: ' + m.text().slice(0, 200)); });
318
+ page.on('pageerror', (e) => logs.push('console-errors: uncaught ' + String(e.message).split('\n')[0]));
319
+ page.on('response', (r) => { if (r.status() >= 400 && !req.some((x) => x.test(r.url()))) logs.push(`failed-requests: ${r.status()} ${r.url()}`); });
320
+ return logs;
321
+ }
322
+ function withMode(url, mode, modeQuery) {
323
+ if (modeQuery === false) return url;
324
+ const q = modeQuery || 'mode';
325
+ return `${url}${url.includes('?') ? '&' : '?'}${q}=${mode}`;
326
+ }
327
+
328
+ // ── the stub spawner ──────────────────────────────────────────────────────────
329
+ function freePort() {
330
+ return new Promise((resolve, reject) => {
331
+ const s = net.createServer();
332
+ s.on('error', reject);
333
+ s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
334
+ });
335
+ }
336
+ async function startStub({ surface, auth = false, port, prefix, child = false } = {}) {
337
+ const p = port || await freePort();
338
+ const env = { ...process.env, STUB_PORT: String(p), AUTH: auth ? '1' : '0', CHILD: child ? '1' : '0' };
339
+ if (surface) env.SURFACE = surface;
340
+ if (prefix) env.PREFIX = prefix;
341
+ const proc = spawn(process.execPath, [path.join(KIT_DIR, 'serve.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
342
+ await new Promise((resolve, reject) => {
343
+ let out = '';
344
+ const timer = setTimeout(() => reject(new Error('the stub did not start within 15s: ' + out.trim())), 15000);
345
+ proc.stdout.on('data', (d) => { out += d; if (/stub on http/.test(out)) { clearTimeout(timer); resolve(); } });
346
+ proc.stderr.on('data', (d) => { out += d; });
347
+ proc.on('exit', (code) => { clearTimeout(timer); reject(new Error(`the stub exited ${code}: ${out.trim()}`)); });
348
+ });
349
+ return { base: `http://127.0.0.1:${p}`, port: p, stop: () => { try { proc.kill(); } catch { /* gone */ } } };
350
+ }
351
+
352
+ // ── WCAG maths (pure) ─────────────────────────────────────────────────────────
353
+ function parseColor(str) {
354
+ const s = String(str || '').trim();
355
+ let m = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+))?\s*\)$/i) || s.match(/^rgba?\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.%]+))?\s*\)$/i);
356
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : (String(m[4]).endsWith('%') ? parseFloat(m[4]) / 100 : +m[4])];
357
+ m = s.match(/^#([0-9a-f]{3,8})$/i);
358
+ if (!m) return null;
359
+ let h = m[1];
360
+ if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join('');
361
+ if (h.length !== 6 && h.length !== 8) return null;
362
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1];
363
+ }
364
+ function composite(fg, bg) { const a = fg[3] == null ? 1 : fg[3]; return [fg[0] * a + bg[0] * (1 - a), fg[1] * a + bg[1] * (1 - a), fg[2] * a + bg[2] * (1 - a), 1]; }
365
+ function luminance(c) { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); }
366
+ function contrastRatio(a, b) { const l1 = luminance(a), l2 = luminance(b); const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; return (hi + 0.05) / (lo + 0.05); }
367
+
368
+ // The instance's resolved branding pack through the doorway — the only core
369
+ // reach the kit makes (task 1003320). Loaded lazily: the pure helpers above never
370
+ // touch it, so the test pins them without booting the doorway.
371
+ function instancePack() {
372
+ const api = require(path.join(REPO_ROOT, 'src', 'module-api.js'));
373
+ return api.clientBranding();
374
+ }
375
+ // The fifteen's colours off a pack in either shape: the FILE shape (theme.ui.colors,
376
+ // config/branding*.json) or the CLIENT shape clientBranding() serves (theme.colors —
377
+ // the ui block flattened onto theme). Same values; the resolver flattens.
378
+ function packColors(pack) {
379
+ const theme = (pack && pack.theme) || {};
380
+ return (theme.ui && theme.ui.colors) || theme.colors || (pack && pack.colors) || {};
381
+ }
382
+
383
+ module.exports = {
384
+ REPO_ROOT, KIT_DIR, EXIT, FLOORS, SIZES, MODES, CHANNELS, SETTLE_MS, ACTIONS, EXPECT_KEYS,
385
+ argOf, hasFlag, listArg, pageName, normalizePageArg, resolveSurfaceDir, sidecarPath, readJson,
386
+ validateActions, runActions, validateExpect, factsInPage, expectProblems, auditFloors, reducedMotionProblems,
387
+ validateStatesFile, validateProbesFile, NoBrowserError, launchBrowser, attachCollectors, withMode,
388
+ freePort, startStub, parseColor, composite, luminance, contrastRatio, instancePack, packColors,
389
+ };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+ // png.js — a minimal PNG decoder (8-bit grey/RGB/RGBA, non-interlaced) → { w, h,
3
+ // data: RGBA Buffer }, so the kit can read the pixels of a screenshot without a
4
+ // dependency. Ported from the apex review pass's glyph probe (task 1003320).
5
+ const zlib = require('node:zlib');
6
+
7
+ function decode(buf) {
8
+ if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
9
+ let off = 8, w = 0, h = 0, bitDepth = 0, colorType = 0, interlace = 0;
10
+ const idat = [];
11
+ while (off < buf.length) {
12
+ const len = buf.readUInt32BE(off);
13
+ const type = buf.toString('ascii', off + 4, off + 8);
14
+ const data = buf.subarray(off + 8, off + 8 + len);
15
+ if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); bitDepth = data[8]; colorType = data[9]; interlace = data[12]; }
16
+ else if (type === 'IDAT') idat.push(data);
17
+ else if (type === 'IEND') break;
18
+ off += 12 + len;
19
+ }
20
+ if (bitDepth !== 8 || interlace !== 0) throw new Error(`unsupported PNG (bit depth ${bitDepth}, interlace ${interlace})`);
21
+ const ch = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : -1;
22
+ if (ch < 0) throw new Error(`unsupported PNG colour type ${colorType}`);
23
+ const raw = zlib.inflateSync(Buffer.concat(idat));
24
+ const stride = w * ch;
25
+ const out = Buffer.alloc(w * h * 4);
26
+ let prev = Buffer.alloc(stride);
27
+ let p = 0;
28
+ for (let y = 0; y < h; y++) {
29
+ const ft = raw[p++];
30
+ const line = Buffer.from(raw.subarray(p, p + stride)); p += stride;
31
+ for (let i = 0; i < stride; i++) {
32
+ const a = i >= ch ? line[i - ch] : 0, b = prev[i], c = i >= ch ? prev[i - ch] : 0;
33
+ let v = line[i];
34
+ if (ft === 1) v = (v + a) & 255;
35
+ else if (ft === 2) v = (v + b) & 255;
36
+ else if (ft === 3) v = (v + ((a + b) >> 1)) & 255;
37
+ else if (ft === 4) { const pp = a + b - c, pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); v = (v + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255; }
38
+ line[i] = v;
39
+ }
40
+ for (let x = 0; x < w; x++) {
41
+ const s = x * ch, d = (y * w + x) * 4;
42
+ if (ch === 1) { out[d] = out[d + 1] = out[d + 2] = line[s]; out[d + 3] = 255; }
43
+ else { out[d] = line[s]; out[d + 1] = line[s + 1]; out[d + 2] = line[s + 2]; out[d + 3] = ch === 4 ? line[s + 3] : 255; }
44
+ }
45
+ prev = line;
46
+ }
47
+ return { w, h, data: out };
48
+ }
49
+ const px = (img, x, y) => { const d = (y * img.w + x) * 4; return [img.data[d], img.data[d + 1], img.data[d + 2]]; };
50
+
51
+ module.exports = { decode, px };
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // probe.js — interaction contracts as PASS/FAIL lines (task 1003320, ADR 0197).
4
+ // A probes file beside the surface (<dir>/<page>.probes.json) lists probes: each
5
+ // opens the page (signed in or out, at a width), drives steps from the shared
6
+ // action vocabulary, then states what must be true (the expectation vocabulary
7
+ // in lib.js). The disclosure contract ships as the first: Enter opens on the
8
+ // first item, arrows cycle and skip hidden cells, Escape returns focus, Tab out
9
+ // closes, a click outside closes, one closes the other; the fold under 480; a
10
+ // dialog yields Escape; signed out nothing throws.
11
+ //
12
+ // node modules/ui-design/kit/probe.js --page /projects [--surface <dir|key>] [--probes <file>] [--only <substring>]
13
+ // --base-out <url> --base-in <url> use running stubs instead of starting two
14
+ //
15
+ // A probe with `continue: true` keeps the previous probe's page (a contract is a
16
+ // sequence). An uncaught page error during a probe fails it. Exit 0 ALL PASS ·
17
+ // 1 FAIL(s) · 2 usage/contract · 3 no browser.
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const lib = require('./lib.js');
21
+
22
+ function usage(msg) {
23
+ process.stderr.write(`probe: ${msg}\n node modules/ui-design/kit/probe.js --page /projects [--surface <dir|key>] [--probes <file>] [--only <substring>] [--base-out <url> --base-in <url>]\n`);
24
+ process.exit(lib.EXIT.USAGE);
25
+ }
26
+ function locate(args) {
27
+ const explicit = lib.argOf(args, '--probes', null);
28
+ let file = explicit && path.resolve(explicit);
29
+ if (!file) {
30
+ const page = lib.normalizePageArg(lib.argOf(args, '--page', null));
31
+ if (!page) usage('--page </path> or --probes <file> is required');
32
+ const dir = lib.resolveSurfaceDir(lib.argOf(args, '--surface', 'public-landing'));
33
+ if (!dir) usage(`--surface does not resolve to a directory`);
34
+ file = lib.sidecarPath(dir, page, 'probes');
35
+ }
36
+ if (!fs.existsSync(file)) usage(`probes file not found: ${file}`);
37
+ let json;
38
+ try { json = lib.readJson(file); } catch (e) { usage(`probes file is not JSON: ${e.message}`); }
39
+ const v = lib.validateProbesFile(json);
40
+ if (!v.ok) usage(`${path.relative(lib.REPO_ROOT, file)} breaks the probes contract:\n - ${v.errors.join('\n - ')}`);
41
+ const surface = json.surface || lib.argOf(args, '--surface', null) || path.relative(lib.REPO_ROOT, path.dirname(file)).split(path.sep).join('/');
42
+ return { file, json, surface };
43
+ }
44
+
45
+ async function main() {
46
+ const args = process.argv.slice(2);
47
+ if (lib.hasFlag(args, '--help')) usage('');
48
+ const { json, surface } = locate(args);
49
+ const only = lib.argOf(args, '--only', null);
50
+ const probes = json.probes.filter((p) => !only || p.name.includes(only));
51
+ if (!probes.length) usage('--only matches no probe');
52
+
53
+ const stubs = [];
54
+ let baseOut = lib.argOf(args, '--base-out', null), baseIn = lib.argOf(args, '--base-in', null);
55
+ const stubOpts = { surface, prefix: json.stub && json.stub.prefix, child: json.stub && json.stub.child };
56
+ if (!baseOut) { const s = await lib.startStub({ ...stubOpts, auth: false }); stubs.push(s); baseOut = s.base; }
57
+ if (!baseIn) { const s = await lib.startStub({ ...stubOpts, auth: true }); stubs.push(s); baseIn = s.base; }
58
+ const stop = () => stubs.forEach((s) => s.stop());
59
+ let browser;
60
+ try { browser = await lib.launchBrowser(); }
61
+ catch (e) { stop(); process.stderr.write(`probe: ${e.message}\n`); process.exit(e instanceof lib.NoBrowserError ? lib.EXIT.NO_BROWSER : lib.EXIT.USAGE); }
62
+
63
+ const results = [];
64
+ const check = (name, problems) => { results.push([name, !problems.length]); process.stdout.write(`${problems.length ? 'FAIL' : 'PASS'} ${name}${problems.length ? ' (' + problems.join('; ') + ')' : ''}\n`); };
65
+ let ctx = null, page = null, errors = [];
66
+ try {
67
+ for (const pr of probes) {
68
+ if (!(pr.continue && page)) {
69
+ if (ctx) await ctx.close();
70
+ const [W, H] = lib.SIZES[pr.width || 1440];
71
+ ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
72
+ page = await ctx.newPage();
73
+ errors = [];
74
+ page.on('pageerror', (e) => errors.push(String(e.message).split('\n')[0]));
75
+ await page.goto((pr.auth ? baseIn : baseOut) + (pr.url || json.page), { waitUntil: 'load' });
76
+ await page.evaluate(() => document.fonts.ready);
77
+ await page.waitForTimeout(lib.SETTLE_MS);
78
+ } else {
79
+ errors.length = 0;
80
+ }
81
+ const problems = [];
82
+ try { await lib.runActions(page, pr.steps); } catch (e) { problems.push('step failed: ' + String(e.message).split('\n')[0]); }
83
+ await page.waitForTimeout(120);
84
+ let facts = null;
85
+ try { facts = await page.evaluate(lib.factsInPage, { expect: pr.expect }); } catch (e) { problems.push('facts failed: ' + String(e.message).split('\n')[0]); }
86
+ if (facts) problems.push(...lib.expectProblems(facts, pr.expect));
87
+ if (errors.length) problems.push('page error: ' + errors.join(' | '));
88
+ check(pr.name, problems);
89
+ }
90
+ } finally {
91
+ if (ctx) await ctx.close();
92
+ await browser.close();
93
+ stop();
94
+ }
95
+ const fails = results.filter((r) => !r[1]).length;
96
+ process.stdout.write(`\n${results.length} probe(s): ${fails ? `${fails} FAIL(S)` : 'ALL PASS'}\n`);
97
+ process.exit(fails ? lib.EXIT.PROBLEMS : lib.EXIT.CLEAN);
98
+ }
99
+
100
+ main().catch((e) => { process.stderr.write(`probe: ${e.stack || e.message}\n`); process.exit(lib.EXIT.USAGE); });