---
name: mobile-bug-log
description: Running reference log of every iOS/mobile bug diagnosed + fixed on the `mobile` branch (symptom → root cause → fix → files/commit), plus the recurring build/runtime gotchas that keep biting. Use when the user asks "what bugs did we fix", "known issues", "list the resolved bugs", "why did X break", when a similar symptom reappears (check if it's a known regression before re-diagnosing), or before shipping a mobile build (re-verify the gotchas). ALWAYS append a new entry here whenever another mobile bug is resolved.
argument-hint: "[optional: keyword to filter, e.g. 'keyboard' | 'backend' | 'navigation']"
allowed-tools: Read Grep Glob Bash(git log*) Bash(git show*) Bash(grep*) Bash(xcrun simctl*)
paths:
  - .claude/skills/mobile-bug-log/**
  - hushh-webapp/**
---

# Mobile bug log (hushh One iOS)

The single source of truth for iOS/mobile bugs we've diagnosed and fixed on the **`mobile`** branch. Read this FIRST when a mobile symptom reappears — it's probably here. When you fix a new one, **append an entry** (symptom, root cause, fix, files, commit).

Related memory: [[hushh-research-mobile-branch]], [[hushh-research-ios-build]]. Diagnosis tool: `xcrun simctl spawn <UDID> log show --last 120s --predicate 'process == "App"'` (the WKWebView console + native network log — this is how most of these were found).

---

## 🔴 RECURRING GOTCHA #1 — Native build must point at the UAT backend (NOT localhost)
- **Symptom:** app can't check vault status ("We could not check your Vault status right now"), and — because guards can't verify state — the **top-bar back button and navigation die on EVERY screen**. Looks like a UI bug; it isn't.
- **Root cause:** `.env.local` has `NEXT_PUBLIC_BACKEND_URL=http://localhost:8000` (local dev backend). `capacitor.config.ts` bakes that into every plugin's `backendUrl`, and the web layer fetches `${NEXT_PUBLIC_BACKEND_URL}/db/vault/check`. On the simulator that resolves to `127.0.0.1:8000`, which is **Connection refused (NSURLError -1004)** unless a local backend is running there. Confirmed via the sim log showing `http://127.0.0.1:8000/db/vault/check ... Connection refused`.
- **Fix:** build the sim against the reachable UAT backend. Inline override (does NOT touch the user's `.env.local`):
  `export NEXT_PUBLIC_BACKEND_URL="https://consent-protocol-f2gsa4kfsq-uc.a.run.app"` before `npm run cap:build && npm run cap:sync:ios`. Confirm after boot: `xcrun simctl spawn <UDID> log show --last 20s --predicate 'process=="App"' | grep -oE "consent-protocol|127.0.0.1:8000"` should show `consent-protocol`, never `127.0.0.1:8000`. (UAT url lives in `.env.uat.local`.)
- **Verify reachable first:** `curl -s -o /dev/null -w "%{http_code}" https://consent-protocol-f2gsa4kfsq-uc.a.run.app/` → `200`.
- **EVERY sim build must set this** or localhost:8000 gets baked back in.

## 🔴 RECURRING GOTCHA #2 — sim shuts down / stale install
- If `simctl install` fails with `Unable to lookup in current state: Shutdown`, the sim isn't booted. `xcrun simctl boot <UDID>; open -a Simulator; sleep 6` then install/launch. The build itself likely already succeeded (`** BUILD SUCCEEDED **`) — don't rebuild, just boot + install the existing `.app`.
- Build gotchas (see [[hushh-research-ios-build]]): Node 22 for `cap sync`; `NODE_OPTIONS=--max-old-space-size=8192` for `cap:build` (OOM); `GoogleService-Info.plist` required; DerivedData in `/tmp/hushh-ios-dd` (iCloud FinderInfo breaks codesign).

## 🔴 RECURRING GOTCHA #3 — `next build` HANGS at 0% CPU when the repo is in iCloud-synced `~/Documents`
- **Symptom:** `npm run cap:build` (or even `next dev`, or a plain `cp`/`mv`) sits at `Creating an optimized production build ...` for 20+ min with **0% CPU** and `.next` frozen. Looks like a webpack/worker deadlock or OOM. It is neither.
- **Root cause:** the working copy lives under `~/Documents` (iCloud "Desktop & Documents"). macOS **FileProvider (`fileproviderd`/`bird`/`cloudd`) mediates and throttles every file read/write** in that tree — and a webpack build does hundreds of thousands of tiny I/Os. With an active iCloud sync backlog the daemons churn at 60–90% CPU while the build blocks on I/O (0% CPU). Not RAM (CPU idle), not disk space, not the `webpackBuildWorker` flag (toggling it doesn't help), not offloaded files. Confirmed: build/dev/`cp`/`mv` ALL crawl; a `cp -Rc` of the repo did 143 MB in 13 min (~9 h projected).
- **Fix:** get the repo OUT of the iCloud domain. `mv` out is itself throttled, so the fast path is: `git clone <origin> ~/hushh-research-fast` (network → non-iCloud disk), copy the few gitignored files (`.env.local`, `.env.uat.local`, `ios/App/App/GoogleService-Info.plist`), `npm ci`, then build there. On a non-iCloud path the SAME build **compiles in ~30s** and runs on the sim. Permanent fix: keep the dev repo outside `~/Desktop`/`~/Documents` (or disable iCloud Desktop & Documents sync). See [[hushh-research-keyboard-desktop-inert]] / memory.
- Also: clear `/tmp/hushh-ios-dd` if xcodebuild errors `file '…FIRAnalytics+OnDevice.h' has been modified since the module file … was built` (stale precompiled-module cache from a prior repo path).

---

## Resolved bugs

### B1 — Set up One back button bounced back to /one/setup (native)
- **Symptom:** top-left back (←) on `/one/setup` did nothing (bounced straight back).
- **Root cause:** back button primes "setup resolved" + `router.push('/one')`, but `OneOnboardingGuard` (`components/kai/onboarding/kai-onboarding-guard.tsx`) only early-exits when the **vault is unlocked** (`unlockedOnStandardKaiRoute = isVaultUnlocked && !onOnboardingRoute`). Vault-locked → runs the async check → on native it transiently reports setup incomplete → `router.replace('/one/setup')`.
- **Fix:** native fast-path in the guard — if `isNativePlatform() && !onOnboardingRoute && readOneSetupCompletionHint(uid) === true`, clear cookies + `setChecking(false); return;` (trust the in-session hint, skip the bounce). Web unchanged (native-gated).
- **File:** `components/kai/onboarding/kai-onboarding-guard.tsx`. Note: this only matters once GOTCHA #1 is fixed (guards need a reachable backend to run at all).

### B2 — Agent chat header slid under the status bar when the keyboard opened
- **Symptom:** opening the composer keyboard pushed the "One" chat header up under the Dynamic Island / status bar.
- **Root cause:** `@capacitor/keyboard` is NOT installed and there's no keyboard-resize handling. iOS WKWebView doesn't shrink `100dvh`; it scrolls the whole `fixed inset-0` overlay up to reveal the focused input → the fixed header drifts under the status bar.
- **Fix:** `visualViewport` keyboard-pin in `components/agent/agent-popover-provider.tsx` — track keyboard height (`window.innerHeight - visualViewport.height - offsetTop`), shrink the mobile sheet to `h-[calc(100dvh-var(--agent-kb-height))]` (top-pinned, `bottom-auto`), and `window.scrollTo(0,0)` to undo any iOS scroll. Composer sits above the keyboard, header stays below the status bar. Mobile-scoped (`max-sm`).

### B3 — Agent chat looked "web-forced": header/composer overlapped safe areas, no back button, blue accents
- **Fix (commit `a6cb290b1`):** popover header height includes `--app-safe-area-top-effective`; composer adds `--app-safe-area-bottom-effective`; ink-glass back button replaces the top-right X; blue `text-primary` → luxury gold `#9C7434`/`#D4AF6A`, SF Pro type, iOS rounded cards. All `isPopover`/`max-sm`-scoped so desktop/web unchanged. File: `components/agent/agent-chat-workspace.tsx`.

### B4 — "Setup tiles do nothing" was NOT a routing/export bug
- **Ruled out:** all `/one/setup/<capability>/index.html` ARE exported in the static build + app bundle; tiles use relative routes (no external/uat URLs). The real cause of dead navigation was GOTCHA #1 (backend down → guards error). Don't chase the tiles; check the backend + guard first.

### B6 — "Finish setup" dashboard bar vanished after entering a couple of setup items (Gmail not set up) — QA/TestFlight blocker
- **Symptom:** on `/one`, the "Finish setup — X% done" bar disappeared once the user opened "Set up One" and tapped a couple of capability tiles, even though Gmail (and others) were not set up.
- **Root cause:** `app/one/setup/[capability]/one-onboarding-capability-client.tsx` `handlePrimary` (commit `d83ed1890`) resolved the **account-wide master gate** `PreVaultUserStateService.syncKaiSetupState({ completed: true })` whenever Continue forwarded into a hard-gated `/one/*` surface (gmail, email→/one/kyc, location, pkm, connected-systems) — done only to stop `OneOnboardingGuard` bouncing. But `setupCompleted === true` also makes `resolveFinance` (`lib/services/capability-setup-state-service.ts`) report **finance = completed** pre-vault, so entering ONE capability flipped enough tiles non-actionable that `hasSetupRemaining = some(isCapabilitySetupActionable)` (`one-dashboard-page.tsx`) went false → bar gone. NOT a symptom of the palette/UI work.
- **Fix (commit `ee35b9e12`) — decouple "entered a capability" from "finished ALL setup":**
  1. `one-onboarding-capability-client.tsx` — stop the master-gate write on capability entry; forward hard-gated surfaces with `?from=setup` instead (removed the `KaiProfileService.setOnboardingCompleted` call from this path too).
  2. `lib/navigation/routes.ts` — added `isCapabilityHandoffTarget()` (the gated `/one/*` handoff set: gmail/kyc/location/pkm/connected-systems; excludes finance→`/one/setup/kai` and consent→`/consents`).
  3. `components/kai/onboarding/kai-onboarding-guard.tsx` — allow a setup-originated (`?from=setup` + known gated target) entry through WITHOUT the master gate, at all 3 bounce points (added `&& !setupOriginatedCapabilityEntry`). Preserves `d83ed1890`'s redirect-loop fix without the account-wide side effect; scoped so arbitrary `/one/*` stays gated.
- **Net:** master gate now resolves only on a genuine finish (hub Skip/Continue → `syncKaiSetupState`), so the bar stays until the user actually finishes.
- **Verified:** typecheck + lint + `verify:design-system` + capability-client/routes/dashboard/auth-gate vitest all pass; iOS build succeeds + runs on sim against UAT. NOTE: full logged-in on-device repro (login → dashboard → tap tiles → bar persists → hub Skip/Continue → bar hides) still needs a human login (vault passphrase or an allowlisted UAT test number) — the unit tests encode the exact behavior change.
- **Same root cause exists on `main` (web)** — a separate PR can port it if the web team wants it.

### B7 — "Personal Data" (PKM) screen showed a red `HTTP Error 404: {"detail":"No data found for user"}` for fresh users (native only)
- **Symptom:** dashboard → "Personal Data" (`/one/pkm`, "Saved Intelligence", Readable tab) on a fresh/no-data account showed a red error banner `HTTP Error 404: {"detail":"No data found for user"}` + `0 domains / 0 saved details / 0 memory cards / Last updated Unavailable`. iOS/TestFlight only.
- **Root cause — web↔native parity gap.** The backend (`consent-protocol/api/routes/pkm_routes_shared.py`) raises `404 "No … data found for user"` for a data-less user as a NORMAL empty condition. The **web** branches of `getMetadata`/`getEncryptedData`/`getDomainData` in `lib/services/personal-knowledge-model-service.ts` already map `response.status === 404` → `emptyMetadata`/`null`. But the **native** branches call the Capacitor plugin (`HushhPersonalKnowledgeModel.*`) directly; the iOS Swift (`ios/App/App/Plugins/PersonalKnowledgeModelPlugin.swift` `executeRequest`) + Android Kotlin plugins `reject("HTTP Error <status>: <body>")` for ALL non-2xx, so the 404 throws and `pkm-natural-panel.tsx` paints `bootstrapError`. (`getDomainManifest` is NOT affected — it uses `ApiService.apiFetch` with its own 404→null; the bug is exactly 3 native call sites, not 4. `getAvailableScopes` is out of scope: no UI consumer + web also throws on 404, i.e. already symmetric.)
- **Fix (commit `bfc8bae3d`), single file `lib/services/personal-knowledge-model-service.ts`:** added `private static isNativeNoDataError(error)` = `/^HTTP Error 404\b/.test(message)` (anchored to the plugin prefix so 401/403/408/429/5xx, `Network error:`, `JSON parsing error:` all keep throwing — no false positives; the plugins reject with a message only, no `.code`). Wrapped the 3 native calls in try/catch: `getMetadata` → on no-data `result = this.emptyMetadata(userId)` (flows through the normal MEDIUM-ttl cache, mirroring web); `getEncryptedData`/`getDomainData` → on no-data `return null` (no cache write, mirroring web). Non-404 errors rethrow. Fixes natural panel + explorer + data-manager + agent-lab transitively.
- **Verified:** typecheck + lint + new `__tests__/services/pkm-native-no-data.test.ts` (9) + 45 PKM regression tests pass; a 9-agent investigation workflow + a 4-lens adversarial review workflow (control-flow / caching-staleness / matcher-precision / parity-completeness) returned **SHIP AS-IS, 0 confirmed defects**; iOS build runs on sim vs UAT. Full logged-in on-device repro (fresh account → Personal Data → no banner, clean empty state; then add a memory → Refresh → data appears; negative: force a 500 → error still surfaces) needs a human login.
- **Same gap exists on `main`/web is already correct there** (web branch handles 404); this is a native-only parity fix. iOS = immediate target; Android has the identical plugin so it benefits from the same TS fix.

### B8 — Chat composer disappears under the keyboard + header showed a generic Bot icon (iOS, QA raised ~20×)
- **⚠️ The KEYBOARD half of this fix was WRONG and is SUPERSEDED by [B9]. The `resize:"none"` + chat-only `--agent-kb-height` approach only handled the open chat and left EVERY other input screen (register-phone OTP, vault) with zero keyboard avoidance — QA re-reported it. B9 replaces it with global `resize:"native"`. The header-logo half (Bot → `/one-quiet-emoji.png`) is still valid.**
- **Symptom 1 (critical):** opening the keyboard in the "One" chat pushed the composer ("Message One…" + mic + send) UNDER the keyboard — user couldn't see what they typed. The earlier visualViewport keyboard-pin (B2) did NOT reliably fix it. **Symptom 2:** header next to "One" showed a lucide `<Bot/>` "random chatbot icon" instead of the hushh One mark.
- **Root cause (keyboard):** NATIVE WKWebView scroll-drift, not CSS. `@capacitor/keyboard` was NOT installed, and `capacitor.config.ts ios.scrollEnabled: true` left the native `UIScrollView` free to auto-scroll the whole webview up to reveal the focused input. Because the chat sheet is `position: fixed`, that native scroll dragged the entire overlay (header under the status bar, composer under the keyboard). The JS `window.scrollTo(0,0)` in the visualViewport hack resets the DOM scroll, **not** `UIScrollView.contentOffset`, so it could never win. `100dvh` never shrinks (contentInset "never", no `interactive-widget` — which iOS/WKWebView does NOT support anyway).
- **Fix (robust, native-layer — commit `01050387a`):**
  1. `npm i @capacitor/keyboard@^8.0.5` + `cap sync ios` (adds CapacitorKeyboard SPM package).
  2. `capacitor.config.ts`: `ios.scrollEnabled: false` (kills the drift at root); add `Keyboard: { resize: "none", style: "LIGHT", resizeOnFullScreen: false }` (resize:none keeps 100dvh full so our own sheet-shrink owns avoidance — `native` would double-shrink + make fixed-bottom UI jump). Type-only import `KeyboardResize`/`KeyboardStyle` + `as` casts (string values need the enum types; type-only = no runtime import).
  3. `ios/App/App/MyViewController.swift`: `webView.scrollView.isScrollEnabled = false` (belt-and-suspenders).
  4. `components/agent/agent-popover-provider.tsx`: keyboard effect now uses the plugin's authoritative `keyboardWillShow.keyboardHeight` on `isNativePlatform()` to set `--agent-kb-height` (visualViewport kept as web fallback via dynamic-import gate); toggles `html.agent-kb-open`. The `--agent-kb-height` var + sheet calc `max-sm:h-[calc(100dvh-var(--agent-kb-height))]` are unchanged.
  5. `app/globals.css`: `html.agent-kb-open .agent-chat-workspace` drops the composer home-indicator padding to 0.5rem while typing (keyboard covers that zone).
  6. `components/agent/agent-chat-workspace.tsx`: header `<Bot/>` → `<Image src="/one-quiet-emoji.png" unoptimized>` (the 🤫 app-icon mark, already proven under the App:// scheme in AuthStep/vault-lock-guard/register-phone) in a gold-tinted squircle badge. `Bot` import kept (still used for message avatars ~L879).
  7. `components/kai/modals/edit-holding-modal.tsx`: `repositionInputs={false}` on its vaul Drawer (only flagged regression — let CSS/native own avoidance, don't let vaul fight).
- **Why scrollEnabled:false is safe:** `html,body` have no `overflow-y` (globals.css:34-35); all scrolling is in the inner `[data-app-scroll-root]` `overflow-y-auto` (providers.tsx:386); top-app-bar listens on that inner root. Verified directly, not just from the plan.
- **Verified:** typecheck+lint+design-system pass; iOS build SUCCEEDED with CapacitorKeyboard compiled/linked + `KeyboardPlugin` registered at runtime; sim runs vs UAT. 9-agent investigation + 3-lens adversarial review workflows. **Full logged-in on-device repro (open chat → focus composer → composer stays above keyboard) needs a human login (auth+vault gated).** Fix is native-config-level = correct by construction.
- **Note:** this is the pattern for ANY future keyboard-avoidance need — the app now has `@capacitor/keyboard` (resize none) + native scroll off + inner-overflow scrolling. Reuse `--agent-kb-height` / `keyboardWillShow` rather than new visualViewport hacks.

### B9 — Keyboard hides the input on EVERY screen (register-phone OTP, chat, …) — the real, app-wide fix (supersedes B8's keyboard half)
- **⚠️ The `resize:"native"` decision here is SUPERSEDED by [B21]. `native` shrinks the whole WKWebView frame every keyboard-animation frame, recomputing every `dvh`/`svh` ~60×/sec → severe vault jank; it was reverted (commits `26453505b`→`78cf3a94f`) back to `none`, which silently re-broke avoidance app-wide. B21 is the current standard: keep `resize:"none"` (no frame resize → no jank) AND add a global event-driven `--kb-height` avoidance layer. Do NOT flip `resize` to `native` or `body` again.**
- **Symptom:** the on-screen keyboard covered the focused input on multiple screens — confirmed on the **phone-verification/OTP** (`app/register-phone`) AND the **One chat composer**. B8's chat-only fix did NOT solve it app-wide.
- **Root cause:** B8 set `Keyboard.resize:"none"` (+ `ios.scrollEnabled:false`). `resize:"none"` means the WKWebView frame NEVER shrinks → `100dvh` stays full-screen and bottom inputs sit behind the keyboard. The ONLY avoidance code was the chat popover's `--agent-kb-height` subtraction (gated to the open chat), so register-phone/vault/every other input screen had **zero** avoidance. `resize:"none"` was chosen in B8 purely to protect the chat's manual subtraction — a one-component concern that broke the whole app.
- **Fix (standard iOS, global — commit `515347b8a`):**
  1. `capacitor.config.ts`: `Keyboard.resize` `"none"` → **`"native"`** (the plugin default). WKWebView frame now shrinks by the keyboard height → `100dvh`/`svh` + `position:fixed` bottom elements sit above the keyboard on EVERY screen, no per-screen JS. Kept `ios.scrollEnabled:false`, `contentInset:"never"`, `style:"LIGHT"`. `cap sync ios` regenerates `ios/App/App/capacitor.config.json` (the runtime-read file) to `resize:"native"` — commit both.
  2. `components/agent/agent-popover-provider.tsx`: **DELETED** the whole custom keyboard machinery (the `keyboardInset` state + `keyboardWillShow/Hide` + visualViewport effect + `html.agent-kb-open` toggle + `--agent-kb-height` in panelStyle + the now-unused `isNativePlatform` import). Sheet height `max-sm:h-[calc(100dvh-var(--agent-kb-height,0px))]` → **`max-sm:h-[100dvh]`** (shrinks with the webview). Removing it avoids a DOUBLE-subtract (webview shrinks AND JS subtracts → composer floats a keyboard-height too high).
  3. `app/globals.css`: deleted the dead `html.agent-kb-open .agent-chat-workspace` block. Kept the base `--agent-chat-composer-bottom` vars (that resting padding is the correct gap).
  4. `app/register-phone/page.tsx`: the OTP white sheet is normal-flow in a `min-h-[100dvh]` column whose page root is `overflow-hidden`. Added `max-h-[calc(100dvh-4rem)] overflow-y-auto` as a safety net so a tall step on iPhone SE scrolls WITHIN the sheet instead of clipping (resting look unchanged — content is short).
- **KEY LESSON (⚠️ CORRECTED by B21):** ~~the STANDARD keyboard fix is `Keyboard.resize:"native"`~~ — that recomputes every dvh 60×/sec and janks the vault. The correct standard for THIS app is `resize:"none"` + a global event-driven `--kb-height` layer (B21). What still holds from B9: do NOT use per-screen `--agent-kb-height`/visualViewport hacks scattered per component — keyboard avoidance must be ONE global mechanism.
- **Verified:** rg `agent-kb-height|agent-kb-open` = 0 hits; typecheck+lint+design-system pass; iOS build + `resize:"native"` in synced json + sim runs vs UAT; 6-agent read-only investigation (all concur `native`). **On-device logged-in repro (OTP + chat + vault inputs above keyboard, incl. iPhone SE) needs QA login (auth+vault gated).**

### B10 — Back button from dashboard-opened Email/Location/Consent/Marketplace went to Profile (not dashboard)
- **Symptom:** on `/one` dashboard, tap Email / Location / Consent Guardian / Information Marketplace → surface opens → top-bar **back** goes to **Profile** instead of the dashboard. Gmail / PKM / Connected-Systems were fine (the clue).
- **Root cause:** the top-bar back button uses a **computed** `backHref` from `resolveTopShellBreadcrumb()` ([lib/navigation/top-shell-breadcrumbs.ts](../../hushh-webapp/lib/navigation/top-shell-breadcrumbs.ts)), NOT `router.back()`. For `ONE_KYC`/`ONE_LOCATION`/`ONE_MARKETPLACE` it **hardcoded `backHref: ROUTES.PROFILE`** (these surfaces were historically reached from Profile panels) and never read `?from`. The dashboard ([one-dashboard-page.tsx](../../hushh-webapp/components/dashboard/one-dashboard-page.tsx)) navigated with the **bare** `cap.href` (no origin). `CONSENTS` was origin-aware but also fell to a profile panel with no marker. Gmail/PKM/Connected already resolved to `ONE_HOME`, so they weren't broken.
- **Fix (commit `9b5706196`) — origin-aware `?from`, mirroring the existing Gmail pattern:**
  1. Dashboard tiles tag each href: `cap.href.includes("?") ? \`${cap.href}&from=${ROUTES.ONE_HOME}\` : \`${cap.href}?from=${ROUTES.ONE_HOME}\``. **Raw `/one`, NOT encoded** — `normalizeInternalRouteHref` requires `startsWith("/")` and `searchParams.get` already decodes.
  2. `top-shell-breadcrumbs.ts` — `ONE_KYC`/`ONE_LOCATION`/`ONE_MARKETPLACE` now `backHref: normalizeInternalRouteHref(searchParams?.get("from")) || ROUTES.PROFILE`, and the leading crumb is "One" (from dashboard) vs "Profile" (fallback). CONSENTS needed no change (already reads `from`).
- **Why not a blanket `backHref → ONE_HOME` flip:** Profile also links to these surfaces (`app/profile/page.tsx`), so origin-aware preserves Profile→surface→back. No-`from` → Profile fallback (unchanged).
- **Verified:** typecheck + lint + design-system + `top-shell-breadcrumbs` (11) + `one-dashboard-page` (updated href assertions) + `top-app-bar.contract` = 24/24; iOS build; **on-device (logged-in sim): dashboard → Email → back → dashboard.** Pre-existing uncommitted `normalizeBreadcrumbPathname`/KAI_IMPORT changes in these files are compatible (query stripped before route match; `from` read from searchParams).
- **PATTERN:** top-bar back is breadcrumb-driven (`backHref`), not history. New surfaces reachable from multiple origins must read `?from` (see Gmail) and callers must tag the origin — don't hardcode a single parent.

### B11 — Welcome ask-bar (logged-out) + "Log in"→"Get Started" + Access & Sharing back
Three small mobile UX/nav fixes (commit `909ea793d`):
- **A — agent ask-bar showed on the logged-out welcome (`/`)** ("backdoor guy under the CTA"). `components/agent/agent-bar.tsx` `unmountBar` gated on routes but never auth (deliberate old comment L219-231). Fix: `|| (isHomeRoute && runtime?.tier === "anon_onboarding")` — hides on the anon welcome only; signed-in users are redirected off `/` so the bar still shows on `/one` + all authed surfaces. NOTE: the runtime exposes `tier` (AgentAccessTier), NOT `signedIn` — use `tier === "anon_onboarding"` (the anon-on-`/` tier).
- **B — CTA "Log in" → "Get Started"** on the welcome (`components/onboarding/IntroStep.tsx` L165). `onLogin` → `/login` → AuthStep (Firebase social handles new + returning), so "Get Started" is accurate. Refreshed the stale "Get started removed" comments.
- **C — back button on "Access & Sharing" (`/profile?panel=access`) did nothing.** Top-bar back (`top-app-bar.tsx` ~L643) did `router.push(backHref)` — a same-pathname, query-only nav. The profile page closes its panels ONLY via `router.replace(href, { scroll: false })` (`profile/page.tsx` `updateProfileView` "replace" / `popProfileStack`), so a plain push is a no-op on device. Fix: in the back handler, for `normalizedPathname === ROUTES.PROFILE && (panel||detail)` → `router.replace(backHref, { scroll:false })` (mirrors popProfileStack); else `router.push(backHref, { scroll:false })`. `/consents` (cross-pathname) already worked.
- **KEY: profile panels are query-state (`?panel`/`?detail`) driven by `useSearchParams` → the profile page's own close uses `router.replace(.., {scroll:false})`. Any code navigating profile panels MUST use that same replace+scroll:false, not a bare push.**
- **Verified:** typecheck + lint + design-system; `top-app-bar.contract` (updated to assert the new `router.push(..,{`/`router.replace(..,{` back-nav contract), `top-shell-breadcrumbs`, `one-dashboard-page` = 24/24; iOS build. On-device: A + B verified on the logged-out welcome (no ask-bar, "Get Started"); C (auth-gated) unit-contract-covered + mirrors the proven panel-close.

### B12 — Setup-hub-opened capability back went to Profile (should retrace to the hub)
- **Symptom:** from the "Set up One" hub (`/one/setup`), tapping an item (Email/Gmail/Location/Marketplace) → capability opens → top-bar **back → Profile** (or `/one`), not back to the hub. User rule: **"jaise aaya waise wapas"** (retrace: hub → item → back → hub → back → dashboard).
- **Root cause:** the setup-hub handoff (`one-onboarding-capability-client.tsx`) forwarded gated surfaces with a **bare literal `?from=setup`**. The breadcrumb reads `from` via `normalizeInternalRouteHref`, which **rejects `"setup"`** (no leading `/`) → null → falls to the hardcoded default (kyc/location/marketplace → PROFILE, gmail → ONE_HOME). The same `"setup"` string was a valid guard bypass (`kai-onboarding-guard.tsx` `params.get("from") === "setup"`) — one token doing two jobs, only the guard tolerated a bare value. (This was a side effect of the B6/finish-setup fix which introduced `?from=setup`.)
- **Fix (commit `81db93823`) — make the marker a valid path so it works for BOTH the guard and the breadcrumb:**
  1. `one-onboarding-capability-client.tsx`: `?from=setup` → `?from=${ROUTES.ONE_SETUP}` (`/one/setup`, raw). Merged the gated/else branches so **every** non-finance capability (incl. **consent**, off `/one/*`) carries `?from=/one/setup` — so consent back retraces too. Removed the now-unused `forwardsToGatedSurface` + `isOneSetupSurfaceRoute` import. Finance keeps its encoded per-capability `from`.
  2. `kai-onboarding-guard.tsx`: `setupOriginatedCapabilityEntry` → `normalizeInternalRouteHref(params.get("from")) === ROUTES.ONE_SETUP && isCapabilityHandoffTarget(pathname)` (+ import). Keeps the finish-setup redirect-loop bypass intact.
  3. `top-shell-breadcrumbs.ts`: made **PKM** + **CONNECTED_SYSTEMS** origin-aware (`originHref || ONE_HOME`) — the other gated surfaces (kyc/location/marketplace/gmail) + consent were already origin-aware. No-`from` → unchanged defaults.
- **Result:** setup hub → any capability → back → **/one/setup**; hub → back → dashboard (retrace). Dashboard-opened (`?from=/one`) + Profile-origin unchanged.
- **PATTERN / gotcha: `from` markers MUST be valid internal paths (leading `/`).** `normalizeInternalRouteHref` silently drops non-path values → breadcrumb falls to the wrong default. Don't invent bare-string markers that the breadcrumb + guard interpret differently.
- **Verified:** typecheck+lint+design-system; capability-client / top-shell-breadcrumbs (added setup-hub-origin regression lock) / auth-gate / top-app-bar.contract / dashboard = 32/32; iOS build. On-device: hub → Email → back → hub.

### B13 — QA re-reported B10/B11 → BUILD STALENESS (not a regression) + residual `?from` gaps on other origins
- **Symptom:** QA re-sent the exact B10/B11 report (verbatim) — dashboard → Email/Location/Consent Guardian/Marketplace → back → **Profile**; "Access & Sharing back doesn't work".
- **Root cause = build staleness, NOT a code regression.** A 10-agent read-only investigation (6 probes → adversarial verify → synthesize) + an on-device test proved the committed fixes are CORRECT and work on a fresh build (dashboard → Email → back → dashboard ✓). The fix commits (`9b5706196` 07-06 02:51, `909ea793d` 07-06 03:31) landed AFTER the last uploaded build; **Capacitor bakes the web bundle into the binary (`iosScheme:"App"`, no OTA)**, so the fix never shipped to the tester. Ruled out (adversarially): static-export query stripping, per-page back overrides, `/consents` vault redirect. Access & Sharing's `router.replace` fix (B11) is confirmed correct — no change needed.
- **Residual finding (same bug class, fixed):** other entry points open the capability screens WITHOUT `?from`, so their top-bar back also falls to Profile/`/one`. Top-bar back is breadcrumb-driven — every caller must tag origin.
- **Fix — build bump (`93b0cd9ca`) + residual `?from` tagging (`cccf6aab7`):**
  1. `chore(ios)`: `CURRENT_PROJECT_VERSION 39 → 40`; rebuilt vs UAT (cap:build + cap:sync:ios; capacitor.config.json backend = consent-protocol). **The actual delivery of B10/B11 — the user must Archive + upload build 40 to TestFlight (Apple creds; I can't).**
  2. `fix(mobile)`: tag `?from=<current route>` on the highest-value non-dashboard origins — `agent-chat-workspace.tsx` (5 sites: consent details/pending/open, marketplace, view-envelope→location, `?from=${pathname||ONE_HOME}`); `consent-inbox-dropdown.tsx` (entryHref/managerHref take `from` via usePathname; RIA branch untouched); `permission-locked-state.tsx` + `kai-invite-handshake.tsx` (usePathname → `{from}`); `app-sidebar.tsx` (`{from:pathname}`); `command-executor.ts` (`{from:currentRoute??undefined}`). `buildConsentCenterHref` already supports `{from}`.
- **Deferred (documented, conscious):** `consent-sheet-controller.tsx` — its open/re-sync `useEffect` rewrites `from` (delicate; its own `closeConsentSheet` already returns to origin); `app-bottom-nav.ts` — tab-switch semantics (nav stays visible), a UX call not a bug; notification/FCM deep links (`fcm-service.ts`, `one-location/notifications.ts`) — cold-start, no origin.
- **KEY GOTCHA: a committed fix that isn't in the archived/uploaded build has NOT shipped.** Capacitor has no OTA — bump the build number and re-Archive/upload. "Works on my fresh sim build" ≠ "shipped to the tester." Always confirm the tested build's commit vs the fix commit before re-debugging a "still broken" report.
- **Verified:** typecheck + lint + design-system; breadcrumbs (agent-origin regression added) / dashboard / top-app-bar.contract / command-executor / consent-sheet-route = 59; iOS build 40 installed; on-device dashboard → Email + Location → back → dashboard.

### B14 — Theme toggle dead on iOS (worked on web)
- **Symptom:** light/dark switching worked on the website but did nothing in the native iOS app.
- **Root cause:** NOT a bug — iOS was deliberately pinned to light ("daylight" ship): `app/providers.tsx` forced light via next-themes on `Capacitor.getPlatform()==="ios"` AND `ios/App/App/Info.plist` set `UIUserInterfaceStyle=Light`. The toggle persisted to localStorage but next-themes never applied it.
- **Fix (fix/voice-intelligence-and-native-ui):** removed both pins; `ThemeProvider attribute="class" defaultTheme="light" enableSystem` on all platforms. `StatusBarManager` already syncs native SystemBars from `resolvedTheme`. Contract test updated: `__tests__/components/providers-theme-contract.test.ts` now asserts NO forced theme anywhere + no `UIUserInterfaceStyle` in Info.plist.

### B15 — Ripple stayed "pressed" after press-and-hold on option tiles (iOS)
- **Symptom:** long-press on an option tile left the md-ripple pressed overlay stuck until the next interaction.
- **Root cause:** WKWebView long-press triggers the system callout/text-selection path, which cancels the pointer stream (`pointercancel` swallowed) before `md-ripple` sees `pointerup`. Nothing set `-webkit-touch-callout:none`/`user-select:none` on actionables.
- **Fix:** `app/globals.css` — actionable roles (a, button, [role=button|radio|tab|menuitem|option]) get `-webkit-touch-callout:none; -webkit-user-select:none; user-select:none`. Inputs keep selection. Also pinned `pointer-events:none` on `md-ripple.morphy-md-ripple` itself (defense vs the historical first-tap-swallow, see material-ripple.tsx comment).

### B16 — Bottom nav sometimes needed a double tap (iOS)
- **Symptom:** first tap on a bottom-bar tab intermittently did nothing; second tap worked. Correlated with scrolling.
- **Root cause:** the nav translates off-screen via `--bottom-chrome-progress` (scroll-hide). A tap landing mid-animation had its target translate away between pointerdown and pointerup → no click event. Wrapper also holds `pointer-events-none` while hidden.
- **Fix:** `snapKaiBottomChromeVisible()` in `lib/navigation/kai-bottom-chrome-visibility.ts` — `onPointerDownCapture` on the nav group freezes the chrome at its resting position when `progress > 0`, so the tap target is stationary at pointerup. File: `components/navbar.tsx`.

### B17 — Search bubble rendered as an oval on iOS (circle on web)
- **Symptom:** the round Search button next to the bottom pill was visibly non-circular in the native app.
- **Root cause:** geometry relied on `aspect-square` + `self-stretch` + `h-auto w-auto`; WKWebView resolves `aspect-ratio` against a stretch-derived flex cross size as indefinite → width fell back to content → oval.
- **Fix:** explicit `h-[52px] w-[52px] self-center` (matches the stacked pill min-height). Hover styles moved behind `[@media(hover:hover)]` so first touch can't latch sticky hover. File: `components/navbar.tsx`.

### B18 — Live location never updates on the recipient; "View" opens nothing (`Recipient key unavailable for this location share.`)
- **Symptom:** a recipient of a One Location live share taps **View** and gets `Recipient key unavailable for this location share.`; the map never opens and the location never updates. The recipient just sees a dead View button.
- **Root cause — device-bound E2E key + custom-scheme IndexedDB eviction.** One Location is E2E-encrypted with a per-recipient ECDH P-256 keypair whose **private key lived ONLY in the recipient device's IndexedDB** (`lib/one-location/encryption.ts`, DB `hushh-one-location-keys`, keyed by Firebase `user.uid`). Decrypt throws whenever the on-device key is absent or its `keyId != envelope.recipientKeyId`. iOS runs under `iosScheme: "App"` where **WKWebView IndexedDB is evicted / not persisted across launches** → the key is lost → `ensureLocationRecipientKey` mints a NEW keyId. The backend **freezes** `recipient_key_id` on the grant and hard-enforces `envelope.recipientKeyId == grant.recipient_key_id` (`consent-protocol/.../one_location_agent_service.py` `create_grant`/`store_encrypted_envelope`), so one rotation **permanently poisons the grant**: the recipient can't decrypt old envelopes AND the sender's publishes get rejected (`LOCATION_ENVELOPE_KEY_MISMATCH`) → live updates stop. Both the map (`LocalMapPreview`) and the silent `setInterval` poll only render when `decryptedPoints[grant.id]` is set (successful decrypt), so the failure looks like "View does nothing." (userId mismatch ruled out — bootstrap + decrypt both use `user.uid`.)
- **Fix (two parts):**
  1. **Durable key (stops the rotation)** — `lib/one-location/encryption.ts`: mirror the private key (as JWK; the pair is generated extractable) into the native **Keychain** via `HushhKeychain` (`@/lib/capacitor`), gated by `isNative()`, key `one_location_recipient_key:${userId}`, `accessible: afterFirstUnlock`, **non-biometric** (no Face ID on the silent poll). `readStoredKey` now: IndexedDB → if empty & native, restore from Keychain with the **SAME keyId** and repopulate IndexedDB. IndexedDB stores `privateKeyJwk` (portable) with legacy `CryptoKey` fallback + migration. Exported `RECIPIENT_KEY_UNAVAILABLE_MESSAGE`. Precedent: `lib/services/vault-bootstrap-service.ts`.
  2. **Self-heal on mismatch** — `app/one/location/page.tsx`: added per-grant `grantViewErrors` state; on `RECIPIENT_KEY_UNAVAILABLE_MESSAGE` in `viewGrantEnvelope` (manual + silent poll) it re-registers the current key (`bootstrapCurrentUserLocationRecipientKey`) and shows an inline "the secure key changed — ask them to share again" notice + **Ask to share again** button (`handleAskReshare` → `OneLocationService.requestAccess` to the grant owner). Same treatment in the redesign chat `view_envelope` handler (`components/one-location/redesign/use-location-chat.ts`). Once the owner re-shares, `create_grant` snapshots the now-durable key and updates resume.
- **Files:** `lib/one-location/encryption.ts`, `app/one/location/page.tsx`, `components/one-location/redesign/use-location-chat.ts`; new test `lib/one-location/__tests__/encryption.test.ts` (+`fake-indexeddb` devDep). Branch `fix/live-location-share-not-updating-recipient`.
- **Verified:** typecheck + lint clean; new encryption test (4) covers ensure→wipe IndexedDB→restore-from-Keychain-same-keyId→decrypt; full one-location + chat + notifications suites pass (140 tests). **On-device logged-in repro is auth+vault gated (needs QA login)** — roadmap: 2 UAT-test-number accounts (Sender/Recipient in One Network), Sender shares → Recipient View shows a live map that updates on the poll (sim Freeway Drive); relaunch Recipient app → still decrypts (key restored from Keychain, same keyId); legacy poisoned grant → inline "ask to share again" → Sender re-shares → live updates resume.
- **GOTCHAS:** (1) for any E2E/at-rest secret that must survive on iOS, **do NOT rely on WKWebView IndexedDB/localStorage under `iosScheme:"App"`** — it gets evicted; back it with the native Keychain (`HushhKeychain`) and restore on cold start (same pattern as the vault default secret). (2) Test the encryption module in **`node`** vitest env (`// @vitest-environment node`) — jsdom hands out cross-realm ArrayBuffers that Node SubtleCrypto rejects.
- **FOLLOW-UP — cross-device consistency (B18b):** durability/self-heal above still failed when the SAME account was signed into **web + iPhone** (the demo case): the backend keeps one `active` recipient key per user, so the last device to register rotates the other out and the non-active device can't decrypt. Fix = **vault-synced shared keypair**: the ECDH private key is encrypted with the user's `vaultKey` (AES-256-GCM, identical on every device after unlock) and stored server-side as an opaque `encrypted_private_key_jwk` blob; every device fetches its own blob (via `list_state.myRecipientKey`, owner-only) and decrypts → **same keyId everywhere**, no rotation. Reuses the PKM/vault pattern (`lib/vault/encrypt.ts` + `HushhVault.encryptData`). Backend: migration `083`, `register_recipient_key(encrypted_private_key_jwk=…)` (COALESCE-preserving), `list_state` self-key read (never leaked in `recipients`). Client: `ensureVaultSyncedRecipientKey` in `encryption.ts` (remote-blob-wins → local-backfill → generate), `key-bootstrap.ts` fetch+register, `vaultKey` threaded through unlock-warm/page/chat, self-heal now attempts a vault-synced restore+retry before "ask to share again". **Requires a backend deploy (migration+route) to UAT/prod before the client relies on it**; degrades gracefully if `myRecipientKey` is absent. Tests: encryption.test.ts cross-device cases (7) + `test_one_location_routes.py` owner-only-blob assertion. Same branch.

### B19 — Acquisition date in Add Holding could not be filled on iOS
- **Symptom:** in Finance → Manage Portfolio → Add/Edit Holding, tapping the "Acquisition Date" field (or its calendar icon) did nothing on iOS; the date could never be entered on device. Worked on desktop Chrome.
- **Root cause:** the field was a read-only text display plus a HIDDEN 1px `type="date"` input (`h-px w-px opacity-0 pointer-events-none, tabIndex=-1, aria-hidden`), opened programmatically via `showPicker()` / `.focus()` / `.click()`. iOS WKWebView only opens the native date wheel from a DIRECT user tap on the date input itself — programmatic `showPicker()`/`click()` on a hidden input are ignored (`showPicker` also throws NotAllowedError without a user gesture, and Safari/WKWebView never supported it for date inputs until very recent versions). So the tap landed on the decorative text input and the real input never received a user gesture.
- **Fix:** invert the layering — the REAL `type="date"` input is now a full-size invisible overlay (`absolute inset-0 h-full w-full opacity-0 cursor-pointer` + full-bleed `::-webkit-calendar-picker-indicator`) stacked above a purely decorative `pointer-events-none` display div (shows MM/DD/YYYY text + calendar icon). Every tap lands directly on the date input = a genuine user gesture = iOS opens its native wheel. Removed the programmatic `handleOpenDatePicker` path entirely.
- **File:** `components/kai/modals/edit-holding-modal.tsx`. Test: `__tests__/components/edit-holding-modal-acquisition-date.test.tsx` (asserts overlay contract: full-size, no pointer-events-none, no aria-hidden/tabIndex=-1, change updates display, future date rejected).
- **GOTCHA:** for ANY picker-backed field on iOS (date/time/select), never hide the real input and proxy taps to it programmatically — WKWebView requires the user gesture to land ON the input. Use the invisible full-size overlay pattern instead.

### B20 — Sim shows STALE UI after code changes (Xcode upgraded, iOS platform missing)
- **Symptom:** rebuild + `simctl install` + relaunch (even after `simctl uninstall` and a clean web `CLEAN=1` build), but the simulator keeps showing an OLD version of the screen — changes never appear on device. Looks like a WebView cache, but clearing it does nothing.
- **Root cause (two compounding):** (1) Xcode was upgraded (here to **26.3**, SDK iOS 26.2) but the **iOS simulator *platform* was not installed** — only an old runtime (iOS 18.2) existed. `xcodebuild` then finds **no simulator destination** and fails with `error: Unable to find a destination matching … {platform:iOS Simulator}` / `iOS 26.x is not installed. Please download and install the platform`. (2) The failure was **masked** because the build was invoked as `xcodebuild … | tail` — a pipeline's exit status is `tail`'s (0), so the failing build looked successful. `simctl install` then re-installed the **previous/stale `App.app`** every time.
- **How to confirm:** `xcodebuild -project ios/App/App.xcodeproj -scheme App -showdestinations` lists NO `platform:iOS Simulator` device rows (only an ineligible "Any iOS Device" needing the new iOS). And grep the built app vs the synced source: `grep -rl "<a NEW string>" /tmp/hushh-ios-dd/Build/Products/Debug-iphonesimulator/App.app/public/_next/static/...` vs `ios/App/App/public/...` — if the App.app has a different/older `page-*.js` chunk than the synced `public/`, the native build never bundled your changes.
- **Fix:** `xcodebuild -downloadPlatform iOS` (installs the current iOS simulator platform, ~8 GB). Afterwards `-showdestinations` shows the existing iPhone 16 (18.2) again, so you can build for the SAME sim (no new device / no data loss beyond a normal reinstall). Then build **without piping to `tail`** (or check `${PIPESTATUS[0]}`) and verify `** BUILD SUCCEEDED **`.
- **GOTCHA 1:** never invoke `xcodebuild … | tail`/`| grep` and trust the exit code — the pipe hides build failures. Redirect to a log (`&> /tmp/xcbuild.log; echo $?`) and grep for `BUILD SUCCEEDED`.
- **GOTCHA 2:** after any Xcode major upgrade, the simulator platform must be re-downloaded (`xcodebuild -downloadPlatform iOS`) before `run-ios-sim`/`launch.sh` can build for the sim.

### B21 — Keyboard hides inputs AGAIN (vault unlock, OTP, chat, sheets) — breaks the `native`↔`none` flip-flop for good
- **Symptom:** on TestFlight, focusing any bottom-anchored input (the "Enter vault key" field on the Unlock Your Vault gate, OTP, One chat composer, bottom sheets) left it hidden BEHIND the on-screen keyboard — user couldn't see what they typed. QA re-reported the same class B8/B9 supposedly fixed.
- **Root cause — a documented flip-flop, not a new bug.** B9 (`515347b8a`) set `Keyboard.resize:"native"` (frame shrinks → avoidance works). That janked the vault (native recomputes every `dvh`/`svh` ~60×/sec during the keyboard animation), so it was reverted: `26453505b` (→`body`) then `78cf3a94f` (→`none`). `resize:"none"` leaves the WKWebView frame full-height AND there was **no JS/CSS fallback** (B9 had deleted the `--agent-kb-height` machinery), so every bottom input sat behind the keyboard. Net: `native`=avoidance-but-jank, `none`=no-jank-but-hidden — the two kept ping-ponging. Extra drift: `ios/App/App/capacitor.config.json` still read `"native"` (stale; `cap sync` hadn't run after the flip).
- **Fix (the loop-breaker — keep `none`, add event-driven avoidance):**
  1. **NEW `components/keyboard-inset-manager.tsx`** — mount-once native/mobile bridge (mirrors `status-bar-manager.tsx`, returns `null`, mounted in `app/providers.tsx` outer `Providers` next to `<StatusBarManager/>`). Native: dynamic-import `@capacitor/keyboard`, `keyboardWillShow/Hide` set `--kb-height` on `<html>` + toggle `.kb-open` — **once per transition, NOT per frame → zero dvh thrash / zero jank.** Also a delegated `focusin` (capture) net that `scrollIntoView({block:"center"})` the focused field (only when `.kb-open`). Mobile-web fallback via thresholded `visualViewport` (>120px). **Desktop/laptop = firewall: binds nothing, `--kb-height` stays 0px → every consumer is a no-op.**
  2. `app/globals.css`: `--kb-height: 0px` in `:root`; agent composer vars (`--agent-chat-composer-bottom/-focused-bottom`, base + `html.native-ios`) fold in `--kb-height` via `max()` (no home-indicator double-count) so the composer lifts.
  3. Fixed/bottom-anchored primitives consume `--kb-height`: `components/ui/drawer.tsx` bottom `DrawerContent` → `bottom:var(--kb-height)` + `max-h:calc(80vh - var(--kb-height))` (lifting the whole sheet is the ONLY reliable avoidance under `resize:none` — the viewport never shrinks, so `scrollIntoView` alone can't clear the keyboard for a bottom-anchored sheet); `components/ui/sheet.tsx` bottom variant likewise; `components/ui/dialog.tsx` centered → `max-h -= --kb-height` and shift up by `--kb-height/2`.
  4. `components/vault/vault-unlock-dialog.tsx`: vault sheet `max-h` as a `data-[vaul-drawer-direction=bottom]:` variant so it deterministically overrides the base drawer max-h and shrinks by `--kb-height`; the lift comes from the base `DrawerContent`. Kept `repositionInputs={false}` (JS owns avoidance; vaul reposition would re-introduce the keyboard-less sim gap).
  5. `app/register-phone/page.tsx`: OTP region `maxHeight` subtracts `--kb-height` (+ focusin net centers the field).
  6. `capacitor.config.ts`: kept `resize:"none"` (+ fixed the stale comment that still described `body`/`native`); kept `ios.scrollEnabled:false` + `MyViewController.swift isScrollEnabled=false`. **`npx cap sync ios` re-run so `ios/App/App/capacitor.config.json` regenerates `native`→`none` — commit both.**
- **Why NOT translate/offset the vaul `DrawerContent` via transform:** vaul animates open/close with `transform: translate3d`; a `transform` offset fights it. Use `bottom`/`max-h` (position + layout) which compose with vaul's transform, and are only ever non-zero when a real keyboard is up.
- **KEY LESSON:** for THIS fixed-overlay Capacitor app, keyboard avoidance = `resize:"none"` (no frame resize → no jank) + ONE global event-driven `--kb-height` var that fixed/bottom surfaces lift by. Never flip `resize` to `native`/`body` (jank), never scatter per-component `visualViewport` hacks (B8), and never ship `none` without the `--kb-height` layer (B9-revert). Every consumer must be a no-op at `--kb-height:0` so **desktop/web stays 100% inert** (user requirement — the website must not do mobile-keyboard things).
- **Scope:** landed for BOTH web (→ `main` → UAT frontend) and mobile (→ `mobile` → TestFlight); the avoidance layer is web-safe (inert on desktop, active on mobile web via the visualViewport fallback).
- **Files:** `components/keyboard-inset-manager.tsx` (new), `app/providers.tsx`, `app/globals.css`, `components/ui/{drawer,sheet,dialog}.tsx`, `components/vault/vault-unlock-dialog.tsx`, `components/agent/agent-popover-provider.tsx` (comment), `app/register-phone/page.tsx`, `capacitor.config.ts` (+ synced `ios/App/App/capacitor.config.json`). Branch `fix/ios-keyboard-avoidance`.
- **Verified:** typecheck (0 errors) + lint (0) + design-system pass; compiled CSS confirmed to contain all `--kb-height` rules (`calc(80vh - var(--kb-height,0px))` etc. — Tailwind v4 normalizes the calc operators, so no `_` escaping needed). **On-device (iPhone 17 sim, iOS 26.5, UAT backend): the Unlock Your Vault hard-gate with the software keyboard OPEN shows the "Enter vault key" input + Unlock button fully ABOVE the keyboard (was hidden behind it before) — the exact reported bug, fixed. No vault jank (resize:none).** Other surfaces (OTP/chat/drawer/dialog) share the same mechanism + verified CSS. NOTE: the local `next build --webpack` hangs when the repo lives in an iCloud-synced `~/Documents` folder (FileProvider throttles all build I/O to a crawl / 0% CPU) — build from a non-iCloud path (see GOTCHA below).

### B5 — Redesign leak check (always run when "it shows on the website")
- **How to verify no leak:** `git merge-base --is-ancestor <redesign-sha> origin/main` for each redesign commit → must be false. The website deploys from `main` only (manual `workflow_dispatch`); the `mobile` branch (pushed as `ankit/iOS-UI-rephrased-v01`) never reaches it.

### B22 — One connection request showed as TWO rows in the Feed's "Needs you"
- **Symptom:** a single incoming connection request rendered twice, stacked: a blue-shield row with only a chevron, then a green-person row with the real Decline/Confirm buttons — same name, same "Wants to connect with you." Reported as a "double notification"; it is NOT a push/APNs bug and not iOS-specific (the hook is shared, so web sees it too).
- **Root cause:** `lib/feed/use-feed-actionables.ts` builds "Needs you" from independent lanes, and a pending connection request arrives on **two** of them. The connections lane reads `ConnectionsService.listRequests({direction:"incoming"})` directly; the consent lane reads the Consent Center's `pending` surface — which folds those same requests in via `_incoming_connection_request_entries` (`consent-protocol/hushh_mcp/services/consent_center_service.py:1242`), calling **that very same** `ConnectionsService.list_requests(direction="incoming")` and setting `id`/`request_id` to the connection request's own id. Same row, fetched twice. The lanes namespace their keys differently (`consent:<id>` vs `connection:<id>`), so nothing deduped them.
- **Fix:** the consent lane now skips `entry.kind === "connection_request"` — the connections lane owns them, because it carries the inline Confirm/Decline and correctly routes *scoped* requests to the Consent Center Review (a feed shortcut must never turn an omitted scope decision into a silent decline). No coverage lost: both lanes resolve to the same backend source. Removed the then-unreachable `connection_request` branch in `consentSummary`.
- **Files:** `hushh-webapp/lib/feed/use-feed-actionables.ts`; regression test `hushh-webapp/__tests__/lib/feed/feed-actionables-connection-dedup.test.tsx`. Branch `fix/feed-duplicate-connection-request`.
- **Verified:** 3/3 tests pass; with the guard removed they fail with `expected [ …(2) ] to have a length of 1 but got 2` and `expected 'consent:conn-req-1' to be 'connection:conn-req-1'` — reproducing the screenshot's stacking order. `tsc --noEmit` 0 errors, `eslint --max-warnings=0` clean.
- **LESSON:** when one item shows twice in "Needs you", suspect two lanes over one source before suspecting push delivery. The Consent Center's `pending` surface is a **union**, not a disjoint feed — anything added there may already have its own lane.
- **Adjacent, still open (separate bug):** duplicate *real* push banners are plausible for a different reason — every Cloud Run instance starts its own `LISTEN consent_audit_new` (`consent-protocol/server.py:507`) and Postgres NOTIFY fans out to all listeners, but the FCM send (`api/consent_listener.py:586`) has no advisory lock or leader election. Prod runs `max-instances=5`. Two warm instances → two identical banners. Unconfirmed in logs; not fixed here.

---

## 🧪 QA test phone numbers (UAT, fixed OTP `000000`)
The app has a backend UAT-test phone path so QA can log in without SMS, with a FULL prod-like experience (real backend/vault/app — only the OTP is bypassed).
- **Backend (already live on `consent-protocol` / `hushh-pda-uat`):** `ENVIRONMENT=uat`, secret `HUSHH_UAT_PHONE_TEST_CODE=000000`, secret `HUSHH_UAT_PHONE_TEST_NUMBERS` (comma/`;`/newline-separated allowlist, ref `:latest` → durable across CI deploys). Handler: `consent-protocol/api/routes/account.py` `/api/account/phone/uat-test/{start,confirm}`. Frontend: `ApiService.*UatPhoneTestVerification`, `AccountIdentityService.*UatTestPhoneVerification`.
- **Native was gated off (`!isNative`) — fixed in `0c19110c2`:** `lib/firebase/auth-context.tsx` `startPhoneVerification` now tries the UAT-test start on native too, and `confirmPhoneVerification` routes `uat-test-phone:` verification ids to the UAT-test confirm before the real Firebase native link. Prod-safe (backend returns ineligible when not UAT/allowlisted → falls through to real Firebase).
- **Allowlist changes are governed operations:** never read or print the secret value and never
  mutate Cloud Run directly from this bug log. Follow
  `.codex/skills/repo-operations/references/admin-release-sop.md` and the current UAT phone-test
  runbook; use the governed workflow so secret rotation, revision provenance, and rollback evidence
  remain auditable.

## Palette invariant (so bugs don't reintroduce blue)
Onboarding + agent chat + profile use the luxury palette: onyx `#0A0908`, champagne gold `#D4AF6A` (dark), deep gold `#9C7434` (light), cream `#F4EAD6`, ivory `#FAF6EE`, ink `#17130C`, positive `#12A150`, destructive `#C94F44`. **No indigo/blue** (`#5E5CE6`/`#8583ff`) on redesigned mobile surfaces. The `/one` dashboard uses the 2a pastel blocks. See [[hushh-research-mobile-branch]].
