@bongos/core 1.19.444 → 1.19.446

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 (63) hide show
  1. package/.bongos-core.json +154 -49
  2. package/docs/adr/0054-session-token-reward-cost-plus.md +17 -0
  3. package/docs/copy-inventory.md +353 -352
  4. package/docs/copy-registry.json +397 -388
  5. package/docs/design/hall-direction-v2.md +295 -0
  6. package/docs/design/mocks/hall/_tokens.css +370 -0
  7. package/docs/design/mocks/hall/board/v1.html +846 -0
  8. package/docs/design/mocks/hall/board/v2.html +858 -0
  9. package/docs/design/mocks/hall/board/v3.html +884 -0
  10. package/docs/design/mocks/hall/goals/v1.html +1212 -0
  11. package/docs/design/mocks/hall/goals/v2.html +1192 -0
  12. package/docs/design/mocks/hall/goals/v3.html +1205 -0
  13. package/docs/design/mocks/hall/hall-sheet.html +244 -0
  14. package/docs/design/mocks/hall/reading/v1.html +1125 -0
  15. package/docs/design/mocks/hall/reading/v2.html +1177 -0
  16. package/docs/design/mocks/hall/reading/v3.html +1150 -0
  17. package/docs/design/mocks/hall/settings/v1.html +1238 -0
  18. package/docs/design/mocks/hall/settings/v2.html +1235 -0
  19. package/docs/design/mocks/hall/settings/v3.html +1299 -0
  20. package/docs/design/mocks/hall/task/v1.html +637 -0
  21. package/docs/design/mocks/hall/task/v2.html +646 -0
  22. package/docs/design/mocks/hall/task/v3.html +651 -0
  23. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -0
  24. package/docs/module-api-changelog.md +4 -0
  25. package/modules/economy/credits.js +46 -13
  26. package/modules/hall-ui/public/atlas.html +1 -2
  27. package/modules/hall-ui/public/collab.html +1 -2
  28. package/modules/hall-ui/public/copy-desk.css +11 -11
  29. package/modules/hall-ui/public/copy-desk.html +1 -2
  30. package/modules/hall-ui/public/diagrams.html +1 -2
  31. package/modules/hall-ui/public/drachmae.html +1 -2
  32. package/modules/hall-ui/public/gate.html +1 -2
  33. package/modules/hall-ui/public/goals.html +1 -2
  34. package/modules/hall-ui/public/government.html +1 -2
  35. package/modules/hall-ui/public/harbor.html +1 -2
  36. package/modules/hall-ui/public/idea.html +1 -2
  37. package/modules/hall-ui/public/ideas.css +6 -8
  38. package/modules/hall-ui/public/ideas.html +1 -2
  39. package/modules/hall-ui/public/index.html +3 -4
  40. package/modules/hall-ui/public/modules.html +1 -2
  41. package/modules/hall-ui/public/my-projects.html +1 -2
  42. package/modules/hall-ui/public/not-ready.html +1 -2
  43. package/modules/hall-ui/public/pair.html +1 -2
  44. package/modules/hall-ui/public/people.html +1 -2
  45. package/modules/hall-ui/public/primer.html +1 -2
  46. package/modules/hall-ui/public/profile.html +1 -2
  47. package/modules/hall-ui/public/ranks.html +1 -2
  48. package/modules/hall-ui/public/roadmap.html +1 -2
  49. package/modules/hall-ui/public/scouting.html +1 -2
  50. package/modules/hall-ui/public/sessions.html +1 -2
  51. package/modules/hall-ui/public/settings.html +1 -2
  52. package/modules/hall-ui/public/shell.js +35 -2
  53. package/modules/hall-ui/public/style.css +818 -392
  54. package/modules/hall-ui/public/task.html +1 -2
  55. package/modules/hall-ui/public/watch.css +1 -1
  56. package/modules/hall-ui/public/watch.html +1 -2
  57. package/modules/hall-ui/public/work.html +1 -2
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/scripts/gds/backfill-subagent-rewards.js +485 -0
  61. package/src/module-api.js +1 -1
  62. package/tests/backfill_subagent_rewards.mjs +308 -0
  63. package/tests/hall_tokens.mjs +371 -0
