@bongos/core 1.19.458 → 1.19.460

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 (50) hide show
  1. package/.bongos-core.json +117 -42
  2. package/.claude/skills/design/SKILL.md +10 -0
  3. package/docs/adr/0219-a-look-is-a-branding-pack-the-style-library.md +30 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/branding-contract.md +1 -1
  6. package/docs/copy-inventory.md +52 -61
  7. package/docs/copy-registry.json +92 -203
  8. package/docs/file-map.md +1 -1
  9. package/docs/module-api-changelog.md +4 -0
  10. package/docs/recipes/ui-look-before-you-ship.md +2 -0
  11. package/modules/hall-ui/public/collab-lib.js +40 -4
  12. package/modules/hall-ui/public/collab.css +18 -34
  13. package/modules/hall-ui/public/collab.html +17 -17
  14. package/modules/hall-ui/public/collab.js +75 -39
  15. package/modules/hall-ui/public/collab.states.json +2 -2
  16. package/modules/hall-ui/public/copy-desk.css +64 -109
  17. package/modules/hall-ui/public/copy-desk.html +76 -69
  18. package/modules/hall-ui/public/copy-desk.js +175 -80
  19. package/modules/hall-ui/public/copy-desk.states.json +59 -2
  20. package/modules/hall-ui/public/oversight.css +11 -2
  21. package/modules/lifecycle/help-requests.js +18 -8
  22. package/modules/ui-design/kit/README.md +7 -5
  23. package/modules/ui-design/kit/check-mock.js +12 -5
  24. package/modules/ui-design/kit/contrast.js +13 -6
  25. package/modules/ui-design/kit/lib.js +39 -2
  26. package/modules/ui-design/kit/probe.js +4 -1
  27. package/modules/ui-design/kit/render.js +7 -4
  28. package/modules/ui-design/kit/serve.js +9 -2
  29. package/modules/ui-design/styles/README.md +54 -2
  30. package/modules/ui-design/styles/chrome-world/DESIGN.md +56 -0
  31. package/modules/ui-design/styles/chrome-world/mock.html +275 -0
  32. package/modules/ui-design/styles/chrome-world/mock.states.json +11 -0
  33. package/modules/ui-design/styles/chrome-world/pack.json +33 -0
  34. package/modules/ui-design/styles/expedition/DESIGN.md +141 -0
  35. package/modules/ui-design/styles/expedition/mock.html +285 -0
  36. package/modules/ui-design/styles/expedition/mock.states.json +11 -0
  37. package/modules/ui-design/styles/expedition/pack.json +33 -0
  38. package/modules/ui-design/styles/grove/DESIGN.md +148 -0
  39. package/modules/ui-design/styles/grove/mock.html +294 -0
  40. package/modules/ui-design/styles/grove/mock.states.json +11 -0
  41. package/modules/ui-design/styles/grove/pack.json +33 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/src/module-api.js +1 -1
  45. package/tests/collab_page.mjs +13 -7
  46. package/tests/cosmos_token_contrast.mjs +9 -2
  47. package/tests/hall_collab_world.mjs +214 -0
  48. package/tests/hall_tokens.mjs +15 -6
  49. package/tests/ui_design_styles.mjs +218 -0
  50. package/tests/world_tokens.mjs +9 -2
