@bongos/core 1.24.51 → 1.24.53

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 (95) hide show
  1. package/.bongos-core.json +125 -95
  2. package/docs/copy-inventory.md +225 -242
  3. package/docs/copy-registry.json +278 -452
  4. package/docs/file-map.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +33 -0
  7. package/docs/page-readings.json +515 -531
  8. package/modules/discord/webhook.js +28 -6
  9. package/modules/hall-ui/public/ai-studio.html +1 -0
  10. package/modules/hall-ui/public/approval-queue.css +3 -11
  11. package/modules/hall-ui/public/approval-queue.js +13 -20
  12. package/modules/hall-ui/public/atlas.html +1 -0
  13. package/modules/hall-ui/public/blockers.html +1 -0
  14. package/modules/hall-ui/public/board-room.html +1 -0
  15. package/modules/hall-ui/public/bulk-toolbar.js +5 -1
  16. package/modules/hall-ui/public/city.html +1 -0
  17. package/modules/hall-ui/public/collab.html +1 -0
  18. package/modules/hall-ui/public/commands.html +1 -0
  19. package/modules/hall-ui/public/controls.css +100 -0
  20. package/modules/hall-ui/public/copy-desk.css +7 -11
  21. package/modules/hall-ui/public/copy-desk.html +17 -16
  22. package/modules/hall-ui/public/copy-desk.js +13 -9
  23. package/modules/hall-ui/public/deploy.html +1 -0
  24. package/modules/hall-ui/public/goal-inbox.js +32 -17
  25. package/modules/hall-ui/public/goal-map.html +1 -0
  26. package/modules/hall-ui/public/goals-page.js +7 -1
  27. package/modules/hall-ui/public/goals.html +1 -0
  28. package/modules/hall-ui/public/government.html +1 -0
  29. package/modules/hall-ui/public/government.js +10 -2
  30. package/modules/hall-ui/public/hall-dialog.js +255 -0
  31. package/modules/hall-ui/public/hall-kit.js +9 -2
  32. package/modules/hall-ui/public/idea.html +1 -0
  33. package/modules/hall-ui/public/ideas.html +1 -0
  34. package/modules/hall-ui/public/index.html +1 -0
  35. package/modules/hall-ui/public/market-watch-item.html +2 -1
  36. package/modules/hall-ui/public/market-watch-lib.js +16 -9
  37. package/modules/hall-ui/public/market-watch-rules.html +1 -0
  38. package/modules/hall-ui/public/market-watch.css +2 -3
  39. package/modules/hall-ui/public/market-watch.html +2 -1
  40. package/modules/hall-ui/public/modules.html +1 -0
  41. package/modules/hall-ui/public/money.html +1 -0
  42. package/modules/hall-ui/public/my-agents.html +1 -0
  43. package/modules/hall-ui/public/oversight.css +3 -33
  44. package/modules/hall-ui/public/primer.html +1 -0
  45. package/modules/hall-ui/public/profile.html +1 -0
  46. package/modules/hall-ui/public/project-settings.html +1 -0
  47. package/modules/hall-ui/public/roster.html +1 -0
  48. package/modules/hall-ui/public/settings-ai-keys.js +5 -1
  49. package/modules/hall-ui/public/settings-passkeys.js +5 -1
  50. package/modules/hall-ui/public/settings-sessions.js +10 -2
  51. package/modules/hall-ui/public/settings.html +1 -0
  52. package/modules/hall-ui/public/settings.js +5 -1
  53. package/modules/hall-ui/public/street.html +1 -0
  54. package/modules/hall-ui/public/studio.css +6 -32
  55. package/modules/hall-ui/public/studio.html +4 -3
  56. package/modules/hall-ui/public/studio.js +40 -35
  57. package/modules/hall-ui/public/style.css +2 -8
  58. package/modules/hall-ui/public/task.html +1 -0
  59. package/modules/hall-ui/public/task.js +26 -12
  60. package/modules/hall-ui/public/thinking.html +1 -0
  61. package/modules/hall-ui/public/tweak-editor.html +1 -0
  62. package/modules/hall-ui/public/watch.html +1 -0
  63. package/modules/hall-ui/public/work.html +1 -0
  64. package/modules/hall-ui/public/work.js +7 -1
  65. package/modules/public-landing/public/index.html +11 -8
  66. package/modules/ui-design/public/brand/og-card.png +0 -0
  67. package/package-lock.json +2 -2
  68. package/package.json +1 -1
  69. package/public-docs/index.html +107 -12
  70. package/release-notes.json +12 -0
  71. package/scripts/bongos/gen-brand.js +40 -6
  72. package/scripts/discord/lib.js +17 -3
  73. package/scripts/status-mirror-sync.js +11 -7
  74. package/src/bongos/serve-internal.js +3 -2
  75. package/src/error-page.js +120 -0
  76. package/src/link-preview.js +71 -0
  77. package/src/module-api.js +1 -1
  78. package/src/platform-server.js +22 -8
  79. package/tests/brand_surfaces.mjs +213 -0
  80. package/tests/bulk_toolbar.mjs +1 -1
  81. package/tests/discord_board_broadcast.mjs +23 -10
  82. package/tests/docs_openapi_route.mjs +2 -1
  83. package/tests/hall_approval_queue.mjs +8 -2
  84. package/tests/hall_audit.mjs +1 -1
  85. package/tests/hall_city.mjs +1 -1
  86. package/tests/hall_collab_world.mjs +9 -6
  87. package/tests/hall_dialog.mjs +401 -0
  88. package/tests/hall_error_envelope.mjs +1 -1
  89. package/tests/hall_oversight_world.mjs +9 -7
  90. package/tests/hall_record_world.mjs +1 -1
  91. package/tests/hall_street.mjs +1 -1
  92. package/tests/hall_studio_home.mjs +1 -1
  93. package/tests/home_attention_cards.mjs +2 -0
  94. package/tests/kind_axis_unset.mjs +1 -1
  95. package/tests/market_watch_pages.mjs +3 -1
