@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.
Files changed (46) hide show
  1. package/.bongos-core.json +88 -43
  2. package/docs/copy-inventory.md +85 -86
  3. package/docs/copy-registry.json +117 -126
  4. package/docs/file-map.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-readings.json +258 -293
  7. package/modules/discord/webhook.js +28 -6
  8. package/modules/hall-ui/public/controls.css +96 -0
  9. package/modules/hall-ui/public/dom-utils.js +64 -3
  10. package/modules/hall-ui/public/genesis-home.js +1 -1
  11. package/modules/hall-ui/public/goal-forest-render.js +1 -1
  12. package/modules/hall-ui/public/goals.css +2 -8
  13. package/modules/hall-ui/public/idea.js +1 -1
  14. package/modules/hall-ui/public/record.css +3 -20
  15. package/modules/hall-ui/public/roster.css +0 -2
  16. package/modules/hall-ui/public/style.css +14 -26
  17. package/modules/hall-ui/public/task.js +8 -10
  18. package/modules/hall-ui/public/watch.css +1 -5
  19. package/modules/hall-ui/specimens/components.css +26 -0
  20. package/modules/hall-ui/specimens/components.html +115 -0
  21. package/modules/hall-ui/specimens/components.js +27 -0
  22. package/modules/hall-ui/specimens/components.states.json +35 -0
  23. package/modules/public-landing/public/index.html +11 -8
  24. package/modules/ui-design/public/brand/og-card.png +0 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/public-docs/index.html +107 -12
  28. package/release-notes.json +12 -0
  29. package/scripts/bongos/gen-brand.js +40 -6
  30. package/scripts/discord/lib.js +17 -3
  31. package/scripts/status-mirror-sync.js +11 -7
  32. package/src/bongos/serve-internal.js +3 -2
  33. package/src/error-page.js +120 -0
  34. package/src/link-preview.js +71 -0
  35. package/src/module-api.js +1 -1
  36. package/src/platform-server.js +22 -8
  37. package/tests/brand_surfaces.mjs +213 -0
  38. package/tests/discord_board_broadcast.mjs +23 -10
  39. package/tests/docs_openapi_route.mjs +2 -1
  40. package/tests/goal_forest_page.mjs +2 -1
  41. package/tests/hall_audit.mjs +2 -1
  42. package/tests/hall_record_world.mjs +10 -6
  43. package/tests/hall_roster_world.mjs +1 -1
  44. package/tests/hall_status_pill.mjs +251 -0
  45. package/tests/helpers.mjs +2 -0
  46. 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.52'; // 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');
@@ -46,6 +46,11 @@ const path = require('path');
46
46
  const express = require('express');
47
47
  const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal } = require('./bongos/serve-internal');
48
48
  const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
49
+ const { errorHandlers } = require('./error-page');
50
+ const { applyLinkPreview } = require('./link-preview');
51
+ const { clientBranding, branding } = require('./branding');
52
+ // The instance's public origin, for an absolute link-preview image URL ('' when unset).
53
+ const safeOrigin = () => { try { return branding().domains.publicOrigin || ''; } catch { return ''; } };
49
54
  const moduleLoader = require('./module-loader/loader');
50
55
  const { start: startErrorTally, startDailySend: startErrorReports } = require('./module-loader/error-tally');
51
56
  const { isModuleEnabled } = require('./modules');
