web: welcome conversation becomes data โ schema v1 frozen against reality (#286)
Phase 1 of docs/conversation-scripts.md completes: the joiner welcome joins
setup as pure data (welcome.flow.js) on the shared engine, so both shipped
conversations are now flow documents โ the schema's own definition of done.
welcome.js keeps the app shell and becomes the host binding (closed verb
table incl. adoptProfilePhoto's nokey tri-state, openPendingThread, the
32-char name cap enforced in the verb so the suggested-name chip and typed
path can't diverge).
Engine grew exactly what welcome forced, nothing more: {all}/{any} compound
conditions and done.X cross-step lookups (the passkey-return resume check),
'auto' advance for the bubble-only greeting step, async imgSource (the
profile photo decrypts to an object URL; falsy drops the image and keeps the
bubble), and chip sets landing before chip effects (the suggested-name chip
sets the answer its effect reads).
Three new welcome transcripts in test/flow.test.mjs: invited fresh join
(greeting variants, probe โ waiting โ Continue, suggested-name chip, photo
nokey retry, inviter chip on finish), skip-everything (nudge acks folded into
the next question, photo skip disarms the waiter, backup nag), and the
passkey-return leg (richer ack, no generic re-greet, question never re-asked;
plus resume-at-photo on a later reload).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+479
−267
docs/conversation-scripts.md+7 −5test/flow.test.mjs+135 −10web/static/apps/flow.js+29 −5web/static/apps/welcome.flow.js+239 −0web/static/apps/welcome.js+69 −247
diff --git a/docs/conversation-scripts.md b/docs/conversation-scripts.md| index 458f4a3..e2692b0 100644 |
| --- a/docs/conversation-scripts.md |
| +++ b/docs/conversation-scripts.md |
| @@ -1,10 +1,12 @@ |
| # ๐ค Conversation scripts โ a schema for Eleven's deterministic flows |
| -Status: **phase 1 underway** โ the engine exists (`web/static/apps/flow.js`) |
| -and the **setup flow is pure data** (`setup.flow.js`, JSON-purity enforced by |
| -`test/flow.test.mjs`, which drives full transcripts in Node with a fake io); |
| -`welcome.js` is next, then serving the scripts from the binary. Design |
| -captured 2026-07-07. Builds on |
| +Status: **phase 1 complete โ schema v1 is frozen against reality.** Both |
| +shipped conversations are pure data (`setup.flow.js`, `welcome.flow.js`) |
| +interpreted by the engine (`web/static/apps/flow.js`); `test/flow.test.mjs` |
| +drives full transcripts of both in Node with a fake io and enforces JSON |
| +purity, so the flow files are shippable fixtures for the Swift interpreter. |
| +Next: phase 2, serving the scripts from the binary. Design captured |
| +2026-07-07. Builds on |
| `conversational-onboarding.md` (the shipped setup + welcome conversations) and |
| exists because of one new fact: **the iOS and Mac apps are moving to fully |
| native rendering**, so they must run the onboarding conversations natively, |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index b6c6930..e746824 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -7,12 +7,13 @@ import assert from "node:assert/strict"; |
| import { test } from "node:test"; |
| import { runFlow, renderText, evalCond } from "../web/static/apps/flow.js"; |
| import SETUP_FLOW from "../web/static/apps/setup.flow.js"; |
| +import WELCOME_FLOW from "../web/static/apps/welcome.flow.js"; |
| const tick = () => new Promise((r) => setImmediate(r)); |
| // A fake io that mirrors the real engine surface: say dedupes by key||text |
| // (like eleven.js) but always re-arms chips; verbs return scripted results. |
| -function harness({ verbs = {}, done = {}, enrolled = false, myName = "" } = {}) { |
| +function harness({ verbs = {}, done = {}, enrolled = false, myName = "", envExtra = {} } = {}) { |
| const h = { |
| transcript: [], // { text, chips: [labels], extra } |
| verbLog: [], |
| @@ -34,6 +35,7 @@ function harness({ verbs = {}, done = {}, enrolled = false, myName = "" } = {}) |
| space: { name: "Old Name", nameSet: false }, |
| onEnd: () => (h.ended = true), |
| }; |
| + Object.assign(h.env, envExtra); |
| h.io = { |
| say: (env, text, chips, extra) => { |
| const key = (extra && extra.key) || text; |
| @@ -81,15 +83,17 @@ function harness({ verbs = {}, done = {}, enrolled = false, myName = "" } = {}) |
| return h; |
| } |
| -await test("setup flow is pure JSON โ shippable to native interpreters", () => { |
| - assert.deepStrictEqual(JSON.parse(JSON.stringify(SETUP_FLOW)), SETUP_FLOW); |
| - assert.equal(SETUP_FLOW.schema, 1); |
| - // Every goto/skipTo/probe target names a real step. |
| - const ids = new Set(SETUP_FLOW.steps.map((s) => s.id)); |
| - const targets = JSON.stringify(SETUP_FLOW).match(/"(?:goto|skipTo|step)":"([^"]+)"/g) || []; |
| - for (const t of targets) { |
| - const id = t.split(":")[1].replaceAll('"', ""); |
| - assert.ok(ids.has(id), `dangling target ${id}`); |
| +await test("flows are pure JSON โ shippable to native interpreters", () => { |
| + for (const FLOW of [SETUP_FLOW, WELCOME_FLOW]) { |
| + assert.deepStrictEqual(JSON.parse(JSON.stringify(FLOW)), FLOW); |
| + assert.equal(FLOW.schema, 1); |
| + // Every goto/skipTo/auto/probe/record target names a real step. |
| + const ids = new Set(FLOW.steps.map((s) => s.id)); |
| + const targets = JSON.stringify(FLOW).match(/"(?:goto|skipTo|auto|step)":"([^"]+)"/g) || []; |
| + for (const t of targets) { |
| + const id = t.split(":")[1].replaceAll('"', ""); |
| + assert.ok(ids.has(id), `${FLOW.flow}: dangling target ${id}`); |
| + } |
| } |
| }); |
| @@ -238,3 +242,124 @@ await test("engine: template + condition mini-language", () => { |
| assert.equal(renderText(env, { if: "vars.missing", then: "T", else: "E" }), "E"); |
| assert.equal(evalCond(env, "!vars.missing"), true); |
| }); |
| + |
| +// ---- welcome flow ----------------------------------------------------------- |
| + |
| +const welcomeEnv = (over = {}) => ({ |
| + suggested: "", |
| + inviterName: "", |
| + adminName: "", |
| + hasPendingThread: false, |
| + space: { name: "Test Space", nameSet: true }, |
| + ...over, |
| +}); |
| + |
| +await test("welcome: invited fresh join โ passkey probe, suggested name, photo nokey retry", async () => { |
| + let photoTries = 0; |
| + const h = harness({ |
| + verbs: { adoptProfilePhoto: () => (++photoTries === 1 ? "nokey" : true) }, |
| + envExtra: welcomeEnv({ suggested: "Luan", inviterName: "Ciara", hasPendingThread: true }), |
| + }); |
| + h.env.accessBackedUp = () => h.enrolled; |
| + runFlow(WELCOME_FLOW, h.env, h.io); |
| + await tick(); |
| + |
| + // The greeting assumes nothing: inviter named, admin unknown โ generic. |
| + assert.match(h.transcript[0].text, /^Welcome! ๐ I'm Eleven, your guide here\./); |
| + assert.match(h.transcript[0].text, /Ciara invited you to Test Space/); |
| + assert.match(h.transcript[0].text, /run by one of its members/); |
| + assert.match(h.transcript[0].text, /locked with keys only members hold/); |
| + |
| + // Passkey first; the ceremony stays put โ probe routes to the waiting step. |
| + await h.tap("Save my access (recommended)"); |
| + assert.equal(h.pendingDelays[0].ms, 1500); |
| + h.pendingDelays[0].fn(); |
| + await tick(); |
| + assert.match(h.lastText(), /I'll wait here while you finish/); |
| + h.enrolled = true; |
| + await h.tap("Continue"); |
| + assert.deepEqual(h.records[0], { step: "access", label: "Saved your access securely with a passkey", value: "passkey" }); |
| + |
| + // The ack folds into the name question; the suggested-name chip is offered. |
| + const nameQ = h.transcript.find((t) => t.extra.key === "q-name"); |
| + assert.match(nameQ.text, /^Perfect โ that's saved\. ๐\n\nFirst things first/); |
| + await h.tap("I'm Luan"); |
| + assert.deepEqual(h.verbLog.find((v) => v[0] === "setMyName"), ["setMyName", "Luan"]); |
| + assert.equal(h.records[1].label, "noted: you're Luan"); |
| + |
| + // Photo: instance key not here yet โ honest nokey line, then a retry works. |
| + const photoQ = h.transcript.find((t) => t.extra.key === "q-photo"); |
| + assert.match(photoQ.text, /^Lovely to meet you, Luan! ๐ค\n\nWant to add a profile photo\?/); |
| + await h.answer("me on holiday", { attachments: [{ type: "image/png" }] }); |
| + assert.ok(h.transcript.find((t) => /still finishing your secure setup/.test(t.text))); |
| + assert.match(h.lastText(), /Ready when you are โ send that photo again/); |
| + await h.answer("me on holiday", { attachments: [{ type: "image/png" }] }); |
| + assert.deepEqual(h.verbLog.filter((v) => v[0] === "adoptProfilePhoto").length, 2); |
| + |
| + // Preview is client-only with the round profile image; Keep records "photo". |
| + assert.equal(h.transcript.at(-1).extra.img, "IMG($myAvatar)"); |
| + assert.equal(h.transcript.at(-1).extra.imgRound, true); |
| + await h.tap("Keep this photo"); |
| + assert.deepEqual(h.records.at(-1), { step: "photo", label: "set your profile photo", value: "" }); |
| + |
| + // Finish: welcome marked done, no backup nag (enrolled), inviter chip first. |
| + assert.ok(h.verbLog.find((v) => v[0] === "markWelcomeDone")); |
| + const fin = h.transcript.find((t) => t.extra.key === "q-finish"); |
| + assert.match(fin.text, /you're all set\. ๐ Enjoy Test Space!/); |
| + assert.equal(/access backup/.test(fin.text), false); |
| + await h.tap("Say hi to Ciara"); |
| + assert.ok(h.verbLog.find((v) => v[0] === "openPendingThread")); |
| + assert.ok(h.lastChips.find((c) => c.label === "Take the tour")); |
| + assert.ok(h.ended); |
| +}); |
| + |
| +await test("welcome: skip everything โ nudge acks, photo skip disarms, nag on finish", async () => { |
| + const h = harness({ envExtra: welcomeEnv({ space: { name: "", nameSet: false } }) }); |
| + h.env.accessBackedUp = () => false; |
| + runFlow(WELCOME_FLOW, h.env, h.io); |
| + await tick(); |
| + |
| + // No inviter โ the generic invitation line, with the space-name fallback. |
| + assert.match(h.transcript[0].text, /You've been invited to this space/); |
| + |
| + await h.tap("Not now"); |
| + assert.ok(h.verbLog.find((v) => v[0] === "deferBackupReminders"), "declining defers the reminders"); |
| + const nameQ = h.transcript.find((t) => t.extra.key === "q-name"); |
| + assert.match(nameQ.text, /^No problem โ I'll nudge you again in a day or so\.\n\nFirst things first/); |
| + assert.deepEqual(nameQ.chips, [], "no suggested-name chip without a suggestion"); |
| + |
| + await h.answer("Paul"); |
| + await h.tap("Skip for now"); |
| + assert.equal(h.cancelled, 1, "photo skip disarms the waiter"); |
| + |
| + // Finish: skipped-photo comfort line + the backup nag; no pending-thread chip. |
| + const fin = h.transcript.find((t) => t.extra.key === "q-finish"); |
| + assert.match(fin.text, /^No problem โ you can add one anytime from your profile\./); |
| + assert.match(fin.text, /come back to that access backup soon/); |
| + assert.deepEqual(h.lastChips.map((c) => c.label), ["Take the tour"]); |
| +}); |
| + |
| +await test("welcome: passkey-return leg โ richer ack, no generic re-greet, question never re-asked", async () => { |
| + const h = harness({ enrolled: true, envExtra: welcomeEnv() }); |
| + h.env.accessBackedUp = () => true; |
| + runFlow(WELCOME_FLOW, h.env, h.io); |
| + await tick(); |
| + |
| + // Not "resumed" (nothing recorded, no name) so no "Welcome back โ" line; the |
| + // access entry supplies the richer ack instead, and the passkey question |
| + // never renders. |
| + assert.equal(h.transcript.some((t) => /^Welcome back โ let's pick up/.test(t.text)), false); |
| + assert.ok(h.transcript.find((t) => /^Great โ welcome back\. ๐/.test(t.text))); |
| + assert.equal(h.transcript.some((t) => /Shall we save it behind a passkey/.test(t.text)), false); |
| + assert.deepEqual(h.records[0], { step: "access", label: "Saved your access securely with a passkey", value: "passkey" }); |
| + assert.ok(h.transcript.find((t) => t.extra.key === "q-name")); |
| + |
| + // A later reload (name set, access recorded): generic re-greet, hello skipped. |
| + const h2 = harness({ enrolled: true, myName: "Paul", done: { access: true, name: true }, envExtra: welcomeEnv() }); |
| + h2.env.accessBackedUp = () => true; |
| + runFlow(WELCOME_FLOW, h2.env, h2.io); |
| + await tick(); |
| + assert.match(h2.transcript[0].text, /^Welcome back โ let's pick up/); |
| + assert.equal(h2.transcript.some((t) => /^Welcome! ๐/.test(t.text)), false, "hello step skipped on resume"); |
| + assert.ok(h2.transcript.find((t) => t.extra.key === "q-photo"), "resumes at the first undone step"); |
| +}); |
diff --git a/web/static/apps/flow.js b/web/static/apps/flow.js| index df30ed2..cc106cd 100644 |
| --- a/web/static/apps/flow.js |
| +++ b/web/static/apps/flow.js |
| @@ -25,6 +25,9 @@ |
| function resolve(env, path) { |
| if (path === "done") return env.__done(); |
| + // "done.X" reads another step's recorded state โ welcome's resumed check |
| + // ("did access or name already happen?") needs cross-step lookups. |
| + if (path.startsWith("done.")) return env.__doneOf(path.slice(5)); |
| const parts = path.split("."); |
| let cur = env; |
| for (const p of parts) { |
| @@ -35,9 +38,16 @@ function resolve(env, path) { |
| return cur; |
| } |
| +// Conditions: a dotted path with optional "!", or the compound forms |
| +// {all:[...]} / {any:[...]} for the few spots that genuinely need AND/OR |
| +// (welcome's passkey-return check is enrolled AND not-yet-recorded). |
| export function evalCond(env, expr) { |
| if (expr == null) return true; |
| if (typeof expr === "boolean") return expr; |
| + if (typeof expr === "object") { |
| + if (expr.all) return expr.all.every((e) => evalCond(env, e)); |
| + if (expr.any) return expr.any.some((e) => evalCond(env, e)); |
| + } |
| const neg = expr.startsWith("!"); |
| const v = resolve(env, neg ? expr.slice(1) : expr); |
| return neg ? !v : !!v; |
| @@ -94,6 +104,7 @@ export function runFlow(flow, env, io) { |
| flow.steps.forEach((s, i) => (byId[s.id] = { step: s, next: flow.steps[i + 1] ? flow.steps[i + 1].id : null })); |
| env.vars = env.vars || {}; |
| env.__ack = ""; |
| + env.__doneOf = (id) => io.done(id); |
| // The resumed re-greeting (a client-only line โ history already shows the |
| // real bubbles). Flows that special-case the return leg (welcome's passkey |
| @@ -166,6 +177,9 @@ export function runFlow(flow, env, io) { |
| run: () => { |
| const act = async () => { |
| if (c.cancelInput) io.cancelAnswer(env.threadId); |
| + // Sets land before the effect so its args can read them (the |
| + // suggested-name chip sets vars.answer, then setMyName reads it). |
| + if (c.set) setPaths(c.set); |
| if (c.effect) await io.verb(c.effect.verb, (c.effect.args || []).map(valueOf), env); |
| await runActions(c, stepCtx); |
| }; |
| @@ -240,7 +254,8 @@ export function runFlow(flow, env, io) { |
| for (const eff of step.effects) if (evalCond(env, eff.if)) await io.verb(eff.verb, (eff.args || []).map(valueOf), env); |
| } |
| const bubbles = (step.bubbles || []).filter((b) => evalCond(env, b.if)); |
| - bubbles.forEach((b, i) => { |
| + for (let i = 0; i < bubbles.length; i++) { |
| + const b = bubbles[i]; |
| const last = i === bubbles.length - 1; |
| const extra = {}; |
| if (b.key) extra.key = template(env, b.key); |
| @@ -248,18 +263,27 @@ export function runFlow(flow, env, io) { |
| if (b.attachHint) extra.attachHint = b.attachHint; |
| if (b.local) extra.local = true; |
| if (b.img) { |
| - extra.img = io.imgSource(b.img); |
| - extra.imgAlt = renderText(env, b.imgAlt || ""); |
| - if (b.imgRound) extra.imgRound = true; |
| + // imgSource may be async (welcome's profile photo decrypts to an |
| + // object URL); a falsy resolution drops the image, keeps the bubble. |
| + const src = await io.imgSource(b.img); |
| + if (src) { |
| + extra.img = src; |
| + extra.imgAlt = renderText(env, b.imgAlt || ""); |
| + if (b.imgRound) extra.imgRound = true; |
| + } |
| } |
| // Chips ride the last bubble โ the question the answer belongs to. |
| io.say(env, renderText(env, b.text), last ? orNull(buildChips(stepCtx, stepCtx.go)) : null, extra); |
| - }); |
| + } |
| if (!bubbles.length && step.chips) { |
| // Chip-only continuation (no new bubble) โ not used by setup, kept tiny. |
| io.say(env, "", buildChips(stepCtx, stepCtx.go), {}); |
| } |
| armInput(stepCtx); |
| + // A bubble-only step may auto-advance (welcome's greeting โ the passkey |
| + // step); input/chip steps advance through answers instead. ("auto", not |
| + // "then" โ text conditionals already use then/else.) |
| + if (step.auto) return enter(step.auto); |
| if (step.end) env.onEnd && env.onEnd(); |
| } |
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js| new file mode 100644 |
| index 0000000..987fedd |
| --- /dev/null |
| +++ b/web/static/apps/welcome.flow.js |
| @@ -0,0 +1,239 @@ |
| +// The joiner welcome conversation as DATA (docs/conversation-scripts.md) โ |
| +// Eleven greeting a new member: passkey FIRST (so the ceremony's round-trip |
| +// lands mid-conversation, not on "all done"), then name, profile photo, and |
| +// the finish chips. The engine (flow.js) interprets it; welcome.js binds it to |
| +// the host bridge. JSON-pure (test-enforced): this is the document the server |
| +// will ship to native interpreters. |
| +export default { |
| + schema: 1, |
| + flow: "welcome", |
| + entry: "hello", |
| + resumedIf: { any: ["done.access", "done.name", "bridge.myName"] }, |
| + // The passkey RETURN leg gets its own richer ack in the access step's entry, |
| + // so it skips the generic re-greeting. |
| + resumeUnless: { all: ["bridge.enrolled", "!done.access"] }, |
| + resumeSay: "Welcome back โ let's pick up where we left off.", |
| + steps: [ |
| + { |
| + id: "hello", |
| + skipIf: ["resumed"], |
| + // Assume nothing: say what this place IS, who runs it, and what the |
| + // encryption means for them โ in lock-and-key terms, not jargon. |
| + bubbles: [ |
| + { |
| + text: [ |
| + "Welcome! ๐ I'm Eleven, your guide here.\n\n", |
| + { if: "inviterName", text: "{inviterName} invited you to {space.name|this space}" }, |
| + { if: "!inviterName", text: "You've been invited to {space.name|this space}" }, |
| + " โ a private, invite-only chat space. It isn't a big platform with accounts and feeds: it's a small server ", |
| + { if: "adminName", text: "run by {adminName}" }, |
| + { if: "!adminName", text: "run by one of its members" }, |
| + ", just for the people in it.\n\n", |
| + "Everything here โ messages, photos, even names โ is locked with keys only members hold, so nobody outside a chat can ever read it. Not the server, and not whoever runs it.", |
| + ], |
| + }, |
| + ], |
| + auto: "access", |
| + }, |
| + { |
| + id: "access", |
| + entries: [ |
| + { |
| + if: { any: ["bridge.enrolled", "done"] }, |
| + recordIfNew: { label: "Saved your access securely with a passkey", value: "passkey" }, |
| + // Just back from the passkey ceremony: acknowledge it worked, then |
| + // move on โ a separate bubble, so the ack lands before the question. |
| + sayIfNew: "Great โ welcome back. ๐ Now that your access is secure, let's continue.", |
| + goto: "name", |
| + }, |
| + ], |
| + // One bubble, chips attached โ context and ask together, so the opening |
| + // run is two messages (intro + this), not three. |
| + bubbles: [ |
| + { |
| + text: |
| + "Before anything else, let's make sure you can't lose your way in. ๐\n\n" + |
| + "Your access to this space lives in a key that exists only in this browser โ there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\n" + |
| + "Shall we save it behind a passkey now? One tap โ you'll be brought straight back and we'll carry on.", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Save my access (recommended)", |
| + advancing: false, |
| + effect: { verb: "savePasskey" }, |
| + probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "access-waiting" }, |
| + }, |
| + { |
| + label: "Not now", |
| + spent: "Skipped", |
| + effect: { verb: "deferBackupReminders" }, |
| + record: { label: "Skipped the access backup for now", value: "skipped" }, |
| + ack: "No problem โ I'll nudge you again in a day or so.", |
| + goto: "name", |
| + }, |
| + ], |
| + }, |
| + { |
| + id: "access-waiting", |
| + bubbles: [{ text: "I'll wait here while you finish saving the passkey. When you're done, tap Continue." }], |
| + chips: [ |
| + { |
| + label: "Continue", |
| + record: { |
| + step: "access", |
| + label: { |
| + if: "bridge.enrolled", |
| + then: "Saved your access securely with a passkey", |
| + else: "Saved your access with a passkey (in your browser)", |
| + }, |
| + value: "passkey", |
| + }, |
| + ack: "Perfect โ that's saved. ๐", |
| + goto: "name", |
| + }, |
| + { |
| + label: "It didn't work โ skip for now", |
| + spent: "Skipped", |
| + effect: { verb: "deferBackupReminders" }, |
| + record: { step: "access", label: "Skipped the access backup for now", value: "skipped" }, |
| + ack: "No problem โ I'll nudge you again in a day or so.", |
| + goto: "name", |
| + }, |
| + ], |
| + }, |
| + { |
| + id: "name", |
| + skipIf: ["done", "bridge.myName"], |
| + bubbles: [ |
| + { |
| + text: "{ack?}First things first โ what should folks here call you? Reply with your name.", |
| + key: "q-name", |
| + hint: "Type your name", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + if: "suggested", |
| + label: "I'm {suggested}", |
| + set: { "vars.answer": "{suggested}" }, |
| + effect: { verb: "setMyName", args: ["$answer"] }, |
| + record: { label: "noted: you're {vars.answer}", value: "$answer" }, |
| + ack: "Lovely to meet you, {vars.answer}! ๐ค", |
| + goto: "photo", |
| + }, |
| + ], |
| + input: { kind: "text", required: true, maxLen: 32 }, |
| + effect: { verb: "setMyName", args: ["$answer"] }, |
| + on: { |
| + ok: { |
| + record: { label: "noted: you're {vars.answer}", value: "$answer" }, |
| + ack: "Lovely to meet you, {vars.answer}! ๐ค", |
| + goto: "photo", |
| + }, |
| + }, |
| + }, |
| + { |
| + id: "photo", |
| + skipIf: ["done"], |
| + skipTo: "finish", |
| + bubbles: [ |
| + { |
| + text: "{ack?}Want to add a profile photo? Only members of this space can see it โ like everything else here, it's encrypted.", |
| + key: "q-photo", |
| + }, |
| + { |
| + text: "Send me a photo โ tap the + next to the message box, or just drag a picture into the chat, and hit send. If you type a message with it, I'll keep that as the photo's description. Or skip for now.", |
| + attachHint: "Optional: describe your photo", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Skip for now", |
| + spent: "Skipped", |
| + cancelInput: true, |
| + set: { "vars.skippedPhoto": true }, |
| + goto: "finish", |
| + }, |
| + ], |
| + input: { |
| + kind: "photo", |
| + reject: { text: "Send it as a picture โ tap the + next to the message box or drag one in โ or tap Skip.", local: true }, |
| + }, |
| + effect: { verb: "adoptProfilePhoto" }, |
| + on: { |
| + ok: { goto: "photo-preview" }, |
| + nokey: { |
| + say: "One sec โ I'm still finishing your secure setup. Try sending it again in a moment, or skip for now.", |
| + local: true, |
| + ask: "Ready when you are โ send that photo again, or skip it.", |
| + }, |
| + error: { |
| + say: "Hmm, I couldn't use that one โ try another?", |
| + local: true, |
| + ask: "Of course! Send me another one whenever you're ready โ and pop a message with it if you'd like a description. ๐ท", |
| + }, |
| + }, |
| + }, |
| + { |
| + id: "photo-preview", |
| + // The preview is a client-only notice: the photo is an encrypted blob, |
| + // so its decrypted object URL can't ride a persisted event. |
| + bubbles: [ |
| + { |
| + text: "Here's how it looks. Happy with it, or want to try another?", |
| + img: "$myAvatar", |
| + imgAlt: "Your profile photo", |
| + imgRound: true, |
| + local: true, |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Keep this photo", |
| + spent: "Kept", |
| + record: { step: "photo", label: "set your profile photo" }, |
| + goto: "finish", |
| + }, |
| + { |
| + label: "Use a different photo", |
| + gotoAsk: { |
| + step: "photo", |
| + text: "Of course! Send me another one whenever you're ready โ and pop a message with it if you'd like a description. ๐ท", |
| + }, |
| + }, |
| + ], |
| + }, |
| + { |
| + id: "finish", |
| + effects: [{ verb: "markWelcomeDone" }], |
| + bubbles: [ |
| + { |
| + text: [ |
| + { if: "vars.skippedPhoto", text: "No problem โ you can add one anytime from your profile.\n\n" }, |
| + "That's it โ you're all set. ๐ Enjoy {space.name|this space}!", |
| + { if: "!accessBackedUp", text: "\n\nAnd do come back to that access backup soon." }, |
| + ], |
| + key: "q-finish", |
| + }, |
| + { text: "Where would you like to start?" }, |
| + ], |
| + chips: [ |
| + { |
| + if: { all: ["hasPendingThread", "inviterName"] }, |
| + label: "Say hi to {inviterName}", |
| + advancing: false, |
| + effect: { verb: "openPendingThread" }, |
| + }, |
| + { |
| + if: { all: ["hasPendingThread", "!inviterName"] }, |
| + label: "Open your first chat", |
| + advancing: false, |
| + effect: { verb: "openPendingThread" }, |
| + }, |
| + { label: "Take the tour", advancing: false, effect: { verb: "startTour" } }, |
| + ], |
| + end: true, |
| + }, |
| + ], |
| +}; |
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js| index 55a00a5..4f1e7a8 100644 |
| --- a/web/static/apps/welcome.js |
| +++ b/web/static/apps/welcome.js |
| @@ -5,15 +5,15 @@ |
| // like the admin setup conversation (docs/conversational-onboarding.md ยง4): |
| // claiming an invite lands straight in this conversation, no name form. |
| // |
| -// Like setup.js this is a CLIENT-DRIVEN script in Eleven's voice โ it must work |
| -// with no AI and no sidecar (the sidecar's welcome DM stood down in its |
| -// favour). The "welcome" app gives the conversation a home: a chat-only solo |
| -// thread whose events are the encrypted transcript โ Eleven's lines persist as |
| -// real bubbles (welcome.say, via makeSay) and completed steps as action lines |
| -// (welcome.step) โ and the resume points across reloads and the passkey |
| -// round-trip. It talks to the same host bridge as setup (setSetupBridge). |
| -import { defineApp, setupBridge, cancelAnswer, awaitAnswer, appStateFor, emitAppEvent, spaceInfo } from "../apps.js"; |
| -import { onceStep, advance, ask, makeSay, sayEvents, sayBubble } from "./eleven.js"; |
| +// The conversation itself is DATA: welcome.flow.js, interpreted by the flow |
| +// engine (flow.js โ docs/conversation-scripts.md). This file is the app shell |
| +// + the host binding: the bridge-verb table and the io wiring the engine to |
| +// eleven.js's feel and shell.js's setup bridge. Like setup it must work with |
| +// no AI and no sidecar (the sidecar's welcome DM stood down in its favour). |
| +import { defineApp, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo } from "../apps.js"; |
| +import { makeSay, sayEvents, sayBubble } from "./eleven.js"; |
| +import { runFlow } from "./flow.js"; |
| +import WELCOME_FLOW from "./welcome.flow.js"; |
| import { startTour } from "./tutorial.js"; |
| export const app = defineApp({ |
| @@ -42,33 +42,76 @@ export const app = defineApp({ |
| }, |
| }); |
| -// ---- the conversation ------------------------------------------------------- |
| +// ---- the host binding ------------------------------------------------------- |
| const running = new Set(); |
| const say = makeSay("welcome", "welcome.say"); |
| +const stepState = (threadId, step) => { |
| + const st = appStateFor(threadId, "welcome"); |
| + return (st && st.steps && st.steps[step]) || null; |
| +}; |
| + |
| +// The bridge-verb table: the CLOSED set of side effects the welcome flow may |
| +// name (docs/conversation-scripts.md ยง interpreter contract). |
| +function makeVerb(env) { |
| + return function verb(name, args) { |
| + const bridge = setupBridge(); |
| + switch (name) { |
| + case "setMyName": |
| + // The same cap the profile rename applies โ enforced here so the |
| + // suggested-name chip and the typed path can't diverge. |
| + return bridge.setMyName(String(args[0]).slice(0, 32)); |
| + case "adoptProfilePhoto": |
| + return bridge.adoptProfilePhoto(env.threadId, args[0], args[1]); |
| + case "savePasskey": |
| + return bridge.savePasskey(); |
| + case "deferBackupReminders": |
| + return bridge.deferBackupReminders && bridge.deferBackupReminders(); |
| + case "markWelcomeDone": |
| + return bridge.markWelcomeDone && bridge.markWelcomeDone(); |
| + case "openPendingThread": |
| + return env.openPendingThread && env.openPendingThread(); |
| + case "startTour": |
| + return startTour(env.respond, env.typing); |
| + default: |
| + throw new Error(`welcome: unknown verb ${name}`); |
| + } |
| + }; |
| +} |
| + |
| // startWelcome(env): env = { threadId, respond, typing, suggested, inviterName, |
| -// openPendingThread } โ the host supplies the notice sink, the typing beat, the |
| -// inviter's suggested name (from the invite fragment, never the server), who |
| -// invited them (decrypted roster label, "" if unknown), and a callback opening |
| -// the DM the invite created (null if none). |
| +// adminName, openPendingThread } โ the host supplies the notice sink, the |
| +// typing beat, the inviter's suggested name (from the invite fragment, never |
| +// the server), who invited them (decrypted roster label, "" if unknown), and a |
| +// callback opening the DM the invite created (null if none). |
| export function startWelcome(env) { |
| const bridge = setupBridge(); |
| if (!bridge || running.has(env.threadId)) return; |
| running.add(env.threadId); |
| env.sender = { name: "Eleven", kind: "bot" }; |
| - env.done = (step) => { |
| - const st = appStateFor(env.threadId, "welcome"); |
| - return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| + env.bridge = { myName: () => setupBridge().myName(), enrolled: () => setupBridge().enrolled() }; |
| + env.hasPendingThread = !!env.openPendingThread; |
| + // Backed-up is real state, not run-local memory: enrolled now, or the access |
| + // step recorded a passkey in an earlier session. |
| + env.accessBackedUp = () => { |
| + const s = stepState(env.threadId, "access"); |
| + return setupBridge().enrolled() || !!(s && s.value === "passkey"); |
| + }; |
| + env.onEnd = () => running.delete(env.threadId); |
| + const io = { |
| + say, |
| + ask: awaitAnswer, |
| + cancelAnswer, |
| + verb: makeVerb(env), |
| + record: (step, label, value) => emitAppEvent(env.threadId, { type: "welcome.step", step, label, value: value || "" }), |
| + done: (step) => { |
| + const s = stepState(env.threadId, step); |
| + return !!(s && s.done); |
| + }, |
| + imgSource: (token) => (token === "$myAvatar" ? setupBridge().myAvatarURL().catch(() => null) : token), |
| + delay: (ms, fn) => setTimeout(fn, ms), |
| }; |
| - env.record = (step, label, value) => emitAppEvent(env.threadId, { type: "welcome.step", step, label, value: value || "" }); |
| - // A resumed run (reload, or the round-trip back from the passkey page): the |
| - // history is real bubbles now, so a brief re-greeting is all that's needed โ |
| - // the steps then skip past whatever's already done. The passkey RETURN gets |
| - // its own richer ack in stepAccess, so it skips the generic line here. |
| - const resumed = env.done("access") || env.done("name") || !!bridge.myName(); |
| - const passkeyReturn = bridge.enrolled() && !env.done("access"); |
| - if (resumed && !passkeyReturn) say(env, "Welcome back โ let's pick up where we left off.", null, { local: true }); |
| spaceInfo() |
| .then((info) => { |
| env.space = info || {}; |
| @@ -76,226 +119,5 @@ export function startWelcome(env) { |
| .catch(() => { |
| env.space = {}; |
| }) |
| - .finally(() => stepAccess(env, !resumed)); |
| -} |
| - |
| -// ---- steps -------------------------------------------------------------------- |
| - |
| -// The passkey ceremony comes FIRST: it's the one thing that protects everything |
| -// after it, and the round-trip back from the passkey page re-enters the |
| -// conversation with the friendlier steps (name, photo) still ahead. |
| -function stepAccess(env, greet) { |
| - const bridge = setupBridge(); |
| - const space = (env.space && env.space.name) || "this space"; |
| - if (greet) { |
| - // Assume nothing: say what this place IS, who runs it, and what the |
| - // encryption means for them โ in lock-and-key terms, not jargon. |
| - const admin = env.adminName || ""; |
| - const invited = env.inviterName ? `${env.inviterName} invited you to ${space}` : `You've been invited to ${space}`; |
| - const runBy = admin ? `run by ${admin}` : `run by one of its members`; |
| - say( |
| - env, |
| - `Welcome! ๐ I'm Eleven, your guide here.\n\n` + |
| - `${invited} โ a private, invite-only chat space. It isn't a big platform with accounts and feeds: it's a small server ${runBy}, just for the people in it.\n\n` + |
| - `Everything here โ messages, photos, even names โ is locked with keys only members hold, so nobody outside a chat can ever read it. Not the server, and not whoever runs it.`, |
| - ); |
| - } |
| - if (bridge.enrolled() || env.done("access")) { |
| - if (!env.done("access")) { |
| - // Just back from the passkey ceremony: acknowledge it worked, then move |
| - // on โ a separate bubble, so the ack lands before the next question. |
| - env.record("access", "Saved your access securely with a passkey", "passkey"); |
| - say(env, "Great โ welcome back. ๐ Now that your access is secure, let's continue."); |
| - } |
| - return stepName(env); |
| - } |
| - const go = onceStep(); |
| - // One bubble, chips attached โ context and ask together, so the opening run |
| - // is two messages (intro + this), not three. |
| - say(env, "Before anything else, let's make sure you can't lose your way in. ๐\n\n" + |
| - "Your access to this space lives in a key that exists only in this browser โ there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\n" + |
| - "Shall we save it behind a passkey now? One tap โ you'll be brought straight back and we'll carry on.", [ |
| - { |
| - label: "Save my access (recommended)", |
| - stale: () => go.taken, |
| - run: () => { |
| - bridge.savePasskey(); // web: navigates away; the welcome resumes on return |
| - setTimeout(() => { |
| - if (!setupBridge().enrolled()) go(() => stepAccessWaiting(env)); |
| - }, 1500); |
| - }, |
| - }, |
| - advance( |
| - go, |
| - "Not now", |
| - async () => { |
| - if (bridge.deferBackupReminders) bridge.deferBackupReminders(); |
| - await env.record("access", "Skipped the access backup for now", "skipped"); |
| - stepName(env, "No problem โ I'll nudge you again in a day or so."); |
| - }, |
| - "Skipped", |
| - ), |
| - ]); |
| -} |
| - |
| -// The stayed-in-place passkey path (the ceremony opened elsewhere). |
| -function stepAccessWaiting(env) { |
| - const go = onceStep(); |
| - say(env, "I'll wait here while you finish saving the passkey. When you're done, tap Continue.", [ |
| - advance(go, "Continue", async () => { |
| - const label = setupBridge().enrolled() |
| - ? "Saved your access securely with a passkey" |
| - : "Saved your access with a passkey (in your browser)"; |
| - await env.record("access", label, "passkey"); |
| - stepName(env, "Perfect โ that's saved. ๐"); |
| - }), |
| - advance( |
| - go, |
| - "It didn't work โ skip for now", |
| - async () => { |
| - const bridge = setupBridge(); |
| - if (bridge.deferBackupReminders) bridge.deferBackupReminders(); |
| - await env.record("access", "Skipped the access backup for now", "skipped"); |
| - stepName(env, "No problem โ I'll nudge you again in a day or so."); |
| - }, |
| - "Skipped", |
| - ), |
| - ]); |
| -} |
| - |
| -function stepName(env, ack) { |
| - if (env.done("name") || setupBridge().myName()) return stepPhoto(env); |
| - const go = onceStep(); |
| - const chips = []; |
| - if (env.suggested) chips.push(advance(go, `I'm ${env.suggested}`, () => acceptName(env, env.suggested))); |
| - say(env, (ack ? ack + "\n\n" : "") + "First things first โ what should folks here call you? Reply with your name.", chips.length ? chips : undefined, { |
| - hint: "Type your name", |
| - key: "q-name", |
| - }); |
| - ask(env, go, (name) => { |
| - if (!name) return stepName(env); |
| - acceptName(env, name); |
| - }); |
| -} |
| - |
| -async function acceptName(env, name) { |
| - name = name.slice(0, 32); // the same cap the profile rename applies |
| - await setupBridge().setMyName(name); |
| - await env.record("name", `noted: you're ${name}`, name); |
| - stepPhoto(env, `Lovely to meet you, ${name}! ๐ค`); |
| -} |
| - |
| -function stepPhoto(env, ack) { |
| - if (env.done("photo")) return stepFinish(env); |
| - say( |
| - env, |
| - (ack ? ack + "\n\n" : "") + |
| - "Want to add a profile photo? Only members of this space can see it โ like everything else here, it's encrypted.", |
| - undefined, |
| - { key: "q-photo" }, |
| - ); |
| - askPhoto(env, "Send me a photo โ tap the + next to the message box, or just drag a picture into the chat, and hit send. If you type a message with it, I'll keep that as the photo's description. Or skip for now."); |
| -} |
| - |
| -// The photo arrives through the chat, like sending any picture. Unlike the |
| -// admin setup's SERVER photo (public branding), a profile photo stays inside |
| -// the encryption: the host re-encrypts it under the instance key. |
| -function askPhoto(env, prompt) { |
| - const go = onceStep(); |
| - say( |
| - env, |
| - prompt, |
| - [ |
| - advance( |
| - go, |
| - "Skip for now", |
| - () => { |
| - cancelAnswer(env.threadId); |
| - stepFinish(env, true); |
| - }, |
| - "Skipped", |
| - ), |
| - ], |
| - { attachHint: "Optional: describe your photo" }, |
| - ); |
| - const arm = () => |
| - awaitAnswer(env.threadId, (text, payload) => { |
| - const img = ((payload && payload.attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/")); |
| - if (!img) { |
| - say(env, "Send it as a picture โ tap the + next to the message box or drag one in โ or tap Skip.", null, { local: true }); |
| - return arm(); |
| - } |
| - go(async () => { |
| - // Any text sent with the photo becomes its description (alt text) โ |
| - // encrypted like everything else here, unlike the admin setup's public |
| - // branding photo, so no consent warning is needed. |
| - const ok = await setupBridge().adoptProfilePhoto(env.threadId, img, (text || "").trim()); |
| - if (ok === "nokey") { |
| - // The instance key hasn't reached us yet โ the photo is fine, we just |
| - // can't encrypt it. Say so honestly and let them try again shortly. |
| - say(env, "One sec โ I'm still finishing your secure setup. Try sending it again in a moment, or skip for now.", null, { local: true }); |
| - return askPhoto(env, "Ready when you are โ send that photo again, or skip it."); |
| - } |
| - if (!ok) { |
| - say(env, "Hmm, I couldn't use that one โ try another?", null, { local: true }); |
| - return askPhoto(env, "Of course! Send me another one whenever you're ready โ and pop a message with it if you'd like a description. ๐ท"); |
| - } |
| - stepPhotoPreview(env); |
| - }); |
| - }); |
| - arm(); |
| -} |
| - |
| -// Show the adopted photo back and let them swap it until it's right โ recorded |
| -// (and moved past) only on an explicit keep, like the admin setup's preview. |
| -// The preview is a client-only notice: the photo is an encrypted blob, so its |
| -// decrypted object URL can't ride a persisted event. |
| -async function stepPhotoPreview(env) { |
| - const go = onceStep(); |
| - let url = null; |
| - try { |
| - url = await setupBridge().myAvatarURL(); |
| - } catch {} |
| - say( |
| - env, |
| - "Here's how it looks. Happy with it, or want to try another?", |
| - [ |
| - advance( |
| - go, |
| - "Keep this photo", |
| - async () => { |
| - await env.record("photo", "set your profile photo"); |
| - stepFinish(env); |
| - }, |
| - "Kept", |
| - ), |
| - advance(go, "Use a different photo", () => askPhoto(env, "Of course! Send me another one whenever you're ready โ and pop a message with it if you'd like a description. ๐ท")), |
| - ], |
| - url ? { img: url, imgAlt: "Your profile photo", imgRound: true, local: true } : { local: true }, |
| - ); |
| -} |
| - |
| -async function stepFinish(env, skippedPhoto) { |
| - const bridge = setupBridge(); |
| - if (bridge.markWelcomeDone) bridge.markWelcomeDone(); |
| - const st = appStateFor(env.threadId, "welcome"); |
| - const backedUp = bridge.enrolled() || !!(st && st.steps && st.steps.access && st.steps.access.value === "passkey"); |
| - const space = (env.space && env.space.name) || "this space"; |
| - say( |
| - env, |
| - (skippedPhoto ? "No problem โ you can add one anytime from your profile.\n\n" : "") + |
| - `That's it โ you're all set. ๐ Enjoy ${space}!` + |
| - (backedUp ? "" : "\n\nAnd do come back to that access backup soon."), |
| - undefined, |
| - { key: "q-finish" }, |
| - ); |
| - const chips = []; |
| - if (env.openPendingThread && env.inviterName) { |
| - chips.push({ label: `Say hi to ${env.inviterName}`, run: env.openPendingThread }); |
| - } else if (env.openPendingThread) { |
| - chips.push({ label: "Open your first chat", run: env.openPendingThread }); |
| - } |
| - chips.push({ label: "Take the tour", run: () => startTour(env.respond, env.typing) }); |
| - say(env, "Where would you like to start?", chips); |
| - running.delete(env.threadId); |
| + .finally(() => runFlow(WELCOME_FLOW, env, io)); |
| } |