@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.
- package/.bongos-core.json +67 -47
- package/docs/copy-inventory.md +354 -352
- package/docs/copy-registry.json +406 -388
- package/docs/module-api-changelog.md +4 -0
- 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/index.states.json +17 -0
- 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 +1023 -455
- 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.css +226 -100
- package/modules/hall-ui/public/work.html +15 -4
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/hall-ui/public/work.states.json +19 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_board_world.mjs +153 -0
- 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
|
+
});
|