web: setup conversation becomes data β flow engine + setup.flow.js (#284)
Phase 1 of docs/conversation-scripts.md: the first-run setup conversation is now a pure-JSON flow definition (setup.flow.js) interpreted by a small engine (flow.js). setup.js keeps the app shell and becomes the host binding: the closed bridge-verb table + the io wiring the engine to eleven.js's feel (pacing, persistence, spent chips) β exactly the schema/engine split the doc specifies. Behaviour-identical: every bubble, chip, branch, validation retry, the photo-vs-Skip race, the passkey probe, and resume semantics map 1:1 from the hand-written steps (records from the preview/confirm steps land under their original step ids, which the port's tests caught). test/flow.test.mjs drives three full transcripts through the engine in Node with a fake io β happy path with detours, resumed skip-everything run, and the already-enrolled short-circuit β plus JSON-purity and dangling-target checks on the flow document, so it stays shippable to native interpreters.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+861
−344
docs/conversation-scripts.md+5 −1package.json+1 −1test/flow.test.mjs+240 −0web/static/apps/flow.js+281 −0web/static/apps/setup.flow.js+272 −0web/static/apps/setup.js+62 −342
diff --git a/docs/conversation-scripts.md b/docs/conversation-scripts.md| index 88a97f7..458f4a3 100644 |
| --- a/docs/conversation-scripts.md |
| +++ b/docs/conversation-scripts.md |
| @@ -1,6 +1,10 @@ |
| # π€ Conversation scripts β a schema for Eleven's deterministic flows |
| -Status: **proposed** β design captured 2026-07-07, nothing built. Builds on |
| +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 |
| `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/package.json b/package.json| index c02aa5c..ce68e59 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs" |
| } |
| } |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| new file mode 100644 |
| index 0000000..b6c6930 |
| --- /dev/null |
| +++ b/test/flow.test.mjs |
| @@ -0,0 +1,240 @@ |
| +// The flow engine + setup flow, driven end-to-end in Node with a fake io β |
| +// the whole conversation (bubbles, chips, typed answers, photo, validation |
| +// branches, the passkey probe) without a browser. This is the transcript-level |
| +// proof that the flow-as-data refactor preserved setup.js's behaviour, and the |
| +// fixture discipline the Swift interpreter will inherit. |
| +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"; |
| + |
| +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 = "" } = {}) { |
| + const h = { |
| + transcript: [], // { text, chips: [labels], extra } |
| + verbLog: [], |
| + records: [], |
| + said: new Set(), |
| + lastChips: null, |
| + pendingAsk: null, |
| + pendingDelays: [], |
| + doneSteps: { ...done }, |
| + enrolled, |
| + myName, |
| + }; |
| + h.env = { |
| + threadId: "T", |
| + respond: () => {}, |
| + typing: null, |
| + sender: { name: "Eleven", kind: "bot" }, |
| + bridge: { myName: () => h.myName, enrolled: () => h.enrolled }, |
| + space: { name: "Old Name", nameSet: false }, |
| + onEnd: () => (h.ended = true), |
| + }; |
| + h.io = { |
| + say: (env, text, chips, extra) => { |
| + const key = (extra && extra.key) || text; |
| + if (!(extra && extra.local) && h.said.has(key)) { |
| + if (chips) h.lastChips = chips; |
| + return; |
| + } |
| + if (!(extra && extra.local)) h.said.add(key); |
| + h.transcript.push({ text, chips: (chips || []).map((c) => c.label), extra: extra || {} }); |
| + if (chips) h.lastChips = chips; |
| + }, |
| + ask: (threadId, cb) => (h.pendingAsk = cb), |
| + cancelAnswer: () => { |
| + h.cancelled = (h.cancelled || 0) + 1; |
| + h.pendingAsk = null; |
| + }, |
| + verb: (name, args) => { |
| + h.verbLog.push([name, ...args]); |
| + const r = verbs[name]; |
| + return typeof r === "function" ? r(...args) : r; |
| + }, |
| + record: (step, label, value) => { |
| + h.records.push({ step, label, value }); |
| + h.doneSteps[step] = true; |
| + }, |
| + done: (step) => !!h.doneSteps[step], |
| + imgSource: (t) => `IMG(${t})`, |
| + delay: (ms, fn) => h.pendingDelays.push({ ms, fn }), |
| + }; |
| + h.answer = async (text, payload) => { |
| + const cb = h.pendingAsk; |
| + h.pendingAsk = null; |
| + cb(text, payload); |
| + await tick(); |
| + await tick(); |
| + }; |
| + h.tap = async (label) => { |
| + const chip = (h.lastChips || []).find((c) => c.label === label); |
| + assert.ok(chip, `chip "${label}" not offered (have: ${(h.lastChips || []).map((c) => c.label).join(", ")})`); |
| + chip.run(); |
| + await tick(); |
| + await tick(); |
| + }; |
| + h.lastText = () => h.transcript[h.transcript.length - 1].text; |
| + 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("setup: the full first-run transcript, happy path with detours", async () => { |
| + const h = harness({ |
| + verbs: { |
| + setGroupName: (name) => (name === "X" ? "invalid" : "ok"), |
| + setContactEmail: (e) => (e.includes("@") ? "ok" : "invalid"), |
| + adoptServerPhoto: () => true, |
| + }, |
| + }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + |
| + // Greeting + name question (fresh run: not resumed, no "Welcome back"). |
| + assert.match(h.transcript[0].text, /^Hi β I'm Eleven! π/); |
| + assert.equal(h.transcript[1].extra.hint, "Type your name"); |
| + assert.equal(h.transcript.some((t) => /Welcome back/.test(t.text)), false); |
| + |
| + // Name: 40 chars caps to 32 (the profile-rename cap). |
| + await h.answer("A".repeat(40)); |
| + assert.deepEqual(h.verbLog[0], ["setMyName", "A".repeat(32)]); |
| + assert.equal(h.records[0].step, "name"); |
| + |
| + // Group: ack folded into the context bubble; no Keep chip (nameSet=false). |
| + const group = h.transcript.find((t) => t.extra.key === "q-group"); |
| + assert.match(group.text, /^Lovely to meet you, A{32}! π€\n\nNext, this space needs a name/); |
| + assert.equal(h.lastChips, null, "no Keep chip on a fresh instance"); |
| + |
| + // Rejected name says the honest error and re-asks; accepted name records. |
| + await h.answer("X"); |
| + assert.match(h.lastText(), /didn't stick β it needs to be 1β60/); |
| + await h.answer("Team Awesome"); |
| + assert.equal(h.env.space.name, "Team Awesome"); |
| + assert.deepEqual(h.records[1], { step: "group", label: "named this space βTeam Awesomeβ", value: "Team Awesome" }); |
| + |
| + // Email: invalid re-asks in place with the Skip chip still live. |
| + await h.answer("not-an-email"); |
| + assert.match(h.lastText(), /doesn't look like an email address/); |
| + assert.ok(h.lastChips.find((c) => c.label === "Skip for now")); |
| + await h.answer("paul@example.com"); |
| + assert.equal(h.records[2].label, "noted a contact email"); |
| + |
| + // Photo: a text-only reply is rejected and the waiter re-arms. |
| + await h.answer("here you go", { attachments: [] }); |
| + assert.match(h.lastText(), /Send it as a picture/); |
| + await h.answer("Our logo", { attachments: [{ type: "image/jpeg", id: "img1" }] }); |
| + const adopt = h.verbLog.find((v) => v[0] === "adoptServerPhoto"); |
| + assert.equal(adopt[2], "Our logo", "message text rides as alt text"); |
| + |
| + // Preview: alt-text publicity warning verbatim, image bubble, Keep records. |
| + assert.match(h.lastText(), /βOur logoβ β and like the photo itself it's PUBLIC/); |
| + assert.equal(h.transcript[h.transcript.length - 1].extra.img, "IMG($serverAvatar)"); |
| + await h.tap("Keep this photo"); |
| + assert.deepEqual(h.records[3], { step: "photo", label: "set the space photo", value: "Our logo" }); |
| + |
| + // Invites β keys: the warning speaks, passkey chip launches the ceremony. |
| + await h.tap("Only me"); |
| + assert.ok(h.verbLog.find((v) => v[0] === "setUserInvites" && v[1] === false)); |
| + assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text))); |
| + await h.tap("Save a passkey (recommended)"); |
| + assert.ok(h.verbLog.find((v) => v[0] === "savePasskey")); |
| + |
| + // The probe fires with no enrolment β the stayed-in-place waiting step. |
| + assert.equal(h.pendingDelays[0].ms, 1500); |
| + h.pendingDelays[0].fn(); |
| + await tick(); |
| + assert.match(h.lastText(), /I'll wait here while you finish/); |
| + |
| + // They finish the ceremony elsewhere; Continue records the verified label. |
| + h.enrolled = true; |
| + await h.tap("Continue"); |
| + assert.deepEqual(h.records.at(-1), { step: "keys", label: "Saved your credentials securely with a passkey", value: "passkey" }); |
| + |
| + // Finish: complete marked, no reminder deferral (backed up), no nag line. |
| + assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete")); |
| + assert.equal(h.verbLog.some((v) => v[0] === "deferBackupReminders"), false); |
| + const fin = h.transcript.find((t) => /Team Awesome is ready! π/.test(t.text)); |
| + assert.equal(/come back to that key backup/.test(fin.text), false); |
| + await h.tap("Open admin"); |
| + assert.ok(h.verbLog.find((v) => v[0] === "openAdmin")); |
| + assert.ok(h.ended, "flow signalled completion"); |
| +}); |
| + |
| +await test("setup: resumed run β skips, Keep chip, skip-everything path", async () => { |
| + const h = harness({ myName: "Paul", verbs: { setContactEmail: () => "ok" } }); |
| + h.env.space = { name: "Old Name", nameSet: true }; |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + |
| + // Resumed: brief local re-greeting, name step skipped entirely. |
| + assert.match(h.transcript[0].text, /^Welcome back/); |
| + assert.equal(h.transcript[0].extra.local, true); |
| + assert.equal(h.transcript.some((t) => /what should everyone here call you/.test(t.text)), false); |
| + |
| + // Group offers keeping the explicitly-set current name. |
| + assert.match(h.transcript.find((t) => t.extra.key === "q-group").text, /^Next, this space needs a name/); |
| + await h.tap("Keep βOld Nameβ"); |
| + assert.equal(h.verbLog.some((v) => v[0] === "setGroupName"), false, "Keep never re-saves the name"); |
| + |
| + await h.tap("Skip for now"); // email |
| + await h.tap("Skip for now"); // photo β must disarm the waiter |
| + assert.equal(h.cancelled, 1, "photo skip cancels the pending answer"); |
| + await h.tap("Members can invite people"); |
| + |
| + // Keys: decline twice through the plain-words confirm. |
| + await h.tap("Not now"); |
| + assert.match(h.lastText(), /I have to be straight with you/); |
| + await h.tap("I understand the risk"); |
| + assert.deepEqual(h.records.at(-1), { step: "keys", label: "Skipped the credentials backup for now", value: "skipped" }); |
| + |
| + // Finish without a backup: reminders deferred, the nag line present. |
| + assert.ok(h.verbLog.find((v) => v[0] === "deferBackupReminders")); |
| + assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete")); |
| + assert.ok(h.transcript.find((t) => /come back to that key backup soon/.test(t.text))); |
| + assert.ok(h.transcript.find((t) => /Old Name is ready! π/.test(t.text))); |
| +}); |
| + |
| +await test("setup: already enrolled β keys short-circuits with the once-only ack", async () => { |
| + const h = harness({ |
| + myName: "Paul", |
| + enrolled: true, |
| + done: { name: true, group: true, email: true, photo: true, invites: true }, |
| + }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + assert.deepEqual(h.records[0], { step: "keys", label: "Saved your credentials securely with a passkey", value: "passkey" }); |
| + assert.ok(h.transcript.find((t) => /safely backed up behind your passkey. β /.test(t.text))); |
| + assert.equal(h.verbLog.some((v) => v[0] === "deferBackupReminders"), false); |
| + assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete")); |
| + |
| + // Second entry with keys done: no duplicate record, straight to finish. |
| + const h2 = harness({ myName: "Paul", enrolled: true, done: { name: true, group: true, email: true, photo: true, invites: true, keys: true } }); |
| + runFlow(SETUP_FLOW, h2.env, h2.io); |
| + await tick(); |
| + assert.equal(h2.records.length, 0); |
| + assert.equal(h2.transcript.some((t) => /safely backed up/.test(t.text)), false); |
| +}); |
| + |
| +await test("engine: template + condition mini-language", () => { |
| + const env = { vars: { x: "hi" }, space: { name: "" }, __ack: "A" }; |
| + assert.equal(renderText(env, "{vars.x} {space.name|fallback}"), "hi fallback"); |
| + assert.equal(renderText(env, "{ack?}rest"), "A\n\nrest"); |
| + assert.equal(renderText(env, "{ack?}rest"), "rest", "ack is consumed"); |
| + assert.equal(renderText(env, ["a", { if: "vars.x", text: "-b" }, { if: "vars.missing", text: "-c" }]), "a-b"); |
| + assert.equal(renderText(env, { if: "vars.missing", then: "T", else: "E" }), "E"); |
| + assert.equal(evalCond(env, "!vars.missing"), true); |
| +}); |
diff --git a/web/static/apps/flow.js b/web/static/apps/flow.js| new file mode 100644 |
| index 0000000..df30ed2 |
| --- /dev/null |
| +++ b/web/static/apps/flow.js |
| @@ -0,0 +1,281 @@ |
| +// The conversation-flow engine: walks a flow definition (pure data β see |
| +// docs/conversation-scripts.md) and drives one of Eleven's scripted |
| +// conversations through an injected `io`. The engine owns control flow only; |
| +// FEEL (typing beats, the reply queue, persistence dedupe, spent chips) lives |
| +// in eleven.js behind io.say, and every side effect goes through io.verb β the |
| +// closed bridge-verb contract. Nothing here touches the DOM or the network, so |
| +// the whole engine runs (and is tested) in Node with a fake io. |
| +// |
| +// io = { |
| +// say(env, text, chips, extra) β one Eleven line (eleven.js makeSay shape) |
| +// ask(threadId, cb) β next composer message β cb(text, payload) |
| +// cancelAnswer(threadId) β disarm a pending ask |
| +// verb(name, args, env) β run a bridge verb; resolves to its result |
| +// record(step, label, value) β persist a completed step (the β line) |
| +// done(step) β is a step already recorded done? |
| +// imgSource(token) β resolve an image token ("$serverAvatar"β¦) |
| +// delay(ms, fn) β a cancellable timer (probes); tests fire it |
| +// } |
| + |
| +// ---- tiny expression + template language --------------------------------- |
| +// Conditions are dotted paths into the env with an optional leading "!": |
| +// "vars.photoAlt", "!vars.backedUp", "space.nameSet", "bridge.enrolled", |
| +// "resumed", "done" (this step recorded). bridge.* resolves through env.bridge |
| +// (calling functions), everything else reads env / env.vars / env.space. |
| + |
| +function resolve(env, path) { |
| + if (path === "done") return env.__done(); |
| + const parts = path.split("."); |
| + let cur = env; |
| + for (const p of parts) { |
| + if (cur == null) return undefined; |
| + cur = cur[p]; |
| + if (typeof cur === "function") cur = cur.call(env); |
| + } |
| + return cur; |
| +} |
| + |
| +export function evalCond(env, expr) { |
| + if (expr == null) return true; |
| + if (typeof expr === "boolean") return expr; |
| + const neg = expr.startsWith("!"); |
| + const v = resolve(env, neg ? expr.slice(1) : expr); |
| + return neg ? !v : !!v; |
| +} |
| + |
| +// "{path}" and "{path|fallback}" interpolation; "{ack?}" splices (and consumes) |
| +// the pending acknowledgement from the previous step, with its trailing gap. |
| +export function template(env, s) { |
| + return String(s).replace(/\{([^}]+)\}/g, (_, body) => { |
| + if (body === "ack?") { |
| + const a = env.__ack; |
| + env.__ack = ""; |
| + return a ? a + "\n\n" : ""; |
| + } |
| + const [path, fallback] = body.split("|"); |
| + const v = resolve(env, path.trim()); |
| + return v == null || v === "" ? (fallback != null ? fallback : "") : String(v); |
| + }); |
| +} |
| + |
| +// A text value is a string, {if, then, else}, or an array of segments |
| +// (strings, or {if, text}) joined after filtering. |
| +export function renderText(env, t) { |
| + if (t == null) return ""; |
| + if (typeof t === "string") return template(env, t); |
| + if (Array.isArray(t)) { |
| + return t |
| + .map((seg) => (typeof seg === "string" ? template(env, seg) : evalCond(env, seg.if) ? template(env, seg.text) : "")) |
| + .join(""); |
| + } |
| + return template(env, evalCond(env, t.if) ? t.then : t.else || ""); |
| +} |
| + |
| +// ---- the walker ----------------------------------------------------------- |
| + |
| +// One-tap-wins step guard (mirrors eleven.js onceStep; local so the engine has |
| +// no import and runs bare in Node). |
| +function onceStep() { |
| + const go = (fn) => { |
| + if (go.taken) return; |
| + go.taken = true; |
| + return fn(); |
| + }; |
| + go.taken = false; |
| + return go; |
| +} |
| + |
| +// Map a verb result onto a branch key: void/true β ok, false β error, strings |
| +// verbatim (setGroupName's "ok"/"invalid"/"error", adoptProfilePhoto's "nokey"). |
| +const resultKey = (r) => (r === undefined || r === true ? "ok" : r === false ? "error" : String(r)); |
| + |
| +export function runFlow(flow, env, io) { |
| + const byId = {}; |
| + 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 = ""; |
| + |
| + // The resumed re-greeting (a client-only line β history already shows the |
| + // real bubbles). Flows that special-case the return leg (welcome's passkey |
| + // ack) suppress it with resumeUnless. |
| + env.resumed = evalCond(env, flow.resumedIf || false); |
| + if (env.resumed && !(flow.resumeUnless && evalCond(env, flow.resumeUnless)) && flow.resumeSay) { |
| + io.say(env, flow.resumeSay, null, { local: true }); |
| + } |
| + |
| + const setPaths = (sets) => { |
| + for (const [path, value] of Object.entries(sets || {})) { |
| + const parts = path.split("."); |
| + let cur = env; |
| + while (parts.length > 1) cur = cur[parts.shift()] || {}; |
| + cur[parts[0]] = typeof value === "string" ? valueOf(value) : value; |
| + } |
| + }; |
| + // "$answer"/"$answerText" read the captured input; anything else templates. |
| + const valueOf = (v) => |
| + v === "$answer" ? env.vars.answer : v === "$answerText" ? env.vars.answerText : typeof v === "string" ? template(env, v) : v; |
| + |
| + async function runActions(a, stepCtx) { |
| + if (!a) return false; |
| + if (a.set) setPaths(a.set); |
| + // A record may name its step explicitly: the photo PREVIEW records "photo", |
| + // the keys WAITING/CONFIRM steps record "keys" β resume guards check those. |
| + if (a.record) await io.record(a.record.step || stepCtx.step.id, renderText(env, a.record.label), a.record.value != null ? valueOf(a.record.value) : ""); |
| + if (a.ack) env.__ack = renderText(env, a.ack); |
| + if (a.say) io.say(env, renderText(env, a.say), null, a.local ? { local: true } : undefined); |
| + if (a.ask) { |
| + askIn(stepCtx, renderText(env, a.ask.text != null ? a.ask.text : a.ask)); |
| + return true; |
| + } |
| + if (a.gotoAsk) { |
| + // Jump to another step's ASK portion with fresh prompt text, skipping its |
| + // context bubbles (the preview's "Use a different photo" β the photo ask). |
| + askIn(byId[a.gotoAsk.step], renderText(env, a.gotoAsk.text)); |
| + return true; |
| + } |
| + if (a.retry === "step") { |
| + enter(stepCtx.step.id); |
| + return true; |
| + } |
| + if (a.goto) { |
| + enter(a.goto); |
| + return true; |
| + } |
| + return false; |
| + } |
| + |
| + async function runEffect(stepCtx, eff, args) { |
| + const r = await io.verb(eff.verb, args || (eff.args || []).map(valueOf), env); |
| + const on = stepCtx.step.on || {}; |
| + const branch = on[resultKey(r)]; |
| + if (!branch) return false; // fire-and-forget verb (setMyName, savePasskey) |
| + const moved = await runActions(branch, stepCtx); |
| + if (!moved) { |
| + // A branch with no movement re-arms in place (rejected email re-asks). |
| + armInput(stepCtx); |
| + } |
| + return true; |
| + } |
| + |
| + function buildChips(stepCtx, go) { |
| + return (stepCtx.step.chips || []).filter((c) => evalCond(env, c.if)).map((c) => ({ |
| + label: renderText(env, c.label), |
| + done: c.spent, |
| + // Finish-line chips (advancing:false, no probe) stay live forever. |
| + stale: c.advancing === false && !c.probe ? undefined : () => go.taken, |
| + run: () => { |
| + const act = async () => { |
| + if (c.cancelInput) io.cancelAnswer(env.threadId); |
| + if (c.effect) await io.verb(c.effect.verb, (c.effect.args || []).map(valueOf), env); |
| + await runActions(c, stepCtx); |
| + }; |
| + if (c.advancing === false) { |
| + act(); |
| + // The probe: if the verb didn't take us away (the passkey ceremony |
| + // stayed in place), fall through to the waiting step. |
| + if (c.probe) io.delay(c.probe.afterMs, () => { |
| + if (!evalCond(env, c.probe.unless)) go(() => enter(c.probe.goto)); |
| + }); |
| + } else { |
| + go(act); |
| + } |
| + }, |
| + })); |
| + } |
| + |
| + // (Re-)arm the step's input without re-saying its bubbles. |
| + function armInput(stepCtx) { |
| + const { step } = stepCtx; |
| + if (!step.input) return; |
| + if (step.input.kind === "text") { |
| + io.ask(env.threadId, (text) => |
| + stepCtx.go(async () => { |
| + env.vars.answer = (text || "").trim().slice(0, step.input.maxLen || Infinity); |
| + // A required answer left empty re-asks the whole step (say-dedupe by |
| + // key means no duplicate bubbles β the input just re-arms). |
| + if (step.input.required && !env.vars.answer) return enter(step.id); |
| + if (step.effect) await runEffect(stepCtx, step.effect); |
| + }) |
| + ); |
| + } else if (step.input.kind === "photo") { |
| + const arm = () => |
| + io.ask(env.threadId, (text, payload) => { |
| + const img = (((payload || {}).attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/")); |
| + if (!img) { |
| + io.say(env, renderText(env, step.input.reject.text), null, step.input.reject.local ? { local: true } : undefined); |
| + return arm(); |
| + } |
| + stepCtx.go(async () => { |
| + env.vars.answerText = (text || "").trim(); |
| + await runEffect(stepCtx, step.effect, [img, env.vars.answerText]); |
| + }); |
| + }); |
| + arm(); |
| + } |
| + } |
| + |
| + async function enter(id) { |
| + const stepCtx = byId[id]; |
| + if (!stepCtx) throw new Error(`flow ${flow.flow}: unknown step "${id}"`); |
| + const { step } = stepCtx; |
| + env.__done = () => io.done(step.id); |
| + |
| + // Entry branches: state that satisfies the step on arrival (an enrolled |
| + // passkey, a name already set) β with once-only ack lines the first time. |
| + for (const e of step.entries || []) { |
| + if (!evalCond(env, e.if)) continue; |
| + if (e.set) setPaths(e.set); |
| + if (!io.done(step.id)) { |
| + if (e.recordIfNew) await io.record(step.id, renderText(env, e.recordIfNew.label), e.recordIfNew.value || ""); |
| + if (e.sayIfNew) io.say(env, renderText(env, e.sayIfNew)); |
| + } |
| + return enter(e.goto || stepCtx.next); |
| + } |
| + if ((step.skipIf || []).some((c) => evalCond(env, c))) return enter(step.skipTo || stepCtx.next); |
| + |
| + stepCtx.go = onceStep(); |
| + // Effects fire before the step speaks (markSetupComplete lands before the |
| + // "you're all set" bubble, as the hand-written flow did). |
| + if (step.effects) { |
| + 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) => { |
| + const last = i === bubbles.length - 1; |
| + const extra = {}; |
| + if (b.key) extra.key = template(env, b.key); |
| + if (b.hint) extra.hint = b.hint; |
| + 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; |
| + } |
| + // 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); |
| + if (step.end) env.onEnd && env.onEnd(); |
| + } |
| + |
| + // A retry that re-asks with new text: one fresh line carrying the step's |
| + // chips + hint, then the input re-arms (askEmail/askPhoto recursion). |
| + function askIn(stepCtx, text) { |
| + stepCtx.go = onceStep(); |
| + const extra = {}; |
| + const src = stepCtx.step.bubbles && stepCtx.step.bubbles[stepCtx.step.bubbles.length - 1]; |
| + if (src && src.hint) extra.hint = src.hint; |
| + if (src && src.attachHint) extra.attachHint = src.attachHint; |
| + io.say(env, text, orNull(buildChips(stepCtx, stepCtx.go)), extra); |
| + armInput(stepCtx); |
| + } |
| + |
| + const orNull = (chips) => (chips && chips.length ? chips : null); |
| + |
| + return enter(flow.entry); |
| +} |
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js| new file mode 100644 |
| index 0000000..edc5596 |
| --- /dev/null |
| +++ b/web/static/apps/setup.flow.js |
| @@ -0,0 +1,272 @@ |
| +// The first-run setup conversation as DATA (docs/conversation-scripts.md) β |
| +// every bubble, chip, branch, and side effect of the flow Eleven walks a fresh |
| +// instance's admin through. The engine (flow.js) interprets it; setup.js binds |
| +// it to the host bridge. This object must stay JSON-pure (a test enforces it): |
| +// it is the exact document the server will ship to native interpreters. |
| +export default { |
| + schema: 1, |
| + flow: "setup", |
| + entry: "name", |
| + resumedIf: "bridge.myName", |
| + resumeSay: "Welcome back β let's pick up where we left off.", |
| + steps: [ |
| + { |
| + id: "name", |
| + skipIf: ["done", "bridge.myName"], |
| + bubbles: [ |
| + { |
| + if: "!resumed", |
| + text: |
| + "Hi β I'm Eleven! π This is your space now: everything in it is end-to-end encrypted, and you're its administrator.\n\n" + |
| + "Let's take two minutes to set things up.", |
| + }, |
| + { text: "First β what should everyone here call you? Reply with your name.", hint: "Type your name" }, |
| + ], |
| + 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: "group", |
| + }, |
| + }, |
| + }, |
| + { |
| + id: "group", |
| + skipIf: ["done"], |
| + bubbles: [ |
| + { |
| + text: "{ack?}Next, this space needs a name. It's the one everyone sees β on the sign-in page, in invites, on the app icon.", |
| + key: "q-group", |
| + }, |
| + { |
| + text: { |
| + if: "space.nameSet", |
| + then: "It's currently β{space.name}β β keep that, or reply with a new name.", |
| + else: "What should we call it? Reply with a name.", |
| + }, |
| + hint: "Type a name for this space", |
| + }, |
| + ], |
| + chips: [{ if: "space.nameSet", label: "Keep β{space.name}β", spent: "Kept", goto: "email" }], |
| + input: { kind: "text", required: true }, |
| + effect: { verb: "setGroupName", args: ["$answer"] }, |
| + on: { |
| + ok: { |
| + set: { "space.name": "$answer" }, |
| + record: { label: "named this space β{vars.answer}β", value: "$answer" }, |
| + goto: "email", |
| + }, |
| + invalid: { say: "Hmm, that name didn't stick β it needs to be 1β60 characters. Try another?", retry: "step" }, |
| + error: { |
| + say: "Something went wrong saving that β it's not the name's fault. Give it another go in a moment.", |
| + retry: "step", |
| + }, |
| + }, |
| + }, |
| + { |
| + id: "email", |
| + skipIf: ["done"], |
| + bubbles: [ |
| + { |
| + text: "Your contact email is shared with members β useful if someone's locked out, or something needs a human. It's optional.", |
| + }, |
| + { |
| + text: "How can people here reach you outside the chat? Reply with an email address, or skip it.", |
| + hint: "Type a contact email", |
| + }, |
| + ], |
| + chips: [{ label: "Skip for now", spent: "Skipped", goto: "photo" }], |
| + input: { kind: "text" }, |
| + effect: { verb: "setContactEmail", args: ["$answer"] }, |
| + on: { |
| + ok: { record: { label: "noted a contact email", value: "$answer" }, goto: "photo" }, |
| + invalid: { ask: "That doesn't look like an email address β try again, or skip it." }, |
| + error: { ask: "Something went wrong saving that β try again in a moment, or skip it." }, |
| + }, |
| + }, |
| + { |
| + id: "photo", |
| + skipIf: ["done"], |
| + skipTo: "invites", |
| + bubbles: [ |
| + { |
| + text: "Want to give this space a face? The photo shows on the welcome page and when people join β you can always change it later.", |
| + }, |
| + { |
| + text: "Send me a photo β tap the + next to the message box, or just drag a picture into the chat, and hit send. Type a message with it if you want to include alt text.", |
| + attachHint: "Optional: type alt text for the photo", |
| + }, |
| + ], |
| + chips: [{ label: "Skip for now", spent: "Skipped", cancelInput: true, goto: "invites" }], |
| + input: { |
| + kind: "photo", |
| + reject: { text: "Send it as a picture β tap the + next to the message box or drag one in β or tap Skip." }, |
| + }, |
| + effect: { verb: "adoptServerPhoto" }, |
| + on: { |
| + ok: { set: { "vars.photoAlt": "$answerText" }, goto: "photo-preview" }, |
| + error: { |
| + say: "Hmm, I couldn't use that one β try another?", |
| + ask: "Send another photo when you're ready, or skip it.", |
| + }, |
| + }, |
| + }, |
| + { |
| + id: "photo-preview", |
| + bubbles: [ |
| + { |
| + text: [ |
| + "Here's how it looks.", |
| + { |
| + if: "vars.photoAlt", |
| + text: "\n\nIts alt text is the message you sent β β{vars.photoAlt}β β and like the photo itself it's PUBLIC: anyone with an invite link can see both.", |
| + }, |
| + "\n\nHappy with it, or want to try another?", |
| + ], |
| + img: "$serverAvatar", |
| + imgAlt: "{vars.photoAlt|The space photo}", |
| + local: true, |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Keep this photo", |
| + spent: "Kept", |
| + record: { step: "photo", label: "set the space photo", value: "{vars.photoAlt}" }, |
| + goto: "invites", |
| + }, |
| + { |
| + 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 alt text. π·", |
| + }, |
| + }, |
| + ], |
| + }, |
| + { |
| + id: "invites", |
| + skipIf: ["done"], |
| + bubbles: [ |
| + { |
| + text: "Who gets to bring people in? You can always invite people. The question is whether members can too, or whether new faces only arrive through you.", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Members can invite people", |
| + effect: { verb: "setUserInvites", args: [true] }, |
| + record: { label: "decided members can invite people", value: "members" }, |
| + goto: "keys", |
| + }, |
| + { |
| + label: "Only me", |
| + effect: { verb: "setUserInvites", args: [false] }, |
| + record: { label: "decided only the admin invites people", value: "admin only" }, |
| + goto: "keys", |
| + }, |
| + ], |
| + }, |
| + { |
| + id: "keys", |
| + entries: [ |
| + { |
| + if: "bridge.enrolled", |
| + set: { "vars.backedUp": true }, |
| + recordIfNew: { label: "Saved your credentials securely with a passkey", value: "passkey" }, |
| + sayIfNew: "Your key is safely backed up behind your passkey. β ", |
| + goto: "finish", |
| + }, |
| + ], |
| + bubbles: [ |
| + { |
| + text: |
| + "Now the most important thing of all. π\n\n" + |
| + "Everything here is locked with a key that exists only on your devices. There's no password reset and no support desk β not even whoever runs the server can read your messages or recover that key. That's not a flaw; it's the whole point.\n\n" + |
| + "But it means: right now, this browser holds the only key to your space. If it's lost β cleared, broken, stolen β your space is gone with it, and nobody can get it back.", |
| + }, |
| + { text: "Shall we save a copy behind a passkey now? One tap β you'll be brought straight back." }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Save a passkey (recommended)", |
| + advancing: false, |
| + effect: { verb: "savePasskey" }, |
| + probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" }, |
| + }, |
| + { label: "Not now", spent: "Skipped", goto: "keys-confirm" }, |
| + ], |
| + }, |
| + { |
| + id: "keys-waiting", |
| + bubbles: [{ text: "I'll wait here while you finish saving the passkey. When you're done, tap Continue." }], |
| + chips: [ |
| + { |
| + label: "Continue", |
| + record: { |
| + step: "keys", |
| + label: { |
| + if: "bridge.enrolled", |
| + then: "Saved your credentials securely with a passkey", |
| + else: "Saved your credentials with a passkey (in your browser)", |
| + }, |
| + value: "passkey", |
| + }, |
| + set: { "vars.backedUp": true }, |
| + goto: "finish", |
| + }, |
| + { |
| + label: "It didn't work β skip for now", |
| + spent: "Skipped", |
| + record: { step: "keys", label: "Skipped the credentials backup for now", value: "skipped" }, |
| + set: { "vars.backedUp": false }, |
| + goto: "finish", |
| + }, |
| + ], |
| + }, |
| + { |
| + id: "keys-confirm", |
| + bubbles: [ |
| + { |
| + text: "I have to be straight with you: without a backup, one lost or cleared browser means this space and everything in it is gone β permanently, for everyone, and no one can help. I'll remind you again in a day or so.", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Go back and save it", |
| + advancing: false, |
| + effect: { verb: "savePasskey" }, |
| + probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" }, |
| + }, |
| + { |
| + label: "I understand the risk", |
| + spent: "Understood", |
| + record: { step: "keys", label: "Skipped the credentials backup for now", value: "skipped" }, |
| + set: { "vars.backedUp": false }, |
| + goto: "finish", |
| + }, |
| + ], |
| + }, |
| + { |
| + id: "finish", |
| + effects: [{ if: "!vars.backedUp", verb: "deferBackupReminders" }, { verb: "markSetupComplete" }], |
| + bubbles: [ |
| + { |
| + text: [ |
| + "That's it β {space.name|your space} is ready! π\n\n", |
| + "Everything we just set up lives in your Admin panel β invite links, the space's name and photo, who can invite. Type `/admin` anywhere to open it; it's yours alone as this space's admin.", |
| + { if: "!vars.backedUp", text: "\n\nAnd do come back to that key backup soon." }, |
| + ], |
| + }, |
| + { text: "Where would you like to start?" }, |
| + ], |
| + chips: [ |
| + { label: "Open admin", advancing: false, effect: { verb: "openAdmin" } }, |
| + { label: "Take the tour", advancing: false, effect: { verb: "startTour" } }, |
| + ], |
| + end: true, |
| + }, |
| + ], |
| +}; |
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js| index 5278be3..3ada898 100644 |
| --- a/web/static/apps/setup.js |
| +++ b/web/static/apps/setup.js |
| @@ -4,16 +4,16 @@ |
| // conversation (chips + typed answers), not a console. See |
| // docs/conversational-onboarding.md. |
| // |
| -// Like /tutorial, this is a CLIENT-DRIVEN script in Eleven's voice β it must |
| -// work with no AI and no bot at all (a fresh instance has neither, and "enable |
| -// AI?" will itself be a setup question). Unlike a mini-app it writes real |
| -// instance settings, so it talks to a host bridge (setSetupBridge in shell.js) |
| -// β the small "setup" app defined here exists only to give the conversation a |
| -// home: a personal thread whose chat view is the surface, whose `setup.step` |
| -// events make completed steps part of the encrypted transcript (the β action |
| -// lines you see after a reload), and whose Interface view is a progress card. |
| +// The conversation itself is DATA: setup.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 (the closed set of side effects |
| +// the flow may name) and the io wiring the engine to eleven.js's feel (pacing, |
| +// persistence) and shell.js's setup bridge (setSetupBridge). Like /tutorial it |
| +// must work with no AI and no bot at all (a fresh instance has neither). |
| import { defineApp, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js"; |
| -import { onceStep, advance, ask, makeSay, sayEvents, sayBubble } from "./eleven.js"; |
| +import { makeSay, sayEvents, sayBubble } from "./eleven.js"; |
| +import { runFlow } from "./flow.js"; |
| +import SETUP_FLOW from "./setup.flow.js"; |
| import { startTour } from "./tutorial.js"; |
| export const app = defineApp({ |
| @@ -48,28 +48,67 @@ export const app = defineApp({ |
| }, |
| }); |
| -// ---- the conversation ----------------------------------------------------- |
| +// ---- the host binding ------------------------------------------------------- |
| -// One live run per thread (a reload starts a fresh one; onceStep guards within). |
| +// One live run per thread (a reload starts a fresh one; the engine's step |
| +// guards and say-dedupe handle everything within). |
| const running = new Set(); |
| +const say = makeSay("setup", "setup.say"); |
| + |
| +// The bridge-verb table: the CLOSED set of side effects the setup flow may |
| +// name (docs/conversation-scripts.md Β§ interpreter contract). A flow is data β |
| +// it can never do anything this table doesn't offer. |
| +function verb(name, args, env) { |
| + const bridge = setupBridge(); |
| + switch (name) { |
| + case "setMyName": |
| + return bridge.setMyName(args[0]); |
| + case "setGroupName": |
| + return bridge.setGroupName(args[0]); |
| + case "setContactEmail": |
| + return bridge.setContactEmail(args[0]); |
| + case "setUserInvites": |
| + return bridge.setUserInvites(args[0]); |
| + case "adoptServerPhoto": |
| + return bridge.adoptServerPhoto(env.threadId, args[0], args[1]); |
| + case "savePasskey": |
| + return bridge.savePasskey(); |
| + case "markSetupComplete": |
| + return bridge.markSetupComplete(); |
| + case "deferBackupReminders": |
| + return bridge.deferBackupReminders && bridge.deferBackupReminders(); |
| + case "openAdmin": |
| + return openAppThread("admin"); |
| + case "startTour": |
| + return startTour(env.respond, env.typing); |
| + default: |
| + throw new Error(`setup: unknown verb ${name}`); |
| + } |
| +} |
| -// startSetup(env): env = { threadId, respond } β respond appends one Eleven |
| -// notice to the setup thread's feed. The host guarantees we're the admin and |
| -// setup isn't complete before calling. |
| +// startSetup(env): env = { threadId, respond, typing } β respond appends one |
| +// Eleven notice to the setup thread's feed. The host guarantees we're the |
| +// admin and setup isn't complete before calling. |
| export function startSetup(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, "setup"); |
| - return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| + env.bridge = { myName: () => setupBridge().myName(), enrolled: () => setupBridge().enrolled() }; |
| + env.onEnd = () => running.delete(env.threadId); |
| + const io = { |
| + say, |
| + ask: awaitAnswer, |
| + cancelAnswer, |
| + verb, |
| + record: (step, label, value) => emitAppEvent(env.threadId, { type: "setup.step", step, label, value: value || "" }), |
| + done: (step) => { |
| + const st = appStateFor(env.threadId, "setup"); |
| + return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| + }, |
| + imgSource: (token) => (token === "$serverAvatar" ? "/api/server-avatar?t=" + Date.now() : token), |
| + delay: (ms, fn) => setTimeout(fn, ms), |
| }; |
| - env.record = (step, label, value) => emitAppEvent(env.threadId, { type: "setup.step", step, label, value: value || "" }); |
| - // A resumed run (reload, or the round-trip to the passkey page) gets a brief |
| - // re-greeting instead of the full hello; the steps then skip what's done. |
| - const resumed = !!bridge.myName(); |
| - if (resumed) say(env, "Welcome back β let's pick up where we left off.", null, { local: true }); |
| spaceInfo() |
| .then((info) => { |
| env.space = info || {}; |
| @@ -77,324 +116,5 @@ export function startSetup(env) { |
| .catch(() => { |
| env.space = {}; |
| }) |
| - .finally(() => stepName(env, !resumed)); |
| -} |
| - |
| -// The paced reply queue, tap guards, and typed-answer helper are shared with |
| -// the welcome conversation (eleven.js). say() PERSISTS each line as a |
| -// setup.say event, so the conversation reads back as real history. |
| -const say = makeSay("setup", "setup.say"); |
| - |
| -// ---- steps ------------------------------------------------------------------ |
| - |
| -function stepName(env, greet) { |
| - // A name already exists (set on a previous run) β don't re-ask. |
| - if (env.done("name") || setupBridge().myName()) return stepGroup(env); |
| - const go = onceStep(); |
| - // Context first, then the request β two bubbles, so the scene-setting never |
| - // buries what's being asked. |
| - if (greet) { |
| - say( |
| - env, |
| - "Hi β I'm Eleven! π This is your space now: everything in it is end-to-end encrypted, and you're its administrator.\n\n" + |
| - "Let's take two minutes to set things up.", |
| - ); |
| - } |
| - say(env, "First β what should everyone here call you? Reply with your name.", undefined, { hint: "Type your name" }); |
| - ask(env, go, async (name) => { |
| - if (!name) return stepName(env, false); |
| - 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); |
| - stepGroup(env, `Lovely to meet you, ${name}! π€`); |
| - }); |
| -} |
| - |
| -// Each step is two bubbles: a (multi-line) CONTEXT message, then a single-line |
| -// QUESTION. `ack` folds the previous step's acknowledgement into the context so |
| -// steps hand over in one breath, not three bubbles. |
| -function stepGroup(env, ack) { |
| - if (env.done("group")) return stepEmail(env); |
| - const go = onceStep(); |
| - // Offer "keep the current name" only when an admin explicitly chose one (a |
| - // re-run) β a fresh instance's compiled-in default is not worth keeping. |
| - const current = env.space && env.space.nameSet ? env.space.name || "" : ""; |
| - const chips = []; |
| - if (current) chips.push(advance(go, `Keep β${current}β`, () => stepEmail(env), "Kept")); |
| - say( |
| - env, |
| - (ack ? ack + "\n\n" : "") + |
| - "Next, this space needs a name. It's the one everyone sees β on the sign-in page, in invites, on the app icon.", |
| - undefined, |
| - { key: "q-group" }, |
| - ); |
| - say( |
| - env, |
| - current ? `It's currently β${current}β β keep that, or reply with a new name.` : "What should we call it? Reply with a name.", |
| - chips.length ? chips : undefined, |
| - { hint: "Type a name for this space" }, |
| - ); |
| - ask(env, go, async (name) => { |
| - if (!name) return stepGroup(env); |
| - const result = await setupBridge().setGroupName(name); |
| - if (result !== "ok") { |
| - // Be honest about which failure this is β a rejected value re-asks, but a |
| - // save that broke for other reasons (auth, network) shouldn't blame the name. |
| - say( |
| - env, |
| - result === "invalid" |
| - ? "Hmm, that name didn't stick β it needs to be 1β60 characters. Try another?" |
| - : "Something went wrong saving that β it's not the name's fault. Give it another go in a moment.", |
| - ); |
| - return stepGroup(env); |
| - } |
| - env.space.name = name; |
| - await env.record("group", `named this space β${name}β`, name); |
| - stepEmail(env); |
| - }); |
| -} |
| - |
| -function stepEmail(env) { |
| - if (env.done("email")) return stepPhoto(env); |
| - say(env, "Your contact email is shared with members β useful if someone's locked out, or something needs a human. It's optional."); |
| - askEmail(env, "How can people here reach you outside the chat? Reply with an email address, or skip it."); |
| -} |
| - |
| -// One bubble per attempt: a rejected address re-asks in place, not the whole |
| -// question again. |
| -function askEmail(env, text) { |
| - const go = onceStep(); |
| - say(env, text, [advance(go, "Skip for now", () => stepPhoto(env), "Skipped")], { hint: "Type a contact email" }); |
| - ask(env, go, async (email) => { |
| - const result = await setupBridge().setContactEmail(email); |
| - if (result !== "ok") { |
| - return askEmail( |
| - env, |
| - result === "invalid" |
| - ? "That doesn't look like an email address β try again, or skip it." |
| - : "Something went wrong saving that β try again in a moment, or skip it.", |
| - ); |
| - } |
| - await env.record("email", `noted a contact email`, email); |
| - stepPhoto(env); |
| - }); |
| -} |
| - |
| -function stepPhoto(env) { |
| - if (env.done("photo")) return stepInvites(env); |
| - say(env, "Want to give this space a face? The photo shows on the welcome page and when people join β you can always change it later."); |
| - askPhoto(env, "Send me a photo β tap the + next to the message box, or just drag a picture into the chat, and hit send. Type a message with it if you want to include alt text."); |
| -} |
| - |
| -// The photo arrives THROUGH THE CHAT, like sending any picture: the next |
| -// message with an image attachment is the answer (it's an ordinary encrypted |
| -// attachment; the host re-posts its bytes as the plaintext branding photo). |
| -function askPhoto(env, prompt) { |
| - const go = onceStep(); |
| - // Skipping must disarm the photo waiter, or the next thing typed (steps from |
| - // here on are chip-only) would still be treated as a photo attempt. |
| - say( |
| - env, |
| - prompt, |
| - [ |
| - advance( |
| - go, |
| - "Skip for now", |
| - () => { |
| - cancelAnswer(env.threadId); |
| - stepInvites(env); |
| - }, |
| - "Skipped", |
| - ), |
| - ], |
| - { attachHint: "Optional: type alt text for the 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."); |
| - return arm(); |
| - } |
| - // The adopt runs INSIDE the step guard: if Skip won the race, the sent |
| - // photo is never installed as branding behind the admin's back. |
| - go(async () => { |
| - // Any text sent along with the photo becomes its alt text (screen |
| - // readers, and anywhere the image can't render) β the preview step |
| - // shows it back verbatim before it's kept, because it's published as |
| - // PLAINTEXT branding, unlike the chat message it arrived in. |
| - env.photoAlt = (text || "").trim(); |
| - const ok = await setupBridge().adoptServerPhoto(env.threadId, img, env.photoAlt); |
| - if (!ok) { |
| - say(env, "Hmm, I couldn't use that one β try another?"); |
| - return askPhoto(env, "Send another photo when you're ready, or skip it."); |
| - } |
| - 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. The alt text is echoed |
| -// VERBATIM with a plain warning: it left the encrypted chat and is now public |
| -// branding β the admin must see exactly what got published before keeping it. |
| -function stepPhotoPreview(env) { |
| - const go = onceStep(); |
| - say( |
| - env, |
| - "Here's how it looks." + |
| - (env.photoAlt |
| - ? `\n\nIts alt text is the message you sent β β${env.photoAlt}β β and like the photo itself it's PUBLIC: anyone with an invite link can see both.` |
| - : "") + |
| - "\n\nHappy with it, or want to try another?", |
| - [ |
| - advance( |
| - go, |
| - "Keep this photo", |
| - async () => { |
| - await env.record("photo", "set the space photo", env.photoAlt || ""); |
| - stepInvites(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 alt text. π·")), |
| - ], |
| - { img: "/api/server-avatar?t=" + Date.now(), imgAlt: env.photoAlt || "The space photo", local: true }, |
| - ); |
| -} |
| - |
| -function stepInvites(env) { |
| - if (env.done("invites")) return stepKeys(env); |
| - const go = onceStep(); |
| - say( |
| - env, |
| - "Who gets to bring people in? You can always invite people. The question is whether members can too, or whether new faces only arrive through you.", |
| - [ |
| - advance(go, "Members can invite people", async () => { |
| - await setupBridge().setUserInvites(true); |
| - await env.record("invites", "decided members can invite people", "members"); |
| - stepKeys(env); |
| - }), |
| - advance(go, "Only me", async () => { |
| - await setupBridge().setUserInvites(false); |
| - await env.record("invites", "decided only the admin invites people", "admin only"); |
| - stepKeys(env); |
| - }), |
| - ], |
| - ); |
| -} |
| - |
| -function stepKeys(env) { |
| - const bridge = setupBridge(); |
| - if (bridge.enrolled()) { |
| - if (!env.done("keys")) { |
| - env.record("keys", "Saved your credentials securely with a passkey", "passkey"); |
| - say(env, "Your key is safely backed up behind your passkey. β "); |
| - } |
| - return stepFinish(env, true); |
| - } |
| - const go = onceStep(); |
| - say( |
| - env, |
| - "Now the most important thing of all. π\n\n" + |
| - "Everything here is locked with a key that exists only on your devices. There's no password reset and no support desk β not even whoever runs the server can read your messages or recover that key. That's not a flaw; it's the whole point.\n\n" + |
| - "But it means: right now, this browser holds the only key to your space. If it's lost β cleared, broken, stolen β your space is gone with it, and nobody can get it back.", |
| - ); |
| - say( |
| - env, |
| - "Shall we save a copy behind a passkey now? One tap β you'll be brought straight back.", |
| - [ |
| - { |
| - label: "Save a passkey (recommended)", |
| - stale: () => go.taken, |
| - run: () => { |
| - // On the web this navigates away and setup RESUMES here on return. |
| - // In the native app the ceremony opens in the system browser and this |
| - // page stays put β so if we're still here in a moment, offer the way |
| - // to continue once they're done over there. |
| - bridge.savePasskey(); |
| - setTimeout(() => { |
| - if (!setupBridge().enrolled()) go(() => stepKeysWaiting(env)); |
| - }, 1500); |
| - }, |
| - }, |
| - advance(go, "Not now", () => stepKeysConfirm(env), "Skipped"), |
| - ], |
| - ); |
| -} |
| - |
| -// The stayed-in-place passkey path (native / system browser): wait for them to |
| -// finish elsewhere, then continue on their say-so. When the ceremony happened in |
| -// THIS browser we can verify; when it happened in another, we can't β take their |
| -// word, and the sidebar reminder stays honest either way (it reads real state). |
| -function stepKeysWaiting(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 credentials securely with a passkey" |
| - : "Saved your credentials with a passkey (in your browser)"; |
| - await env.record("keys", label, "passkey"); |
| - stepFinish(env, true); |
| - }), |
| - advance( |
| - go, |
| - "It didn't work β skip for now", |
| - async () => { |
| - await env.record("keys", "Skipped the credentials backup for now", "skipped"); |
| - stepFinish(env, false); |
| - }, |
| - "Skipped", |
| - ), |
| - ]); |
| -} |
| - |
| -function stepKeysConfirm(env) { |
| - const go = onceStep(); |
| - say( |
| - env, |
| - "I have to be straight with you: without a backup, one lost or cleared browser means this space and everything in it is gone β permanently, for everyone, and no one can help. I'll remind you again in a day or so.", |
| - [ |
| - { |
| - label: "Go back and save it", |
| - stale: () => go.taken, |
| - run: () => { |
| - setupBridge().savePasskey(); |
| - setTimeout(() => { |
| - if (!setupBridge().enrolled()) go(() => stepKeysWaiting(env)); |
| - }, 1500); |
| - }, |
| - }, |
| - advance( |
| - go, |
| - "I understand the risk", |
| - async () => { |
| - await env.record("keys", "Skipped the credentials backup for now", "skipped"); |
| - stepFinish(env, false); |
| - }, |
| - "Understood", |
| - ), |
| - ], |
| - ); |
| -} |
| - |
| -async function stepFinish(env, backedUp) { |
| - const bridge = setupBridge(); |
| - // A skipped key backup hands the reminding over to the sidebar banner β on |
| - // the gentle escalating schedule, not immediately (Eleven just said its piece). |
| - if (!backedUp && bridge.deferBackupReminders) bridge.deferBackupReminders(); |
| - await bridge.markSetupComplete(); |
| - const name = (env.space && env.space.name) || "your space"; |
| - say( |
| - env, |
| - `That's it β ${name} is ready! π\n\n` + |
| - `Everything we just set up lives in your Admin panel β invite links, the space's name and photo, who can invite. Type \`/admin\` anywhere to open it; it's yours alone as this space's admin.` + |
| - (backedUp ? "" : `\n\nAnd do come back to that key backup soon.`), |
| - ); |
| - say(env, "Where would you like to start?", [ |
| - { label: "Open admin", run: () => openAppThread("admin") }, |
| - { label: "Take the tour", run: () => startTour(env.respond, env.typing) }, |
| - ]); |
| - running.delete(env.threadId); |
| + .finally(() => runFlow(SETUP_FLOW, env, io)); |
| } |