@bongos/core 1.19.448 → 1.19.450

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.
Files changed (42) hide show
  1. package/.bongos-core.json +96 -36
  2. package/docs/copy-inventory.md +121 -114
  3. package/docs/copy-registry.json +377 -331
  4. package/docs/file-map.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/modules/hall-ui/public/diagram-viewer.css +51 -34
  7. package/modules/hall-ui/public/diagram-viewer.js +4 -1
  8. package/modules/hall-ui/public/diagrams.css +45 -0
  9. package/modules/hall-ui/public/diagrams.html +12 -20
  10. package/modules/hall-ui/public/diagrams.states.json +28 -0
  11. package/modules/hall-ui/public/idea.css +138 -71
  12. package/modules/hall-ui/public/idea.html +13 -3
  13. package/modules/hall-ui/public/idea.js +17 -4
  14. package/modules/hall-ui/public/idea.states.json +14 -0
  15. package/modules/hall-ui/public/ideas.css +148 -107
  16. package/modules/hall-ui/public/ideas.html +15 -5
  17. package/modules/hall-ui/public/ideas.states.json +15 -0
  18. package/modules/hall-ui/public/primer.css +92 -400
  19. package/modules/hall-ui/public/primer.html +68 -26
  20. package/modules/hall-ui/public/primer.js +107 -308
  21. package/modules/hall-ui/public/primer.states.json +38 -0
  22. package/modules/hall-ui/public/ranks.html +9 -1
  23. package/modules/hall-ui/public/ranks.states.json +1 -1
  24. package/modules/hall-ui/public/record.css +324 -0
  25. package/modules/hall-ui/public/roadmap.css +152 -86
  26. package/modules/hall-ui/public/roadmap.html +6 -1
  27. package/modules/hall-ui/public/roadmap.states.json +31 -0
  28. package/modules/hall-ui/public/room.css +441 -0
  29. package/modules/hall-ui/public/style.css +269 -63
  30. package/modules/hall-ui/public/task.css +71 -0
  31. package/modules/hall-ui/public/task.html +15 -32
  32. package/modules/hall-ui/public/task.js +142 -39
  33. package/modules/hall-ui/public/task.states.json +14 -0
  34. package/modules/hall-ui/public/work.css +4 -15
  35. package/modules/ui-design/kit/serve.js +7 -1
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/scripts/hall-preview/server.js +34 -0
  39. package/src/module-api.js +1 -1
  40. package/tests/hall_audit.mjs +18 -11
  41. package/tests/hall_reading_world.mjs +430 -0
  42. package/tests/hall_record_world.mjs +412 -0
