@bongos/core 1.24.51 → 1.24.52

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 (81) hide show
  1. package/.bongos-core.json +95 -85
  2. package/docs/copy-inventory.md +199 -215
  3. package/docs/copy-registry.json +230 -395
  4. package/docs/file-map.md +1 -1
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-inventory.json +33 -0
  7. package/docs/page-readings.json +450 -463
  8. package/modules/hall-ui/public/ai-studio.html +1 -0
  9. package/modules/hall-ui/public/approval-queue.css +3 -11
  10. package/modules/hall-ui/public/approval-queue.js +13 -20
  11. package/modules/hall-ui/public/atlas.html +1 -0
  12. package/modules/hall-ui/public/blockers.html +1 -0
  13. package/modules/hall-ui/public/board-room.html +1 -0
  14. package/modules/hall-ui/public/bulk-toolbar.js +5 -1
  15. package/modules/hall-ui/public/city.html +1 -0
  16. package/modules/hall-ui/public/collab.html +1 -0
  17. package/modules/hall-ui/public/commands.html +1 -0
  18. package/modules/hall-ui/public/controls.css +100 -0
  19. package/modules/hall-ui/public/copy-desk.css +7 -11
  20. package/modules/hall-ui/public/copy-desk.html +17 -16
  21. package/modules/hall-ui/public/copy-desk.js +13 -9
  22. package/modules/hall-ui/public/deploy.html +1 -0
  23. package/modules/hall-ui/public/goal-inbox.js +32 -17
  24. package/modules/hall-ui/public/goal-map.html +1 -0
  25. package/modules/hall-ui/public/goals-page.js +7 -1
  26. package/modules/hall-ui/public/goals.html +1 -0
  27. package/modules/hall-ui/public/government.html +1 -0
  28. package/modules/hall-ui/public/government.js +10 -2
  29. package/modules/hall-ui/public/hall-dialog.js +255 -0
  30. package/modules/hall-ui/public/hall-kit.js +9 -2
  31. package/modules/hall-ui/public/idea.html +1 -0
  32. package/modules/hall-ui/public/ideas.html +1 -0
  33. package/modules/hall-ui/public/index.html +1 -0
  34. package/modules/hall-ui/public/market-watch-item.html +2 -1
  35. package/modules/hall-ui/public/market-watch-lib.js +16 -9
  36. package/modules/hall-ui/public/market-watch-rules.html +1 -0
  37. package/modules/hall-ui/public/market-watch.css +2 -3
  38. package/modules/hall-ui/public/market-watch.html +2 -1
  39. package/modules/hall-ui/public/modules.html +1 -0
  40. package/modules/hall-ui/public/money.html +1 -0
  41. package/modules/hall-ui/public/my-agents.html +1 -0
  42. package/modules/hall-ui/public/oversight.css +3 -33
  43. package/modules/hall-ui/public/primer.html +1 -0
  44. package/modules/hall-ui/public/profile.html +1 -0
  45. package/modules/hall-ui/public/project-settings.html +1 -0
  46. package/modules/hall-ui/public/roster.html +1 -0
  47. package/modules/hall-ui/public/settings-ai-keys.js +5 -1
  48. package/modules/hall-ui/public/settings-passkeys.js +5 -1
  49. package/modules/hall-ui/public/settings-sessions.js +10 -2
  50. package/modules/hall-ui/public/settings.html +1 -0
  51. package/modules/hall-ui/public/settings.js +5 -1
  52. package/modules/hall-ui/public/street.html +1 -0
  53. package/modules/hall-ui/public/studio.css +6 -32
  54. package/modules/hall-ui/public/studio.html +4 -3
  55. package/modules/hall-ui/public/studio.js +40 -35
  56. package/modules/hall-ui/public/style.css +2 -8
  57. package/modules/hall-ui/public/task.html +1 -0
  58. package/modules/hall-ui/public/task.js +26 -12
  59. package/modules/hall-ui/public/thinking.html +1 -0
  60. package/modules/hall-ui/public/tweak-editor.html +1 -0
  61. package/modules/hall-ui/public/watch.html +1 -0
  62. package/modules/hall-ui/public/work.html +1 -0
  63. package/modules/hall-ui/public/work.js +7 -1
  64. package/package-lock.json +2 -2
  65. package/package.json +1 -1
  66. package/release-notes.json +6 -0
  67. package/src/module-api.js +1 -1
  68. package/tests/bulk_toolbar.mjs +1 -1
  69. package/tests/hall_approval_queue.mjs +8 -2
  70. package/tests/hall_audit.mjs +1 -1
  71. package/tests/hall_city.mjs +1 -1
  72. package/tests/hall_collab_world.mjs +9 -6
  73. package/tests/hall_dialog.mjs +401 -0
  74. package/tests/hall_error_envelope.mjs +1 -1
  75. package/tests/hall_oversight_world.mjs +9 -7
  76. package/tests/hall_record_world.mjs +1 -1
  77. package/tests/hall_street.mjs +1 -1
  78. package/tests/hall_studio_home.mjs +1 -1
  79. package/tests/home_attention_cards.mjs +2 -0
  80. package/tests/kind_axis_unset.mjs +1 -1
  81. package/tests/market_watch_pages.mjs +3 -1
