An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add MM-150 wallet home screen design plan

Completed brainstorming session. Design includes:
- Flat OnboardingStep extension with home, did_document, recovery_info steps
- Combined load_home_data Tauri command (parallel health + getSession + Keychain check)
- Four new Svelte components in src/lib/components/home/
- 6 implementation phases

authored by

Malpercio and committed by
Tangled
37abcacd c11087a6

+231
+231
docs/design-plans/2026-03-27-MM-150.md
··· 1 + # MM-150: Wallet Home Screen Design 2 + 3 + ## Summary 4 + 5 + MM-150 builds the post-onboarding home screen for the identity wallet: the first thing a user sees after completing account setup or re-opening the app while already authenticated. The screen presents the user's core identity information — their DID, handle, and email drawn from the relay's `getSession` endpoint — alongside live connectivity indicators and three action flows: viewing the DID document, logging out, and inspecting their Shamir recovery share status. 6 + 7 + The implementation extends the existing flat `OnboardingStep` state machine in `+page.svelte` with three new steps (`home`, `did_document`, `recovery_info`) and adds a new Rust module (`home.rs`) exposing two Tauri commands. `load_home_data` fires the relay health check and `getSession` concurrently, then checks the Keychain for the first recovery share, packaging all results into a single `HomeData` struct that always succeeds — partial failures (relay unreachable, session expired) are represented as fields rather than errors, so the UI renders whatever is available. `log_out` wipes the three OAuth-related Keychain entries and clears the in-memory session, then always returns `Ok(())` so the frontend unconditionally navigates to the welcome screen. Four Svelte components in `src/lib/components/home/` render the data, following the same `$props()`, scoped CSS, and IPC-wrapper conventions established by the onboarding screens. 8 + 9 + ## Definition of Done 10 + 11 + 1. A `HomeScreen` Svelte component replaces the `authenticated` stub in `+page.svelte`, showing the user's DID (truncated + copy button), handle, email, and a deterministic DID-derived avatar — all loaded from `getSession` on mount. 12 + 2. Relay and session status indicators are shown accurately: relay connectivity via `GET /xrpc/_health` (connected / error), session validity via `getSession` success/failure after transparent OAuth auto-refresh (active / error — two states only). 13 + 3. Three action flows are implemented and working: View DID Document (structured sheet with optional raw JSON toggle), Log out (clears OAuth access + refresh tokens and DID from Keychain, returns to welcome screen), Recovery info (read-only display of Share 1 in Keychain ✓, Share 2 on relay ✓, Share 3 user-managed). 14 + 4. New Tauri commands (`get_session`, `check_health`, `log_out`) and their `ipc.ts` typed wrappers are in place, following existing IPC patterns. 15 + 5. The app launches directly to the home screen when already onboarded (existing `auth_ready` event mechanism — no new work required). 16 + 17 + ## Acceptance Criteria 18 + 19 + ### MM-150.AC1: Identity card displays correctly 20 + - **MM-150.AC1.1 Success:** Home screen shows the user's handle from `getSession` response 21 + - **MM-150.AC1.2 Success:** DID is displayed truncated as `did:plc:XXXXXXXX…XXXXXX` (full prefix + first 8 + `…` + last 6 of method-specific part) 22 + - **MM-150.AC1.3 Success:** Copy button copies the full untruncated DID to clipboard 23 + - **MM-150.AC1.4 Success:** Email from `getSession` is shown 24 + - **MM-150.AC1.5 Success:** DID-derived avatar circle is visible with a stable hue derived from the DID hash 25 + - **MM-150.AC1.6 Success:** Avatar shows the first letter of the handle as its initial 26 + - **MM-150.AC1.7 Edge:** Avatar shows `?` when handle is `handle.invalid` 27 + - **MM-150.AC1.8 Edge:** Loading spinner is shown while `loadHomeData()` is in flight 28 + 29 + ### MM-150.AC2: Status indicators are accurate 30 + - **MM-150.AC2.1 Success:** Relay status shows Connected when `_health` returns 200 31 + - **MM-150.AC2.2 Failure:** Relay status shows Error when `_health` returns non-200 or network fails 32 + - **MM-150.AC2.3 Success:** Session status shows Active when `getSession` succeeds 33 + - **MM-150.AC2.4 Failure:** Session status shows Error when `getSession` fails after OAuthClient refresh attempt 34 + - **MM-150.AC2.5 Edge:** Relay and session statuses are independent — one can be error while the other is active 35 + 36 + ### MM-150.AC3: Three action flows work 37 + - **MM-150.AC3.1 Success:** Log out clears `oauth-access-token`, `oauth-refresh-token`, and `did` from Keychain 38 + - **MM-150.AC3.2 Success:** Log out navigates to the welcome screen 39 + - **MM-150.AC3.3 Success:** Device key and DPoP key remain in Keychain after logout 40 + - **MM-150.AC3.4 Success:** Tapping View DID Document navigates to `did_document` step 41 + - **MM-150.AC3.5 Success:** DID document view shows `id`, `alsoKnownAs`, `verificationMethod`, and `service` fields in structured form 42 + - **MM-150.AC3.6 Success:** Raw JSON toggle reveals the full DID document as a monospace block 43 + - **MM-150.AC3.7 Success:** Key copy button copies `publicKeyMultibase` value to clipboard 44 + - **MM-150.AC3.8 Edge:** View DID Document button is hidden when `session.didDoc` is null 45 + - **MM-150.AC3.9 Success:** Back from DID document returns to home 46 + - **MM-150.AC3.10 Success:** Tapping Recovery Info navigates to `recovery_info` step 47 + - **MM-150.AC3.11 Success:** Share 1 shows ✓ when `recovery-share-1` exists in Keychain 48 + - **MM-150.AC3.12 Failure:** Share 1 shows ✗ when `recovery-share-1` is absent from Keychain 49 + - **MM-150.AC3.13 Success:** Share 2 always shows ✓ (static relay custody fact from onboarding) 50 + - **MM-150.AC3.14 Success:** Back from recovery info returns to home 51 + 52 + ### MM-150.AC4: Tauri commands and IPC wrappers 53 + - **MM-150.AC4.1 Success:** `load_home_data` returns `relayHealthy: true` when `_health` returns 200 54 + - **MM-150.AC4.2 Success:** `load_home_data` returns populated `session` when `getSession` succeeds 55 + - **MM-150.AC4.3 Failure:** `load_home_data` returns `relayHealthy: false` (with `session` still populated) when `_health` fails 56 + - **MM-150.AC4.4 Failure:** `load_home_data` returns `session: null` and `sessionError` populated when `getSession` fails 57 + - **MM-150.AC4.5 Success:** `load_home_data` always returns `Ok(HomeData)` — never `Err` — regardless of partial sub-call failures 58 + - **MM-150.AC4.6 Success:** `log_out` deletes OAuth tokens and DID from Keychain 59 + - **MM-150.AC4.7 Success:** `log_out` always returns `Ok(())` even if Keychain delete partially fails 60 + 61 + ### MM-150.AC5: App launches to home when already onboarded 62 + - **MM-150.AC5.1 Success:** App starts at the `home` step (not welcome) when OAuth tokens exist in Keychain on launch 63 + - **MM-150.AC5.2 Success:** `homeData` is loaded on mount of `HomeScreen` regardless of entry path (startup or post-onboarding) 64 + 65 + ## Glossary 66 + 67 + - **ATProto (AT Protocol)**: The open federated protocol developed by Bluesky on which this application is built. Defines the XRPC endpoint naming convention (`com.atproto.server.*`), DID method (`did:plc`), and handle resolution semantics used throughout the design. 68 + - **DID (Decentralized Identifier)**: A globally unique, self-sovereign identifier specified by W3C. In this app the format is `did:plc:<method-specific-id>`. Created during onboarding and stored in the Keychain. 69 + - **DID document**: A JSON object anchored to a DID that lists the subject's public keys, also-known-as handles, and service endpoints. Retrieved from `getSession` via the relay's `did_documents` table; absent when no document has been published for a DID. 70 + - **DPoP (Demonstrating Proof of Possession)**: An OAuth extension (RFC 9449) that cryptographically binds access tokens to a client-held P-256 key pair, preventing token theft. The identity wallet generates a DPoP key at first OAuth flow and reuses it across sessions. 71 + - **Keychain**: The iOS system credential store accessed via Apple's Security framework. Entries are keyed by account name (e.g. `"oauth-access-token"`, `"recovery-share-1"`) under the service `"ezpds-identity-wallet"`. 72 + - **OAuthClient**: The app-internal Rust struct (`src-tauri/src/oauth_client.rs`) that wraps every HTTP request to the relay with `Authorization: DPoP {access_token}` and a fresh DPoP proof header, and transparently refreshes the access token when less than 60 seconds remain. 73 + - **`getSession` (`com.atproto.server.getSession`)**: An ATProto XRPC endpoint that returns the authenticated user's DID, handle, email, and optionally their DID document. Primary data source for the home screen identity card. 74 + - **`_health` (`GET /xrpc/_health`)**: A lightweight relay endpoint returning HTTP 200 when the server is operational. Used by `load_home_data` to determine the relay connectivity indicator. 75 + - **Shamir recovery shares**: Three pieces of a secret split using Shamir's Secret Sharing during the DID ceremony. Share 1 is in the iOS Keychain (auto-backed up to iCloud); Share 2 is held by the relay; Share 3 is given to the user as a manual backup. 76 + - **`handle.invalid`**: An ATProto sentinel string returned by the relay when an account has no registered handle. Used to decide whether to show `?` as the avatar initial. 77 + - **`OnboardingStep` union**: A TypeScript discriminated union in `src/routes/+page.svelte` that is the single source of navigation truth for the entire app. This ticket adds `home`, `did_document`, and `recovery_info` to this union. 78 + - **Tauri IPC / `invoke()`**: Tauri v2's bridge for calling Rust functions from the JavaScript frontend. All `invoke()` calls are wrapped in typed functions in `src/lib/ipc.ts`. 79 + - **`tokio::join!`**: A Rust macro that runs multiple futures concurrently, collecting all their results. Used in `load_home_data` to fire the health check and `getSession` in parallel. 80 + - **Svelte 5 `$props()`**: The Svelte 5 runes API for declaring a component's typed props. All home screen components use this pattern. 81 + - **`publicKeyMultibase`**: A field in a DID document verification method encoding a public key in multibase format. The DID Document screen includes a copy button for this value. 82 + - **`WKWebView`**: Apple's iOS WebKit-based web view used by Tauri v2 to render the SvelteKit frontend. CSS `overscroll-behavior` (pull-to-refresh) does not work here, which is why the design uses a refresh button instead. 83 + 84 + ## Architecture 85 + 86 + The home screen extends the existing flat `OnboardingStep` state machine in `src/routes/+page.svelte`. Three new steps are added: `home` (replaces the `authenticated` stub), `did_document`, and `recovery_info`. Navigation between them is bidirectional: `did_document` and `recovery_info` both return to `home` via a back button. 87 + 88 + Page-level state holds `homeData: HomeData | null` so sub-screens receive the already-loaded data without re-fetching. 89 + 90 + A new Tauri module `src-tauri/src/home.rs` provides two commands: 91 + 92 + - **`load_home_data`** — fires `GET /xrpc/_health` and `GET /xrpc/com.atproto.server.getSession` concurrently via `tokio::join!`, then checks the Keychain for the `recovery-share-1` entry. Always returns `Ok(HomeData)` — partial failures are encoded in the struct so the UI can render whatever succeeded. 93 + - **`log_out`** — deletes `oauth-access-token`, `oauth-refresh-token`, and `did` from the Keychain and clears `AppState.oauth_session`. Always returns `Ok(())` so the frontend always proceeds to the welcome screen regardless of Keychain state. 94 + 95 + IPC contract additions to `src/lib/ipc.ts`: 96 + 97 + ```typescript 98 + interface SessionInfo { 99 + did: string; 100 + handle: string; 101 + email: string; 102 + emailConfirmed: boolean; 103 + didDoc: Record<string, unknown> | null; 104 + } 105 + 106 + interface HomeData { 107 + relayHealthy: boolean; 108 + session: SessionInfo | null; // null when getSession failed 109 + sessionError: string | null; // SCREAMING_SNAKE error code when session is null 110 + share1InKeychain: boolean; // live Keychain check for "recovery-share-1" 111 + } 112 + 113 + type LogOutError = { code: 'KEYCHAIN_ERROR' }; 114 + 115 + export const loadHomeData = (): Promise<HomeData> => invoke('load_home_data'); 116 + export const logOut = (): Promise<void> => invoke('log_out'); 117 + ``` 118 + 119 + Four new Svelte components live in `src/lib/components/home/`: 120 + 121 + - **`DIDAvatar.svelte`** — deterministic gradient circle. Hue derived from a simple hash of the DID string; initial letter from the handle (falling back to `?` for `handle.invalid`). Zero dependencies, pure scoped CSS. 122 + - **`HomeScreen.svelte`** — identity card (avatar, handle, truncated DID with copy button, email), relay and session status cards (green/red dot), and three action buttons. Calls `loadHomeData()` on mount and on refresh button tap. 123 + - **`DIDDocumentScreen.svelte`** — structured view of the DID document: identity (`id`), also-known-as handle, verification keys with copy button, and services. Raw JSON toggle reveals a monospace block. Hidden/disabled if `session.didDoc` is null. 124 + - **`RecoveryInfoScreen.svelte`** — read-only display of the three Shamir shares: Share 1 (live Keychain check), Share 2 (static relay custody fact from onboarding), Share 3 (user-managed, always shown as manual backup reminder). 125 + 126 + **DID display truncation:** `did:plc:` prefix always shown in full; method-specific ID truncated to first 8 + `…` + last 6 characters (e.g., `did:plc:abcdefgh…xyz123`). 127 + 128 + ## Existing Patterns 129 + 130 + This design follows all established patterns from the `onboarding/` components and the `oauth.rs` Tauri module: 131 + 132 + - **Svelte 5 `$props()` + scoped CSS** — each home component follows the same structure as `WelcomeScreen.svelte`, `DIDSuccessScreen.svelte`, etc. 133 + - **IPC invariant** — all `invoke()` calls go through `src/lib/ipc.ts`; no raw `invoke()` in Svelte components. 134 + - **Error code serialization** — `{ code: "SCREAMING_SNAKE_CASE" }` matching `OAuthError`, `DIDCeremonyError`, etc. 135 + - **Tauri command module** — `home.rs` as a standalone module registered in `lib.rs::generate_handler![]`, following `oauth.rs`. 136 + - **Flat `OnboardingStep` union** — no routing layer introduced; the step union continues to be the single source of navigation truth. 137 + - **Color palette and layout** — `#007aff` primary, `#111827` text, `#6b7280` secondary, `#d1d5db` border, `#ef4444` error; 12 px border-radius; 320 px max-width; flex column layout. 138 + 139 + No new patterns are introduced. The CSS overlay / modal pattern was considered and rejected in favour of extending the existing step union for consistency. 140 + 141 + ## Implementation Phases 142 + 143 + <!-- START_PHASE_1 --> 144 + ### Phase 1: Tauri home module and IPC wrappers 145 + 146 + **Goal:** Rust commands and TypeScript wrappers that implement and expose the home screen data contract. 147 + 148 + **Components:** 149 + - `src-tauri/src/home.rs` — `load_home_data` command (parallel `_health` + `getSession` + Keychain check) and `log_out` command (Keychain wipe + AppState clear); `HomeData`, `SessionInfo`, `HomeError` types 150 + - `src-tauri/src/lib.rs` — register `home::load_home_data` and `home::log_out` in `generate_handler![]`; add `mod home;` 151 + - `src/lib/ipc.ts` — `loadHomeData()`, `logOut()`, `HomeData`, `SessionInfo`, `LogOutError` types 152 + 153 + **Dependencies:** None (OAuthClient and Keychain helpers already exist) 154 + 155 + **Done when:** `load_home_data` returns correct `HomeData` for authenticated and unauthenticated states; `log_out` clears Keychain entries; unit tests in `home.rs` pass covering MM-150.AC1, MM-150.AC2, MM-150.AC4 156 + <!-- END_PHASE_1 --> 157 + 158 + <!-- START_PHASE_2 --> 159 + ### Phase 2: DIDAvatar component 160 + 161 + **Goal:** Standalone, deterministic avatar component usable by HomeScreen. 162 + 163 + **Components:** 164 + - `src/lib/components/home/DIDAvatar.svelte` — accepts `did: string` and `handle: string` props; derives gradient hue from DID hash; displays handle initial (falls back to `?` for `handle.invalid`) 165 + 166 + **Dependencies:** Phase 1 (types available, but component is stateless so can be built in parallel) 167 + 168 + **Done when:** Avatar renders correctly for known DIDs; hue is stable across re-renders; handles `handle.invalid` fallback; covers MM-150.AC1 (avatar visible in identity card) 169 + <!-- END_PHASE_2 --> 170 + 171 + <!-- START_PHASE_3 --> 172 + ### Phase 3: HomeScreen component 173 + 174 + **Goal:** Main home screen displaying identity card, status indicators, and action buttons. 175 + 176 + **Components:** 177 + - `src/lib/components/home/HomeScreen.svelte` — calls `loadHomeData()` on mount; shows spinner during load (reusing `LoadingScreen` pattern); renders identity card with `DIDAvatar`, relay and session status cards, and action buttons; refresh button calls `loadHomeData()` again; `logOut()` on log-out button clears tokens and signals parent to navigate to `welcome` 178 + 179 + **Dependencies:** Phase 1 (IPC), Phase 2 (DIDAvatar) 180 + 181 + **Done when:** Home screen renders all identity fields; relay and session status indicators show correct state; log-out navigates to welcome; refresh button re-loads data; covers MM-150.AC1, MM-150.AC2, MM-150.AC3 (log out) 182 + <!-- END_PHASE_3 --> 183 + 184 + <!-- START_PHASE_4 --> 185 + ### Phase 4: State machine wiring 186 + 187 + **Goal:** Connect all home screens into the `+page.svelte` state machine. 188 + 189 + **Components:** 190 + - `src/routes/+page.svelte` — rename `authenticated` → `home` in `OnboardingStep` type and all call sites; add `did_document` and `recovery_info` to the union; add page-level `homeData: HomeData | null` state; add conditional rendering blocks for `home`, `did_document`, `recovery_info`; wire `HomeScreen`, `DIDDocumentScreen`, `RecoveryInfoScreen` with `homeData` prop and back-navigation callbacks 191 + 192 + **Dependencies:** Phase 3 (HomeScreen), Phase 5 (DIDDocumentScreen), Phase 6 (RecoveryInfoScreen) — can wire stub components first and replace 193 + 194 + **Done when:** `auth_ready` event navigates to `home` step; all three screens render and navigate correctly; covers MM-150.AC5 (app launches to home when onboarded) 195 + <!-- END_PHASE_4 --> 196 + 197 + <!-- START_PHASE_5 --> 198 + ### Phase 5: DIDDocumentScreen component 199 + 200 + **Goal:** Structured DID document viewer with raw JSON fallback. 201 + 202 + **Components:** 203 + - `src/lib/components/home/DIDDocumentScreen.svelte` — accepts `didDoc: Record<string, unknown>` prop; renders structured sections: identity (`id`), also-known-as, verification methods (with copy button for `publicKeyMultibase`), services; raw JSON toggle reveals monospace `<pre>` block; back button emits navigation event to parent 204 + 205 + **Dependencies:** Phase 4 (state machine wiring; receives `didDoc` from `homeData`) 206 + 207 + **Done when:** DID doc fields render correctly; raw JSON toggle works; copy button copies key to clipboard; back navigation returns to `home`; covers MM-150.AC3 (View DID Document flow) 208 + <!-- END_PHASE_5 --> 209 + 210 + <!-- START_PHASE_6 --> 211 + ### Phase 6: RecoveryInfoScreen component 212 + 213 + **Goal:** Read-only recovery share status display. 214 + 215 + **Components:** 216 + - `src/lib/components/home/RecoveryInfoScreen.svelte` — accepts `share1InKeychain: boolean` prop; shows Share 1 status (live Keychain result), Share 2 as static relay custody fact, Share 3 as manual backup reminder; back button emits navigation event to parent 217 + 218 + **Dependencies:** Phase 4 (state machine wiring; `share1InKeychain` from `homeData`) 219 + 220 + **Done when:** Share statuses render correctly; Share 1 reflects live Keychain check; back navigation returns to `home`; covers MM-150.AC3 (Recovery Info flow) 221 + <!-- END_PHASE_6 --> 222 + 223 + ## Additional Considerations 224 + 225 + **No OAuth revocation on logout:** The relay's `deleteSession` endpoint accepts a legacy HS256 refresh JWT, not an OAuth token. OAuth users have no server-side revocation endpoint. Local Keychain wipe is sufficient for v0.1. 226 + 227 + **Pull-to-refresh:** CSS `overscroll-behavior` does not work on iOS/WebKit (open bug since 2016). JS-based touch interception is fragile on WKWebView due to native momentum scrolling. A refresh icon button (top-right of HomeScreen) is the pragmatic v0.1 approach; a native `UIRefreshControl` plugin is the right path if gesture-based refresh becomes a requirement. 228 + 229 + **Avatar initial source:** The handle's first letter is used, not the DID (which would always yield `d`). The `handle.invalid` sentinel — emitted by the relay when no handle is registered — falls back to `?`. 230 + 231 + **`didDoc` is optional:** `getSession` only includes `didDoc` if a DID document is stored in the relay's `did_documents` table. The "View DID Document" button is hidden when `session.didDoc` is null.