@bongos/core 1.19.396 → 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.
- package/.bongos-core.json +125 -15
- package/.claude/skills/design/SKILL.md +1 -1
- package/docs/file-map.md +1 -1
- package/docs/recipes/ui-look-before-you-ship.md +113 -0
- package/modules/hall-ui/public/ranks.states.json +15 -0
- package/modules/ui-design/kit/README.md +12 -2
- package/modules/ui-design/kit/check-mock.js +147 -0
- package/modules/ui-design/kit/contrast.js +156 -0
- package/modules/ui-design/kit/fixtures/cost-summary.json +1 -0
- package/modules/ui-design/kit/fixtures/featured.json +1 -0
- package/modules/ui-design/kit/fixtures/leaderboard.json +1 -0
- package/modules/ui-design/kit/fixtures/me.json +9 -0
- package/modules/ui-design/kit/fixtures/my-projects.json +1 -0
- package/modules/ui-design/kit/fixtures/progress.json +1 -0
- package/modules/ui-design/kit/fixtures/recent-shipped.json +1 -0
- package/modules/ui-design/kit/lib.js +389 -0
- package/modules/ui-design/kit/png.js +51 -0
- package/modules/ui-design/kit/probe.js +100 -0
- package/modules/ui-design/kit/render.js +137 -0
- package/modules/ui-design/kit/serve.js +178 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/index.probes.json +37 -0
- package/public-landing/index.states.json +16 -0
- package/public-landing/projects.probes.json +79 -0
- package/public-landing/projects.states.json +25 -0
- package/scripts/gds/ui-kit-transform.js +43 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +3 -1
- package/tests/ui_design_kit.mjs +217 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// tests/ui_design_kit.mjs — the ui-design kit (task 1003320, ADR 0197): the platform
|
|
2
|
+
// floors are a pinned contract; a states/probes file has a shape the kit refuses to
|
|
3
|
+
// guess at; the pure audit names what a bad render did; the mock checker keeps the
|
|
4
|
+
// platform's rules apart from an instance's taste bans; the instance's own pack
|
|
5
|
+
// clears the floor; every CLI exits with its documented code on a bad input; and the
|
|
6
|
+
// stub serves a surface through the doorway's real transforms. DB-free and
|
|
7
|
+
// browser-free — the renders themselves are the recipe's job, not CI's.
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const KIT = path.join(ROOT, 'modules', 'ui-design', 'kit');
|
|
20
|
+
const lib = require(path.join(KIT, 'lib.js'));
|
|
21
|
+
const mock = require(path.join(KIT, 'check-mock.js'));
|
|
22
|
+
const contrast = require(path.join(KIT, 'contrast.js'));
|
|
23
|
+
|
|
24
|
+
const run = (file, args, env = {}) => spawnSync(process.execPath, [path.join(KIT, file), ...args], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 60000 });
|
|
25
|
+
const tmp = (name, text) => { const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ui-kit-test-')), name); fs.writeFileSync(p, text); return p; };
|
|
26
|
+
const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
|
|
27
|
+
|
|
28
|
+
// ── the contract ──────────────────────────────────────────────────────────────
|
|
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']);
|
|
31
|
+
assert.deepEqual(lib.EXIT, { CLEAN: 0, PROBLEMS: 1, USAGE: 2, NO_BROWSER: 3 });
|
|
32
|
+
assert.deepEqual(Object.keys(lib.SIZES), ['320', '390', '1440']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('the shipped states files validate and sit beside the page they name', () => {
|
|
36
|
+
for (const rel of ['public-landing/projects.states.json', 'public-landing/index.states.json', 'modules/hall-ui/public/ranks.states.json']) {
|
|
37
|
+
const json = readJson(rel);
|
|
38
|
+
const v = lib.validateStatesFile(json);
|
|
39
|
+
assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
|
|
40
|
+
assert.equal(path.basename(rel), `${lib.pageName(json.page)}.states.json`, `${rel} is named for its page`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the shipped probes files validate', () => {
|
|
45
|
+
for (const rel of ['public-landing/projects.probes.json', 'public-landing/index.probes.json']) {
|
|
46
|
+
const v = lib.validateProbesFile(readJson(rel));
|
|
47
|
+
assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a states file that breaks the contract is refused with the reason named', () => {
|
|
52
|
+
assert.equal(lib.validateStatesFile(null).ok, false);
|
|
53
|
+
const v = lib.validateStatesFile({});
|
|
54
|
+
assert.ok(v.errors.some((e) => e.startsWith('page: required')));
|
|
55
|
+
assert.ok(v.errors.some((e) => e.startsWith('states: required')));
|
|
56
|
+
const bad = lib.validateStatesFile({ page: '/x', states: { 'Bad Name': { actions: [['teleport', '#a']], expect: { glow: true } }, ok: { auth: 'yes' } } });
|
|
57
|
+
assert.ok(bad.errors.some((e) => /kebab-case/.test(e)));
|
|
58
|
+
assert.ok(bad.errors.some((e) => /unknown action "teleport"/.test(e)));
|
|
59
|
+
assert.ok(bad.errors.some((e) => /unknown expectation/.test(e) && /glow/.test(e)));
|
|
60
|
+
assert.ok(bad.errors.some((e) => /states\.ok\.auth/.test(e)));
|
|
61
|
+
const stub = lib.validateStatesFile({ page: '/x', stub: { prefix: 'builders' }, states: { a: {} } });
|
|
62
|
+
assert.ok(stub.errors.some((e) => /stub\.prefix/.test(e)));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a probes file needs a name and an expectation per probe, and cannot continue from nothing', () => {
|
|
66
|
+
const v = lib.validateProbesFile({ page: '/x', probes: [{ continue: true, steps: [] }, { name: 'n', steps: [['wait', 1]] }] });
|
|
67
|
+
assert.ok(v.errors.some((e) => /probes\[0\]\.name: required/.test(e)));
|
|
68
|
+
assert.ok(v.errors.some((e) => /probes\[0\]\.continue: the first probe/.test(e)));
|
|
69
|
+
assert.ok(v.errors.some((e) => /probes\[1\]\.expect: required/.test(e)));
|
|
70
|
+
assert.ok(lib.validateProbesFile({ page: '/x', probes: [{ name: 'n', steps: [], expect: { dialog: false } }] }).ok);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── the pure audit ────────────────────────────────────────────────────────────
|
|
74
|
+
const clean = () => ({ scrollWidth: 1440, clientWidth: 1440, mode: 'dark', headers: 1, mains: 1, h1s: 1, smallControls: [], bar: { h: 64, top: 0 }, expanded: [], dialog: false, running: 0, activeId: '', activeText: '', sel: {} });
|
|
75
|
+
|
|
76
|
+
test('auditFloors names each floor a render breaks, and says nothing about a clean one', () => {
|
|
77
|
+
assert.deepEqual(lib.auditFloors(clean(), { width: 1440, mode: 'dark' }), []);
|
|
78
|
+
const p = lib.auditFloors({ ...clean(), scrollWidth: 1500, h1s: 3, smallControls: ['a.x "Docs" 40x17'], bar: { h: 96 }, mode: 'light' }, { width: 1440, mode: 'dark' });
|
|
79
|
+
assert.ok(p.some((x) => x.startsWith('horizontal-overflow')));
|
|
80
|
+
assert.ok(p.some((x) => x.startsWith('one-header-main-h1') && /h1 3/.test(x)));
|
|
81
|
+
assert.ok(p.some((x) => x.startsWith('controls-24px') && /40x17/.test(x)));
|
|
82
|
+
assert.ok(p.some((x) => x.startsWith('top-bar-80px') && /96px/.test(x)));
|
|
83
|
+
assert.ok(p.some((x) => /data-mode "light" under a dark scheme/.test(x)));
|
|
84
|
+
assert.deepEqual(lib.auditFloors({ ...clean(), mode: null }, { mode: 'dark' }), [], 'a page that sets no data-mode is not held to one');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('expectProblems compares every expectation the vocabulary allows', () => {
|
|
88
|
+
const facts = { ...clean(), expanded: ['projTrig'], dialog: true, sel: { '#a': true, '#b': false }, activeId: 'x', activeText: 'Sky', param: { view: 'mine' }, attr: ['dark'], text: ['Table'], href: ['/docs'], count: [3], checked: false, value: ['demo'], hasClass: [true] };
|
|
89
|
+
assert.deepEqual(lib.expectProblems(facts, { expanded: true, dialog: true, visible: ['#a'], hidden: ['#b'], open: '#a', closed: '#b', activeId: 'x', activeText: 'Sky', expandedIds: ['projTrig'], param: { view: 'mine' }, attr: [['html', 'data-mode', 'dark']], text: [['h1', 'Table']], href: [['a', '/docs']], count: [['li', 3]], value: [['#q', 'demo']], hasClass: [['#w', 'is-open']] }), []);
|
|
90
|
+
const p = lib.expectProblems(facts, { expanded: false, dialog: false, visible: ['#b'], hidden: ['#a'], activeText: 'Table', param: { view: null }, count: [['li', 5]], checked: '#c', hasClass: [['#w', 'is-open', false]], noOverflow: true });
|
|
91
|
+
for (const needle of ['expected no expanded trigger', 'expected no visible dialog', 'expected visible: #b', 'expected hidden: #a', 'active text "Sky", expected "Table"', '?view=mine, expected null', '3 × li, expected 5', '#c is not checked', 'still has class "is-open"']) {
|
|
92
|
+
assert.ok(p.some((x) => x.includes(needle)), `missing: ${needle} in ${p.join(' | ')}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('reducedMotionProblems: a running animation or a rAF loop is a floor break', () => {
|
|
97
|
+
assert.deepEqual(lib.reducedMotionProblems({ running: [], frames: 1 }), []);
|
|
98
|
+
const p = lib.reducedMotionProblems({ running: ['breath'], frames: 40 });
|
|
99
|
+
assert.equal(p.length, 2);
|
|
100
|
+
assert.match(p[0], /breath/); assert.match(p[1], /requestAnimationFrame loop/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the page argument survives Git Bash path conversion of a bare slash', () => {
|
|
104
|
+
assert.equal(lib.normalizePageArg('C:/Program Files/Git/'), '/');
|
|
105
|
+
assert.equal(lib.normalizePageArg('/projects'), '/projects');
|
|
106
|
+
assert.equal(lib.pageName('/'), 'index'); assert.equal(lib.pageName('/builders/ranks'), 'ranks'); assert.equal(lib.pageName('/projects?view=mine'), 'projects');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── the mock checker: platform rules vs the instance's taste ──────────────────
|
|
110
|
+
const GOOD_MOCK = `<!doctype html><html><head><style>
|
|
111
|
+
body{--pad:8px;color:var(--ink);background:var(--bg)}
|
|
112
|
+
:root[data-mode="dark"] body{--pad:9px}
|
|
113
|
+
@media (prefers-color-scheme: dark){:root:not([data-mode="light"]) body{--pad:9px}}
|
|
114
|
+
.a{transition:opacity 200ms}
|
|
115
|
+
</style></head><body><!-- THESIS: a mock that says what it claims, so the reviewer can check the render against the words -->
|
|
116
|
+
<a class="skip" href="#main">Skip</a><header><button aria-expanded="false" aria-controls="m">Menu</button></header>
|
|
117
|
+
<main id="main"><h1>Title</h1><p>Plain copy.</p><div id="m" hidden></div></main>
|
|
118
|
+
<script>const state = new URLSearchParams(location.search).get('state');</script></body></html>`;
|
|
119
|
+
const fails = (results) => results.filter((r) => r[0] === 'FAIL').map((r) => r[1]);
|
|
120
|
+
|
|
121
|
+
test('checkMock: a mock inside the contract passes every platform rule', () => {
|
|
122
|
+
assert.deepEqual(fails(mock.checkMock(GOOD_MOCK)), []);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('checkMock: the platform rules catch a page :root, a literal colour, a pinned pack colour, JS motion', () => {
|
|
126
|
+
const f1 = fails(mock.checkMock(GOOD_MOCK.replace('<style>', '<style>:root{--x:1}')));
|
|
127
|
+
assert.ok(f1.some((r) => r.startsWith('no page-local :root')), f1.join(' | '));
|
|
128
|
+
const f2 = fails(mock.checkMock(GOOD_MOCK.replace('color:var(--ink)', 'color:#ff0000')));
|
|
129
|
+
assert.ok(f2.some((r) => r.startsWith('no literal hex colour')), f2.join(' | '));
|
|
130
|
+
const f3 = fails(mock.checkMock(GOOD_MOCK.replace('color:var(--ink)', 'color:#fb9569'), { packColors: { accent: '#fb9569' } }));
|
|
131
|
+
assert.ok(f3.some((r) => r.startsWith('no pack colour pinned')), f3.join(' | '));
|
|
132
|
+
const f4 = fails(mock.checkMock(GOOD_MOCK.replace('<script>', '<script>requestAnimationFrame(() => {});')));
|
|
133
|
+
assert.ok(f4.some((r) => r.startsWith('no motion in JavaScript')), f4.join(' | '));
|
|
134
|
+
const f5 = fails(mock.checkMock(GOOD_MOCK.replace(':root[data-mode="dark"] body{--pad:9px}', ':root[data-mode="dark"] body{--pad:10px}')));
|
|
135
|
+
assert.ok(f5.some((r) => r.startsWith('every dark rule written twice')), f5.join(' | '));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('checkMock: a taste ban applies only when the instance declared it', () => {
|
|
139
|
+
const dashed = GOOD_MOCK.replace('Plain copy.', 'Plain — copy.');
|
|
140
|
+
const withBan = mock.checkMock(dashed, { bans: new Set(['em-dash']) });
|
|
141
|
+
assert.ok(fails(withBan).some((r) => r.startsWith('taste: no em/en dash')));
|
|
142
|
+
const withoutBan = mock.checkMock(dashed, { bans: new Set() });
|
|
143
|
+
assert.deepEqual(fails(withoutBan), []);
|
|
144
|
+
assert.ok(!withoutBan.some((r) => r[1].startsWith('taste:')), 'no taste rule is even evaluated without a ban');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('readDesignBans reads the Don\'t section — the live DESIGN.md declares the five, an empty file none', () => {
|
|
148
|
+
const live = mock.readDesignBans(fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8'));
|
|
149
|
+
for (const k of ['em-dash', 'emoji', 'gradient-text', 'text-glow', 'box-shadow']) assert.ok(live.has(k), `live DESIGN.md bans ${k}`);
|
|
150
|
+
assert.equal(mock.readDesignBans('').size, 0);
|
|
151
|
+
assert.deepEqual([...mock.readDesignBans("## Do's and Don'ts\n\n### Do\n- Do use an em dash.\n\n### Don't\n- Don't use emoji.\n\n## Next\n")], ['emoji'], 'only the Don\'t section counts');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── the pack ──────────────────────────────────────────────────────────────────
|
|
155
|
+
test('the neutral pack clears the text floor on its own grounds (both pack shapes)', () => {
|
|
156
|
+
const filePack = readJson('config/branding.neutral.json');
|
|
157
|
+
const rows = contrast.packContrast(filePack);
|
|
158
|
+
const onGrounds = rows.filter((r) => r.bg === 'bg' || r.bg === 'bgCard');
|
|
159
|
+
assert.ok(onGrounds.length >= 6);
|
|
160
|
+
for (const r of onGrounds) assert.equal(r.ok, true, `${r.fg} on ${r.bg}: ${r.ratio} (need ${r.need})`);
|
|
161
|
+
const clientShape = { theme: filePack.theme.ui };
|
|
162
|
+
assert.deepEqual(contrast.packContrast(clientShape), rows, 'clientBranding() flattens theme.ui onto theme; same answer');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the WCAG maths: black on white is 21:1, alpha composites before it is measured', () => {
|
|
166
|
+
assert.equal(+lib.contrastRatio(lib.parseColor('#000'), lib.parseColor('#ffffff')).toFixed(1), 21);
|
|
167
|
+
const half = lib.composite(lib.parseColor('rgba(0,0,0,0.5)'), lib.parseColor('#fff'));
|
|
168
|
+
assert.ok(Math.abs(half[0] - 127.5) < 0.01);
|
|
169
|
+
assert.equal(lib.parseColor('nope'), null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── the CLIs: exit codes are the contract a caller can script against ───────────
|
|
173
|
+
test('render.js: usage and a broken states file exit 2 with the reason, before any browser', () => {
|
|
174
|
+
const noArgs = run('render.js', []);
|
|
175
|
+
assert.equal(noArgs.status, lib.EXIT.USAGE); assert.match(noArgs.stderr, /--page/);
|
|
176
|
+
const missing = run('render.js', ['--states', path.join(os.tmpdir(), 'no-such.states.json')]);
|
|
177
|
+
assert.equal(missing.status, lib.EXIT.USAGE); assert.match(missing.stderr, /not found/);
|
|
178
|
+
const bad = run('render.js', ['--states', tmp('x.states.json', JSON.stringify({ page: 'projects', states: { a: { actions: [['fly']] } } }))]);
|
|
179
|
+
assert.equal(bad.status, lib.EXIT.USAGE);
|
|
180
|
+
assert.match(bad.stderr, /page: required/); assert.match(bad.stderr, /unknown action "fly"/);
|
|
181
|
+
const noSidecar = run('render.js', ['--page', '/nowhere']);
|
|
182
|
+
assert.equal(noSidecar.status, lib.EXIT.USAGE); assert.match(noSidecar.stderr, /no states file beside the surface/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('probe.js and check-mock.js: the same discipline', () => {
|
|
186
|
+
const bad = run('probe.js', ['--probes', tmp('x.probes.json', JSON.stringify({ page: '/', probes: [{ name: 'n', steps: [] }] }))]);
|
|
187
|
+
assert.equal(bad.status, lib.EXIT.USAGE); assert.match(bad.stderr, /expect: required/);
|
|
188
|
+
assert.equal(run('check-mock.js', []).status, lib.EXIT.USAGE);
|
|
189
|
+
const failing = run('check-mock.js', ['--file', tmp('m.html', GOOD_MOCK.replace('<style>', '<style>:root{--x:1}')), '--no-taste']);
|
|
190
|
+
assert.equal(failing.status, lib.EXIT.PROBLEMS); assert.match(failing.stdout, /FAIL no page-local :root/);
|
|
191
|
+
const passing = run('check-mock.js', ['--file', tmp('m.html', GOOD_MOCK), '--no-taste']);
|
|
192
|
+
assert.equal(passing.status, lib.EXIT.CLEAN, passing.stdout); assert.match(passing.stdout, /ALL RULES PASS/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('serve.js refuses a surface that does not resolve, and serves one through the doorway transforms', async () => {
|
|
196
|
+
const bad = run('serve.js', [], { SURFACE: 'no-such-surface', STUB_PORT: '0' });
|
|
197
|
+
assert.equal(bad.status, lib.EXIT.USAGE); assert.match(bad.stderr, /neither a directory/);
|
|
198
|
+
for (const auth of [false, true]) {
|
|
199
|
+
const s = await lib.startStub({ surface: 'public-landing', auth });
|
|
200
|
+
try {
|
|
201
|
+
const html = await (await fetch(s.base + '/')).text();
|
|
202
|
+
assert.ok(html.includes('window.__BRANDING__'), 'the pack is injected');
|
|
203
|
+
assert.ok(/:root\s*\{/.test(html), 'the theme :root is spliced');
|
|
204
|
+
assert.ok(/\?v=[0-9a-f]{8}/.test(html), 'assets are content-stamped');
|
|
205
|
+
const me = await fetch(s.base + '/api/bongos/me');
|
|
206
|
+
assert.equal(me.status, auth ? 200 : 401);
|
|
207
|
+
if (auth) { const body = await me.json(); assert.ok(body.builder && body.builder.github_login, 'the real { builder } shape'); assert.match(body.builder.avatar_url, /^http:\/\/127\.0\.0\.1:\d+\/__kit\/avatar\.svg$/); }
|
|
208
|
+
assert.equal((await fetch(s.base + '/api/gds/public/progress')).status, 200, 'the public feed answers from a fixture');
|
|
209
|
+
assert.equal((await fetch(s.base + '/api/gds/nope')).status, 404);
|
|
210
|
+
} finally { s.stop(); }
|
|
211
|
+
}
|
|
212
|
+
const child = await lib.startStub({ surface: 'public-landing', child: true });
|
|
213
|
+
try {
|
|
214
|
+
const html = await (await fetch(child.base + '/')).text();
|
|
215
|
+
assert.equal((html.match(/<!--apex-only:start-->/g) || []).length, 0, 'the child shape strips the apex-only regions');
|
|
216
|
+
} finally { child.stop(); }
|
|
217
|
+
});
|