@bongos/core 1.24.46 → 1.24.48

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.
@@ -0,0 +1,274 @@
1
+ // tests/hall_controls.mjs — the hall's one button and one field (task 1005084,
2
+ // goal 1000138 "One Cloud Bongos design system").
3
+ //
4
+ // The UI audit counted eight main-button heights and five field styles in the
5
+ // hall. modules/hall-ui/public/controls.css is the one of each that replaced
6
+ // them, and this file is what keeps them one:
7
+ // * THE IMPORT — style.css pulls the sheet in right after the shared token
8
+ // sheet, so every hall page gets it and a page sheet can still place a
9
+ // button (it loads later) without re-drawing it;
10
+ // * THE SIZES — primary is DESIGN.md's 46–48px door, the row size and the
11
+ // text button keep the 24px floor, the field clears it;
12
+ // * CONTRAST, MEASURED — the button's ink on its fill (rest AND hover), the
13
+ // ghost's and the text button's ink on both grounds, the field's text and
14
+ // its edge (3:1, WCAG 1.4.11), in both modes, resolved from the real
15
+ // sheets' tokens the way tests/hall_tokens.mjs does. No white on the salmon;
16
+ // * THE MIGRATION — every older button name the pages still write sits in the
17
+ // shared rules, no page sheet re-draws one (its own height, fill, corner or
18
+ // type size), the dead white-on-salmon setup button stays gone, and the
19
+ // pages the task named (Goals, Work, the setup screen, Settings) write
20
+ // names the shared rules own.
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { createRequire } from 'node:module';
28
+
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
31
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
32
+ const decomment = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
33
+
34
+ const styleSrc = read('style.css');
35
+ const controls = decomment(read('controls.css'));
36
+
37
+ // Every flat rule of a sheet as { sels: [selector…], body }. The hall's sheets
38
+ // nest only @media / @supports, whose inner rules this still reaches (the
39
+ // at-rule's own prelude is dropped by the [^{}] match).
40
+ const rulesOf = (css) => {
41
+ const out = [];
42
+ for (const m of decomment(css).matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
43
+ const sels = m[1].trim().split(/,(?![^(]*\))/).map((s) => s.trim().replace(/\s+/g, ' ')).filter(Boolean);
44
+ out.push({ sels, body: m[2] });
45
+ }
46
+ return out;
47
+ };
48
+ const prop = (body, name) => {
49
+ const m = body.match(new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`));
50
+ return m ? m[1].trim() : null;
51
+ };
52
+ // the body of the first controls.css rule whose selector list holds `sel`
53
+ const sharedRule = (sel) => {
54
+ const r = rulesOf(controls).find((x) => x.sels.includes(sel));
55
+ assert.ok(r, `controls.css has a rule for ${sel}`);
56
+ return r.body;
57
+ };
58
+ // the body of the LAST controls.css rule that holds `sel` AND declares `name` —
59
+ // the cascade's answer when the rules are equally specific
60
+ const sharedWith = (sel, name) => {
61
+ const r = rulesOf(controls).filter((x) => x.sels.includes(sel) && prop(x.body, name) !== null);
62
+ assert.ok(r.length, `controls.css gives ${sel} a ${name}`);
63
+ return r[r.length - 1].body;
64
+ };
65
+ const px = (v) => { const m = String(v).match(/^([\d.]+)px$/); return m ? +m[1] : NaN; };
66
+
67
+ // ── the tokens, resolved (the hall_tokens.mjs resolver, copied: a test file is
68
+ // not a module) ───────────────────────────────────────────────────────────
69
+ const declarations = (body) => {
70
+ const out = {};
71
+ for (const m of decomment(body).matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) out[m[1]] = m[2].replace(/\s+/g, ' ').trim();
72
+ return out;
73
+ };
74
+ const bodyAt = (src, open) => {
75
+ let depth = 0;
76
+ for (let i = open; i < src.length; i++) {
77
+ if (src[i] === '{') depth++;
78
+ else if (src[i] === '}' && --depth === 0) return src.slice(open + 1, i);
79
+ }
80
+ throw new Error('unbalanced braces');
81
+ };
82
+ const blockAfter = (src, marker) => {
83
+ const at = src.indexOf(marker);
84
+ assert.notEqual(at, -1, `missing ${marker}`);
85
+ return declarations(bodyAt(src, src.indexOf('{', at)));
86
+ };
87
+ const tokensSrc = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'public', 'tokens.css'), 'utf8');
88
+ const camelToVar = (k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
89
+ const PACK = Object.fromEntries(Object.entries(JSON.parse(
90
+ fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8')).theme.ui.colors)
91
+ .map(([k, v]) => [camelToVar(k), v]));
92
+ const root = blockAfter(styleSrc, '\n:root {');
93
+ const RAW = {
94
+ light: { ...blockAfter(tokensSrc, ':root {'), ...root, ...PACK },
95
+ dark: { ...blockAfter(tokensSrc, ':root {'), ...root, ...PACK, ...blockAfter(styleSrc, 'html[data-theme="dark"] {') },
96
+ };
97
+
98
+ function resolve(expr, mode, seen = new Set()) {
99
+ const e = String(expr).trim();
100
+ if (e === 'transparent') return [0, 0, 0, 0];
101
+ let m = e.match(/^#([0-9a-f]{6})$/i);
102
+ if (m) { const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]; }
103
+ m = e.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
104
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
105
+ m = e.match(/^var\(\s*(--[\w-]+)\s*(?:,([\s\S]+))?\)$/);
106
+ if (m) {
107
+ assert.ok(!seen.has(m[1]), `cyclic ${m[1]}`);
108
+ const next = RAW[mode][m[1]] ?? m[2];
109
+ assert.ok(next !== undefined, `${m[1]} is not defined in ${mode}`);
110
+ return resolve(next, mode, new Set([...seen, m[1]]));
111
+ }
112
+ m = e.match(/^color-mix\(\s*in\s+srgb\s*,\s*([\s\S]+?)\s+([\d.]+)%\s*,\s*([\s\S]+?)\s*\)$/);
113
+ if (m) {
114
+ const p = +m[2] / 100, a = resolve(m[1], mode, seen), b = resolve(m[3], mode, seen);
115
+ const alpha = a[3] * p + b[3] * (1 - p);
116
+ if (alpha === 0) return [0, 0, 0, 0];
117
+ return [0, 1, 2].map((i) => (a[i] * a[3] * p + b[i] * b[3] * (1 - p)) / alpha).concat(alpha);
118
+ }
119
+ throw new Error(`cannot resolve ${e}`);
120
+ }
121
+ // The ratio is the KIT's own WCAG maths (modules/ui-design/kit/lib.js — what
122
+ // render.js and contrast.js measure with), so "the kit passes AA" is the kit's
123
+ // number, not a second implementation of it.
124
+ const require = createRequire(import.meta.url);
125
+ const kit = require('../modules/ui-design/kit/lib.js');
126
+ const contrast = (fgExpr, bgExpr, mode) => {
127
+ const ground = resolve(bgExpr, mode);
128
+ return kit.contrastRatio(kit.composite(resolve(fgExpr, mode), ground), ground);
129
+ };
130
+ // a fill painted on a ground (the ghost's transparent fill is the ground itself)
131
+ const onGround = (fill, ground, mode) => {
132
+ const f = resolve(fill, mode);
133
+ return f[3] === 0 ? ground : fill;
134
+ };
135
+
136
+ // ── 1. the sheet is the hall's ───────────────────────────────────────────────
137
+
138
+ test('style.css imports controls.css right after the shared token sheet', () => {
139
+ const imports = [...styleSrc.matchAll(/@import\s+url\("([^"]+)"\)/g)].map((m) => m[1]);
140
+ const at = imports.indexOf('/modules/ui-design/public/tokens.css');
141
+ assert.notEqual(at, -1, 'the shared token sheet is still imported');
142
+ assert.equal(imports[at + 1], 'controls.css', 'the shared button + field come straight after the scales');
143
+ });
144
+
145
+ // ── 2. the sizes ─────────────────────────────────────────────────────────────
146
+
147
+ test('primary is the 46–48px pill; the row size and the text button keep the 24px floor', () => {
148
+ const base = sharedRule('.btn');
149
+ const h = px(prop(base, 'min-height'));
150
+ assert.ok(h >= 46 && h <= 48, `.btn is ${h}px — DESIGN.md's door is 46–48px`);
151
+ assert.equal(prop(base, 'border-radius'), 'var(--radius-pill)', 'pill or nothing (DESIGN.md, Shapes)');
152
+ assert.match(prop(base, 'border'), /transparent$/, 'the edge is reserved, so a modifier that sets only its colour draws');
153
+ for (const sel of ['.btn--sm', '.btn--text']) {
154
+ assert.ok(px(prop(sharedWith(sel, 'min-height'), 'min-height')) >= 24, `${sel} keeps 24px of height`);
155
+ assert.ok(px(prop(sharedWith(sel, 'min-width'), 'min-width')) >= 24, `${sel} keeps 24px of width`);
156
+ }
157
+ const field = sharedRule('select');
158
+ assert.ok(px(prop(field, 'min-height')) >= 24, 'the field clears the floor');
159
+ assert.equal(prop(field, 'border-radius'), 'var(--radius-pill)', 'a field is a pill too');
160
+ });
161
+
162
+ // ── 3. AA, measured, in both modes ───────────────────────────────────────────
163
+
164
+ for (const mode of ['light', 'dark']) {
165
+ test(`${mode}: every button and field pair clears AA`, () => {
166
+ const primary = sharedWith('.btn', 'background');
167
+ const hover = sharedRule('.btn:hover:not(:disabled)');
168
+ const ghost = sharedWith('.btn--ghost', 'color');
169
+ const text = sharedWith('.btn--text', 'color');
170
+ const field = sharedRule('select');
171
+ const fieldEdge = prop(field, 'border').replace(/^var\(--line\)\s+solid\s+/, '');
172
+ for (const ground of ['var(--bg)', 'var(--bg-card)']) {
173
+ const pairs = [
174
+ ['primary ink on the accent', prop(primary, 'color'), prop(primary, 'background'), 4.5],
175
+ ['primary ink on the hover lift', prop(hover, 'color'), prop(hover, 'background'), 4.5],
176
+ ['ghost ink', prop(ghost, 'color'), onGround(prop(ghost, 'background'), ground, mode), 4.5],
177
+ ['text-button ink', prop(text, 'color'), ground, 4.5],
178
+ ['field text', prop(field, 'color'), onGround(prop(field, 'background'), ground, mode), 4.5],
179
+ ['field edge (WCAG 1.4.11)', fieldEdge, ground, 3],
180
+ ];
181
+ for (const [what, fg, bg, floor] of pairs) {
182
+ const r = contrast(fg, bg, mode);
183
+ assert.ok(r >= floor, `${mode} ${what} on ${ground}: ${r.toFixed(2)}:1 < ${floor}:1`);
184
+ }
185
+ }
186
+ });
187
+ }
188
+
189
+ test('no hall sheet puts white ink on an accent fill', () => {
190
+ const sheets = fs.readdirSync(HALL).filter((f) => f.endsWith('.css'));
191
+ const hits = [];
192
+ for (const f of sheets) {
193
+ for (const { sels, body } of rulesOf(read(f))) {
194
+ if (!/background(-color)?:\s*var\(--accent\)\s*(;|$)/.test(body)) continue;
195
+ if (/(^|;)\s*color:\s*(#fff\b|#ffffff\b|white\b)/i.test(body)) hits.push(`${f}: ${sels.join(', ')}`);
196
+ }
197
+ }
198
+ assert.deepEqual(hits, [], 'white on the salmon fails AA and DESIGN.md bans it — --accent-on is the ink');
199
+ });
200
+
201
+ // ── 4. the migration ─────────────────────────────────────────────────────────
202
+
203
+ const PRIMARY = ['.btn', '.btn-accent', '.landing__btn', '.task__claim', '.setup-gate__save', '.td-btn--primary', '.pbtn--save', '.craft-save'];
204
+ const GHOST = ['.btn--ghost', '.btn-ghost', '.landing__btn--quiet', '.td-btn', '.wip__release', '.pbtn', '.settings-btn'];
205
+ const SMALL = ['.btn--sm', '.btn-ghost', '.task__claim', '.td-btn', '.wip__release'];
206
+
207
+ test('every older button name sits in the shared rule it means', () => {
208
+ const fill = (sel) => {
209
+ const r = rulesOf(controls).filter((x) => x.sels.includes(sel) && prop(x.body, 'background'));
210
+ return r.length ? prop(r[0].body, 'background') : null;
211
+ };
212
+ for (const sel of PRIMARY) assert.equal(fill(sel), 'var(--accent)', `${sel} is a primary door`);
213
+ for (const sel of GHOST) assert.equal(fill(sel), 'transparent', `${sel} is a ghost`);
214
+ for (const sel of SMALL) assert.equal(prop(sharedWith(sel, 'min-height'), 'min-height'), '30px', `${sel} is the row size`);
215
+ // and the primary-on-a-ghost-base names come AFTER the ghost rule, or the ghost wins on order
216
+ const list = rulesOf(controls);
217
+ const ghostAt = list.findIndex((x) => x.sels.includes('.td-btn') && prop(x.body, 'background') === 'transparent');
218
+ const primaryAt = list.findIndex((x) => x.sels.includes('.td-btn--primary') && prop(x.body, 'background'));
219
+ assert.ok(primaryAt > ghostAt, '.td-btn--primary follows .td-btn, so the shared order paints it');
220
+ });
221
+
222
+ test('no page sheet re-draws a shared button or field', () => {
223
+ const NAMES = [...new Set([...PRIMARY, ...GHOST, '.btn--sm', '.btn--text', '.goal-claim-btn', '.goal-release-btn', '.setup__btn',
224
+ '.pinput', '.psel', '.cb-reply-input', '.curve__filter-select'])];
225
+ const DRAW = ['min-height', 'height', 'padding', 'font-size', 'background', 'border', 'border-radius', 'color'];
226
+ const hits = [];
227
+ for (const f of fs.readdirSync(HALL).filter((x) => x.endsWith('.css') && x !== 'controls.css')) {
228
+ for (const { sels, body } of rulesOf(read(f))) {
229
+ for (const s of sels) {
230
+ // the bare name, or the name on an element (button.craft-save) — a
231
+ // descendant or a state selector is the page placing it, not re-drawing it
232
+ const bare = s.replace(/^[a-z]+(?=\.)/, '');
233
+ if (!NAMES.includes(bare)) continue;
234
+ const drawn = DRAW.filter((p) => prop(body, p) !== null);
235
+ if (drawn.length) hits.push(`${f}: ${s} { ${drawn.join(', ')} }`);
236
+ }
237
+ }
238
+ }
239
+ assert.deepEqual(hits, [], 'a page sheet re-draws a shared control — move the difference into controls.css or drop it');
240
+ });
241
+
242
+ test('the old field duplicates reduce to the shared field', () => {
243
+ const ov = rulesOf(read('oversight.css')).find((x) => x.sels.includes('.ov-field input'));
244
+ assert.ok(!ov || ['min-height', 'padding', 'border', 'border-radius', 'background'].every((p) => prop(ov.body, p) === null),
245
+ 'oversight.css re-states the field the shared sheet already draws');
246
+ const uat = rulesOf(read('goals.css')).find((x) => x.sels.includes('.uat-field textarea'));
247
+ assert.ok(!uat || ['padding', 'border', 'border-radius', 'background'].every((p) => prop(uat.body, p) === null),
248
+ 'goals.css re-draws the UAT textarea');
249
+ for (const sel of ['.filter select', '.filter input[type="search"]', '.td-field input', '.cb-reply-input', '.psel', '.pinput']) {
250
+ for (const f of ['style.css', 'collab.css', 'panel.css']) {
251
+ const r = rulesOf(read(f)).find((x) => x.sels.includes(sel));
252
+ if (!r) continue;
253
+ const drawn = ['min-height', 'padding', 'font-size', 'border', 'border-radius', 'background'].filter((p) => prop(r.body, p) !== null);
254
+ assert.deepEqual(drawn, [], `${f}: ${sel} re-draws the shared field`);
255
+ }
256
+ }
257
+ });
258
+
259
+ test('the pages the task named write names the shared rules own', () => {
260
+ const ALL = new Set([...PRIMARY, ...GHOST, ...SMALL].map((s) => s.slice(1)));
261
+ const cases = [
262
+ ['goals-render.js', /<button class="(task__claim)" type="button" data-claim-task/, 'the Goals claim'],
263
+ ['goals-render.js', /<button class="(wip__release)" type="button" data-release-claim/, 'the Goals release'],
264
+ ['setup-gate.js', /<button type="button" class="(setup-gate__save)" data-save-crafts/, 'the setup screen\'s save'],
265
+ ['work.js', /class="(task__claim)"/, 'the Work claim'],
266
+ ['settings.html', /class="(?:[^"]*\s)?(pbtn|settings-btn|btn)(?:\s[^"]*)?"/, 'a Settings button'],
267
+ ];
268
+ for (const [file, re, what] of cases) {
269
+ const m = read(file).match(re);
270
+ assert.ok(m, `${file}: ${what} is still where this test looks`);
271
+ assert.ok(ALL.has(m[1]), `${file}: ${what} writes .${m[1]}, which the shared rules own`);
272
+ }
273
+ assert.doesNotMatch(styleSrc, /\.setup__btn\b/, 'the dead white-on-salmon setup button stays gone');
274
+ });
@@ -104,10 +104,13 @@ test('the shared confirm modal sits on --z-modal, closed by one hairline and no
104
104
  });
105
105
 
106
106
  test('a number input inside a family field clears the 24px floor', () => {
107
- const css = read('oversight.css');
108
- const i = css.indexOf('.ov-field input, .ov-field select, .ov-field textarea {');
109
- assert.ok(i > 0, 'the field rule exists');
110
- assert.match(css.slice(i, i + 500), /min-height: 30px/, 'the field restates the shell\'s 30px pill');
107
+ // task 1005084: the shared field (controls.css) reaches a number input itself,
108
+ // at 40px; the family field only stretches it to the row.
109
+ const shared = read('controls.css').replace(/\/\*[\s\S]*?\*\//g, '');
110
+ const m = shared.match(/([^{}]*input\[type="number"\][^{}]*)\{([^{}]*min-height[^{}]*)\}/);
111
+ assert.ok(m, 'the shared field rule covers a number input');
112
+ assert.ok(Number((m[2].match(/min-height:\s*(\d+)px/) || [])[1]) >= 24, 'and clears the 24px floor');
113
+ assert.match(read('oversight.css'), /\.ov-field input, \.ov-field select, \.ov-field textarea \{ width: 100%/, 'the family field stretches it');
111
114
  assert.match(read('government.html'), /id="gov-assign-builder" type="number"/, 'the builder id is still a number input');
112
115
  });
113
116
 
@@ -66,6 +66,7 @@ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
66
66
 
67
67
  const styleCss = read('style.css');
68
68
  const recordCss = read('record.css');
69
+ const controlsCss = read('controls.css');
69
70
  const taskCss = read('task.css');
70
71
  const ideaCss = read('idea.css');
71
72
  const ideasCss = read('ideas.css');
@@ -83,6 +84,16 @@ const rule = (css, selector) => {
83
84
  return m ? m[2] : null;
84
85
  };
85
86
  const decomment = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
87
+ // The body of the first rule in `css` whose selector LIST holds `selector` and
88
+ // declares `prop` (task 1005084: the shared controls.css lists the hall's button
89
+ // names together, so a name is rarely the last selector before the brace).
90
+ const ruleHolding = (css, selector, prop) => {
91
+ for (const m of css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
92
+ const sels = m[1].split(',').map((s) => s.trim());
93
+ if (sels.includes(selector) && new RegExp(`(^|;)\\s*${prop}\\s*:`).test(m[2])) return m[2];
94
+ }
95
+ return null;
96
+ };
86
97
 
87
98
  // ---- 1. the right rail belongs to the shell --------------------------------
88
99
 
@@ -134,30 +145,33 @@ test('the ideas composer OPTS IN, and since the fold it opts in WITH THE METER',
134
145
  // ---- 2. the button vocabulary that had no rule -----------------------------
135
146
 
136
147
  test('.btn and .btn--ghost are defined, and say what .btn-accent / .btn-ghost say', () => {
137
- const btn = rule(styleCss, '.btn');
148
+ // task 1005084 moved the hall's buttons into the shared controls.css
149
+ const btn = ruleHolding(controlsCss, '.btn', 'background');
138
150
  assert.ok(btn, '.btn has a rule — six pages rendered browser-default buttons without one');
139
151
  assert.match(btn, /background:\s*var\(--accent\)/, '.btn is the accent door');
140
152
  assert.match(btn, /color:\s*var\(--accent-on\)/, '--accent-on is the dark ink for the salmon');
141
- assert.match(btn, /border-radius:\s*var\(--radius-pill\)/, 'pill or nothing (DESIGN.md, Shapes)');
153
+ const shape = ruleHolding(controlsCss, '.btn', 'border-radius');
154
+ assert.match(shape, /border-radius:\s*var\(--radius-pill\)/, 'pill or nothing (DESIGN.md, Shapes)');
142
155
  // collab.css's .btn--quiet sets only a border-COLOUR; a modifier cannot paint
143
156
  // an edge that was never reserved, so the base has to carry a transparent one.
144
- assert.match(btn, /border:\s*var\(--line\)\s+solid\s+transparent/,
157
+ assert.match(shape, /border:\s*var\(--line\)\s+solid\s+transparent/,
145
158
  'the edge is reserved so a modifier that sets only border-color still draws');
146
- const ghost = rule(styleCss, '.btn--ghost');
159
+ const ghost = ruleHolding(controlsCss, '.btn--ghost', 'background');
147
160
  assert.ok(ghost, '.btn--ghost has a rule');
148
- assert.match(ghost, /background:\s*none/, 'the quiet sibling is a hairline pill, not a fill');
161
+ assert.match(ghost, /background:\s*transparent/, 'the quiet sibling is a hairline pill, not a fill');
162
+ assert.equal(ghost, ruleHolding(controlsCss, '.btn-ghost', 'background'), 'and .btn-ghost says the same, in the same rule');
149
163
  assert.match(ghost, /border-color:\s*var\(--rule\)/, 'and it takes the reserved edge');
150
164
  });
151
165
 
152
166
  // ---- 3. the world's own bans ------------------------------------------------
153
167
 
154
168
  test('nothing in the record family paints text white on the accent', () => {
155
- const primary = rule(styleCss, '.td-btn--primary');
169
+ const primary = ruleHolding(controlsCss, '.td-btn--primary', 'background');
156
170
  assert.ok(primary, '.td-btn--primary has a rule');
157
171
  assert.match(primary, /color:\s*var\(--accent-on\)/, 'the dark ink, not white');
158
172
  // Match white as a VALUE, not anywhere in the block: `white-space: nowrap` is
159
173
  // a property name and would be read as a colour by a bare /\bwhite\b/.
160
- for (const [name, css] of [['style.css .td-btn--primary', primary], ['record.css', decomment(recordCss)],
174
+ for (const [name, css] of [['controls.css .td-btn--primary', primary], ['record.css', decomment(recordCss)],
161
175
  ['task.css', decomment(taskCss)], ['idea.css', decomment(ideaCss)], ['ideas.css', decomment(ideasCss)]]) {
162
176
  assert.doesNotMatch(css, /(background|color):\s*(#fff\b|#ffffff\b|white\b)/i,
163
177
  `${name} paints a white literal — DESIGN.md bans white on the salmon, and every other ground has a token`);
@@ -290,11 +290,14 @@ test('white on the accent is gone, and the Discord mark is the one named excepti
290
290
  // base pill rule AND the save rule, so the save rule is found by its own name
291
291
  // and the member is asserted against it. (`.pair-approve` was its sibling here
292
292
  // until task 1003890 removed the pair page.)
293
- const save = ruleOf(panelCss, '.pbtn--save');
293
+ // task 1005084: the family's buttons are the hall's shared ones (controls.css)
294
+ const controlsCss = read('controls.css');
295
+ const saveRule = stripComments(controlsCss).match(/([^{}]*\.pbtn--save[^{}]*)\{([^{}]*background[^{}]*)\}/);
296
+ const save = saveRule && saveRule[2];
294
297
  assert.ok(save, '.pbtn--save has a rule');
295
298
  assert.match(save, /background:\s*var\(--accent\)/);
296
299
  assert.match(save, /color:\s*var\(--accent-on\)/, 'the dark ink for the salmon — white on it fails AA');
297
- const saveSelector = stripComments(panelCss).match(/([^{}]*\.pbtn--save[^{}]*)\{/);
300
+ const saveSelector = saveRule;
298
301
  assert.ok(saveSelector, 'the save rule\'s selector list is readable');
299
302
  for (const member of ['.craft-save']) {
300
303
  assert.ok(saveSelector[1].split(',').map((s) => s.trim()).includes(member), `${member} is the accent door on its page`);