@bongos/core 1.25.17 → 1.25.18

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 (68) hide show
  1. package/.bongos-core.json +131 -61
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +15 -1
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  8. package/docs/api/openapi.json +243 -6
  9. package/docs/api-reference.md +12 -3
  10. package/docs/copy-inventory.md +118 -108
  11. package/docs/copy-registry.json +315 -172
  12. package/docs/file-map.md +2 -1
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +62 -62
  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/lifecycle/first-read-catalog.js +62 -0
  25. package/modules/lifecycle/first-read-db.js +47 -9
  26. package/modules/lifecycle/first-read.js +88 -23
  27. package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
  28. package/modules/lifecycle/routes/first-read.js +27 -5
  29. package/modules/onboarding/genesis-work.js +101 -4
  30. package/modules/onboarding/routes/genesis-work.js +2 -2
  31. package/modules/platform-identity/module.json +1 -1
  32. package/modules/platform-identity/routes/sso-app-status.js +65 -0
  33. package/modules/provisioning/module.json +1 -0
  34. package/modules/provisioning/seams.js +32 -1
  35. package/modules/provisioning/starter-bundles.js +1 -0
  36. package/modules/public-landing/public/assets/guide-core.js +475 -0
  37. package/modules/public-landing/public/assets/guide-hall.css +121 -0
  38. package/modules/public-landing/public/assets/guide-hall.js +164 -0
  39. package/modules/public-landing/public/assets/guide-scene.css +9 -0
  40. package/modules/public-landing/public/assets/guide-scene.js +58 -381
  41. package/modules/public-landing/public/index.html +46 -1
  42. package/modules/public-landing/public/projects.html +11 -2
  43. package/modules/ui-design/kit/serve.js +1 -1
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +18 -0
  47. package/scripts/bongos/first-read.js +45 -11
  48. package/scripts/bongos/genesis.js +21 -1
  49. package/scripts/bongos/publish-manifest.js +1 -0
  50. package/scripts/bongos/seam-wiring-guard.js +3 -2
  51. package/scripts/hall-preview/server.js +2 -0
  52. package/src/bongos/auth-admission.js +64 -0
  53. package/src/bongos/auth.js +2 -1
  54. package/src/bongos/module-scope-map.js +4 -0
  55. package/src/bongos/routes/core-update.js +3 -16
  56. package/src/bongos/routes/modules.js +11 -0
  57. package/src/bongos/serve-internal.js +20 -6
  58. package/src/module-api.js +6 -1
  59. package/src/module-loader/loader.js +2 -2
  60. package/tests/app_host.mjs +413 -0
  61. package/tests/first_read.mjs +184 -8
  62. package/tests/genesis_work.mjs +74 -3
  63. package/tests/government_charter_advice.mjs +150 -0
  64. package/tests/landing_guide_hall.mjs +364 -0
  65. package/tests/landing_guide_scene.mjs +8 -5
  66. package/tests/module_api.mjs +1 -0
  67. package/tests/module_loader.mjs +1 -1
  68. package/tests/npm_release_where.mjs +13 -3
@@ -222,10 +222,81 @@ if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
222
222
 