@@ -0,0 +1,371 @@
1
+ // tests/hall_tokens.mjs — the builders' hall's token tier (task 1003303, goal 1000081).
2
+ //
3
+ // The hall was rebuilt into the chrome world DESIGN.md records, taking the tier
4
+ // the composition round authored and verified (docs/design/mocks/hall/_tokens.css,
5
+ // task 1003302; the pick and its steers are docs/design/hall-direction-v2.md §6).
6
+ // This file is to modules/hall-ui/public/style.css what tests/world_tokens.mjs is
7
+ // to world.css, and the method is the same: the TOKENS are the only input — it
8
+ // parses style.css and resolves var() / color-mix() itself, so it measures what
9
+ // the hall actually paints. Restating the hex values here would prove nothing.
10
+ // The resolver is COPIED rather than imported because world_tokens.mjs is a test
11
+ // file, not a module, and exporting from it would make one file's failure
12
+ // silence the other's.
13
+ //
14
+ // What it locks, all of it silent in a browser:
15
+ // * DRIFT — the contract tier is a dead default (serve-internal.js's
16
+ // themeMarkup re-declares the thirteen from the pack AFTER this sheet, so
17
+ // the pack always wins); a value can rot here and nobody sees the colour
18
+ // they audited, and the sheet only reads correctly standalone while the two
19
+ // agree;
20
+ // * LOCKSTEP — dark is written twice (the hall's own [data-theme] switch and
21
+ // the prefers-color-scheme twin that covers first paint before shell.js
22
+ // runs); a change to one only works for half of users;
23
+ // * RE-THEMEABILITY — the dark blocks outrank the pack's :root, so a literal
24
+ // --accent inside one would defeat every instance's palette forever;
25
+ // * THE STATUS VOCABULARY — the hall renders VERIFICATION state, so DESIGN.md
26
+ // §Reuse licenses it a fixed green/amber/red instead of the world's warm
27
+ // accent-derived states. Three of the four values had to MOVE for the
28
+ // world's darker paper, which is a measurement: this is what re-measures it,
29
+ // on both grounds in both modes. Hand-tuning one without re-running this is
30
+ // how the shipped values came to fail in the first place;
31
+ // * THE SKELETON — --bg-deep is the obvious loading tone and it is invisible
32
+ // in dark, where DESIGN.md collapses deep back to black;
33
+ // * THE COMPAT ALIASES — ~20 page sheets still derive from --surface /
34
+ // --border / --accent-text; dropping one silently breaks colours across
35
+ // pages this task did not re-skin, rather than erroring;
36
+ // * THE KILL SWITCH — present, and LAST in the token layer, because a later
37
+ // rule re-declaring animation would outrank it on source order.
38
+
39
+ import { test } from 'node:test';
40
+ import assert from 'node:assert/strict';
41
+ import fs from 'node:fs';
42
+ import path from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+
45
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
46
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
47
+ const src = fs.readFileSync(path.join(HALL, 'style.css'), 'utf8');
48
+
49
+ // ── parse ────────────────────────────────────────────────────────────────────
50
+ const declarations = (body) => {
51
+ const out = {};
52
+ for (const m of body.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
53
+ out[m[1]] = m[2].replace(/\s+/g, ' ').trim();
54
+ }
55
+ return out;
56
+ };
57
+
58
+ const bodyAt = (open) => {
59
+ let depth = 0;
60
+ for (let i = open; i < src.length; i++) {
61
+ if (src[i] === '{') depth++;
62
+ else if (src[i] === '}' && --depth === 0) return src.slice(open + 1, i);
63
+ }
64
+ throw new Error(`unbalanced braces at offset ${open}`);
65
+ };
66
+
67
+ const blockAfter = (marker) => {
68
+ const at = src.indexOf(marker);
69
+ assert.notEqual(at, -1, `style.css no longer contains ${marker} — update this test`);
70
+ return bodyAt(src.indexOf('{', at));
71
+ };
72
+
73
+ // The one top-level bare :root: the contract tier, the derived tier and the
74
+ // compat aliases in a single block. tests/hall_audit.mjs pins ten of its names
75
+ // by reading the FIRST ':root {', so a second bare one appearing above it would
76
+ // quietly move that check to the wrong block.
77
+ const bareRoots = [...src.matchAll(/\n:root\s*\{/g)];
78
+ assert.equal(bareRoots.length, 1, 'expected exactly one top-level :root block in style.css');
79
+ const contractTier = declarations(bodyAt(src.indexOf('{', bareRoots[0].index)));
80
+
81
+ const darkExplicit = declarations(blockAfter('html[data-theme="dark"] {'));
82
+ const darkMedia = declarations(blockAfter('html:not([data-theme]) {'));
83
+
84
+ // THE CASCADE THAT ACTUALLY SHIPS. injectBrandingInto splices the pack's theme
85
+ // <style> at the END of <head> — after style.css's <link> — as a plain :root.
86
+ // Equal specificity, later in document order, so for the tokens the pack emits
87
+ // the PACK wins and style.css's own value never renders. html[data-theme="dark"]
88
+ // is (0,1,1) and wins back.
89
+ const packUi = JSON.parse(
90
+ fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'),
91
+ ).theme.ui;
92
+
93
+ // mirrors COLOR_VARS in src/bongos/serve-internal.js
94
+ const camelToVar = (k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
95
+ const PACK = Object.fromEntries(
96
+ Object.entries(packUi.colors).map(([k, v]) => [camelToVar(k), v]),
97
+ );
98
+
99
+ const RAW = {
100
+ light: { ...contractTier, ...PACK },
101
+ dark: { ...contractTier, ...PACK, ...darkExplicit },
102
+ };
103
+
104
+ // ── color resolution ─────────────────────────────────────────────────────────
105
+ const clamp = (v) => Math.min(255, Math.max(0, v));
106
+
107
+ function resolve(expr, mode, seen = new Set()) {
108
+ const e = String(expr).trim();
109
+
110
+ if (e === 'transparent') return [0, 0, 0, 0];
111
+
112
+ let m = e.match(/^#([0-9a-f]{6})$/i);
113
+ if (m) { const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]; }
114
+
115
+ m = e.match(/^#([0-9a-f]{3})$/i);
116
+ if (m) return [...m[1]].map((c) => parseInt(c + c, 16)).concat(1);
117
+
118
+ m = e.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
119
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
120
+
121
+ m = e.match(/^var\(\s*(--[\w-]+)\s*(?:,([\s\S]+))?\)$/);
122
+ if (m) {
123
+ const name = m[1];
124
+ assert.ok(!seen.has(name), `cyclic var reference at ${name}`);
125
+ const next = RAW[mode][name] ?? m[2];
126
+ assert.ok(next !== undefined, `${name} is not defined for ${mode} mode`);
127
+ return resolve(next, mode, new Set([...seen, name]));
128
+ }
129
+
130
+ m = e.match(/^color-mix\(\s*in\s+srgb\s*,\s*([\s\S]+?)\s+([\d.]+)%\s*,\s*([\s\S]+?)\s*\)$/);
131
+ if (m) {
132
+ const p = +m[2] / 100;
133
+ const a = resolve(m[1], mode, seen);
134
+ const b = resolve(m[3], mode, seen);
135
+ const alpha = a[3] * p + b[3] * (1 - p);
136
+ if (alpha === 0) return [0, 0, 0, 0];
137
+ return [0, 1, 2]
138
+ .map((i) => (a[i] * a[3] * p + b[i] * b[3] * (1 - p)) / alpha)
139
+ .concat(alpha);
140
+ }
141
+
142
+ throw new Error(`cannot resolve color expression: ${e}`);
143
+ }
144
+
145
+ // flatten a possibly-translucent color onto an opaque backdrop
146
+ const over = (fg, bg) =>
147
+ fg[3] === 1 ? fg : [0, 1, 2].map((i) => clamp(fg[i] * fg[3] + bg[i] * (1 - fg[3]))).concat(1);
148
+
149
+ const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
150
+ const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
151
+ const contrast = (fg, bg) => {
152
+ const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a);
153
+ return (hi + 0.05) / (lo + 0.05);
154
+ };
155
+
156
+ const paint = (name, mode) => resolve(RAW[mode][name] ?? name, mode);
157
+ const ratio = (fgName, bgName, mode) => {
158
+ const bg = paint(bgName, mode);
159
+ return contrast(over(paint(fgName, mode), bg), bg);
160
+ };
161
+
162
+ // ── (a) the contract tier is the pack ────────────────────────────────────────
163
+
164
+ test('style.css defaults do not drift from the branding pack', () => {
165
+ // For the pack-owned tokens style.css's :root is a DEAD DEFAULT — the pack
166
+ // always overrides it. That makes silent drift easy and dangerous: anyone
167
+ // auditing the sheet alone would measure colours no builder ever sees.
168
+ const drift = [];
169
+ for (const [name, value] of Object.entries(PACK)) {
170
+ if (contractTier[name] !== value) drift.push(`${name}: sheet ${contractTier[name]} vs pack ${value}`);
171
+ }
172
+ 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]}`);
175
+ }
176
+ }
177
+ assert.deepEqual(drift, [], 'the contract tier must equal config/branding.neutral.json theme.ui');
178
+ });
179
+
180
+ test('the pack still carries the web-font links the hall relies on', () => {
181
+ // The hall's 27 heads used to hardcode an Inter <link>; task 1003303 deleted
182
+ // all 27 and let themeMarkup() inject the pack's instead, which is what makes
183
+ // the faces host-overridable and reachable from a mirror. If the pack ever
184
+ // stops carrying them the hall silently falls back to the system stack.
185
+ assert.ok(Array.isArray(packUi.fonts.links) && packUi.fonts.links.length,
186
+ 'branding.neutral.json theme.ui.fonts.links must carry the face stylesheet(s)');
187
+ const pages = fs.readdirSync(HALL).filter((f) => f.endsWith('.html'));
188
+ const hardcoded = pages.filter((f) => /fonts\.googleapis\.com\/css2/.test(fs.readFileSync(path.join(HALL, f), 'utf8')));
189
+ assert.deepEqual(hardcoded, [], 'no hall page may hardcode a web-font stylesheet — the pack injects it');
190
+ });
191
+
192
+ // ── (b) the dark twins are in lockstep ───────────────────────────────────────
193
+
194
+ test('the two dark blocks are byte-identical', () => {
195
+ // html[data-theme="dark"] is the switch shell.js writes; the media block covers
196
+ // first paint for OS-dark users before the script runs. A value that lands in
197
+ // one and not the other works for half the audience and nobody can see which.
198
+ assert.deepEqual(darkMedia, darkExplicit,
199
+ 'html[data-theme="dark"] and the prefers-color-scheme twin must declare the same tokens');
200
+ assert.ok(Object.keys(darkExplicit).length >= 20, 'sanity: the dark tier is substantial');
201
+ });
202
+
203
+ test('no dark block pins an accent literal (the Unpinned Accent Rule)', () => {
204
+ // The dark blocks are (0,1,1) and the pack's injected :root is (0,1,0), so a
205
+ // literal here outranks every instance's palette forever and a re-themed
206
+ // instance could not reach dark.
207
+ for (const name of ['--accent', '--sea', '--terracotta']) {
208
+ assert.equal(darkExplicit[name], undefined, `${name} must not be re-declared in dark`);
209
+ }
210
+ });
211
+
212
+ // ── (c) contrast, in both modes ──────────────────────────────────────────────
213
+
214
+ // The ink-on-ground pairs the hall actually paints. --ink-faint on --bg is the
215
+ // thinnest pair in the world and DESIGN.md says not to thin it further.
216
+ const INK_PAIRS = [
217
+ ['--ink', '--bg'], ['--ink', '--bg-card'], ['--ink', '--bg-frame'], ['--ink', '--surface-2'],
218
+ ['--ink-soft', '--bg'], ['--ink-soft', '--bg-card'], ['--ink-soft', '--surface-2'],
219
+ ['--ink-faint', '--bg'], ['--ink-faint', '--bg-card'],
220
+ ['--accent-ink', '--bg'], ['--accent-ink', '--bg-card'],
221
+ ];
222
+
223
+ test('every ink-on-ground pair clears AA in both modes', () => {
224
+ const fails = [];
225
+ for (const mode of ['light', 'dark']) {
226
+ for (const [fg, bg] of INK_PAIRS) {
227
+ const r = ratio(fg, bg, mode);
228
+ if (r < 4.5) fails.push(`${mode} ${fg} on ${bg} = ${r.toFixed(2)}:1`);
229
+ }
230
+ }
231
+ assert.deepEqual(fails, [], 'body text needs 4.5:1');
232
+ });
233
+
234
+ test('accent-on is readable on the accent fill, and white would not be', () => {
235
+ // DESIGN.md bans white on this salmon; --accent-on is the dark ink that goes
236
+ // on an accent fill. The second half of this is what stops a future edit from
237
+ // "simplifying" it back to #fff.
238
+ for (const mode of ['light', 'dark']) {
239
+ const r = ratio('--accent-on', '--accent', mode);
240
+ assert.ok(r >= 4.5, `${mode}: --accent-on on --accent = ${r.toFixed(2)}:1`);
241
+ const white = contrast([255, 255, 255], paint('--accent', mode));
242
+ assert.ok(white < 4.5, `${mode}: white on --accent measures ${white.toFixed(2)}:1 — it must stay banned`);
243
+ }
244
+ });
245
+
246
+ test('the status vocabulary clears AA on both grounds in both modes', () => {
247
+ // The round's finding: the shipped values were tuned against the hall's
248
+ // retired ground (--bg #f5f6f8) and FAIL against the world's darker paper
249
+ // (--bg #edecee) — ok 4.27, warn 4.13, info 3.85 and 4.36, and 4.19 on the
250
+ // dark card. These are the re-solved values; this is what keeps them solved.
251
+ const fails = [];
252
+ for (const mode of ['light', 'dark']) {
253
+ for (const state of ['--ok', '--warn', '--danger', '--info']) {
254
+ for (const ground of ['--bg', '--bg-card']) {
255
+ const r = ratio(state, ground, mode);
256
+ if (r < 4.5) fails.push(`${mode} ${state} on ${ground} = ${r.toFixed(2)}:1`);
257
+ }
258
+ }
259
+ }
260
+ assert.deepEqual(fails, [], 'a verification state must be legible on both grounds');
261
+ });
262
+
263
+ test('each state reads against its own weak fill', () => {
264
+ const fails = [];
265
+ for (const mode of ['light', 'dark']) {
266
+ for (const state of ['--ok', '--warn', '--danger', '--info']) {
267
+ const r = ratio(state, state + '-weak', mode);
268
+ if (r < 4.5) fails.push(`${mode} ${state} on ${state}-weak = ${r.toFixed(2)}:1`);
269
+ }
270
+ }
271
+ assert.deepEqual(fails, [], 'a status chip paints its own colour on its own weak fill');
272
+ });
273
+
274
+ test('the contrast probe would CATCH the values that failed', () => {
275
+ // 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');
278
+ for (const [name, hex] of [['ok', '#1a7f4b'], ['warn', '#9a6700']]) {
279
+ const r = contrast(resolve(hex, 'light'), bg);
280
+ assert.ok(r < 4.5, `the retired ${name} ${hex} must still measure under AA here (got ${r.toFixed(2)}:1)`);
281
+ }
282
+ });
283
+
284
+ // ── (d) the derived tier's own hazards ───────────────────────────────────────
285
+
286
+ test('--skeleton reads on the card in both modes, where --bg-deep cannot', () => {
287
+ // DESIGN.md collapses deep back to black in dark, so --bg-deep and --bg are
288
+ // the same value and a skeleton painted with it is invisible. Every hall page
289
+ // has loading states; the landing has none, which is why it never noticed.
290
+ for (const mode of ['light', 'dark']) {
291
+ const r = ratio('--skeleton', '--bg-card', mode);
292
+ assert.ok(r >= 1.1, `${mode}: --skeleton on --bg-card = ${r.toFixed(3)}:1 — a skeleton must be visible`);
293
+ }
294
+ const deep = paint('--bg-deep', 'dark');
295
+ const bg = paint('--bg', 'dark');
296
+ assert.deepEqual(deep.slice(0, 3), bg.slice(0, 3),
297
+ 'in dark --bg-deep IS --bg — this is why it cannot be the skeleton tone');
298
+ });
299
+
300
+ test('--line is a width, not a colour', () => {
301
+ // DESIGN.md warns about this exact name: --line is a 1px WIDTH here and a
302
+ // hairline COLOUR in the hub's sheet. Page sheets that treated it as a colour
303
+ // were repointed at --rule / --rule-soft in task 1003303.
304
+ assert.match(contractTier['--line'], /^\d+px$/, '--line must resolve to a length');
305
+ const sheets = fs.readdirSync(HALL).filter((f) => f.endsWith('.css'));
306
+ const misuse = sheets.filter((f) => /var\(--line\s*,/.test(fs.readFileSync(path.join(HALL, f), 'utf8')));
307
+ assert.deepEqual(misuse, [], 'a colour fallback on --line means the sheet thinks it is a colour');
308
+ });
309
+
310
+ test('the plate seam is declared and resolves to none in both modes', () => {
311
+ // The owner deferred the hall's ground ("decide after the shell lands"), so
312
+ // the shell must be a complete composition with no plate — and adding one
313
+ // later must be a token change, not a re-layout.
314
+ for (const name of ['--plate', '--plate-blend', '--plate-veil']) {
315
+ assert.ok(contractTier[name], `${name} must stay declared as the seam`);
316
+ }
317
+ assert.equal(contractTier['--plate'], 'none', 'no plate ships with the shell');
318
+ assert.equal(contractTier['--plate-veil'], 'none', 'light never veils');
319
+ assert.equal(darkExplicit['--plate'], undefined, 'dark must not smuggle a plate in');
320
+ });
321
+
322
+ // ── (e) the compat aliases the un-reskinned pages still consume ──────────────
323
+
324
+ test('every token a hall stylesheet references is defined', () => {
325
+ // ~20 page sheets derive from the aliases (--surface, --border, --accent-text,
326
+ // …). A page this task did not re-skin must degrade into the new palette, not
327
+ // break; dropping a name it uses fails silently in a browser.
328
+ const sheets = fs.readdirSync(HALL).filter((f) => f.endsWith('.css'));
329
+ const defined = new Set([
330
+ ...Object.keys(contractTier), ...Object.keys(darkExplicit), ...Object.keys(PACK),
331
+ ]);
332
+ // A sheet may define its own private tokens (atlas.css's --ab-* family), and
333
+ // a page script may set one inline on the element (roadmap.js writes
334
+ // style="--p:…;--rc:…" on the progress ring). Both are legitimate, so both
335
+ // count as defined — and the collector must not anchor to line start, because
336
+ // atlas.css declares two per line.
337
+ for (const f of [...sheets, ...fs.readdirSync(HALL).filter((x) => x.endsWith('.js'))]) {
338
+ for (const m of fs.readFileSync(path.join(HALL, f), 'utf8').matchAll(/(--[\w-]+)\s*:/g)) defined.add(m[1]);
339
+ }
340
+ const missing = new Map();
341
+ for (const f of sheets) {
342
+ const css = fs.readFileSync(path.join(HALL, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
343
+ for (const m of css.matchAll(/var\(\s*(--[\w-]+)\s*([,)])/g)) {
344
+ // a var() WITH a fallback is a deliberate optional; one without must resolve
345
+ if (m[2] === ',') continue;
346
+ if (!defined.has(m[1])) {
347
+ if (!missing.has(m[1])) missing.set(m[1], []);
348
+ missing.get(m[1]).push(f);
349
+ }
350
+ }
351
+ }
352
+ assert.deepEqual([...missing.keys()], [], `undefined tokens: ${[...missing].map(([k, v]) => `${k} (${[...new Set(v)].join(', ')})`).join('; ')}`);
353
+ });
354
+
355
+ // ── (f) the kill switch ──────────────────────────────────────────────────────
356
+
357
+ test('the reduced-motion kill switch is present and closes the token layer', () => {
358
+ const at = src.indexOf('@media (prefers-reduced-motion: reduce)');
359
+ assert.ok(at > 0, 'the kill switch must exist');
360
+ const body = bodyAt(src.indexOf('{', at));
361
+ assert.match(body, /animation:\s*none\s*!important/, 'animation must be killed with !important');
362
+ assert.match(body, /transition:\s*none\s*!important/, 'transition must be killed with !important');
363
+ assert.match(body, /\*,\s*\*::before,\s*\*::after/, 'nothing may escape it');
364
+ // It must sit AFTER the dark blocks: a later rule re-declaring animation would
365
+ // outrank it on source order, so this is where it can still win.
366
+ assert.ok(at > src.indexOf('html:not([data-theme]) {'),
367
+ 'the kill switch closes the token layer, after the dark twins');
368
+ // and it must be the only one, or a second copy can drift from the first
369
+ assert.equal(src.split('@media (prefers-reduced-motion: reduce)').length - 1, 1,
370
+ 'exactly one kill switch — a second copy is a place for the rule to rot');
371
+ });