eleven / ocho Public

web: welcome conversation becomes data โ€” schema v1 frozen against reality (#286)

Phase 1 of docs/conversation-scripts.md completes: the joiner welcome joins
setup as pure data (welcome.flow.js) on the shared engine, so both shipped
conversations are now flow documents โ€” the schema's own definition of done.
welcome.js keeps the app shell and becomes the host binding (closed verb
table incl. adoptProfilePhoto's nokey tri-state, openPendingThread, the
32-char name cap enforced in the verb so the suggested-name chip and typed
path can't diverge).

Engine grew exactly what welcome forced, nothing more: {all}/{any} compound
conditions and done.X cross-step lookups (the passkey-return resume check),
'auto' advance for the bubble-only greeting step, async imgSource (the
profile photo decrypts to an object URL; falsy drops the image and keeps the
bubble), and chip sets landing before chip effects (the suggested-name chip
sets the answer its effect reads).

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