@bongos/core 1.19.387 → 1.19.389

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 (69) hide show
  1. package/.bongos-core.json +144 -59
  2. package/.claude/skills/grade-recover/SKILL.md +1 -1
  3. package/.claude/skills/grader-health/SKILL.md +1 -1
  4. package/config/branding.neutral.json +19 -19
  5. package/config/design-tokens.neutral.json +31 -36
  6. package/docs/adr/0196-chrome-world-token-contract-and-the-landing-sheets.md +31 -0
  7. package/docs/adr/README.md +1 -0
  8. package/docs/branding-contract.md +1 -1
  9. package/docs/copy-inventory.md +93 -67
  10. package/docs/copy-registry.json +411 -133
  11. package/docs/file-map.md +15 -0
  12. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  13. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  14. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  15. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  16. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  17. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  18. package/modules/dev-box/app/build-resources/icon.png +0 -0
  19. package/modules/dev-box/app/renderer/index.html +1 -1
  20. package/modules/dev-box/app/renderer/styles.css +3 -1
  21. package/modules/dev-box/app/renderer/theme.generated.css +13 -13
  22. package/modules/dev-box/app/scripts/gen-brand.js +13 -13
  23. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +3 -3
  24. package/modules/dev-box/app/scripts/gen-icons.js +3 -3
  25. package/modules/dev-box/app/src/brand.generated.json +1 -1
  26. package/modules/grading/grader-config.js +5 -3
  27. package/modules/grading/grader-rubric.json +2 -2
  28. package/modules/grading/grader-score.js +86 -33
  29. package/modules/grading/grader-workers/index.js +19 -0
  30. package/modules/hall-ui/public/builders.js +8 -3
  31. package/modules/provisioning/routes/provisioning.js +4 -4
  32. package/modules/status-ui/public/explore.html +12 -6
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/public-landing/assets/cosmos.css +82 -64
  36. package/public-landing/assets/world/og-card.jpg +0 -0
  37. package/public-landing/assets/world/r2-bongos-chrome-a-cut.webp +0 -0
  38. package/public-landing/assets/world/r2-bongos-chrome-a-cut.webp.json +12 -0
  39. package/public-landing/assets/world/r2-bongos-chrome-b.jpg +0 -0
  40. package/public-landing/assets/world/r2-bongos-chrome-b.jpg.json +12 -0
  41. package/public-landing/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  42. package/public-landing/assets/world/r2-chrome-blob-one-cut.webp.json +13 -0
  43. package/public-landing/assets/world/r2-chrome-blob-one.jpg +0 -0
  44. package/public-landing/assets/world/r2-chrome-blob-one.jpg.json +12 -0
  45. package/public-landing/assets/world/r2-chrome-blobs-set-cut.webp +0 -0
  46. package/public-landing/assets/world/r2-chrome-blobs-set-cut.webp.json +13 -0
  47. package/public-landing/assets/world/r2-chrome-blobs-set.jpg +0 -0
  48. package/public-landing/assets/world/r2-chrome-blobs-set.jpg.json +12 -0
  49. package/public-landing/assets/world.css +241 -0
  50. package/public-landing/contact.html +1 -1
  51. package/public-landing/index.html +1139 -580
  52. package/public-landing/privacy.html +1 -1
  53. package/public-landing/projects.html +2 -2
  54. package/public-landing/terms.html +1 -1
  55. package/scripts/gds/ship-grade-steps.js +27 -0
  56. package/src/bongos/platform-visibility-gate.js +8 -7
  57. package/src/bongos/stealth-gate.js +9 -8
  58. package/src/module-api.js +5 -1
  59. package/src/ui/adapters/claude-design/index.js +2 -2
  60. package/tests/cloud_bongos_pack.mjs +22 -18
  61. package/tests/cosmos_token_contrast.mjs +23 -8
  62. package/tests/grader.mjs +7 -6
  63. package/tests/grader_quality_window_overflow.mjs +324 -0
  64. package/tests/landing_legal_pages.mjs +8 -5
  65. package/tests/landing_page.mjs +295 -166
  66. package/tests/platform_boot.mjs +3 -1
  67. package/tests/projects_hub.mjs +9 -5
  68. package/tests/projects_hub_pre_uat.mjs +3 -1
  69. package/tests/world_tokens.mjs +351 -0
