mac: locked-pane phase-0 rungs — Link from Your iPhone / Ask for a New Invite (#1277)
The Mac's locked-space pane (a rail entry whose keys aren't on this Mac) offered exactly one rung — Sign In with Eleven — plus destructive removal. accounts-and-recovery.md phase 0 promised the missing rungs; the consume side (SpaceClaim adopting add-device links via the Add-from-link sheet) already existed, so the rungs open that sheet with contextual guidance. Also: when a sign-in lands but the account holds no key for this space, the pane now says so instead of silently staying locked.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+42
−10
docs/accounts-and-recovery.md+9 −4ios/LChatMac/AddFromLinkSheet.swift+7 −1ios/LChatMac/LChatMacApp.swift+8 −1ios/LChatMac/RootView.swift+18 −4
diff --git a/docs/accounts-and-recovery.md b/docs/accounts-and-recovery.md| index 00b87a9..c7b672d 100644 |
| --- a/docs/accounts-and-recovery.md |
| +++ b/docs/accounts-and-recovery.md |
| @@ -6,8 +6,9 @@ home (passkey, email code, Apple, Google, Microsoft, GitHub — the #1112 card), |
| identity anchors + ceremony proofs (`stateless.go`), the Sign-in options |
| management step, account combine/merge (#1098), sealed anchor sessions, and |
| the native passkey/Apple/email doors. Merged but dark: the synced DEK |
| -(#1144, flag off pending the device matrix). Not built yet: the phase-0 |
| -locked-card rungs and `/invite` link redesign, owner anchoring at creation, |
| +(#1144, flag off pending the device matrix). Not built yet: the `/invite` |
| +link redesign (the phase-0 locked-card rungs shipped on the Mac 2026-07-22; |
| +native add-device mint/QR still open), owner anchoring at creation, |
| approve-from-another-device, paper kits, and the native OIDC doors (server |
| legs done, client work open) — see *Phasing* below, per-item. |
| This document supersedes the *Invariants* section of |
| @@ -359,8 +360,12 @@ Status per item as of 2026-07-22: |
| missing rungs — "Link from your iPhone" (add-device QR) and "Ask for a |
| new invite" — and failure copy that never dead-ends at a ceremony sheet. |
| The **cosmetic invite-link redesign** (`/invite#Name/<T>`) also rides |
| - here: no crypto change, old links parse forever. — **not built** (the Mac |
| - locked pane still shows the two-rung ladder; no `/invite` route exists). |
| + here: no crypto change, old links parse forever. — **partially built** |
| + (2026-07-22): the Mac locked pane now carries both rungs (each opens the |
| + Add-from-link sheet with contextual guidance — the consume side of |
| + add-device links already worked) plus signed-in-but-still-locked |
| + feedback. Still open: a native mint/QR for add-device links (minting is |
| + web-only today), and no `/invite` route exists. |
| 1. **SiwA + synced DEK** on iOS/Mac; web gains approve-from-phone. |
| Identity-first onboarding lands with it — the vault becomes the handoff |
| bus and web↔native jumps stop losing fidelity. — **partially shipped**: |
diff --git a/ios/LChatMac/AddFromLinkSheet.swift b/ios/LChatMac/AddFromLinkSheet.swift| index be2597f..7cf4e6e 100644 |
| --- a/ios/LChatMac/AddFromLinkSheet.swift |
| +++ b/ios/LChatMac/AddFromLinkSheet.swift |
| @@ -13,21 +13,27 @@ struct AddFromLinkSheet: View { |
| @State private var link = "" |
| @State private var busy = false |
| @State private var error: String? |
| + @State private var hint: String? |
| /// The browser handoff's pre-fill (onOpenURL stashes the validated link; |
| /// see LChatMacApp): consumed once, so a later manual open starts blank. |
| + /// The locked pane's rungs stash contextual guidance the same way. |
| private func consumePendingLink() { |
| if let pending = app.pendingAddLink { |
| link = pending |
| app.pendingAddLink = nil |
| } |
| + if let h = app.addFromLinkHint { |
| + hint = h |
| + app.addFromLinkHint = nil |
| + } |
| } |
| var body: some View { |
| VStack(alignment: .leading, spacing: 12) { |
| Text("Add a space from a link") |
| .font(.headline) |
| - Text("Paste an invite link — the space joins this Mac and syncs to your Eleven account.") |
| + Text(hint ?? "Paste an invite link — the space joins this Mac and syncs to your Eleven account.") |
| .font(.callout) |
| .foregroundStyle(.secondary) |
| .fixedSize(horizontal: false, vertical: true) |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index 7952730..17ebcf8 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -268,9 +268,16 @@ final class MacAppState: ObservableObject { |
| // Add-from-link sheet — the sheet IS the confirming click; a URL arriving |
| // from outside the app must never join silently. Consumed on sheet appear. |
| @Published var pendingAddLink: String? |
| + // Context for the sheet when a rung opens it (the locked pane's "Link |
| + // from Your iPhone…" / "Ask for a New Invite…") — replaces the sheet's |
| + // generic subtitle for that one presentation. Consumed on sheet appear. |
| + @Published var addFromLinkHint: String? |
| func requestCreateSpace() { createSpaceTick += 1 } |
| - func requestAddFromLink() { addFromLinkTick += 1 } |
| + func requestAddFromLink(hint: String? = nil) { |
| + addFromLinkHint = hint |
| + addFromLinkTick += 1 |
| + } |
| // The Spaces menu's rail mirror: favourites when any exist, else the first |
| // nine active spaces (⌘1…⌘9 follow this order); the rest fold under More. |
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift| index dc7722b..725271c 100644 |
| --- a/ios/LChatMac/RootView.swift |
| +++ b/ios/LChatMac/RootView.swift |
| @@ -146,14 +146,16 @@ struct RootView: View { |
| // A rail entry with no native identity: signing in again pulls the key |
| // from the member's Eleven account; there is deliberately no web fallback. |
| - // Sign-in is the pane's PRIMARY action — the copy tells you to sign in, so |
| - // the button must be right here, not hidden in Settings (removal stays as |
| - // the destructive escape hatch). |
| + // Sign-in leads, but it is never the only rung — a one-rung ladder reads |
| + // as "the app is broken", so the pane always offers the add-device link |
| + // and re-invite paths too, and every failure leaves them in reach |
| + // (accounts-and-recovery.md, phase 0). Removal stays as the destructive |
| + // escape hatch. |
| private func lockedPane(_ chat: SavedChat) -> some View { |
| ContentUnavailableView { |
| Label(app.serverName(chat), systemImage: "lock") |
| } description: { |
| - Text("This space can't be unlocked on this Mac. Sign in with Eleven to bring its key back, or remove it from the rail.") |
| + Text("This space can't be unlocked on this Mac yet. Sign in with Eleven to bring its key back, link it from another device, or ask a member for a fresh invite.") |
| } actions: { |
| if firstRunSignIn.phase.busy { |
| ProgressView().controlSize(.small) |
| @@ -163,10 +165,22 @@ struct RootView: View { |
| } |
| .buttonStyle(.borderedProminent) |
| } |
| + Button("Link from Your iPhone…") { |
| + app.requestAddFromLink(hint: "On your iPhone (or anywhere you're already in this space), open the space menu, choose Add a device, and bring the link here — it signs this Mac in as you, with your history.") |
| + } |
| + Button("Ask for a New Invite…") { |
| + app.requestAddFromLink(hint: "Any member of this space can send you a fresh invite link — add it here. You'll rejoin as a new member.") |
| + } |
| Button("Remove from This Mac", role: .destructive) { app.remove(chat) } |
| if case .failed(let msg) = firstRunSignIn.phase { |
| Text(msg).font(.footnote).foregroundStyle(.red) |
| .frame(maxWidth: 420) |
| + } else if firstRunSignIn.phase.signedIn { |
| + // Signed in, yet this pane still renders: the account didn't |
| + // hold a key for this space — say so instead of dead-ending. |
| + Text("You're signed in, but that account doesn't hold a key for this space. Link it from a device that's already in, or ask a member for a fresh invite.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + .frame(maxWidth: 420) |
| } |
| } |
| } |