@bongos/core 1.19.445 → 1.19.447

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 (45) hide show
  1. package/.bongos-core.json +67 -47
  2. package/docs/copy-inventory.md +354 -352
  3. package/docs/copy-registry.json +406 -388
  4. package/docs/module-api-changelog.md +4 -0
  5. package/modules/hall-ui/public/atlas.html +1 -2
  6. package/modules/hall-ui/public/collab.html +1 -2
  7. package/modules/hall-ui/public/copy-desk.css +11 -11
  8. package/modules/hall-ui/public/copy-desk.html +1 -2
  9. package/modules/hall-ui/public/diagrams.html +1 -2
  10. package/modules/hall-ui/public/drachmae.html +1 -2
  11. package/modules/hall-ui/public/gate.html +1 -2
  12. package/modules/hall-ui/public/goals.html +1 -2
  13. package/modules/hall-ui/public/government.html +1 -2
  14. package/modules/hall-ui/public/harbor.html +1 -2
  15. package/modules/hall-ui/public/idea.html +1 -2
  16. package/modules/hall-ui/public/ideas.css +6 -8
  17. package/modules/hall-ui/public/ideas.html +1 -2
  18. package/modules/hall-ui/public/index.html +3 -4
  19. package/modules/hall-ui/public/index.states.json +17 -0
  20. package/modules/hall-ui/public/modules.html +1 -2
  21. package/modules/hall-ui/public/my-projects.html +1 -2
  22. package/modules/hall-ui/public/not-ready.html +1 -2
  23. package/modules/hall-ui/public/pair.html +1 -2
  24. package/modules/hall-ui/public/people.html +1 -2
  25. package/modules/hall-ui/public/primer.html +1 -2
  26. package/modules/hall-ui/public/profile.html +1 -2
  27. package/modules/hall-ui/public/ranks.html +1 -2
  28. package/modules/hall-ui/public/roadmap.html +1 -2
  29. package/modules/hall-ui/public/scouting.html +1 -2
  30. package/modules/hall-ui/public/sessions.html +1 -2
  31. package/modules/hall-ui/public/settings.html +1 -2
  32. package/modules/hall-ui/public/shell.js +35 -2
  33. package/modules/hall-ui/public/style.css +1023 -455
  34. package/modules/hall-ui/public/task.html +1 -2
  35. package/modules/hall-ui/public/watch.css +1 -1
  36. package/modules/hall-ui/public/watch.html +1 -2
  37. package/modules/hall-ui/public/work.css +226 -100
  38. package/modules/hall-ui/public/work.html +15 -4
  39. package/modules/hall-ui/public/work.js +1 -1
  40. package/modules/hall-ui/public/work.states.json +19 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/src/module-api.js +1 -1
  44. package/tests/hall_board_world.mjs +153 -0
  45. package/tests/hall_tokens.mjs +371 -0
