@bongos/core 1.24.53 → 1.24.54

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/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.53'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.54'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -19,7 +19,7 @@ import path from 'node:path';
19
19
  import vm from 'node:vm';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { createRequire } from 'node:module';
22
- import { makeRunner, makeEl, walk } from './helpers.mjs';
22
+ import { makeRunner, makeEl, walk, realOTB } from './helpers.mjs';
23
23
 
24
24
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
25
  const HALL = path.join(ROOT, 'modules/hall-ui/public');
@@ -58,6 +58,7 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
58
58
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
59
59
  $: (sel) => documentObj.getElementById(String(sel).replace(/^#/, '')),
60
60
  fmtNum: (n) => String(n), fmtDate: (iso) => String(iso ?? ''), toast() {}, rankLabel: (r) => String(r ?? ''), categoryChip: () => '',
61
+ statusPill: realOTB().statusPill, // the one status pill (task 1005086)
61
62
  },
62
63
  OTBTaskLib: {},
63
64
  OTBKit: {
@@ -128,7 +128,8 @@ test('the removed-class guard CATCHES a reintroduction, and tolerates lookalikes
128
128
  // watch.css legitimately defines badge--sev-low; the guard must not match it.
129
129
  assert.equal(re('sev-low').test('.badge--sev-low { color: red }'), false,
130
130
  'a different class that merely ends with the name is not a reintroduction');
131
- assert.match(read('watch.css'), /\.badge--sev-low/, 'watch.css still owns the live severity styles');
131
+ // task 1005086: the severity badges joined the shared status pill's tones in controls.css
132
+ assert.match(read('controls.css'), /\.badge--sev-low/, 'controls.css owns the live severity styles');
132
133
  });
133
134
 
134
135
  // ---- 5. the page never scrolls sideways (task 1002446) --------------------
@@ -59,6 +59,7 @@ import fs from 'node:fs';
59
59
  import path from 'node:path';
60
60
  import vm from 'node:vm';
61
61
  import { fileURLToPath } from 'node:url';
62
+ import { realOTB } from './helpers.mjs';
62
63
 
63
64
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
64
65
  const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
@@ -403,6 +404,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
403
404
  kindIsUnset: (k) => !k || k === 'unclassified',
404
405
  KIND_UNSET: 'unclassified',
405
406
  escapeHtml,
407
+ statusPill: realOTB().statusPill, // the one status pill (task 1005086)
406
408
  $: (sel) => documentObj.querySelector(sel),
407
409
  fmtDate: (iso) => String(iso).slice(0, 10),
408
410
  errCode: (d) => (d && d.error && d.error.code) || null,
@@ -520,21 +522,23 @@ test('under two figures there is no band at all — one figure is not a row', as
520
522
  // The status pill is the one place a lifecycle state is coloured, and it must
521
523
  // take the status vocabulary rather than the accent (DESIGN.md §Reuse).
522
524
  test('the status pill takes the state vocabulary, and backlog is deliberately neutral', async () => {
525
+ // task 1005086: the tone comes from the hall's ONE map (dom-utils.js STATUS_TONE) and the
526
+ // pill is the ONE .status-pill, so the task page reads exactly as every other page does.
523
527
  const tones = {
524
- shipped: 'rec-pill--ok', confirmed: 'rec-pill--ok',
525
- active: 'rec-pill--info', completed: 'rec-pill--info', ready: 'rec-pill--info',
526
- blocked: 'rec-pill--warn',
528
+ shipped: 'status-pill--ok', confirmed: 'status-pill--ok',
529
+ active: 'status-pill--info', completed: 'status-pill--info', ready: 'status-pill--info',
530
+ blocked: 'status-pill--warn',
527
531
  };
528
532
  for (const [status, cls] of Object.entries(tones)) {
529
533
  const r = renderTask({ ...BASE_TASK, status });
530
534
  await r.boot(); await settle();
531
- assert.match(r.html(), new RegExp(`class="rec-pill ${cls}">${status}<`), `${status} takes ${cls}`);
535
+ assert.match(r.html(), new RegExp(`class="status-pill ${cls}">${status}<`), `${status} takes ${cls}`);
532
536
  }
533
537
  const r = renderTask({ ...BASE_TASK, status: 'backlog' });
534
538
  await r.boot(); await settle();
535
- assert.match(r.html(), /class="rec-pill">backlog</,
539
+ assert.match(r.html(), /class="status-pill status-pill--neutral">backlog</,
536
540
  'backlog is the NEUTRAL pill: the retired inline block painted it amber like blocked, and "not scheduled yet" is not "something is wrong here"');
537
- assert.doesNotMatch(r.html(), /rec-pill--\w+">backlog/, 'and it takes no state tone at all');
541
+ assert.doesNotMatch(r.html(), /status-pill--(ok|warn|danger|info)">backlog/, 'and it takes no state tone at all');
538
542
  });
539
543
 
540
544
  test('an open rank floor is said as the absence of one, not "min rank any rank"', async () => {
@@ -102,7 +102,7 @@ test('goals: rows|cards is a segmented view toggle that still writes #f-goal-vie
102
102
  assert.match(src, /function setGoalView\(view, opts\)/);
103
103
  assert.match(src, /b\.setAttribute\('aria-pressed', String\(b\.dataset\.view === view\)\)/, 'the buttons carry the pressed state');
104
104
  assert.match(src, /if \(savedGoalView === 'cards'\) setGoalView\('cards', \{ fire: false \}\)/, 'the saved pref restores the toggle without a render');
105
- const css = read('goals.css');
105
+ const css = read('controls.css'); // the shared view toggle since task 1005086
106
106
  const pressed = block(css, '.viewtoggle__btn[aria-pressed="true"]');
107
107
  assert.match(pressed, /var\(--accent-tint\)/, 'the pressed side takes the current-thing tint');
108
108
  assert.doesNotMatch(pressed, /var\(--accent\)/, 'never the accent fill: a view is not a live door');
@@ -0,0 +1,251 @@
1
+ // tests/hall_status_pill.mjs — the hall's one status pill, one status→tone map, one toast,
2
+ // one view toggle, and the components specimen (task 1005086, goal 1000138).
3
+ //
4
+ // The UI audit found a task's status drawn three ways (a mono outline .fact-pill, a filled
5
+ // .rec-pill, plain text) with the colour decided per page, a toast whose hold time drifted per
6
+ // page and that printed whatever a failed fetch said (server addresses included), and a view
7
+ // toggle that lived in one page's sheet. This keeps each one ONE:
8
+ // * THE MAP — every task status the database allows maps to one tone, in dom-utils.js, and
9
+ // every hall script that shows a task's status goes through OTB.statusPill;
10
+ // * THE PILL — controls.css holds the one rule; the older names join it; every tone clears
11
+ // AA in both modes, measured from the real sheets' tokens (the hall_controls.mjs resolver);
12
+ // * THE TOAST — a duration per kind, failures read as errors, and plainMessage() strips URLs,
13
+ // hosts and IPs (watched go red with the sanitiser removed);
14
+ // * THE SPECIMEN — modules/hall-ui/specimens/components.html shows every component in every
15
+ // tone, and the kit's states file for it is valid.
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import vm from 'node:vm';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
27
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
28
+ const decomment = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
29
+ const OTB = require(path.join(HALL, 'dom-utils.js'));
30
+
31
+ // ── 1. the map ──────────────────────────────────────────────────────────────
32
+
33
+ // Every status the tasks table allows, read from the migration that set the constraint last.
34
+ function dbStatuses() {
35
+ const dirs = [path.join(ROOT, 'migrations'), ...fs.readdirSync(path.join(ROOT, 'modules')).map((m) => path.join(ROOT, 'modules', m, 'migrations'))];
36
+ let last = null;
37
+ for (const d of dirs) {
38
+ if (!fs.existsSync(d)) continue;
39
+ for (const f of fs.readdirSync(d).filter((n) => n.endsWith('.sql')).sort()) {
40
+ const sql = fs.readFileSync(path.join(d, f), 'utf8');
41
+ const m = sql.match(/ADD CONSTRAINT tasks_status_check\s+CHECK\s*\(\s*status\s+IN\s*\(([^)]*)\)/i);
42
+ if (m) last = m[1];
43
+ }
44
+ }
45
+ assert.ok(last, 'found the tasks_status_check constraint');
46
+ return [...last.matchAll(/'([a-z_]+)'/g)].map((m) => m[1]);
47
+ }
48
+
49
+ test('every task status the database allows maps to exactly one tone, from the six', () => {
50
+ const statuses = dbStatuses();
51
+ assert.ok(statuses.length >= 8, `read ${statuses.length} statuses`);
52
+ for (const s of statuses) {
53
+ assert.ok(Object.prototype.hasOwnProperty.call(OTB.STATUS_TONE, s), `${s} has a tone in STATUS_TONE`);
54
+ assert.ok(OTB.STATUS_TONES.includes(OTB.statusTone(s)), `${s} → a known tone`);
55
+ }
56
+ assert.deepEqual(Object.keys(OTB.STATUS_TONE).sort(), [...statuses].sort(), 'the map names no status the database does not have');
57
+ assert.equal(OTB.statusTone('shipped'), 'ok');
58
+ assert.equal(OTB.statusTone('blocked'), 'warn');
59
+ assert.equal(OTB.statusTone('backlog'), 'neutral', 'not scheduled is not a problem: backlog is never amber');
60
+ assert.equal(OTB.statusTone('no such status'), 'neutral');
61
+ assert.equal(OTB.statusTone(' Shipped '), 'ok', 'case and spacing do not change the tone');
62
+ });
63
+
64
+ test('the pill markup: one class, one tone, the word escaped', () => {
65
+ assert.equal(OTB.statusPill('active'), '<span class="status-pill status-pill--info">active</span>');
66
+ assert.equal(OTB.statusPill('shipped', { small: true, extraClass: 'x' }), '<span class="status-pill status-pill--ok status-pill--sm x">shipped</span>');
67
+ assert.equal(OTB.statusPill('feed unavailable', { tone: 'danger' }), '<span class="status-pill status-pill--danger">feed unavailable</span>');
68
+ assert.match(OTB.statusPill('<b>'), /&lt;b&gt;/);
69
+ assert.match(OTB.statusPill('ready', { label: 'claimable' }), />claimable</);
70
+ });
71
+
72
+ test('a task status looks the same on every page: each page that shows one asks the one map', () => {
73
+ // the pages that render a task's lifecycle status as a word
74
+ const sites = {
75
+ 'task.js': /OTB\.statusPill\(|window\.OTB\.statusPill\(/,
76
+ 'goal-forest-render.js': /statusPill\(r\.status/,
77
+ 'genesis-home.js': /statusPill\(t\.status/,
78
+ 'idea.js': /statusPill\(row\.status/,
79
+ };
80
+ for (const [f, re] of Object.entries(sites)) assert.match(read(f), re, `${f} draws a task status through OTB.statusPill`);
81
+ // no hall script keeps a private status → tone table any more
82
+ const hits = [];
83
+ for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.js') && n !== 'dom-utils.js')) {
84
+ const src = read(f).replace(/\/\/[^\n]*/g, '');
85
+ if (/\{\s*shipped:\s*'(ok|info|warn|danger)'\s*,\s*confirmed:/.test(src)) hits.push(f);
86
+ }
87
+ assert.deepEqual(hits, [], 'a page-local status → tone map would drift from the one in dom-utils.js');
88
+ });
89
+
90
+ // ── 2. the pill ─────────────────────────────────────────────────────────────
91
+
92
+ const controls = decomment(read('controls.css'));
93
+ const rulesOf = (css) => [...decomment(css).matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({
94
+ sels: m[1].trim().split(/,(?![^(]*\))/).map((s) => s.trim().replace(/\s+/g, ' ')).filter(Boolean), body: m[2],
95
+ }));
96
+ const prop = (body, name) => { const m = body.match(new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`)); return m ? m[1].trim() : null; };
97
+ const ruleFor = (sel) => { const r = rulesOf(controls).filter((x) => x.sels.includes(sel)); assert.ok(r.length, `controls.css has ${sel}`); return r; };
98
+ const lastWith = (sel, name) => { const r = ruleFor(sel).filter((x) => prop(x.body, name) !== null); assert.ok(r.length, `${sel} sets ${name}`); return prop(r[r.length - 1].body, name); };
99
+
100
+ test('the one pill: DESIGN.md\'s chip shape, lowercase, and the older names join it', () => {
101
+ const base = ruleFor('.status-pill')[0];
102
+ for (const alias of ['.rec-pill', '.fact-pill', '.gov-pill', '.gate-pill', '.badge']) assert.ok(base.sels.includes(alias), `${alias} is the same pill`);
103
+ assert.equal(prop(base.body, 'min-height'), '26px');
104
+ assert.equal(prop(base.body, 'border-radius'), 'var(--radius-pill)');
105
+ assert.equal(prop(base.body, 'text-transform'), 'lowercase');
106
+ // the old modifiers land on the shared tones
107
+ const same = [['.rec-pill--ok', '.status-pill--ok'], ['.fact-pill--achieved', '.status-pill--ok'], ['.fact-pill--blocked', '.status-pill--warn'],
108
+ ['.fact-pill--open', '.status-pill--info'], ['.fact-pill--archived', '.status-pill--neutral'], ['.rec-pill--danger', '.status-pill--danger']];
109
+ for (const [a, b] of same) assert.equal(lastWith(a, 'color'), lastWith(b, 'color'), `${a} is ${b}`);
110
+ // and nobody else draws them any more
111
+ for (const f of ['style.css', 'record.css']) {
112
+ const sheet = rulesOf(read(f));
113
+ const own = sheet.filter((r) => r.sels.some((s) => /^\.(fact-pill|rec-pill)(--[a-z]+)?$/.test(s)) && /(^|;)\s*(color|background|min-height|font-size)\s*:/.test(r.body));
114
+ assert.deepEqual(own.map((r) => r.sels.join(', ')), [], `${f} re-draws no status pill`);
115
+ }
116
+ });
117
+
118
+ // The token resolver from tests/hall_controls.mjs (a test file is not a module).
119
+ const styleSrc = read('style.css');
120
+ const bodyAt = (src, open) => { let d = 0; for (let i = open; i < src.length; i++) { if (src[i] === '{') d++; else if (src[i] === '}' && --d === 0) return src.slice(open + 1, i); } throw new Error('unbalanced'); };
121
+ const declarations = (body) => Object.fromEntries([...decomment(body).matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].replace(/\s+/g, ' ').trim()]));
122
+ const blockAfter = (src, marker) => { const at = src.indexOf(marker); assert.notEqual(at, -1, marker); return declarations(bodyAt(src, src.indexOf('{', at))); };
123
+ const tokensSrc = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'public', 'tokens.css'), 'utf8');
124
+ const camelToVar = (k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
125
+ const PACK = Object.fromEntries(Object.entries(JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8')).theme.ui.colors).map(([k, v]) => [camelToVar(k), v]));
126
+ const root = blockAfter(styleSrc, '\n:root {');
127
+ const RAW = {
128
+ light: { ...blockAfter(tokensSrc, ':root {'), ...root, ...PACK },
129
+ dark: { ...blockAfter(tokensSrc, ':root {'), ...root, ...PACK, ...blockAfter(styleSrc, 'html[data-theme="dark"] {') },
130
+ };
131
+ function resolve(expr, mode, seen = new Set()) {
132
+ const e = String(expr).trim();
133
+ if (e === 'transparent') return [0, 0, 0, 0];
134
+ let m = e.match(/^#([0-9a-f]{6})$/i);
135
+ if (m) { const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]; }
136
+ m = e.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
137
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
138
+ m = e.match(/^var\(\s*(--[\w-]+)\s*(?:,([\s\S]+))?\)$/);
139
+ if (m) { const next = RAW[mode][m[1]] ?? m[2]; assert.ok(next !== undefined && !seen.has(m[1]), `${m[1]} in ${mode}`); return resolve(next, mode, new Set([...seen, m[1]])); }
140
+ m = e.match(/^color-mix\(\s*in\s+srgb\s*,\s*([\s\S]+?)\s+([\d.]+)%\s*,\s*([\s\S]+?)\s*\)$/);
141
+ if (m) {
142
+ const p = +m[2] / 100, a = resolve(m[1], mode, seen), b = resolve(m[3], mode, seen);
143
+ const alpha = a[3] * p + b[3] * (1 - p);
144
+ return alpha === 0 ? [0, 0, 0, 0] : [0, 1, 2].map((i) => (a[i] * a[3] * p + b[i] * b[3] * (1 - p)) / alpha).concat(alpha);
145
+ }
146
+ throw new Error(`cannot resolve ${e}`);
147
+ }
148
+ const kit = require('../modules/ui-design/kit/lib.js');
149
+
150
+ for (const mode of ['light', 'dark']) {
151
+ test(`${mode}: every tone's word clears AA on its own fill, on both grounds`, () => {
152
+ for (const tone of OTB.STATUS_TONES) {
153
+ const sel = `.status-pill--${tone}`;
154
+ const fg = lastWith(sel, 'color');
155
+ const fill = lastWith(sel, 'background');
156
+ for (const groundExpr of ['var(--bg)', 'var(--bg-card)']) {
157
+ const ground = resolve(groundExpr, mode);
158
+ const bg = kit.composite(resolve(fill, mode), ground);
159
+ const r = kit.contrastRatio(kit.composite(resolve(fg, mode), bg), bg);
160
+ assert.ok(r >= 4.5, `${mode} ${tone} on ${groundExpr}: ${r.toFixed(2)}:1`);
161
+ }
162
+ }
163
+ });
164
+ }
165
+
166
+ test('the view toggle is shared: controls.css holds it, goals.css no longer does', () => {
167
+ ruleFor('.viewtoggle');
168
+ ruleFor('.viewtoggle__btn');
169
+ assert.doesNotMatch(decomment(read('goals.css')), /\.viewtoggle\s*\{/, 'goals.css keeps no copy');
170
+ assert.match(read('goals.html'), /class="viewtoggle"/);
171
+ });
172
+
173
+ test('the panel is one rule: the card ground, the panel corner, no shadow', () => {
174
+ const panel = rulesOf(styleSrc).find((r) => r.sels.includes('.scroll') && r.sels.includes('.panel'));
175
+ assert.ok(panel, '.panel and .scroll are one rule');
176
+ assert.equal(prop(panel.body, 'background'), 'var(--bg-card)');
177
+ assert.equal(prop(panel.body, 'border-radius'), 'var(--ds-radius-panel)');
178
+ assert.equal(prop(panel.body, 'box-shadow'), null, 'a panel never lifts');
179
+ });
180
+
181
+ // ── 3. the toast ────────────────────────────────────────────────────────────
182
+
183
+ test('error toasts never show a server address', () => {
184
+ const cases = [
185
+ ['Could not reach https://10.0.0.5:8443/api/bongos/claims: try again', /^Could not reach the server: try again$/],
186
+ ['request to http://localhost:4600/api failed', /^request to the server failed$/],
187
+ ['connect ECONNREFUSED 127.0.0.1:5432', /^connect ECONNREFUSED the server$/],
188
+ ['Failed at [2001:db8::1]:443 just now', /^Failed at the server just now$/],
189
+ ['Could not load builders.cloudbongos.com/api/me', /^Could not load the server$/],
190
+ ['getaddrinfo ENOTFOUND db.internal', /^getaddrinfo ENOTFOUND the server$/],
191
+ ];
192
+ for (const [raw, want] of cases) {
193
+ const out = OTB.plainMessage(raw);
194
+ assert.match(out, want, raw);
195
+ assert.doesNotMatch(out, /\d+\.\d+\.\d+\.\d+|https?:|:\d{2,5}\b|\.com|\.internal/, `${raw} → ${out}`);
196
+ }
197
+ // a plain sentence is left alone, file names and versions included
198
+ for (const s of ['Task 1005086 claimed.', 'Saved work.js and goals.css.', 'Core 1.24.50 is out.', 'Could not save your key. Try again.']) {
199
+ assert.equal(OTB.plainMessage(s), s);
200
+ }
201
+ });
202
+
203
+ test('one toast, a duration per kind; a failure reads as an error even unlabelled', () => {
204
+ const ms = OTB.TOAST_MS;
205
+ assert.ok(ms.error > ms.warn && ms.warn > ms.info && ms.info > ms.ok, 'an error stays longest, a confirmation shortest');
206
+ assert.equal(OTB.toastKind('Could not end that session: HTTP 500'), 'error');
207
+ assert.equal(OTB.toastKind('That did not go through (409). Try again.'), 'error');
208
+ assert.equal(OTB.toastKind('Session ended.'), 'info');
209
+ assert.equal(OTB.toastKind('Session ended.', 'ok'), 'ok');
210
+ // the live toast: the element shows the plain message, carries the kind, and holds for the kind's time
211
+ const el = { textContent: '', dataset: {} };
212
+ const timers = [];
213
+ const ctx = vm.createContext({ window: {}, document: { querySelector: (s) => (s === '#toast' ? el : null) }, setTimeout: (fn, t) => { timers.push(t); return 1; }, clearTimeout() {}, console });
214
+ ctx.module = { exports: {} };
215
+ vm.runInContext(read('dom-utils.js'), ctx);
216
+ ctx.window.OTB.toast('Could not reach https://10.1.2.3:9000/x');
217
+ assert.equal(el.textContent, 'Could not reach the server');
218
+ assert.equal(el.dataset.kind, 'error');
219
+ assert.equal(el.dataset.visible, '1');
220
+ assert.equal(timers.pop(), ms.error);
221
+ ctx.window.OTB.toast('Saved.', { kind: 'ok' });
222
+ assert.equal(timers.pop(), ms.ok);
223
+ ctx.window.OTB.toast('Saved.', { duration: 1234 });
224
+ assert.equal(timers.pop(), 1234, 'an explicit duration still wins');
225
+ });
226
+
227
+ test('an error toast reads as one: the danger ground with the card ink, in both modes', () => {
228
+ const r = rulesOf(styleSrc).find((x) => x.sels.includes('.toast[data-kind="error"]'));
229
+ assert.ok(r, 'style.css styles the error toast');
230
+ for (const mode of ['light', 'dark']) {
231
+ const bg = resolve(prop(r.body, 'background'), mode);
232
+ const ratio = kit.contrastRatio(kit.composite(resolve(prop(r.body, 'color'), mode), bg), bg);
233
+ assert.ok(ratio >= 4.5, `${mode}: ${ratio.toFixed(2)}:1`);
234
+ }
235
+ });
236
+
237
+ // ── 4. the specimen ─────────────────────────────────────────────────────────
238
+
239
+ test('the components specimen shows every component, every tone, and the kit can render it', () => {
240
+ const dir = path.join(ROOT, 'modules', 'hall-ui', 'specimens');
241
+ const html = fs.readFileSync(path.join(dir, 'components.html'), 'utf8');
242
+ for (const tone of OTB.STATUS_TONES) assert.match(html, new RegExp(`status-pill--${tone}\\b`), `the ${tone} tone`);
243
+ for (const part of ['class="scroll', 'class="empty-state"', 'class="hall-loading"', 'id="spec-toasts"', 'class="kit-tabs"', 'class="viewtoggle"', 'class="btn"', 'class="hall-dialog"', 'id="spec-task-statuses"']) {
244
+ assert.ok(html.includes(part), `the specimen shows ${part}`);
245
+ }
246
+ assert.match(html, /href="\/modules\/hall-ui\/public\/style\.css"/, 'it draws with the hall\'s own sheet');
247
+ const states = JSON.parse(fs.readFileSync(path.join(dir, 'components.states.json'), 'utf8'));
248
+ const v = kit.validateStatesFile(states);
249
+ assert.ok(v.ok, (v.errors || []).join('; '));
250
+ assert.equal(states.surface, 'modules/hall-ui/specimens');
251
+ });
package/tests/helpers.mjs CHANGED
@@ -636,6 +636,7 @@ export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator
636
636
  roleIsUnset: (d) => !d || d === 'unclassified',
637
637
  ROLE_UNSET: 'unclassified',
638
638
  escapeHtml: realOTB().escapeHtml, // the real one (task 1004876)
639
+ statusPill: realOTB().statusPill, // the one status pill (task 1005086)
639
640
  $: (sel) => documentObj.querySelector(sel),
640
641
  fmtNum: (n) => String(n ?? 0),
641
642
  fmtDate: (iso) => String(iso ?? '').slice(0, 10),
@@ -750,6 +751,7 @@ export function bootIdeaPage({ idea = null, status = 200, template = null, viewe
750
751
  BongosClient: { createClient: () => client },
751
752
  OTB: {
752
753
  escapeHtml: realOTB().escapeHtml, // the real one (task 1004876)
754
+ statusPill: realOTB().statusPill, // the one status pill (task 1005086)
753
755
  $: (sel) => documentObj.querySelector(sel),
754
756
  fmtDate: (iso) => String(iso ?? '').slice(0, 10),
755
757
  toast() {},
@@ -115,7 +115,8 @@ function rowsOf(html) {
115
115
  id,
116
116
  mark: m[1],
117
117
  depth: Number(m[2]),
118
- meta: (/<span class="ig-desc__meta">([\s\S]*?)<\/span>/.exec(body) || [])[1] || '',
118
+ // to the meta's own line end: it holds a status pill (a span of its own) since task 1005086
119
+ meta: (/<span class="ig-desc__meta">([^\n]*)<\/span>\n/.exec(body) || [])[1] || '',
119
120
  title: (/<a class="ig-desc__title"[^>]*>([\s\S]*?)<\/a>/.exec(body) || [])[1] || '',
120
121
  href: (/<a class="ig-desc__title" href="([^"]+)"/.exec(body) || [])[1] || '',
121
122
  });