eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 16f1ae9b76198391d937102829dd70b00233fa78 parent d7f13cd
9 files changed, +322 −96
  • ios/LChat/Views/ElevenChatScreen.swift +22 −0
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +19 −5
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +66 −18
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +48 −14
  • ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift +50 −3
  • ios/LChatMac/ElevenConversationPane.swift +12 −0
  • test/flow.test.mjs +48 −6
  • web/static/setup.flow.js +34 −27
  • web/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)",
},