223
223
  await test('a stage that has not adopted questions still takes a page and a link', async () => {
224
224
  const deps = { store: memStore() };
225
- const r = await gw.setPage('charter', 'Monarchy to start.', 1, deps);
225
+ const r = await gw.setPage('legalize', 'Credits only to start.', 1, deps);
226
226
  assert.equal(r.work.adopted, false);
227
- assert.equal(r.work.page, 'Monarchy to start.');
228
- assert.equal((await gw.setAnswer('charter', 'anything', { text: 'x' }, 1, deps)).reason, 'unknown_question');
227
+ assert.equal(r.work.page, 'Credits only to start.');
228
+ assert.equal((await gw.setAnswer('legalize', 'anything', { text: 'x' }, 1, deps)).reason, 'unknown_question');
229
+ });
230
+
231
+ // ── the charter stage (task 1005119) ──────────────────────────────────────
232
+ // A stand-in government port that records what it was handed, so the test sees
233
+ // exactly which answers reach the recommender.
234
+ const fakeGov = () => {
235
+ const calls = [];
236
+ return { calls, async charterAdvice(input) { calls.push(input); return { charter: { key: 'monarchy' }, input }; } };
237
+ };
238
+ const charterDeps = (extra = {}) => ({ store: memStore(), government: fakeGov(), getSetting: async () => null, ...extra });
239
+
240
+ await test('charter: a closed answer must be one of its choices, and a label reads back as its key', async () => {
241
+ const deps = charterDeps();
242
+ const bad = await gw.setAnswer('charter', 'final_say', { text: 'my cat' }, 1, deps);
243
+ assert.equal(bad.reason, 'not_a_choice');
244
+ assert.deepEqual(bad.expected, ['founder', 'trusted', 'everyone', 'council']);
245
+ const r = await gw.setAnswer('charter', 'final_say', { text: 'Everyone on the project' }, 1, deps);
246
+ const q = qOf(r, 'final_say');
247
+ assert.equal(q.answer.text, 'everyone', 'stored as the key');
248
+ assert.equal(q.answer.choice_label, 'Everyone on the project');
249
+ assert.ok(q.choices.some((c) => c.key === 'council'), 'the choices ride the view');
250
+ // prose questions take any words
251
+ const p = await gw.setAnswer('charter', 'feel', { text: 'Calm and careful.' }, 1, deps);
252
+ assert.equal(qOf(p, 'feel').answer.text, 'Calm and careful.');
253
+ });
254
+
255
+ await test('charter: every question is skippable and answerable "not sure yet"', async () => {
256
+ const deps = charterDeps();
257
+ for (const q of gw.QUESTIONS.charter) {
258
+ assert.equal((await gw.setAnswer('charter', q.id, { state: 'not_sure' }, 1, deps)).ok, true, q.id);
259
+ assert.equal((await gw.setAnswer('charter', q.id, { state: 'skipped' }, 1, deps)).ok, true, q.id);
260
+ }
261
+ });
262
+
263
+ await test('charter: only given answers reach the recommender, closed as keys and prose as words', async () => {
264
+ const gov = fakeGov();
265
+ const deps = charterDeps({ government: gov, getSetting: async () => ({ value: JSON.stringify({ expected_builders: 6 }) }) });
266
+ await gw.setAnswer('charter', 'final_say', { text: 'trusted' }, 1, deps);
267
+ await gw.setAnswer('charter', 'agreement', { state: 'not_sure' }, 1, deps);
268
+ await gw.setAnswer('charter', 'speed', { state: 'skipped' }, 1, deps);
269
+ await gw.setAnswer('charter', 'leave_behaviour', { text: 'Lying about shipped work.' }, 1, deps);
270
+ const r = await gw.readStage('charter', deps);
271
+ const last = gov.calls[gov.calls.length - 1];
272
+ assert.deepEqual(last.answers, { final_say: 'trusted' }, 'not sure yet and skipped are not sent');
273
+ assert.deepEqual(last.prose, { leave_behaviour: 'Lying about shipped work.' });
274
+ assert.equal(last.headcount, 6, "the founder's expected headcount from the plan");
275
+ assert.equal(r.work.advice.charter.key, 'monarchy', 'the advice rides the stage');
276
+ });
277
+
278
+ await test('charter: no government module, or a failing one, still reads the stage (advice null)', async () => {
279
+ const none = await gw.readStage('charter', charterDeps({ government: null }));
280
+ assert.equal(none.ok, true);
281
+ assert.equal(none.work.advice, null);
282
+ const broken = await gw.readStage('charter', charterDeps({ government: { charterAdvice: async () => { throw new Error('db down'); } } }));
283
+ assert.equal(broken.work.advice, null);
284
+ const other = await gw.readStage('strategize', charterDeps());
285
+ assert.equal(other.work.advice, undefined, 'only the charter stage carries advice');
286
+ });
287
+
288
+ await test('charter: the assisted brief lists the keys to record for each closed question', async () => {
289
+ const p = gw.assistPrompt('charter');
290
+ assert.match(p, /\[final_say\]/);
291
+ assert.match(p, /Record one of: founder \(Me, the founder\) · trusted/);
292
+ assert.match(p, /\[leave_behaviour\]/);
293
+ });
294
+
295
+ await test('charter: the planning read path shows a closed answer in words, not its key', async () => {
296
+ const deps = charterDeps();
297
+ await gw.setAnswer('charter', 'agreement', { text: 'consent' }, 1, deps);
298
+ const { markdown } = await gw.readContext(deps);
299
+ assert.match(markdown, /How much agreement should a decision need\?\*\*\n It passes unless someone objects/);
229
300
  });
230
301
 