@@ -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
+ });
@@ -46,7 +46,7 @@ function stubPage(present) {
46
46
  const els = {};
47
47
  for (const id of present) els[id] = { hidden: true, disabled: false, textContent: '', title: '', listeners: {}, addEventListener(t, fn) { this.listeners[t] = fn; } };
48
48
  globalThis.document = { getElementById: (id) => els[id] || null };
49
- globalThis.window = { confirm: () => true };
49
+ globalThis.window = { OTBDialog: { confirm: async () => true } }; // the branded confirm (task 1005085)
50
50
  globalThis.location = loc;
51
51
  return els;
52
52
  }
@@ -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
 
@@ -90,6 +90,9 @@ function bootPanel({ answers = {}, writes = null, mode = 'light' } = {}) {
90
90
  getElementById: byId,
91
91
  createElement: (tag) => augment(makeEl(tag, {})),
92
92
  get activeElement() { return boot.focused || null; },
93
+ // the shared dialog (hall-dialog.js, task 1005085) hears its keys on the document
94
+ listeners: {},
95
+ addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
93
96
  };
94
97
  const api = {
95
98
  // The REAL client's shape: request(method, path, { body }). This fake once
@@ -110,6 +113,7 @@ function bootPanel({ answers = {}, writes = null, mode = 'light' } = {}) {
110
113
  win.window = win;
111
114
  vm.runInContext(read('brand-holes.js'), ctx, { filename: 'brand-holes.js' });
112
115
  vm.runInContext(read('approval-queue-lib.js'), ctx, { filename: 'approval-queue-lib.js' });
116
+ vm.runInContext(read('hall-dialog.js'), ctx, { filename: 'hall-dialog.js' }); // the shared dialog behaviour (task 1005085)
113
117
  vm.runInContext(read('approval-queue.js'), ctx, { filename: 'approval-queue.js' });
114
118
  const panel = win.OTBApprovalQueue.mount({
115
119
  api,
@@ -124,7 +128,9 @@ function bootPanel({ answers = {}, writes = null, mode = 'light' } = {}) {
124
128
  const q = (sel) => scrim.querySelector(sel);
125
129
  const qa = (sel) => scrim.querySelectorAll(sel);
126
130
  const click = async (el) => { assert.ok(el, 'the control exists'); scrim.fire('click', { target: el }); await settle(); await settle(); };
127
- boot = { body, scrim, q, qa, click, requests, counts, panel, classes, win, focused: null };
131
+ boot = { body, scrim, q, qa, click, requests, counts, panel, classes, win, focused: null,
132
+ // a key pressed anywhere in the page reaches the document, where the shared dialog listens
133
+ key: (ev) => (documentObj.listeners.keydown || []).forEach((fn) => fn({ preventDefault() {}, stopPropagation() {}, ...ev })) };
128
134
  return boot;
129
135
  }
130
136
 
@@ -330,7 +336,7 @@ await test('empty, closed with Escape, and focus goes back where it was', async
330
336
  await b.panel.open(); await settle();
331
337
  assert.equal(text(b.q('.aq-title')), 'Nothing waiting');
332
338
  assert.match(text(b.q('.aq-frame__note')), /Nothing is waiting for you/);
333
- b.scrim.fire('keydown', { key: 'Escape', preventDefault() {}, target: b.scrim });
339
+ b.key({ key: 'Escape', target: b.scrim });
334
340
  assert.equal(b.scrim.hidden, true);
335
341
  assert.equal(b.focused, opener);
336
342
  assert.ok(!b.classes.has('aq-open'));
@@ -510,7 +510,7 @@ test('the verdict classes and the scrim exist in the token tier, in both modes',
510
510
  assert.match(css, /\.ledger__verdict--danger\s*\{[^}]*var\(--danger\)/, 'fell short → --danger');
511
511
  assert.match(css, /\.ledger__verdict--none\s*\{[^}]*var\(--ink-soft\)/, 'not graded → --ink-soft');
512
512
  assert.match(css, /--scrim:/, 'the modal scrim is a token, not a sheet-local literal');
513
- assert.match(read('copy-desk.css'), /::backdrop\s*\{[^}]*var\(--scrim\)/, 'the copy-desk backdrop takes it');
513
+ assert.match(read('controls.css'), /\.hall-dialog::backdrop \{ background: var\(--scrim\); \}/, 'the one dialog backdrop takes it (task 1005085)');
514
514
  });
515
515
 
516
516
  // ---- 10. an outline: none always names its replacement (task 1003496) -----
@@ -288,7 +288,7 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
288
288
  // task 1004685 (the living city): gov-living.js, the model under the drawing, and
289
289
  // gov-feeds.js, its one reader per page (shared with the shell's tally), load before it
290
290
  // task 1004702: the card answers its first ask in place, so the deck's card and sender (and the desk rows under them) load before the page
291
- assert.deepEqual(scripts, ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, as on every hall page');
291
+ assert.deepEqual(scripts, ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, then the shared dialog (task 1005085), as on every hall page');
292
292
  // task 1004925: every script but dom-utils.js is deferred, so none holds up the
293
293
  // parser; defer runs them in document order, which is why the order above still
294
294
  // holds. dom-utils.js stays synchronous: the one-escaper guard
@@ -53,7 +53,9 @@ test('the page sheets spend only the fifteen, in px, and never opacity', () => {
53
53
  const cd = read('copy-desk.css');
54
54
  const literals = cd.match(/rgba?\([^)]*\)/g) || [];
55
55
  assert.deepEqual(literals, [], 'not even the scrim is a literal here any more');
56
- assert.match(cd, /\.cd-dialog::backdrop \{ background: var\(--scrim\); \}/, 'the backdrop spends the token');
56
+ // task 1005085: the dialogs are the hall's one dialog, whose backdrop lives in controls.css
57
+ assert.doesNotMatch(cd, /::backdrop/, 'no backdrop of its own');
58
+ assert.match(read('controls.css'), /\.hall-dialog::backdrop \{ background: var\(--scrim\); \}/, 'the shared backdrop spends the token');
57
59
  });
58
60
 
59
61
  test('every list is the family\'s row, and the card-in-a-card is gone', () => {
@@ -335,15 +337,16 @@ test('the filters and the search are the kit\'s bar; the duplicates are paged th
335
337
  assert.match(read('copy-desk.html'), /src="\/builders\/hall-kit\.js/, 'the page loads the kit');
336
338
  });
337
339
 
338
- test('the three dialogs are the family modal on a native <dialog>, with the number input in a field', () => {
340
+ test('the three dialogs are the hall\'s one dialog on a native <dialog>, with the number input in a field', () => {
339
341
  const html = read('copy-desk.html');
340
- // Three since task 1003114 (R03): flag, close, and the proposal composer.
341
- assert.equal((html.match(/<dialog id="cd-(close-dialog|dialog|propose-dialog)" class="ov-modal cd-dialog">/g) || []).length, 3);
342
+ // Three since task 1003114 (R03): flag, close, and the proposal composer; the
343
+ // shared .hall-dialog since task 1005085, each named by its own title.
344
+ assert.equal((html.match(/<dialog id="cd-(close-dialog|dialog|propose-dialog)" class="hall-dialog cd-dialog" aria-labelledby="cd-(close|dialog|propose)-h">/g) || []).length, 3);
342
345
  assert.match(html, /<div class="ov-field ov-field--wide">\s*<label class="ov-field__l" for="cd-close-task">[^<]*<\/label>\s*<input type="number" id="cd-close-task"/, 'the number input sits in the family field, which restates the 30px pill');
343
346
  assert.ok(!/<label[^>]*>[^<]*<span class="cd-dialog__hint"/.test(html), 'no hint lives inside a label (the label voice would uppercase it)');
344
347
  assert.equal((html.match(/class="ov-form__why"/g) || []).length, 4, 'the four hints are the form\'s own explainer line');
345
- assert.match(html, /class="btn-accent" id="cd-submit"/, 'the accent door files the flag');
346
- assert.match(html, /class="btn-accent" id="cd-propose-submit"/, 'and the proposal composer has its own');
348
+ assert.match(html, /class="btn" id="cd-submit"/, 'the accent door files the flag');
349
+ assert.match(html, /class="btn" id="cd-propose-submit"/, 'and the proposal composer has its own');
347
350
  });
348
351
 
349
352
  test('the proposal composer is prefilled, and says what filing it does and does not do', () => {