@@ -0,0 +1,153 @@
1
+ // tests/hall_board_world.mjs — the Work Board + Home in the chrome world
2
+ // (task 1003304, goal 1000081).
3
+ //
4
+ // tests/hall_tokens.mjs pins the TIER these two pages spend; this pins how they
5
+ // spend it. Everything here is a fact that is silent in a browser: the page
6
+ // still renders, it just renders wrong, or it renders right on one machine and
7
+ // fails a floor on the reviewer's. Each assertion is something the re-skin
8
+ // measured and would otherwise have to re-measure by hand every time.
9
+ //
10
+ // What it locks:
11
+ // * THE CARD-IN-CARD. In v3 a claimable task IS a --bg-card panel, so the
12
+ // section around the list has to be .scroll--bare. Put the .scroll back and
13
+ // the board silently returns to a stack of boxes inside a box, which is
14
+ // exactly the treatment the owner's pick removed — and nothing errors.
15
+ // * THE 24px FLOOR ON BOTH AXES. The two checkboxes were 15px controls; they
16
+ // are now 24px hit areas with a 14px drawn box, and the prose links buy
17
+ // their width with padding and give it back with a negative margin. A
18
+ // later "tidy" that drops the ::before box or the negative margin puts the
19
+ // floor back under, and only the kit render would catch it.
20
+ // * WHITE ON THE SALMON. DESIGN.md bans it outright (it fails AA); the claim
21
+ // button carried a literal `#fff` until this task. It is the single most
22
+ // likely thing to be pasted back from another codebase.
23
+ // * THE COMPAT ALIASES. work.css's whole removal list. The aliases still
24
+ // EXIST for the pages nobody has re-skinned, so spending one here is legal
25
+ // CSS that quietly opts this sheet back out of the world.
26
+ // * THE ONE h1. work.html had none — the hall predates the floor — so the
27
+ // page failed one-header-main-h1 on all 36 width x mode x state renders.
28
+ //
29
+ // Reproduce the renders these came from:
30
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/work.states.json
31
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/index.states.json
32
+
33
+ import { test } from 'node:test';
34
+ import assert from 'node:assert/strict';
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
40
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
41
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
42
+
43
+ const styleCss = read('style.css');
44
+ const workCss = read('work.css');
45
+ const workHtml = read('work.html');
46
+
47
+ // The body of the first rule matching `selector {`, comments stripped. Naive on
48
+ // purpose: these are flat declaration blocks, not nested at-rules. Takes a PLAIN
49
+ // selector and escapes it here — pass a pre-escaped one and it is escaped twice,
50
+ // which matches a literal backslash and silently finds nothing.
51
+ const rule = (css, selector) => {
52
+ const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
+ const re = new RegExp(`(^|[},])\\s*${esc}\\s*\\{([^}]*)\\}`, 'm');
54
+ const m = css.replace(/\/\*[\s\S]*?\*\//g, '').match(re);
55
+ return m ? m[2] : null;
56
+ };
57
+
58
+ // ---- 1. the card is the panel, so the section must not be one ---------------
59
+
60
+ test('the Ready queue is BARE — a task card inside a .scroll is a card in a card', () => {
61
+ const m = workHtml.match(/<section[^>]*id="claim-scroll"[^>]*>/);
62
+ assert.ok(m, 'work.html still has the #claim-scroll section');
63
+ assert.match(m[0], /class="[^"]*\bscroll--bare\b/,
64
+ 'v3 puts claimable tasks on the ground as --bg-card cards; a .scroll around them paints a card inside a card');
65
+ });
66
+
67
+ test('.task IS the panel — --bg-card at the full radius, with no drawn edge at rest', () => {
68
+ const t = rule(styleCss, '.task');
69
+ assert.ok(t, '.task has a rule');
70
+ assert.match(t, /background:\s*var\(--bg-card\)/, 'the card is the panel tone');
71
+ assert.match(t, /border-radius:\s*var\(--radius\)/, 'the full radius, not --radius-sm');
72
+ assert.match(t, /border:\s*var\(--line\)\s+solid\s+transparent/,
73
+ 'the edge is transparent at rest and reserved so hover does not reflow the row');
74
+ });
75
+
76
+ // ---- 2. the 24px floor, on BOTH axes ---------------------------------------
77
+
78
+ // The floor is `width < 24 || height < 24`, and the width half is the one that
79
+ // gets missed: "Git" came back 17x24 after a height-only fix.
80
+ for (const sel of ['.task__select', '.wip__select']) {
81
+ test(`${sel} is a 24px target with a 14px drawn box`, () => {
82
+ const css = sel.includes('wip') ? workCss : styleCss;
83
+ const box = rule(css, sel);
84
+ assert.ok(box, `${sel} has a rule`);
85
+ assert.match(box, /width:\s*24px/, 'the HIT AREA is 24px — this is what the floor measures');
86
+ assert.match(box, /appearance:\s*none/, 'the native box has to go, or sizing it up sizes the glyph up too');
87
+ const before = rule(css, `${sel}::before`);
88
+ assert.ok(before, `${sel}::before draws the box the viewer actually sees`);
89
+ assert.match(before, /width:\s*14px/, 'the drawn box stays 14px inside the 24px target');
90
+ });
91
+ }
92
+
93
+ test('a prose link buys BOTH axes — padding for width, negative margin to give it back', () => {
94
+ const body = styleCss.replace(/\/\*[\s\S]*?\*\//g, '')
95
+ .match(/\.scroll__lede a,[\s\S]*?\{([^}]*)\}/);
96
+ assert.ok(body, 'the shared prose-link floor rule is still keyed on .scroll__lede a');
97
+ assert.match(body[1], /min-width:\s*24px/, 'width is half the floor — "Git" is 17px wide and fails without it');
98
+ assert.match(body[1], /min-height:\s*24px/, 'height is the other half');
99
+ assert.match(body[1], /margin:\s*0\s+-4px/,
100
+ 'the negative margin hands the padding back to the line, so the sentence does not re-space');
101
+ });
102
+
103
+ // ---- 3. the world's own bans ------------------------------------------------
104
+
105
+ test('nothing on the board paints text white on the accent', () => {
106
+ const claim = rule(workCss, '.task__claim');
107
+ assert.ok(claim, '.task__claim has a rule');
108
+ assert.match(claim, /background:\s*var\(--accent\)/, 'the claim button is the page\'s one accent fill');
109
+ assert.match(claim, /color:\s*var\(--accent-on\)/,
110
+ '--accent-on is the dark ink for the salmon; white on it fails AA and DESIGN.md bans it');
111
+ // Match white as a VALUE, not anywhere in the block: `white-space: nowrap` is
112
+ // a property name and read as a colour by a bare /\bwhite\b/.
113
+ assert.doesNotMatch(claim, /:\s*(#fff\b|#ffffff\b|white\b)/i,
114
+ 'the literal this task removed must not come back');
115
+ });
116
+
117
+ test('work.css spends no compat alias — that was the sheet\'s removal list', () => {
118
+ const aliases = ['--surface', '--surface-2', '--border', '--border-strong', '--accent-weak', '--accent-text'];
119
+ const bare = workCss.replace(/\/\*[\s\S]*?\*\//g, '');
120
+ const spent = aliases.filter((a) => new RegExp(`var\\(\\s*${a}\\s*[,)]`).test(bare));
121
+ assert.deepEqual(spent, [],
122
+ 'the aliases still exist for un-reskinned pages, so spending one here is legal CSS that silently opts this sheet out of the world');
123
+ });
124
+
125
+ test('the bulk toolbar carries no rgba literal and is opaque over the cards', () => {
126
+ const bar = rule(workCss, '.bulk-toolbar');
127
+ assert.ok(bar, '.bulk-toolbar has a rule');
128
+ assert.doesNotMatch(bar, /rgba?\(/i,
129
+ 'the three rgba(255,255,255,…) literals went with the inverted --ink surface that needed them');
130
+ // The bar sits over CONTENT, not the page ground, and --glass in light is a 5%
131
+ // tint with the blur at 0 — bare glass let a task title read straight through.
132
+ assert.match(bar, /background:\s*linear-gradient\(var\(--glass\), var\(--glass\)\), var\(--bg\)/,
133
+ 'the tint needs the page ground painted under it, or the bar is transparent in light');
134
+ assert.match(bar, /z-index:\s*var\(--z-/, 'the z ladder, not a hand-picked number');
135
+ });
136
+
137
+ // ---- 4. the landmark floor --------------------------------------------------
138
+
139
+ test('work.html has exactly one h1', () => {
140
+ // Strip HTML comments first: the comment ABOVE the heading names <h1>, and
141
+ // counting it made this read 2 when the page has one.
142
+ const h1s = workHtml.replace(/<!--[\s\S]*?-->/g, '').match(/<h1\b/g) || [];
143
+ assert.equal(h1s.length, 1,
144
+ 'the page had none until task 1003304 and failed one-header-main-h1 on every render');
145
+ });
146
+
147
+ test('both re-skinned pages have a states file, so the floors are reproducible', () => {
148
+ for (const f of ['work.states.json', 'index.states.json']) {
149
+ const json = JSON.parse(read(f));
150
+ assert.equal(json.surface, 'hall-ui', `${f} names its surface`);
151
+ assert.ok(Object.keys(json.states).length >= 4, `${f} renders more than the default state`);
152
+ }
153
+ });
@@ -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
+ });