@bongos/core 1.24.52 → 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 +88 -43
- package/docs/copy-inventory.md +85 -86
- package/docs/copy-registry.json +117 -126
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +258 -293
- package/modules/discord/webhook.js +28 -6
- 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/modules/public-landing/public/index.html +11 -8
- package/modules/ui-design/public/brand/og-card.png +0 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-docs/index.html +107 -12
- package/release-notes.json +12 -0
- package/scripts/bongos/gen-brand.js +40 -6
- package/scripts/discord/lib.js +17 -3
- package/scripts/status-mirror-sync.js +11 -7
- package/src/bongos/serve-internal.js +3 -2
- package/src/error-page.js +120 -0
- package/src/link-preview.js +71 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +22 -8
- package/tests/brand_surfaces.mjs +213 -0
- package/tests/discord_board_broadcast.mjs +23 -10
- package/tests/docs_openapi_route.mjs +2 -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
|
@@ -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
|
});
|