@@ -0,0 +1,412 @@
1
+ // tests/hall_record_world.mjs — the task record, the idea record and the idea
2
+ // composer in the chrome world (task 1003305, goal 1000081).
3
+ //
4
+ // tests/hall_tokens.mjs pins the TIER these three pages spend and
5
+ // tests/hall_board_world.mjs pins how the board spends it; this is the same job
6
+ // for the record family. Everything here is a fact that is SILENT in a browser:
7
+ // the page still renders, it just renders wrong, or it renders right here and
8
+ // fails a floor on the reviewer's machine.
9
+ //
10
+ // What it locks:
11
+ // * THE SHELL OVERRIDE STAYS GONE. ideas.css set `.app-content > main
12
+ // { padding-right: 20rem }` and pinned its meter at a hand-copied `6.5rem`
13
+ // topbar height. A page reaching into shell layout is invisible from the
14
+ // shell, and the copy silently desyncs the moment --topbar-h moves. The
15
+ // reserve and the panel now live in style.css as .app-content--railed +
16
+ // .app-rail, and the page opts in from its markup.
17
+ // * `.btn` AND `.btn--ghost` HAVE A RULE. They had none anywhere in the
18
+ // surface — six pages were rendering browser-default buttons on a surface
19
+ // whose every other shape is a pill. The class names are spelled into markup
20
+ // and into tests/idea_spark_hall.mjs, so they are made to SAY the same thing
21
+ // as .btn-accent/.btn-ghost rather than renamed.
22
+ // * WHITE ON THE SALMON. `.td-btn--primary` carried a literal `#fff` on
23
+ // var(--accent) — the pairing DESIGN.md bans outright because it fails AA.
24
+ // It is the single most likely thing to be pasted back.
25
+ // * THE ONE h1, IN EVERY STATE. All three pages had none at load, and the two
26
+ // records had none at all when the fetch failed — one-header-main-h1
27
+ // measures the rendered DOM, not the happy path.
28
+ // * THE INLINE <style> IS GONE. task.html's 27-line block was the hall's last
29
+ // substantive one and sat at its hall_audit ratchet with zero headroom.
30
+ // * THE COMPAT ALIASES. Each sheet's removal list. The aliases still EXIST for
31
+ // the pages nobody has re-skinned, so spending one here is legal CSS that
32
+ // quietly opts the sheet back out of the world.
33
+ // * `--measure` IS THE BODY MEASURE. Spent on a 34px display heading it caps
34
+ // at ~950px and the title runs the panel's width; the title's cap is its own
35
+ // em-based one. The inverse mistake — fresh `ch` arithmetic in body prose —
36
+ // is what idea.css's old `70ch` was.
37
+ //
38
+ // Reproduce the renders these came from (the RECORD pages need a loaded record,
39
+ // which the kit's own stub has no feed for — so they run against the hall's
40
+ // fixture harness):
41
+ // node scripts/hall-preview/server.js --port 4612 --fixture-me
42
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/task.states.json \
43
+ // --base-in http://builders.localhost:4612 --base-out http://builders.localhost:4612
44
+ // (likewise idea.states.json and ideas.states.json)
45
+
46
+ // §9 leaves static analysis behind and RUNS task.js: the three data transforms
47
+ // the re-skin introduced (the figure band's drop rule, the lifecycle rail's
48
+ // status→beat mapping, the status pill's tone lookup) are branching logic, and a
49
+ // render floor only proves the page did not fall over — not that it lit the
50
+ // right beat.
51
+
52
+ import { test } from 'node:test';
53
+ import assert from 'node:assert/strict';
54
+ import fs from 'node:fs';
55
+ import path from 'node:path';
56
+ import vm from 'node:vm';
57
+ import { fileURLToPath } from 'node:url';
58
+
59
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
60
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
61
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
62
+
63
+ const styleCss = read('style.css');
64
+ const recordCss = read('record.css');
65
+ const taskCss = read('task.css');
66
+ const ideaCss = read('idea.css');
67
+ const ideasCss = read('ideas.css');
68
+ const taskHtml = read('task.html');
69
+ const ideaHtml = read('idea.html');
70
+ const ideasHtml = read('ideas.html');
71
+
72
+ // The body of the first rule matching `selector {`, comments stripped. Naive on
73
+ // purpose: these are flat declaration blocks, not nested at-rules. Takes a PLAIN
74
+ // selector and escapes it here — pass a pre-escaped one and it is escaped twice.
75
+ const rule = (css, selector) => {
76
+ const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
+ const re = new RegExp(`(^|[},])\\s*${esc}\\s*\\{([^}]*)\\}`, 'm');
78
+ const m = css.replace(/\/\*[\s\S]*?\*\//g, '').match(re);
79
+ return m ? m[2] : null;
80
+ };
81
+ const decomment = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
82
+
83
+ // ---- 1. the right rail belongs to the shell --------------------------------
84
+
85
+ test('no page sheet reaches into .app-content > main any more', () => {
86
+ for (const [name, css] of [['ideas.css', ideasCss], ['idea.css', ideaCss], ['task.css', taskCss], ['record.css', recordCss]]) {
87
+ assert.doesNotMatch(decomment(css), /\.app-content\s*>\s*main/,
88
+ `${name} overrides shell layout — ask the shell for the room with .app-content--railed instead`);
89
+ }
90
+ });
91
+
92
+ test('the shell owns BOTH halves of the rail, measured from one value', () => {
93
+ const reserve = rule(styleCss, '.app-content--railed > main');
94
+ assert.ok(reserve, 'style.css reserves the room on the content column');
95
+ assert.match(reserve, /--rail-right-w/, 'the reserve is measured from the rail token, not a literal');
96
+ const rail = decomment(styleCss).match(/\.app-rail\s*\{[^}]*\}\s*$|\.app-rail\s*\{([^}]*)\}[\s\S]*?position:\s*fixed/);
97
+ assert.ok(rail, '.app-rail has a rule');
98
+ const fixed = decomment(styleCss).match(/@media \(min-width: 62rem\)\s*\{([\s\S]*?)\n\}/);
99
+ assert.ok(fixed, 'the rail opens at a declared breakpoint');
100
+ assert.match(fixed[1], /top:\s*calc\(var\(--topbar-h\)/,
101
+ 'the panel is placed from --topbar-h; ideas.css hand-copied it as 6.5rem and nothing kept the two in step');
102
+ });
103
+
104
+ test('the ideas composer OPTS IN from its markup', () => {
105
+ assert.match(ideasHtml, /class="app-content app-content--railed"/, 'the content column reserves the room');
106
+ assert.match(ideasHtml, /class="fi-meter app-rail"/, 'and the meter takes the shell\'s rail frame');
107
+ });
108
+
109
+ // ---- 2. the button vocabulary that had no rule -----------------------------
110
+
111
+ test('.btn and .btn--ghost are defined, and say what .btn-accent / .btn-ghost say', () => {
112
+ const btn = rule(styleCss, '.btn');
113
+ assert.ok(btn, '.btn has a rule — six pages rendered browser-default buttons without one');
114
+ assert.match(btn, /background:\s*var\(--accent\)/, '.btn is the accent door');
115
+ assert.match(btn, /color:\s*var\(--accent-on\)/, '--accent-on is the dark ink for the salmon');
116
+ assert.match(btn, /border-radius:\s*var\(--radius-pill\)/, 'pill or nothing (DESIGN.md, Shapes)');
117
+ // collab.css's .btn--quiet sets only a border-COLOUR; a modifier cannot paint
118
+ // an edge that was never reserved, so the base has to carry a transparent one.
119
+ assert.match(btn, /border:\s*var\(--line\)\s+solid\s+transparent/,
120
+ 'the edge is reserved so a modifier that sets only border-color still draws');
121
+ const ghost = rule(styleCss, '.btn--ghost');
122
+ assert.ok(ghost, '.btn--ghost has a rule');
123
+ assert.match(ghost, /background:\s*none/, 'the quiet sibling is a hairline pill, not a fill');
124
+ assert.match(ghost, /border-color:\s*var\(--rule\)/, 'and it takes the reserved edge');
125
+ });
126
+
127
+ // ---- 3. the world's own bans ------------------------------------------------
128
+
129
+ test('nothing in the record family paints text white on the accent', () => {
130
+ const primary = rule(styleCss, '.td-btn--primary');
131
+ assert.ok(primary, '.td-btn--primary has a rule');
132
+ assert.match(primary, /color:\s*var\(--accent-on\)/, 'the dark ink, not white');
133
+ // Match white as a VALUE, not anywhere in the block: `white-space: nowrap` is
134
+ // a property name and would be read as a colour by a bare /\bwhite\b/.
135
+ for (const [name, css] of [['style.css .td-btn--primary', primary], ['record.css', decomment(recordCss)],
136
+ ['task.css', decomment(taskCss)], ['idea.css', decomment(ideaCss)], ['ideas.css', decomment(ideasCss)]]) {
137
+ assert.doesNotMatch(css, /(background|color):\s*(#fff\b|#ffffff\b|white\b)/i,
138
+ `${name} paints a white literal — DESIGN.md bans white on the salmon, and every other ground has a token`);
139
+ }
140
+ });
141
+
142
+ test('the three re-skinned sheets spend no compat alias', () => {
143
+ const aliases = ['--surface', '--surface-2', '--border', '--border-strong', '--accent-weak', '--accent-text'];
144
+ for (const [name, css] of [['record.css', recordCss], ['task.css', taskCss], ['idea.css', ideaCss], ['ideas.css', ideasCss]]) {
145
+ const spent = aliases.filter((a) => new RegExp(`var\\(\\s*${a}\\s*[,)]`).test(decomment(css)));
146
+ assert.deepEqual(spent, [], `${name} still spends an alias — that was this sheet's removal list`);
147
+ }
148
+ });
149
+
150
+ test('--line is spent as a WIDTH in the new sheets, never as a colour', () => {
151
+ for (const [name, css] of [['record.css', recordCss], ['task.css', taskCss], ['idea.css', ideaCss], ['ideas.css', ideasCss]]) {
152
+ assert.doesNotMatch(decomment(css), /(border-color|color|background):[^;]*var\(--line\)/,
153
+ `${name} spends --line as a colour; the hairline's colour is --rule / --rule-soft`);
154
+ }
155
+ });
156
+
157
+ // ---- 4. the reading measure, spent on the right thing -----------------------
158
+
159
+ test('the record title caps on its OWN em measure, not the body --measure', () => {
160
+ const title = rule(recordCss, '.rec-title');
161
+ assert.ok(title, '.rec-title has a rule');
162
+ assert.doesNotMatch(title, /max-width:\s*var\(--measure\)/,
163
+ '--measure is 48ch at the BODY size (~60 characters); on a 34px display face it caps at ~950px and the title runs the panel');
164
+ assert.match(title, /max-width:\s*24ch/, 'the mock\'s own cap, measured on the display face');
165
+ });
166
+
167
+ test('body prose takes --measure rather than fresh ch arithmetic', () => {
168
+ for (const sel of ['.rec-body', '.rec-desc']) {
169
+ assert.match(rule(recordCss, sel) || '', /max-width:\s*var\(--measure\)/, `${sel} reads at the measured width`);
170
+ }
171
+ assert.match(rule(ideaCss, '.ig-answer__a') || '', /max-width:\s*var\(--measure\)/,
172
+ 'idea.css\'s old 70ch rendered ~88 characters — a ch is the zero glyph\'s width, not a character');
173
+ });
174
+
175
+ // ---- 5. the landmark floor, in every state ---------------------------------
176
+
177
+ for (const [file, html] of [['task.html', taskHtml], ['idea.html', ideaHtml], ['ideas.html', ideasHtml]]) {
178
+ test(`${file} has exactly one h1, present before any fetch lands`, () => {
179
+ // Strip HTML comments first: the comment above each heading names <h1>, and
180
+ // counting it reads 2 on a page that has one.
181
+ const h1s = html.replace(/<!--[\s\S]*?-->/g, '').match(/<h1\b/g) || [];
182
+ assert.equal(h1s.length, 1, 'all three pages had none until task 1003305');
183
+ });
184
+ }
185
+
186
+ test('the record pages keep their h1 in the not-found and error renders', () => {
187
+ const taskJs = read('task.js');
188
+ const ideaJs = read('idea.js');
189
+ for (const fn of ['renderNotFound', 'renderError']) {
190
+ const body = taskJs.slice(taskJs.indexOf(`function ${fn}`), taskJs.indexOf(`function ${fn}`) + 900);
191
+ assert.match(body, /<h1 class="rec-big" id="task-h">/, `task.js ${fn} renders a real h1, not a styled <p>`);
192
+ }
193
+ const fail = ideaJs.slice(ideaJs.indexOf('function fail'), ideaJs.indexOf('function fail') + 600);
194
+ assert.match(fail, /<h1 class="rec-big" id="idea-h">/, 'idea.js fail() renders a real h1');
195
+ });
196
+
197
+ // ---- 6. the inline style is gone -------------------------------------------
198
+
199
+ test('task.html carries no inline <style> at all', () => {
200
+ const blocks = [...taskHtml.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].filter((m) => m[1].trim());
201
+ assert.deepEqual(blocks.map((m) => m[1].trim().split('\n').length), [],
202
+ 'the 27-line task-751 block was the hall\'s last substantive one; it is task.css + record.css now');
203
+ assert.match(taskHtml, /href="\/builders\/record\.css"/, 'and the page loads the archetype');
204
+ assert.match(taskHtml, /href="\/builders\/task\.css"/, 'and its own two shapes');
205
+ });
206
+
207
+ test('hall_audit no longer grants task.html an inline-style exception', () => {
208
+ const audit = fs.readFileSync(path.join(ROOT, 'tests', 'hall_audit.mjs'), 'utf8');
209
+ const map = audit.slice(audit.indexOf('const INLINE_STYLE_BUDGET'), audit.indexOf('const DEFAULT_INLINE_BUDGET'));
210
+ assert.doesNotMatch(map, /^\s*'task\.html':/m, 'the exception retires with the block it covered');
211
+ });
212
+
213
+ // ---- 7. the record archetype is ONE sheet, shared ---------------------------
214
+
215
+ test('both record pages load record.css, so the composition cannot drift', () => {
216
+ for (const [file, html] of [['task.html', taskHtml], ['idea.html', ideaHtml]]) {
217
+ assert.match(html, /href="\/builders\/record\.css"/, `${file} loads the shared archetype`);
218
+ }
219
+ // The composer is a form beside a readout, not a record — it must NOT pull the
220
+ // record sheet in just because it is next door.
221
+ assert.doesNotMatch(ideasHtml, /record\.css/, 'the composer is not a record and does not load the archetype');
222
+ });
223
+
224
+ test('the retired idea classes are gone from the sheet as well as the markup', () => {
225
+ for (const c of ['idea-detail__back', 'ig-head', 'ig-title']) {
226
+ assert.doesNotMatch(decomment(ideaCss), new RegExp('\\.' + c + '(?![\\w-])'),
227
+ `.${c} was replaced by the archetype's own class and its rule should have gone with it`);
228
+ }
229
+ });
230
+
231
+ // ---- 8. the floors are reproducible ----------------------------------------
232
+
233
+ test('all three pages have a states file that names its surface and its harness', () => {
234
+ for (const f of ['task.states.json', 'idea.states.json', 'ideas.states.json']) {
235
+ const json = JSON.parse(read(f));
236
+ assert.equal(json.surface, 'hall-ui', `${f} names its surface`);
237
+ assert.ok(Object.keys(json.states).length >= 2, `${f} renders more than one state`);
238
+ assert.match(json._, /hall-preview/,
239
+ `${f} must say WHICH harness its states need — the kit's own stub carries no hall feed, so a record renders "no such task" against it`);
240
+ }
241
+ });
242
+
243
+ test('the preview harness can reach both record pages', () => {
244
+ const server = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'server.js'), 'utf8');
245
+ assert.match(server, /\/\^\\\/task\\\/\\d\+\$\//, 'the harness serves /task/:id');
246
+ assert.match(server, /\/\^\\\/idea\\\/\\d\+\$\//,
247
+ 'and /idea/:id — it was missing, so the idea record fell through to the real gate and 302d to sign-in');
248
+ for (const f of ['inbox__template.json', 'inbox__sparks.json']) {
249
+ assert.ok(fs.existsSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', f)),
250
+ `${f} — without it the composer renders "could not load the idea template" and its five fields are never seen`);
251
+ }
252
+ });
253
+
254
+ // ---- 9. the record's data transforms, RUN ----------------------------------
255
+
256
+ // Boot task.js against a stub DOM + a stub client (the tests/idea_detail_page.mjs
257
+ // pattern) and return the HTML it wrote into #task-body. The page renders by
258
+ // string-building, so this exercises the real render path rather than a
259
+ // re-implementation of it.
260
+ const TASK_SRC = read('task.js');
261
+
262
+ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200 } = {}) {
263
+ const els = new Map();
264
+ const makeEl = () => ({
265
+ dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
266
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
267
+ setAttribute() {}, getAttribute() { return null; },
268
+ _handlers: {},
269
+ addEventListener(type, fn) { this._handlers[type] = fn; },
270
+ querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {}, remove() {},
271
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
272
+ });
273
+ let onReady = null;
274
+ const documentObj = {
275
+ title: 'Task — Cloud Bongos',
276
+ body: { dataset: { page: 'task' } },
277
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
278
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
279
+ querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
280
+ querySelectorAll() { return []; },
281
+ createElement() { return makeEl(); },
282
+ addEventListener(type, fn) { if (type === 'DOMContentLoaded') onReady = fn; },
283
+ };
284
+ const client = {
285
+ request: async (method, p) => {
286
+ if (/\/tasks\/\d+/.test(p)) return { status, ok: status === 200, data: status === 200 ? { task, last_claim: lastClaim } : null };
287
+ if (/\/me$/.test(p)) return { status: 200, ok: true, data: { builder: viewer } };
288
+ if (/\/goals\//.test(p)) return { status: 200, ok: true, data: { goal } };
289
+ return { status: 404, ok: false, data: null };
290
+ },
291
+ };
292
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
293
+ const windowObj = {
294
+ __BRANDING__: { currency: { label: 'credits' } }, __MODULES__: { enabled: {} },
295
+ BongosClient: { createClient: () => client },
296
+ OTB: {
297
+ escapeHtml,
298
+ $: (sel) => documentObj.querySelector(sel),
299
+ fmtDate: (iso) => String(iso).slice(0, 10),
300
+ errCode: (d) => (d && d.error && d.error.code) || null,
301
+ errMessage: (d) => (d && d.error && d.error.message) || null,
302
+ categoryChip: () => '',
303
+ toast() {},
304
+ },
305
+ location: { hostname: 'builders.cloudbongos.com', pathname: `/task/${task && task.id ? task.id : '1'}`, origin: 'https://builders.cloudbongos.com', assign() {} },
306
+ addEventListener() {},
307
+ confirm: () => true,
308
+ navigator: { clipboard: { writeText: async () => {} } },
309
+ };
310
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date };
311
+ sandbox.globalThis = sandbox;
312
+ vm.createContext(sandbox);
313
+ vm.runInContext(TASK_SRC, sandbox, { filename: 'task.js' });
314
+ assert.ok(onReady, 'task.js registers its DOMContentLoaded boot');
315
+ return { boot: onReady, html: () => documentObj.getElementById('task-body').innerHTML, sub: () => documentObj.getElementById('task-sub').textContent };
316
+ }
317
+
318
+ const settle = () => new Promise((r) => setTimeout(r, 0));
319
+
320
+ const BASE_TASK = {
321
+ id: '1003305', title: 'A record in the world', status: 'shipped', version_id: 'BONGOS-V1',
322
+ kind: 'feature', discipline: 'engineer', priority: 1, requires_rank: 'metic',
323
+ credits_reward: 60, est_minutes: 240, actual_minutes: 37,
324
+ created_at: '2026-08-28T00:00:00Z', description: 'the description', touches: ['a.js', 'b.js'],
325
+ dependencies: [], dependents: [],
326
+ };
327
+
328
+ // The lit beat is the whole point of the component: one of five, and the six GDS
329
+ // statuses fold into it. A mapping that quietly falls back to 0 would light
330
+ // "Claim" on a shipped task and nothing would error.
331
+ test('the lifecycle rail lights exactly one beat, and the right one', async () => {
332
+ const expected = {
333
+ backlog: 'Claim', blocked: 'Claim', ready: 'Claim',
334
+ active: 'Build', completed: 'Grade', confirmed: 'Ship', shipped: 'Credit',
335
+ };
336
+ for (const [status, beat] of Object.entries(expected)) {
337
+ const r = renderTask({ ...BASE_TASK, status });
338
+ await r.boot(); await settle();
339
+ const html = r.html();
340
+ const lit = [...html.matchAll(/<li class="tk-step" aria-current="step">[\s\S]*?<span class="tk-step__n">([^<]+)</g)].map((m) => m[1]);
341
+ assert.deepEqual(lit, [beat], `status "${status}" lights ${beat}, and only it`);
342
+ assert.equal((html.match(/aria-current="step"/g) || []).length, 1, `status "${status}" lights exactly one beat`);
343
+ }
344
+ });
345
+
346
+ // The first beat's caption is what keeps the rail honest for the three statuses
347
+ // that share it — without it, a `ready` task reads as already claimed.
348
+ test('the first beat says WHICH of the three unclaimed states it is', async () => {
349
+ const captions = {
350
+ backlog: /not ready to be claimed yet/,
351
+ blocked: /waiting on another task/,
352
+ ready: /waiting for a builder/,
353
+ active: /one builder, one worktree/,
354
+ };
355
+ for (const [status, re] of Object.entries(captions)) {
356
+ const r = renderTask({ ...BASE_TASK, status });
357
+ await r.boot(); await settle();
358
+ assert.match(r.html(), re, `status "${status}" names its own reason`);
359
+ }
360
+ });
361
+
362
+ test('the figure band drops absent figures rather than drawing a dash', async () => {
363
+ const r = renderTask({ ...BASE_TASK, actual_minutes: null });
364
+ await r.boot(); await settle();
365
+ const labels = [...r.html().matchAll(/<span class="rec-fig__l">([^<]+)</g)].map((m) => m[1]);
366
+ assert.ok(!labels.includes('Actual'), 'a task with no recorded actual must not render an empty Actual figure');
367
+ assert.deepEqual(labels, ['credits', 'Estimated', 'Priority'], 'the figures it DOES carry are all drawn, in order');
368
+ });
369
+
370
+ test('under two figures there is no band at all — one figure is not a row', async () => {
371
+ const r = renderTask({ ...BASE_TASK, credits_reward: null, est_minutes: null, actual_minutes: null });
372
+ await r.boot(); await settle();
373
+ assert.doesNotMatch(r.html(), /rec-figs/, 'a single figure renders no band');
374
+ });
375
+
376
+ // The status pill is the one place a lifecycle state is coloured, and it must
377
+ // take the status vocabulary rather than the accent (DESIGN.md §Reuse).
378
+ test('the status pill takes the state vocabulary, and backlog is deliberately neutral', async () => {
379
+ const tones = {
380
+ shipped: 'rec-pill--ok', confirmed: 'rec-pill--ok',
381
+ active: 'rec-pill--info', completed: 'rec-pill--info', ready: 'rec-pill--info',
382
+ blocked: 'rec-pill--warn',
383
+ };
384
+ for (const [status, cls] of Object.entries(tones)) {
385
+ const r = renderTask({ ...BASE_TASK, status });
386
+ await r.boot(); await settle();
387
+ assert.match(r.html(), new RegExp(`class="rec-pill ${cls}">${status}<`), `${status} takes ${cls}`);
388
+ }
389
+ const r = renderTask({ ...BASE_TASK, status: 'backlog' });
390
+ await r.boot(); await settle();
391
+ assert.match(r.html(), /class="rec-pill">backlog</,
392
+ 'backlog is the NEUTRAL pill: the retired inline block painted it amber like blocked, and "not scheduled yet" is not "something is wrong here"');
393
+ assert.doesNotMatch(r.html(), /rec-pill--\w+">backlog/, 'and it takes no state tone at all');
394
+ });
395
+
396
+ test('an open rank floor is said as the absence of one, not "min rank any rank"', async () => {
397
+ const open = renderTask({ ...BASE_TASK, requires_rank: 'xenos' });
398
+ await open.boot(); await settle();
399
+ assert.match(open.html(), /open to any rank/);
400
+ assert.doesNotMatch(open.html(), /min rank any rank/, 'the label prefix read as a minimum that is not one');
401
+ const floored = renderTask({ ...BASE_TASK, requires_rank: 'metic' });
402
+ await floored.boot(); await settle();
403
+ assert.match(floored.html(), /min rank Metic\+/);
404
+ });
405
+
406
+ test('the idea grade badge IS the archetype pill, not a private copy of it', () => {
407
+ const ideaJs = read('idea.js');
408
+ assert.match(ideaJs, /class="rec-pill rec-pill--\$\{escapeHtml\(GRADE_TONE\[g\]\)\}"/,
409
+ 'gradeBadge() spends the shared component');
410
+ assert.doesNotMatch(decomment(ideaCss), /\.ig-grade(?![\w-])/,
411
+ 'idea.css carried a near-verbatim copy of .rec-pill down to the token triples — a hundred lines waiting to drift');
412
+ });