@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.
- package/.bongos-core.json +144 -59
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/.claude/skills/grader-health/SKILL.md +1 -1
- package/config/branding.neutral.json +19 -19
- package/config/design-tokens.neutral.json +31 -36
- package/docs/adr/0196-chrome-world-token-contract-and-the-landing-sheets.md +31 -0
- package/docs/adr/README.md +1 -0
- package/docs/branding-contract.md +1 -1
- package/docs/copy-inventory.md +93 -67
- package/docs/copy-registry.json +411 -133
- package/docs/file-map.md +15 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/renderer/index.html +1 -1
- package/modules/dev-box/app/renderer/styles.css +3 -1
- package/modules/dev-box/app/renderer/theme.generated.css +13 -13
- package/modules/dev-box/app/scripts/gen-brand.js +13 -13
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +3 -3
- package/modules/dev-box/app/scripts/gen-icons.js +3 -3
- package/modules/dev-box/app/src/brand.generated.json +1 -1
- package/modules/grading/grader-config.js +5 -3
- package/modules/grading/grader-rubric.json +2 -2
- package/modules/grading/grader-score.js +86 -33
- package/modules/grading/grader-workers/index.js +19 -0
- package/modules/hall-ui/public/builders.js +8 -3
- package/modules/provisioning/routes/provisioning.js +4 -4
- package/modules/status-ui/public/explore.html +12 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +82 -64
- package/public-landing/assets/world/og-card.jpg +0 -0
- package/public-landing/assets/world/r2-bongos-chrome-a-cut.webp +0 -0
- package/public-landing/assets/world/r2-bongos-chrome-a-cut.webp.json +12 -0
- package/public-landing/assets/world/r2-bongos-chrome-b.jpg +0 -0
- package/public-landing/assets/world/r2-bongos-chrome-b.jpg.json +12 -0
- package/public-landing/assets/world/r2-chrome-blob-one-cut.webp +0 -0
- package/public-landing/assets/world/r2-chrome-blob-one-cut.webp.json +13 -0
- package/public-landing/assets/world/r2-chrome-blob-one.jpg +0 -0
- package/public-landing/assets/world/r2-chrome-blob-one.jpg.json +12 -0
- package/public-landing/assets/world/r2-chrome-blobs-set-cut.webp +0 -0
- package/public-landing/assets/world/r2-chrome-blobs-set-cut.webp.json +13 -0
- package/public-landing/assets/world/r2-chrome-blobs-set.jpg +0 -0
- package/public-landing/assets/world/r2-chrome-blobs-set.jpg.json +12 -0
- package/public-landing/assets/world.css +241 -0
- package/public-landing/contact.html +1 -1
- package/public-landing/index.html +1139 -580
- package/public-landing/privacy.html +1 -1
- package/public-landing/projects.html +2 -2
- package/public-landing/terms.html +1 -1
- package/scripts/gds/ship-grade-steps.js +27 -0
- package/src/bongos/platform-visibility-gate.js +8 -7
- package/src/bongos/stealth-gate.js +9 -8
- package/src/module-api.js +5 -1
- package/src/ui/adapters/claude-design/index.js +2 -2
- package/tests/cloud_bongos_pack.mjs +22 -18
- package/tests/cosmos_token_contrast.mjs +23 -8
- package/tests/grader.mjs +7 -6
- package/tests/grader_quality_window_overflow.mjs +324 -0
- package/tests/landing_legal_pages.mjs +8 -5
- package/tests/landing_page.mjs +295 -166
- package/tests/platform_boot.mjs +3 -1
- package/tests/projects_hub.mjs +9 -5
- package/tests/projects_hub_pre_uat.mjs +3 -1
- package/tests/world_tokens.mjs +351 -0
package/tests/platform_boot.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -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
|
|
423
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
+
});
|