@@ -317,9 +322,14 @@ function buildPlatformApp() {
317
322
  // per request, because modules mount inside mountInternalSurfaces above. No
318
323
  // provider (the module is off, as on every instance but the hub) → next(),
319
324
  // and /raise falls through to the 404 tail like any unknown path.
325
+ // The module renders its own HTML, so the platform's one link-preview card (task
326
+ // 1005091) is added here, on the way out, to any page it sends.
320
327
  app.use('/raise', (req, res, next) => {
321
328
  const raise = isApexHost(req) && seams.resolveOptional('raise.web');
322
329
  if (!raise) return next();
330
+ const send = res.send.bind(res);
331
+ res.send = (body) => send(typeof body === 'string' && body.includes('</head>')
332
+ ? applyLinkPreview(body, clientBranding(), safeOrigin()) : body);
323
333
  Promise.resolve(raise.handle(req, res, next)).catch(next);
324
334
  });
325
335
  // THE ACCOUNT PAGE (task 1004259, ADR 0337 D4.3): where a person edits what
@@ -402,15 +412,19 @@ function buildPlatformApp() {
402
412
  // or the builders' hall lands here. There is NO game to fall through to, so:
403
413
  // * an API-shaped path gets a self-describing JSON 404 (never HTML), the
404
414
  // same contract server.js gives so a mis-prefixed CLI call is legible;
405
- // * everything else gets a plain-text 404 — a game-less instance ships no
406
- // themed island page (public/404.html lives in the game module).
407
- app.use((req, res) => {
408
- res.status(404);
409
- if (isApiShapedPath(req.path)) {
410
- return res.json(apiNotFoundBody(req.path));
411
- }
412
- return res.type('text/plain').send('404 — not found');
415
+ // * a browser asking for a page gets the branded 404 (src/error-page.js,
416
+ // task 1005091), and any other client the plain-text 404 it always had.
417
+ // Then the trailing error net: a page route that throws answers the branded 500
418
+ // to a browser instead of Express's bare "Internal Server Error".
419
+ const { notFound, onError } = errorHandlers({
420
+ brandHtml: injectBrandingGlobal,
421
+ notFoundCopy: () => { try { return clientBranding().copy.notFound; } catch { return 'Page not found.'; } },
422
+ isApiShapedPath,
423
+ apiNotFoundBody,
424
+ log: (err, req) => logger.error({ err, method: req.method, url: req.originalUrl }, 'page request failed'),
413
425
  });
426
+ app.use(notFound);
427
+ app.use(onError);
414
428
 
415
429
  return app;
416
430
  }
@@ -0,0 +1,213 @@
1
+ // tests/brand_surfaces.mjs — the brand outside the hall (task 1005091, goal 1000138):
2
+ // the link-preview card every public page carries, the branded 404 and 500 pages,
3
+ // the themed API docs, the brand colour on Discord posts, and no maker's name written
4
+ // into the portable landing.
5
+ //
6
+ // Behaviour, not spelling: the server is built and asked for pages over HTTP, the
7
+ // error handlers and the link-preview template are called, and the Discord posters
8
+ // send to a stubbed fetch whose bodies are read back.
9
+ //
10
+ // Run: node --test tests/brand_surfaces.mjs
11
+
12
+ import { test, before, after } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import fs from 'node:fs';
15
+ import http from 'node:http';
16
+ import path from 'node:path';
17
+ import { createRequire } from 'node:module';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const { buildPlatformApp } = require('../src/platform-server.js');
23
+ const { linkPreviewTags, applyLinkPreview, CARD_PATH } = require('../src/link-preview.js');
24
+ const { errorPageTemplate, errorHandlers } = require('../src/error-page.js');
25
+ const { applyBrandTokens, injectBrandingGlobal } = require('../src/bongos/serve-internal.js');
26
+ const { clientBranding } = require('../src/branding.js');
27
+ const webhook = require('../modules/discord/webhook.js');
28
+ const discordLib = require('../scripts/discord/lib.js');
29
+ const { ogCardHtml } = require('../scripts/bongos/gen-brand.js');
30
+
31
+ const BRAND = clientBranding();
32
+ const PRODUCT = BRAND.identity.productName;
33
+
34
+ let server;
35
+ let port;
36
+ before(async () => {
37
+ server = http.createServer(buildPlatformApp());
38
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
39
+ port = server.address().port;
40
+ });
41
+ after(async () => { await new Promise((resolve) => server.close(resolve)); });
42
+
43
+ // node:http, not fetch: fetch drops a Host override.
44
+ function get(p, { host = '127.0.0.1', accept = 'text/html,application/xhtml+xml,*/*;q=0.8' } = {}) {
45
+ return new Promise((resolve, reject) => {
46
+ const req = http.request({ host: '127.0.0.1', port, path: p, headers: { Host: host, Accept: accept } }, (res) => {
47
+ let body = '';
48
+ res.setEncoding('utf8');
49
+ res.on('data', (c) => { body += c; });
50
+ res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'] || '', body }));
51
+ });
52
+ req.on('error', reject);
53
+ req.end();
54
+ });
55
+ }
56
+
57
+ // ── the link-preview card ───────────────────────────────────────────────────────
58
+ test('the card is built from the page\'s own title and description, with the shared image', () => {
59
+ const html = '<html><head><title>Status &amp; more — Acme</title><meta name="description" content="What &quot;shipped&quot;."></head><body></body></html>';
60
+ const brand = { identity: { productName: 'Acme' }, copy: { tagline: 'unused' } };
61
+ const tags = linkPreviewTags(html, brand, 'https://acme.example/');
62
+ assert.ok(tags.includes('<meta property="og:title" content="Status &amp; more — Acme">'), 'escaped once, not twice');
63
+ assert.ok(tags.includes('<meta property="og:description" content="What &quot;shipped&quot;.">'));
64
+ assert.ok(tags.includes(`<meta property="og:image" content="https://acme.example${CARD_PATH}">`), 'absolute when the instance has an origin');
65
+ assert.ok(tags.includes('<meta name="twitter:card" content="summary_large_image">'));
66
+ assert.ok(tags.includes('<meta property="og:site_name" content="Acme">'));
67
+ assert.ok(linkPreviewTags('<title>x</title>', {}, '').includes(`content="${CARD_PATH}"`), 'root-relative with no origin');
68
+ // a page that writes its own card keeps it
69
+ assert.equal(linkPreviewTags('<head><meta property="og:title" content="own"></head>', brand, ''), '');
70
+ const applied = applyLinkPreview(html, brand, '');
71
+ assert.ok(applied.indexOf('og:image') < applied.indexOf('</head>'));
72
+ });
73
+
74
+ test('every served public page carries the card: status, docs, and anything branded', async () => {
75
+ const status = await get('/', { host: 'status.example.com' });
76
+ assert.equal(status.status, 200);
77
+ assert.match(status.body, new RegExp(`<meta property="og:image" content="[^"]*${CARD_PATH.replace(/[.]/g, '\\.')}">`));
78
+ assert.match(status.body, /<meta property="og:title" content="[^"]*build status">/);
79
+ const docs = await get('/docs');
80
+ assert.equal(docs.status, 200);
81
+ assert.match(docs.body, /<meta name="twitter:card" content="summary_large_image">/);
82
+ // the landing and hub pages go through the same injection
83
+ const landingLike = injectBrandingGlobal('<html><head><title>{{productName}} — community</title></head><body></body></html>', BRAND, {});
84
+ assert.ok(landingLike.includes(`<meta property="og:title" content="${PRODUCT} — community">`));
85
+ });
86
+
87
+ test('the card image is a 1200x630 PNG drawn from the shared mark', () => {
88
+ const png = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'public', 'brand', 'og-card.png'));
89
+ assert.equal(png.toString('ascii', 1, 4), 'PNG');
90
+ assert.deepEqual([png.readUInt32BE(16), png.readUInt32BE(20)], [1200, 630]);
91
+ const page = ogCardHtml({ markSvg: '<svg/>', name: 'Acme <Co>', fontLinks: ['https://f.example/x'], fontFamily: '"Manrope", sans-serif' });
92
+ assert.ok(page.includes('text-transform:lowercase'), 'the wordmark is lowercase (DESIGN.md "Naming")');
93
+ assert.ok(page.includes('Acme &lt;Co&gt;'), 'the name is escaped');
94
+ assert.ok(page.includes('data:image/svg+xml;base64,'), 'the mark is the shared file, embedded');
95
+ });
96
+
97
+ // ── the branded 404 and 500 ────────────────────────────────────────────────────
98
+ test('a browser gets the branded 404; an API path keeps JSON; a non-HTML client keeps text', async () => {
99
+ const page = await get('/no-such-page');
100
+ assert.equal(page.status, 404);
101
+ assert.match(page.type, /text\/html/);
102
+ assert.ok(page.body.includes('<use href="/modules/ui-design/public/brand/mark.svg#mark"/>'), 'the shared mark');
103
+ assert.ok(page.body.includes(`<title>${BRAND.copy.notFound} · ${PRODUCT}</title>`), 'the pack\'s words and the product name');
104
+ assert.ok(page.body.includes(BRAND.theme.fonts.links[0].replace(/&/g, '&amp;')), 'the pack\'s faces');
105
+ assert.doesNotMatch(page.body, /\{\{/);
106
+
107
+ const api = await get('/api/bongos/no-such-thing', { accept: 'text/html' });
108
+ assert.equal(api.status, 404);
109
+ assert.match(api.type, /application\/json/);
110
+
111
+ const curl = await get('/no-such-page', { accept: 'text/plain' });
112
+ assert.equal(curl.status, 404);
113
+ assert.match(curl.type, /text\/plain/);
114
+ });
115
+
116
+ test('a page route that throws answers the branded 500 to a browser and JSON to the API', () => {
117
+ const logged = [];
118
+ const { onError } = errorHandlers({
119
+ brandHtml: (h) => applyBrandTokens(h, { identity: { productName: 'Acme' } }, '', '', '', '', ''),
120
+ notFoundCopy: () => 'Gone.', isApiShapedPath: (p) => p.startsWith('/api/'), apiNotFoundBody: () => ({}),
121
+ log: (err) => logged.push(err.message),
122
+ });
123
+ const res = () => {
124
+ const r = { headersSent: false, code: 0, ctype: '', body: null };
125
+ r.status = (c) => { r.code = c; return r; };
126
+ r.type = (t) => { r.ctype = t; return r; };
127
+ r.send = (b) => { r.body = b; return r; };
128
+ r.json = (b) => { r.ctype = 'json'; r.body = b; return r; };
129
+ return r;
130
+ };
131
+ const req = (p, accept) => ({ path: p, method: 'GET', accepts: (t) => (Array.isArray(t) ? (accept === 'html' ? 'html' : 'json') : accept === 'html') });
132
+ const page = res();
133
+ onError(new Error('boom'), req('/projects', 'html'), page, () => {});
134
+ assert.equal(page.code, 500);
135
+ assert.equal(page.ctype, 'html');
136
+ assert.ok(page.body.includes('Something went wrong on our side.') && page.body.includes('· Acme</title>'));
137
+ assert.doesNotMatch(page.body, /boom/, 'the error text never reaches the page');
138
+ const api = res();
139
+ onError(new Error('boom'), req('/api/bongos/x', 'html'), api, () => {});
140
+ assert.equal(api.ctype, 'json');
141
+ assert.equal(api.body.error.code, 'internal_error');
142
+ assert.deepEqual(logged, ['boom', 'boom']);
143
+ // the 404 template carries the pack's words for "not found"
144
+ assert.ok(errorPageTemplate(404, { notFound: 'Gone.' }).includes('<h1>Gone.</h1>'));
145
+ });
146
+
147
+ test('the error page and the docs write their dark tier twice, byte-identical', () => {
148
+ const twins = (src) => {
149
+ const a = src.match(/html\[data-theme="dark"\]\s*\{([^}]*)\}/);
150
+ const b = src.match(/@media \(prefers-color-scheme:\s*dark\)\s*\{\s*html:not\(\[data-theme\]\)\s*\{([^}]*)\}/);
151
+ assert.ok(a && b, 'both twins exist');
152
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
153
+ assert.equal(norm(b[1]), norm(a[1]));
154
+ };
155
+ twins(errorPageTemplate(404));
156
+ twins(fs.readFileSync(path.join(ROOT, 'public-docs', 'index.html'), 'utf8'));
157
+ });
158
+
159
+ // ── the API docs ───────────────────────────────────────────────────────────────
160
+ test('the API docs load the brand: the scales, the mark, the pack\'s faces and a token theme for Redoc', async () => {
161
+ const r = await get('/docs');
162
+ assert.equal(r.status, 200);
163
+ assert.ok(r.body.includes('<link rel="stylesheet" href="/modules/ui-design/public/tokens.css">'));
164
+ assert.ok(r.body.includes('<use href="/modules/ui-design/public/brand/mark.svg#mark"/>'));
165
+ assert.ok(r.body.includes(BRAND.theme.fonts.links[0].replace(/&/g, '&amp;')), 'the pack\'s font link is spliced');
166
+ assert.match(r.body, /Redoc\.init\('\/docs\/openapi\.json', \{[^}]*theme: theme \}/);
167
+ assert.doesNotMatch(r.body, /system-ui, -apple-system, Segoe UI, Roboto/, 'the old system-font bar is gone');
168
+ assert.doesNotMatch(r.body, /#2b6cb0|#1a1a1a/, 'no off-pack colour');
169
+ });
170
+
171
+ // ── Discord ────────────────────────────────────────────────────────────────────
172
+ async function capture(fn, envKey) {
173
+ const sent = [];
174
+ const realFetch = globalThis.fetch;
175
+ const prev = process.env[envKey];
176
+ process.env[envKey] = 'https://discord.example/api/webhooks/1/x';
177
+ globalThis.fetch = async (url, init) => { sent.push(JSON.parse(init.body)); return { ok: true, status: 200, json: async () => ({ id: '1' }), text: async () => '' }; };
178
+ try { await fn(); } finally {
179
+ globalThis.fetch = realFetch;
180
+ if (prev === undefined) delete process.env[envKey]; else process.env[envKey] = prev;
181
+ }
182
+ return sent;
183
+ }
184
+
185
+ test('a Discord broadcast is an embed in the pack\'s accent (a sample post)', async () => {
186
+ const accent = parseInt(BRAND.theme.colors.accent.slice(1), 16);
187
+ const sample = webhook.embedBody('**Shipped:** the status page now matches the hall.');
188
+ assert.deepEqual(sample, {
189
+ embeds: [{ description: '**Shipped:** the status page now matches the hall.', color: accent }],
190
+ allowed_mentions: { parse: [] },
191
+ });
192
+ assert.equal(webhook.embedBody('x', { theme: { colors: { accent: 'not-a-colour' } } }).embeds[0].color, undefined, 'no bar rather than a wrong one');
193
+ assert.equal(webhook.embedBody('y'.repeat(5000)).embeds[0].description.length, 4096);
194
+
195
+ const [moduleBody] = await capture(() => webhook.postWebhook('ship_news', 'shipped'), 'DISCORD_SHIP_NEWS_WEBHOOK');
196
+ assert.deepEqual(moduleBody, { embeds: [{ description: 'shipped', color: accent }], allowed_mentions: { parse: [] } });
197
+ const [coreBody] = await capture(() => discordLib.postWebhook('ship_news', 'shipped'), 'DISCORD_SHIP_NEWS_WEBHOOK');
198
+ assert.deepEqual(coreBody, moduleBody, 'the core twin posts the same embed');
199
+ });
200
+
201
+ // ── the portable landing names no maker ─────────────────────────────────────────
202
+ test('the landing names its maker only from the pack, and drops the band when there is none', () => {
203
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'index.html'), 'utf8');
204
+ const code = src.replace(/<!--(?!\/?brand-if)[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
205
+ assert.doesNotMatch(code, /Verro|VERRO/, 'no maker\'s name written into the portable page');
206
+ const stamp = (name) => applyBrandTokens(src, { identity: { productName: 'Acme', worldName: 'Acme', company: { name, site: '', tagline: '' } } }, '', '', '', '', '');
207
+ const named = stamp('Rocket Labs');
208
+ assert.ok(named.includes('<p class="wordmark">Rocket Labs</p>'));
209
+ assert.ok(named.includes('Built by Rocket Labs, Acme is our way'));
210
+ assert.ok(named.includes('held by Rocket Labs or its pilot clients'));
211
+ const none = stamp('');
212
+ assert.doesNotMatch(none, /class="wrap verro"|held by|Built by ,/, 'the band and the line drop whole');
213
+ });
@@ -132,16 +132,29 @@ test('the emitter fires on FRESH opens only, and the listener is registered (sou
132
132
  assert.ok(manifest.consumes.includes('government'), 'the port consumption is declared');
133
133
  });
134
134
 
135
- test('#871 survives: the mention poster keeps parse:[] and rings only the explicit allowlist', () => {
136
- const src = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'webhook.js'), 'utf8');
137
- const block = src.slice(src.indexOf('async function postWebhookMentions'));
138
- assert.match(block, /postCore\(channel, text, \{ parse: \[\], users \}\)/,
139
- 'content can never ping on its own — only the server-resolved ids ring');
140
- const plain = src.slice(src.indexOf('async function postWebhook('), src.indexOf('async function postWebhookMentions'));
141
- assert.match(plain, /postCore\(channel, text, \{ parse: \[\] \}\)/,
142
- 'the default poster stays fully suppressed — #871 verbatim');
143
- assert.match(block, /slice\(0, 20\)/, 'the allowlist is sanity-bounded');
144
- assert.match(block, /\^\\d\+\$/, 'only numeric snowflake ids survive the filter');
135
+ test('#871 survives: the mention poster keeps parse:[] and rings only the explicit allowlist', async () => {
136
+ // Drive the two posters against a stubbed fetch and read the bodies they send
137
+ // (task 1005091 moved the plain poster onto an embed; the rule did not move).
138
+ const sent = [];
139
+ const realFetch = globalThis.fetch;
140
+ const prev = process.env.DISCORD_BOARD_ROOM_WEBHOOK;
141
+ process.env.DISCORD_BOARD_ROOM_WEBHOOK = 'https://discord.example/api/webhooks/1/x';
142
+ globalThis.fetch = async (url, init) => { sent.push(JSON.parse(init.body)); return { ok: true, status: 200, json: async () => ({ id: '9' }) }; };
143
+ try {
144
+ await webhook.postWebhookMentions('board_room', 'vote <@1> @everyone', ['111', '222', 'not-an-id', ...Array.from({ length: 30 }, (_, i) => String(1000 + i))]);
145
+ await webhook.postWebhook('board_room', 'plain @everyone');
146
+ } finally {
147
+ globalThis.fetch = realFetch;
148
+ if (prev === undefined) delete process.env.DISCORD_BOARD_ROOM_WEBHOOK; else process.env.DISCORD_BOARD_ROOM_WEBHOOK = prev;
149
+ }
150
+ const [ring, plain] = sent;
151
+ assert.deepEqual(ring.allowed_mentions.parse, [], 'content can never ping on its own — only the server-resolved ids ring');
152
+ assert.equal(ring.content, 'vote <@1> @everyone', 'the mention post stays message text, so its ids can ring');
153
+ assert.equal(ring.allowed_mentions.users.length, 20, 'the allowlist is sanity-bounded');
154
+ assert.ok(ring.allowed_mentions.users.every((u) => /^\d+$/.test(u)), 'only numeric snowflake ids survive the filter');
155
+ assert.deepEqual(plain.allowed_mentions, { parse: [] }, 'the default poster stays fully suppressed — #871 verbatim');
156
+ assert.equal(plain.content, undefined, 'the default poster sends an embed, not message text');
157
+ assert.equal(plain.embeds[0].description, 'plain @everyone');
145
158
  assert.ok(webhook.CHANNELS.has('board_room'), 'the channel key exists');
146
159
  });
147
160
 
@@ -67,7 +67,8 @@ test('/docs Redoc page points at the same-origin spec URL it now always serves',
67
67
  const res = await fetch(base + '/docs');
68
68
  assert.equal(res.status, 200);
69
69
  const html = await res.text();
70
- assert.match(html, /spec-url="\/docs\/openapi\.json"/, 'Redoc host page fetches /docs/openapi.json');
70
+ // task 1005091: Redoc is started by Redoc.init with the page's theme, so the URL is its first argument
71
+ assert.match(html, /Redoc\.init\('\/docs\/openapi\.json'/, 'Redoc host page fetches /docs/openapi.json');
71
72
  });
72
73
  });
73
74
 
@@ -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');