eleven / messenger Public

ios+mac: sign-in doors share one button geometry + the pressed door spins (#1151)

* ios+mac: sign-in doors share one button geometry + the pressed door spins

Paul's two rules from the device session: (1) the Apple control fixes
the shape — full-width, low-radius rounded corners — so every door
matches it (DoorButtonStyle / MacDoorButtonStyle; no mixed button types
in a list; the demoted passkey text link becomes a row), and (2) the
web's busy behaviour lands natively: the pressed door hosts its own
spinner. Apple's control can't host one, so a same-geometry stand-in
overlays it while its verify runs — the real button stays in the
hierarchy, hidden, or the in-flight authorization would be torn down
with the view. The email step's Send code / Sign in match too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: quieter first-run — 'Secure group chat', no footer; sealed says it with the button

Paul's copy pass on the device session: the tagline becomes 'Secure
group chat' and the privacy footer goes. The sealed landing sheds every
word that isn't the action — no tagline, no 'Signed in — your spaces
are locked…' status (if you can't see your spaces, you're not signed
in) — leaving 'Unlock with your passkey' with the passkey glyph, and
the quiet sign-in-a-different-way escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: the sealed landing holds through the ceremony — keyed on session, not phase

Tapping 'Unlock with your passkey' flipped phase to .working, which
bounced the body back to the doors mid-ceremony (and a cancelled sheet,
landing on .idle, did the same). The sealed landing now renders from
SESSION state — sessionSealed (a stored anchor session with no DEK),
added to the iOS model to mirror the Mac's — so the screen holds steady
until the unlock actually resolves. The escape link disables while a
ceremony is in flight, and the Mac's unlock label catches up to the
shorter iOS copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell adcd5544924d007cb7bc48fefc2c72c42c2a235e parent 92ad577
2 files changed, +183 −111
  • ios/LChat/Views/SignInWithEleven.swift +103 −64
  • ios/LChatMac/MacHomeSignIn.swift +80 −47
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
index d4714ea..4ec4862 100644
--- a/ios/LChat/Views/SignInWithEleven.swift
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -63,6 +63,12 @@ final class HomeSignInModel: ObservableObject {
/// A stale one-shot copy here once left "Back up this device" visible
/// after sign-in, a path to accidentally minting a second account.
@Published private(set) var sessionExists = HomeSessionStore.load() != nil
+ /// True when the stored session is an anchor's (Apple / email): signed
+ /// in, but no DEK — the vault stays sealed until a passkey unlocks it.
+ /// SESSION state, deliberately not phase: the sealed landing must hold
+ /// steady through a ceremony's transient phases (.working after the tap,
+ /// .idle after a cancelled sheet) instead of bouncing to the doors.
+ @Published private(set) var sessionSealed = HomeSessionStore.load().map { $0.dek == nil } ?? false
init() {}
@@ -102,7 +108,9 @@ final class HomeSignInModel: ObservableObject {
/// Re-read the Keychain session (the dev reset wipes it out from under the
/// model) and drop any terminal status back to the idle pitch.
func refreshSession() {
- sessionExists = HomeSessionStore.load() != nil
+ let session = HomeSessionStore.load()
+ sessionExists = session != nil
+ sessionSealed = session.map { $0.dek == nil } ?? false
phase = .idle
}
@@ -112,6 +120,7 @@ final class HomeSignInModel: ObservableObject {
func switchAccount() {
HomeSessionStore.clear()
sessionExists = false
+ sessionSealed = false
phase = .idle
}
@@ -124,6 +133,7 @@ final class HomeSignInModel: ObservableObject {
private func adopt(_ session: HomeSession, method: SignInMethod) {
HomeSessionStore.save(session)
sessionExists = true
+ sessionSealed = session.dek == nil
UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey)
}
@@ -417,11 +427,17 @@ struct SignInFirstRun: View {
.accessibilityHidden(true)
Text("Welcome to Eleven")
.font(.title2.weight(.semibold))
- Text("The group chat of dreams")
- .font(.callout)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal, 8)
+ // The tagline is the welcome pitch — the sealed landing is an
+ // unlock moment, not a welcome, and earns no marketing words
+ // (Paul, 2026-07-19: the button says everything this state
+ // needs).
+ if !model.sessionSealed {
+ Text("Secure group chat")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 8)
+ }
}
// Status / result folds in under the pitch, occupying layout space
@@ -445,7 +461,7 @@ struct SignInFirstRun: View {
.padding(.bottom, 48)
} else if let step = emailStep {
emailDoor(step)
- } else if model.phase.signedIn {
+ } else if model.phase.signedIn || model.sessionSealed {
landed
} else {
doors
@@ -485,12 +501,13 @@ struct SignInFirstRun: View {
// below it covers the wrong-account landing either way.
private var landed: some View {
VStack(spacing: 12) {
- if model.phase == .sealed {
+ if model.sessionSealed {
door(.passkey, leading: true)
}
Button("Sign in a different way") { model.switchAccount() }
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
+ .disabled(model.phase.busy) // never mid-ceremony
}
.padding(.horizontal, 32)
.padding(.bottom, 8)
@@ -509,12 +526,6 @@ struct SignInFirstRun: View {
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
-
- // The home page's privacy pitch, word for word.
- Text("No signup required. We don't store any personal details. We can't spam what we don't have.")
- .font(.footnote)
- .foregroundStyle(.tertiary)
- .multilineTextAlignment(.center)
}
.padding(.horizontal, 32)
.padding(.bottom, 8)
@@ -525,6 +536,12 @@ struct SignInFirstRun: View {
case .apple:
// Apple's own button (HIG); the model owns the nonce + verify so
// the session-adoption seam is shared with every other door.
+ // While its trip is in flight Apple's control can't host a
+ // spinner (the web's pressed door spins — home.js's .loading),
+ // so a same-geometry stand-in covers it; the real button stays
+ // in the hierarchy, hidden, or the in-flight authorization
+ // would be torn down with the view.
+ let appleBusy = model.phase.busy && tapped == .apple
SignInWithAppleButton(.continue) { request in
tapped = .apple
model.prepareApple(request)
@@ -532,8 +549,23 @@ struct SignInFirstRun: View {
Task { await model.handleApple(result) }
}
.signInWithAppleButtonStyle(scheme == .dark ? .white : .black)
- .frame(height: 50)
+ .frame(height: DoorButtonStyle.height)
.disabled(model.phase.busy)
+ .opacity(appleBusy ? 0 : 1)
+ .overlay {
+ if appleBusy {
+ HStack(spacing: 8) {
+ ProgressView()
+ .tint(scheme == .dark ? .black : .white)
+ Text("Signing in…").fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity, minHeight: DoorButtonStyle.height)
+ .foregroundStyle(scheme == .dark ? Color.black : .white)
+ .background(scheme == .dark ? Color.white : .black,
+ in: RoundedRectangle(cornerRadius: DoorButtonStyle.radius,
+ style: .continuous))
+ }
+ }
case .email:
Button {
tapped = .email
@@ -541,50 +573,38 @@ struct SignInFirstRun: View {
focus = .address
} label: {
Text("Continue with email")
- .fontWeight(.semibold)
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.bordered)
- .controlSize(.large)
+ .buttonStyle(DoorButtonStyle(prominent: leading))
.disabled(model.phase.busy)
case .passkey:
- if leading {
- // Promoted: the hero form (last-used passkey, or a sealed
- // anchor sign-in pointing at the door that opens the backup).
- Button {
- tapped = .passkey
- Task { await model.signIn(into: app) }
- } label: {
- HStack(spacing: 8) {
- if model.phase == .working && tapped == .passkey {
- ProgressView().controlSize(.small)
- }
- Text(passkeyLeadLabel)
- .fontWeight(.semibold)
+ // Promoted or listed, the passkey door is the same row shape —
+ // only the fill marks the lead (no mixed button types in the
+ // list; the Apple control fixes the geometry).
+ Button {
+ tapped = .passkey
+ Task { await model.signIn(into: app) }
+ } label: {
+ HStack(spacing: 8) {
+ if model.phase == .working && tapped == .passkey {
+ ProgressView()
+ .controlSize(.small)
+ .tint(leading ? .white : nil)
+ } else if leading && model.sessionSealed {
+ // The unlock moment gets the passkey glyph — the icon
+ // carries what the trimmed copy no longer spells out.
+ Image(systemName: "person.badge.key.fill")
}
- .frame(maxWidth: .infinity)
+ Text(leading ? passkeyLeadLabel : "Sign in with a passkey")
}
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .disabled(model.phase.busy)
- } else {
- // Demoted: the quiet text button under the anchor doors.
- Button("Sign in with a passkey") {
- tapped = .passkey
- Task { await model.signIn(into: app) }
- }
- .buttonStyle(.plain)
- .font(.subheadline.weight(.medium))
- .foregroundStyle(.secondary)
- .padding(.vertical, 4)
- .disabled(model.phase.busy)
}
+ .buttonStyle(DoorButtonStyle(prominent: leading))
+ .disabled(model.phase.busy)
}
}
private var passkeyLeadLabel: String {
if model.phase == .working && tapped == .passkey { return "Signing in…" }
- if model.phase == .sealed { return "Unlock your spaces with your passkey" }
+ if model.sessionSealed { return "Unlock with your passkey" }
return "Sign in with a passkey"
}
@@ -626,13 +646,11 @@ struct SignInFirstRun: View {
.accessibilityLabel("Your email address")
Button(action: sendCode) {
HStack(spacing: 8) {
- if model.phase == .working { ProgressView().controlSize(.small) }
- Text("Send code").fontWeight(.semibold)
+ if model.phase == .working { ProgressView().controlSize(.small).tint(.white) }
+ Text("Send code")
}
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
+ .buttonStyle(DoorButtonStyle(prominent: true))
.disabled(model.phase.busy || !emailValid)
// The web's note, word for word — under the address field.
Text("We never keep your email address. No spam, ever.")
@@ -659,13 +677,11 @@ struct SignInFirstRun: View {
.accessibilityLabel("The 6-digit code from your email")
Button(action: verifyCode) {
HStack(spacing: 8) {
- if model.phase == .working { ProgressView().controlSize(.small) }
- Text("Sign in").fontWeight(.semibold)
+ if model.phase == .working { ProgressView().controlSize(.small).tint(.white) }
+ Text("Sign in")
}
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
+ .buttonStyle(DoorButtonStyle(prominent: true))
.disabled(model.phase.busy || code.count != 6)
}
}
@@ -708,19 +724,42 @@ struct SignInFirstRun: View {
case .backedUp(let n):
Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill")
.font(.callout).foregroundStyle(.green)
- case .sealed:
- // The identity-vs-key split, said plainly: the anchor signed you
- // in; the passkey (promoted below) is what opens your spaces.
- Text("Signed in — your spaces are locked with your passkey. Unlock them below.")
- .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
case .working:
Text("Confirming it’s you…")
.font(.callout).foregroundStyle(.secondary)
case .failed(let msg):
Label(msg, systemImage: "exclamationmark.triangle")
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
- case .idle, .syncing:
+ case .idle, .syncing, .sealed:
+ // Sealed deliberately says NOTHING here: "signed in but locked"
+ // reads as noise when no spaces are visible (Paul: if you can't
+ // see your spaces, you're not signed in). The unlock button — a
+ // passkey icon and five words — is the whole story.
EmptyView()
}
}
}
+
+// 🤖 One geometry for every door (Paul, 2026-07-19): the Apple control fixes
+// the shape — full-width, 50pt, low-radius continuous corners — and no other
+// button type may sit beside it in the list. Prominent = the promoted door
+// (or a step's main action); plain = a demoted row. The pressed door hosts
+// its own spinner (the web's .loading rule), so busy state lives in the
+// button, not beside it.
+private struct DoorButtonStyle: ButtonStyle {
+ var prominent = false
+ @Environment(\.isEnabled) private var enabled
+
+ static let radius: CGFloat = 12
+ static let height: CGFloat = 50
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity, minHeight: Self.height)
+ .foregroundStyle(prominent ? Color.white : Color.accentColor)
+ .background(prominent ? Color.accentColor : Color(.secondarySystemFill),
+ in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous))
+ .opacity((configuration.isPressed ? 0.75 : 1) * (enabled ? 1 : 0.55))
+ }
+}
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift
index 221319e..3037d10 100644
--- a/ios/LChatMac/MacHomeSignIn.swift
+++ b/ios/LChatMac/MacHomeSignIn.swift
@@ -545,8 +545,13 @@ struct MacFirstRun: View {
.accessibilityHidden(true)
Text("Welcome to Eleven")
.font(.title2.weight(.semibold))
- Text("The group chat of dreams")
- .foregroundStyle(.secondary)
+ // The tagline is the welcome pitch — the sealed landing is an
+ // unlock moment, not a welcome, and earns no marketing words
+ // (Paul, 2026-07-19: the button says everything this state needs).
+ if !model.sessionSealed {
+ Text("Secure group chat")
+ .foregroundStyle(.secondary)
+ }
// Status folds in under the pitch, occupying layout space (the
// inline-feedback rule — nothing floats).
@@ -564,7 +569,7 @@ struct MacFirstRun: View {
.padding(.bottom, 56)
} else if let step = emailStep {
emailDoor(step)
- } else if model.phase.signedIn {
+ } else if model.phase.signedIn || model.sessionSealed {
landed
} else {
doors
@@ -605,7 +610,7 @@ struct MacFirstRun: View {
// below it covers the wrong-account landing either way.
private var landed: some View {
VStack(spacing: 10) {
- if model.phase == .sealed {
+ if model.sessionSealed {
door(.passkey, leading: true)
}
Button("Sign in a different way") { model.switchAccount() }
@@ -613,6 +618,7 @@ struct MacFirstRun: View {
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
.padding(.vertical, 2)
+ .disabled(model.phase.busy) // never mid-ceremony
}
.frame(width: 280)
.padding(.bottom, 48)
@@ -633,14 +639,6 @@ struct MacFirstRun: View {
.foregroundStyle(.secondary)
.padding(.vertical, 2)
}
-
- // The home page's privacy pitch, word for word.
- Text("No signup required. We don't store any personal details. We can't spam what we don't have.")
- .font(.footnote)
- .foregroundStyle(.tertiary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 300)
- .padding(.top, 2)
}
.frame(width: 280)
.padding(.bottom, 48)
@@ -652,24 +650,41 @@ struct MacFirstRun: View {
// Apple's branded control (HIG, glyph) over OUR web-session
// flow — see MacHomeSignIn.signInWithApple for why not the native
// sheet. Recreated on scheme flips (.id) — the style is init-only.
+ // While its trip is in flight the control can't host a spinner
+ // (the web's pressed door spins), so a same-geometry stand-in
+ // covers it; the real button stays in the hierarchy, hidden.
+ let appleBusy = model.phase.busy && tapped == .apple
AppleDoorButton(style: scheme == .dark ? .white : .black) {
tapped = .apple
Task { await model.signInWithApple() }
}
.id(scheme)
- .frame(height: 32)
+ .frame(height: MacDoorButtonStyle.height)
.disabled(model.phase.busy)
+ .opacity(appleBusy ? 0 : 1)
+ .overlay {
+ if appleBusy {
+ HStack(spacing: 6) {
+ ProgressView()
+ .controlSize(.small)
+ .tint(scheme == .dark ? .black : .white)
+ Text("Signing in…").fontWeight(.medium)
+ }
+ .frame(maxWidth: .infinity, minHeight: MacDoorButtonStyle.height)
+ .foregroundStyle(scheme == .dark ? Color.black : .white)
+ .background(scheme == .dark ? Color.white : .black,
+ in: RoundedRectangle(cornerRadius: MacDoorButtonStyle.radius,
+ style: .continuous))
+ }
+ }
case .email:
Button {
tapped = .email
emailStep = .address
} label: {
Text("Continue with email")
- .fontWeight(.medium)
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.bordered)
- .controlSize(.large)
+ .buttonStyle(MacDoorButtonStyle(prominent: leading))
.disabled(model.phase.busy)
case .passkey:
if leading {
@@ -679,31 +694,31 @@ struct MacFirstRun: View {
tapped = .passkey
Task { await model.signIn(into: app) }
} label: {
- HStack(spacing: 8) {
+ HStack(spacing: 6) {
if model.phase == .working && tapped == .passkey {
- ProgressView().controlSize(.small)
+ ProgressView().controlSize(.small).tint(.white)
+ } else if model.sessionSealed {
+ // The unlock moment gets the passkey glyph — the
+ // icon carries what the trimmed copy no longer
+ // spells out.
+ Image(systemName: "person.badge.key.fill")
}
Text(passkeyLeadLabel)
- .fontWeight(.semibold)
}
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
+ .buttonStyle(MacDoorButtonStyle(prominent: true))
.disabled(model.phase.busy)
} else {
- // Demoted: the quiet text path under the anchor doors. The
- // model still routes it — native ceremony where it can run,
+ // Demoted, but the same row shape — no mixed button types in
+ // the list; the Apple control fixes the geometry. The model
+ // still routes it — native ceremony where it can run,
// automatic browser failover everywhere else (no entitlement,
// 1Password's PRF mismatch, pre-15).
Button("Sign in with a passkey") {
tapped = .passkey
Task { await model.signIn(into: app) }
}
- .buttonStyle(.plain)
- .font(.subheadline.weight(.medium))
- .foregroundStyle(.secondary)
- .padding(.vertical, 2)
+ .buttonStyle(MacDoorButtonStyle())
.disabled(model.phase.busy)
}
}
@@ -711,7 +726,7 @@ struct MacFirstRun: View {
private var passkeyLeadLabel: String {
if model.phase == .working && tapped == .passkey { return "Signing in…" }
- if model.phase == .sealed { return "Unlock your spaces with your passkey" }
+ if model.sessionSealed { return "Unlock with your passkey" }
return "Sign in with a passkey"
}
@@ -752,14 +767,12 @@ struct MacFirstRun: View {
.onSubmit { if emailValid { sendCode() } } // Return advances
.accessibilityLabel("Your email address")
Button(action: sendCode) {
- HStack(spacing: 8) {
- if model.phase == .working { ProgressView().controlSize(.small) }
- Text("Send code").fontWeight(.semibold)
+ HStack(spacing: 6) {
+ if model.phase == .working { ProgressView().controlSize(.small).tint(.white) }
+ Text("Send code")
}
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
+ .buttonStyle(MacDoorButtonStyle(prominent: true))
.disabled(model.phase.busy || !emailValid)
// The web's note, word for word — under the address field.
Text("We never keep your email address. No spam, ever.")
@@ -785,14 +798,12 @@ struct MacFirstRun: View {
}
.accessibilityLabel("The 6-digit code from your email")
Button(action: verifyCode) {
- HStack(spacing: 8) {
- if model.phase == .working { ProgressView().controlSize(.small) }
- Text("Sign in").fontWeight(.semibold)
+ HStack(spacing: 6) {
+ if model.phase == .working { ProgressView().controlSize(.small).tint(.white) }
+ Text("Sign in")
}
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
+ .buttonStyle(MacDoorButtonStyle(prominent: true))
.disabled(model.phase.busy || code.count != 6)
}
}
@@ -835,11 +846,11 @@ struct MacFirstRun: View {
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
case .sealed:
- // The identity-vs-key split, said plainly: the anchor signed you
- // in; the passkey (promoted below) is what opens your spaces.
- Text("Signed in — your spaces are locked with your passkey. Unlock them below.")
- .font(.callout).foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
+ // Deliberately NOTHING: "signed in but locked" reads as noise
+ // when no spaces are visible (Paul: if you can't see your
+ // spaces, you're not signed in). The unlock button — a passkey
+ // icon and five words — is the whole story.
+ EmptyView()
case .restored(let n):
// Visible only in the beat before the rail fills and RootView
// swaps this screen out.
@@ -886,3 +897,25 @@ private struct AppleDoorButton: NSViewRepresentable {
@objc func tap() { action() }
}
}
+
+// 🤖 One geometry for every door — the Mac sizing of the iOS DoorButtonStyle
+// (Paul, 2026-07-19: the Apple control fixes the shape; no mixed button
+// types in the list). Prominent = the promoted door / a step's main action.
+// The pressed door hosts its own spinner, so busy state lives in the button.
+private struct MacDoorButtonStyle: ButtonStyle {
+ var prominent = false
+ @Environment(\.isEnabled) private var enabled
+
+ static let radius: CGFloat = 8
+ static let height: CGFloat = 32
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .fontWeight(.medium)
+ .frame(maxWidth: .infinity, minHeight: Self.height)
+ .foregroundStyle(prominent ? Color.white : Color.accentColor)
+ .background(prominent ? Color.accentColor : Color.primary.opacity(0.07),
+ in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous))
+ .opacity((configuration.isPressed ? 0.75 : 1) * (enabled ? 1 : 0.55))
+ }
+}