@@ -0,0 +1,214 @@
1
+ // tests/hall_collab_world.mjs — the collaboration pages in the chrome world
2
+ // (task 1003471, goal 1000081): collab and the copy desk, redesigned on the
3
+ // hall's row archetype (oversight.css) rather than re-skinned. Static pins over
4
+ // the shipped source: the family sheet is linked and the page sheets spend only
5
+ // the fifteen, every list is the family's row and the card-in-a-card is gone,
6
+ // state is colour and never opacity, the facts the audit found thrown away (who
7
+ // is asking; the duplicate total; the surface count) are rendered, the server
8
+ // names the asker, the dialogs are the family modal, the lists that grow are
9
+ // paged through the kit, and the harness carries what the pages read.
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
18
+ const FIX = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
19
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8').replace(/\r\n/g, '\n');
20
+ const readRoot = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8').replace(/\r\n/g, '\n');
21
+ const PAGES = ['collab', 'copy-desk'];
22
+
23
+ // collab-lib.js is pure, so RUN it rather than grep it.
24
+ function lib() {
25
+ const win = {};
26
+ new Function('window', read('collab-lib.js'))(win);
27
+ return win.OTBCollabLib;
28
+ }
29
+
30
+ test('both pages link the family sheet, before their own', () => {
31
+ for (const p of PAGES) {
32
+ const html = read(`${p}.html`);
33
+ const fam = html.indexOf('/builders/oversight.css');
34
+ const own = html.indexOf(`/builders/${p}.css`);
35
+ assert.ok(fam > 0, `${p}.html links oversight.css`);
36
+ assert.ok(own > fam, `${p}.html links its own sheet after the family's`);
37
+ }
38
+ });
39
+
40
+ test('the page sheets spend only the fifteen, in px, and never opacity', () => {
41
+ for (const p of PAGES) {
42
+ const css = read(`${p}.css`);
43
+ const hexes = css.match(/#[0-9a-f]{3,8}\b/gi) || [];
44
+ assert.deepEqual(hexes, [], `${p}.css carries literal colours: ${hexes.join(', ')}`);
45
+ assert.ok(!/\d(\.\d+)?rem\b/.test(css), `${p}.css sizes in px plus tokens, not rem`);
46
+ assert.ok(!/opacity\s*:/.test(css), `${p}.css carries state as colour, never opacity`);
47
+ assert.ok(!/border-left/.test(css), `${p}.css paints no coloured band`);
48
+ }
49
+ // The one literal the copy desk keeps: the native dialog's scrim.
50
+ const cd = read('copy-desk.css');
51
+ const literals = cd.match(/rgba?\([^)]*\)/g) || [];
52
+ assert.deepEqual(literals, ['rgba(0, 0, 0, 0.55)'], 'black with alpha as a scrim, and nothing else');
53
+ assert.match(cd, /\.cd-dialog::backdrop \{ background: rgba\(0, 0, 0, 0\.55\); \}/, 'and it is spent on ::backdrop');
54
+ });
55
+
56
+ test('every list is the family\'s row, and the card-in-a-card is gone', () => {
57
+ for (const p of PAGES) assert.match(read(`${p}.js`), /class="ov-row/, `${p}.js renders .ov-row`);
58
+ // A class name, whole: `cd-stat` must not match the `cd-status` filter id.
59
+ const gone = (src, cls) => !new RegExp(`${cls}(?![a-z])`).test(src);
60
+ for (const old of ['cb-item', 'cb-list', 'cb-ctx']) {
61
+ assert.ok(gone(read('collab.js'), old) && gone(read('collab.css'), `\\.${old}`), `collab no longer emits ${old}`);
62
+ }
63
+ for (const old of ['cd-card', 'cd-stat', 'cd-tag', 'cd-filters__select', 'cd-browse', 'cd-empty']) {
64
+ assert.ok(gone(read('copy-desk.js'), old) && gone(read('copy-desk.css'), `\\.${old}`), `copy desk no longer emits ${old}`);
65
+ }
66
+ for (const p of PAGES) assert.match(read(`${p}.html`), /class="empty-state"/, `${p}.html empties with the kit's own state`);
67
+ });
68
+
69
+ // ---- collab: the facts the audit found thrown away ---------------------------
70
+ test('collab renders WHO IS ASKING, as a door to their profile', () => {
71
+ const L = lib();
72
+ assert.equal(L.askerOf({ requested_by: 92, requested_by_name: 'Preview Gamma', requested_by_login: 'preview-gamma' }), 'Preview Gamma');
73
+ assert.equal(L.askerOf({ requested_by: 92, requested_by_login: 'preview-gamma' }), 'preview-gamma', 'the login when there is no display name');
74
+ assert.equal(L.askerOf({ requested_by: 92 }), 'builder 92', 'an older server\'s bare id is still a name, never nothing');
75
+ const js = read('collab.js');
76
+ assert.match(js, /L\.askerOf\(req\)/, 'the row asks the lib who is asking');
77
+ assert.match(js, /\/builders\/profile\?id=\$\{escapeHtml\(String\(req\.requested_by\)\)\}/, 'and links the hall\'s per-builder profile');
78
+ assert.match(js, /asked by \$\{who\}/, 'as a fact on the row');
79
+ });
80
+
81
+ test('the server names the people on a row through a join, and the writes stay bare', () => {
82
+ const src = readRoot('modules', 'lifecycle', 'help-requests.js');
83
+ assert.match(src, /requested_by_login, a\.display_name AS requested_by_name/, 'the asker is joined');
84
+ assert.match(src, /needs_builder_login, n\.display_name AS needs_builder_name/, 'and the by-name addressee');
85
+ assert.match(src, /LEFT JOIN builders a ON a\.id = h\.requested_by/, 'a LEFT join, so a row never vanishes with its builder');
86
+ for (const fn of ['listOpenForBuilder', 'listMine']) {
87
+ const body = src.slice(src.indexOf(`async function ${fn}`));
88
+ assert.match(body.slice(0, 700), /SELECT \$\{LIST_COLS\} \$\{LIST_FROM\}/, `${fn} reads the joined columns`);
89
+ }
90
+ const upd = src.slice(src.indexOf('async function setHelpRequestStatus'));
91
+ assert.match(upd.slice(0, 600), /RETURNING \$\{COLS\}/, 'the UPDATE cannot reach a joined alias, so it returns the bare columns');
92
+ });
93
+
94
+ test('the first line is decided: the ask\'s first sentence is the title, the rest the sub, no word lost', () => {
95
+ const L = lib();
96
+ const two = L.titleOf('The record exits to the board. I cannot tell which link does it.');
97
+ assert.deepEqual(two, { title: 'The record exits to the board.', rest: 'I cannot tell which link does it.' });
98
+ const one = L.titleOf('Is there a safe order of operations?');
99
+ assert.deepEqual(one, { title: 'Is there a safe order of operations?', rest: '' }, 'one sentence is the whole title');
100
+ const long = 'A'.repeat(150) + '. Then more.';
101
+ assert.equal(L.titleOf(long).title, long, 'a first sentence too long to be a line keeps the whole ask together');
102
+ assert.equal(L.titleOf('docs/x.md is stale and y.js too').title, 'docs/x.md is stale and y.js too', 'a dot inside a path is not a sentence break');
103
+ const t = L.titleOf(' padded. and trailing ');
104
+ assert.equal(`${t.title} ${t.rest}`, 'padded. and trailing', 'nothing is clipped');
105
+ const js = read('collab.js');
106
+ assert.match(js, /L\.titleOf\(req\.what_is_stuck\)/);
107
+ assert.match(js, /class="ov-row__title">\$\{escapeHtml\(t\.title\)\}/);
108
+ assert.match(js, /class="ov-row__sub">\$\{escapeHtml\(t\.rest\)\}/);
109
+ });
110
+
111
+ test('"asked of you" reads in runs — by name first, then each craft', () => {
112
+ const L = lib();
113
+ assert.equal(L.groupOf({ needs_builder_id: 95 }).id, 'you');
114
+ assert.equal(L.groupOf({ needs_craft: 'artist' }).label, 'asked of artists');
115
+ assert.match(L.addressedTo({ needs_builder_id: 7, needs_builder_name: 'Preview Alpha' }, 9), /Preview Alpha by name/, 'a named addressee is named');
116
+ const js = read('collab.js');
117
+ assert.match(js, /ov-group__head/, 'each run has the family\'s group head');
118
+ assert.match(js, /Number\(b\.id === 'you'\) - Number\(a\.id === 'you'\)/, 'and the personal run sorts first');
119
+ });
120
+
121
+ test('the age is a state pill in the row\'s right column, and every band has its own colour', () => {
122
+ const css = read('collab.css');
123
+ const L = lib();
124
+ for (const b of L.BANDS) assert.match(css, new RegExp(`^\\.cb-age--${b.id}\\s*\\{[^}]*color: var\\(--`, 'm'), `${b.id} has its own colour rule`);
125
+ assert.match(css, /\.cb-age--stale\s*\{[^}]*var\(--danger\)/, 'stale is the danger ink');
126
+ assert.match(css, /\.cb-age--aging\s*\{[^}]*var\(--warn\)/, 'aging is the warn ink');
127
+ const js = read('collab.js');
128
+ assert.match(js, /class="fact-pill cb-age cb-age--\$\{band\.id\}"/, 'the pill is the shell\'s fact-pill');
129
+ assert.match(js, /<div class="ov-row__right">\$\{ageChip\(req, nowMs\)\}/, 'in the right column, so a glance down the list reads the ages');
130
+ });
131
+
132
+ // ---- the copy desk: quieter, and no fact lost ----------------------------------
133
+ test('the copy desk\'s counts are the shell\'s glass strip, carrying two facts the boxes dropped', () => {
134
+ assert.match(read('copy-desk.html'), /class="stat-row ov-summary cd-summary" id="cd-summary-scroll"/);
135
+ const js = read('copy-desk.js');
136
+ assert.match(js, /stat-tile__num/, 'the figure is the shell\'s tile');
137
+ assert.match(js, /of \$\{int\(c\.duplicates_total\)\} said more than once/, 'the duplicate total was never shown before');
138
+ assert.match(js, /across \$\{int\(surfaces\)\} surface/, 'nor the surface count');
139
+ assert.ok(!/cd-stat__n/.test(js), 'the bordered box is gone');
140
+ });
141
+
142
+ test('state is colour from the verification vocabulary, never opacity', () => {
143
+ const js = read('copy-desk.js');
144
+ assert.match(js, /pill\('fact-pill--open', 'already flagged'\)/);
145
+ assert.match(js, /pill\('fact-pill--ok', 'looked at'\)/);
146
+ assert.match(js, /pill\('', 'untouched'\)/, 'the untouched row is the plain pill: colour marks the exception in a list that is mostly work');
147
+ assert.match(js, /pill\('fact-pill--warn', 'target gone'/);
148
+ assert.match(js, /f\.status === 'resolved' \? 'fact-pill--ok' : 'fact-pill--quiet'/);
149
+ });
150
+
151
+ test('the filters and the search are the kit\'s bar; the duplicates are paged through the kit', () => {
152
+ const js = read('copy-desk.js');
153
+ assert.equal((js.match(/kit\.makeFilterBar\(\{/g) || []).length, 2, 'two bars: the queue\'s filters and the search');
154
+ for (const id of ['cd-status', 'cd-surface', 'cd-search']) assert.match(js, new RegExp(`id: '${id}'`), `${id} is a kit control`);
155
+ assert.match(js, /kit\.makePager\('cd-dupes'/);
156
+ assert.match(js, /kit\.paginate\(ordered, dupePager\)/);
157
+ assert.match(js, /kit\.wirePager\(dupePager, rerender\)/);
158
+ assert.match(read('copy-desk.html'), /src="\/builders\/hall-kit\.js/, 'the page loads the kit');
159
+ });
160
+
161
+ test('the two dialogs are the family modal on a native <dialog>, with the number input in a field', () => {
162
+ const html = read('copy-desk.html');
163
+ assert.equal((html.match(/<dialog id="cd-(close-)?dialog" class="ov-modal cd-dialog">/g) || []).length, 2);
164
+ assert.match(html, /<div class="ov-field ov-field--wide">\s*<label class="ov-field__l" for="cd-close-task">[^<]*<\/label>\s*<input type="number" id="cd-close-task"/, 'the number input sits in the family field, which restates the 30px pill');
165
+ assert.ok(!/<label[^>]*>[^<]*<span class="cd-dialog__hint"/.test(html), 'no hint lives inside a label (the label voice would uppercase it)');
166
+ assert.equal((html.match(/class="ov-form__why"/g) || []).length, 3, 'the three hints are the form\'s own explainer line');
167
+ assert.match(html, /class="btn-accent" id="cd-submit"/, 'the accent door files the flag');
168
+ });
169
+
170
+ test('the copy desk renders MORE facts per row than any other page, and lost none', () => {
171
+ const js = read('copy-desk.js');
172
+ for (const fact of ['flagged_by_name', 'flagged_by_login', 'flagged_at', 'resolution_note', 'resolved_task_id',
173
+ 'text_at_flag', 'file_at_flag', 'line_at_flag', 'f.current.file', 'f.current.line', 'f.moved', 'target_state',
174
+ 'd.at', 'd.occurrences', 'd.surface', 'e.origin', 'e.file', 'truncated', 'blind_spots', 'freshness_check', 'r.at']) {
175
+ assert.ok(js.includes(fact), `${fact} is still rendered`);
176
+ }
177
+ assert.match(js, /said, when flagged:/, 'the snapshot is still labelled as the snapshot');
178
+ assert.match(js, /whole surface/, 'a surface-scope flag still says so');
179
+ });
180
+
181
+ test('the closing floor is the pack\'s rank label, not a Greek name in the markup', () => {
182
+ assert.ok(!/Metic\+/.test(read('copy-desk.html')), 'no "Metic+" in the lede');
183
+ assert.match(read('copy-desk.html'), /<span id="cd-close-rank">/);
184
+ assert.match(read('copy-desk.js'), /rankLabel\('metic'\)/);
185
+ });
186
+
187
+ // ---- the harness carries what the pages read ------------------------------------
188
+ test('the harness fixtures exist and carry the shapes the pages read', () => {
189
+ const fx = (n) => JSON.parse(fs.readFileSync(path.join(FIX, `${n}.json`), 'utf8'));
190
+ const forMe = fx('help-requests__for-me');
191
+ assert.ok(Array.isArray(forMe.help_requests) && forMe.help_requests.length >= 4, 'four asks, one per band');
192
+ assert.ok(forMe.help_requests.every((r) => r.requested_by_name && r.what_is_stuck && r.created_at), 'each names its asker');
193
+ assert.ok(forMe.help_requests.some((r) => r.needs_builder_id != null), 'one is by name');
194
+ assert.deepEqual(forMe.crafts, ['builder']);
195
+ const mine = fx('help-requests__mine');
196
+ assert.ok(mine.help_requests.length >= 2 && mine.help_requests.every((r) => String(r.requested_by) === '95'), 'the viewer\'s own (me.json is builder 95)');
197
+ const q = fx('copy-desk__queue');
198
+ assert.ok(q.flags.some((f) => f.target_state === 'gone') && q.flags.some((f) => f.moved) && q.flags.some((f) => f.scope === 'surface'), 'a gone target, a moved one, a whole-surface one');
199
+ assert.ok(q.flags.some((f) => f.status === 'resolved') && q.flags.some((f) => f.status === 'dismissed'), 'and both closed outcomes');
200
+ assert.ok(q.duplicates.length >= 8 && q.duplicates.every((d) => Array.isArray(d.at) && d.at.length === d.occurrences), 'real duplicates with their places');
201
+ assert.ok(q.registry.at && q.registry.freshness_check && q.registry.counts && q.registry.surfaces, 'the registry block the note reads');
202
+ const s = fx('copy-desk__strings');
203
+ assert.ok(Array.isArray(s.entries) && s.entries.length > 0 && typeof s.matched === 'number' && 'truncated' in s && 'limit' in s);
204
+ });
205
+
206
+ test('the states files walk the new states', () => {
207
+ const cd = JSON.parse(read('copy-desk.states.json'));
208
+ assert.deepEqual(Object.keys(cd.states), ['queue', 'out', 'search', 'composer', 'closer']);
209
+ assert.deepEqual(cd.states.search.actions[1], ['fill', '#cd-search', 'save']);
210
+ assert.deepEqual(cd.states.composer.actions[1], ['clickIfVisible', '.cd-flag-btn'], 'clickIfVisible: against the kit\'s stub the button does not exist');
211
+ assert.deepEqual(cd.states.closer.actions[1], ['clickIfVisible', '.cd-close-btn']);
212
+ const cb = JSON.parse(read('collab.states.json'));
213
+ assert.deepEqual(cb.states.board.expect.visible, ['#cb-for-me']);
214
+ });
@@ -86,9 +86,15 @@ const darkMedia = declarations(blockAfter('html:not([data-theme]) {'));
86
86
  // Equal specificity, later in document order, so for the tokens the pack emits
87
87
  // the PACK wins and style.css's own value never renders. html[data-theme="dark"]
88
88
  // is (0,1,1) and wins back.
89
- const packUi = JSON.parse(
89
+ // UI_DESIGN_PACK=<pack.json> measures the sheet under a look from the style library
90
+ // (tests/ui_design_styles.mjs runs this file once per pack, task 1003323); the DRIFT test
91
+ // below always compares the sheet against the neutral pack, which is what it must equal.
92
+ const neutralUi = JSON.parse(
90
93
  fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'),
91
94
  ).theme.ui;
95
+ const packUi = process.env.UI_DESIGN_PACK
96
+ ? JSON.parse(fs.readFileSync(path.resolve(process.env.UI_DESIGN_PACK), 'utf8')).theme.ui
97
+ : neutralUi;
92
98
 
93
99
  // mirrors COLOR_VARS in src/bongos/serve-internal.js
94
100
  const camelToVar = (k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
@@ -166,12 +172,13 @@ test('style.css defaults do not drift from the branding pack', () => {
166
172
  // always overrides it. That makes silent drift easy and dangerous: anyone
167
173
  // auditing the sheet alone would measure colours no builder ever sees.
168
174
  const drift = [];
169
- for (const [name, value] of Object.entries(PACK)) {
175
+ const NEUTRAL = Object.fromEntries(Object.entries(neutralUi.colors).map(([k, v]) => [camelToVar(k), v]));
176
+ for (const [name, value] of Object.entries(NEUTRAL)) {
170
177
  if (contractTier[name] !== value) drift.push(`${name}: sheet ${contractTier[name]} vs pack ${value}`);
171
178
  }
172
179
  for (const [key, cssVar] of [['display', '--font-display'], ['body', '--font-body']]) {
173
- if (contractTier[cssVar] !== packUi.fonts[key]) {
174
- drift.push(`${cssVar}: sheet ${contractTier[cssVar]} vs pack ${packUi.fonts[key]}`);
180
+ if (contractTier[cssVar] !== neutralUi.fonts[key]) {
181
+ drift.push(`${cssVar}: sheet ${contractTier[cssVar]} vs pack ${neutralUi.fonts[key]}`);
175
182
  }
176
183
  }
177
184
  assert.deepEqual(drift, [], 'the contract tier must equal config/branding.neutral.json theme.ui');
@@ -273,8 +280,10 @@ test('each state reads against its own weak fill', () => {
273
280
 
274
281
  test('the contrast probe would CATCH the values that failed', () => {
275
282
  // The shipped-before values, measured against the world's paper. If this ever
276
- // passes, the probe has stopped measuring what it claims to.
277
- const bg = paint('--bg', 'light');
283
+ // passes, the probe has stopped measuring what it claims to. The NEUTRAL paper on
284
+ // purpose: this calibrates the probe, and a look's own ground (UI_DESIGN_PACK) is
285
+ // measured by the pair tests above, not by this one.
286
+ const bg = resolve(neutralUi.colors.bg, 'light');
278
287
  for (const [name, hex] of [['ok', '#1a7f4b'], ['warn', '#9a6700']]) {
279
288
  const r = contrast(resolve(hex, 'light'), bg);
280
289
  assert.ok(r < 4.5, `the retired ${name} ${hex} must still measure under AA here (got ${r.toFixed(2)}:1)`);
@@ -0,0 +1,218 @@
1
+ // tests/ui_design_styles.mjs — the ui-design module's style library (task 1003323, ADR 0219)
2
+ // is a pinned contract, not a folder of ideas: every style dir carries a pack (exactly the
3
+ // fifteen), a DESIGN.md whose frontmatter holds the same thirteen value for value, and a
4
+ // specimen mock that passes the platform's rules under its own colours; the chrome world
5
+ // entry IS the neutral pack; every pack clears the pack floor; the three served sheets
6
+ // (world.css, cosmos.css, the hall's style.css) clear AA under every alternate in both modes
7
+ // (their own suites run once per pack through UI_DESIGN_PACK, and a bad pack is proven to
8
+ // fail them); the stub serves a page under a style with the look's fifteen spliced in and the
9
+ // instance's identity untouched; the CLIs refuse an unknown style; and the doors (the styles
10
+ // README, the module CLAUDE.md, /design) name every look. DB-free; the one browser-free stub
11
+ // round-trip is the same the kit test already makes.
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { listStyles, resolveStyle, styleBrandingFile, packColors, startStub, STYLES_DIR, EXIT } from '../modules/ui-design/kit/lib.js';
20
+ import { packContrast, PACK_PAIRS } from '../modules/ui-design/kit/contrast.js';
21
+ import { checkMock, readDesignBans } from '../modules/ui-design/kit/check-mock.js';
22
+
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const KIT = path.join(ROOT, 'modules', 'ui-design', 'kit');
25
+ const read = (p) => fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
26
+ const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'));
27
+ const neutralUi = readJson(path.join(ROOT, 'config', 'branding.neutral.json')).theme.ui;
28
+ const THIRTEEN = Object.keys(neutralUi.colors).sort();
29
+ const camelToKebab = (k) => k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
30
+ const styles = listStyles().map((name) => resolveStyle(name));
31
+ const alternates = styles.filter((s) => s.name !== 'chrome-world');
32
+ // a child `node --test` must not inherit NODE_TEST_CONTEXT, or it reports to this runner's
33
+ // channel instead of its own stdout (an empty stdout and exit 0: a silent pass)
34
+ const childEnv = (extra = {}) => { const e = { ...process.env, ...extra }; delete e.NODE_TEST_CONTEXT; return e; };
35
+ const run = (file, args, env = {}) => spawnSync(process.execPath, [path.join(KIT, file), ...args], { encoding: 'utf8', env: childEnv(env), timeout: 60000 });
36
+
37
+ // The `colors:` block of a DESIGN.md frontmatter → { 'bg': '#…', … } (quoted scalars only;
38
+ // comments and the nested keys of other blocks are skipped).
39
+ function frontmatterColors(md) {
40
+ const fm = md.match(/^---\n([\s\S]*?)\n---\n/);
41
+ assert.ok(fm, 'DESIGN.md opens with a frontmatter block');
42
+ const lines = fm[1].split('\n');
43
+ const start = lines.findIndex((l) => /^colors:\s*$/.test(l));
44
+ assert.ok(start >= 0, 'the frontmatter carries a colors: block');
45
+ const out = {};
46
+ for (const line of lines.slice(start + 1)) {
47
+ if (/^[a-z]/.test(line)) break;
48
+ const m = line.match(/^\s+([a-z][a-z0-9-]*):\s*"([^"]*)"/);
49
+ if (m) out[m[1]] = m[2];
50
+ }
51
+ return out;
52
+ }
53
+
54
+ // ── the catalogue ─────────────────────────────────────────────────────────────
55
+ test('the catalogue: the chrome world plus at least two alternates, each dir carrying pack.json, DESIGN.md and mock.html', () => {
56
+ assert.ok(styles.some((s) => s.name === 'chrome-world'), 'the chrome world is the first entry');
57
+ assert.ok(alternates.length >= 2, `at least two alternates (found ${alternates.map((s) => s.name).join(', ') || 'none'})`);
58
+ for (const s of styles) {
59
+ for (const f of ['pack.json', 'DESIGN.md', 'mock.html']) assert.ok(fs.existsSync(path.join(s.dir, f)), `${s.name}: ${f}`);
60
+ assert.equal(s.name, path.basename(s.dir), `${s.name}: pack.json names its own dir`);
61
+ assert.match(s.name, /^[a-z0-9][a-z0-9-]*$/, `${s.name}: kebab-case`);
62
+ }
63
+ });
64
+
65
+ test('a pack is exactly the fifteen: the thirteen colour keys of the neutral pack, the two font stacks and a links line, with a title, a summary and an origin', () => {
66
+ for (const s of styles) {
67
+ const ui = s.pack.theme && s.pack.theme.ui;
68
+ assert.ok(ui && ui.colors && ui.fonts, `${s.name}: theme.ui.colors + theme.ui.fonts`);
69
+ assert.deepEqual(Object.keys(ui.colors).sort(), THIRTEEN, `${s.name}: exactly the thirteen`);
70
+ for (const [k, v] of Object.entries(ui.colors)) assert.match(v, /^#[0-9a-f]{6}$/i, `${s.name}: ${k} is a plain hex (the sheets do hex maths on the contract tier)`);
71
+ assert.ok(typeof ui.fonts.display === 'string' && ui.fonts.display && typeof ui.fonts.body === 'string' && ui.fonts.body, `${s.name}: both font stacks`);
72
+ assert.ok(Array.isArray(ui.fonts.links) && ui.fonts.links.length && ui.fonts.links.every((l) => /^https:\/\//.test(l)), `${s.name}: fonts.links carries the face stylesheet(s)`);
73
+ assert.deepEqual(Object.keys(ui).sort(), ['colors', 'fonts'], `${s.name}: a look sets the fifteen and nothing else (favicon and backdrop stay the instance's)`);
74
+ for (const k of ['title', 'summary', 'origin']) assert.ok(typeof s.pack[k] === 'string' && s.pack[k].length > 8, `${s.name}: ${k}`);
75
+ }
76
+ });
77
+
78
+ test('the chrome world pack IS the neutral pack, and every alternate is a different look', () => {
79
+ const chrome = styles.find((s) => s.name === 'chrome-world');
80
+ assert.deepEqual(chrome.pack.theme.ui.colors, neutralUi.colors, 'chrome-world colours = config/branding.neutral.json theme.ui.colors');
81
+ assert.equal(chrome.pack.theme.ui.fonts.display, neutralUi.fonts.display);
82
+ assert.equal(chrome.pack.theme.ui.fonts.body, neutralUi.fonts.body);
83
+ assert.deepEqual(chrome.pack.theme.ui.fonts.links, neutralUi.fonts.links);
84
+ for (const s of alternates) {
85
+ assert.notEqual(s.pack.theme.ui.colors.accent.toLowerCase(), neutralUi.colors.accent.toLowerCase(), `${s.name}: a look has its own accent`);
86
+ assert.notEqual(s.pack.theme.ui.fonts.display, neutralUi.fonts.display, `${s.name}: a look has its own display face`);
87
+ }
88
+ });
89
+
90
+ test('every DESIGN.md frontmatter carries its pack\'s thirteen, value for value, and its prose names the look\'s adoption path', () => {
91
+ for (const s of styles) {
92
+ const md = read(path.join(s.dir, 'DESIGN.md'));
93
+ const colors = frontmatterColors(md);
94
+ for (const [k, v] of Object.entries(s.pack.theme.ui.colors)) {
95
+ assert.equal((colors[camelToKebab(k)] || '').toLowerCase(), v.toLowerCase(), `${s.name}: DESIGN.md ${camelToKebab(k)} = pack ${k}`);
96
+ }
97
+ assert.match(md, /config\/branding\.json/, `${s.name}: DESIGN.md says how an instance adopts the look`);
98
+ }
99
+ });
100
+
101
+ // ── the floors ────────────────────────────────────────────────────────────────
102
+ test('every pack clears the pack floor: every ink on every ground at 4.5:1', () => {
103
+ for (const s of styles) {
104
+ const rows = packContrast(s.pack);
105
+ assert.equal(rows.length, PACK_PAIRS.length);
106
+ for (const r of rows) assert.equal(r.ok, true, `${s.name}: ${r.fg} on ${r.bg} = ${r.ratio} (need ${r.need})`);
107
+ }
108
+ });
109
+
110
+ test('every mock passes the platform rules under its own colours and its own DESIGN.md bans, is stamped by the transform, and declares no colour of its own', () => {
111
+ for (const s of styles) {
112
+ const html = read(path.join(s.dir, 'mock.html'));
113
+ const bans = readDesignBans(read(path.join(s.dir, 'DESIGN.md')));
114
+ const results = checkMock(html, { bans, packColors: packColors(s.pack) });
115
+ const fails = results.filter((r) => r[0] === 'FAIL').map((r) => `${r[1]} (${r[2]})`);
116
+ assert.deepEqual(fails, [], `${s.name}: mock.html`);
117
+ assert.ok(results.some((r) => r[1].startsWith('every dark rule written twice') && r[0] === 'PASS'), `${s.name}: the mock carries a dark tier, written twice`);
118
+ assert.ok(html.includes('{{productName}}'), `${s.name}: the mock carries the identity token the transform stamps`);
119
+ assert.ok(bans.has('em-dash') && bans.has('emoji'), `${s.name}: the look's DESIGN.md declares the copy bans`);
120
+ }
121
+ });
122
+
123
+ test('the world, hub and hall sheets clear AA under every alternate in both modes: the three token suites run once per pack', () => {
124
+ // relative, forward-slash paths: `node --test <path>` reads its positionals as globs, and a
125
+ // Windows absolute path matches nothing (zero tests, exit 0 — a silent pass)
126
+ const suites = ['tests/world_tokens.mjs', 'tests/cosmos_token_contrast.mjs', 'tests/hall_tokens.mjs'];
127
+ for (const s of alternates) {
128
+ const r = spawnSync(process.execPath, ['--test', '--test-reporter=tap', ...suites], { cwd: ROOT, encoding: 'utf8', env: childEnv({ UI_DESIGN_PACK: s.file }), timeout: 120000 });
129
+ assert.match(r.stdout, /# tests [1-9]/, `${s.name}: the suites actually ran`);
130
+ const fails = (r.stdout.match(/^not ok .*$/gm) || []).join('\n');
131
+ assert.equal(r.status, 0, `${s.name}: a sheet fails under this pack\n${fails}\n${(r.stdout.split('\n').filter((l) => /^\s+(\+|-|\*|actual|expected|error|message)/.test(l) || /:1 \(need|vs pack| = \d/.test(l)).slice(0, 30).join('\n'))}`);
132
+ assert.match(r.stdout, /# pass \d+/);
133
+ }
134
+ });
135
+
136
+ test('UI_DESIGN_PACK is honoured, not decorative: a pack that thins ink-faint fails the same suites', () => {
137
+ const bad = structuredClone(readJson(path.join(STYLES_DIR, 'expedition', 'pack.json')));
138
+ bad.theme.ui.colors.inkFaint = '#c8c6bd';
139
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ui-style-bad-')), 'pack.json');
140
+ fs.writeFileSync(file, JSON.stringify(bad));
141
+ const r = spawnSync(process.execPath, ['--test', '--test-reporter=tap', 'tests/world_tokens.mjs'], { cwd: ROOT, encoding: 'utf8', env: childEnv({ UI_DESIGN_PACK: file }), timeout: 60000 });
142
+ assert.match(r.stdout, /# tests [1-9]/, 'the suite actually ran');
143
+ assert.notEqual(r.status, 0, 'a thinned ink-faint must fail the world suite under the env var');
144
+ assert.match(r.stdout, /ink-faint on the page ground/, 'the failing pair is named');
145
+ const drift = spawnSync(process.execPath, ['--test', '--test-reporter=tap', 'tests/world_tokens.mjs'], { cwd: ROOT, encoding: 'utf8', env: childEnv({ UI_DESIGN_PACK: path.join(STYLES_DIR, 'expedition', 'pack.json') }), timeout: 60000 });
146
+ assert.match(drift.stdout, /^ok .*do not drift/m, 'the drift test keeps measuring the sheet against the NEUTRAL pack under an alternate');
147
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
148
+ });
149
+
150
+ // ── the picker ────────────────────────────────────────────────────────────────
151
+ test('resolveStyle: by name, by pack.json path, and nothing else; styleBrandingFile lays the look over the instance and keeps its identity', () => {
152
+ const byName = resolveStyle('expedition');
153
+ assert.equal(byName.name, 'expedition');
154
+ assert.equal(resolveStyle(byName.file).name, 'expedition', 'a pack.json path resolves too');
155
+ assert.equal(resolveStyle('no-such-look'), null);
156
+ assert.equal(resolveStyle('../kit/lib.js'), null, 'a path that is not a pack.json does not resolve');
157
+ assert.equal(resolveStyle(''), null);
158
+ const merged = readJson(styleBrandingFile(byName));
159
+ assert.equal(merged.theme.ui.colors.accent, '#ffd214', 'the look\'s fifteen');
160
+ assert.equal(merged.theme.ui.fonts.display, byName.pack.theme.ui.fonts.display);
161
+ assert.equal(merged.identity, undefined, 'a neutral checkout has no instance pack: only theme is written, so identity resolves from the neutral starter');
162
+ // a pack.json loaded by path names itself: the temp file the transform reads takes a slug, never the name
163
+ const hostile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ui-style-name-')), 'pack.json');
164
+ fs.writeFileSync(hostile, JSON.stringify({ ...byName.pack, name: '../../escape' }));
165
+ const written = styleBrandingFile(resolveStyle(hostile));
166
+ assert.equal(path.dirname(written), path.join(os.tmpdir(), 'ui-design-kit', 'styles'), "the merged pack is written inside the kit's temp dir whatever the pack calls itself");
167
+ assert.match(path.basename(written), /^[A-Za-z0-9_-]+\.branding\.json$/);
168
+ fs.rmSync(path.dirname(hostile), { recursive: true, force: true });
169
+ });
170
+
171
+ test('the stub serves a real surface and a specimen under a style: the spliced :root carries the look\'s fifteen, the instance\'s name is stamped, the feeds still answer', async () => {
172
+ const s = await startStub({ surface: 'public-landing', style: 'grove' });
173
+ try {
174
+ const html = await (await fetch(s.base + '/')).text();
175
+ assert.ok(html.includes('--accent:#e3c39c;'), 'the grove\'s accent is in the spliced :root');
176
+ assert.ok(html.includes('--font-display:"Nunito"'), 'the grove\'s display face');
177
+ assert.ok(html.includes('family=Nunito'), 'the look\'s font link is injected');
178
+ assert.ok(html.includes('window.__BRANDING__'), 'the pack is injected');
179
+ assert.ok(!html.includes('#fb9569'), 'the neutral accent does not leak into the served page');
180
+ assert.equal((await fetch(s.base + '/api/gds/public/progress')).status, 200);
181
+ } finally { s.stop(); }
182
+ const spec = await startStub({ surface: 'modules/ui-design/styles/expedition', style: 'expedition' });
183
+ try {
184
+ const html = await (await fetch(spec.base + '/mock')).text();
185
+ assert.ok(html.includes('--accent:#ffd214;'), 'the specimen is served under its own pack');
186
+ assert.ok(!html.includes('{{productName}}'), 'the identity token is stamped by the real transform');
187
+ assert.match(html, /<title>[^<]*the expedition, on a sheet<\/title>/);
188
+ } finally { spec.stop(); }
189
+ });
190
+
191
+ test('the CLIs refuse an unknown style with the catalogue named, before any browser; contrast --pack <name> measures a look', () => {
192
+ const bad = run('serve.js', [], { STYLE: 'no-such-look', STUB_PORT: '0' });
193
+ assert.equal(bad.status, EXIT.USAGE); assert.match(bad.stderr, /neither a style/); assert.match(bad.stderr, /chrome-world, expedition, grove/);
194
+ const render = run('render.js', ['--page', '/', '--style', 'no-such-look']);
195
+ assert.equal(render.status, EXIT.USAGE); assert.match(render.stderr, /--style "no-such-look" is not a style/);
196
+ const probe = run('probe.js', ['--page', '/projects', '--style', 'no-such-look']);
197
+ assert.equal(probe.status, EXIT.USAGE); assert.match(probe.stderr, /--style "no-such-look" is not a style/);
198
+ const c = run('contrast.js', ['--pack', 'expedition']);
199
+ assert.equal(c.status, EXIT.CLEAN, c.stdout + c.stderr); assert.match(c.stdout, /the expedition pack/); assert.match(c.stdout, /PACK CLEAN/);
200
+ const cBad = run('contrast.js', ['--pack', 'no-such-look']);
201
+ assert.equal(cBad.status, EXIT.USAGE); assert.match(cBad.stderr, /not a style/);
202
+ const mock = run('check-mock.js', ['--file', path.join(STYLES_DIR, 'grove', 'mock.html'), '--pack', 'grove']);
203
+ assert.equal(mock.status, EXIT.CLEAN, mock.stdout); assert.match(mock.stdout, /ALL RULES PASS/);
204
+ });
205
+
206
+ // ── the doors ─────────────────────────────────────────────────────────────────
207
+ test('the styles README, the module CLAUDE.md and /design name every look', () => {
208
+ const readme = read(path.join(STYLES_DIR, 'README.md'));
209
+ const claude = read(path.join(ROOT, 'modules', 'ui-design', 'CLAUDE.md'));
210
+ const design = read(path.join(ROOT, '.claude', 'skills', 'design', 'SKILL.md'));
211
+ for (const s of styles) {
212
+ assert.ok(readme.includes(`\`${s.name}\``), `styles/README.md rosters ${s.name}`);
213
+ assert.ok(claude.includes(`\`${s.name}\``), `modules/ui-design/CLAUDE.md names ${s.name}`);
214
+ assert.ok(design.split('\n').some((l) => l.includes(`\`${s.name}\``)), `/design names ${s.name} on a line`);
215
+ }
216
+ assert.match(design, /STYLE=/, '/design says how to render under a look');
217
+ assert.match(readme, /GDS_BRANDING_FILE/, 'the README names the knob the picker rides');
218
+ });
@@ -93,9 +93,15 @@ const darkMedia = declarations(blockAfter(':root:not([data-mode="light"]) {'));
93
93
  // pack emits the PACK wins and world.css's own value never renders. Measuring
94
94
  // world.css alone would measure a page nobody sees. The dark blocks are (0,2,0)
95
95
  // and win back.
96
- const packUi = JSON.parse(
96
+ // UI_DESIGN_PACK=<pack.json> measures the sheet under a look from the style library
97
+ // (tests/ui_design_styles.mjs runs this file once per pack, task 1003323); the DRIFT test
98
+ // below always compares the sheet against the neutral pack, which is what it must equal.
99
+ const neutralUi = JSON.parse(
97
100
  fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'),
98
101
  ).theme.ui;
102
+ const packUi = process.env.UI_DESIGN_PACK
103
+ ? JSON.parse(fs.readFileSync(path.resolve(process.env.UI_DESIGN_PACK), 'utf8')).theme.ui
104
+ : neutralUi;
99
105
 
100
106
  // mirrors COLOR_VARS in src/bongos/serve-internal.js
101
107
  const camelToVar = (k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
@@ -173,7 +179,8 @@ test('world.css defaults do not drift from the branding pack', () => {
173
179
  // page reads correctly standalone (opened as a file, with no server) only while
174
180
  // the two agree. This is the fifteen: thirteen colours plus the two faces.
175
181
  const drift = [];
176
- const expected = { ...PACK, '--font-display': packUi.fonts.display, '--font-body': packUi.fonts.body };
182
+ const NEUTRAL = Object.fromEntries(Object.entries(neutralUi.colors).map(([k, v]) => [camelToVar(k), v]));
183
+ const expected = { ...NEUTRAL, '--font-display': neutralUi.fonts.display, '--font-body': neutralUi.fonts.body };
177
184
  for (const [name, packValue] of Object.entries(expected)) {
178
185
  const cssValue = contractTier[name];
179
186
  if (cssValue === undefined) {