231
302
  await test('an empty project gives the planning read path nothing to print', async () => {
@@ -0,0 +1,150 @@
1
+ // tests/government_charter_advice.mjs — the genesis charter stage's recommendation
2
+ // from the founder's own answers (task 1005119).
3
+ //
4
+ // The rules held here:
5
+ // • ONE recommender: the answers widen recommendedCharter; with none it answers
6
+ // exactly as the size ladder always has, for every type and size;
7
+ // • every recommendation is an adoptable form, says why in the founder's answers,
8
+ // and a Foundation still brings its council rank;
9
+ // • the principles point at a strictness preset (or none), and saying what a preset
10
+ // does is part of the answer;
11
+ // • the drafted bylaws list the recommended preset's own laws word for word, so the
12
+ // prose and the law book agree, and say plainly when no laws are switched on;
13
+ // • the closed answers' keys are the genesis questions' choices (two modules, one
14
+ // vocabulary, held equal here because neither may import the other).
15
+ import assert from 'node:assert/strict';
16
+ import { test } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import path from 'node:path';
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 reco = require(path.join(ROOT, 'modules', 'government', 'recommendations.js'));
24
+ const presets = require(path.join(ROOT, 'modules', 'government', 'presets.js'));
25
+ const charter = require(path.join(ROOT, 'modules', 'government', 'charter.js'));
26
+ const advice = require(path.join(ROOT, 'modules', 'government', 'charter-advice.js'));
27
+ const gw = require(path.join(ROOT, 'modules', 'onboarding', 'genesis-work.js'));
28
+
29
+ const TYPES = [...reco.PROJECT_TYPES.map((t) => t.key), undefined];
30
+ const SIZES = [0, 1, 2, 3, 8, 9, 49, 50, 149, 150, 1000];
31
+
32
+ test('the answer keys are exactly the charter questions\' choices', () => {
33
+ const closed = Object.fromEntries(gw.QUESTIONS.charter.filter((q) => q.choices).map((q) => [q.id, q.choices.map((c) => c.key)]));
34
+ const expected = { ...reco.CHARTER_ANSWERS, ...presets.PRINCIPLE_ANSWERS };
35
+ assert.deepEqual(Object.keys(closed).sort(), Object.keys(expected).sort());
36
+ for (const [id, keys] of Object.entries(expected)) assert.deepEqual(closed[id], [...keys], id);
37
+ });
38
+
39
+ test('with no answers (or only "not sure"), the ladder answers exactly as before', () => {
40
+ for (const type of TYPES) {
41
+ for (const n of SIZES) {
42
+ const plain = reco.recommendedCharter(n, type);
43
+ for (const a of [undefined, {}, { final_say: 'maybe', agreement: null }]) {
44
+ const r = reco.recommendedCharter(n, type, a);
45
+ assert.equal(r.key, plain.key);
46
+ assert.equal(r.why, plain.why);
47
+ assert.equal(r.from_answers, false);
48
+ }
49
+ }
50
+ }
51
+ });
52
+
53
+ test('the founder\'s final say picks the form, and why quotes them', () => {
54
+ const r = reco.recommendedCharter(1, 'game', { final_say: 'everyone' });
55
+ assert.equal(r.key, 'direct-democracy', 'a solo founder who wants everyone deciding is not told monarchy');
56
+ assert.match(r.why, /You said everyone on the project should have the final say/);
57
+ assert.equal(r.from_answers, true);
58
+ const council = reco.recommendedCharter(5, 'research', { final_say: 'council' });
59
+ assert.equal(council.key, 'foundation');
60
+ assert.equal(council.council_rank, 'steering-committee', 'a Foundation still brings its council');
61
+ });
62
+
63
+ test('the other answers move the franchise when the final say is not given', () => {
64
+ assert.equal(reco.recommendedCharter(5, null, { answers_to: 'yes' }).key, 'foundation');
65
+ const back = reco.recommendedCharter(1, null, { step_back: 'yes' });
66
+ assert.equal(back.key, 'timocratic-republic');
67
+ assert.match(back.why, /step back/);
68
+ assert.equal(reco.recommendedCharter(5, null, { strangers: 'yes' }).key, 'timocratic-republic');
69
+ const fast = reco.recommendedCharter(5, null, { speed: 'fast' });
70
+ assert.equal(fast.key, 'direct-democracy', 'speed alone keeps the ladder\'s form');
71
+ assert.match(fast.why, /Who votes follows your size/, 'the part the answers did not decide says the ladder decided it');
72
+ });
73
+
74
+ test('how much agreement tunes the pass rule and says where it is set', () => {
75
+ const r = reco.recommendedCharter(20, null, { agreement: 'majority' });
76
+ assert.equal(r.key, 'timocratic-republic');
77
+ assert.equal(r.pass_rule, 'majority');
78
+ assert.equal(r.pass_rule_tuned, true);
79
+ assert.match(r.why, /legislation stage/);
80
+ const solo = reco.recommendedCharter(1, null, { final_say: 'founder', agreement: 'unanimous' });
81
+ assert.equal(solo.pass_rule, 'first_ratifier', 'a board of one is decided by its one yes');
82
+ assert.match(solo.why, /only voter/);
83
+ });
84
+
85
+ test('every answer combination recommends an adoptable form whose settings build a proposal', () => {
86
+ const A = reco.CHARTER_ANSWERS;
87
+ const available = charter.FORMS.filter((f) => f.available).map((f) => f.key);
88
+ const live = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
89
+ for (const fs of [undefined, ...A.final_say]) for (const ag of [undefined, ...A.agreement]) for (const yn of [undefined, 'yes', 'no']) {
90
+ const r = reco.recommendedCharter(5, 'business', { final_say: fs, agreement: ag, strangers: yn, answers_to: yn, step_back: yn });
91
+ assert.ok(available.includes(r.key), r.key);
92
+ assert.ok(typeof r.why === 'string' && r.why.length > 10);
93
+ const built = charter.buildCharterProposal(live, { franchise: r.franchise, passRule: r.pass_rule, councilRank: r.council_rank });
94
+ assert.equal(built.ok, true, JSON.stringify(r));
95
+ assert.equal(charter.readCharter(built.proposal).franchise, r.franchise);
96
+ }
97
+ });
98
+
99
+ test('principles point at a preset, or at none', () => {
100
+ assert.equal(presets.recommendedPreset({}), null, 'no preset is a fine answer');
101
+ assert.equal(presets.recommendedPreset({ trust_level: 'a_lot' }).key, 'open');
102
+ assert.equal(presets.recommendedPreset({ trust_level: 'some' }).key, 'guild');
103
+ assert.equal(presets.recommendedPreset({ trust_level: 'little' }).key, 'factory');
104
+ assert.equal(presets.recommendedPreset({ trust_level: 'a_lot', catch_or_fix: 'catch_early' }).key, 'guild');
105
+ assert.equal(presets.recommendedPreset({ trust_level: 'little', catch_or_fix: 'move_fast' }).key, 'guild');
106
+ const p = presets.recommendedPreset({ trust_level: 'some' });
107
+ assert.match(p.why, /You said/);
108
+ assert.match(p.is, /not a style guide/);
109
+ assert.ok(p.laws.length > 0);
110
+ });
111
+
112
+ test('the draft names the recommended preset\'s own laws word for word', () => {
113
+ const a = advice.buildAdvice({
114
+ answers: { final_say: 'trusted', trust_level: 'little' },
115
+ prose: { feel: 'Calm and careful.', leave_behaviour: 'Lying about shipped work.', write_down: 'We never ship on Fridays.' },
116
+ headcount: 12,
117
+ });
118
+ assert.equal(a.charter.key, 'timocratic-republic');
119
+ assert.equal(a.preset.key, 'factory');
120
+ const factory = presets.PRESETS.find((p) => p.key === 'factory');
121
+ for (const law of factory.laws) assert.ok(a.draft_md.includes(`**${law.text}.** ${law.effect_on_ai}`), law.text);
122
+ assert.ok(a.preset.locks.some((l) => l.setting === 'wandering'), 'what it would lock is said');
123
+ assert.match(a.draft_md, /Timocratic Republic/);
124
+ assert.match(a.draft_md, /Calm and careful\./);
125
+ assert.match(a.draft_md, /Lying about shipped work\./);
126
+ assert.match(a.draft_md, /We never ship on Fridays\./);
127
+ });
128
+
129
+ test('with no preset the draft says no laws are switched on', () => {
130
+ const a = advice.buildAdvice({ answers: {}, headcount: 1 });
131
+ assert.equal(a.preset, null);
132
+ assert.match(a.draft_md, /No laws are switched on/);
133
+ assert.equal(a.charter.key, 'monarchy');
134
+ assert.doesNotMatch(a.draft_md, /Code of conduct/, 'no section for words the founder has not written');
135
+ });
136
+
137
+ test('the port entry reads the head count and the law book', async () => {
138
+ const calls = [];
139
+ const db = {
140
+ countActiveBuilders: async () => { calls.push('count'); return 40; },
141
+ listLaws: async () => { calls.push('laws'); return []; },
142
+ };
143
+ const live = await advice.charterAdvice({ answers: {}, prose: { feel: ' ' } }, { db });
144
+ assert.equal(live.headcount, 40);
145
+ assert.equal(live.charter.key, 'timocratic-republic');
146
+ assert.ok(calls.includes('laws'));
147
+ const expected = await advice.charterAdvice({ answers: {}, headcount: 2 }, { db });
148
+ assert.equal(expected.headcount, 2, 'the founder\'s expected number wins over the live count');
149
+ assert.equal(expected.charter.key, 'monarchy');
150
+ });
@@ -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