eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// The first-run setup conversation as DATA (docs/conversation-scripts.md) โ€”
2// every bubble, chip, branch, and side effect of the flow Eleven walks a fresh
3// instance's admin through. The engine (flow.js) interprets it; setup.js binds
4// it to the host bridge. This object must stay JSON-pure (a test enforces it):
5// it is the exact document the server will ship to native interpreters.
6export default {
7 schema: 1,
8 flow: "setup",
9 entry: "hello",
10 // Resumed = a step was actually recorded in an earlier session. (A name
11 // alone doesn't count: the native creator arrives with one pre-set from the
12 // create screen, and that first run must still get the greeting.)
13 resumedIf: { any: ["done.name", "done.group", "done.email", "done.photo", "done.invites", "done.extras", "done.keys"] },
14 // The passkey RETURN leg gets its own richer ack in the keys step's entry,
15 // so it skips the generic re-greeting (mirrors welcome.flow.js).
16 resumeUnless: { all: ["bridge.enrolled", "!done.keys"] },
17 resumeSay: "Welcome back โ€” let's pick up where we left off.",
18 steps: [
19 {
20 id: "hello",
21 skipIf: ["resumed"],
22 bubbles: [
23 {
24 text:
25 "Hi โ€” I'm Eleven! ๐Ÿ‘‹ This is your space now: everything in it is end-to-end encrypted, and you're its administrator.\n\n" +
26 "Let's take two minutes to set things up.",
27 },
28 ],
29 auto: "name",
30 },
31 {
32 id: "name",
33 // A device that already knows this member's name (an add-device link
34 // ships it in the fragment; a native shell carries the rail's) must
35 // not skip PAST this step with a bare skipIf: that writes nothing to
36 // the log, so every client that does NOT know the name stays parked
37 // here forever while this one moves on โ€” two composers armed on
38 // different steps of the same thread (the 2026-07-19 web-vs-Mac
39 // divergence). The entry RECORDS the settled name, so done.name folds
40 // to every client and they all derive the same position from the log.
41 entries: [
42 {
43 if: "bridge.myName",
44 recordIfNew: { label: "noted the name you go by", value: "known" },
45 goto: "group",
46 },
47 ],
48 skipIf: ["done"],
49 // Explicit, because the confirm step (name-email) now sits between this
50 // one and "group": a skip falls through in ARRAY order, and landing a
51 // resumed client on a confirmation for an answer it never gave would
52 // park the whole conversation there.
53 skipTo: "group",
54 bubbles: [
55 { text: "First โ€” what should everyone here call you? Reply with your name.", label: "Your name:", hint: "e.g. Finn Wolfhard" },
56 ],
57 // The fast path for admins in a hurry: one form with the whole setup at
58 // once, ending in the same sign-in (the keys step's settle picks the
59 // ceremony's return up from the fast-filled records).
60 chips: [
61 {
62 label: "Fill in a form instead",
63 trailing: true,
64 advancing: false,
65 effect: { verb: "openQuickForm" },
66 },
67 ],
68 // An email address typed here would become the name the whole space
69 // sees, silently. Confirm it first (flow.js input.confirm) โ€” everything
70 // else is accepted as typed, because people are called all sorts.
71 input: { kind: "text", required: true, maxLen: 32, clean: "name", confirm: { when: "email", goto: "name-email" } },
72 effect: { verb: "setMyName", args: ["$answer"] },
73 on: {
74 ok: {
75 record: { label: "noted: you're {vars.answer}", value: "$answer" },
76 ack: "Lovely to meet you, {vars.answer}! ๐Ÿค",
77 goto: "group",
78 },
79 },
80 },
81 {
82 // The confirm step for an email answered as a name. Nothing has been
83 // recorded yet, so both chips are live choices: keeping it runs the
84 // effect + record the name step would have, and changing it re-asks.
85 id: "name-email",
86 bubbles: [
87 {
88 text: "That's an email address โ€” is that what you'd like to be called here? It's the name everyone in this space sees.",
89 key: "q-name-email",
90 },
91 ],
92 chips: [
93 {
94 label: "Use it anyway",
95 spent: "Kept",
96 effect: { verb: "setMyName", args: ["$answer"] },
97 record: { step: "name", label: "noted: you're {vars.answer}", value: "$answer" },
98 ack: "Righto โ€” {vars.answer} it is. ๐Ÿค",
99 goto: "group",
100 },
101 { label: "Type a different name", spent: "Changed", goto: "name" },
102 ],
103 },
104 {
105 id: "group",
106 skipIf: ["done"],
107 bubbles: [
108 {
109 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.",
110 key: "q-group",
111 },
112 {
113 text: {
114 if: "space.nameSet",
115 then: "It's currently โ€œ{space.name}โ€ โ€” keep that, or reply with a new name.",
116 else: "What should we call it? Reply with a name.",
117 },
118 label: "Space name:",
119 hint: "e.g. The Book Club",
120 },
121 ],
122 chips: [{ if: "space.nameSet", label: "Keep โ€œ{space.name}โ€", spent: "Kept", goto: "email" }],
123 input: { kind: "text", required: true },
124 effect: { verb: "setGroupName", args: ["$answer"] },
125 on: {
126 ok: {
127 set: { "space.name": "$answer" },
128 record: { label: "named this space โ€œ{vars.answer}โ€", value: "$answer" },
129 goto: "email",
130 },
131 invalid: { say: "Hmm, that name didn't stick โ€” it needs to be 1โ€“60 characters. Try another?", retry: "step" },
132 error: {
133 say: "Something went wrong saving that โ€” it's not the name's fault. Give it another go in a moment.",
134 retry: "step",
135 },
136 },
137 },
138 {
139 id: "email",
140 skipIf: ["done"],
141 bubbles: [
142 {
143 text: "Your contact email is shared with members โ€” useful if someone's locked out, or something needs a human. It's optional.",
144 },
145 {
146 text: "How can people here reach you outside the chat? Reply with an email address, or skip it.",
147 label: "Contact email:",
148 hint: "e.g. finn@example.com",
149 },
150 ],
151 // The skip RECORDS the step: an unrecorded skip replays on every resume โ€”
152 // notably the passkey return leg, which re-asked the email (and photo)
153 // instead of carrying on. A skip is an answer.
154 chips: [
155 { label: "Skip for now", spent: "Skipped", record: { label: "skipped the contact email", value: "skipped" }, goto: "photo" },
156 ],
157 input: { kind: "text" },
158 effect: { verb: "setContactEmail", args: ["$answer"] },
159 on: {
160 ok: { record: { label: "noted a contact email", value: "$answer" }, goto: "photo" },
161 invalid: { ask: "That doesn't look like an email address โ€” try again, or skip it." },
162 error: { ask: "Something went wrong saving that โ€” try again in a moment, or skip it." },
163 },
164 },
165 {
166 id: "photo",
167 // Native v1: no attachment flow in the conversation composer โ€” the space
168 // photo lives in the Admin app there.
169 skipIf: ["done", "native"],
170 skipTo: "invites",
171 bubbles: [
172 {
173 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.",
174 },
175 {
176 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.",
177 attachHint: "Optional: type alt text for the photo",
178 },
179 ],
180 // Recorded for the same resume reason as the email skip.
181 chips: [
182 { label: "Skip for now", spent: "Skipped", cancelInput: true, record: { label: "skipped the space photo", value: "skipped" }, goto: "invites" },
183 ],
184 input: {
185 kind: "photo",
186 reject: { text: "Send it as a picture โ€” tap the + next to the message box or drag one in โ€” or tap Skip." },
187 },
188 effect: { verb: "adoptServerPhoto" },
189 on: {
190 ok: { set: { "vars.photoAlt": "$answerText" }, goto: "photo-preview" },
191 error: {
192 say: "Hmm, I couldn't use that one โ€” try another?",
193 ask: "Send another photo when you're ready, or skip it.",
194 },
195 },
196 },
197 {
198 id: "photo-preview",
199 bubbles: [
200 {
201 text: [
202 "Here's how it looks.",
203 {
204 if: "vars.photoAlt",
205 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.",
206 },
207 "\n\nHappy with it, or want to try another?",
208 ],
209 img: "$serverAvatar",
210 imgAlt: "{vars.photoAlt|The space photo}",
211 local: true,
212 },
213 ],
214 chips: [
215 {
216 label: "Keep this photo",
217 spent: "Kept",
218 record: { step: "photo", label: "set the space photo", value: "{vars.photoAlt}" },
219 goto: "invites",
220 },
221 {
222 label: "Use a different photo",
223 gotoAsk: {
224 step: "photo",
225 text: "Of course! Send me another one whenever you're ready โ€” and pop a message with it if you'd like alt text. ๐Ÿ“ท",
226 },
227 },
228 ],
229 },
230 {
231 id: "invites",
232 skipIf: ["done"],
233 bubbles: [
234 {
235 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.",
236 },
237 ],
238 chips: [
239 {
240 label: "Members can invite people",
241 effect: { verb: "setUserInvites", args: [true] },
242 record: { label: "decided members can invite people", value: "members" },
243 goto: "extras",
244 },
245 {
246 label: "Only me",
247 effect: { verb: "setUserInvites", args: [false] },
248 record: { label: "decided only the admin invites people", value: "admin only" },
249 goto: "extras",
250 },
251 ],
252 },
253 {
254 // Bots + apps (two of the #712 gates) as ONE question with a one-tap
255 // common path; "Let me choose" opens the granular pair. Defaults are
256 // already on server-side, so keeping both on writes nothing โ€” the
257 // granular chips write the setting either way. AI is deliberately NOT
258 // in this bundle: it gets its own explicit opt-in step below
259 // (docs/ai-provisioning.md โ€” saying yes provisions a real helper, so
260 // it's never a side effect of "keep everything on").
261 id: "extras",
262 skipIf: ["done"],
263 skipTo: "ai-optin", // past the granular pair, which only "Let me choose" enters
264 entries: [
265 // A resume that already finished the granular path: close this out.
266 {
267 if: "done.extras-apps",
268 recordIfNew: { label: "chose this space's extras", value: "chosen" },
269 goto: "ai-optin",
270 },
271 ],
272 bubbles: [
273 {
274 text:
275 "Nearly there. Your space comes with a couple of extras:\n\n" +
276 "โ€ข Bots โ€” little helpers members can create for reminders and jobs.\n" +
277 "โ€ข Apps โ€” to-dos, polls, whiteboards, and a studio for building your own.\n\n" +
278 "Everything essential works with these off, and you can change your mind anytime in Admin.",
279 },
280 ],
281 chips: [
282 {
283 label: "Keep both on",
284 spent: "Kept",
285 record: { label: "kept bots and apps on", value: "all-on" },
286 goto: "ai-optin",
287 },
288 { label: "Let me choose", goto: "extras-bots" },
289 ],
290 },
291 {
292 id: "extras-bots",
293 skipIf: ["done"],
294 bubbles: [
295 { text: "Bots โ€” helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." },
296 ],
297 chips: [
298 {
299 label: "Enable bots",
300 effect: { verb: "setFeature", args: ["bots", true] },
301 record: { label: "enabled bots", value: "on" },
302 goto: "extras-apps",
303 },
304 {
305 label: "No bots here",
306 spent: "Off",
307 effect: { verb: "setFeature", args: ["bots", false] },
308 record: { label: "switched bots off", value: "off" },
309 goto: "extras-apps",
310 },
311 ],
312 },
313 {
314 id: "extras-apps",
315 skipIf: ["done"],
316 bubbles: [
317 { text: "And apps โ€” the built-in extras like to-dos, polls, and whiteboards, plus the App Studio where members build their own." },
318 ],
319 chips: [
320 {
321 label: "Enable apps",
322 effect: { verb: "setFeature", args: ["apps", true] },
323 record: { label: "enabled apps", value: "on" },
324 goto: "ai-optin",
325 },
326 {
327 label: "No apps here",
328 spent: "Off",
329 effect: { verb: "setFeature", args: ["apps", false] },
330 record: { label: "switched apps off", value: "off" },
331 goto: "ai-optin",
332 },
333 ],
334 },
335 {
336 // AI is opt-in with the honest note (docs/ai-provisioning.md): saying
337 // yes flips ai_enabled on, and on a cloud space the server asks the
338 // provisioner for the sidecar โ€” Eleven joins moments later. Declining
339 // writes an explicit off. Either way the choice is the admin's, never
340 // a default's.
341 id: "ai-optin",
342 skipIf: ["done"],
343 bubbles: [
344 {
345 text:
346 "Last choice โ€” AI. Say yes and Eleven, the built-in helper, joins this space: members can ask it things in plain language and it answers, builds, and helps out.\n\n" +
347 "An honest note before you decide: Eleven's AI runs on Eleven Messenger's machines, and it can read the chats it's invited to โ€” that's what lets it help. It never sees chats it isn't in, and you can switch it off anytime in Admin.",
348 },
349 ],
350 chips: [
351 {
352 label: "Bring Eleven in",
353 spent: "On",
354 effect: { verb: "setFeature", args: ["ai", true] },
355 record: { label: "invited Eleven's AI in", value: "on" },
356 goto: "plan",
357 },
358 {
359 label: "Not now",
360 spent: "Off",
361 effect: { verb: "setFeature", args: ["ai", false] },
362 record: { label: "kept AI off", value: "off" },
363 goto: "plan",
364 },
365 ],
366 },
367 {
368 // The plan question, moved HERE from the create flow (#1374): creating
369 // a space is never pay-gated โ€” the conversation is where "who is this
370 // space for?" gets asked, with real prices from the price book, and a
371 // paid answer becomes an upgrade chip on the finish line (the space
372 // already exists, free, and stays free until that tap). Web-only, like
373 // the photo: native wrappers must not surface an external purchase
374 // (App Store rules โ€” the same gate as the โ‹ฎ Upgrade item), and with no
375 // reachable price book (self-hosted, billing off, already paid) the
376 // step vanishes. Whichever client asks, the record lands on THIS step.
377 id: "plan",
378 skipIf: ["done", "native"],
379 skipTo: "keys",
380 entries: [{ if: "!bridge.planPrices", goto: "keys" }],
381 bubbles: [
382 {
383 text:
384 "One more question โ€” who is this space for?\n\n" +
385 "Every space starts free, and free is a real home โ€” kept that way by the spaces that pay. " +
386 "If this one's for a team, a community, or family, there's a plan that backs it (and the free spaces too).",
387 key: "q-plan",
388 },
389 ],
390 chips: [
391 { if: "bridge.planTeam", label: "A team", goto: "plan-team" },
392 { if: "bridge.planCommunity", label: "A community", goto: "plan-community" },
393 { if: "bridge.planFamily", label: "Family & friends", goto: "plan-family" },
394 {
395 label: "Keep it free",
396 spent: "Free",
397 trailing: true,
398 record: { label: "kept this space free", value: "free" },
399 goto: "keys",
400 },
401 ],
402 },
403 {
404 // The three per-audience price cards. Names and prices interpolate from
405 // the price book (config, never code โ€” docs/tier-economics.md); the
406 // record's value is the AUDIENCE, which openPlanCheckout maps back to
407 // the book's tier key at tap time.
408 id: "plan-team",
409 bubbles: [
410 {
411 text:
412 "For a team that's {bridge.planNameTeam}: {bridge.planPriceTeam} โ€” it scales with the team, and bots are free.\n\n" +
413 "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
414 key: "q-plan-team",
415 },
416 ],
417 chips: [
418 {
419 label: "Choose {bridge.planNameTeam}",
420 spent: "Chosen",
421 set: { "vars.planTier": "team" },
422 record: { step: "plan", label: "chose the {bridge.planNameTeam} plan โ€” payment waits at the finish line", value: "team" },
423 goto: "keys",
424 },
425 { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
426 ],
427 },
428 {
429 id: "plan-community",
430 bubbles: [
431 {
432 text:
433 "For a community that's {bridge.planNameCommunity}: {bridge.planPriceCommunity}.\n\n" +
434 "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
435 key: "q-plan-community",
436 },
437 ],
438 chips: [
439 {
440 label: "Choose {bridge.planNameCommunity}",
441 spent: "Chosen",
442 set: { "vars.planTier": "community" },
443 record: { step: "plan", label: "chose the {bridge.planNameCommunity} plan โ€” payment waits at the finish line", value: "community" },
444 goto: "keys",
445 },
446 { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
447 ],
448 },
449 {
450 id: "plan-family",
451 bubbles: [
452 {
453 text:
454 "For family and friends that's {bridge.planNameFamily}: {bridge.planPriceFamily}.\n\n" +
455 "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
456 key: "q-plan-family",
457 },
458 ],
459 chips: [
460 {
461 label: "Choose {bridge.planNameFamily}",
462 spent: "Chosen",
463 set: { "vars.planTier": "family" },
464 record: { step: "plan", label: "chose the {bridge.planNameFamily} plan โ€” payment waits at the finish line", value: "family" },
465 goto: "keys",
466 },
467 { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
468 ],
469 },
470 {
471 id: "keys",
472 entries: [
473 {
474 // Native shells (iOS/Mac) WITH a live Eleven session: claiming a
475 // space pushes it โ€” key included โ€” straight into that account's
476 // vault (addInstance โ†’ HomeSync.pushSoon), so the pitch is moot.
477 if: { all: ["native", "bridge.homeSignedIn"] },
478 set: { "vars.backedUp": true },
479 recordIfNew: { label: "Your space's key is saved with your Eleven account", value: "native" },
480 goto: "finish",
481 },
482 {
483 if: "bridge.enrolled",
484 set: { "vars.backedUp": true },
485 recordIfNew: { label: "Signed in โ€” your space's key is safe", value: "passkey" },
486 sayIfNew: "You're signed in โ€” your space's key is safe. โœ…",
487 goto: "finish",
488 },
489 // Answered in an earlier session, but this browser isn't enrolled
490 // ("Continue" after a detour, "I understand the risk", a lost local
491 // flag): the step is DONE โ€” never re-pitch it. Without this entry the
492 // flow re-asked on every resume and never reached finish, so
493 // markSetupComplete never ran (#1377). backedUp stays false, so the
494 // finish line still carries the honest reminder.
495 { if: "done", goto: "finish" },
496 // Nowhere to sign in: a self-hosted space with no home server (#1377),
497 // or a native host that wired no sign-in. The pitch would be a
498 // dead-end chip โ€” record where the key actually is and move on.
499 {
500 if: "!bridge.canEnrol",
501 recordIfNew: {
502 label: { if: "native", then: "This device holds this space's key", else: "This browser holds this space's key" },
503 value: "local",
504 },
505 goto: "finish",
506 },
507 // Someone who created this space from their Eleven sign-in page already
508 // holds an account โ€” don't pitch signing in from scratch, offer to add
509 // this space's key to it (keys-home).
510 { if: "bridge.fromHome", goto: "keys-home" },
511 ],
512 // The sign-in returns asynchronously โ€” a redirect back from the home
513 // server on web, a sheet or Settings window on native โ€” so watch both
514 // signals and advance without a manual tap.
515 settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] } },
516 // Member-facing language is "sign in" โ€” never the mechanism (passkeys,
517 // backups). The lock-and-key framing carries the stakes instead.
518 bubbles: [
519 {
520 // Segments, so only the noun changes between surfaces: a native app
521 // holds the key on the DEVICE, a browser in the browser.
522 text: [
523 "Now the most important thing of all. ๐Ÿ”‘\n\n",
524 "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",
525 "But it means: right now, ",
526 { if: "native", text: "this device holds" },
527 { if: "!native", text: "this browser holds" },
528 " 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.",
529 ],
530 },
531 {
532 text: {
533 if: "native",
534 then: "Sign in with Eleven and your key stays safe โ€” it's saved to your account, and every device you sign in on can open this space.",
535 else: "Sign in with Eleven and your key stays safe โ€” one tap, your face or fingerprint, and you'll be brought straight back.",
536 },
537 },
538 ],
539 chips: [
540 {
541 label: "Sign in",
542 advancing: false,
543 effect: { verb: "savePasskey" },
544 probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" },
545 },
546 { label: "Not now", spent: "Skipped", goto: "keys-confirm" },
547 ],
548 },
549 {
550 // The from-home variant of "keys": they signed in with Eleven to create
551 // this space, so the ask is one addition, not a new concept. Mechanics
552 // are identical (the enrol popup signs into their existing account and
553 // merges this space's key + name over the address-only stub).
554 id: "keys-home",
555 entries: [
556 {
557 if: "bridge.enrolled",
558 set: { "vars.backedUp": true },
559 recordIfNew: { label: "Signed in โ€” your space's key is safe", value: "passkey" },
560 sayIfNew: "You're signed in โ€” your space's key is safe. โœ…",
561 goto: "finish",
562 },
563 ],
564 // Auto-detect the return from the home server's sign-in ceremony (same as
565 // the keys step above) so the user is carried on without tapping Continue.
566 settle: { when: "bridge.enrolled" },
567 bubbles: [
568 {
569 text:
570 "Last thing โ€” the key. ๐Ÿ”‘\n\n" +
571 "This space is locked with a key that was just created here, in this browser. Your Eleven sign-in doesn't hold it yet โ€” so if this browser is lost, the space goes with it.",
572 },
573 { text: "Sign in and it's added to your account โ€” one tap, and you'll be brought straight back." },
574 ],
575 chips: [
576 {
577 label: "Sign in",
578 advancing: false,
579 effect: { verb: "savePasskey" },
580 probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" },
581 },
582 { label: "Not now", spent: "Skipped", goto: "keys-confirm" },
583 ],
584 },
585 {
586 id: "keys-waiting",
587 // The ceremony may confirm while we sit here (the completion poll flips
588 // the flag) โ€” re-enter keys so its entry records + acks, no tap needed.
589 settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys" },
590 bubbles: [{ text: "I'll wait here while you finish signing in. When you're done, tap Continue." }],
591 chips: [
592 {
593 label: "Continue",
594 record: {
595 step: "keys",
596 label: {
597 if: { any: ["bridge.enrolled", "bridge.homeSignedIn"] },
598 then: "Signed in โ€” your space's key is safe",
599 else: "Signed in (in this browser)",
600 },
601 value: "passkey",
602 },
603 set: { "vars.backedUp": true },
604 goto: "finish",
605 },
606 {
607 label: "It didn't work โ€” skip for now",
608 spent: "Skipped",
609 record: { step: "keys", label: "Skipped signing in for now", value: "skipped" },
610 set: { "vars.backedUp": false },
611 goto: "finish",
612 },
613 ],
614 },
615 {
616 id: "keys-confirm",
617 // Same late-confirmation catch as keys-waiting: a ceremony finished in
618 // another context can land while the warning is up โ€” advance over it.
619 settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys" },
620 bubbles: [
621 {
622 text: "I have to be straight with you: if you don't sign in, 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.",
623 },
624 ],
625 chips: [
626 {
627 label: "Go back and sign in",
628 advancing: false,
629 effect: { verb: "savePasskey" },
630 probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" },
631 },
632 {
633 label: "I understand the risk",
634 spent: "Understood",
635 record: { step: "keys", label: "Skipped signing in for now", value: "skipped" },
636 set: { "vars.backedUp": false },
637 goto: "finish",
638 },
639 ],
640 },
641 {
642 id: "finish",
643 // The completion marker: hosts without the server flag's context (the
644 // native embed gates on conversation state, not setup_complete โ€” native
645 // sets that eagerly at claim as the reaper's not-abandoned signal) read
646 // this recorded step to know the conversation is over.
647 record: { label: "Your space is ready", value: "done" },
648 effects: [{ if: "!vars.backedUp", verb: "deferBackupReminders" }, { verb: "markSetupComplete" }],
649 bubbles: [
650 {
651 text: [
652 "That's it โ€” {space.name|your space} is ready! ๐ŸŽ‰\n\n",
653 "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.",
654 // The one honest parting warning for an admin who chose "Not now":
655 // plain consequences, no mechanism talk, and the way out right
656 // there. Gated on canEnrol โ€” a self-hosted admin has no sign-in
657 // to be reminded of.
658 {
659 if: { all: ["!vars.backedUp", "bridge.canEnrol"] },
660 text:
661 "\n\nOne more reminder before you go: you haven't signed in, so this browser holds the only key to this space. If it's lost or cleared, the space is gone with it โ€” for everyone. Sign in any time and it's safe.",
662 },
663 // The plan they chose mid-conversation (#1374): the payment is a
664 // chip below, never a side effect โ€” and the space stays free (and
665 // fully working) until that tap.
666 {
667 if: "vars.planTier",
668 text: "\n\nAnd the plan you picked โ€” tap below whenever you're ready to set up the payment. Your space runs free until then.",
669 },
670 ],
671 // A stable dedup key (welcome has q-finish too): without one the
672 // dedupe rides the rendered text, and a renamed space or a flipped
673 // backup state persisted a second finish bubble (#1377).
674 key: "q-finish",
675 },
676 { text: "Where would you like to start?" },
677 ],
678 chips: [
679 // The parting reminder's action โ€” finish-line chips stay live, and the
680 // completion poll picks the sign-in up even after the flow has ended.
681 { if: { all: ["!vars.backedUp", "bridge.canEnrol"] }, label: "Sign in", advancing: false, effect: { verb: "savePasskey" } },
682 // The chosen plan's payment (#1374): hands off to the home page's
683 // upgrade picker with the tier pre-highlighted. Same tab โ€” setup is
684 // complete, and home brings you back to your list.
685 { if: "vars.planTier", label: "Set up your plan", advancing: false, effect: { verb: "openPlanCheckout" } },
686 // Native hops threads too (the conversation model's openAdmin verb).
687 { label: "Open admin", advancing: false, effect: { verb: "openAdmin" } },
688 // The tour is the web's guided overlay โ€” web-only for now.
689 { if: "!native", label: "Take the tour", advancing: false, effect: { verb: "startTour" } },
690 // "Where would you like to start?" needs an answer that ISN'T a
691 // destination: most people are finished, and every chip pointing
692 // somewhere else made leaving feel like the one thing on offer.
693 // Advancing (so it spends itself), and it says the thing out loud โ€”
694 // you're set up, nothing is outstanding, Eleven is here if you want it.
695 { label: "Done for now", spent: "Done", say: "You're all set. ๐Ÿ‘ I'm here whenever you want me โ€” just say hello, or type `/` to see what I can do." },
696 ],
697 end: true,
698 },
699 ],
700};
701