web,home: create-from-home tail — same-tab setup, auto-detected return, straight-in tour (#384)
* web,home: the create-from-home tail — same-tab setup, auto-detected return, straight-in tour Three snags in the home → create-a-space → set-up journey: web: "Set up your space" opened the setup link in a new tab. You're heading there next and coming back to your list afterwards, so keep it in the same tab. web: after saving your key mid-setup (the passkey ceremony redirects out to the home server and back), Eleven sometimes sat on the key step waiting for a manual "Continue" instead of noticing you were back. Two fixes: setup.js now forwards `fromHome` into the flow's bridge (the keys-home variant was unreachable without it), and the flow engine gains a `settle` primitive — a step can wait for a condition to flip true on its own (an async enrolment return) and advance itself, no tap. Wired onto the setup keys/keys-home steps and the welcome access step. web: the "Take the tour" chip at the end of setup/welcome ran the tour's own "Hi, fancy a tour? / Show me around" greeting — redundant when you just tapped "Take the tour". startTour gains a skipIntro option that drops straight in; a cold `/tutorial` still opens with the greeting. Tests: flow engine settle + from-home routing; tutorial skipIntro; refreshed the two stale probe-index assertions and the retired "Save a passkey" chip label. Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> * docs: changelog — onboarding passkey-return + straight-in tour Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 (1M context) <noreply@anthropic.com>
10 files changed,
+114
−17
changelog/2026-07-08-onboarding-return-and-tour.md+5 −0home/web/static/home.js+3 −2test/flow.test.mjs+39 −8test/tutorial.test.mjs+12 −0web/static/apps/flow.js+17 −0web/static/apps/setup.flow.js+6 −0web/static/apps/setup.js+13 −2web/static/apps/tutorial.js+11 −4web/static/apps/welcome.flow.js+4 −0web/static/apps/welcome.js+4 −1
diff --git a/changelog/2026-07-08-onboarding-return-and-tour.md b/changelog/2026-07-08-onboarding-return-and-tour.md| new file mode 100644 |
| index 0000000..6fff40a |
| --- /dev/null |
| +++ b/changelog/2026-07-08-onboarding-return-and-tour.md |
| @@ -0,0 +1,5 @@ |
| +🤖 Two little rough edges smoothed when you're getting started. When you save |
| +your access behind a passkey, Eleven now notices the moment you're back from the |
| +passkey prompt and carries straight on — no "Continue" tap to nudge it. And when |
| +you tap "Take the tour" at the end, it drops you straight into the tour instead |
| +of asking a second time whether you'd like one. |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 64585ea..b373e91 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -513,8 +513,9 @@ function showNewSpaceResult(host, setupLink) { |
| // pitching passkeys from scratch. Query param, not fragment: the secret |
| // stays alone in the fragment. (Query never carries anything sensitive.) |
| a.href = setupLink.replace("/join/setup#", "/join/setup?src=home#"); |
| - a.target = "_blank"; |
| - a.rel = "noopener"; |
| + // Same tab, not a new one: setting up a space is where you're headed next, |
| + // and you'll come back here (to your list) once it's done — no reason to |
| + // strand this page behind a second tab. |
| a.textContent = "Set up your space →"; |
| const note = document.createElement("p"); |
| note.className = "sub"; |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index e746824..fae2d68 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -13,7 +13,7 @@ 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 = "", envExtra = {} } = {}) { |
| +function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, myName = "", envExtra = {} } = {}) { |
| const h = { |
| transcript: [], // { text, chips: [labels], extra } |
| verbLog: [], |
| @@ -24,6 +24,7 @@ function harness({ verbs = {}, done = {}, enrolled = false, myName = "", envExtr |
| pendingDelays: [], |
| doneSteps: { ...done }, |
| enrolled, |
| + fromHome, |
| myName, |
| }; |
| h.env = { |
| @@ -31,7 +32,7 @@ function harness({ verbs = {}, done = {}, enrolled = false, myName = "", envExtr |
| respond: () => {}, |
| typing: null, |
| sender: { name: "Eleven", kind: "bot" }, |
| - bridge: { myName: () => h.myName, enrolled: () => h.enrolled }, |
| + bridge: { myName: () => h.myName, enrolled: () => h.enrolled, fromHome: () => h.fromHome }, |
| space: { name: "Old Name", nameSet: false }, |
| onEnd: () => (h.ended = true), |
| }; |
| @@ -154,12 +155,14 @@ await test("setup: the full first-run transcript, happy path with detours", asyn |
| 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)"); |
| + await h.tap("Sign in"); |
| 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(); |
| + // The probe fires with no enrolment → the stayed-in-place waiting step. (The |
| + // step also arms a background settle poll; target the probe by its interval.) |
| + const keysProbe = h.pendingDelays.find((d) => d.ms === 1500); |
| + assert.ok(keysProbe, "the Sign in probe schedules a 1500ms check"); |
| + keysProbe.fn(); |
| await tick(); |
| assert.match(h.lastText(), /I'll wait here while you finish/); |
| @@ -233,6 +236,32 @@ await test("setup: already enrolled — keys short-circuits with the once-only a |
| assert.equal(h2.transcript.some((t) => /safely backed up/.test(t.text)), false); |
| }); |
| +await test("setup: from-home key step + settle auto-detects the enrolment return", async () => { |
| + const h = harness({ |
| + myName: "Paul", |
| + fromHome: true, // arrived from the Eleven sign-in page → the keys-home variant |
| + done: { name: true, group: true, email: true, photo: true, invites: true }, |
| + }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + |
| + // fromHome routes to keys-home ("add to your passkey"), not the from-scratch pitch. |
| + assert.ok(h.transcript.find((t) => /Last thing — the key/.test(t.text)), "keys-home shown"); |
| + assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false); |
| + |
| + // The ceremony returns asynchronously: enrolment lands, and the settle poll — |
| + // NOT a manual "Continue" tap — carries the flow on to finish. |
| + const settle = h.pendingDelays.find((d) => d.ms === 700); |
| + assert.ok(settle, "keys-home arms a settle poll while it waits"); |
| + h.enrolled = true; |
| + settle.fn(); |
| + await tick(); |
| + await tick(); |
| + assert.deepEqual(h.records.at(-1), { step: "keys-home", 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.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"), "reached finish without a tap"); |
| +}); |
| + |
| 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"); |
| @@ -271,9 +300,11 @@ await test("welcome: invited fresh join — passkey probe, suggested name, photo |
| assert.match(h.transcript[0].text, /locked with keys only members hold/); |
| // Passkey first; the ceremony stays put → probe routes to the waiting step. |
| + // (The step also arms a background settle poll; target the probe by interval.) |
| await h.tap("Save my access (recommended)"); |
| - assert.equal(h.pendingDelays[0].ms, 1500); |
| - h.pendingDelays[0].fn(); |
| + const accessProbe = h.pendingDelays.find((d) => d.ms === 1500); |
| + assert.ok(accessProbe, "the Save-my-access probe schedules a 1500ms check"); |
| + accessProbe.fn(); |
| await tick(); |
| assert.match(h.lastText(), /I'll wait here while you finish/); |
| h.enrolled = true; |
diff --git a/test/tutorial.test.mjs b/test/tutorial.test.mjs| index f136e70..77746fb 100644 |
| --- a/test/tutorial.test.mjs |
| +++ b/test/tutorial.test.mjs |
| @@ -45,6 +45,18 @@ await test("every step is attributed to Eleven and the tour ends with a 🎉", a |
| assert.match(steps[steps.length - 1].text, /🎉/); // celebratory ending |
| }); |
| +await test("skipIntro jumps past the 'fancy a tour?' greeting straight into the tour", async () => { |
| + setDefaultBot(() => null); // no Eleven to DM → the tour renders in place |
| + const steps = []; |
| + startTour((spec) => steps.push(spec), null, { skipIntro: true }); |
| + await tick(); // spaceInfo().finally → toElevenDm(elevenTour resolves null) |
| + await tick(); // → the first real step |
| + assert.ok(steps.length >= 1, "a step rendered"); |
| + assert.doesNotMatch(steps[0].text, /Fancy a quick tour/); // the greeting is skipped |
| + assert.doesNotMatch(steps[0].text, /your guide to this app/); // ...and its intro line |
| + assert.match(steps[0].text, /You're in /); // it opens on the space step instead |
| +}); |
| + |
| await test("the default path skips the security deep-dive; picking it shows it", async () => { |
| setDefaultBot(() => null); |
| const plain = textOf(await walk()); // taps "No thanks" at the privacy step |
diff --git a/web/static/apps/flow.js b/web/static/apps/flow.js| index cc106cd..75b3f27 100644 |
| --- a/web/static/apps/flow.js |
| +++ b/web/static/apps/flow.js |
| @@ -280,6 +280,23 @@ export function runFlow(flow, env, io) { |
| io.say(env, "", buildChips(stepCtx, stepCtx.go), {}); |
| } |
| armInput(stepCtx); |
| + // A step may WAIT for a condition to become true on its own — the passkey |
| + // ceremony returns asynchronously (a redirect back, or the user tabbing back |
| + // from a system browser), so `bridge.enrolled` can flip true seconds after |
| + // we've shown the pitch. Poll it while the step is on screen and, the moment |
| + // it holds, re-enter the step so its entries fire (record the backup, advance) |
| + // — no manual "Continue" tap. The shared onceStep guard means a chip tap still |
| + // wins and stops the poll. |
| + if (step.settle && !evalCond(env, step.settle.when)) { |
| + const go = stepCtx.go; |
| + const period = step.settle.everyMs || 700; |
| + const tick = () => { |
| + if (go.taken) return; // a chip/advance already took the step |
| + if (evalCond(env, step.settle.when)) return go(() => enter(step.settle.goto || step.id)); |
| + io.delay(period, tick); |
| + }; |
| + io.delay(period, tick); |
| + } |
| // 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.) |
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js| index ff3d1b1..c9f770e 100644 |
| --- a/web/static/apps/setup.flow.js |
| +++ b/web/static/apps/setup.flow.js |
| @@ -184,6 +184,9 @@ export default { |
| // this space's key to theirs (keys-home). |
| { if: "bridge.fromHome", goto: "keys-home" }, |
| ], |
| + // The passkey ceremony returns asynchronously (a redirect back from the |
| + // home server); auto-detect that return and advance without a manual tap. |
| + settle: { when: "bridge.enrolled" }, |
| bubbles: [ |
| { |
| text: |
| @@ -218,6 +221,9 @@ export default { |
| goto: "finish", |
| }, |
| ], |
| + // Auto-detect the return from the home server's passkey ceremony (same as |
| + // the keys step above) so the user is carried on without tapping Continue. |
| + settle: { when: "bridge.enrolled" }, |
| bubbles: [ |
| { |
| text: |
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js| index 3ada898..8d55b79 100644 |
| --- a/web/static/apps/setup.js |
| +++ b/web/static/apps/setup.js |
| @@ -80,7 +80,10 @@ function verb(name, args, env) { |
| case "openAdmin": |
| return openAppThread("admin"); |
| case "startTour": |
| - return startTour(env.respond, env.typing); |
| + // Reached by tapping "Take the tour" at the end of setup — the user has |
| + // just spent minutes with Eleven, so skip the tour's own "Hi, fancy a |
| + // tour?" intro and drop straight into it. |
| + return startTour(env.respond, env.typing, { skipIntro: true }); |
| default: |
| throw new Error(`setup: unknown verb ${name}`); |
| } |
| @@ -94,7 +97,15 @@ export function startSetup(env) { |
| if (!bridge || running.has(env.threadId)) return; |
| running.add(env.threadId); |
| env.sender = { name: "Eleven", kind: "bot" }; |
| - env.bridge = { myName: () => setupBridge().myName(), enrolled: () => setupBridge().enrolled() }; |
| + env.bridge = { |
| + myName: () => setupBridge().myName(), |
| + enrolled: () => setupBridge().enrolled(), |
| + // Did this admin arrive from their Eleven sign-in page (home)? If so the key |
| + // step offers to ADD this space to their existing passkey (keys-home) rather |
| + // than pitching passkeys from scratch. Must be forwarded here — the flow's |
| + // bridge.* only sees what env.bridge exposes. |
| + fromHome: () => setupBridge().fromHome(), |
| + }; |
| env.onEnd = () => running.delete(env.threadId); |
| const io = { |
| say, |
diff --git a/web/static/apps/tutorial.js b/web/static/apps/tutorial.js| index d7ef9f8..68faec6 100644 |
| --- a/web/static/apps/tutorial.js |
| +++ b/web/static/apps/tutorial.js |
| @@ -22,9 +22,13 @@ registerFloorCommand("/tutorial", { |
| run: (_args, _threadId, reply) => startTour(reply), |
| }); |
| -// startTour(respond): respond(spec) appends one Eleven notice to the current feed. |
| -// Both entry points supply their own respond bound to the thread you're in. |
| -export function startTour(respond, typing) { |
| +// startTour(respond, typing, opts): respond(spec) appends one Eleven notice to |
| +// the current feed. Both entry points supply their own respond bound to the |
| +// thread you're in. opts.skipIntro jumps past the tour's own "Hi, fancy a tour?" |
| +// greeting straight into it — used when a conversation's "Take the tour" chip |
| +// already asked (the user has just been talking to Eleven; a fresh /tutorial |
| +// keeps the greeting). |
| +export function startTour(respond, typing, opts = {}) { |
| const bot = defaultBot(); |
| // With a typing hook (the setup/welcome conversations pass theirs), each tour |
| // reply gets a short beat first — same voice, same rhythm as Eleven's script. |
| @@ -52,12 +56,15 @@ export function startTour(respond, typing) { |
| }; |
| // Fetch the space name + administrator identity, then open — but never block the |
| // tour on it: if the read fails, we degrade to a nameless "this space". |
| + // skipIntro drops straight into the tour proper (in the Eleven DM, exactly as |
| + // the greeting's "Show me around" would have); otherwise it opens with the |
| + // greeting. |
| spaceInfo() |
| .then((info) => { |
| env.space = info || {}; |
| }) |
| .catch(() => {}) |
| - .finally(() => stepWelcome(env)); |
| + .finally(() => (opts.skipIntro ? toElevenDm(env, () => stepSpace(env)) : stepWelcome(env))); |
| } |
| // A per-step guard: the first advancing tap wins, later taps are ignored. The |
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js| index 987fedd..c4f4f2c 100644 |
| --- a/web/static/apps/welcome.flow.js |
| +++ b/web/static/apps/welcome.flow.js |
| @@ -47,6 +47,10 @@ export default { |
| goto: "name", |
| }, |
| ], |
| + // The passkey ceremony returns asynchronously (a redirect back, or tabbing |
| + // back from a system browser); auto-detect that return and carry on without |
| + // a manual "Continue" tap. |
| + settle: { when: "bridge.enrolled" }, |
| // One bubble, chips attached — context and ask together, so the opening |
| // run is two messages (intro + this), not three. |
| bubbles: [ |
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js| index 4f1e7a8..1810244 100644 |
| --- a/web/static/apps/welcome.js |
| +++ b/web/static/apps/welcome.js |
| @@ -73,7 +73,10 @@ function makeVerb(env) { |
| case "openPendingThread": |
| return env.openPendingThread && env.openPendingThread(); |
| case "startTour": |
| - return startTour(env.respond, env.typing); |
| + // Reached by "Take the tour" at the end of the welcome — the user has |
| + // just been talking to Eleven, so skip the tour's own "fancy a tour?" |
| + // greeting and go straight in. |
| + return startTour(env.respond, env.typing, { skipIntro: true }); |
| default: |
| throw new Error(`welcome: unknown verb ${name}`); |
| } |