@bongos/core 1.25.16 → 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.
- package/.bongos-core.json +200 -75
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +36 -2
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/api/openapi.json +761 -112
- package/docs/api-reference.md +23 -3
- package/docs/copy-inventory.md +118 -108
- package/docs/copy-registry.json +315 -172
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +62 -62
- package/modules/app-host/CLAUDE.md +15 -0
- package/modules/app-host/module.json +20 -0
- package/modules/app-host/public/settings-widget.js +143 -0
- package/modules/app-host/routes/app-host.js +88 -0
- package/modules/government/charter-advice.js +103 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/government/presets.js +42 -0
- package/modules/government/recommendations.js +102 -7
- package/modules/government/routes/government.js +1 -0
- package/modules/lifecycle/first-read-catalog.js +62 -0
- package/modules/lifecycle/first-read-db.js +190 -0
- package/modules/lifecycle/first-read.js +330 -0
- package/modules/lifecycle/kickoff-checklist.js +12 -3
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +101 -0
- package/modules/onboarding/genesis-work.js +101 -4
- package/modules/onboarding/routes/genesis-work.js +2 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso-app-status.js +65 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/pollers/app-liveness.js +23 -1
- package/modules/provisioning/render-sandbox.js +113 -0
- package/modules/provisioning/render-standup.js +3 -1
- package/modules/provisioning/routes/provisioning.js +4 -1
- package/modules/provisioning/routes/render-standup.js +49 -1
- package/modules/provisioning/seams.js +32 -1
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/public-landing/public/assets/guide-core.js +475 -0
- package/modules/public-landing/public/assets/guide-hall.css +121 -0
- package/modules/public-landing/public/assets/guide-hall.js +164 -0
- package/modules/public-landing/public/assets/guide-scene.css +9 -0
- package/modules/public-landing/public/assets/guide-scene.js +58 -381
- package/modules/public-landing/public/index.html +46 -1
- package/modules/public-landing/public/projects.html +112 -6
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/bongos/first-read.js +150 -0
- package/scripts/bongos/genesis.js +21 -1
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/bongos/provision-render-sandbox.js +171 -0
- package/scripts/bongos/provision-render.js +37 -2
- package/scripts/bongos/provision-teardown.js +1 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/scripts/hall-preview/server.js +2 -0
- package/src/bongos/auth-admission.js +64 -0
- package/src/bongos/auth.js +2 -1
- package/src/bongos/module-scope-map.js +4 -0
- package/src/bongos/routes/core-update.js +3 -16
- package/src/bongos/routes/modules.js +11 -0
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +6 -1
- package/src/module-loader/loader.js +2 -2
- package/tests/app_host.mjs +413 -0
- package/tests/first_read.mjs +479 -0
- package/tests/genesis_work.mjs +74 -3
- package/tests/government_act_alone_buildings.mjs +2 -0
- package/tests/government_charter_advice.mjs +150 -0
- package/tests/landing_guide_hall.mjs +364 -0
- package/tests/landing_guide_scene.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_where.mjs +13 -3
- package/tests/provisioning_app_status.mjs +3 -2
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +50 -0
- package/tests/render_api.mjs +5 -2
- package/tests/render_sandbox.mjs +447 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/* assets/guide-core.js — THE START-A-PROJECT GUIDE, the part both presentations share
|
|
2
|
+
(tasks 1005275 game, 1005276 hall; goal 1000106).
|
|
3
|
+
|
|
4
|
+
The landing shows the guide two ways: the game scene (guide-scene.js) and the plain hall
|
|
5
|
+
panel (guide-hall.js). They are the same conversation: one server
|
|
6
|
+
(modules/front-door-guide, its CLAUDE.md has the contract), one state, one set of client
|
|
7
|
+
rules. This file holds all of that once, so the two can only differ in how they look:
|
|
8
|
+
|
|
9
|
+
- the state machine (reduce) and the plan reader (readPlan), pure;
|
|
10
|
+
- the server's three client rules: the hidden `website` field sent as it stands, the
|
|
11
|
+
first message held MIN_FIRST_MS after the chat started, and the chat's { id, token }
|
|
12
|
+
kept in LOCAL storage (STORE_KEY, which the sign-up hand-off reads) and resumed on load;
|
|
13
|
+
- the caps and the fallback: a spent budget, a missing key or a lost chat is the plain
|
|
14
|
+
door, never an error;
|
|
15
|
+
- the controller: the status read and the resume on load, the "Start a project" doors,
|
|
16
|
+
sending, asking for the plan, and which presentation is showing.
|
|
17
|
+
|
|
18
|
+
A presentation registers itself with addView(name, view), where view is
|
|
19
|
+
{ show(), hide(), paint(state), focusFirst(state), website(), onPlanned?(state) }. The
|
|
20
|
+
doors open the visitor's pick (VIEW_KEY in local storage: a per-browser convenience, no
|
|
21
|
+
account preference) or the game, and switchTo(name) swaps presentations mid-chat with
|
|
22
|
+
the same conversation. One controller per window. */
|
|
23
|
+
(function (factory) {
|
|
24
|
+
'use strict';
|
|
25
|
+
var api = factory();
|
|
26
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
27
|
+
if (typeof window !== 'undefined') window.GuideCore = api;
|
|
28
|
+
})(function () {
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
var API = '/api/bongos/front-door-guide';
|
|
32
|
+
var MODULE_KEY = 'front-door-guide';
|
|
33
|
+
// the server refuses a first message sooner than 1500ms after the start (a bot); wait a
|
|
34
|
+
// little longer so a clock edge never turns a person into one
|
|
35
|
+
var MIN_FIRST_MS = 1600;
|
|
36
|
+
// the chat's { id, token } in local storage: the server's contract keeps it there, not in
|
|
37
|
+
// session storage, so a closed browser comes back to the same chat
|
|
38
|
+
var STORE_KEY = 'cb-front-door-guide';
|
|
39
|
+
// which presentation this browser picked last: 'game' or 'hall'
|
|
40
|
+
var VIEW_KEY = 'cb-front-door-guide-view';
|
|
41
|
+
var SIGN_UP = 'Sign up and get started';
|
|
42
|
+
|
|
43
|
+
// Every sentence the guide says on its own. The guide's replies are the server's.
|
|
44
|
+
var COPY = {
|
|
45
|
+
fallback: 'The guide is resting right now. You can still start your project.',
|
|
46
|
+
lost: 'This chat has ended. You can still start your project.',
|
|
47
|
+
failed: 'That did not send. Try again.',
|
|
48
|
+
planFailed: 'Your plan did not come through. Try again.',
|
|
49
|
+
tooEarly: 'Tell the guide a little about your project first.',
|
|
50
|
+
empty: 'Type a message first.',
|
|
51
|
+
tooLong: function (n) { return 'That is too long. Keep it under ' + n + ' characters.'; },
|
|
52
|
+
tooFast: function (s) { return 'Too quick. Try again in ' + s + (s === 1 ? ' second.' : ' seconds.'); },
|
|
53
|
+
left: function (left, total) {
|
|
54
|
+
if (left <= 0) return 'No messages left';
|
|
55
|
+
return left + ' of ' + total + (total === 1 ? ' message left' : ' messages left');
|
|
56
|
+
},
|
|
57
|
+
waitTalk: 'Your guide is thinking',
|
|
58
|
+
waitPlan: 'Your guide is writing your plan',
|
|
59
|
+
planWait: function (phase) {
|
|
60
|
+
return phase === 'planning' ? 'Your guide is writing your plan.'
|
|
61
|
+
: phase === 'ended' ? 'No plan yet.'
|
|
62
|
+
: phase === 'fallback' ? 'No plan to show.'
|
|
63
|
+
: 'When the chat ends, your guide writes your plan here.';
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/* ── is the guide on for this page? The module set the server injected. ── */
|
|
68
|
+
function guideOn(mods) {
|
|
69
|
+
return !!(mods && mods.enabled && mods.enabled[MODULE_KEY] === true);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ── the plan, read leniently. The server's Plan (modules/front-door-guide/plan.js) is
|
|
73
|
+
{ summary, project_type, how_it_works[], modules[{label, summary, why}], steps[{title,
|
|
74
|
+
detail}], needs[{item, detail}], open_questions[], cost_and_time }: each list becomes a
|
|
75
|
+
section under its own heading, in that order. Anything else it carries is read as text
|
|
76
|
+
where it can be and dropped where it cannot; no plan at all is null. ── */
|
|
77
|
+
var SECTIONS = [
|
|
78
|
+
['how_it_works', 'How it works'],
|
|
79
|
+
['steps', 'Steps'],
|
|
80
|
+
['modules', 'Modules to start with'],
|
|
81
|
+
['needs', 'You will need'],
|
|
82
|
+
['open_questions', 'Still to decide'],
|
|
83
|
+
];
|
|
84
|
+
function asText(v) {
|
|
85
|
+
if (typeof v === 'string') return v.trim();
|
|
86
|
+
if (typeof v === 'number') return String(v);
|
|
87
|
+
if (v && typeof v === 'object') {
|
|
88
|
+
var head = [v.title, v.label, v.item, v.name, v.heading].filter(function (s) { return typeof s === 'string' && s.trim(); })[0];
|
|
89
|
+
var body = [v.why, v.detail, v.text, v.summary, v.description].filter(function (s) { return typeof s === 'string' && s.trim(); })[0];
|
|
90
|
+
return [head, body].filter(Boolean).map(function (s) { return s.trim(); }).join(': ');
|
|
91
|
+
}
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
function asItems(arr) {
|
|
95
|
+
return (Array.isArray(arr) ? arr : []).map(asText).filter(Boolean).slice(0, 12);
|
|
96
|
+
}
|
|
97
|
+
function readPlan(raw) {
|
|
98
|
+
if (raw == null) return null;
|
|
99
|
+
if (typeof raw === 'string') return raw.trim() ? { summary: raw.trim(), sections: [], note: '' } : null;
|
|
100
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
101
|
+
var plan = { summary: asText(raw.summary || ''), sections: [], note: typeof raw.cost_and_time === 'string' ? raw.cost_and_time.trim() : '' };
|
|
102
|
+
var seen = {};
|
|
103
|
+
SECTIONS.forEach(function (s) {
|
|
104
|
+
seen[s[0]] = true;
|
|
105
|
+
var its = asItems(raw[s[0]]);
|
|
106
|
+
if (its.length) plan.sections.push({ heading: s[1], items: its });
|
|
107
|
+
});
|
|
108
|
+
Object.keys(raw).forEach(function (k) {
|
|
109
|
+
if (seen[k] || !Array.isArray(raw[k])) return;
|
|
110
|
+
var its = asItems(raw[k]);
|
|
111
|
+
if (its.length) plan.sections.push({ heading: '', items: its });
|
|
112
|
+
});
|
|
113
|
+
return plan.summary || plan.sections.length ? plan : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* ── the state machine. One state object; every event returns a new one. ──
|
|
117
|
+
phase: closed · starting · chat · sending · planning · ended · fallback
|
|
118
|
+
A chat that has ended with no plan yet (the plan call failed, or a resumed chat never
|
|
119
|
+
asked) is 'ended' with plan null: the end strip then offers "Make my plan" again. */
|
|
120
|
+
function initial() {
|
|
121
|
+
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 };
|
|
122
|
+
}
|
|
123
|
+
function copyState(s) {
|
|
124
|
+
var n = {};
|
|
125
|
+
Object.keys(s).forEach(function (k) { n[k] = s[k]; });
|
|
126
|
+
n.messages = s.messages.slice();
|
|
127
|
+
return n;
|
|
128
|
+
}
|
|
129
|
+
function conversationCounts(n, c) {
|
|
130
|
+
if (!c || typeof c !== 'object') return;
|
|
131
|
+
if (typeof c.messages_left === 'number') n.left = Math.max(0, c.messages_left);
|
|
132
|
+
if (typeof c.messages_used === 'number') n.sent = c.messages_used;
|
|
133
|
+
if (typeof c.messages_used === 'number' && typeof c.messages_left === 'number') n.total = Math.max(n.total, c.messages_used + c.messages_left);
|
|
134
|
+
if (typeof c.max_message_chars === 'number') n.maxChars = c.max_message_chars;
|
|
135
|
+
}
|
|
136
|
+
function takeExtras(n, b) {
|
|
137
|
+
if (b.saved && typeof b.saved.message === 'string' && b.saved.message) n.saved = b.saved.message;
|
|
138
|
+
if (b.next && typeof b.next.label === 'string' && b.next.label) n.next = b.next.label;
|
|
139
|
+
var p = readPlan(b.plan);
|
|
140
|
+
if (p) n.plan = p;
|
|
141
|
+
}
|
|
142
|
+
function toFallback(n, message) {
|
|
143
|
+
n.phase = 'fallback';
|
|
144
|
+
n.fallback = message || COPY.fallback;
|
|
145
|
+
n.error = '';
|
|
146
|
+
n.restore = '';
|
|
147
|
+
return n;
|
|
148
|
+
}
|
|
149
|
+
function dropLastVisitor(n) {
|
|
150
|
+
for (var i = n.messages.length - 1; i >= 0; i--) {
|
|
151
|
+
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; }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function errCode(body) {
|
|
155
|
+
return body && (body.error && typeof body.error === 'object' ? body.error.code : body.error || body.code);
|
|
156
|
+
}
|
|
157
|
+
function reduce(state, ev) {
|
|
158
|
+
var n = copyState(state || initial());
|
|
159
|
+
n.restore = '';
|
|
160
|
+
var b = (ev && ev.body) || {};
|
|
161
|
+
var ok = ev && ev.status >= 200 && ev.status < 300;
|
|
162
|
+
switch (ev && ev.type) {
|
|
163
|
+
case 'start':
|
|
164
|
+
n.phase = 'starting';
|
|
165
|
+
n.error = '';
|
|
166
|
+
return n;
|
|
167
|
+
case 'started':
|
|
168
|
+
if (ok && b.mode === 'chat' && b.conversation && b.conversation.id && b.conversation.token) {
|
|
169
|
+
n.phase = 'chat';
|
|
170
|
+
n.conv = { id: String(b.conversation.id), token: String(b.conversation.token) };
|
|
171
|
+
n.startedAt = ev.now || 0;
|
|
172
|
+
n.total = 0;
|
|
173
|
+
conversationCounts(n, b.conversation);
|
|
174
|
+
n.messages = typeof b.greeting === 'string' && b.greeting ? [{ role: 'guide', text: b.greeting }] : [];
|
|
175
|
+
takeExtras(n, b);
|
|
176
|
+
return n;
|
|
177
|
+
}
|
|
178
|
+
return toFallback(n, b.mode === 'form' ? b.message : '');
|
|
179
|
+
case 'resumed': {
|
|
180
|
+
// GET /conversations/:id on load: the transcript (no greeting), the plan if written
|
|
181
|
+
if (!ok || !b.conversation || !Array.isArray(b.transcript)) return initial();
|
|
182
|
+
n.conv = ev.conv;
|
|
183
|
+
n.total = 0;
|
|
184
|
+
conversationCounts(n, b.conversation);
|
|
185
|
+
n.messages = b.transcript
|
|
186
|
+
.filter(function (m) { return m && typeof m.text === 'string' && (m.role === 'visitor' || m.role === 'guide'); })
|
|
187
|
+
.map(function (m) { return { role: m.role, text: m.text }; });
|
|
188
|
+
n.sent = Math.max(n.sent, n.messages.filter(function (m) { return m.role === 'visitor'; }).length);
|
|
189
|
+
takeExtras(n, b);
|
|
190
|
+
n.phase = b.conversation.status === 'ended' || n.plan ? 'ended' : 'chat';
|
|
191
|
+
// a resumed chat with nothing said yet is new enough for the bot check to apply
|
|
192
|
+
n.startedAt = n.sent ? 0 : (ev.now || 0);
|
|
193
|
+
return n;
|
|
194
|
+
}
|
|
195
|
+
case 'send':
|
|
196
|
+
n.phase = 'sending';
|
|
197
|
+
n.error = '';
|
|
198
|
+
n.sent += 1;
|
|
199
|
+
n.messages.push({ role: 'visitor', text: ev.text });
|
|
200
|
+
return n;
|
|
201
|
+
case 'answer': {
|
|
202
|
+
if (ok) {
|
|
203
|
+
takeExtras(n, b);
|
|
204
|
+
if (b.mode === 'chat') {
|
|
205
|
+
if (b.reply && typeof b.reply.text === 'string') n.messages.push({ role: 'guide', text: b.reply.text });
|
|
206
|
+
conversationCounts(n, b.conversation);
|
|
207
|
+
n.phase = n.left > 0 ? 'chat' : 'planning';
|
|
208
|
+
return n;
|
|
209
|
+
}
|
|
210
|
+
if (b.mode === 'ended') {
|
|
211
|
+
if (b.reply && typeof b.reply.text === 'string') n.messages.push({ role: 'guide', text: b.reply.text });
|
|
212
|
+
conversationCounts(n, b.conversation);
|
|
213
|
+
n.left = 0;
|
|
214
|
+
if (typeof b.message === 'string' && b.message) n.messages.push({ role: 'note', text: b.message });
|
|
215
|
+
// next_step 'plan': ask for it now; 'sign_up': it already exists
|
|
216
|
+
n.phase = b.next_step === 'sign_up' && n.plan ? 'ended' : 'planning';
|
|
217
|
+
return n;
|
|
218
|
+
}
|
|
219
|
+
return toFallback(n, b.mode === 'form' ? b.message : '');
|
|
220
|
+
}
|
|
221
|
+
var code = errCode(b);
|
|
222
|
+
if (ev.status === 404) return toFallback(n, COPY.lost);
|
|
223
|
+
dropLastVisitor(n);
|
|
224
|
+
n.phase = 'chat';
|
|
225
|
+
if (ev.status === 400 && code === 'too_long') {
|
|
226
|
+
n.maxChars = Number(b.max_message_chars) || n.maxChars;
|
|
227
|
+
n.error = COPY.tooLong(n.maxChars);
|
|
228
|
+
} else if (ev.status === 400 && code === 'empty') {
|
|
229
|
+
n.error = COPY.empty;
|
|
230
|
+
} else if (ev.status === 429) {
|
|
231
|
+
n.error = COPY.tooFast(Math.max(1, Math.ceil(Number(b.retry_after_seconds) || 5)));
|
|
232
|
+
} else {
|
|
233
|
+
n.error = COPY.failed;
|
|
234
|
+
}
|
|
235
|
+
return n;
|
|
236
|
+
}
|
|
237
|
+
case 'failed':
|
|
238
|
+
dropLastVisitor(n);
|
|
239
|
+
n.phase = n.conv ? 'chat' : 'fallback';
|
|
240
|
+
if (n.phase === 'fallback') n.fallback = COPY.fallback;
|
|
241
|
+
else n.error = COPY.failed;
|
|
242
|
+
return n;
|
|
243
|
+
case 'plan':
|
|
244
|
+
n.phase = 'planning';
|
|
245
|
+
n.error = '';
|
|
246
|
+
return n;
|
|
247
|
+
case 'planned': {
|
|
248
|
+
if (ok && b.mode === 'plan') {
|
|
249
|
+
takeExtras(n, b);
|
|
250
|
+
conversationCounts(n, b.conversation);
|
|
251
|
+
n.left = 0;
|
|
252
|
+
n.phase = 'ended';
|
|
253
|
+
return n;
|
|
254
|
+
}
|
|
255
|
+
if (ok && b.mode === 'form') return toFallback(n, b.message);
|
|
256
|
+
if (ev.status === 404) return toFallback(n, COPY.lost);
|
|
257
|
+
if (ev.status === 409 && errCode(b) === 'too_early') { n.phase = 'chat'; n.error = COPY.tooEarly; return n; }
|
|
258
|
+
// anything else: the chat is over but the plan can be asked for again
|
|
259
|
+
n.phase = 'ended';
|
|
260
|
+
n.error = ev.status === 429 ? COPY.tooFast(Math.max(1, Math.ceil(Number(b.retry_after_seconds) || 5))) : COPY.planFailed;
|
|
261
|
+
return n;
|
|
262
|
+
}
|
|
263
|
+
case 'error':
|
|
264
|
+
// a check the presentation made before sending (an empty box)
|
|
265
|
+
n.error = ev.text || '';
|
|
266
|
+
return n;
|
|
267
|
+
default:
|
|
268
|
+
return n;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// how long the first send must still wait (0 once the bot check is behind us)
|
|
273
|
+
function firstSendWait(state, now) {
|
|
274
|
+
if (state.sent || !state.startedAt) return 0;
|
|
275
|
+
return Math.max(0, state.startedAt + MIN_FIRST_MS - now);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function readStore(win) {
|
|
279
|
+
try {
|
|
280
|
+
var raw = JSON.parse(win.localStorage.getItem(STORE_KEY) || 'null');
|
|
281
|
+
return raw && typeof raw.id === 'string' && typeof raw.token === 'string' ? raw : null;
|
|
282
|
+
} catch (e) { return null; }
|
|
283
|
+
}
|
|
284
|
+
function writeStore(win, conv) {
|
|
285
|
+
try {
|
|
286
|
+
if (conv) win.localStorage.setItem(STORE_KEY, JSON.stringify({ id: conv.id, token: conv.token }));
|
|
287
|
+
else win.localStorage.removeItem(STORE_KEY);
|
|
288
|
+
} catch (e) { /* storage off: the chat still works, it just does not come back */ }
|
|
289
|
+
}
|
|
290
|
+
function readView(win) {
|
|
291
|
+
try { var v = win.localStorage.getItem(VIEW_KEY); return v === 'game' || v === 'hall' ? v : null; } catch (e) { return null; }
|
|
292
|
+
}
|
|
293
|
+
function writeView(win, v) {
|
|
294
|
+
try { win.localStorage.setItem(VIEW_KEY, v); } catch (e) { /* storage off: the game next time */ }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Tab and Shift+Tab stay inside an open dialog. `f` is its visible focusable controls.
|
|
298
|
+
function trapTab(doc, f, e) {
|
|
299
|
+
if (!f.length) return;
|
|
300
|
+
var first = f[0], last = f[f.length - 1];
|
|
301
|
+
if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
302
|
+
else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* ──────────────────────────── the controller ──────────────────────────── */
|
|
306
|
+
// One per window (both presentations ask for it; the first call makes it). Null when
|
|
307
|
+
// the module is off: then nothing is read and every door stays a plain link.
|
|
308
|
+
function controller(win, doc) {
|
|
309
|
+
if (win.__frontDoorGuide) return win.__frontDoorGuide;
|
|
310
|
+
if (!guideOn(win.__MODULES__)) return null;
|
|
311
|
+
var state = initial();
|
|
312
|
+
var chatOK = false; // the status read said 'chat' (or this browser holds a chat)
|
|
313
|
+
var limits = null;
|
|
314
|
+
var views = {};
|
|
315
|
+
var order = [];
|
|
316
|
+
var active = null; // the name of the presentation showing, or null
|
|
317
|
+
var opener = null;
|
|
318
|
+
var asking = false;
|
|
319
|
+
|
|
320
|
+
function json(method, url, body, headers) {
|
|
321
|
+
var h = { accept: 'application/json' };
|
|
322
|
+
if (body) h['content-type'] = 'application/json';
|
|
323
|
+
Object.keys(headers || {}).forEach(function (k) { h[k] = headers[k]; });
|
|
324
|
+
return win.fetch(url, { method: method, credentials: 'same-origin', headers: h, body: body ? JSON.stringify(body) : undefined })
|
|
325
|
+
.then(function (res) {
|
|
326
|
+
return res.json().then(function (b) { return { status: res.status, body: b || {} }; }, function () { return { status: res.status, body: {} }; });
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function convUrl(suffix) { return API + '/conversations/' + encodeURIComponent(state.conv.id) + (suffix || ''); }
|
|
330
|
+
function view() { return active ? views[active] : null; }
|
|
331
|
+
function website() { var v = view(); return v ? v.website() : ''; }
|
|
332
|
+
function paint() { var v = view(); if (v) v.paint(state); }
|
|
333
|
+
function focusFirst() { var v = view(); if (v) v.focusFirst(state); }
|
|
334
|
+
|
|
335
|
+
function dispatch(ev) {
|
|
336
|
+
state = reduce(state, ev);
|
|
337
|
+
if (ev.type === 'started' && state.conv) writeStore(win, state.conv);
|
|
338
|
+
if (state.phase === 'fallback' && (ev.status === 404)) writeStore(win, null);
|
|
339
|
+
paint();
|
|
340
|
+
if (state.phase === 'planning' && ev.type !== 'plan') askPlan();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ask once, on load, whether the guide can talk: a click must never wait on it
|
|
344
|
+
json('GET', API + '/status').then(function (r) {
|
|
345
|
+
chatOK = r.status === 200 && r.body.mode === 'chat';
|
|
346
|
+
limits = r.body.limits;
|
|
347
|
+
}, function () { chatOK = false; });
|
|
348
|
+
// and pick up a chat this browser already holds
|
|
349
|
+
var held = readStore(win);
|
|
350
|
+
if (held) {
|
|
351
|
+
json('GET', API + '/conversations/' + encodeURIComponent(held.id), null, { 'x-guide-token': held.token }).then(function (r) {
|
|
352
|
+
if (state.phase !== 'closed') return;
|
|
353
|
+
if (r.status === 404 || r.status === 400) { writeStore(win, null); return; }
|
|
354
|
+
state = reduce(state, { type: 'resumed', status: r.status, body: r.body, conv: held, now: Date.now() });
|
|
355
|
+
// a held chat can always be shown, even once the day's budget says 'form'
|
|
356
|
+
if (state.conv) chatOK = true;
|
|
357
|
+
}, function () { /* offline: a click starts fresh or falls back */ });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// the presentation a door opens: this browser's last pick, else the game
|
|
361
|
+
function pick() {
|
|
362
|
+
var p = readView(win);
|
|
363
|
+
if (p && views[p]) return p;
|
|
364
|
+
return views.game ? 'game' : order[0];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function open(door) {
|
|
368
|
+
if (!order.length) return;
|
|
369
|
+
opener = door;
|
|
370
|
+
active = pick();
|
|
371
|
+
view().show();
|
|
372
|
+
if (state.phase === 'closed') {
|
|
373
|
+
dispatch({ type: 'start' });
|
|
374
|
+
json('POST', API + '/conversations', { website: website() }).then(function (r) {
|
|
375
|
+
dispatch({ type: 'started', status: r.status, body: r.body, now: Date.now() });
|
|
376
|
+
focusFirst();
|
|
377
|
+
}, function () {
|
|
378
|
+
dispatch({ type: 'started', status: 0, body: {} });
|
|
379
|
+
focusFirst();
|
|
380
|
+
});
|
|
381
|
+
} else {
|
|
382
|
+
paint();
|
|
383
|
+
}
|
|
384
|
+
focusFirst();
|
|
385
|
+
}
|
|
386
|
+
function close() {
|
|
387
|
+
var v = view();
|
|
388
|
+
if (!v) return;
|
|
389
|
+
v.hide();
|
|
390
|
+
active = null;
|
|
391
|
+
if (opener) { try { opener.focus(); } catch (e) { /* gone */ } }
|
|
392
|
+
}
|
|
393
|
+
// the other presentation, same chat: hide this one, show that one, remember the pick
|
|
394
|
+
function switchTo(name) {
|
|
395
|
+
if (!active || !views[name] || name === active) return;
|
|
396
|
+
view().hide();
|
|
397
|
+
active = name;
|
|
398
|
+
writeView(win, name);
|
|
399
|
+
views[name].show();
|
|
400
|
+
paint();
|
|
401
|
+
focusFirst();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// the visitor's words: false when they were not taken (not the time, or nothing typed)
|
|
405
|
+
function send(words) {
|
|
406
|
+
if (state.phase !== 'chat') return false;
|
|
407
|
+
if (!words) { dispatch({ type: 'error', text: COPY.empty }); return false; }
|
|
408
|
+
var wait = firstSendWait(state, Date.now());
|
|
409
|
+
var conv = state.conv;
|
|
410
|
+
dispatch({ type: 'send', text: words });
|
|
411
|
+
win.setTimeout(function () {
|
|
412
|
+
json('POST', API + '/conversations/' + encodeURIComponent(conv.id) + '/messages', { token: conv.token, text: words, website: website() })
|
|
413
|
+
.then(function (r) { dispatch({ type: 'answer', status: r.status, body: r.body }); },
|
|
414
|
+
function () { dispatch({ type: 'failed' }); })
|
|
415
|
+
.then(focusFirst);
|
|
416
|
+
}, wait);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// the plan: asked for once the chat ends (or the visitor presses "Make my plan");
|
|
421
|
+
// the server writes it once and re-reads it for free after that
|
|
422
|
+
function askPlan() {
|
|
423
|
+
if (asking || !state.conv) return;
|
|
424
|
+
asking = true;
|
|
425
|
+
if (state.phase !== 'planning') dispatch({ type: 'plan' });
|
|
426
|
+
json('POST', convUrl('/plan'), { token: state.conv.token, website: website() })
|
|
427
|
+
.then(function (r) { asking = false; dispatch({ type: 'planned', status: r.status, body: r.body }); },
|
|
428
|
+
function () { asking = false; dispatch({ type: 'planned', status: 0, body: {} }); })
|
|
429
|
+
.then(function () {
|
|
430
|
+
focusFirst();
|
|
431
|
+
var v = view();
|
|
432
|
+
if (v && v.onPlanned) v.onPlanned(state);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* the doors: a click is the guide only when everything says it can talk */
|
|
437
|
+
doc.addEventListener('click', function (e) {
|
|
438
|
+
var door = e.target && e.target.closest && e.target.closest('[data-guide-door]');
|
|
439
|
+
if (!door || !chatOK || !order.length || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
open(door);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
var ctl = {
|
|
445
|
+
addView: function (name, v) { if (!views[name]) order.push(name); views[name] = v; return ctl; },
|
|
446
|
+
has: function (name) { return !!views[name]; },
|
|
447
|
+
state: function () { return state; },
|
|
448
|
+
limits: function () { return limits; },
|
|
449
|
+
active: function () { return active; },
|
|
450
|
+
open: open,
|
|
451
|
+
close: close,
|
|
452
|
+
switchTo: switchTo,
|
|
453
|
+
send: send,
|
|
454
|
+
askPlan: askPlan,
|
|
455
|
+
};
|
|
456
|
+
win.__frontDoorGuide = ctl;
|
|
457
|
+
return ctl;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
API: API,
|
|
462
|
+
MIN_FIRST_MS: MIN_FIRST_MS,
|
|
463
|
+
STORE_KEY: STORE_KEY,
|
|
464
|
+
VIEW_KEY: VIEW_KEY,
|
|
465
|
+
SIGN_UP: SIGN_UP,
|
|
466
|
+
COPY: COPY,
|
|
467
|
+
guideOn: guideOn,
|
|
468
|
+
readPlan: readPlan,
|
|
469
|
+
initial: initial,
|
|
470
|
+
reduce: reduce,
|
|
471
|
+
firstSendWait: firstSendWait,
|
|
472
|
+
trapTab: trapTab,
|
|
473
|
+
controller: controller,
|
|
474
|
+
};
|
|
475
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/* assets/guide-hall.css — the start-a-project guide, plain (task 1005276).
|
|
2
|
+
|
|
3
|
+
The same conversation as the game scene (guide-scene.css), for a visitor who does not
|
|
4
|
+
want the game: one panel on the landing's own tokens, sentence case in the body face, a
|
|
5
|
+
plain list of who said what, the plan under it, and the landing's own door pills for
|
|
6
|
+
what you press (.door / .door--go, index.html). The panel is the one sanctioned
|
|
7
|
+
container (DESIGN.md "The hall": the lifted ground closed by one hairline, no lift);
|
|
8
|
+
it floats over the scrim, so it takes the one shadow for floating things.
|
|
9
|
+
|
|
10
|
+
Every value is a token: no colour, size, corner or shadow is typed in here, and there is
|
|
11
|
+
no breakpoint. The layout values that change on a phone are the game scene's own --gd-*
|
|
12
|
+
names, which the landing's ONE breakpoint (900px, index.html's @media body block)
|
|
13
|
+
already restates: the panel then fills the screen. No motion of its own; the waiting
|
|
14
|
+
line is words. */
|
|
15
|
+
.gh {
|
|
16
|
+
position: fixed; inset: 0; z-index: var(--ds-z-modal, 160);
|
|
17
|
+
display: flex; align-items: center; justify-content: center;
|
|
18
|
+
padding: var(--gd-pad, var(--ds-space-md));
|
|
19
|
+
background: rgba(0, 0, 0, .62);
|
|
20
|
+
color: var(--ink); font-family: var(--font-body); font-size: var(--ds-text-lg); line-height: 1.5;
|
|
21
|
+
}
|
|
22
|
+
.gh[hidden] { display: none; }
|
|
23
|
+
|
|
24
|
+
.gh__panel {
|
|
25
|
+
box-sizing: border-box; display: flex; flex-direction: column; min-height: 0;
|
|
26
|
+
width: min(100%, var(--gd-frame-w, 720px)); height: min(100%, var(--gd-frame-h, 760px));
|
|
27
|
+
background: var(--bg-card);
|
|
28
|
+
border: var(--ds-line, 1px) solid var(--rule);
|
|
29
|
+
border-radius: var(--gd-hall-radius, var(--ds-radius-panel));
|
|
30
|
+
box-shadow: var(--ds-shadow-pop);
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.gh__head {
|
|
35
|
+
display: flex; align-items: center; gap: var(--ds-space-sm); flex-wrap: wrap;
|
|
36
|
+
padding: var(--ds-space-sm) var(--gd-gutter, var(--ds-space-md));
|
|
37
|
+
border-bottom: var(--ds-line, 1px) solid var(--rule-soft);
|
|
38
|
+
}
|
|
39
|
+
.gh__title { margin: 0 auto 0 0; font-family: var(--font-body); font-weight: 500; font-size: var(--ds-text-2xl); line-height: 1.25; }
|
|
40
|
+
.gh__x {
|
|
41
|
+
flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; padding: 0;
|
|
42
|
+
background: transparent; color: var(--ink-soft); border: 0; border-radius: var(--ds-radius-pill); cursor: pointer;
|
|
43
|
+
}
|
|
44
|
+
.gh__x svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; fill: none; }
|
|
45
|
+
.gh__x:hover { color: var(--ink); background: var(--pill-bg-hi, transparent); }
|
|
46
|
+
|
|
47
|
+
/* a text press: a link in a sentence (Use the game version, Skip to sign up) */
|
|
48
|
+
.gh__link {
|
|
49
|
+
display: inline-flex; align-items: center; min-height: 24px; padding: 2px 0;
|
|
50
|
+
background: transparent; border: 0; cursor: pointer;
|
|
51
|
+
color: var(--ink-soft); font: inherit; font-size: var(--ds-text-md);
|
|
52
|
+
text-decoration: underline; text-underline-offset: 3px;
|
|
53
|
+
}
|
|
54
|
+
.gh__link:hover { color: var(--ink); }
|
|
55
|
+
.gh__link[hidden] { display: none; }
|
|
56
|
+
|
|
57
|
+
.gh__left {
|
|
58
|
+
margin: 0; padding: var(--ds-space-xs) var(--gd-gutter, var(--ds-space-md));
|
|
59
|
+
color: var(--ink-soft); font-size: var(--ds-text-md); font-variant-numeric: tabular-nums;
|
|
60
|
+
border-bottom: var(--ds-line, 1px) solid var(--rule-soft);
|
|
61
|
+
}
|
|
62
|
+
.gh__left[hidden] { display: none; }
|
|
63
|
+
|
|
64
|
+
/* the talk and the plan scroll together */
|
|
65
|
+
/* positioned, so the plan's offsetTop (where guide-hall.js scrolls to) is measured from here */
|
|
66
|
+
.gh__body {
|
|
67
|
+
position: relative; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
|
68
|
+
padding: var(--ds-space-md) var(--gd-gutter, var(--ds-space-md));
|
|
69
|
+
display: flex; flex-direction: column; gap: var(--ds-space-md);
|
|
70
|
+
}
|
|
71
|
+
.gh__log { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: var(--ds-space-sm); }
|
|
72
|
+
.gh__msg { display: flex; flex-direction: column; gap: 2px; }
|
|
73
|
+
.gh__who { color: var(--ink-soft); font-size: var(--ds-text-sm); font-weight: 500; }
|
|
74
|
+
.gh__words { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; text-wrap: pretty; }
|
|
75
|
+
.gh__msg--note .gh__words, .gh__msg--wait .gh__words { color: var(--ink-soft); font-size: var(--ds-text-md); }
|
|
76
|
+
.gh__msg--note { padding-top: var(--ds-space-xs); border-top: var(--ds-line, 1px) solid var(--rule-soft); }
|
|
77
|
+
|
|
78
|
+
.gh__plan { border-top: var(--ds-line, 1px) solid var(--rule); padding-top: var(--ds-space-sm); display: flex; flex-direction: column; gap: var(--ds-space-xs); }
|
|
79
|
+
.gh__plan[hidden] { display: none; }
|
|
80
|
+
.gh__planh { margin: 0; font-family: var(--font-body); font-weight: 500; font-size: var(--ds-text-xl); }
|
|
81
|
+
.gh__planh4 { margin: var(--ds-space-xs) 0 0; color: var(--ink-soft); font-size: var(--ds-text-md); font-weight: 500; }
|
|
82
|
+
.gh__plansum { margin: 0; text-wrap: pretty; }
|
|
83
|
+
.gh__planwait, .gh__plannote, .gh__saved { margin: 0; color: var(--ink-soft); font-size: var(--ds-text-md); }
|
|
84
|
+
.gh__planlist { margin: 0; padding-left: var(--ds-space-md); }
|
|
85
|
+
.gh__planlist li { padding: 2px 0; }
|
|
86
|
+
.gh__saved[hidden] { display: none; }
|
|
87
|
+
|
|
88
|
+
.gh__err {
|
|
89
|
+
margin: 0 var(--gd-gutter, var(--ds-space-md)) var(--ds-space-xs); padding: var(--ds-space-xs) var(--ds-space-sm);
|
|
90
|
+
border-left: 2px solid var(--err); color: var(--ink); font-size: var(--ds-text-md);
|
|
91
|
+
}
|
|
92
|
+
.gh__err[hidden] { display: none; }
|
|
93
|
+
|
|
94
|
+
/* the composer */
|
|
95
|
+
.gh__say, .gh__end {
|
|
96
|
+
display: flex; flex-direction: column; gap: var(--ds-space-xs);
|
|
97
|
+
padding: var(--ds-space-sm) var(--gd-gutter, var(--ds-space-md)) var(--ds-space-md);
|
|
98
|
+
border-top: var(--ds-line, 1px) solid var(--rule-soft);
|
|
99
|
+
}
|
|
100
|
+
.gh__say[hidden], .gh__end[hidden] { display: none; }
|
|
101
|
+
.gh__label { color: var(--ink-soft); font-size: var(--ds-text-md); }
|
|
102
|
+
.gh__text {
|
|
103
|
+
box-sizing: border-box; width: 100%; min-height: 72px; max-height: 12rem; resize: vertical;
|
|
104
|
+
padding: var(--ds-space-xs) var(--ds-space-sm);
|
|
105
|
+
background: var(--bg); color: var(--ink); caret-color: var(--accent);
|
|
106
|
+
border: var(--ds-line, 1px) solid var(--rule); border-radius: var(--radius-sm, var(--ds-radius-tile));
|
|
107
|
+
font: inherit;
|
|
108
|
+
}
|
|
109
|
+
.gh__text:disabled { opacity: .7; }
|
|
110
|
+
.gh__hp { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; }
|
|
111
|
+
.gh__row { display: flex; align-items: center; gap: var(--ds-space-sm); flex-wrap: wrap; }
|
|
112
|
+
.gh__row .gh__link { margin-left: auto; }
|
|
113
|
+
.gh__end { flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: flex-end; }
|
|
114
|
+
|
|
115
|
+
/* the landing's door pills, as buttons: the same look as the doors on the page */
|
|
116
|
+
/* (the landing's phone rule stretches its door pills to share a row; here each keeps its words on one line) */
|
|
117
|
+
.gh .door { border: 0; font-family: inherit; cursor: pointer; flex: 0 1 auto; white-space: nowrap; padding: 0 var(--ds-space-md); }
|
|
118
|
+
.gh .door:not(.door--go) { background: transparent; border: var(--ds-line, 1px) solid var(--rule); }
|
|
119
|
+
.gh .door:not(.door--go):hover { background: var(--pill-bg-hi, transparent); }
|
|
120
|
+
.gh .door:disabled { background: transparent; color: var(--ink-soft); border: var(--ds-line, 1px) solid var(--rule-soft); box-shadow: none; cursor: default; }
|
|
121
|
+
.gh .door[hidden] { display: none; }
|