@bongos/core 1.25.15 → 1.25.16
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 +47 -32
- package/.claude/skills/goal-create/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +5 -4
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/copy-inventory.md +120 -109
- package/docs/copy-registry.json +275 -165
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +107 -107
- package/docs/project-context.template.md +1 -0
- package/modules/hall-ui/public/dom-utils.js +5 -0
- package/modules/hall-ui/public/genesis-home.js +23 -8
- package/modules/hall-ui/public/setup-gate.js +1 -1
- package/modules/hall-ui/public/style.css +21 -1
- package/modules/lifecycle/kickoff-checklist.js +17 -3
- package/modules/lifecycle/kickoff-seed.js +27 -4
- package/modules/onboarding/genesis-home.js +11 -2
- package/modules/onboarding/genesis-work.js +28 -5
- package/modules/public-landing/public/assets/guide-scene.css +245 -0
- package/modules/public-landing/public/assets/guide-scene.js +566 -0
- package/modules/public-landing/public/index.html +62 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +13 -1
- package/scripts/bongos/genesis.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/genesis_home.mjs +70 -2
- package/tests/genesis_work.mjs +49 -0
- package/tests/landing_guide_scene.mjs +504 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/* assets/guide-scene.js — THE START-A-PROJECT GUIDE, game version (task 1005275, goal 1000106).
|
|
2
|
+
|
|
3
|
+
When the front-door-guide module is on and its server says it can talk, the landing's
|
|
4
|
+
"Start a project" doors open a scene in the game's look (Helm: the night panel, the name
|
|
5
|
+
voice, the cut corner, the segment) instead of going straight to the create page: a guide
|
|
6
|
+
(the Patron sprite) the visitor talks with for a few capped messages, the count of
|
|
7
|
+
messages left, the plan, and "Sign up and get started", which goes on to the create page
|
|
8
|
+
exactly where the door always went. The server and its contract are
|
|
9
|
+
modules/front-door-guide (CLAUDE.md there: tasks 1005272 chat, 1005273 plan).
|
|
10
|
+
|
|
11
|
+
The flow: start → talk → the chat ends (the cap, the visitor's budget, or "Make my plan")
|
|
12
|
+
→ the plan is asked for once (POST …/plan) → the plan shows beside the talk and the one
|
|
13
|
+
closing action is "Sign up and get started". The chat's id and token are kept in local
|
|
14
|
+
storage (STORE_KEY), so a visitor who reloads or comes back later resumes the same chat
|
|
15
|
+
(GET …/conversations/:id on load) instead of spending a new one, and the sign-up hand-off
|
|
16
|
+
(task 1005274) finds them there.
|
|
17
|
+
|
|
18
|
+
What it never does:
|
|
19
|
+
- change a door when the module is off, the status read fails or answers "form", or the
|
|
20
|
+
visitor is signed in (the page's /me read takes data-guide-door off the doors): the
|
|
21
|
+
click then is the plain link it always was;
|
|
22
|
+
- show an error where the visitor has nowhere to go: a spent budget, a missing key or a
|
|
23
|
+
chat the server lost is the plain fallback, a line and the create door;
|
|
24
|
+
- break the server's client rules: the hidden `website` field is sent as it stands (empty
|
|
25
|
+
unless a bot filled it), the first message waits MIN_FIRST_MS after the chat started,
|
|
26
|
+
and the plan is read as optional (no plan, no panel content beyond one line);
|
|
27
|
+
- write innerHTML, or move anything from script: the waiting dots are CSS, so the world's
|
|
28
|
+
reduced-motion switch (world.css) stops them.
|
|
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. */
|
|
32
|
+
(function (factory) {
|
|
33
|
+
'use strict';
|
|
34
|
+
var api = factory();
|
|
35
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
36
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
37
|
+
window.GuideScene = api;
|
|
38
|
+
api.boot(window, document);
|
|
39
|
+
}
|
|
40
|
+
})(function () {
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
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';
|
|
51
|
+
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
|
+
|
|
292
|
+
/* ──────────────────────────── the DOM half ──────────────────────────── */
|
|
293
|
+
function boot(win, doc) {
|
|
294
|
+
var scene = doc.getElementById('guide');
|
|
295
|
+
if (!scene || !guideOn(win.__MODULES__)) return;
|
|
296
|
+
var $ = function (name) { return scene.querySelector('[data-gd="' + name + '"]'); };
|
|
297
|
+
var log = $('log'), err = $('err'), form = $('form'), text = $('text'), hp = $('website');
|
|
298
|
+
var send = $('send'), end = $('end'), go = $('go'), left = $('left'), segs = $('segs');
|
|
299
|
+
var planBody = $('plan-body'), planToggle = $('plan-toggle'), planClose = $('plan-close');
|
|
300
|
+
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
|
+
}
|
|
333
|
+
|
|
334
|
+
function el(tag, cls, txt) {
|
|
335
|
+
var e = doc.createElement(tag);
|
|
336
|
+
if (cls) e.className = cls;
|
|
337
|
+
if (txt != null) e.textContent = txt;
|
|
338
|
+
return e;
|
|
339
|
+
}
|
|
340
|
+
function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }
|
|
341
|
+
|
|
342
|
+
function waitLine(words) {
|
|
343
|
+
var wait = el('li', 'gd__msg gd__msg--guide gd__msg--wait');
|
|
344
|
+
wait.appendChild(el('span', 'gd__who', 'Guide'));
|
|
345
|
+
var p = el('p', 'gd__words');
|
|
346
|
+
p.appendChild(el('span', 'vh', words));
|
|
347
|
+
for (var i = 0; i < 3; i++) { var d = el('span', 'gd__dot'); d.setAttribute('aria-hidden', 'true'); p.appendChild(d); }
|
|
348
|
+
wait.appendChild(p);
|
|
349
|
+
return wait;
|
|
350
|
+
}
|
|
351
|
+
function paintLog() {
|
|
352
|
+
clear(log);
|
|
353
|
+
state.messages.forEach(function (m) {
|
|
354
|
+
var li = el('li', 'gd__msg gd__msg--' + m.role);
|
|
355
|
+
if (m.role !== 'note') li.appendChild(el('span', 'gd__who', m.role === 'guide' ? 'Guide' : 'You'));
|
|
356
|
+
li.appendChild(el('p', 'gd__words', m.text));
|
|
357
|
+
log.appendChild(li);
|
|
358
|
+
});
|
|
359
|
+
if (state.phase === 'starting' || state.phase === 'sending') log.appendChild(waitLine(COPY.waitTalk));
|
|
360
|
+
if (state.phase === 'planning') log.appendChild(waitLine(COPY.waitPlan));
|
|
361
|
+
if (state.phase === 'fallback') {
|
|
362
|
+
var fb = el('li', 'gd__msg gd__msg--guide');
|
|
363
|
+
fb.appendChild(el('span', 'gd__who', 'Guide'));
|
|
364
|
+
fb.appendChild(el('p', 'gd__words', state.fallback));
|
|
365
|
+
log.appendChild(fb);
|
|
366
|
+
}
|
|
367
|
+
log.scrollTop = log.scrollHeight;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function paintCount() {
|
|
371
|
+
var total = state.total || (limits && limits.max_messages) || 0;
|
|
372
|
+
var shown = state.phase !== 'fallback' && state.phase !== 'starting' && total > 0;
|
|
373
|
+
left.hidden = !shown;
|
|
374
|
+
segs.hidden = !shown;
|
|
375
|
+
if (!shown) return;
|
|
376
|
+
left.textContent = COPY.left(state.left, total);
|
|
377
|
+
clear(segs);
|
|
378
|
+
for (var i = 0; i < total; i++) segs.appendChild(el('span', 'gd__seg' + (i < total - state.left ? ' gd__seg--used' : '')));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function paintPlan() {
|
|
382
|
+
clear(planBody);
|
|
383
|
+
var plan = state.plan;
|
|
384
|
+
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));
|
|
390
|
+
} else {
|
|
391
|
+
if (plan.summary) planBody.appendChild(el('p', 'gd__plansum', plan.summary));
|
|
392
|
+
plan.sections.forEach(function (s) {
|
|
393
|
+
if (s.heading) planBody.appendChild(el('p', 'gd__planh4', s.heading));
|
|
394
|
+
var ul = el('ul', 'gd__planlist');
|
|
395
|
+
s.items.forEach(function (t) { ul.appendChild(el('li', null, t)); });
|
|
396
|
+
planBody.appendChild(ul);
|
|
397
|
+
});
|
|
398
|
+
if (plan.note) planBody.appendChild(el('p', 'gd__plannote', plan.note));
|
|
399
|
+
}
|
|
400
|
+
saved.textContent = state.saved;
|
|
401
|
+
saved.hidden = !state.saved || state.phase === 'fallback';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function paint() {
|
|
405
|
+
scene.setAttribute('data-phase', state.phase);
|
|
406
|
+
paintLog();
|
|
407
|
+
paintCount();
|
|
408
|
+
paintPlan();
|
|
409
|
+
var talking = state.phase === 'chat' || state.phase === 'sending' || state.phase === 'starting';
|
|
410
|
+
form.hidden = !talking;
|
|
411
|
+
end.hidden = talking;
|
|
412
|
+
text.disabled = state.phase !== 'chat';
|
|
413
|
+
send.disabled = state.phase !== 'chat';
|
|
414
|
+
makePlan.hidden = !(state.phase === 'chat' && state.sent > 0);
|
|
415
|
+
againPlan.hidden = !(state.phase === 'ended' && !state.plan && state.conv);
|
|
416
|
+
go.hidden = state.phase === 'planning';
|
|
417
|
+
text.maxLength = state.maxChars;
|
|
418
|
+
err.hidden = !state.error;
|
|
419
|
+
err.textContent = state.error;
|
|
420
|
+
go.textContent = state.phase === 'fallback' ? 'Start a project' : state.next;
|
|
421
|
+
if (state.restore && !text.value) text.value = state.restore;
|
|
422
|
+
scene.classList.toggle('gd--done', state.phase === 'ended' && !!state.plan);
|
|
423
|
+
}
|
|
424
|
+
|
|
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
|
+
/* the Helm faces: loaded the first time the scene opens, never on a plain visit */
|
|
434
|
+
function loadFaces() {
|
|
435
|
+
if (doc.querySelector('link[data-gd-faces]')) return;
|
|
436
|
+
var l = doc.createElement('link');
|
|
437
|
+
l.rel = 'stylesheet';
|
|
438
|
+
l.href = HELM_FONTS;
|
|
439
|
+
l.setAttribute('data-gd-faces', '');
|
|
440
|
+
doc.head.appendChild(l);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function focusables() {
|
|
444
|
+
return Array.prototype.filter.call(
|
|
445
|
+
scene.querySelectorAll('a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]):not([tabindex="-1"])'),
|
|
446
|
+
function (n) { return n.offsetParent !== null; });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function open(door) {
|
|
450
|
+
opener = door;
|
|
451
|
+
loadFaces();
|
|
452
|
+
scene.hidden = false;
|
|
453
|
+
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();
|
|
467
|
+
}
|
|
468
|
+
function focusFirst() {
|
|
469
|
+
if (scene.hidden) return;
|
|
470
|
+
var target = !text.disabled && !form.hidden ? text : (!end.hidden && !go.hidden ? go : $('close'));
|
|
471
|
+
try { target.focus(); } catch (e) { /* not focusable yet */ }
|
|
472
|
+
}
|
|
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
|
+
function setPlan(on) {
|
|
480
|
+
scene.classList.toggle('gd--plan', !!on);
|
|
481
|
+
planToggle.setAttribute('aria-expanded', on ? 'true' : 'false');
|
|
482
|
+
if (on) { try { planClose.focus(); } catch (e) { /* hidden */ } }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function submit() {
|
|
486
|
+
if (state.phase !== 'chat') return;
|
|
487
|
+
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
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
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
|
+
form.addEventListener('submit', function (e) { e.preventDefault(); submit(); });
|
|
527
|
+
text.addEventListener('keydown', function (e) {
|
|
528
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit(); }
|
|
529
|
+
});
|
|
530
|
+
makePlan.addEventListener('click', askPlan);
|
|
531
|
+
againPlan.addEventListener('click', askPlan);
|
|
532
|
+
$('close').addEventListener('click', close);
|
|
533
|
+
planToggle.addEventListener('click', function () { setPlan(planToggle.getAttribute('aria-expanded') !== 'true'); });
|
|
534
|
+
planClose.addEventListener('click', function () { setPlan(false); try { planToggle.focus(); } catch (e) { /* hidden */ } });
|
|
535
|
+
scene.addEventListener('click', function (e) { if (e.target === scene) close(); });
|
|
536
|
+
scene.addEventListener('keydown', function (e) {
|
|
537
|
+
if (e.key === 'Escape') {
|
|
538
|
+
e.preventDefault();
|
|
539
|
+
if (scene.classList.contains('gd--plan')) { setPlan(false); try { planToggle.focus(); } catch (x) { /* hidden */ } }
|
|
540
|
+
else close();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
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(); }
|
|
549
|
+
});
|
|
550
|
+
var face = scene.querySelector('.gd__face img');
|
|
551
|
+
if (face) face.addEventListener('error', function () { face.hidden = true; });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
API: API,
|
|
556
|
+
MIN_FIRST_MS: MIN_FIRST_MS,
|
|
557
|
+
STORE_KEY: STORE_KEY,
|
|
558
|
+
COPY: COPY,
|
|
559
|
+
guideOn: guideOn,
|
|
560
|
+
readPlan: readPlan,
|
|
561
|
+
initial: initial,
|
|
562
|
+
reduce: reduce,
|
|
563
|
+
firstSendWait: firstSendWait,
|
|
564
|
+
boot: boot,
|
|
565
|
+
};
|
|
566
|
+
});
|