@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.
- package/.bongos-core.json +154 -49
- package/docs/adr/0054-session-token-reward-cost-plus.md +17 -0
- package/docs/copy-inventory.md +353 -352
- package/docs/copy-registry.json +397 -388
- package/docs/design/hall-direction-v2.md +295 -0
- package/docs/design/mocks/hall/_tokens.css +370 -0
- package/docs/design/mocks/hall/board/v1.html +846 -0
- package/docs/design/mocks/hall/board/v2.html +858 -0
- package/docs/design/mocks/hall/board/v3.html +884 -0
- package/docs/design/mocks/hall/goals/v1.html +1212 -0
- package/docs/design/mocks/hall/goals/v2.html +1192 -0
- package/docs/design/mocks/hall/goals/v3.html +1205 -0
- package/docs/design/mocks/hall/hall-sheet.html +244 -0
- package/docs/design/mocks/hall/reading/v1.html +1125 -0
- package/docs/design/mocks/hall/reading/v2.html +1177 -0
- package/docs/design/mocks/hall/reading/v3.html +1150 -0
- package/docs/design/mocks/hall/settings/v1.html +1238 -0
- package/docs/design/mocks/hall/settings/v2.html +1235 -0
- package/docs/design/mocks/hall/settings/v3.html +1299 -0
- package/docs/design/mocks/hall/task/v1.html +637 -0
- package/docs/design/mocks/hall/task/v2.html +646 -0
- package/docs/design/mocks/hall/task/v3.html +651 -0
- package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +46 -13
- package/modules/hall-ui/public/atlas.html +1 -2
- package/modules/hall-ui/public/collab.html +1 -2
- package/modules/hall-ui/public/copy-desk.css +11 -11
- package/modules/hall-ui/public/copy-desk.html +1 -2
- package/modules/hall-ui/public/diagrams.html +1 -2
- package/modules/hall-ui/public/drachmae.html +1 -2
- package/modules/hall-ui/public/gate.html +1 -2
- package/modules/hall-ui/public/goals.html +1 -2
- package/modules/hall-ui/public/government.html +1 -2
- package/modules/hall-ui/public/harbor.html +1 -2
- package/modules/hall-ui/public/idea.html +1 -2
- package/modules/hall-ui/public/ideas.css +6 -8
- package/modules/hall-ui/public/ideas.html +1 -2
- package/modules/hall-ui/public/index.html +3 -4
- package/modules/hall-ui/public/modules.html +1 -2
- package/modules/hall-ui/public/my-projects.html +1 -2
- package/modules/hall-ui/public/not-ready.html +1 -2
- package/modules/hall-ui/public/pair.html +1 -2
- package/modules/hall-ui/public/people.html +1 -2
- package/modules/hall-ui/public/primer.html +1 -2
- package/modules/hall-ui/public/profile.html +1 -2
- package/modules/hall-ui/public/ranks.html +1 -2
- package/modules/hall-ui/public/roadmap.html +1 -2
- package/modules/hall-ui/public/scouting.html +1 -2
- package/modules/hall-ui/public/sessions.html +1 -2
- package/modules/hall-ui/public/settings.html +1 -2
- package/modules/hall-ui/public/shell.js +35 -2
- package/modules/hall-ui/public/style.css +818 -392
- package/modules/hall-ui/public/task.html +1 -2
- package/modules/hall-ui/public/watch.css +1 -1
- package/modules/hall-ui/public/watch.html +1 -2
- package/modules/hall-ui/public/work.html +1 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-subagent-rewards.js +485 -0
- package/src/module-api.js +1 -1
- package/tests/backfill_subagent_rewards.mjs +308 -0
- 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
|
+
});
|