@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/.bongos-core.json +58 -33
- package/docs/copy-inventory.md +58 -58
- package/docs/copy-registry.json +66 -66
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +193 -225
- package/modules/hall-ui/public/controls.css +96 -0
- package/modules/hall-ui/public/dom-utils.js +64 -3
- package/modules/hall-ui/public/genesis-home.js +1 -1
- package/modules/hall-ui/public/goal-forest-render.js +1 -1
- package/modules/hall-ui/public/goals.css +2 -8
- package/modules/hall-ui/public/idea.js +1 -1
- package/modules/hall-ui/public/record.css +3 -20
- package/modules/hall-ui/public/roster.css +0 -2
- package/modules/hall-ui/public/style.css +14 -26
- package/modules/hall-ui/public/task.js +8 -10
- package/modules/hall-ui/public/watch.css +1 -5
- package/modules/hall-ui/specimens/components.css +26 -0
- package/modules/hall-ui/specimens/components.html +115 -0
- package/modules/hall-ui/specimens/components.js +27 -0
- package/modules/hall-ui/specimens/components.states.json +35 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/goal_forest_page.mjs +2 -1
- package/tests/hall_audit.mjs +2 -1
- package/tests/hall_record_world.mjs +10 -6
- package/tests/hall_roster_world.mjs +1 -1
- package/tests/hall_status_pill.mjs +251 -0
- package/tests/helpers.mjs +2 -0
- package/tests/idea_descent_surface.mjs +2 -1
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.
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: {
|
package/tests/hall_audit.mjs
CHANGED
|
@@ -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
|
-
|
|
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: '
|
|
525
|
-
active: '
|
|
526
|
-
blocked: '
|
|
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="
|
|
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="
|
|
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(), /
|
|
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('
|
|
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>'), /<b>/);
|
|
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: (
|
|
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
|
});
|