Skip to content

feat(connect): Phase 1 — Connect tab + picker unification#52

Merged
DocNR merged 23 commits into
mainfrom
feat/connect-tab-restructure
May 12, 2026
Merged

feat(connect): Phase 1 — Connect tab + picker unification#52
DocNR merged 23 commits into
mainfrom
feat/connect-tab-restructure

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented May 12, 2026

Summary

Phase 1 of multi-account NostrConnect. Connect graduates from a HomeView-presented sheet to a top-level cross-account tab; all account-binding consent for pairing flows through a single unified ConnectAccountPicker. No protocol changes; Phase 2 (accounts=multi opt-in) follows separately.

What ships

  • New Connect tab (4th position, ⚡ bolt.fill) + new Discover stub tab (5th)
  • Tab order: Home / Activity / Connect / Discover / Settings
  • Unified ConnectAccountPicker (renamed from DeeplinkAccountPicker) used by all 3 entry paths (in-app NostrConnect, in-app bunker, external deeplink) — auto-skips at N=1, full-sheet with profile pictures + truncated npubs when N≥2
  • handleNostrConnect signature refactored to (parsedURI:, signerPubkeys: [String], permissions:) → HandshakeResult. Phase 1 always passes a 1-element array; Phase 2 enables N > 1
  • Per-account bunker flow: picker before render, account-header card on the URI screen (avatar + display name + truncated npub), rotateBunkerSecret(for:) per-signer (fixes a latent footgun where the bunker tab always showed currentAccount's URI)
  • Bunker affordance promoted from a buried "Or share a code from Clave →" to a prominent action card right below the paste field
  • New ConnectHelpSheet: "Nostrconnect vs Bunker" comparison including a callout about the same-device pairing reliability gotcha (iOS suspending WebSockets on the other app during nostrconnect handshakes)
  • Old Clave/Views/Home/Connect/ directory deleted (ConnectSheet, ConnectNostrconnectTabView, ConnectBunkerTabView, ConnectAccountContextBar, DeeplinkAccountPicker — all superseded)
  • HomeView empty-state CTA updated to point at the Connect tab; no longer presents ConnectSheet

Build state

  • Current TestFlight build: 79 (external rollout in progress)
  • pbxproj bump series on the branch: 71 → 73 → 74 → 75 → 76 → 77 → 78 → 79 (72 skipped, 73 burned from wrong branch)
  • 246 unit tests pass
  • New test files: HandshakeResultTests, AppStateHandshakeSignatureTests, ConnectAccountPickerAutoSkipTests

Out of scope

  • Phase 2 protocol extension (accounts=multi URI flag, multi-select picker, N-up handshake loop) — separate PR gated on Tableau-side implementation
  • Center-prominent visual treatment for the Connect tab — discussed during smoke testing, deferred to a UI polish PR
  • Tableau-side parallel plan in /Users/danielwyler/tableau/docs/superpowers/plans/ — separate brainstorm cycle when Phase 2 is near merge

Spec + plan

  • Spec: docs/superpowers/specs/2026-05-10-multi-account-nostrconnect-design.md (on local branch spec/multi-account-nostrconnect — can push separately if useful for review reference)
  • Plan: docs/superpowers/plans/2026-05-10-multi-account-nostrconnect-plan.md (same branch)
  • 14-task implementation plan + 5 smoke-fix iterations from real-device testing

Test plan

  • All 246 unit tests pass (xcodebuild test)
  • Internal TestFlight smoke (build 79): Connect tab present, picker fires on multi-account, bunker URI renders per-account, deeplink path unchanged, HomeView empty state correct
  • External TestFlight smoke (build 79 just pushed to external testers)
  • After merge: tag v0.2.0-build79, GitHub release marked Pre-release per v0.2.0-build60/62 convention

23 commits on the branch; recommend squash-merge per project convention (#45, #46 were squashed).

🤖 Generated with Claude Code

DocNR and others added 23 commits May 10, 2026 22:01
Phase 1 of multi-account NostrConnect. New return type for the
refactored handleNostrConnect signature: tracks succeeded + failed
signer pubkeys with derived isAllSuccess / isAllFailure /
isPartialFailure conveniences. FailedSigner stores error as String
for Equatable conformance (tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sult

Phase 1 of multi-account NostrConnect. Refactors handleNostrConnect
from `(parsedURI:, permissions:, boundAccountPubkey:)` to
`(parsedURI:, signerPubkeys:, permissions:) → HandshakeResult`. The
implementation iterates over the array; Phase 1 always passes
exactly 1 element so behavior is identical to today.

Body of the existing function moves into a private runSingleConnect
helper that takes the single signer pubkey explicitly. The public
function is the iteration wrapper that collects per-signer success
/ failure into HandshakeResult.

All known call sites updated to pass a 1-element array. Empty
signerPubkeys throws ClaveError.noSignerKey at the boundary.

No protocol change; pure refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Task 1 implementation made isAllSuccess vacuously true on empty
results (failed.isEmpty alone) to satisfy the plan's contradictory
test assertion. Task 2's code review flagged this as a Phase 2
hazard — partial-failure UX in ApprovalSheet keys on isAllSuccess,
and an empty result auto-dismissing as "signed in for nobody" would
be a broken UI state.

Tightens to !succeeded.isEmpty && failed.isEmpty: empty result is
explicitly NOT a success. Empty input remains unreachable in
production (handleNostrConnect guards against empty signerPubkeys),
so this is a type-contract clarification, not a behavioral change.

testEmptyResult assertion updated to match: empty → all three
booleans are false (no state — neither success nor failure).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect. The deeplink picker becomes
the unified account-binding component used by all connect entry
paths: in-app NostrConnect (future task), in-app bunker (future
task), and external deeplinks (this rename's primary call site).
Picker gains a Mode enum — only .single is implemented in Phase 1;
.multi lands in Phase 2.

onPick changes from `(String) -> Void` to `([String]) -> Void` to
match handleNostrConnect's array signature. Single mode always
passes a 1-element array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two findings from code quality review:

1. Shared/DeeplinkRouter.swift:11 still referenced the old
   DeeplinkAccountPicker name in a doc comment — missed by the
   rename sweep in 5f502f8.

2. Clave/Views/Home/HomeView.swift used `pubkeys[0]` to extract the
   single-mode element. The picker .single mode always sends a
   1-element array today, but the subscript is a latent crash
   surface if a future caller ever sends an empty array. Replaced
   with `guard let pubkey = pubkeys.first else { return }`.

Both are mechanical cleanups; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect. Static predicate the caller
checks BEFORE presenting the picker — when true, the caller calls
onPick directly with the single account's pubkey rather than
rendering a degenerate one-row sheet.

Skip rule: accountCount == 1. Zero accounts is NOT skipped (caller
routes to onboarding); 2+ accounts presents normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect. The primary surface inside
the new Connect tab — camera viewfinder + paste field + help link,
plus a new "Or share a code from Clave →" secondary affordance at
the bottom that pushes into the bunker view (Task 6).

Camera, paste, scan dedup, and validation logic are direct copies
of the to-be-deleted ConnectNostrconnectTabView (Task 11). The
bunker affordance is new — implements UX shape γ per the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect. Bunker child route under the
new Connect tab. Picker fires first (when N >= 2) so the user picks
the account whose bunker URI to share; otherwise auto-skips and
renders directly.

BunkerURIRender takes signerPubkey as an explicit parameter — fixes
the latent footgun where today's bunker tab shows currentAccount's
URI even when the user might intend a different account.

AppState.rotateBunkerSecret(for:) added as the per-signer variant of
rotateBunkerSecret() — delegates to SharedStorage.rotateBunkerSecret(for:)
and bumps bunkerSecretsTick so views re-evaluate. bunkerURI(for:) was
already per-signer (no change needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tate

Two findings from code quality review:

1. ConnectBunkerView's .onAppear could fire more than once after a
   sheet dismissal, re-presenting the picker the user just
   cancelled (trapping multi-account users who tap Cancel). Added
   didInvokePickerOrAutoSkip one-shot guard so presentOrAutoSkip
   runs at most once per view lifetime.

2. rotateBunkerSecret() (current-account variant) now delegates to
   rotateBunkerSecret(for: signerPubkeyHex) so it inherits the new
   accounts.contains(...) membership guard for free. Eliminates a
   theoretical gap where a stale currentAccount reference could
   rotate secrets for a no-longer-known account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect. Top-level view for the new
Connect tab. Hosts ConnectNostrConnectSurface as primary content,
navigates to ConnectBunkerView via the "Or share a code from Clave"
secondary affordance.

State machine for NostrConnect flow: paste/scan → parse → picker
(auto-skipped if N=1) → ApprovalSheet → handshake under
UIBackgroundTask (build-62 pattern preserved). Single-mode only;
multi-mode wiring lands in Phase 2.

Not yet wired into MainTabView — that happens in Task 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect. Promotes Connect from a
HomeView-presented sheet to a top-level cross-account tab.

Position: between Home and Activity. Rationale per spec — Home is
per-account dashboard (primary), Connect is action (second),
Activity is review (third), Settings last.

The old Connect sheet trigger on HomeView is removed in Task 9.
The old ConnectSheet/Connect/ files are deleted in Task 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect. HomeView no longer presents
ConnectSheet — Connect is now its own top-level tab (Task 8). The
sheet trigger button is removed; the empty-state CTA for users with
zero paired clients is replaced with a Text label pointing at the
Connect tab.

This makes Connect a cross-account action surface (matches Activity
and Settings, which were already cross-account). Per-account
binding for connect-time actions now happens explicitly via
ConnectAccountPicker at the moment of pairing, not implicitly via
the identity-bar selection.

ConnectSheet.swift itself is deleted in Task 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…emoval

Task 9 removed handlePairNewConnectionTap (the only setter of
showConnectionCapAlert in HomeView), leaving the @State declaration
and the .alert modifier as dead code — the alert could never fire.
Removing both.

Note: ApprovalSheet has its own independent showConnectionCapAlert
state which is unrelated and unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect. Removes files in
Clave/Views/Home/Connect/ that have been superseded:
- ConnectSheet.swift (replaced by ConnectTabView, Task 7)
- ConnectNostrconnectTabView.swift (replaced by ConnectNostrConnectSurface, Task 5)
- ConnectBunkerTabView.swift (replaced by ConnectBunkerView + BunkerURIRender, Task 6)

Moves Clave/Views/Home/Connect/ConnectHelpSheet.swift →
Clave/Views/Connect/ConnectHelpSheet.swift (still in use by
ConnectNostrConnectSurface).

DeeplinkAccountPicker.swift was already removed in Task 3.

ConnectAccountContextBar.swift: deleted (only used by removed ConnectSheet).

NostrConnectURISource enum was defined inside ConnectNostrconnectTabView;
rescued into ConnectNostrConnectSurface.swift (which owns the onParsed
callback that uses the type) so ConnectTabView.swift compiles cleanly.

The Clave/Views/Home/Connect/ directory is now empty and removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect (feat/connect-tab-restructure).
14-task implementation complete; this build is for internal TestFlight
smoke testing before PR/merge to main. Build 72 skipped (was burned
in a prior archive that didn't ship).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 73 was uploaded from feat/ios-entitlement-service by mistake
(wrong worktree). That upload contained the entitlement work, not
Phase 1's Connect tab. Bumping to 74 for the correct Phase 1
TestFlight archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported smoke-test bug: tapping "Or share a code from Clave"
on the Connect tab pushed an empty ConnectBunkerView that immediately
presented the picker as a sheet from .onAppear. If the user cancelled
the picker, the blank pushed view remained in the nav stack — confusing
"blank sheet behind the half sheet" UX.

Fix: move picker presentation up to ConnectTabView's tap handler for
the bunker affordance.
  - N=1: navigate directly to BunkerURIRender (no picker)
  - N>=2: present picker as a sheet from ConnectTabView; on pick,
    navigate to BunkerURIRender; on cancel, just dismiss the sheet
    (no orphan push)
ConnectBunkerView is deleted — no longer needed; nav destination
renders BunkerURIRender directly with the title applied inline.

Also widen ConnectAccountPicker detents from [.medium] to
[.medium, .large] — medium got cut off on multi-account devices
with 4+ accounts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account NostrConnect, smoke-test follow-up.
New tab order: Home / Activity / Connect / Discover / Settings.

DiscoverView is a stub — will eventually surface NIP-46-compatible
Nostr clients (Damus, Yakihonne, Stacker.news, nostrudel, Tableau,
etc.) with one-tap pairing flows. For now, "Coming soon" placeholder.

Tab order rationale: Home is the per-account dashboard; Activity is
the cross-account log (review-oriented); Connect is the action
surface (sits in the natural middle position); Discover is the new
ecosystem surface; Settings stays last.

A center-prominent visual treatment for the Connect tab was
discussed but deferred to a follow-up PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Includes the bunker picker presentation fix (6593f75) and the
Discover tab stub + tab reorder (b15053d). Both addressed during
initial real-device smoke testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…75 → 76

User feedback during smoke test: the link icon felt wrong for the
Connect tab. Switching to bolt.fill (matches the mockup in the
tab-redesign discussion) — feels more action-oriented for what is
fundamentally a "do a thing" surface.

Build bump folded in to avoid a separate commit; 75 was bumped but
not archived.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three smoke-test fixes to ConnectAccountPicker:
  1. Removed .presentationDetents([.medium, .large]) so the sheet
     defaults to full height. Half-sheet was getting cut off on
     multi-account devices with 4+ accounts.
  2. accountAvatar(for:) helper wraps AvatarView in AsyncImage when
     account.profile?.pictureURL is available — matches the pattern
     in HomeView.clientRow and ApprovalSheet. AvatarView (initials +
     pubkey-derived gradient) stays as the loading/failure placeholder.
  3. Subtitle now shows bech32-encoded npub ("npub1abc234…wxyz")
     instead of raw hex. Added Nip19.encodeNpub helper alongside the
     existing encodeNote / encodeNevent.

Bumps pbxproj 76 → 77 in the same commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual reminder of which account's bunker URI you're sharing — a
labelled header card at the top of the bunker render screen
("Sharing as @alice" + truncated npub + avatar). Helps when you
navigate here from the picker since the URI itself is opaque hex.

Reuses the accountAvatar pattern from ConnectAccountPicker
(AsyncImage with AvatarView fallback) and Nip19.encodeNpub for the
truncated npub display.

Bumps pbxproj 77 → 78 in the same commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ods guide

Promote bunker affordance from a buried "Or share a code from Clave →"
at the bottom of the NostrConnect surface to a prominent action card
positioned right below the paste field — qrcode icon, accent border,
larger title weight, dropped the "Or" prefix.

Repurpose the help link as an educational entry point: was "What's a
nostrconnect URI?" → now "What's the difference between Nostrconnect
and Bunker?" Smaller (footnote) and positioned below the bunker card.

Rewrite ConnectHelpSheet to be a methods comparison:
  - Card per method (Nostrconnect, Bunker) with URI scheme chip and
    plain-language description
  - Same-device callout (orange tint) explaining the iOS-suspends-
    WebSocket gotcha that makes Bunker preferred for on-device pairing,
    citing the failure mode (handshake fails silently) without going
    into protocol jargon

Bumps pbxproj 78 → 79.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DocNR DocNR merged commit d9378d9 into main May 12, 2026
@DocNR DocNR deleted the feat/connect-tab-restructure branch May 12, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant