@bongos/core 1.25.17 → 1.25.19

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 (72) hide show
  1. package/.bongos-core.json +135 -65
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +17 -1
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +2 -1
  8. package/docs/api/openapi.json +288 -6
  9. package/docs/api-reference.md +14 -4
  10. package/docs/copy-inventory.md +132 -121
  11. package/docs/copy-registry.json +347 -195
  12. package/docs/file-map.md +2 -1
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +63 -63
  16. package/modules/app-host/CLAUDE.md +15 -0
  17. package/modules/app-host/module.json +20 -0
  18. package/modules/app-host/public/settings-widget.js +143 -0
  19. package/modules/app-host/routes/app-host.js +88 -0
  20. package/modules/government/charter-advice.js +103 -0
  21. package/modules/government/presets.js +42 -0
  22. package/modules/government/recommendations.js +102 -7
  23. package/modules/government/routes/government.js +1 -0
  24. package/modules/hall-ui/public/genesis-home.js +28 -3
  25. package/modules/lifecycle/first-read-catalog.js +62 -0
  26. package/modules/lifecycle/first-read-db.js +47 -9
  27. package/modules/lifecycle/first-read.js +88 -23
  28. package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
  29. package/modules/lifecycle/routes/first-read.js +27 -5
  30. package/modules/onboarding/genesis-home.js +68 -7
  31. package/modules/onboarding/genesis-work.js +169 -4
  32. package/modules/onboarding/routes/genesis-work.js +5 -2
  33. package/modules/platform-identity/module.json +1 -1
  34. package/modules/platform-identity/routes/sso-app-status.js +65 -0
  35. package/modules/provisioning/module.json +1 -0
  36. package/modules/provisioning/seams.js +32 -1
  37. package/modules/provisioning/starter-bundles.js +1 -0
  38. package/modules/public-landing/public/assets/guide-core.js +475 -0
  39. package/modules/public-landing/public/assets/guide-hall.css +121 -0
  40. package/modules/public-landing/public/assets/guide-hall.js +164 -0
  41. package/modules/public-landing/public/assets/guide-scene.css +9 -0
  42. package/modules/public-landing/public/assets/guide-scene.js +58 -381
  43. package/modules/public-landing/public/index.html +46 -1
  44. package/modules/public-landing/public/projects.html +11 -2
  45. package/modules/ui-design/kit/serve.js +1 -1
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +24 -0
  49. package/scripts/bongos/first-read.js +45 -11
  50. package/scripts/bongos/genesis.js +29 -2
  51. package/scripts/bongos/publish-manifest.js +1 -0
  52. package/scripts/bongos/seam-wiring-guard.js +3 -2
  53. package/scripts/hall-preview/server.js +2 -0
  54. package/src/bongos/auth-admission.js +64 -0
  55. package/src/bongos/auth.js +2 -1
  56. package/src/bongos/module-scope-map.js +4 -0
  57. package/src/bongos/routes/core-update.js +3 -16
  58. package/src/bongos/routes/modules.js +11 -0
  59. package/src/bongos/serve-internal.js +20 -6
  60. package/src/module-api.js +6 -1
  61. package/src/module-loader/loader.js +2 -2
  62. package/tests/app_host.mjs +413 -0
  63. package/tests/first_read.mjs +184 -8
  64. package/tests/genesis_home.mjs +81 -1
  65. package/tests/genesis_work.mjs +103 -3
  66. package/tests/genesis_work_routes.mjs +9 -0
  67. package/tests/government_charter_advice.mjs +150 -0
  68. package/tests/landing_guide_hall.mjs +364 -0
  69. package/tests/landing_guide_scene.mjs +8 -5
  70. package/tests/module_api.mjs +1 -0
  71. package/tests/module_loader.mjs +1 -1
  72. package/tests/npm_release_where.mjs +13 -3
