eleven / messenger Public

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>
Paul Campbell fe4fcceefacc1fc3b6bd810859ec95f4f0574c34 parent 13c7b91
6 files changed, +861 −344
  • docs/conversation-scripts.md +5 −1
  • package.json +1 −1
  • test/flow.test.mjs +240 −0
  • web/static/apps/flow.js +281 −0
  • web/static/apps/setup.flow.js +272 −0
  • web/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));
}