@@ -115,7 +115,9 @@ test('the apex host serves the landing page — not the game — on a game-less
115
115
  assert.match(res.body, /window\.__BRANDING__=/);
116
116
  // task 1002934: the doors became two bare buttons; "Find a Project" is the
117
117
  // portable landing marker now ("Enter the Hall" left with the door cards).
118
- assert.match(res.body, /Find a Project/);
118
+ // task 1003298: the chrome-world landing sets its doors in sentence case
119
+ // ("Find a project") — the marker is the phrase, not its capitalisation.
120
+ assert.match(res.body, /Find a [Pp]roject/);
119
121
  // the per-project landing carries the top-right identity chip + hall entry
120
122
  // (goal 35, task 2094). "Create a project" is an ORG-level action that lives
121
123
  // only on the gate now — it must NOT appear on a project's own landing.
@@ -419,13 +419,17 @@ test('the wordmark rests on the real product name, not on a joke', () => {
419
419
  assert.ok(home > hold, 'the resting state must outlast the alternates');
420
420
  });
421
421
 
422
- test('the hub and the landing hero share one typing cadence', () => {
423
- // two surfaces, one system — a divergence here is a visible inconsistency
422
+ test('the hub wordmark keeps its own typing cadence; the landing hero no longer types (task 1003298)', () => {
423
+ // The landing's self-typing eyebrow ("type bongos in any AI") retired with the
424
+ // chrome world: its hero is the h1, the lede and the doors, and its only motion is
425
+ // the CSS breath. The hub's typing wordmark stays; a shared cadence is no longer a
426
+ // thing to keep in step, so this pins the hub half and the landing's silence.
424
427
  const LANDING = fs.readFileSync(path.join(ROOT, 'public-landing', 'index.html'), 'utf8');
425
- const heroHolds = [...LANDING.matchAll(/\?\s*(\d+)\s*:\s*(\d+)\)/g)].map(m => [m[1], m[2]]).flat();
428
+ assert.doesNotMatch(LANDING, /id="typed"|setTimeout\(type\b/, 'the landing hero does not type');
426
429
  const mark = (SCRIPT.match(/function typeMark\(\)([\s\S]*?)\n \}\)\(\);/) || [, ''])[1];
427
- for (const n of [mark.match(/HOLD_HOME\s*=\s*(\d+)/)[1], mark.match(/HOLD\s*=\s*(\d+)/)[1]]) {
428
- assert.ok(heroHolds.includes(n), `hold ${n}ms should match the hero's cadence (${heroHolds.join('/')})`);
430
+ assert.ok(mark, 'the hub wordmark still types');
431
+ for (const name of ['HOLD_HOME', 'HOLD']) {
432
+ assert.match(mark, new RegExp(name + '\\s*=\\s*\\d+'), `the hub declares ${name}`);
429
433
  }
430
434
  });
431
435
 
@@ -305,7 +305,9 @@ test('decision 9 — only mouse-driven focus on a wizard heading goes ringless',
305
305
  test('decision 10 — both footers carry the open-source (AGPL-3.0) link, in link ink', () => {
306
306
  const AGPL = /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html" target="_blank" rel="noopener">Open source \(AGPL-3\.0\)<\/a>\./;
307
307
  const hubFoot = (HUB.match(/<footer class="foot">[\s\S]*?<\/footer>/) || [''])[0];
308
- const indexFoot = (INDEX.match(/<div class="foot">[\s\S]*?<\/div>/) || [''])[0];
308
+ // task 1003298: the chrome-world landing's footer is a real <footer>; the
309
+ // pre-world page used <div class="foot">. Either element is the footer.
310
+ const indexFoot = (INDEX.match(/<footer(?:\s[^>]*)?>[\s\S]*?<\/footer>|<div class="foot">[\s\S]*?<\/div>/) || [''])[0];
309
311
  assert.match(hubFoot, AGPL, 'projects.html');
310
312
  assert.match(indexFoot, AGPL, 'index.html');
311
313
  // the link sits OUTSIDE the apex-only span, so a child instance keeps it too
@@ -0,0 +1,351 @@
1
+ // tests/world_tokens.mjs — the chrome world's token layer (task 1003298, goal 1000080).
2
+ //
3
+ // public-landing/assets/world.css is the world the apex landing is built in: a
4
+ // contract tier of fifteen tokens a host branding pack overrides, and a derived
5
+ // tier (the void, the glass, the accent behaviours, the state colours, the
6
+ // plates) that must be a var() or a color-mix() of those fifteen so a re-themed
7
+ // instance still gets a coherent world.
8
+ //
9
+ // This file mirrors tests/cosmos_token_contrast.mjs's method for that sheet: the
10
+ // TOKENS are the only input — it parses world.css and resolves var()/color-mix()
11
+ // itself, so it measures what the surfaces actually paint. Duplicating the hex
12
+ // values here would prove nothing. The resolver is COPIED rather than imported
13
+ // because cosmos_token_contrast.mjs is a test file, not a module, and exporting
14
+ // from it would make one file's failure silence the other's.
15
+ //
16
+ // What it locks, all of it silent in a browser:
17
+ // * DRIFT — the contract tier is a dead default (the pack always wins), so a
18
+ // value can rot here and nobody sees the colour they audited;
19
+ // * LOCKSTEP — dark is written twice (a data-mode hook and a
20
+ // prefers-color-scheme twin); a change to one only works for half of users;
21
+ // * RE-THEMEABILITY — both dark blocks are (0,2,0) and the pack's :root is
22
+ // (0,1,0), so a literal --accent in a dark block outranks every pack forever;
23
+ // * THE PLATES — a url() inside a custom property resolves against the
24
+ // DOCUMENT, not this sheet, so a relative path 404s from every other route;
25
+ // * CONTRAST — every pair the landing paints, in both modes;
26
+ // * THE KILL SWITCH — the whole world's reduced-motion stop, which must be
27
+ // present and LAST, because a later rule re-declaring animation would
28
+ // outrank it on source order.
29
+
30
+ import { test } from 'node:test';
31
+ import assert from 'node:assert/strict';
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
37
+ const LANDING = path.join(ROOT, 'public-landing');
38
+ const CSS = path.join(LANDING, 'assets', 'world.css');
39
+ const src = fs.readFileSync(CSS, 'utf8');
40
+ const PAGE = fs.readFileSync(path.join(LANDING, 'index.html'), 'utf8');
41
+
42
+ // ── parse ────────────────────────────────────────────────────────────────────
43
+ const declarations = (body) => {
44
+ const out = {};
45
+ // strip comments, then take --name:value up to the ; that ends the declaration
46
+ for (const m of body.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
47
+ // collapse the whitespace of multi-line values so the lockstep check compares
48
+ // meaning, not indentation
49
+ out[m[1]] = m[2].replace(/\s+/g, ' ').trim();
50
+ }
51
+ return out;
52
+ };
53
+
54
+ const bodyAt = (open) => {
55
+ let depth = 0;
56
+ for (let i = open; i < src.length; i++) {
57
+ if (src[i] === '{') depth++;
58
+ else if (src[i] === '}' && --depth === 0) return src.slice(open + 1, i);
59
+ }
60
+ throw new Error(`unbalanced braces at offset ${open}`);
61
+ };
62
+
63
+ const endOf = (open) => {
64
+ let depth = 0;
65
+ for (let i = open; i < src.length; i++) {
66
+ if (src[i] === '{') depth++;
67
+ else if (src[i] === '}' && --depth === 0) return i;
68
+ }
69
+ throw new Error(`unbalanced braces at offset ${open}`);
70
+ };
71
+
72
+ const blockAfter = (marker) => {
73
+ const at = src.indexOf(marker);
74
+ assert.notEqual(at, -1, `world.css no longer contains ${marker} — update this test`);
75
+ return bodyAt(src.indexOf('{', at));
76
+ };
77
+
78
+ // The top-level bare :root — the contract tier and the derived tier in one
79
+ // block. The narrow-width `--top-h` override is INDENTED inside a @media, so the
80
+ // anchored newline is what keeps it out of the map (it is a geometry token and
81
+ // carries no colour).
82
+ const bareRoots = [...src.matchAll(/\n:root\s*\{/g)];
83
+ assert.equal(bareRoots.length, 1, 'expected exactly one top-level :root block in world.css');
84
+ const contractTier = declarations(bodyAt(src.indexOf('{', bareRoots[0].index)));
85
+
86
+ const darkExplicit = declarations(blockAfter(':root[data-mode="dark"] {'));
87
+ const darkMedia = declarations(blockAfter(':root:not([data-mode="light"]) {'));
88
+
89
+ // THE CASCADE THAT ACTUALLY SHIPS. injectBrandingGlobal splices the branding
90
+ // pack's theme <style> at the END of <head> — after the world.css <link> — as a
91
+ // plain :root. Equal specificity, later in document order, so for the tokens the
92
+ // pack emits the PACK wins and world.css's own value never renders. Measuring
93
+ // world.css alone would measure a page nobody sees. The dark blocks are (0,2,0)
94
+ // and win back.
95
+ const packUi = JSON.parse(
96
+ fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'),
97
+ ).theme.ui;
98
+
99
+ // mirrors COLOR_VARS in src/bongos/serve-internal.js
100
+ const camelToVar = (k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
101
+ const PACK = Object.fromEntries(
102
+ Object.entries(packUi.colors).map(([k, v]) => [camelToVar(k), v]),
103
+ );
104
+
105
+ const RAW = {
106
+ light: { ...contractTier, ...PACK },
107
+ dark: { ...contractTier, ...PACK, ...darkExplicit },
108
+ };
109
+
110
+ // ── color resolution ─────────────────────────────────────────────────────────
111
+ const clamp = (v) => Math.min(255, Math.max(0, v));
112
+
113
+ function resolve(expr, mode, seen = new Set()) {
114
+ const e = String(expr).trim();
115
+
116
+ if (e === 'transparent') return [0, 0, 0, 0];
117
+
118
+ let m = e.match(/^#([0-9a-f]{6})$/i);
119
+ if (m) { const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]; }
120
+
121
+ m = e.match(/^#([0-9a-f]{3})$/i);
122
+ if (m) return [...m[1]].map((c) => parseInt(c + c, 16)).concat(1);
123
+
124
+ m = e.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
125
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
126
+
127
+ m = e.match(/^var\(\s*(--[\w-]+)\s*(?:,([\s\S]+))?\)$/);
128
+ if (m) {
129
+ const name = m[1];
130
+ assert.ok(!seen.has(name), `cyclic var reference at ${name}`);
131
+ const next = RAW[mode][name] ?? m[2];
132
+ assert.ok(next !== undefined, `${name} is not defined for ${mode} mode`);
133
+ return resolve(next, mode, new Set([...seen, name]));
134
+ }
135
+
136
+ m = e.match(/^color-mix\(\s*in\s+srgb\s*,\s*([\s\S]+?)\s+([\d.]+)%\s*,\s*([\s\S]+?)\s*\)$/);
137
+ if (m) {
138
+ const p = +m[2] / 100;
139
+ const a = resolve(m[1], mode, seen);
140
+ const b = resolve(m[3], mode, seen);
141
+ // color-mix premultiplies by alpha; every pair asserted below resolves
142
+ // opaque, and `transparent` correctly drags the result toward the other operand.
143
+ const alpha = a[3] * p + b[3] * (1 - p);
144
+ if (alpha === 0) return [0, 0, 0, 0];
145
+ return [0, 1, 2]
146
+ .map((i) => (a[i] * a[3] * p + b[i] * b[3] * (1 - p)) / alpha)
147
+ .concat(alpha);
148
+ }
149
+
150
+ throw new Error(`cannot resolve color expression: ${e}`);
151
+ }
152
+
153
+ // flatten a possibly-translucent color onto an opaque backdrop
154
+ const over = (fg, bg) =>
155
+ fg[3] === 1 ? fg : [0, 1, 2].map((i) => clamp(fg[i] * fg[3] + bg[i] * (1 - fg[3]))).concat(1);
156
+
157
+ const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
158
+ const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
159
+ const contrast = (fg, bg) => {
160
+ const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a);
161
+ return (hi + 0.05) / (lo + 0.05);
162
+ };
163
+
164
+ const hex = (c) => '#' + c.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
165
+
166
+ // ── (a) the contract tier is the pack ────────────────────────────────────────
167
+
168
+ test('world.css defaults do not drift from the branding pack', () => {
169
+ // For the pack-owned tokens, world.css's :root is a DEAD DEFAULT — the pack
170
+ // always overrides it. That makes silent drift easy and dangerous: anyone
171
+ // auditing the sheet alone would measure colours no visitor ever sees, and the
172
+ // page reads correctly standalone (opened as a file, with no server) only while
173
+ // the two agree. This is the fifteen: thirteen colours plus the two faces.
174
+ const drift = [];
175
+ const expected = { ...PACK, '--font-display': packUi.fonts.display, '--font-body': packUi.fonts.body };
176
+ for (const [name, packValue] of Object.entries(expected)) {
177
+ const cssValue = contractTier[name];
178
+ if (cssValue === undefined) {
179
+ drift.push(`${name}: absent from world.css :root — the sheet cannot stand alone`);
180
+ continue;
181
+ }
182
+ if (cssValue.toLowerCase() !== String(packValue).toLowerCase()) {
183
+ drift.push(`${name}: world.css ${cssValue} vs pack ${packValue}`);
184
+ }
185
+ }
186
+ assert.deepEqual(drift, [], `\n ${drift.join('\n ')}\n`);
187
+ });
188
+
189
+ // ── (b) the two dark blocks are one block, written twice ─────────────────────
190
+
191
+ test('the two dark blocks stay in lockstep', () => {
192
+ // world.css carries dark twice — an explicit data-mode hook (the toggle) and a
193
+ // prefers-color-scheme twin (the OS setting). A change to one and not the
194
+ // other is the classic regression here, and it only shows up for half of users.
195
+ assert.deepEqual(
196
+ darkMedia,
197
+ darkExplicit,
198
+ 'the @media (prefers-color-scheme: dark) twin drifted from :root[data-mode="dark"]',
199
+ );
200
+ });
201
+
202
+ // ── (c) dark never pins a token the pack owns ────────────────────────────────
203
+
204
+ test('the dark blocks never pin a token the branding pack owns', () => {
205
+ // The pack injects a plain :root — specificity (0,1,0). Both dark selectors are
206
+ // (0,2,0), so ANY literal declared there outranks the pack forever and that
207
+ // instance wears Cloud Bongos' salmon in dark mode. A dark override of a
208
+ // pack-settable token is only safe if it DERIVES from one (var(--…)) — which is
209
+ // exactly what --accent-deep does here, brightening the accent for a black ground.
210
+ const PACK_OWNED = ['--accent', '--sea', '--terracotta', '--accent-deep'];
211
+ for (const [where, block] of [
212
+ [':root[data-mode="dark"]', darkExplicit],
213
+ ['the prefers-color-scheme twin', darkMedia],
214
+ ]) {
215
+ for (const name of PACK_OWNED) {
216
+ const v = block[name];
217
+ if (v === undefined) continue; // absent = the pack reaches dark. Ideal.
218
+ assert.match(
219
+ v,
220
+ /var\(--/,
221
+ `${where}: ${name} is pinned to a literal — a re-themed instance can never reach dark`,
222
+ );
223
+ }
224
+ }
225
+ });
226
+
227
+ // ── (d) the plates ───────────────────────────────────────────────────────────
228
+
229
+ test('every plate is root-absolute and the file is there', () => {
230
+ // Chromium resolves a url() inside a CUSTOM PROPERTY against the document, not
231
+ // against the stylesheet that declared it. A relative path here would load from
232
+ // / and 404 on every route but this file's own folder — and a missing plate is
233
+ // an empty box, not an error, so nothing tells you.
234
+ const urls = [...src.matchAll(/url\(\s*(["']?)([^)"']+)\1\s*\)/g)]
235
+ .map((m) => m[2].trim())
236
+ .filter((u) => !u.startsWith('data:'));
237
+ assert.ok(urls.length >= 6, 'the world names six plates in pairs — update this test');
238
+ const bad = [];
239
+ for (const u of urls) {
240
+ if (!u.startsWith('/')) { bad.push(`${u} is not root-absolute`); continue; }
241
+ const abs = path.join(LANDING, u.replace(/^\//, ''));
242
+ if (!fs.existsSync(abs)) bad.push(`${u} does not exist under public-landing/`);
243
+ }
244
+ assert.deepEqual(bad, [], `\n ${bad.join('\n ')}\n`);
245
+ // the pairs the world promises: a black-ground plate for dark, its alpha cutout
246
+ // for light. A half-pair means one mode silently falls back to the other's material.
247
+ for (const token of ['--plate-set', '--plate-bead', '--plate-bongos']) {
248
+ assert.ok(contractTier[token], `${token} is gone`);
249
+ assert.ok(contractTier[`${token}-cut`], `${token}-cut is gone — light has no cutout`);
250
+ }
251
+ });
252
+
253
+ // ── (e) the pairs the landing paints ─────────────────────────────────────────
254
+ // need: 4.5 normal text · 3.0 large text and meaningful non-text.
255
+ // world.css's own comments name no ratios, so this list is the landing's real
256
+ // paint, read off public-landing/index.html: --ink/--ink-soft/--ink-faint on the
257
+ // page ground and on the card, the accent inks, the focus ring and the void.
258
+ const PAIRS = [
259
+ // the hero and every band: h1/.fig/.orb-name are --ink, .lede/.cap/.sub/.clause
260
+ // are --ink-soft, .label/.beat .step/.rows .id are --ink-faint, all on --bg.
261
+ ['--ink on the page ground', 'var(--ink)', 'var(--bg)', 4.5],
262
+ ['--ink-soft on the page ground', 'var(--ink-soft)', 'var(--bg)', 4.5],
263
+ ['--ink-faint on the page ground', 'var(--ink-faint)', 'var(--bg)', 4.5],
264
+ // the pills: .pill and the skip link sit on --bg-card (light) / a glass over
265
+ // the ground (dark); --bg-card is the opaque worst case for both.
266
+ ['--ink on a card', 'var(--ink)', 'var(--bg-card)', 4.5],
267
+ ['--ink-soft on a card', 'var(--ink-soft)', 'var(--bg-card)', 4.5],
268
+ // the underlined links: .more ("Open the map") and every footer link.
269
+ ['--accent-ink link on the ground', 'var(--accent-ink)', 'var(--bg)', 4.5],
270
+ // the primary door's label on its accent FILL, and ::selection. The world's
271
+ // salmon carries white at 2.19:1, which is why --accent-on is a dark ink.
272
+ ['--accent-on on the accent fill', 'var(--accent-on)', 'var(--accent)', 4.5],
273
+ // hover keeps the same ink on the lifted fill
274
+ ['--accent-on on the hover lift', 'var(--accent-on)', 'var(--accent-lift)', 4.5],
275
+ // the void is dark-only: in light no black panel is painted anywhere (that IS
276
+ // the light answer), so measuring --void there would measure a pairing nothing
277
+ // renders.
278
+ ['--void-ink on the void', 'var(--void-ink)', 'var(--void)', 4.5, 'dark'],
279
+ ['--void-ink-soft on the void', 'var(--void-ink-soft)', 'var(--void)', 4.5, 'dark'],
280
+ // :focus-visible — non-text at WCAG 1.4.11's 3:1. The ring rides inside a --bg
281
+ // halo, so the ground is what it must clear.
282
+ ['--focus ring on the ground', 'var(--focus)', 'var(--bg)', 3.0],
283
+ // the lit rail dot, non-text at 3:1 — DARK ONLY. See the companion test below:
284
+ // in light the world's salmon is 1.86:1 on the pale ground, and the lit step is
285
+ // identified there by FILL (an empty ring becomes a solid disc) rather than by
286
+ // hue, which is what 1.4.1 and 1.4.11 actually ask for.
287
+ ['the lit rail dot on the ground', 'var(--accent)', 'var(--bg)', 3.0, 'dark'],
288
+ ];
289
+
290
+ for (const mode of ['light', 'dark']) {
291
+ test(`world.css tokens clear WCAG AA in ${mode} mode`, () => {
292
+ const ground = resolve('var(--bg)', mode);
293
+ const failures = [];
294
+ for (const [what, fgExpr, bgExpr, need, onlyMode] of PAIRS) {
295
+ if (onlyMode && onlyMode !== mode) continue;
296
+ const bg = over(resolve(bgExpr, mode), ground);
297
+ const fg = over(resolve(fgExpr, mode), bg);
298
+ const got = contrast(fg, bg);
299
+ if (got < need) {
300
+ failures.push(`${what}: ${got.toFixed(2)}:1 (needs ${need}) — ${hex(fg)} on ${hex(bg)}`);
301
+ }
302
+ }
303
+ assert.deepEqual(failures, [], `\n ${failures.join('\n ')}\n`);
304
+ });
305
+ }
306
+
307
+ test('the lit rail step is identified in light by fill, not by the accent alone', () => {
308
+ // The companion to the dark-only accent row above, and the reason it is
309
+ // dark-only. On the pale ground the accent measures 1.86:1, so if the lit step
310
+ // were told from the idle ones by HUE it would fail both 1.4.1 (use of colour)
311
+ // and 1.4.11. It is not: the idle dot is an unfilled ring, the lit dot is a
312
+ // solid disc, and the idle ring's own --ink-faint border clears 3:1 on the
313
+ // ground — so the state reads as a shape difference with the accent as
314
+ // reinforcement. If the dots ever become the same shape, this test is the one
315
+ // that has to fail before the contrast row can be re-armed.
316
+ const css = PAGE.match(/<style>([\s\S]*?)<\/style>/)[1].replace(/\/\*[\s\S]*?\*\//g, '');
317
+ const idle = css.match(/\.beat \.dot\{([^}]*)\}/);
318
+ const lit = css.match(/\.beat \.lit \.dot\{([^}]*)\}/);
319
+ assert.ok(idle && lit, 'the rail dots moved — update this test');
320
+ assert.doesNotMatch(idle[1], /background/, 'the idle dot must stay an unfilled ring');
321
+ assert.match(idle[1], /border:[^;]*var\(--ink-faint\)/);
322
+ assert.match(lit[1], /background:\s*var\(--accent\)/, 'the lit dot must be a filled disc');
323
+ const ground = resolve('var(--bg)', 'light');
324
+ const ring = contrast(over(resolve('var(--ink-faint)', 'light'), ground), ground);
325
+ assert.ok(ring >= 3.0, `the idle ring is ${ring.toFixed(2)}:1 on the ground (needs 3.0)`);
326
+ });
327
+
328
+ // ── (f) the kill switch ──────────────────────────────────────────────────────
329
+
330
+ test('the reduced-motion kill switch is present, universal, and LAST', () => {
331
+ // Every animation in this world is authored on a plate or a control, and this
332
+ // one block is what stops all of them — the page itself carries no
333
+ // prefers-reduced-motion rule (tests/landing_page.mjs locks that half). It has
334
+ // to be last: `animation: none !important` loses to a LATER !important
335
+ // declaration on source order, so a rule appended below it would escape.
336
+ const at = src.search(/@media\s*\(prefers-reduced-motion:\s*reduce\)/);
337
+ assert.notEqual(at, -1, 'world.css must keep a prefers-reduced-motion block');
338
+ const open = src.indexOf('{', at);
339
+ const body = bodyAt(open);
340
+ assert.match(
341
+ body,
342
+ /\*\s*,\s*\*::before\s*,\s*\*::after\s*\{[^}]*animation:\s*none\s*!important/,
343
+ 'the universal selector + !important is what stops the sculpture',
344
+ );
345
+ assert.match(body, /transition:\s*none\s*!important/, 'the transitions stop too');
346
+ assert.equal(
347
+ src.slice(endOf(open) + 1).trim(),
348
+ '',
349
+ 'a rule follows the kill switch — anything after it can outrank it on source order',
350
+ );
351
+ });