| 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 | // 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 | |