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