ios: Eleven can offer the sign-in it was only ever describing (#1478)
#1476 stopped the native conversation claiming an Eleven account a signed-out device didn't have; this gives it something to offer instead. savePasskey now dispatches natively to a signInToEleven host callback — Mac opens Settings, iOS presents the doors — and bridge.canEnrol reads live as "did the host wire one?", so an iOS 17 device still records the honest line with no dead chip. Both flows settle on enrolled OR a native session landing, and the pitch reads in the surface's own nouns (this device / this browser). Claude-Session: https://claude.ai/code/session_01GUA1EJFYwgr2nZNVBRFf8k
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+322
−96
ios/LChat/Views/ElevenChatScreen.swift+22 −0ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift+19 −5ios/LChatCore/Sources/LChatCore/Flows/setup.json+66 −18ios/LChatCore/Sources/LChatCore/Flows/welcome.json+48 −14ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift+50 −3ios/LChatMac/ElevenConversationPane.swift+12 −0test/flow.test.mjs+48 −6web/static/setup.flow.js+34 −27web/static/welcome.flow.js+23 −23
diff --git a/ios/LChat/Views/ElevenChatScreen.swift b/ios/LChat/Views/ElevenChatScreen.swift| index 7f5c419..2cb9770 100644 |
| --- a/ios/LChat/Views/ElevenChatScreen.swift |
| +++ b/ios/LChat/Views/ElevenChatScreen.swift |
| @@ -13,6 +13,11 @@ struct ElevenChatScreen: View { |
| @State private var model: ElevenConversationModel? |
| @State private var showDiscover = false |
| @State private var showQuickForm = false |
| + @State private var showSignIn = false |
| + /// The key/access step's "Sign in" chip presents the ordinary doors — |
| + /// its own model, so a sign-in taken here behaves exactly like one taken |
| + /// on the first-run screen (same adopt seam, same last-used marker). |
| + @StateObject private var signIn = HomeSignInModel() |
| @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17 |
| @AppStorage("nativeTheme") private var themeName = "classic" |
| @@ -44,6 +49,20 @@ struct ElevenChatScreen: View { |
| ElevenQuickFormView(model: model) |
| } |
| } |
| + .sheet(isPresented: $showSignIn) { |
| + // iOS 18 gates the ceremony (PRF); below it the chip is never |
| + // offered at all — signInToEleven stays nil, so the flow records |
| + // where the key actually lives instead of pitching. |
| + if #available(iOS 18.0, *) { |
| + NavigationStack { SignInFirstRun(model: signIn) } |
| + } |
| + } |
| + // The doors don't dismiss themselves (first-run swaps the whole |
| + // screen instead); close on a landed sign-in, and the conversation's |
| + // settle picks the session up from there. |
| + .onChange(of: signIn.phase) { _, phase in |
| + if phase.signedIn { showSignIn = false } |
| + } |
| .onAppear { |
| guard model == nil else { return } |
| let m = ElevenConversationCache.shared.model(for: instance, thread: thread, |
| @@ -58,6 +77,9 @@ struct ElevenChatScreen: View { |
| } |
| } |
| m?.openDiscover = { showDiscover = true } |
| + if #available(iOS 18.0, *) { |
| + m?.signInToEleven = { showSignIn = true } |
| + } |
| // Outgoing payloads stamp the SavedInstance's name — persist a |
| // flow rename, the way ServerSettingsSheet persists a manual one. |
| m?.onNameSet = { name in |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift| index 0a8596d..5f76b5e 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| @@ -64,6 +64,13 @@ public final class ElevenConversationModel: ObservableObject { |
| /// The "Fill in a form instead" chip — the host presents the quick-form |
| /// sheet (ElevenQuickFormView), which submits back through applyQuickForm. |
| public var openQuickForm: (() -> Void)? |
| + /// The key/access step's "Sign in" chip: the host opens the app's own |
| + /// Eleven sign-in (Mac: Settings; iOS: the doors sheet). Its presence is |
| + /// what makes bridge.canEnrol true — a host that can't offer a sign-in |
| + /// leaves it nil and the flows record the honest "this device holds the |
| + /// key" instead of pitching something that goes nowhere. |
| + public var signInToEleven: (() -> Void)? |
| + |
| /// The welcome flow's name step just published a label — the host must |
| /// also persist the name into the saved instance (outgoing payloads stamp |
| /// SavedInstance.screenName; without this a flow rename fixes the roster |
| @@ -151,11 +158,11 @@ public final class ElevenConversationModel: ObservableObject { |
| // with your Eleven account" when nothing had been pushed anywhere. |
| // The flows branch on this now; both halves are honest. |
| env.live["bridge.homeSignedIn"] = { .bool(HomeSessionStore.load() != nil) } |
| - // Nothing in a native conversation can run a sign-in: Eleven sign-in |
| - // lives in the app's own surfaces, so a "Sign in" chip here would be |
| - // dead. Stated rather than left undefined — the flows' finish nag and |
| - // its chip both gate on it, and an accident is not a decision. |
| - env.live["bridge.canEnrol"] = { .bool(false) } |
| + // Can this host actually offer a sign-in? It can when it wired |
| + // signInToEleven — read live, because the shell assigns the callback |
| + // after the model is built, and a chip that goes nowhere is worse |
| + // than an honest line about where the key lives. |
| + env.live["bridge.canEnrol"] = { [weak self] in .bool(self?.signInToEleven != nil) } |
| env.live["accessBackedUp"] = { .bool(HomeSessionStore.load() != nil) } |
| let hop = pendingThreadId |
| pendingHop = hop |
| @@ -481,6 +488,13 @@ extension ElevenConversationModel: FlowIO { |
| case "openPendingThread": |
| if let hop = pendingHop { openThread?(hop) } |
| return .null |
| + case "savePasskey": |
| + // Same verb name as the web (one vocabulary in the flow data), a |
| + // different act: no ceremony here — the host opens the app's own |
| + // Eleven sign-in, and the step's settle picks the session up when |
| + // it lands. |
| + signInToEleven?() |
| + return .null |
| case "openAdmin": |
| openAdmin?() |
| return .null |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index 844b172..f8718e9 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -702,14 +702,6 @@ |
| }, |
| "goto": "finish" |
| }, |
| - { |
| - "if": "native", |
| - "recordIfNew": { |
| - "label": "This device holds this space's key", |
| - "value": "device" |
| - }, |
| - "goto": "finish" |
| - }, |
| { |
| "if": "bridge.enrolled", |
| "set": { |
| @@ -729,7 +721,11 @@ |
| { |
| "if": "!bridge.canEnrol", |
| "recordIfNew": { |
| - "label": "This browser holds this space's key", |
| + "label": { |
| + "if": "native", |
| + "then": "This device holds this space's key", |
| + "else": "This browser holds this space's key" |
| + }, |
| "value": "local" |
| }, |
| "goto": "finish" |
| @@ -740,14 +736,36 @@ |
| } |
| ], |
| "settle": { |
| - "when": "bridge.enrolled" |
| + "when": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + } |
| }, |
| "bubbles": [ |
| { |
| - "text": "Now the most important thing of all. 🔑\n\nEverything 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\nBut 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." |
| + "text": [ |
| + "Now the most important thing of all. 🔑\n\n", |
| + "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", |
| + "But it means: right now, ", |
| + { |
| + "if": "native", |
| + "text": "this device holds" |
| + }, |
| + { |
| + "if": "!native", |
| + "text": "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." |
| + ] |
| }, |
| { |
| - "text": "Sign in with Eleven and your key stays safe — one tap, your face or fingerprint, and you'll be brought straight back." |
| + "text": { |
| + "if": "native", |
| + "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.", |
| + "else": "Sign in with Eleven and your key stays safe — one tap, your face or fingerprint, and you'll be brought straight back." |
| + } |
| } |
| ], |
| "chips": [ |
| @@ -759,7 +777,12 @@ |
| }, |
| "probe": { |
| "afterMs": 1500, |
| - "unless": "bridge.enrolled", |
| + "unless": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "goto": "keys-waiting" |
| } |
| }, |
| @@ -806,7 +829,12 @@ |
| }, |
| "probe": { |
| "afterMs": 1500, |
| - "unless": "bridge.enrolled", |
| + "unless": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "goto": "keys-waiting" |
| } |
| }, |
| @@ -820,7 +848,12 @@ |
| { |
| "id": "keys-waiting", |
| "settle": { |
| - "when": "bridge.enrolled", |
| + "when": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "goto": "keys" |
| }, |
| "bubbles": [ |
| @@ -834,7 +867,12 @@ |
| "record": { |
| "step": "keys", |
| "label": { |
| - "if": "bridge.enrolled", |
| + "if": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "then": "Signed in — your space's key is safe", |
| "else": "Signed in (in this browser)" |
| }, |
| @@ -863,7 +901,12 @@ |
| { |
| "id": "keys-confirm", |
| "settle": { |
| - "when": "bridge.enrolled", |
| + "when": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "goto": "keys" |
| }, |
| "bubbles": [ |
| @@ -880,7 +923,12 @@ |
| }, |
| "probe": { |
| "afterMs": 1500, |
| - "unless": "bridge.enrolled", |
| + "unless": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "goto": "keys-waiting" |
| } |
| }, |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json| index a663f23..3e7334f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| @@ -66,14 +66,6 @@ |
| }, |
| "goto": "name" |
| }, |
| - { |
| - "if": "native", |
| - "recordIfNew": { |
| - "label": "Your access lives on this device", |
| - "value": "device" |
| - }, |
| - "goto": "name" |
| - }, |
| { |
| "if": { |
| "any": [ |
| @@ -91,18 +83,45 @@ |
| { |
| "if": "!bridge.canEnrol", |
| "recordIfNew": { |
| - "label": "Your access lives in this browser", |
| + "label": { |
| + "if": "native", |
| + "then": "Your access lives on this device", |
| + "else": "Your access lives in this browser" |
| + }, |
| "value": "local" |
| }, |
| "goto": "name" |
| } |
| ], |
| "settle": { |
| - "when": "bridge.enrolled" |
| + "when": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + } |
| }, |
| "bubbles": [ |
| { |
| - "text": "Before anything else, let's make sure you can't lose your way in. 🔑\n\nYour access to this space lives in a key that exists only in this browser — there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\nSign in with Eleven and your access is safe — one tap, your face or fingerprint, and you'll be brought straight back so we can carry on." |
| + "text": [ |
| + "Before anything else, let's make sure you can't lose your way in. 🔑\n\n", |
| + { |
| + "if": "native", |
| + "text": "Your access to this space lives in a key that exists only on this device — there's no username or password, and nobody can reset it for you. If you lose the device, your access goes with it.\n\n" |
| + }, |
| + { |
| + "if": "!native", |
| + "text": "Your access to this space lives in a key that exists only in this browser — there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\n" |
| + }, |
| + { |
| + "if": "native", |
| + "text": "Sign in with Eleven and your access is safe — it's saved to your account, and every device you sign in on can open this space." |
| + }, |
| + { |
| + "if": "!native", |
| + "text": "Sign in with Eleven and your access is safe — one tap, your face or fingerprint, and you'll be brought straight back so we can carry on." |
| + } |
| + ] |
| } |
| ], |
| "chips": [ |
| @@ -114,7 +133,12 @@ |
| }, |
| "probe": { |
| "afterMs": 1500, |
| - "unless": "bridge.enrolled", |
| + "unless": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "goto": "access-waiting" |
| } |
| }, |
| @@ -144,7 +168,12 @@ |
| { |
| "id": "access-waiting", |
| "settle": { |
| - "when": "bridge.enrolled", |
| + "when": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "goto": "access" |
| }, |
| "bubbles": [ |
| @@ -158,7 +187,12 @@ |
| "record": { |
| "step": "access", |
| "label": { |
| - "if": "bridge.enrolled", |
| + "if": { |
| + "any": [ |
| + "bridge.enrolled", |
| + "bridge.homeSignedIn" |
| + ] |
| + }, |
| "then": "Signed in — your access is safe", |
| "else": "Signed in (in this browser)" |
| }, |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift| index 8dc23de..e69383e 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift |
| @@ -178,8 +178,9 @@ final class FlowEngineTests: XCTestCase { |
| let io = Harness() |
| io.verbResults["setGroupName"] = .string("ok") |
| let env = nativeEnv(name: "Paul", homeSignedIn: false) |
| - // Mirrors ElevenConversationModel: a sign-in can't be run from inside |
| - // a native conversation, so the flows must not offer one. |
| + // A host that wired NO signInToEleven (an iOS 17 device, say): the |
| + // model reports canEnrol false and the flow must record where the key |
| + // is rather than pitch something that goes nowhere. |
| env.live["bridge.canEnrol"] = { .bool(false) } |
| let engine = FlowEngine(flow: flow, env: env, io: io) |
| engine.start() |
| @@ -195,12 +196,58 @@ final class FlowEngineTests: XCTestCase { |
| let keys = io.records.first { $0.step == "keys" } |
| XCTAssertEqual(keys?.label, "This device holds this space's key") |
| - XCTAssertEqual(keys?.value, "device") |
| + XCTAssertEqual(keys?.value, "local") |
| XCTAssertFalse(io.verbLog.contains { $0.0 == "savePasskey" }, "no dead sign-in chip") |
| XCTAssertFalse(io.transcript.flatMap { $0.chips ?? [] }.map(\.label).contains("Sign in")) |
| XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }, "still reaches finish") |
| } |
| + // A native host that CAN sign in (signInToEleven wired → canEnrol true) |
| + // pitches it in the app's own words, and settles the moment the session |
| + // lands — no "Continue" tap. Twin of "setup: native signed out — pitched |
| + // in device words, settles on the sign-in". |
| + func testNativeSignedOutIsPitchedAndSettles() async throws { |
| + let flow = try moduleFlow("setup") |
| + let io = Harness() |
| + io.verbResults["setGroupName"] = .string("ok") |
| + var signedIn = false |
| + let env = nativeEnv(name: "Paul", space: "") |
| + env.live["bridge.homeSignedIn"] = { .bool(signedIn) } |
| + env.live["accessBackedUp"] = { .bool(signedIn) } |
| + env.live["bridge.canEnrol"] = { .bool(true) } |
| + let engine = FlowEngine(flow: flow, env: env, io: io) |
| + engine.start() |
| + await io.settle() |
| + defer { withExtendedLifetime(engine) {} } |
| + |
| + await io.answer("Team Awesome") |
| + env.space["name"] = .string("Team Awesome") |
| + await io.tap("Skip for now") |
| + await io.tap("Members can invite people") |
| + await io.tap("Keep both on") |
| + await io.tap("Not now") |
| + |
| + let pitch = io.transcript.first { $0.text.contains("Now the most important thing of all") } |
| + XCTAssertNotNil(pitch, "the key pitch runs") |
| + XCTAssertTrue(pitch?.text.contains("this device holds the only key") ?? false, |
| + "device words, not browser words") |
| + |
| + await io.tap("Sign in") |
| + XCTAssertTrue(io.verbLog.contains { $0.0 == "savePasskey" }, |
| + "the host is asked to open its sign-in") |
| + |
| + // The app signs in; the step's settle poll notices on its own. |
| + signedIn = true |
| + let poll = io.pendingDelays.last { $0.0 == 700 } |
| + XCTAssertNotNil(poll, "the keys step arms a settle poll") |
| + poll?.1() |
| + await io.settle() |
| + let keys = io.records.first { $0.step == "keys" } |
| + XCTAssertEqual(keys?.label, "Your space's key is saved with your Eleven account") |
| + XCTAssertEqual(keys?.value, "native") |
| + XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }, "carried on to finish") |
| + } |
| + |
| // MARK: input.confirm — an email answered as a name |
| // Twin of "setup: an email answered as a name is confirmed, not swallowed" |
diff --git a/ios/LChatMac/ElevenConversationPane.swift b/ios/LChatMac/ElevenConversationPane.swift| index f9fb638..4fd427c 100644 |
| --- a/ios/LChatMac/ElevenConversationPane.swift |
| +++ b/ios/LChatMac/ElevenConversationPane.swift |
| @@ -65,6 +65,18 @@ struct ElevenConversationPane: View { |
| } |
| } |
| m?.openDiscover = { showDiscover = true } |
| + // The key step's "Sign in": open Settings, where Sign in with |
| + // Eleven lives (the same window /themes opens for the theme |
| + // picker). The conversation doesn't run the ceremony itself — |
| + // it waits, and its settle picks the session up when it lands. |
| + m?.signInToEleven = { |
| + NSApp.activate(ignoringOtherApps: true) |
| + if NSApp.responds(to: Selector(("showSettingsWindow:"))) { |
| + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) |
| + } else { |
| + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) |
| + } |
| + } |
| // Outgoing payloads stamp the SavedInstance's name — persist a |
| // flow rename, the way MacSettingsView persists a manual one. |
| m?.onNameSet = { name in |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index 0c92092..6d5a03d 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, fromHome = false, canEnrol = true, myName = "", homeSignedIn = true, planBook = null, envExtra = {} } = {}) { |
| +function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, canEnrol = true, myName = "", homeSignedIn = false, planBook = null, envExtra = {} } = {}) { |
| const h = { |
| transcript: [], // { text, chips: [labels], extra } |
| verbLog: [], |
| @@ -40,6 +40,9 @@ function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, ca |
| fromHome: () => h.fromHome, |
| // Native only: whether this device holds a live Eleven session, which |
| // decides whether claiming a space really did push its key anywhere. |
| + // The web never defines it (undefined reads false), which is why the |
| + // harness default is false — a web-shaped run must behave as the web |
| + // does, or every settle that watches this fact fires on the wrong side. |
| homeSignedIn: () => h.homeSignedIn, |
| // Whether signing in is possible at all (#1377) — false on self-hosted |
| // spaces with no home server; the flows skip the un-actionable pitch. |
| @@ -478,7 +481,8 @@ await test("setup: from-home key step + settle auto-detects the enrolment return |
| await test("setup: native creator — greeting shows, name pre-set, passkey never pitched, no admin hop", async () => { |
| // The native create-space landing: screen name typed in the create UI, the |
| // conversation runs in the app-thread's embedded webview with env.native. |
| - const h = harness({ myName: "Paul", verbs: { setGroupName: () => "ok", setContactEmail: () => "ok" } }); |
| + const h = harness({ myName: "Paul", homeSignedIn: true, |
| + verbs: { setGroupName: () => "ok", setContactEmail: () => "ok" } }); |
| h.env.native = true; |
| runFlow(SETUP_FLOW, h.env, h.io); |
| await tick(); |
| @@ -691,6 +695,42 @@ await test("welcome: the joiner's name step confirms an email the same way", asy |
| // Native, signed OUT of Eleven: claiming a space pushes nothing (HomeSync |
| // no-ops without a session), so the conversation must not claim an account |
| // that isn't there (Paul, 2026-08-29). |
| +// A native shell that CAN offer a sign-in (the app wired signInToEleven, so |
| +// canEnrol is true) pitches it like the web does — in the app's own words — |
| +// and settles on the session landing, with no "Continue" tap. |
| +await test("setup: native signed out — pitched in device words, settles on the sign-in", async () => { |
| + const h = harness({ myName: "Paul", homeSignedIn: false, canEnrol: true, |
| + verbs: { setGroupName: () => "ok", setContactEmail: () => "ok" } }); |
| + h.env.native = true; |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + await h.answer("Team Awesome"); |
| + await h.tap("Skip for now"); |
| + await h.tap("Members can invite people"); |
| + await h.tap("Keep both on"); |
| + await h.tap("Not now"); // AI opt-in |
| + |
| + const pitch = h.transcript.find((t) => /Now the most important thing of all/.test(t.text)); |
| + assert.ok(pitch, "the key pitch runs"); |
| + assert.match(pitch.text, /this device holds the only key/, "device words, not browser words"); |
| + assert.ok(h.transcript.some((t) => /every device you sign in on can open this space/.test(t.text))); |
| + |
| + await h.tap("Sign in"); |
| + assert.ok(h.verbLog.some((v) => v[0] === "savePasskey"), "the host is asked to open its sign-in"); |
| + |
| + // The app signs in; the step's settle poll notices without a tap. |
| + h.homeSignedIn = true; |
| + const settle = h.pendingDelays.filter((d) => d.ms === 700).at(-1); |
| + assert.ok(settle, "the keys step arms a settle poll"); |
| + settle.fn(); |
| + await tick(); |
| + await tick(); |
| + assert.deepEqual(h.records.find((r) => r.step === "keys"), |
| + { step: "keys", label: "Your space's key is saved with your Eleven account", value: "native" }); |
| + assert.ok(h.verbLog.some((v) => v[0] === "markSetupComplete"), "carried on to finish"); |
| + assert.equal(h.transcript.some((t) => /One more reminder/.test(t.text)), false, "no nag — it's saved"); |
| +}); |
| + |
| await test("setup: native without an Eleven session says where the key really is", async () => { |
| // canEnrol false mirrors the native bridge: a sign-in can't be run from |
| // inside the conversation, so the flows must not offer one. |
| @@ -706,7 +746,7 @@ await test("setup: native without an Eleven session says where the key really is |
| await h.tap("Not now"); |
| assert.deepEqual(h.records.find((r) => r.step === "keys"), |
| - { step: "keys", label: "This device holds this space's key", value: "device" }); |
| + { step: "keys", label: "This device holds this space's key", value: "local" }); |
| // Still no pitch and no dead chip — signing in lives in the app's own |
| // surfaces, not in this conversation. |
| assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false); |
| @@ -722,7 +762,7 @@ await test("welcome: native without an Eleven session records the device, not an |
| runFlow(WELCOME_FLOW, h.env, h.io); |
| await tick(); |
| assert.deepEqual(h.records.find((r) => r.step === "access"), |
| - { step: "access", label: "Your access lives on this device", value: "device" }); |
| + { step: "access", label: "Your access lives on this device", value: "local" }); |
| assert.equal(h.transcript.some((t) => /Before anything else/.test(t.text)), false, "no pitch"); |
| }); |
| @@ -877,8 +917,10 @@ await test("welcome: open groups get a finish mention + a Browse chip", async () |
| }); |
| await test("welcome: native embed — passkey step skipped, access secured by the sign-in, finish records completion", async () => { |
| - const h = harness({ envExtra: welcomeEnv({ native: true, inviterName: "Ciara" }) }); |
| - h.env.accessBackedUp = () => true; // native: secured by Sign in with Eleven |
| + // A native shell WITH an Eleven session — what "secured by the sign-in" |
| + // actually rests on (a signed-out one gets the ask, see below). |
| + const h = harness({ homeSignedIn: true, envExtra: welcomeEnv({ native: true, inviterName: "Ciara" }) }); |
| + h.env.accessBackedUp = () => true; // native + signed in: secured by that sign-in |
| runFlow(WELCOME_FLOW, h.env, h.io); |
| await tick(); |
diff --git a/web/static/setup.flow.js b/web/static/setup.flow.js| index 029a186..9c8d959 100644 |
| --- a/web/static/setup.flow.js |
| +++ b/web/static/setup.flow.js |
| @@ -479,16 +479,6 @@ export default { |
| recordIfNew: { label: "Your space's key is saved with your Eleven account", value: "native" }, |
| goto: "finish", |
| }, |
| - { |
| - // Native, signed OUT: the push above no-ops silently, so claiming |
| - // "saved with your Eleven account" would be a lie about the one |
| - // thing this step exists to be honest about. Say where the key |
| - // actually is. No pitch: signing in to Eleven lives in the app's |
| - // own surfaces, not in this conversation. |
| - if: "native", |
| - recordIfNew: { label: "This device holds this space's key", value: "device" }, |
| - goto: "finish", |
| - }, |
| { |
| if: "bridge.enrolled", |
| set: { "vars.backedUp": true }, |
| @@ -503,11 +493,15 @@ export default { |
| // markSetupComplete never ran (#1377). backedUp stays false, so the |
| // finish line still carries the honest reminder. |
| { if: "done", goto: "finish" }, |
| - // No home server to sign in with (self-hosted): the pitch would be a |
| - // dead-end chip — record the honest state and move on (#1377). |
| + // Nowhere to sign in: a self-hosted space with no home server (#1377), |
| + // or a native host that wired no sign-in. The pitch would be a |
| + // dead-end chip — record where the key actually is and move on. |
| { |
| if: "!bridge.canEnrol", |
| - recordIfNew: { label: "This browser holds this space's key", value: "local" }, |
| + recordIfNew: { |
| + label: { if: "native", then: "This device holds this space's key", else: "This browser holds this space's key" }, |
| + value: "local", |
| + }, |
| goto: "finish", |
| }, |
| // Someone who created this space from their Eleven sign-in page already |
| @@ -515,26 +509,39 @@ export default { |
| // this space's key to it (keys-home). |
| { if: "bridge.fromHome", goto: "keys-home" }, |
| ], |
| - // The sign-in ceremony returns asynchronously (a redirect back from the |
| - // home server); auto-detect that return and advance without a manual tap. |
| - settle: { when: "bridge.enrolled" }, |
| + // The sign-in returns asynchronously — a redirect back from the home |
| + // server on web, a sheet or Settings window on native — so watch both |
| + // signals and advance without a manual tap. |
| + settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] } }, |
| // Member-facing language is "sign in" — never the mechanism (passkeys, |
| // backups). The lock-and-key framing carries the stakes instead. |
| bubbles: [ |
| { |
| - text: |
| - "Now the most important thing of all. 🔑\n\n" + |
| - "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" + |
| - "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.", |
| + // Segments, so only the noun changes between surfaces: a native app |
| + // holds the key on the DEVICE, a browser in the browser. |
| + text: [ |
| + "Now the most important thing of all. 🔑\n\n", |
| + "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", |
| + "But it means: right now, ", |
| + { if: "native", text: "this device holds" }, |
| + { if: "!native", text: "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.", |
| + ], |
| + }, |
| + { |
| + text: { |
| + if: "native", |
| + 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.", |
| + else: "Sign in with Eleven and your key stays safe — one tap, your face or fingerprint, and you'll be brought straight back.", |
| + }, |
| }, |
| - { text: "Sign in with Eleven and your key stays safe — one tap, your face or fingerprint, and you'll be brought straight back." }, |
| ], |
| chips: [ |
| { |
| label: "Sign in", |
| advancing: false, |
| effect: { verb: "savePasskey" }, |
| - probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" }, |
| + probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" }, |
| }, |
| { label: "Not now", spent: "Skipped", goto: "keys-confirm" }, |
| ], |
| @@ -570,7 +577,7 @@ export default { |
| label: "Sign in", |
| advancing: false, |
| effect: { verb: "savePasskey" }, |
| - probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" }, |
| + probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" }, |
| }, |
| { label: "Not now", spent: "Skipped", goto: "keys-confirm" }, |
| ], |
| @@ -579,7 +586,7 @@ export default { |
| id: "keys-waiting", |
| // The ceremony may confirm while we sit here (the completion poll flips |
| // the flag) — re-enter keys so its entry records + acks, no tap needed. |
| - settle: { when: "bridge.enrolled", goto: "keys" }, |
| + settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys" }, |
| bubbles: [{ text: "I'll wait here while you finish signing in. When you're done, tap Continue." }], |
| chips: [ |
| { |
| @@ -587,7 +594,7 @@ export default { |
| record: { |
| step: "keys", |
| label: { |
| - if: "bridge.enrolled", |
| + if: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, |
| then: "Signed in — your space's key is safe", |
| else: "Signed in (in this browser)", |
| }, |
| @@ -609,7 +616,7 @@ export default { |
| id: "keys-confirm", |
| // Same late-confirmation catch as keys-waiting: a ceremony finished in |
| // another context can land while the warning is up — advance over it. |
| - settle: { when: "bridge.enrolled", goto: "keys" }, |
| + settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys" }, |
| bubbles: [ |
| { |
| 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.", |
| @@ -620,7 +627,7 @@ export default { |
| label: "Go back and sign in", |
| advancing: false, |
| effect: { verb: "savePasskey" }, |
| - probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" }, |
| + probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" }, |
| }, |
| { |
| label: "I understand the risk", |
diff --git a/web/static/welcome.flow.js b/web/static/welcome.flow.js| index 2613183..ca164df 100644 |
| --- a/web/static/welcome.flow.js |
| +++ b/web/static/welcome.flow.js |
| @@ -46,15 +46,6 @@ export default { |
| recordIfNew: { label: "Your access is already secured by your Eleven sign-in", value: "native" }, |
| goto: "name", |
| }, |
| - { |
| - // Native, signed OUT: nothing was pushed anywhere. Record where the |
| - // access actually lives (accessBackedUp stays false, so the finish |
| - // line keeps its honest reminder) rather than claiming an account |
| - // that isn't there. |
| - if: "native", |
| - recordIfNew: { label: "Your access lives on this device", value: "device" }, |
| - goto: "name", |
| - }, |
| { |
| if: { any: ["bridge.enrolled", "done"] }, |
| recordIfNew: { label: "Signed in — your access is safe", value: "passkey" }, |
| @@ -66,27 +57,36 @@ export default { |
| sayIfNew: "Great — you're signed in, so your access is safe. 🔒 Let's continue.", |
| goto: "name", |
| }, |
| - // No home server to sign in with (self-hosted): the ask would be a |
| - // dead-end chip — record the honest state and carry on (#1377). |
| + // Nowhere to sign in: a self-hosted space with no home server (#1377), |
| + // or a native host that wired no sign-in. The ask would be a dead-end |
| + // chip — record where the access actually lives and carry on. |
| { |
| if: "!bridge.canEnrol", |
| - recordIfNew: { label: "Your access lives in this browser", value: "local" }, |
| + recordIfNew: { |
| + label: { if: "native", then: "Your access lives on this device", else: "Your access lives in this browser" }, |
| + value: "local", |
| + }, |
| goto: "name", |
| }, |
| ], |
| - // The sign-in 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" }, |
| + // The sign-in returns asynchronously (a redirect back, tabbing back from |
| + // a system browser, or a native sheet closing); auto-detect it on either |
| + // signal and carry on without a manual "Continue" tap. |
| + settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] } }, |
| // One bubble, chips attached — context and ask together, so the opening |
| // run is two messages (intro + this), not three. The member-facing word |
| // is "sign in" — never the mechanism (passkeys, backups). |
| bubbles: [ |
| { |
| - text: |
| - "Before anything else, let's make sure you can't lose your way in. 🔑\n\n" + |
| - "Your access to this space lives in a key that exists only in this browser — there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\n" + |
| - "Sign in with Eleven and your access is safe — one tap, your face or fingerprint, and you'll be brought straight back so we can carry on.", |
| + // Segments, so only the noun changes between surfaces (see the |
| + // setup flow's keys step). |
| + text: [ |
| + "Before anything else, let's make sure you can't lose your way in. 🔑\n\n", |
| + { if: "native", text: "Your access to this space lives in a key that exists only on this device — there's no username or password, and nobody can reset it for you. If you lose the device, your access goes with it.\n\n" }, |
| + { if: "!native", text: "Your access to this space lives in a key that exists only in this browser — there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\n" }, |
| + { if: "native", text: "Sign in with Eleven and your access is safe — it's saved to your account, and every device you sign in on can open this space." }, |
| + { if: "!native", text: "Sign in with Eleven and your access is safe — one tap, your face or fingerprint, and you'll be brought straight back so we can carry on." }, |
| + ], |
| }, |
| ], |
| chips: [ |
| @@ -94,7 +94,7 @@ export default { |
| label: "Sign in (recommended)", |
| advancing: false, |
| effect: { verb: "savePasskey" }, |
| - probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "access-waiting" }, |
| + probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "access-waiting" }, |
| }, |
| { |
| label: "Not now", |
| @@ -120,7 +120,7 @@ export default { |
| id: "access-waiting", |
| // The ceremony may confirm while we sit here (the completion poll flips |
| // the flag) — re-enter access so its entry records + acks, no tap needed. |
| - settle: { when: "bridge.enrolled", goto: "access" }, |
| + settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "access" }, |
| bubbles: [{ text: "I'll wait here while you finish signing in. When you're done, tap Continue." }], |
| chips: [ |
| { |
| @@ -128,7 +128,7 @@ export default { |
| record: { |
| step: "access", |
| label: { |
| - if: "bridge.enrolled", |
| + if: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, |
| then: "Signed in — your access is safe", |
| else: "Signed in (in this browser)", |
| }, |