@@ -0,0 +1,364 @@
1
+ // tests/landing_guide_hall.mjs — the start-a-project guide, plain (builders' hall) version,
2
+ // and the switch between it and the game scene (task 1005276, goal 1000106).
3
+ //
4
+ // The two presentations share ONE conversation (assets/guide-core.js): the state machine,
5
+ // the server's client rules and the doors are pinned by tests/landing_guide_scene.mjs. What
6
+ // this file holds is the hall half and the switch, RUN under a small DOM stub:
7
+ // * the hall walk: open → chat → the first send held → the cap → the plan asked for and
8
+ // drawn under the talk → "Sign up and get started"; Escape hands focus back;
9
+ // * the fallback (a "form" start) and a retry error that gives the words back;
10
+ // * which one a door opens: the game unless this browser picked the plain one; the switch
11
+ // both ways mid-chat keeps the same chat (no second start) and is remembered;
12
+ // * the switch is offered only when the other presentation is on the page;
13
+ // * the markup and the sheet: hidden labelled dialog, the honeypot out of the tab order,
14
+ // the core loaded first, tokens only (no typed colour, size, corner, shadow or breakpoint).
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { createRequire } from 'node:module';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
26
+ const HTML = fs.readFileSync(path.join(LANDING, 'index.html'), 'utf8').replace(/\r\n/g, '\n');
27
+ const CSS = fs.readFileSync(path.join(LANDING, 'assets', 'guide-hall.css'), 'utf8').replace(/\r\n/g, '\n');
28
+ const HALL_SRC = fs.readFileSync(path.join(LANDING, 'assets', 'guide-hall.js'), 'utf8');
29
+ const Core = require(path.join(LANDING, 'assets', 'guide-core.js'));
30
+ const Scene = require(path.join(LANDING, 'assets', 'guide-scene.js'));
31
+ const Hall = require(path.join(LANDING, 'assets', 'guide-hall.js'));
32
+
33
+ // ── a small DOM: the two dialogs, each with its named parts ───────────────────────
34
+ const GAME_PARTS = ['log', 'err', 'form', 'text', 'website', 'send', 'end', 'go', 'left', 'segs', 'plan-body', 'plan-toggle', 'plan-close', 'close', 'skip', 'make-plan', 'plan-again', 'saved', 'switch'];
35
+ const HALL_PARTS = ['log', 'err', 'form', 'text', 'website', 'send', 'end', 'go', 'left', 'body', 'plan', 'plan-body', 'saved', 'close', 'make-plan', 'plan-again', 'switch'];
36
+
37
+ function makeDom({ game = true, hall = true } = {}) {
38
+ const listeners = new Map();
39
+ const on = (target) => (type, fn) => { if (!listeners.has(target)) listeners.set(target, {}); (listeners.get(target)[type] ||= []).push(fn); };
40
+ const fire = (target, type, ev) => ((listeners.get(target) || {})[type] || []).forEach((fn) => fn(ev));
41
+ const doc = { activeElement: null };
42
+ function node(tag = 'div') {
43
+ const n = {
44
+ tagName: tag.toUpperCase(), children: [], className: '', textContent: '', hidden: false, disabled: false, value: '', maxLength: 0, attrs: {}, scrollTop: 0, scrollHeight: 0, offsetTop: 0, offsetParent: {},
45
+ get firstChild() { return this.children[0] || null; },
46
+ appendChild(c) { this.children.push(c); return c; },
47
+ removeChild(c) { this.children.splice(this.children.indexOf(c), 1); return c; },
48
+ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
49
+ removeAttribute(k) { delete this.attrs[k]; },
50
+ classList: { set: new Set(), add(c) { this.set.add(c); }, remove(c) { this.set.delete(c); }, toggle(c, v) { if (v) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } },
51
+ focus() { doc.activeElement = n; },
52
+ querySelectorAll() { return []; },
53
+ querySelector() { return null; },
54
+ };
55
+ n.addEventListener = on(n);
56
+ Object.defineProperty(n, 'innerHTML', { set() { throw new Error('innerHTML is not allowed'); } });
57
+ return n;
58
+ }
59
+ function dialog(attr, names) {
60
+ const parts = {};
61
+ names.forEach((k) => { parts[k] = node(); });
62
+ const root = node();
63
+ root.hidden = true;
64
+ root.querySelector = (sel) => {
65
+ const m = sel.match(new RegExp('^\\[' + attr + '="([^"]+)"\\]$'));
66
+ return m ? parts[m[1]] || null : null;
67
+ };
68
+ return { root, parts };
69
+ }
70
+ const g = game ? dialog('data-gd', GAME_PARTS) : null;
71
+ if (g) g.parts['plan-toggle'].offsetParent = null; // desktop: the plan stands beside the talk
72
+ const h = hall ? dialog('data-gh', HALL_PARTS) : null;
73
+ Object.assign(doc, {
74
+ documentElement: node('html'),
75
+ head: node('head'),
76
+ getElementById: (id) => (id === 'guide' && g ? g.root : id === 'guideHall' && h ? h.root : null),
77
+ createElement: (t) => node(t),
78
+ querySelector: () => null,
79
+ });
80
+ doc.addEventListener = on(doc);
81
+ return { doc, game: g, hall: h, fire, node };
82
+ }
83
+
84
+ function makeWin(mods, routes) {
85
+ const calls = [];
86
+ const timers = [];
87
+ const win = {
88
+ __MODULES__: mods,
89
+ localStorage: { data: {}, getItem(k) { return k in this.data ? this.data[k] : null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } },
90
+ setTimeout: (fn, ms) => { timers.push({ fn, ms }); },
91
+ fetch: (url, opts = {}) => {
92
+ calls.push({ url, method: opts.method || 'GET', body: opts.body ? JSON.parse(opts.body) : null, headers: opts.headers || {} });
93
+ const r = routes(url, opts);
94
+ return Promise.resolve({ status: r.status, json: () => Promise.resolve(r.body) });
95
+ },
96
+ };
97
+ return { win, calls, timers };
98
+ }
99
+ const ON = { enabled: { 'front-door-guide': true } };
100
+ const tick = () => new Promise((r) => setImmediate(r));
101
+ const settle = async (n = 4) => { for (let i = 0; i < n; i++) await tick(); };
102
+ function click(dom, door) {
103
+ let prevented = false;
104
+ const ev = { target: { closest: (sel) => (sel === '[data-guide-door]' ? door : null) }, button: 0, preventDefault() { prevented = true; }, get defaultPrevented() { return prevented; } };
105
+ dom.fire(dom.doc, 'click', ev);
106
+ return prevented;
107
+ }
108
+ // boot both, in the page's script order (the core is required by each)
109
+ function bootAll(win, dom) { Scene.boot(win, dom.doc); Hall.boot(win, dom.doc); }
110
+
111
+ const planAnswer = {
112
+ ok: true, mode: 'plan', message: 'Here is your plan.', next: { action: 'sign_up', label: 'Sign up and get started' }, saved: { message: 'Saved.' },
113
+ conversation: { messages_used: 2, messages_left: 0 },
114
+ plan: { summary: 'A booking site for a dance school.', how_it_works: ['Students pick a class'], steps: [{ title: 'Sign up', detail: 'Make your account' }], modules: [], needs: [], open_questions: ['Payments?'], cost_and_time: 'Cost and time depend on you.' },
115
+ };
116
+ function chatRoutes(over = {}) {
117
+ let left = 2;
118
+ return (url, opts) => {
119
+ const m = (opts.method || 'GET') + ' ' + url;
120
+ if (over[m]) return over[m](opts);
121
+ if (url.endsWith('/status')) return { status: 200, body: { ok: true, mode: 'chat', limits: { max_messages: 2, max_message_chars: 1000 } } };
122
+ if (m === 'POST /api/bongos/front-door-guide/conversations') return { status: 201, body: { ok: true, mode: 'chat', greeting: 'Hi!', conversation: { id: '9', token: 'tk', messages_used: 0, messages_left: 2, max_message_chars: 1000 }, saved: { message: 'This chat is saved on this device.', keep_days: 30 } } };
123
+ if (url.endsWith('/plan')) return { status: 200, body: planAnswer };
124
+ left -= 1;
125
+ return left > 0
126
+ ? { status: 200, body: { ok: true, mode: 'chat', reply: { text: 'Tell me more.' }, conversation: { messages_used: 1, messages_left: left } } }
127
+ : { status: 200, body: { ok: true, mode: 'ended', reason: 'message_cap', next_step: 'plan', message: 'That is all.', reply: { text: 'Thanks.' }, conversation: { messages_used: 2, messages_left: 0 } } };
128
+ };
129
+ }
130
+ const texts = (list) => list.children.map((li) => li.children.map((c) => c.textContent).join(' | '));
131
+
132
+ // ── module off ─────────────────────────────────────────────────────────────────────
133
+ test('module off: the hall reads nothing and takes no door', async () => {
134
+ const dom = makeDom({ game: false });
135
+ const { win, calls } = makeWin({ enabled: { 'front-door-guide': false } }, () => ({ status: 500, body: {} }));
136
+ Hall.boot(win, dom.doc);
137
+ await tick();
138
+ assert.equal(calls.length, 0);
139
+ assert.equal(click(dom, dom.node('a')), false);
140
+ assert.equal(dom.hall.root.hidden, true);
141
+ });
142
+
143
+ // ── the hall walk ──────────────────────────────────────────────────────────────────
144
+ test('the hall walk: open, the first send held, the cap asks for the plan, the plan under the talk, sign up', async () => {
145
+ const dom = makeDom();
146
+ const { win, calls, timers } = makeWin(ON, chatRoutes());
147
+ win.localStorage.data[Core.VIEW_KEY] = 'hall';
148
+ bootAll(win, dom);
149
+ await tick();
150
+ const door = dom.node('a');
151
+ assert.equal(click(dom, door), true, 'the door is taken');
152
+ const P = dom.hall.parts;
153
+ assert.equal(dom.hall.root.hidden, false, 'this browser picked the plain one');
154
+ assert.equal(dom.game.root.hidden, true, 'and the game stays down');
155
+ assert.ok(dom.doc.documentElement.classList.contains('gd-open'), 'the page under it does not scroll');
156
+ assert.deepEqual(texts(P.log), ['Guide | Your guide is thinking…'], 'the waiting line is words');
157
+ await settle();
158
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'chat');
159
+ assert.deepEqual(calls[1].body, { website: '' }, 'the hidden field is sent as it stands');
160
+ assert.deepEqual(JSON.parse(win.localStorage.data[Core.STORE_KEY]), { id: '9', token: 'tk' }, 'the same chat key the hand-off reads');
161
+ assert.deepEqual(texts(P.log), ['Guide | Hi!']);
162
+ assert.equal(P.left.textContent, '2 of 2 messages left');
163
+ assert.equal(P.saved.textContent, 'This chat is saved on this device.');
164
+ assert.equal(P.plan.hidden, true, 'no plan section while talking');
165
+ assert.equal(P['make-plan'].hidden, true);
166
+ assert.equal(dom.doc.activeElement, P.text, 'focus lands in the box');
167
+ assert.equal(P.switch.hidden, false, 'the game is offered');
168
+
169
+ P.text.value = 'A booking site';
170
+ dom.fire(P.form, 'submit', { preventDefault() {} });
171
+ assert.equal(P.text.value, '');
172
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'sending');
173
+ assert.ok(timers[0].ms > 1000, 'the first message waits out the bot check');
174
+ timers.shift().fn();
175
+ await settle();
176
+ assert.deepEqual(calls[2].body, { token: 'tk', text: 'A booking site', website: '' });
177
+ assert.equal(P.left.textContent, '1 of 2 messages left');
178
+ assert.equal(P['make-plan'].hidden, false, 'after a message the plan can be asked for early');
179
+
180
+ // Enter sends too
181
+ P.text.value = 'Students book';
182
+ dom.fire(P.text, 'keydown', { key: 'Enter', shiftKey: false, preventDefault() {} });
183
+ assert.equal(timers[0].ms, 0, 'later messages never wait');
184
+ timers.shift().fn();
185
+ await settle(6);
186
+ assert.ok(calls.some((c) => c.url.endsWith('/conversations/9/plan')), 'POST …/plan once the cap ended the chat');
187
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'ended');
188
+ assert.equal(P.form.hidden, true);
189
+ assert.equal(P.end.hidden, false);
190
+ assert.equal(P.plan.hidden, false, 'the plan sits under the talk');
191
+ const plan = P['plan-body'].children.map((c) => c.textContent);
192
+ assert.equal(plan[0], 'A booking site for a dance school.');
193
+ assert.ok(plan.includes('How it works') && plan.includes('Steps') && plan.includes('Still to decide'));
194
+ assert.equal(plan.at(-1), 'Cost and time depend on you.');
195
+ assert.equal(P.left.textContent, 'No messages left');
196
+ assert.equal(P.go.textContent, 'Sign up and get started');
197
+ assert.equal(P['plan-again'].hidden, true);
198
+ assert.equal(dom.doc.activeElement, P.go, 'focus moves to the way on');
199
+
200
+ dom.fire(dom.hall.root, 'keydown', { key: 'Escape', preventDefault() {} });
201
+ assert.equal(dom.hall.root.hidden, true);
202
+ assert.equal(dom.doc.activeElement, door, 'focus goes back to the door');
203
+ assert.ok(!dom.doc.documentElement.classList.contains('gd-open'));
204
+ });
205
+
206
+ test('the hall: a "form" start is the plain door, never an error', async () => {
207
+ const dom = makeDom();
208
+ const { win } = makeWin(ON, chatRoutes({ 'POST /api/bongos/front-door-guide/conversations': () => ({ status: 200, body: { ok: true, mode: 'form', reason: 'daily_budget', message: 'Our guide is busy today.' } }) }));
209
+ win.localStorage.data[Core.VIEW_KEY] = 'hall';
210
+ bootAll(win, dom);
211
+ await tick();
212
+ click(dom, dom.node('a'));
213
+ await settle();
214
+ const P = dom.hall.parts;
215
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'fallback');
216
+ assert.deepEqual(texts(P.log), ['Guide | Our guide is busy today.']);
217
+ assert.equal(P.err.hidden, true);
218
+ assert.equal(P.form.hidden, true);
219
+ assert.equal(P.go.textContent, 'Start a project');
220
+ assert.equal(P.left.hidden, true, 'no count on the fallback');
221
+ assert.equal(P.saved.hidden, true);
222
+ });
223
+
224
+ test('the hall: too long says so and gives the words back; an empty box says so and sends nothing', async () => {
225
+ const dom = makeDom();
226
+ const { win, calls, timers } = makeWin(ON, chatRoutes({ 'POST /api/bongos/front-door-guide/conversations/9/messages': () => ({ status: 400, body: { ok: false, error: 'too_long', max_message_chars: 500 } }) }));
227
+ win.localStorage.data[Core.VIEW_KEY] = 'hall';
228
+ bootAll(win, dom);
229
+ await tick();
230
+ click(dom, dom.node('a'));
231
+ await settle();
232
+ const P = dom.hall.parts;
233
+ P.text.value = ' ';
234
+ dom.fire(P.form, 'submit', { preventDefault() {} });
235
+ assert.equal(P.err.textContent, Core.COPY.empty);
236
+ assert.equal(timers.length, 0, 'nothing sent');
237
+ P.text.value = 'my long words';
238
+ dom.fire(P.form, 'submit', { preventDefault() {} });
239
+ timers.shift().fn();
240
+ await settle();
241
+ assert.equal(P.err.hidden, false);
242
+ assert.equal(P.err.textContent, 'That is too long. Keep it under 500 characters.');
243
+ assert.equal(P.text.value, 'my long words', 'the words come back');
244
+ assert.equal(P.text.maxLength, 500);
245
+ assert.equal(calls.filter((c) => c.url.endsWith('/messages')).length, 1);
246
+ });
247
+
248
+ // ── which one, and the switch ──────────────────────────────────────────────────────
249
+ test('a door opens the game when this browser has not picked; the switch carries the chat both ways and is remembered', async () => {
250
+ const dom = makeDom();
251
+ const { win, calls, timers } = makeWin(ON, chatRoutes());
252
+ bootAll(win, dom);
253
+ await tick();
254
+ click(dom, dom.node('a'));
255
+ await settle();
256
+ assert.equal(dom.game.root.hidden, false, 'the game by default');
257
+ assert.equal(dom.hall.root.hidden, true);
258
+ assert.equal(dom.game.parts.switch.hidden, false, '"Use the simple version" is there');
259
+
260
+ dom.game.parts.text.value = 'A booking site';
261
+ dom.fire(dom.game.parts.form, 'submit', { preventDefault() {} });
262
+ timers.shift().fn();
263
+ await settle();
264
+
265
+ dom.fire(dom.game.parts.switch, 'click', {});
266
+ assert.equal(dom.game.root.hidden, true);
267
+ assert.equal(dom.hall.root.hidden, false);
268
+ assert.ok(dom.doc.documentElement.classList.contains('gd-open'));
269
+ assert.equal(win.localStorage.data[Core.VIEW_KEY], 'hall', 'the pick is kept on this browser');
270
+ assert.deepEqual(texts(dom.hall.parts.log), ['Guide | Hi!', 'You | A booking site', 'Guide | Tell me more.'], 'the same chat');
271
+ assert.equal(dom.hall.parts.left.textContent, '1 of 2 messages left');
272
+ assert.equal(dom.doc.activeElement, dom.hall.parts.text);
273
+ assert.equal(calls.filter((c) => c.method === 'POST' && c.url.endsWith('/conversations')).length, 1, 'never a second chat');
274
+
275
+ // keep talking in the plain one: the same conversation id
276
+ dom.hall.parts.text.value = 'Students book';
277
+ dom.fire(dom.hall.parts.form, 'submit', { preventDefault() {} });
278
+ timers.shift().fn();
279
+ await settle(6);
280
+ assert.equal(calls.filter((c) => c.url.endsWith('/conversations/9/messages')).length, 2);
281
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'ended');
282
+
283
+ // and back to the game, plan and all
284
+ dom.fire(dom.hall.parts.switch, 'click', {});
285
+ assert.equal(dom.hall.root.hidden, true);
286
+ assert.equal(dom.game.root.hidden, false);
287
+ assert.equal(win.localStorage.data[Core.VIEW_KEY], 'game');
288
+ assert.equal(dom.game.root.getAttribute('data-phase'), 'ended');
289
+ assert.equal(dom.game.parts['plan-body'].children[0].textContent, 'A booking site for a dance school.');
290
+
291
+ // closed and opened again: the last pick
292
+ dom.fire(dom.game.root, 'keydown', { key: 'Escape', preventDefault() {} });
293
+ dom.fire(dom.game.parts.switch, 'click', {}); // nothing open: the press opens nothing
294
+ assert.equal(dom.hall.root.hidden, true);
295
+ assert.equal(win.localStorage.data[Core.VIEW_KEY], 'game');
296
+ win.localStorage.data[Core.VIEW_KEY] = 'hall';
297
+ click(dom, dom.node('a'));
298
+ assert.equal(dom.hall.root.hidden, false);
299
+ assert.equal(dom.game.root.hidden, true);
300
+ });
301
+
302
+ test('the switch is offered only when the other one is on the page', async () => {
303
+ const gameOnly = makeDom({ hall: false });
304
+ const a = makeWin(ON, chatRoutes());
305
+ Scene.boot(a.win, gameOnly.doc);
306
+ await tick();
307
+ click(gameOnly, gameOnly.node('a'));
308
+ assert.equal(gameOnly.game.parts.switch.hidden, true);
309
+
310
+ const hallOnly = makeDom({ game: false });
311
+ const b = makeWin(ON, chatRoutes());
312
+ Hall.boot(b.win, hallOnly.doc);
313
+ await tick();
314
+ click(hallOnly, hallOnly.node('a'));
315
+ assert.equal(hallOnly.hall.root.hidden, false, 'with no game the plain one opens');
316
+ assert.equal(hallOnly.hall.parts.switch.hidden, true);
317
+ });
318
+
319
+ test('a stored pick that is not a presentation falls back to the game', async () => {
320
+ const dom = makeDom();
321
+ const { win } = makeWin(ON, chatRoutes());
322
+ win.localStorage.data[Core.VIEW_KEY] = 'bogus';
323
+ bootAll(win, dom);
324
+ await tick();
325
+ click(dom, dom.node('a'));
326
+ assert.equal(dom.game.root.hidden, false);
327
+ });
328
+
329
+ // ── markup and sheet ───────────────────────────────────────────────────────────────
330
+ test('the plain dialog is markup, hidden, labelled, with the honeypot out of the tab order and the same closing action', () => {
331
+ assert.match(HTML, /<div class="gh" id="guideHall" role="dialog" aria-modal="true" aria-labelledby="ghTitle" hidden>/);
332
+ assert.match(HTML, /<input class="gh__hp" type="text" name="website" data-gh="website" tabindex="-1" autocomplete="off" aria-hidden="true">/);
333
+ assert.match(HTML, /<label class="gh__label" for="ghText">Your message<\/label>/);
334
+ assert.match(HTML, /<a class="door door--go" data-gh="go" href="\/projects\?view=new">Sign up and get started<\/a>/);
335
+ assert.match(HTML, /<button type="button" class="gh__link" data-gh="switch">Use the game version<\/button>/);
336
+ assert.match(HTML, /<button type="button" class="gd__switch" data-gd="switch" hidden>Use the simple version<\/button>/);
337
+ assert.match(HTML, /<script src="\/assets\/guide-core\.js"><\/script>\n<script src="\/assets\/guide-scene\.js"><\/script>\n<script src="\/assets\/guide-hall\.js"><\/script>/);
338
+ assert.match(HTML, /<link rel="stylesheet" href="\/assets\/guide-hall\.css">/);
339
+ assert.ok(HTML.indexOf('/assets/world.css') < HTML.indexOf('/assets/guide-hall.css'));
340
+ });
341
+
342
+ test('the hall script writes no innerHTML and asks the server nothing itself', () => {
343
+ const code = HALL_SRC.replace(/\/\*[\s\S]*?\*\//g, '');
344
+ assert.doesNotMatch(code, /innerHTML/);
345
+ assert.doesNotMatch(code, /fetch\(|localStorage|sessionStorage/, 'the routes and the storage are the core\'s');
346
+ });
347
+
348
+ test('the sheet: tokens only, no :root, no breakpoint, no !important, no motion', () => {
349
+ const code = CSS.replace(/\/\*[\s\S]*?\*\//g, '');
350
+ assert.doesNotMatch(code, /#[0-9a-f]{3,8}\b/i, 'no hex colour');
351
+ assert.doesNotMatch(code, /\brgba?\((?!0, 0, 0,)/, 'the only literal is black with alpha (the scrim)');
352
+ assert.doesNotMatch(code, /font-size:\s*\d/, 'type from the scale');
353
+ assert.doesNotMatch(code, /border-radius:\s*\d/, 'corners from the scale');
354
+ assert.doesNotMatch(code, /:root|@media|!important|animation|transition/);
355
+ });
356
+
357
+ test('every control in the plain dialog clears the 24px floor', () => {
358
+ for (const [sel, min] of [['.gh__x', /width: 40px; height: 40px/], ['.gh__link', /min-height: 24px/]]) {
359
+ const rule = CSS.slice(CSS.indexOf(sel + ' {'));
360
+ assert.match(rule.slice(0, rule.indexOf('}')), min, sel);
361
+ }
362
+ // the presses are the landing's own door pills (48px, index.html .door)
363
+ assert.match(HTML, /\.door\{display:inline-flex;align-items:center;justify-content:center;min-height:48px;/);
364
+ });
@@ -31,6 +31,8 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
31
31
  const HTML = fs.readFileSync(path.join(LANDING, 'index.html'), 'utf8').replace(/\r\n/g, '\n');
32
32
  const CSS = fs.readFileSync(path.join(LANDING, 'assets', 'guide-scene.css'), 'utf8').replace(/\r\n/g, '\n');
33
33
  const JS_SRC = fs.readFileSync(path.join(LANDING, 'assets', 'guide-scene.js'), 'utf8');
34
+ // the conversation's own half (the routes, the storage) is shared with the plain twin since task 1005276
35
+ const CORE_SRC = fs.readFileSync(path.join(LANDING, 'assets', 'guide-core.js'), 'utf8');
34
36
  const G = require(path.join(LANDING, 'assets', 'guide-scene.js'));
35
37
 
36
38
  const chatStart = (over = {}) => ({
@@ -462,7 +464,7 @@ test('the scene is markup, hidden, a labelled modal dialog, with the honeypot ou
462
464
  assert.match(HTML, /<button type="button" class="gd__more" data-gd="make-plan" hidden>Make my plan<\/button>/);
463
465
  assert.match(HTML, /<button type="button" class="gd__more" data-gd="plan-again" hidden>Make my plan<\/button>/);
464
466
  assert.match(HTML, /<p class="gd__saved" data-gd="saved" hidden><\/p>/);
465
- assert.match(HTML, /<script src="\/assets\/guide-scene\.js"><\/script>/);
467
+ assert.match(HTML, /<script src="\/assets\/guide-core\.js"><\/script>\n<script src="\/assets\/guide-scene\.js"><\/script>/, 'the shared core loads first');
466
468
  assert.match(HTML, /<link rel="stylesheet" href="\/assets\/guide-scene\.css">/);
467
469
  // the guide is the Patron sprite the hall already serves, not a second drawing
468
470
  assert.match(HTML, /src="\/modules\/hall-ui\/public\/characters\/patron\.png"/);
@@ -474,11 +476,12 @@ test('a signed-in visitor keeps the plain doors: the /me read takes the hook off
474
476
  });
475
477
 
476
478
  test('the script writes no innerHTML and talks only to the guide routes', () => {
477
- assert.doesNotMatch(JS_SRC.replace(/\/\*[\s\S]*?\*\//g, ''), /innerHTML/);
478
- const urls = [...JS_SRC.matchAll(/API \+ '([^']+)'/g)].map((m) => m[1]);
479
+ for (const src of [JS_SRC, CORE_SRC]) assert.doesNotMatch(src.replace(/\/\*[\s\S]*?\*\//g, ''), /innerHTML/);
480
+ const urls = [...CORE_SRC.matchAll(/API \+ '([^']+)'/g)].map((m) => m[1]);
479
481
  assert.deepEqual([...new Set(urls)].sort(), ['/conversations', '/conversations/', '/status']);
480
- assert.match(JS_SRC, /convUrl\('\/plan'\)/, 'the plan route');
481
- assert.doesNotMatch(JS_SRC, /sessionStorage/, 'the chat is kept in local storage, as the server\'s contract says');
482
+ assert.doesNotMatch(JS_SRC, /fetch\(/, 'the scene asks the server nothing itself: the core does');
483
+ assert.match(CORE_SRC, /convUrl\('\/plan'\)/, 'the plan route');
484
+ assert.doesNotMatch(CORE_SRC + JS_SRC, /sessionStorage/, 'the chat is kept in local storage, as the server\'s contract says');
482
485
  assert.equal(G.API, '/api/bongos/front-door-guide');
483
486
  });
484
487
 
@@ -55,6 +55,7 @@ const PUBLISHED_SURFACE = [
55
55
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
56
56
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
57
57
  'readApplicantViewFromHub', // added by task 1002304 (ADR 0365 D5): the same NARROW hub read as readApplicantProfileFromHub, answering { profile, guild } so the reviewer queue can group a guild's applicants. The guild rides only beside a profile, and the older port is kept so a module on it reads exactly what it did
58
+ 'readAppStatusFromHub', // added by task 1004571: where THIS project's app runs, read from the hub for the options menu's app-host panel. The same NARROW-port reason as readApplicantProfileFromHub: it carries this instance's hub client secret, so it takes no argument and a module can neither widen nor re-address it
58
59
  'notifyHubOfInvite', // added by task 1002818 (ADR 0353): tell the hub about one of THIS project's invites (or its rescind) so the invitee sees it in My Projects. The same NARROW-port reason as readApplicantProfileFromHub: the call carries this instance's hub client secret, so a module passes one login and one direction and can neither widen nor re-address it
59
60
  'inviteExpiry', // added by task 1002969 (ADR 0356): the ONE read-time definition of when a recruit invite's door closes. The sign-in gate reads it in core; the onboarding queues and the hub's notice list are modules, so without the doorway each would restate the 30 days and the predicate, and two copies can close a door on different days
60
61
  'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience
@@ -278,7 +278,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
278
278
  // That is precisely how the agents regression reached an open PR (task 1002485).
279
279
  loader._reset();
280
280
  const snap = loader.loaderSnapshot();
281
- assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'front-door-guide', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'market-watch', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
281
+ assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'app-host', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'front-door-guide', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'market-watch', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
282
282
  assert.deepEqual(snap.errors, []);
283
283
  assert.deepEqual(snap.skipped, []);
284
284
  loader._reset();
@@ -290,8 +290,14 @@ test('the module declares the task page section and ships its widget; the seam g
290
290
  assert.deepEqual(manifest.contributes.uiSections, ['task-where']);
291
291
  assert.ok(manifest.contributes.routes.includes('task-where'));
292
292
  assert.ok(fs.existsSync(path.join(ROOT, 'modules/npm-release/public/task-widget.js')));
293
- const guard = fs.readFileSync(path.join(ROOT, 'scripts/bongos/seam-wiring-guard.js'), 'utf8');
294
- assert.match(guard, /alternates: \(dir\) => \[path\.join\(dir, 'public', 'task-widget\.js'\)\]/);
293
+ // Ask the guard itself (task 1004571 converted this from a source-text match): a module
294
+ // whose only widget is task-widget.js counts as wired, and with no widget it does not.
295
+ const { scanSeamWiring } = require('../scripts/bongos/seam-wiring-guard.js');
296
+ const dir = path.join(ROOT, 'modules/npm-release');
297
+ const only = (file) => ({ exists: (p) => p === path.join(dir, 'public', file) });
298
+ const mod = [{ key: 'npm-release', dir, manifest: { contributes: { uiSections: ['task-where'] } } }];
299
+ assert.deepEqual(scanSeamWiring(mod, only('task-widget.js')).findings, []);
300
+ assert.equal(scanSeamWiring(mod, { exists: () => false }).findings.length, 1);
295
301
  });
296
302
 
297
303
  test('the server injects task widgets just before task.js, and names no module doing it', () => {
@@ -302,8 +308,12 @@ test('the server injects task widgets just before task.js, and names no module d
302
308
  assert.ok(out.indexOf('/builders/md-reader.js') < out.indexOf('task-widget.js'));
303
309
  assert.match(out, /<script src="\/modules\/npm-release\/public\/task-widget\.js" defer><\/script>/);
304
310
  assert.equal(injectModuleHallWidgets(page, [], 'task'), page, 'no module, no change');
305
- const index = '<script src="/builders/builders.js?v=1"></script>';
311
+ // The index's builders.js is deferred, so its widgets are too (unchanged). Since task
312
+ // 1004571 a widget loads the way its consumer does, so a plain consumer gets a plain one.
313
+ const index = '<script src="/builders/builders.js?v=1" defer></script>';
306
314
  assert.match(injectModuleHallWidgets(index, [{ key: 'x', src: '/m.js' }]), /^<script src="\/m\.js" defer><\/script><script src="\/builders\/builders\.js/, 'the index injection is unchanged');
315
+ const plain = '<script src="/builders/settings.js?v=1"></script>';
316
+ assert.match(injectModuleHallWidgets(plain, [{ key: 'x', src: '/m.js' }], 'settings'), /^<script src="\/m\.js"><\/script><script src="\/builders\/settings\.js/);
307
317
  const si = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
308
318
  assert.match(si, /hallWidgetScripts\(\{ isEnabled: isModuleEnabled, file: 'task-widget\.js' \}\),\s*\n\s*'task',/);
309
319
  });