@@ -0,0 +1,401 @@
1
+ // tests/hall_dialog.mjs — the hall's ONE dialog and its branded confirm (task 1005085, goal 1000138).
2
+ //
3
+ // WHY. The UI audit found six dialog builds (attn, cd, mw, aq, ov, st) and fourteen window.confirm()
4
+ // calls. modules/hall-ui/public/hall-dialog.js replaces both. This proves three things:
5
+ // 1. the component, in a real browser: role + aria-modal + labelled by its title, focus trapped,
6
+ // Escape closes the TOP dialog only and hands focus back to the trigger, the scrim closes, the
7
+ // confirm resolves true only on its confirm button, and it wears the shared layer, shadow and
8
+ // panel corner (skips when the machine has no system Edge or Chrome, as ui_design_kit_fit does);
9
+ // 2. every replaced confirm flow: the REAL function, lifted out of its page script, asks the
10
+ // branded confirm (what will happen, in words) and runs its write only when the answer is yes;
11
+ // 3. the guards: no window.confirm anywhere in modules/hall-ui, and each of the six builds now
12
+ // runs on OTBDialog with no scrim/backdrop rule of its own.
13
+ import assert from 'node:assert/strict';
14
+ import { test, after } from 'node:test';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import vm from 'node:vm';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
24
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
25
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
26
+
27
+ // ── the source helpers ──────────────────────────────────────────────────────
28
+
29
+ // Comments out, strings kept: a mention of window.confirm in a comment is history, not a call.
30
+ function stripComments(src) {
31
+ return src
32
+ .replace(/<!--[\s\S]*?-->/g, '')
33
+ .replace(/\/\*[\s\S]*?\*\//g, '')
34
+ .replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
35
+ }
36
+
37
+ // The source of `function name(...) { ... }` (async or not), by brace matching. The flows below
38
+ // hold no unbalanced brace in a string, and the vm compile is the check that the cut is whole.
39
+ function extractFn(src, name) {
40
+ const m = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`).exec(src);
41
+ assert.ok(m, `function ${name} is in the source`);
42
+ let i = src.indexOf('{', src.indexOf(')', m.index + m[0].length - 1));
43
+ let depth = 0;
44
+ for (let j = i; j < src.length; j++) {
45
+ if (src[j] === '{') depth++;
46
+ else if (src[j] === '}') { depth--; if (depth === 0) return src.slice(m.index, j + 1); }
47
+ }
48
+ throw new Error(`unbalanced function ${name}`);
49
+ }
50
+
51
+ // Run one real flow function with its page's free names stubbed. `answer` is what the branded
52
+ // confirm returns. Reports the confirms asked and the writes sent.
53
+ async function runFlow(file, name, args, { answer, globals = {} } = {}) {
54
+ const asked = [];
55
+ const writes = [];
56
+ const record = (m, u, b) => { writes.push({ method: m, url: String(u), body: b }); return { ok: true, status: 200, data: { permission_count: 3 } }; };
57
+ const window = {
58
+ OTBDialog: { confirm: async (o) => { asked.push(o); return answer; } },
59
+ location: { assign() {} },
60
+ confirm: () => { throw new Error('window.confirm was called'); },
61
+ };
62
+ const ctx = vm.createContext({
63
+ window,
64
+ API: '/api/bongos',
65
+ api: { request: async (m, u, o) => record(m, u, o && o.body) },
66
+ postJSON: async (u, b) => record('POST', u, b),
67
+ sendJson: async (m, u) => record(m, u),
68
+ claimAction: {
69
+ release: async (c, t) => { record('POST', `/claims/${c}/resolve`, { task: t }); return { ok: true }; },
70
+ releaseMany: async (e) => { record('POST', '/claims/batch/release', e); return { ok: true }; },
71
+ },
72
+ toast() {}, render: async () => {}, reloadRanks: async () => {}, toAuth() {}, curLabel: () => 'credits',
73
+ fail: (r) => new Error(String(r.status)), deleteRefusal: () => '',
74
+ renderArtKeyCard() {}, wireArtKeyCard() {},
75
+ $: () => null,
76
+ console,
77
+ ...globals,
78
+ });
79
+ const fn = vm.runInContext(`(${extractFn(read(file), name)})`, ctx);
80
+ await fn(...args);
81
+ return { asked, writes };
82
+ }
83
+
84
+ const fakeBtn = () => ({ disabled: false, textContent: '' });
85
+
86
+ // Every replaced window.confirm, as { file, fn, args, globals, write: what the yes sends }.
87
+ const FLOWS = [
88
+ { flow: 'end the session you are using', file: 'settings-sessions.js', fn: 'endOne',
89
+ args: () => [{ closest: () => ({ getAttribute: () => 's1', querySelector: () => ({}) }), disabled: false }],
90
+ write: /me\/sessions\/revoke$/ },
91
+ { flow: 'sign out everywhere', file: 'settings-sessions.js', fn: 'endAll',
92
+ args: () => [], globals: { $: () => fakeBtn() }, write: /revoke-all/ },
93
+ { flow: 'remove a passkey card', file: 'settings-passkeys.js', fn: 'remove', args: () => ['cred-1'], write: /passkeys\/remove/ },
94
+ { flow: 'remove an AI studio key', file: 'settings-ai-keys.js', fn: 'removeKey', args: () => ['openai', 'OpenAI'], write: /ai-studio\/keys\/openai/ },
95
+ { flow: 'remove the image-generation key', file: 'settings.js', fn: 'removeArtKey', args: () => [], write: /art-key\/own/ },
96
+ { flow: 'reset a rank to its defaults', file: 'government.js', fn: 'resetRank',
97
+ args: () => [{ rank_key: 'metic', label: 'Metic' }, fakeBtn()], write: /ranks\/metic\/reset/ },
98
+ { flow: 'delete a rank', file: 'government.js', fn: 'deleteRank',
99
+ args: () => [{ rank_key: 'scribe', label: 'Scribe' }, fakeBtn()], write: /ranks\/scribe$/ },
100
+ { flow: 'release a claim on /work', file: 'work.js', fn: 'doRelease',
101
+ args: () => [11, 1001, fakeBtn()], globals: { pending: new Set() }, write: /claims\/11\/resolve/ },
102
+ { flow: 'release a claim on a goal', file: 'goals-page.js', fn: 'doGoalRelease',
103
+ args: () => [12, 1002, fakeBtn()], globals: { pendingClaims: new Set() }, write: /claims\/12\/resolve/ },
104
+ { flow: 'confirm a verify task', file: 'task.js', fn: 'confirmTask',
105
+ args: () => [{ id: 1003, kind: 'verify' }, fakeBtn()],
106
+ globals: { runLifecycle: (b, u, body) => ({ __w: true, u, body }) }, write: /tasks\/1003\/confirm/, lifecycle: true },
107
+ { flow: 'retire a task', file: 'task.js', fn: 'retireTask',
108
+ args: () => [{ id: 1004 }, fakeBtn(), 'superseded by 1005'],
109
+ globals: { runWrite: async (b, fn) => fn(), abandonOpen: true }, write: /tasks\/1004\/abandon/ },
110
+ { flow: 'delete a saved view', file: 'hall-kit.js', fn: 'deleteView',
111
+ args: () => [{ id: 5, name: 'Mine' }],
112
+ globals: { act: (fn) => fn(), call: (m, u) => ({ m, u }), pageQ: 'page=work' }, write: /\/5\?page=work/, viewCall: true },
113
+ ];
114
+
115
+ for (const f of FLOWS) {
116
+ test(`${f.flow}: the branded confirm says what happens, and the write runs only on yes`, async () => {
117
+ const extra = { ...(f.globals || {}) };
118
+ // task.js hands the write to runLifecycle; the saved-view menu hands it to call(). Record both.
119
+ let sent = [];
120
+ if (f.lifecycle) extra.runLifecycle = (b, u, body) => { sent.push({ url: u, body }); };
121
+ if (f.viewCall) extra.call = (m, u) => { sent.push({ url: u, method: m }); };
122
+ const no = await runFlow(f.file, f.fn, f.args(), { answer: false, globals: extra });
123
+ const noSent = sent; sent = [];
124
+ assert.equal(no.asked.length, 1, 'the confirm is shown once');
125
+ const o = no.asked[0];
126
+ assert.ok(o.title && o.title.length > 3, 'it has a title');
127
+ assert.ok(o.body && o.body.length > 10, 'its body says what will happen');
128
+ assert.ok(o.confirmLabel, 'the confirm button names the act');
129
+ assert.equal(no.writes.filter((w) => f.write.test(w.url)).length + noSent.filter((w) => f.write.test(w.url)).length, 0,
130
+ 'Cancel sends nothing');
131
+ const yes = await runFlow(f.file, f.fn, f.args(), { answer: true, globals: extra });
132
+ assert.equal(yes.asked.length, 1);
133
+ assert.equal(yes.writes.filter((w) => f.write.test(w.url)).length + sent.filter((w) => f.write.test(w.url)).length, 1,
134
+ 'the yes sends the write, once');
135
+ });
136
+ }
137
+
138
+ test('a verify-kind confirm attests the verification; any other kind asks nothing', async () => {
139
+ const sent = [];
140
+ const runLifecycle = (b, u, body) => sent.push({ u, body });
141
+ const plain = await runFlow('task.js', 'confirmTask', [{ id: 9, kind: 'build' }, fakeBtn()], { answer: false, globals: { runLifecycle } });
142
+ assert.equal(plain.asked.length, 0, 'no confirm for a non-verify task');
143
+ assert.equal(JSON.stringify(sent.map((s) => s.body)), "[{}]");
144
+ await runFlow('task.js', 'confirmTask', [{ id: 9, kind: 'verify' }, fakeBtn()], { answer: true, globals: { runLifecycle } });
145
+ assert.equal(JSON.stringify(sent[1].body), JSON.stringify({ verified: true }));
146
+ });
147
+
148
+ test('ending a session that is not this one asks nothing', async () => {
149
+ const btn = { closest: () => ({ getAttribute: () => 's2', querySelector: () => null }), disabled: false };
150
+ const r = await runFlow('settings-sessions.js', 'endOne', [btn], { answer: false });
151
+ assert.equal(r.asked.length, 0);
152
+ assert.equal(r.writes.length, 1);
153
+ });
154
+
155
+ test('the bulk release on the shared toolbar asks first, and releases only on yes', async () => {
156
+ const { makeBulkToolbar } = require(path.join(PUB, 'bulk-toolbar.js'));
157
+ for (const answer of [false, true]) {
158
+ const asked = [];
159
+ const els = {};
160
+ for (const id of ['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']) {
161
+ els[id] = { hidden: true, disabled: false, textContent: '', listeners: {}, addEventListener(t, fn) { this.listeners[t] = fn; } };
162
+ }
163
+ globalThis.document = { getElementById: (id) => els[id] || null };
164
+ globalThis.window = { OTBDialog: { confirm: async (o) => { asked.push(o); return answer; } }, confirm: () => { throw new Error('window.confirm'); } };
165
+ const released = [];
166
+ const selection = { claim: new Set(), release: new Map([['77', '9'], ['78', '10']]) };
167
+ const bar = makeBulkToolbar({
168
+ ids: { bar: 'bar', count: 'count', claim: 'claim', release: 'release', parallel: 'parallel', sequential: 'sequential', clear: 'clear' },
169
+ selection, findTask: () => null,
170
+ claimAction: { validateMany: async () => ({ ok: true, byId: new Map() }), claimMany: async () => ({ ok: true }), releaseMany: async (e) => { released.push(e); return { ok: true }; } },
171
+ getJSON: async () => ({}), API: '/api/bongos', copyText: async () => true, toast() {}, curLabel: () => 'credits',
172
+ describeReason: (r) => r, onClear() {},
173
+ });
174
+ bar.wire();
175
+ await els.release.listeners.click();
176
+ assert.equal(asked.length, 1);
177
+ assert.match(asked[0].title, /Release 2 tasks\?/);
178
+ assert.match(asked[0].body, /return to the queue/);
179
+ assert.equal(released.length, answer ? 1 : 0, answer ? 'yes releases' : 'cancel releases nothing');
180
+ }
181
+ });
182
+
183
+ // ── the guards ──────────────────────────────────────────────────────────────
184
+
185
+ function hallFiles() {
186
+ const out = [];
187
+ const walk = (d) => {
188
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
189
+ const p = path.join(d, e.name);
190
+ if (e.isDirectory()) walk(p);
191
+ else if (/\.(js|html|mjs|cjs)$/.test(e.name)) out.push(p);
192
+ }
193
+ };
194
+ walk(path.join(ROOT, 'modules', 'hall-ui'));
195
+ return out;
196
+ }
197
+
198
+ test('no window.confirm anywhere in modules/hall-ui (comments stripped)', () => {
199
+ const hits = [];
200
+ for (const f of hallFiles()) {
201
+ const src = stripComments(fs.readFileSync(f, 'utf8'));
202
+ // a call to the browser's box: window.confirm, or a bare confirm( that is not a method or a definition
203
+ if (/\bwindow\s*\.\s*confirm\b/.test(src) || /(^|[^.\w$])(?<!function\s)confirm\s*\(/m.test(src.replace(/function\s+confirm\s*\(/g, ''))) {
204
+ hits.push(path.relative(ROOT, f));
205
+ }
206
+ }
207
+ assert.deepEqual(hits, [], 'use OTBDialog.confirm (hall-dialog.js) instead of the browser confirm');
208
+ });
209
+
210
+ test('the guard catches a window.confirm when one comes back', () => {
211
+ const src = stripComments("// window.confirm('old') is history\nif (!window.confirm('Delete?')) return;");
212
+ assert.ok(/\bwindow\s*\.\s*confirm\b/.test(src));
213
+ assert.ok(!/\bwindow\s*\.\s*confirm\b/.test(stripComments('/* window.confirm() was here */ OTBDialog.confirm({})')));
214
+ });
215
+
216
+ test('every hall page loads the shared dialog right after the shell, deferred alike', () => {
217
+ for (const f of fs.readdirSync(PUB).filter((n) => n.endsWith('.html'))) {
218
+ const html = read(f);
219
+ const shell = html.match(/<script src="\/builders\/shell\.js[^"]*"( defer)?><\/script>\n<script src="\/builders\/hall-dialog\.js"( defer)?><\/script>/);
220
+ if (!/\/builders\/shell\.js/.test(html)) continue;
221
+ assert.ok(shell, `${f} loads hall-dialog.js right after shell.js`);
222
+ assert.equal(shell[1], shell[2], `${f}: deferred alike`);
223
+ }
224
+ });
225
+
226
+ test('the six dialog builds run on OTBDialog, with no scrim, layer or backdrop of their own', () => {
227
+ // attn — Home's attention window
228
+ const inbox = read('goal-inbox.js');
229
+ assert.match(inbox, /<dialog class="hall-dialog hall-dialog--wide" id="attention-dialog"/);
230
+ assert.match(inbox, /OTBDialog\.bind\(/);
231
+ // cd — the copy desk's three dialogs
232
+ const cdHtml = read('copy-desk.html');
233
+ assert.equal((cdHtml.match(/<dialog id="cd-[a-z-]+" class="hall-dialog cd-dialog" aria-labelledby="[a-z-]+">/g) || []).length, 3);
234
+ assert.match(read('copy-desk.js'), /OTBDialog\.bind\(/);
235
+ // mw — the market watch's propose dialog
236
+ assert.match(read('market-watch-lib.js'), /OTBDialog\.bind\(/);
237
+ for (const p of ['market-watch.html', 'market-watch-item.html']) assert.match(read(p), /<dialog class="hall-dialog mw-dialog" id="mw-propose"/);
238
+ // aq — the approval queue's glass
239
+ assert.match(read('approval-queue.js'), /OTBDialog\.bind\(/);
240
+ // st — the studio's two glass panels
241
+ assert.equal((read('studio.js').match(/OTBDialog\.bind\(/g) || []).length, 2);
242
+ // ov — the oversight family's confirm modal is gone; copy-desk was its last user
243
+ assert.doesNotMatch(read('oversight.css'), /\.ov-modal/);
244
+ // nobody but controls.css draws a dialog backdrop or a dialog's own z layer
245
+ for (const f of fs.readdirSync(PUB).filter((n) => n.endsWith('.css') && n !== 'controls.css')) {
246
+ assert.doesNotMatch(stripComments(read(f)), /::backdrop/, `${f} draws no backdrop of its own`);
247
+ }
248
+ // the div overlays take the shared .hall-scrim (its layer and placement) and keep no layer of their own
249
+ assert.match(read('approval-queue.js'), /scrim\.className = 'hall-scrim aq-overlay'/);
250
+ assert.match(read('studio.html'), /class="hall-scrim st-overlay" id="st-panel"/);
251
+ assert.match(read('studio.html'), /class="hall-scrim st-reviews" id="st-reviews"/);
252
+ assert.match(read('studio.html'), /class="hall-dialog hall-dialog--wide st-reviews__dialog"/);
253
+ for (const [f, sel] of [['approval-queue.css', '.aq-overlay'], ['studio.css', '.st-overlay'], ['studio.css', '.st-reviews']]) {
254
+ const css = stripComments(read(f));
255
+ const at = css.indexOf(`\n${sel} {`);
256
+ assert.ok(at !== -1, `${sel} rule`);
257
+ const rule = css.slice(at, css.indexOf('}', at));
258
+ assert.doesNotMatch(rule, /z-index|position:/, `${sel} keeps no layer of its own`);
259
+ }
260
+ // the glass keeps its rim but floats on the one shared shadow
261
+ assert.match(read('approval-queue.css'), /box-shadow: inset 0 1px 0 var\(--aq-glass-rim\), var\(--ds-shadow-pop\);/);
262
+ assert.match(read('studio.css'), /box-shadow: inset 0 1px 0 var\(--st-panel-rim\), var\(--ds-shadow-pop\);/);
263
+ });
264
+
265
+ test('the dialog look lives once, in controls.css, on the shared tokens', () => {
266
+ const css = stripComments(read('controls.css'));
267
+ const rule = css.slice(css.indexOf('.hall-dialog {'), css.indexOf('}', css.indexOf('.hall-dialog {')));
268
+ for (const want of [/background: var\(--bg-card\)/, /border-radius: var\(--ds-radius-panel\)/, /box-shadow: var\(--ds-shadow-pop\)/, /z-index: var\(--ds-z-modal\)/]) {
269
+ assert.match(rule, want);
270
+ }
271
+ assert.match(css, /\.hall-dialog::backdrop \{ background: var\(--scrim\); \}/);
272
+ assert.match(css, /\.hall-scrim \{[^}]*z-index: var\(--ds-z-modal\)[^}]*background: var\(--scrim\)/);
273
+ });
274
+
275
+ // ── the component, in a real browser ────────────────────────────────────────
276
+
277
+ let browser = null;
278
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
279
+ after(() => browser && browser.close());
280
+ const skip = !browser && 'no system Edge or Chrome';
281
+
282
+ const SHEETS = [path.join(ROOT, 'modules', 'ui-design', 'public', 'tokens.css'), path.join(PUB, 'controls.css')]
283
+ .map((f) => fs.readFileSync(f, 'utf8')).join('\n');
284
+ const PAGE = `<!doctype html><html><head><style>:root{--bg-card:#fff;--ink:#111;--rule:#ccc;--line:1px;--scrim:rgba(0,0,0,.55);--danger:#b3261e;--radius-pill:999px}</style></head>
285
+ <body><main><button id="go">Delete</button><button id="other">Other</button>
286
+ <div class="hall-scrim" id="ov" hidden><div class="panel" role="dialog" aria-labelledby="ov-t"><h2 id="ov-t">Overlay</h2><button id="ov-a">a</button><button id="ov-b">b</button></div></div>
287
+ </main></body></html>`;
288
+
289
+ async function boot() {
290
+ const ctx = await browser.newContext({ viewport: { width: 1000, height: 700 } });
291
+ const page = await ctx.newPage();
292
+ await page.setContent(PAGE);
293
+ await page.addStyleTag({ content: SHEETS });
294
+ await page.addScriptTag({ content: read('dom-utils.js') }); // the shared escaper, first as on every page
295
+ await page.addScriptTag({ content: read('hall-dialog.js') });
296
+ return { ctx, page };
297
+ }
298
+
299
+ test('the confirm: a labelled modal on the shared layer, shadow and corner; Cancel first when it is destructive', { skip }, async () => {
300
+ const { ctx, page } = await boot();
301
+ await page.focus('#go');
302
+ await page.evaluate(() => { window.__r = window.OTBDialog.confirm({ title: 'Delete the Scribe rank?', body: 'Its permissions go with it.\n\nThis cannot be undone.', confirmLabel: 'Delete rank', danger: true }); });
303
+ const s = await page.evaluate(() => {
304
+ const d = document.querySelector('dialog.hall-dialog');
305
+ const cs = getComputedStyle(d);
306
+ const lab = document.getElementById(d.getAttribute('aria-labelledby'));
307
+ return {
308
+ open: d.open, modal: d.matches(':modal'), role: d.getAttribute('role'), ariaModal: d.getAttribute('aria-modal'),
309
+ name: lab && lab.textContent, paras: d.querySelectorAll('.hall-dialog__body p').length,
310
+ shadow: cs.boxShadow, radius: cs.borderTopLeftRadius, z: cs.zIndex,
311
+ focus: document.activeElement.textContent, danger: !!d.querySelector('.btn.btn--danger'), ghost: !!d.querySelector('.btn.btn--ghost'),
312
+ };
313
+ });
314
+ assert.equal(s.open, true);
315
+ assert.equal(s.modal, true, 'opened with showModal: the page behind is inert');
316
+ assert.equal(s.role, 'dialog');
317
+ assert.equal(s.ariaModal, 'true');
318
+ assert.equal(s.name, 'Delete the Scribe rank?', 'labelled by its title');
319
+ assert.equal(s.paras, 2, 'a blank line starts a paragraph');
320
+ assert.match(s.shadow, /rgba\(0, 0, 0, 0\.28\)/, 'the one floating shadow');
321
+ assert.equal(s.radius, '16px', 'the panel corner');
322
+ assert.equal(s.z, '160', 'the modal layer');
323
+ assert.equal(s.focus, 'Cancel', 'a destructive confirm starts on Cancel');
324
+ assert.ok(s.danger && s.ghost);
325
+ await page.keyboard.press('Enter');
326
+ assert.equal(await page.evaluate(() => window.__r), false, 'Enter on Cancel says no');
327
+ await ctx.close();
328
+ });
329
+
330
+ test('the confirm resolves true only on its confirm button, and is gone after', { skip }, async () => {
331
+ const { ctx, page } = await boot();
332
+ await page.evaluate(() => { window.__r = window.OTBDialog.confirm({ title: 'Release task 7?', body: 'It goes back to the queue.', confirmLabel: 'Release' }); });
333
+ await page.click('dialog.hall-dialog .btn:not(.btn--ghost)');
334
+ assert.equal(await page.evaluate(() => window.__r), true);
335
+ assert.equal(await page.evaluate(() => document.querySelectorAll('dialog').length), 0, 'removed on close');
336
+ await ctx.close();
337
+ });
338
+
339
+ test('focus is trapped: Tab and Shift+Tab wrap inside the dialog', { skip }, async () => {
340
+ const { ctx, page } = await boot();
341
+ await page.focus('#go');
342
+ await page.evaluate(() => { window.OTBDialog.confirm({ title: 'T', body: 'what happens', confirmLabel: 'Yes' }); });
343
+ const seen = [];
344
+ for (let i = 0; i < 5; i++) {
345
+ await page.keyboard.press('Tab');
346
+ seen.push(await page.evaluate(() => !!document.activeElement.closest('dialog')));
347
+ }
348
+ await page.keyboard.press('Shift+Tab');
349
+ seen.push(await page.evaluate(() => !!document.activeElement.closest('dialog')));
350
+ assert.deepEqual(seen, seen.map(() => true), 'focus never leaves the dialog');
351
+ await ctx.close();
352
+ });
353
+
354
+ test('Escape closes, says no, and hands focus back to the trigger', { skip }, async () => {
355
+ const { ctx, page } = await boot();
356
+ await page.focus('#go');
357
+ await page.evaluate(() => { window.__r = window.OTBDialog.confirm({ title: 'T', body: 'what happens', confirmLabel: 'Yes' }); });
358
+ await page.keyboard.press('Escape');
359
+ assert.equal(await page.evaluate(() => window.__r), false);
360
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'go');
361
+ await ctx.close();
362
+ });
363
+
364
+ test('a click on the scrim says no', { skip }, async () => {
365
+ const { ctx, page } = await boot();
366
+ await page.evaluate(() => { window.__r = window.OTBDialog.confirm({ title: 'T', body: 'what happens', confirmLabel: 'Yes' }); });
367
+ await page.mouse.click(10, 10);
368
+ assert.equal(await page.evaluate(() => window.__r), false);
369
+ await ctx.close();
370
+ });
371
+
372
+ test('a confirm over a bound overlay: Escape closes the confirm only, then the overlay, and focus walks back', { skip }, async () => {
373
+ const { ctx, page } = await boot();
374
+ await page.focus('#other');
375
+ await page.evaluate(() => {
376
+ const ov = document.getElementById('ov');
377
+ window.__h = window.OTBDialog.bind(ov, { panel: ov.querySelector('.panel') });
378
+ window.__h.open();
379
+ });
380
+ assert.equal(await page.evaluate(() => document.getElementById('ov').hidden), false);
381
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'ov-a', 'focus moves in');
382
+ await page.evaluate(() => { window.__r = window.OTBDialog.confirm({ title: 'T', body: 'what happens', confirmLabel: 'Yes' }); });
383
+ await page.keyboard.press('Escape');
384
+ assert.equal(await page.evaluate(() => window.__r), false);
385
+ assert.equal(await page.evaluate(() => document.getElementById('ov').hidden), false, 'the overlay stays');
386
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'ov-a', 'focus returns into the overlay');
387
+ await page.keyboard.press('Tab');
388
+ await page.keyboard.press('Tab');
389
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'ov-a', 'the overlay traps Tab too');
390
+ await page.keyboard.press('Escape');
391
+ assert.equal(await page.evaluate(() => document.getElementById('ov').hidden), true);
392
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'other', 'and back to the trigger');
393
+ const ov = await page.evaluate(() => {
394
+ const cs = getComputedStyle(document.getElementById('ov'));
395
+ const p = document.querySelector('#ov .panel');
396
+ return { z: cs.zIndex, modal: p.getAttribute('aria-modal') };
397
+ });
398
+ assert.equal(ov.z, '160');
399
+ assert.equal(ov.modal, 'true');
400
+ await ctx.close();
401
+ });
@@ -185,7 +185,7 @@ async function bootTaskPage({ task, failWith }) {
185
185
  location: { hostname: 'builders.example.com', pathname: `/task/${task.id}`, href: '', assign() {} },
186
186
  window: {
187
187
  OTBBranding: { taskCreditValue: () => null, taskCreditLabel: () => 'credits' },
188
- confirm: () => true,
188
+ OTBDialog: { confirm: async () => true }, // the hall's branded confirm (task 1005085)
189
189
  location: { hostname: 'builders.example.com', pathname: `/task/${task.id}`, assign() {} },
190
190
  // The generated typed client's result mode: { status, ok, data }.
191
191
  BongosClient: {
@@ -45,7 +45,7 @@ test('the family sheet spends only the fifteen: no literal colour', () => {
45
45
  test('the row is the family\'s archetype, and every page renders it', () => {
46
46
  const css = read('oversight.css');
47
47
  for (const cls of ['.ov-row', '.ov-row__title', '.ov-row__sub', '.ov-row__facts', '.ov-row__right',
48
- '.ov-group__head', '.ov-figs', '.ov-modal', '.ov-form', '.ov-toggle', '.ov-lines', '.ov-prose']) {
48
+ '.ov-group__head', '.ov-figs', '.ov-form', '.ov-toggle', '.ov-lines', '.ov-prose']) {
49
49
  assert.ok(new RegExp(`^${cls.replace(/[.]/g, '\\.')}[\\s,{]`, 'm').test(css), `oversight.css defines ${cls}`);
50
50
  }
51
51
  for (const p of PAGES) assert.match(read(`${p}.js`), /class="ov-row/, `${p}.js renders .ov-row`);
@@ -90,12 +90,14 @@ test('Gate carries no coloured band: state is a pill', () => {
90
90
  }
91
91
  });
92
92
 
93
- test('the shared confirm modal sits on --z-modal, closed by one hairline and no shadow', () => {
94
- const css = read('oversight.css');
95
- const bg = css.slice(css.indexOf('.ov-modal-bg {'), css.indexOf('.ov-modal {'));
96
- assert.match(bg, /z-index: var\(--z-modal\)/, 'the shell layer, not a guessed number');
97
- const modal = css.slice(css.indexOf('.ov-modal {'), css.indexOf('.ov-modal .scroll__h'));
98
- assert.ok(!/box-shadow/.test(modal), 'no shadow under the dialog');
93
+ test('the family keeps no confirm modal: it is the hall\'s one dialog, on the modal layer with one hairline', () => {
94
+ // task 1005085: the family's .ov-modal (copy desk its last user) became controls.css's
95
+ // .hall-dialog. It floats over a scrim, so it spends the one shadow DESIGN.md allows.
96
+ assert.doesNotMatch(read('oversight.css'), /\.ov-modal/, 'oversight.css draws no modal of its own');
97
+ const css = read('controls.css');
98
+ const modal = css.slice(css.indexOf('.hall-dialog {'), css.indexOf('}', css.indexOf('.hall-dialog {')));
99
+ assert.match(modal, /z-index: var\(--ds-z-modal\)/, 'the shell layer, not a guessed number');
100
+ assert.match(modal, /box-shadow: var\(--ds-shadow-pop\)/, 'the one floating shadow');
99
101
  assert.match(modal, /border: var\(--line\) solid var\(--rule\)/, 'one rule hairline');
100
102
  // task 1004829 made the Gate's approve an ask card with no modal; task 1004859
101
103
  // removed the approve altogether. The page still opens no modal — and styles none.
@@ -412,7 +412,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
412
412
  },
413
413
  location: { hostname: 'builders.cloudbongos.com', pathname: `/task/${task && task.id ? task.id : '1'}`, search, origin: 'https://builders.cloudbongos.com', assign() {} },
414
414
  addEventListener() {},
415
- confirm: () => true,
415
+ OTBDialog: { confirm: async () => true }, // the hall's branded confirm (task 1005085)
416
416
  navigator: { clipboard: { writeText: async () => {} } },
417
417
  };
418
418
  // task 1004477: the shell is what fills a [data-shell-exit] link. `shell: true`
@@ -489,7 +489,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
489
489
  assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
490
490
  for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
491
491
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
492
- assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
492
+ assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
493
493
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
494
494
  });
495
495
 
@@ -88,7 +88,7 @@ const RESUME_READ = { ok: true, counter: { changed: 12, total: 38 } };
88
88
  // ── the mini-DOM over the page's own markup ─────────────────────────────────
89
89
  const HTML = read('studio.html');
90
90
  const BODY = /<body[^>]*>([\s\S]*)<\/body>/.exec(HTML)[1].replace(/<script[\s\S]*?<\/script>/g, '');
91
- const SOURCES = ['dom-utils.js', 'tweak-editor-lib.js', 'studio-lib.js', 'studio.js'].map((f) => [f, read(f)]);
91
+ const SOURCES = ['dom-utils.js', 'hall-dialog.js', 'tweak-editor-lib.js', 'studio-lib.js', 'studio.js'].map((f) => [f, read(f)]);
92
92
  let boot = null;
93
93
 
94
94
  function augment(el) {
@@ -17,6 +17,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
17
  const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
18
18
  const GOAL_INBOX = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'goal-inbox.js'), 'utf8');
19
19
  const HALL_ASK = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'hall-ask.js'), 'utf8');
20
+ const HALL_DIALOG = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'hall-dialog.js'), 'utf8');
20
21
 
21
22
  let browser = null;
22
23
  try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
@@ -50,6 +51,7 @@ async function bootInbox() {
50
51
  await page.setContent(PAGE);
51
52
  await page.addScriptTag({ content: STUBS });
52
53
  await page.addScriptTag({ content: HALL_ASK }); // task 1004820: the rows are OTBAsk cards
54
+ await page.addScriptTag({ content: HALL_DIALOG }); // the window is the hall's one dialog (task 1005085)
53
55
  await page.addScriptTag({ content: GOAL_INBOX });
54
56
  await page.evaluate(() => window.__widget.mount(document.getElementById('attention-scroll')));
55
57
  await page.waitForFunction(() => document.querySelectorAll('.attn-card').length === 2);
@@ -126,7 +126,7 @@ const USES = {
126
126
  'task.js': {
127
127
  src: scanned(taskJs),
128
128
  nonRender: {
129
- "if (t.kind === 'verify' && !window.confirm(": 'a BRANCH on the value — gates a confirm prompt, renders nothing',
129
+ "if (t.kind === 'verify' && !(await window.OTBDialog.confirm({": 'a BRANCH on the value — gates a confirm prompt, renders nothing',
130
130
  "const body = t.kind === 'verify' ? { verified: true } : {};": 'a REQUEST body — sent to the route, never displayed',
131
131
  },
132
132
  },
@@ -213,7 +213,7 @@ test('a corrected entry reads corrected on the item page; one marked wrong is no
213
213
  // task 1005018: the dialog's screen-reader name follows the action that opened it.
214
214
  test('the propose dialog is named by its own title: propose a change, stop tracking or send a new scout', () => {
215
215
  const titleOf = (html) => {
216
- const all = [...html.matchAll(/<span[^>]*id="mw-propose-title"[^>]*>([^<]*)<\/span>/g)];
216
+ const all = [...html.matchAll(/<h2[^>]*id="mw-propose-title"[^>]*>([^<]*)<\/h2>/g)];
217
217
  assert.equal(all.length, 1, 'exactly one title to point at');
218
218
  return all[0][1];
219
219
  };
@@ -232,6 +232,8 @@ test('the propose dialog is named by its own title: propose a change, stop track
232
232
  querySelector: (s) => (s === 'form' ? form : stub()), querySelectorAll: () => [], showModal() {}, close() {},
233
233
  };
234
234
  const saved = globalThis.document;
235
+ // the hall's one dialog (hall-dialog.js, task 1005085) opens it; a stand-in records the open
236
+ globalThis.OTBDialog = { bind: () => ({ open() {}, close() {} }) };
235
237
  globalThis.document = { getElementById: (id) => (id === 'mw-propose' ? dlg : null) };
236
238
  try { MW.openPropose(mode, { item, dimensions: [], entries: [] }, () => {}); } finally { globalThis.document = saved; }
237
239
  assert.equal(attrs.has('aria-label'), false, `${mode}: no fixed label left to override the title`);