@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.
- package/.bongos-core.json +48 -28
- package/config/design-tokens.neutral.json +10 -10
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +35 -35
- package/modules/hall-ui/public/collab.css +3 -22
- package/modules/hall-ui/public/controls.css +223 -0
- package/modules/hall-ui/public/goals.css +4 -23
- package/modules/hall-ui/public/my-agents.css +3 -25
- package/modules/hall-ui/public/oversight.css +3 -17
- package/modules/hall-ui/public/panel.css +6 -48
- package/modules/hall-ui/public/speciality-tools.js +14 -14
- package/modules/hall-ui/public/style.css +24 -246
- package/modules/ui-design/scripts/gen-design-tokens.js +102 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/bulk_toolbar.mjs +4 -1
- package/tests/design_tokens_generated.mjs +56 -0
- package/tests/hall_board_world.mjs +6 -2
- package/tests/hall_builders_page_pass.mjs +6 -4
- package/tests/hall_controls.mjs +274 -0
- package/tests/hall_oversight_world.mjs +7 -4
- package/tests/hall_record_world.mjs +21 -7
- package/tests/hall_settings_world.mjs +5 -2
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
159
|
+
const ghost = ruleHolding(controlsCss, '.btn--ghost', 'background');
|
|
147
160
|
assert.ok(ghost, '.btn--ghost has a rule');
|
|
148
|
-
assert.match(ghost, /background:\s*
|
|
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 =
|
|
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 [['
|
|
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
|
-
|
|
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 =
|
|
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`);
|