@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
@@ -27,309 +27,38 @@
27
27
  - write innerHTML, or move anything from script: the waiting dots are CSS, so the world's
28
28
  reduced-motion switch (world.css) stops them.
29
29
 
30
- The state machine (reduce) and the plan reader (readPlan) are pure and exported for
31
- tests/landing_guide_scene.mjs; boot() is the DOM half and runs only in a browser. */
30
+ Since task 1005276 the conversation itself (the state machine, the plan reader, the
31
+ server's client rules, the doors) is assets/guide-core.js, shared with the plain hall
32
+ presentation (assets/guide-hall.js); this file is the game's look on top of it, and
33
+ re-exports the core's pure half for tests/landing_guide_scene.mjs. "Use the simple
34
+ version" swaps to the hall panel with the same chat. */
32
35
  (function (factory) {
33
36
  'use strict';
34
- var api = factory();
37
+ var core = typeof module === 'object' && module.exports ? require('./guide-core.js') : window.GuideCore;
38
+ var api = factory(core);
35
39
  if (typeof module === 'object' && module.exports) module.exports = api;
36
40
  if (typeof window !== 'undefined' && typeof document !== 'undefined') {
37
41
  window.GuideScene = api;
38
42
  api.boot(window, document);
39
43
  }
40
- })(function () {
44
+ })(function (core) {
41
45
  'use strict';
42
46
 
43
- var API = '/api/bongos/front-door-guide';
44
- var MODULE_KEY = 'front-door-guide';
45
- // the server refuses a first message sooner than 1500ms after the start (a bot); wait a
46
- // little longer so a clock edge never turns a person into one
47
- var MIN_FIRST_MS = 1600;
48
- // the chat's { id, token } in local storage: the server's contract keeps it there, not in
49
- // session storage, so a closed browser comes back to the same chat
50
- var STORE_KEY = 'cb-front-door-guide';
47
+ var COPY = core.COPY;
51
48
  var HELM_FONTS = 'https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;500;600&family=Barlow:wght@400;500&display=swap';
52
- var SIGN_UP = 'Sign up and get started';
53
-
54
- // Every sentence the scene says on its own. The guide's replies are the server's.
55
- var COPY = {
56
- fallback: 'The guide is resting right now. You can still start your project.',
57
- lost: 'This chat has ended. You can still start your project.',
58
- failed: 'That did not send. Try again.',
59
- planFailed: 'Your plan did not come through. Try again.',
60
- tooEarly: 'Tell the guide a little about your project first.',
61
- empty: 'Type a message first.',
62
- tooLong: function (n) { return 'That is too long. Keep it under ' + n + ' characters.'; },
63
- tooFast: function (s) { return 'Too quick. Try again in ' + s + (s === 1 ? ' second.' : ' seconds.'); },
64
- left: function (left, total) {
65
- if (left <= 0) return 'No messages left';
66
- return left + ' of ' + total + (total === 1 ? ' message left' : ' messages left');
67
- },
68
- waitTalk: 'Your guide is thinking',
69
- waitPlan: 'Your guide is writing your plan',
70
- };
71
-
72
- /* ── is the guide on for this page? The module set the server injected. ── */
73
- function guideOn(mods) {
74
- return !!(mods && mods.enabled && mods.enabled[MODULE_KEY] === true);
75
- }
76
-
77
- /* ── the plan, read leniently. The server's Plan (modules/front-door-guide/plan.js) is
78
- { summary, project_type, how_it_works[], modules[{label, summary, why}], steps[{title,
79
- detail}], needs[{item, detail}], open_questions[], cost_and_time }: each list becomes a
80
- section under its own heading, in that order. Anything else it carries is read as text
81
- where it can be and dropped where it cannot; no plan at all is null. ── */
82
- var SECTIONS = [
83
- ['how_it_works', 'How it works'],
84
- ['steps', 'Steps'],
85
- ['modules', 'Modules to start with'],
86
- ['needs', 'You will need'],
87
- ['open_questions', 'Still to decide'],
88
- ];
89
- function asText(v) {
90
- if (typeof v === 'string') return v.trim();
91
- if (typeof v === 'number') return String(v);
92
- if (v && typeof v === 'object') {
93
- var head = [v.title, v.label, v.item, v.name, v.heading].filter(function (s) { return typeof s === 'string' && s.trim(); })[0];
94
- var body = [v.why, v.detail, v.text, v.summary, v.description].filter(function (s) { return typeof s === 'string' && s.trim(); })[0];
95
- return [head, body].filter(Boolean).map(function (s) { return s.trim(); }).join(': ');
96
- }
97
- return '';
98
- }
99
- function asItems(arr) {
100
- return (Array.isArray(arr) ? arr : []).map(asText).filter(Boolean).slice(0, 12);
101
- }
102
- function readPlan(raw) {
103
- if (raw == null) return null;
104
- if (typeof raw === 'string') return raw.trim() ? { summary: raw.trim(), sections: [], note: '' } : null;
105
- if (typeof raw !== 'object' || Array.isArray(raw)) return null;
106
- var plan = { summary: asText(raw.summary || ''), sections: [], note: typeof raw.cost_and_time === 'string' ? raw.cost_and_time.trim() : '' };
107
- var seen = {};
108
- SECTIONS.forEach(function (s) {
109
- seen[s[0]] = true;
110
- var its = asItems(raw[s[0]]);
111
- if (its.length) plan.sections.push({ heading: s[1], items: its });
112
- });
113
- Object.keys(raw).forEach(function (k) {
114
- if (seen[k] || !Array.isArray(raw[k])) return;
115
- var its = asItems(raw[k]);
116
- if (its.length) plan.sections.push({ heading: '', items: its });
117
- });
118
- return plan.summary || plan.sections.length ? plan : null;
119
- }
120
-
121
- /* ── the state machine. One state object; every event returns a new one. ──
122
- phase: closed · starting · chat · sending · planning · ended · fallback
123
- A chat that has ended with no plan yet (the plan call failed, or a resumed chat never
124
- asked) is 'ended' with plan null: the end strip then offers "Make my plan" again. */
125
- function initial() {
126
- return { phase: 'closed', messages: [], left: 0, total: 0, maxChars: 1000, plan: null, error: '', fallback: '', conv: null, startedAt: 0, restore: '', saved: '', next: SIGN_UP, sent: 0 };
127
- }
128
- function copyState(s) {
129
- var n = {};
130
- Object.keys(s).forEach(function (k) { n[k] = s[k]; });
131
- n.messages = s.messages.slice();
132
- return n;
133
- }
134
- function conversationCounts(n, c) {
135
- if (!c || typeof c !== 'object') return;
136
- if (typeof c.messages_left === 'number') n.left = Math.max(0, c.messages_left);
137
- if (typeof c.messages_used === 'number') n.sent = c.messages_used;
138
- if (typeof c.messages_used === 'number' && typeof c.messages_left === 'number') n.total = Math.max(n.total, c.messages_used + c.messages_left);
139
- if (typeof c.max_message_chars === 'number') n.maxChars = c.max_message_chars;
140
- }
141
- function takeExtras(n, b) {
142
- if (b.saved && typeof b.saved.message === 'string' && b.saved.message) n.saved = b.saved.message;
143
- if (b.next && typeof b.next.label === 'string' && b.next.label) n.next = b.next.label;
144
- var p = readPlan(b.plan);
145
- if (p) n.plan = p;
146
- }
147
- function toFallback(n, message) {
148
- n.phase = 'fallback';
149
- n.fallback = message || COPY.fallback;
150
- n.error = '';
151
- n.restore = '';
152
- return n;
153
- }
154
- function dropLastVisitor(n) {
155
- for (var i = n.messages.length - 1; i >= 0; i--) {
156
- if (n.messages[i].role === 'visitor') { n.restore = n.messages[i].text; n.messages.splice(i, 1); n.sent = Math.max(0, n.sent - 1); return; }
157
- }
158
- }
159
- function errCode(body) {
160
- return body && (body.error && typeof body.error === 'object' ? body.error.code : body.error || body.code);
161
- }
162
- function reduce(state, ev) {
163
- var n = copyState(state || initial());
164
- n.restore = '';
165
- var b = (ev && ev.body) || {};
166
- var ok = ev && ev.status >= 200 && ev.status < 300;
167
- switch (ev && ev.type) {
168
- case 'start':
169
- n.phase = 'starting';
170
- n.error = '';
171
- return n;
172
- case 'started':
173
- if (ok && b.mode === 'chat' && b.conversation && b.conversation.id && b.conversation.token) {
174
- n.phase = 'chat';
175
- n.conv = { id: String(b.conversation.id), token: String(b.conversation.token) };
176
- n.startedAt = ev.now || 0;
177
- n.total = 0;
178
- conversationCounts(n, b.conversation);
179
- n.messages = typeof b.greeting === 'string' && b.greeting ? [{ role: 'guide', text: b.greeting }] : [];
180
- takeExtras(n, b);
181
- return n;
182
- }
183
- return toFallback(n, b.mode === 'form' ? b.message : '');
184
- case 'resumed': {
185
- // GET /conversations/:id on load: the transcript (no greeting), the plan if written
186
- if (!ok || !b.conversation || !Array.isArray(b.transcript)) return initial();
187
- n.conv = ev.conv;
188
- n.total = 0;
189
- conversationCounts(n, b.conversation);
190
- n.messages = b.transcript
191
- .filter(function (m) { return m && typeof m.text === 'string' && (m.role === 'visitor' || m.role === 'guide'); })
192
- .map(function (m) { return { role: m.role, text: m.text }; });
193
- n.sent = Math.max(n.sent, n.messages.filter(function (m) { return m.role === 'visitor'; }).length);
194
- takeExtras(n, b);
195
- n.phase = b.conversation.status === 'ended' || n.plan ? 'ended' : 'chat';
196
- // a resumed chat with nothing said yet is new enough for the bot check to apply
197
- n.startedAt = n.sent ? 0 : (ev.now || 0);
198
- return n;
199
- }
200
- case 'send':
201
- n.phase = 'sending';
202
- n.error = '';
203
- n.sent += 1;
204
- n.messages.push({ role: 'visitor', text: ev.text });
205
- return n;
206
- case 'answer': {
207
- if (ok) {
208
- takeExtras(n, b);
209
- if (b.mode === 'chat') {
210
- if (b.reply && typeof b.reply.text === 'string') n.messages.push({ role: 'guide', text: b.reply.text });
211
- conversationCounts(n, b.conversation);
212
- n.phase = n.left > 0 ? 'chat' : 'planning';
213
- return n;
214
- }
215
- if (b.mode === 'ended') {
216
- if (b.reply && typeof b.reply.text === 'string') n.messages.push({ role: 'guide', text: b.reply.text });
217
- conversationCounts(n, b.conversation);
218
- n.left = 0;
219
- if (typeof b.message === 'string' && b.message) n.messages.push({ role: 'note', text: b.message });
220
- // next_step 'plan': ask for it now; 'sign_up': it already exists
221
- n.phase = b.next_step === 'sign_up' && n.plan ? 'ended' : 'planning';
222
- return n;
223
- }
224
- return toFallback(n, b.mode === 'form' ? b.message : '');
225
- }
226
- var code = errCode(b);
227
- if (ev.status === 404) return toFallback(n, COPY.lost);
228
- dropLastVisitor(n);
229
- n.phase = 'chat';
230
- if (ev.status === 400 && code === 'too_long') {
231
- n.maxChars = Number(b.max_message_chars) || n.maxChars;
232
- n.error = COPY.tooLong(n.maxChars);
233
- } else if (ev.status === 400 && code === 'empty') {
234
- n.error = COPY.empty;
235
- } else if (ev.status === 429) {
236
- n.error = COPY.tooFast(Math.max(1, Math.ceil(Number(b.retry_after_seconds) || 5)));
237
- } else {
238
- n.error = COPY.failed;
239
- }
240
- return n;
241
- }
242
- case 'failed':
243
- dropLastVisitor(n);
244
- n.phase = n.conv ? 'chat' : 'fallback';
245
- if (n.phase === 'fallback') n.fallback = COPY.fallback;
246
- else n.error = COPY.failed;
247
- return n;
248
- case 'plan':
249
- n.phase = 'planning';
250
- n.error = '';
251
- return n;
252
- case 'planned': {
253
- if (ok && b.mode === 'plan') {
254
- takeExtras(n, b);
255
- conversationCounts(n, b.conversation);
256
- n.left = 0;
257
- n.phase = 'ended';
258
- return n;
259
- }
260
- if (ok && b.mode === 'form') return toFallback(n, b.message);
261
- if (ev.status === 404) return toFallback(n, COPY.lost);
262
- if (ev.status === 409 && errCode(b) === 'too_early') { n.phase = 'chat'; n.error = COPY.tooEarly; return n; }
263
- // anything else: the chat is over but the plan can be asked for again
264
- n.phase = 'ended';
265
- n.error = ev.status === 429 ? COPY.tooFast(Math.max(1, Math.ceil(Number(b.retry_after_seconds) || 5))) : COPY.planFailed;
266
- return n;
267
- }
268
- default:
269
- return n;
270
- }
271
- }
272
-
273
- // how long the first send must still wait (0 once the bot check is behind us)
274
- function firstSendWait(state, now) {
275
- if (state.sent || !state.startedAt) return 0;
276
- return Math.max(0, state.startedAt + MIN_FIRST_MS - now);
277
- }
278
-
279
- function readStore(win) {
280
- try {
281
- var raw = JSON.parse(win.localStorage.getItem(STORE_KEY) || 'null');
282
- return raw && typeof raw.id === 'string' && typeof raw.token === 'string' ? raw : null;
283
- } catch (e) { return null; }
284
- }
285
- function writeStore(win, conv) {
286
- try {
287
- if (conv) win.localStorage.setItem(STORE_KEY, JSON.stringify({ id: conv.id, token: conv.token }));
288
- else win.localStorage.removeItem(STORE_KEY);
289
- } catch (e) { /* storage off: the chat still works, it just does not come back */ }
290
- }
291
49
 
292
50
  /* ──────────────────────────── the DOM half ──────────────────────────── */
293
51
  function boot(win, doc) {
294
52
  var scene = doc.getElementById('guide');
295
- if (!scene || !guideOn(win.__MODULES__)) return;
53
+ if (!scene || !core.guideOn(win.__MODULES__)) return;
54
+ var ctl = core.controller(win, doc);
55
+ if (!ctl) return;
296
56
  var $ = function (name) { return scene.querySelector('[data-gd="' + name + '"]'); };
297
57
  var log = $('log'), err = $('err'), form = $('form'), text = $('text'), hp = $('website');
298
58
  var send = $('send'), end = $('end'), go = $('go'), left = $('left'), segs = $('segs');
299
59
  var planBody = $('plan-body'), planToggle = $('plan-toggle'), planClose = $('plan-close');
300
60
  var makePlan = $('make-plan'), againPlan = $('plan-again'), saved = $('saved');
301
- var state = initial();
302
- var chatOK = false; // the status read said 'chat'
303
- var opener = null;
304
- var limits = null;
305
-
306
- function json(method, url, body, headers) {
307
- var h = { accept: 'application/json' };
308
- if (body) h['content-type'] = 'application/json';
309
- Object.keys(headers || {}).forEach(function (k) { h[k] = headers[k]; });
310
- return win.fetch(url, { method: method, credentials: 'same-origin', headers: h, body: body ? JSON.stringify(body) : undefined })
311
- .then(function (res) {
312
- return res.json().then(function (b) { return { status: res.status, body: b || {} }; }, function () { return { status: res.status, body: {} }; });
313
- });
314
- }
315
- function convUrl(suffix) { return API + '/conversations/' + encodeURIComponent(state.conv.id) + (suffix || ''); }
316
-
317
- // ask once, on load, whether the guide can talk: a click must never wait on it
318
- json('GET', API + '/status').then(function (r) {
319
- chatOK = r.status === 200 && r.body.mode === 'chat';
320
- limits = r.body.limits;
321
- }, function () { chatOK = false; });
322
- // and pick up a chat this browser already holds
323
- var held = readStore(win);
324
- if (held) {
325
- json('GET', API + '/conversations/' + encodeURIComponent(held.id), null, { 'x-guide-token': held.token }).then(function (r) {
326
- if (state.phase !== 'closed') return;
327
- if (r.status === 404 || r.status === 400) { writeStore(win, null); return; }
328
- state = reduce(state, { type: 'resumed', status: r.status, body: r.body, conv: held, now: Date.now() });
329
- // a held chat can always be shown, even once the day's budget says 'form'
330
- if (state.conv) chatOK = true;
331
- }, function () { /* offline: a click starts fresh or falls back */ });
332
- }
61
+ var simple = $('switch');
333
62
 
334
63
  function el(tag, cls, txt) {
335
64
  var e = doc.createElement(tag);
@@ -348,7 +77,7 @@
348
77
  wait.appendChild(p);
349
78
  return wait;
350
79
  }
351
- function paintLog() {
80
+ function paintLog(state) {
352
81
  clear(log);
353
82
  state.messages.forEach(function (m) {
354
83
  var li = el('li', 'gd__msg gd__msg--' + m.role);
@@ -367,7 +96,8 @@
367
96
  log.scrollTop = log.scrollHeight;
368
97
  }
369
98
 
370
- function paintCount() {
99
+ function paintCount(state) {
100
+ var limits = ctl.limits();
371
101
  var total = state.total || (limits && limits.max_messages) || 0;
372
102
  var shown = state.phase !== 'fallback' && state.phase !== 'starting' && total > 0;
373
103
  left.hidden = !shown;
@@ -378,15 +108,11 @@
378
108
  for (var i = 0; i < total; i++) segs.appendChild(el('span', 'gd__seg' + (i < total - state.left ? ' gd__seg--used' : '')));
379
109
  }
380
110
 
381
- function paintPlan() {
111
+ function paintPlan(state) {
382
112
  clear(planBody);
383
113
  var plan = state.plan;
384
114
  if (!plan) {
385
- var wait = state.phase === 'planning' ? 'Your guide is writing your plan.'
386
- : state.phase === 'ended' ? 'No plan yet.'
387
- : state.phase === 'fallback' ? 'No plan to show.'
388
- : 'When the chat ends, your guide writes your plan here.';
389
- planBody.appendChild(el('p', 'gd__planwait', wait));
115
+ planBody.appendChild(el('p', 'gd__planwait', COPY.planWait(state.phase)));
390
116
  } else {
391
117
  if (plan.summary) planBody.appendChild(el('p', 'gd__plansum', plan.summary));
392
118
  plan.sections.forEach(function (s) {
@@ -401,11 +127,11 @@
401
127
  saved.hidden = !state.saved || state.phase === 'fallback';
402
128
  }
403
129
 
404
- function paint() {
130
+ function paint(state) {
405
131
  scene.setAttribute('data-phase', state.phase);
406
- paintLog();
407
- paintCount();
408
- paintPlan();
132
+ paintLog(state);
133
+ paintCount(state);
134
+ paintPlan(state);
409
135
  var talking = state.phase === 'chat' || state.phase === 'sending' || state.phase === 'starting';
410
136
  form.hidden = !talking;
411
137
  end.hidden = talking;
@@ -422,14 +148,6 @@
422
148
  scene.classList.toggle('gd--done', state.phase === 'ended' && !!state.plan);
423
149
  }
424
150
 
425
- function dispatch(ev) {
426
- state = reduce(state, ev);
427
- if (ev.type === 'started' && state.conv) writeStore(win, state.conv);
428
- if (state.phase === 'fallback' && (ev.status === 404)) writeStore(win, null);
429
- paint();
430
- if (state.phase === 'planning' && ev.type !== 'plan') askPlan();
431
- }
432
-
433
151
  /* the Helm faces: loaded the first time the scene opens, never on a plain visit */
434
152
  function loadFaces() {
435
153
  if (doc.querySelector('link[data-gd-faces]')) return;
@@ -446,121 +164,80 @@
446
164
  function (n) { return n.offsetParent !== null; });
447
165
  }
448
166
 
449
- function open(door) {
450
- opener = door;
167
+ function show() {
451
168
  loadFaces();
169
+ // the simple version is offered only when the page carries it
170
+ if (simple) simple.hidden = !ctl.has('hall');
452
171
  scene.hidden = false;
453
172
  doc.documentElement.classList.add('gd-open');
454
- if (state.phase === 'closed') {
455
- dispatch({ type: 'start' });
456
- json('POST', API + '/conversations', { website: hp.value }).then(function (r) {
457
- dispatch({ type: 'started', status: r.status, body: r.body, now: Date.now() });
458
- focusFirst();
459
- }, function () {
460
- dispatch({ type: 'started', status: 0, body: {} });
461
- focusFirst();
462
- });
463
- } else {
464
- paint();
465
- }
466
- focusFirst();
173
+ }
174
+ function hide() {
175
+ scene.hidden = true;
176
+ setPlan(false);
177
+ doc.documentElement.classList.remove('gd-open');
467
178
  }
468
179
  function focusFirst() {
469
180
  if (scene.hidden) return;
470
181
  var target = !text.disabled && !form.hidden ? text : (!end.hidden && !go.hidden ? go : $('close'));
471
182
  try { target.focus(); } catch (e) { /* not focusable yet */ }
472
183
  }
473
- function close() {
474
- scene.hidden = true;
475
- setPlan(false);
476
- doc.documentElement.classList.remove('gd-open');
477
- if (opener) { try { opener.focus(); } catch (e) { /* gone */ } }
478
- }
479
184
  function setPlan(on) {
480
185
  scene.classList.toggle('gd--plan', !!on);
481
186
  planToggle.setAttribute('aria-expanded', on ? 'true' : 'false');
482
187
  if (on) { try { planClose.focus(); } catch (e) { /* hidden */ } }
483
188
  }
484
189
 
190
+ ctl.addView('game', {
191
+ show: show,
192
+ hide: hide,
193
+ paint: paint,
194
+ focusFirst: focusFirst,
195
+ website: function () { return hp.value; },
196
+ // one column: the plan is the reward, so it opens by itself
197
+ onPlanned: function (state) { if (state.plan && planToggle.offsetParent !== null) setPlan(true); },
198
+ });
199
+
485
200
  function submit() {
486
- if (state.phase !== 'chat') return;
201
+ if (ctl.state().phase !== 'chat') return;
487
202
  var words = text.value.trim();
488
- if (!words) { state.error = COPY.empty; paint(); return; }
489
- var wait = firstSendWait(state, Date.now());
490
- var conv = state.conv;
491
- text.value = '';
492
- dispatch({ type: 'send', text: words });
493
- win.setTimeout(function () {
494
- json('POST', API + '/conversations/' + encodeURIComponent(conv.id) + '/messages', { token: conv.token, text: words, website: hp.value })
495
- .then(function (r) { dispatch({ type: 'answer', status: r.status, body: r.body }); },
496
- function () { dispatch({ type: 'failed' }); })
497
- .then(focusFirst);
498
- }, wait);
499
- }
500
-
501
- // the plan: asked for once the chat ends (or the visitor presses "Make my plan");
502
- // the server writes it once and re-reads it for free after that
503
- var asking = false;
504
- function askPlan() {
505
- if (asking || !state.conv) return;
506
- asking = true;
507
- if (state.phase !== 'planning') dispatch({ type: 'plan' });
508
- json('POST', convUrl('/plan'), { token: state.conv.token, website: hp.value })
509
- .then(function (r) { asking = false; dispatch({ type: 'planned', status: r.status, body: r.body }); },
510
- function () { asking = false; dispatch({ type: 'planned', status: 0, body: {} }); })
511
- .then(function () {
512
- focusFirst();
513
- // one column: the plan is the reward, so it opens by itself
514
- if (state.plan && planToggle.offsetParent !== null) setPlan(true);
515
- });
203
+ if (words) text.value = '';
204
+ ctl.send(words);
516
205
  }
517
206
 
518
- /* the doors: a click is the guide only when everything says it can talk */
519
- doc.addEventListener('click', function (e) {
520
- var door = e.target && e.target.closest && e.target.closest('[data-guide-door]');
521
- if (!door || !chatOK || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
522
- e.preventDefault();
523
- open(door);
524
- });
525
-
526
207
  form.addEventListener('submit', function (e) { e.preventDefault(); submit(); });
527
208
  text.addEventListener('keydown', function (e) {
528
209
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit(); }
529
210
  });
530
- makePlan.addEventListener('click', askPlan);
531
- againPlan.addEventListener('click', askPlan);
532
- $('close').addEventListener('click', close);
211
+ makePlan.addEventListener('click', ctl.askPlan);
212
+ againPlan.addEventListener('click', ctl.askPlan);
213
+ $('close').addEventListener('click', ctl.close);
214
+ if (simple) simple.addEventListener('click', function () { ctl.switchTo('hall'); });
533
215
  planToggle.addEventListener('click', function () { setPlan(planToggle.getAttribute('aria-expanded') !== 'true'); });
534
216
  planClose.addEventListener('click', function () { setPlan(false); try { planToggle.focus(); } catch (e) { /* hidden */ } });
535
- scene.addEventListener('click', function (e) { if (e.target === scene) close(); });
217
+ scene.addEventListener('click', function (e) { if (e.target === scene) ctl.close(); });
536
218
  scene.addEventListener('keydown', function (e) {
537
219
  if (e.key === 'Escape') {
538
220
  e.preventDefault();
539
221
  if (scene.classList.contains('gd--plan')) { setPlan(false); try { planToggle.focus(); } catch (x) { /* hidden */ } }
540
- else close();
222
+ else ctl.close();
541
223
  return;
542
224
  }
543
- if (e.key !== 'Tab') return;
544
- var f = focusables();
545
- if (!f.length) return;
546
- var first = f[0], last = f[f.length - 1];
547
- if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); }
548
- else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); }
225
+ if (e.key === 'Tab') core.trapTab(doc, focusables(), e);
549
226
  });
550
227
  var face = scene.querySelector('.gd__face img');
551
228
  if (face) face.addEventListener('error', function () { face.hidden = true; });
552
229
  }
553
230
 
554
231
  return {
555
- API: API,
556
- MIN_FIRST_MS: MIN_FIRST_MS,
557
- STORE_KEY: STORE_KEY,
232
+ API: core.API,
233
+ MIN_FIRST_MS: core.MIN_FIRST_MS,
234
+ STORE_KEY: core.STORE_KEY,
558
235
  COPY: COPY,
559
- guideOn: guideOn,
560
- readPlan: readPlan,
561
- initial: initial,
562
- reduce: reduce,
563
- firstSendWait: firstSendWait,
236
+ guideOn: core.guideOn,
237
+ readPlan: core.readPlan,
238
+ initial: core.initial,
239
+ reduce: core.reduce,
240
+ firstSendWait: core.firstSendWait,
564
241
  boot: boot,
565
242
  };
566
243
  });
@@ -46,6 +46,8 @@
46
46
  <!-- the start-a-project guide, as a scene in the game (task 1005275): drawn only when the
47
47
  front-door-guide module is on and its server can talk -->
48
48
  <link rel="stylesheet" href="/assets/guide-scene.css">
49
+ <!-- and plainly, for a visitor who does not want the game (task 1005276) -->
50
+ <link rel="stylesheet" href="/assets/guide-hall.css">
49
51
  <style>
50
52
  /* ── PAGE-LOCAL SHEET ────────────────────────────────────────────────────
51
53
  The composition: how this page arranges the world's sculpture, and nothing
@@ -583,6 +585,8 @@ body{
583
585
  --gd-gutter:var(--ds-space-gutter-narrow);--gd-face:56px;--gd-face-img:48px;
584
586
  --gd-planbtn:inline-flex;--gd-planx:inline-grid;--gd-go-w:100%;
585
587
  --gd-plan-show:none;--gd-plan-pos:absolute;--gd-plan-edge:0px;--gd-talk-under-plan:hidden;
588
+ /* and its plain twin (assets/guide-hall.css, task 1005276): the whole screen, square */
589
+ --gd-hall-radius:0;
586
590
  }
587
591
  :root[data-mode="dark"] body{
588
592
  --layer-pos:relative;
@@ -974,6 +978,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
974
978
  <section class="gd__talk" aria-labelledby="gdTitle">
975
979
  <div class="gd__head">
976
980
  <h2 class="gd__title" id="gdTitle">Start a project</h2>
981
+ <button type="button" class="gd__switch" data-gd="switch" hidden>Use the simple version</button>
977
982
  <button type="button" class="gd__x" data-gd="close" aria-label="Close"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15"/></svg></button>
978
983
  </div>
979
984
  <div class="gd__who-row">
@@ -1013,6 +1018,43 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
1013
1018
  </div>
1014
1019
  </div>
1015
1020
 
1021
+ <!-- THE GUIDE, PLAIN (task 1005276): the same conversation for a visitor who does not want
1022
+ the game. One chat behind both (assets/guide-core.js); the doors open the game unless
1023
+ this browser picked this one, and each offers the other. Hidden, like the scene. -->
1024
+ <div class="gh" id="guideHall" role="dialog" aria-modal="true" aria-labelledby="ghTitle" hidden>
1025
+ <section class="gh__panel" aria-labelledby="ghTitle">
1026
+ <div class="gh__head">
1027
+ <h2 class="gh__title" id="ghTitle">Start a project</h2>
1028
+ <button type="button" class="gh__link" data-gh="switch">Use the game version</button>
1029
+ <button type="button" class="gh__x" data-gh="close" aria-label="Close"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15"/></svg></button>
1030
+ </div>
1031
+ <p class="gh__left" data-gh="left" aria-live="polite" hidden></p>
1032
+ <div class="gh__body" data-gh="body">
1033
+ <ol class="gh__log" data-gh="log" aria-live="polite" aria-label="Conversation"></ol>
1034
+ <section class="gh__plan" data-gh="plan" aria-labelledby="ghPlanH" hidden>
1035
+ <h3 class="gh__planh" id="ghPlanH">Your plan</h3>
1036
+ <div data-gh="plan-body"></div>
1037
+ </section>
1038
+ <p class="gh__saved" data-gh="saved" hidden></p>
1039
+ </div>
1040
+ <p class="gh__err" data-gh="err" role="alert" hidden></p>
1041
+ <form class="gh__say" data-gh="form" hidden>
1042
+ <label class="gh__label" for="ghText">Your message</label>
1043
+ <textarea class="gh__text" id="ghText" data-gh="text" rows="3" maxlength="1000" placeholder="What do you want to build?"></textarea>
1044
+ <input class="gh__hp" type="text" name="website" data-gh="website" tabindex="-1" autocomplete="off" aria-hidden="true">
1045
+ <div class="gh__row">
1046
+ <button type="submit" class="door door--go" data-gh="send">Send</button>
1047
+ <button type="button" class="door" data-gh="make-plan" hidden>Make my plan</button>
1048
+ <a class="gh__link" href="/projects?view=new">Skip to sign up</a>
1049
+ </div>
1050
+ </form>
1051
+ <div class="gh__end" data-gh="end" hidden>
1052
+ <button type="button" class="door" data-gh="plan-again" hidden>Make my plan</button>
1053
+ <a class="door door--go" data-gh="go" href="/projects?view=new">Sign up and get started</a>
1054
+ </div>
1055
+ </section>
1056
+ </div>
1057
+
1016
1058
  <footer>
1017
1059
  <div class="footrow">
1018
1060
  <p><!--apex-only:start-->A non-profit, capture-resistant commons. <!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).</p>
@@ -1360,8 +1402,11 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
1360
1402
  });
1361
1403
  })();
1362
1404
  </script>
1363
- <!-- the start-a-project guide (task 1005275) -->
1405
+ <!-- the start-a-project guide: the shared conversation, then the game scene (task 1005275)
1406
+ and its plain twin (task 1005276) -->
1407
+ <script src="/assets/guide-core.js"></script>
1364
1408
  <script src="/assets/guide-scene.js"></script>
1409
+ <script src="/assets/guide-hall.js"></script>
1365
1410
  <!-- the Artist review button: signed-in builders only (task 1004499) -->
1366
1411
  <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
1367
1412
  </body>
@@ -2766,7 +2766,7 @@ summary{min-height:24px;padding:3px 0;}
2766
2766
  try { id = new URLSearchParams(location.search).get('id'); } catch (e) {}
2767
2767
  if (id) { manageId = String(id); return 'manage'; }
2768
2768
  try { handedProject = new URLSearchParams(location.search).get('project') || null; } catch (e) {}
2769
- if (handedProject && /^#m(Delete|Disconnect)$/.test(location.hash || '')) handedSection = location.hash.slice(1);
2769
+ if (handedProject && /^#m(Delete|Disconnect|AppCard)$/.test(location.hash || '')) handedSection = location.hash.slice(1);
2770
2770
  return 'mine';
2771
2771
  }
2772
2772
  return VIEWS[v] ? v : 'map';
@@ -9693,7 +9693,16 @@ summary{min-height:24px;padding:3px 0;}
9693
9693
  if (handedSection) {
9694
9694
  var target = $(handedSection);
9695
9695
  handedSection = null;
9696
- if (target && target.scrollIntoView) target.scrollIntoView({ block: 'start' });
9696
+ /* the app card (#mAppCard, the hall's Connect Render link — task 1004571) is
9697
+ drawn by its own read below, so it is still hidden here: land on it once shown */
9698
+ if (target && target.hidden && typeof MutationObserver === 'function') {
9699
+ var seen = new MutationObserver(function () {
9700
+ if (target.hidden) return;
9701
+ seen.disconnect();
9702
+ if (target.scrollIntoView) target.scrollIntoView({ block: 'start' });
9703
+ });
9704
+ seen.observe(target, { attributes: true, attributeFilter: ['hidden'] });
9705
+ } else if (target && target.scrollIntoView) target.scrollIntoView({ block: 'start' });
9697
9706
  }
9698
9707
  /* the SECOND, GitHub-backed read — fired only here (entry + tab
9699
9708
  return), never from renderManage, which the fetch-free tick calls */