Source profileQuality 96/100

hushh-labs/hushh-research/.claude/skills/mobile-bug-log/SKILL.md

mobile-bug-log

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 mobi

Source repository stars
25
Declared platforms
0
Static risk flags
1
Last source update
2026-08-25
Source checked
2026-08-25

Decision brief

What it does: where it fits

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).

Best for

  • 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…

Not for

  • Tasks that require unconfirmed production actions or broad system permissions.
  • Environments where the pinned source and install steps cannot be inspected.

Compatibility matrix

Platform support, with evidence labels

PlatformStatusEvidenceWhat to check
CodexNot declaredNo explicit evidencePortability before use
Claude CodeNot declaredNo explicit evidencePortability before use
CursorNot declaredNo explicit evidencePortability before use
Gemini CLINot declaredNo explicit evidencePortability before use
Open the compatibility checker

Installation

Inspect first. Install second.

The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

Source-detected install commandSource
npx skills add https://github.com/hushh-labs/hushh-research --skill ".claude/skills/mobile-bug-log"
Safe inspection promptEditorial

Inspect the Agent Skill "mobile-bug-log" from https://github.com/hushh-labs/hushh-research/blob/42522d05abd9f89f9de6b436befe801e0ea0585b/.claude/skills/mobile-bug-log/SKILL.md at commit 42522d05abd9f89f9de6b436befe801e0ea0585b. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

Workflow

What the source asks the agent to do

  1. 01

    B1 — Set up One back button bounced back to /one/setup (native)

    Symptom: top-left back (←) on /one/setup did nothing (bounced straight back).

    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…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…
  2. 02

    B4 — "Setup tiles do nothing" was NOT a routing/export bug

    Ruled out: all /one/setup//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…

    Ruled out: all /one/setup//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…- Ruled out: all /one/setup//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'…
  3. 03

    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.

    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…Fix (commit ee35b9e12) — decouple "entered a capability" from "finished ALL setup":
  4. 04

    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" (retrac…

    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" (retrac…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 lead…Fix (commit 81db93823) — make the marker a valid path so it works for BOTH the guard and the breadcrumb:
  5. 05

    🔴 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…

    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…Root cause: .env.local has NEXTPUBLICBACKENDURL=http://localhost:8000 (local dev backend). capacitor.config.ts bakes that into every plugin's backendUrl, and the web layer fetches ${NEXTPUBLICBACKENDURL}/db/vault/check.…Fix: build the sim against the reachable UAT backend. Inline override (does NOT touch the user's .env.local):

Permission review

Static risk signals and limitations

Reads files

low · line 88

The documentation asks the agent to read local files, directories, or repositories.

`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-s

Reads files

low · line 299

The documentation asks the agent to read local files, directories, or repositories.

**GOTCHA 3 — check whether main already solved it before inventing an API.** `dragDismiss: boolean | "handle"` and `contentDragDismiss: boolean` are the same feature; main's is better (a separate boolean beats an overloaded prop, and it shi

Evidence record

Why each signal appears

EvidenceSourceComputedTestedEditorial
SignalValueEvidence typeMeaning
Quality score96/100ComputedDocumentation, specificity, maintenance, and trust rules
Repository stars25SourceRepository attention, not individual Skill quality
Compatibility0 platformsSourceDeclared in the catalog source record
Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

Pinned source

Provenance and original SKILL.md

Repository
hushh-labs/hushh-research
Skill path
.claude/skills/mobile-bug-log/SKILL.md
Commit
42522d05abd9f89f9de6b436befe801e0ea0585b
License
Apache-2.0
Collected
2026-08-25
Default branch
main
View the original SKILL.md

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 === 404emptyMetadata/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 26453505b78cf3a94f) 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), 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) 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** — normalizeInternalRouteHrefrequiresstartsWith("/")andsearchParams.get` already decodes.
    2. top-shell-breadcrumbs.tsONE_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: setupOriginatedCapabilityEntrynormalizeInternalRouteHref(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.tsonPointerDownCapture 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 mismatchapp/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 (handleAskReshareOneLocationService.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 nativenone 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-openonce 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 DrawerContentbottom: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 nativenone — 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).

B22 — Share location hangs forever, every Location control takes 5–7s, and sharing with several people only ever reaches the first one

  • Symptom (three screenshots + report, UAT / web / app): (a) "Share outside your Circle" → the create button spins forever — no link, no error, and the button stays disabled so there is no retry; (b) "Before you start" (Step 3 of 3) → toast Could not get your location. Turn on Location for your device/browser and try again. and the share never starts; (c) every CTA on the Location surface takes 5–7 seconds before anything happens; (d) sharing live location with multiple people "does not work" — the first person's dot keeps moving, nobody else ever updates.
  • Root cause (a)+(b) — one defect, two faces: a promise that can never settle. readOnce in lib/capacitor/plugins/location-web.ts trusted the W3C timeout option. Per spec that clock does not start until the permission prompt is resolved, so a prompt the user never answers — or one the browser suppresses without telling the page — fires neither callback, ever. Every caller awaits it (OneLocationService.captureCurrentPositionHushhLocation.getCurrentPosition), so the public-link CTA spun with no result and no error; the share CTA was the same chain reaching its rejecting end. handleCreatePublicInvite's finally was correct all along — it simply could never run. The sampling stage also rejected with a bare { code: 3 } object literal, which every downstream instanceof Error check (locationServicesErrorMessage, LocationBus.isDeniedError) silently degraded.
  • Root cause (c) — no reuse, no coalescing. Every control called captureCurrentPosition() directly (11 uncoalesced call sites). A three-step share wizard paid for three full GPS acquisitions; N recipients started N simultaneous reads competing for the same radio. Step 2 acquired a valid fix and discarded it, so Step 3 paid again.
  • Root cause (d) — the try/catch was OUTSIDE the loop. Both live-publish loops in app/one/location/page.tsx (the 20s interval publisher and the movement watcher) wrapped the whole for … await in one try. The first recipient that threw aborted the entire tick — and because activeOwnerGrants is stably ordered, the same recipient threw on every tick, starving everyone after them permanently. In the movement watcher it was worse: lastPublishedPointRef.current = point sat after the loop, so it never advanced and the gate re-fired the same failing publish forever.
  • Fix (PR #5231, branch fix/one-location-capture-latency):
    1. location-web.ts — one overallDeadlineAt shared by every stage (timeoutMs + 8s), a LAST_RESORT_RESERVE_MS = 4s so the cached-fix recovery still gets a window, a real setTimeout deadline inside readOnce, and timeoutError() returning an Error (name LocationTimeoutError, code: 3) instead of an object literal.
    2. lib/one-location/service.tscaptureCurrentPosition({ maxAgeMs }) reuses a fix younger than 20s (CAPTURE_DEFAULT_MAX_AGE_MS, deliberately under the backend's 60s capture→confirm freshness limit) and coalesces concurrent callers into one read via pendingCapture. Plus invalidateCapturedPosition() and a test reset seam.
    3. page.tsx — both live loops became Promise.all with a per-grant try/catch; a grant with no usable recipient key now logs instead of skipping silently. Public-link + contact-invite routed through ensureForegroundLocationReady (they were the only share entry points with no permission pre-check). ensureForegroundLocationReady now returns its point unconditionally.
    4. Freshness opt-outs (maxAgeMs: 0) where a reused fix would be wrong: live publishing, "locate me", saving a place, and the nearby check-in confirmation anchor (whose comment already demanded a fresh point).
    5. lib/services/api-service.tsfetchWithWebTimeout, a 60s ceiling on the browser fetch (above the Next proxy's own 45s so its 504 still wins). The native branch was already bounded; the web branch relied on a proxy that only bounds its own upstream call. Composed manually rather than with AbortSignal.any, which is not in every WKWebView this app runs in.
  • ⚠️ Deliberately NOT changed: recipientForGrant's strict keyId match. The backend freezes a grant against one recipient key and rejects any other envelope (see [B18] LOCATION_ENVELOPE_KEY_MISMATCH), so relaxing the match would trade a silent skip for a guaranteed publish failure. The skip is logged instead.
  • Verified: typecheck + lint (--max-warnings=0) + verify:design-system clean. 39 new tests across location-web-get-current-position.test.ts, capture-current-position.test.ts, api-web-fetch-timeout.test.ts, and a live-loop case in one-location-agent-page.test.tsx. Full suite 29 failed / 3547 passed vs baseline origin/main 29 failed / 3508 passed — failing sets byte-identical, +39 passing, zero regressions. Two tests were confirmed to FAIL against pre-fix code: the acquisition promise had still not settled after 60 simulated seconds, and the multi-recipient case gave expected [ 'key_b' ] to include 'key_d' (the second recipient was never reached).
  • GOTCHA 1 — never trust the browser's own geolocation timeout. It does not start until the permission prompt resolves, so it cannot bound an unanswered prompt. Any getCurrentPosition/watchPosition wrapper needs its own setTimeout deadline or it can hang forever.
  • GOTCHA 2 — a for … await loop over recipients needs the try/catch INSIDE it. With stable ordering, one bad item starves every item after it on every tick, deterministically. Prefer Promise.all + per-item catch.
  • GOTCHA 3 — reject with Error instances, never { code: n } literals. Several layers here branch on instanceof Error, and a literal silently degrades every user-facing message.

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.

B23 — Your Map opened BLANK the first time, correct on every entry after (iOS/Android native)

  • Symptom: QA opened Location → Your Map and got a blank surface with the map chrome and the people tray drawn over it normally — no error, no spinner, no console output. Re-entering the screen rendered fine. Reads as "the map didn't load"; it is not a tiles, key, permission or data problem.
  • Read the screenshot first: the loading overlay only lifts once mapReady is true, and the full-bleed fallback only renders when status === "unavailable". Seeing NEITHER, over a blank canvas, means the component believed the map was ready over a native view that no longer existed. That combination is the whole diagnosis.
  • Root cause: @capacitor/google-maps addresses every native map by its string id alone. GoogleMap.create() cannot be cancelled — it waits ~200 ms before the native view is registered (node_modules/@capacitor/google-maps/dist/esm/map.js:116) — and destroy() resolves to maps.removeValue(forKey: id) (ios/Sources/CapacitorGoogleMapsPlugin/CapacitorGoogleMapsPlugin.swift:170) with no check that the caller is the instance that registered it. So an unmount inside a create window left an abandoned create running: the old cleanup found mapRef.current still null and destroyed nothing, the abandoned create registered its map anyway, and its cancelled branch then destroyed MAP_ID — which by then belonged to the map the NEXT mount had created. Reachable from ordinary navigation: both map routes are keyed key={auth.userId ?? "anonymous"}, and the Location hub's check-in tile pushes /one/location/map?action=check-in, which the map route immediately redirects to /one/location/check-in — same component, same MAP_ID, fresh create over an in-flight one.
  • Fix: serialize every create and destroy for that id behind a module-level lock + generation counter, so a superseded instance tears down its own map while still holding the lock and the next create always starts on a free id. The unmount effect stopped being a second, unlocked teardown owner (the create effect's cleanup is the single owner and also runs on unmount); closeMap goes through the lock too. Also wait for a non-zero box before handing the element over — the plugin only retries a zero width (map.js:137), so a container measured mid-layout at full width and zero height had that zero baked into the native frame permanently.
  • Files: hushh-webapp/components/one-location/location-immersive-map.tsx; tests in hushh-webapp/__tests__/components/location-immersive-map.test.tsx ("native map lifecycle"). Branch fix/map-blank-first-view, PR #5235.
  • Verified: 22/22 in that file. Against the unfixed component the two new tests fail with exactly the reported signature — expect(registry.has("one-location-private-map")).toBe(true) returns false while the screen reports data-map-ready="true", and the event order comes back create:1, create:2, destroyed:1 instead of create:1, destroyed:1, create:2. Wider one-location surface: 64 failed/131 passed vs 64 failed/129 passed on origin/main, failing sets identical by name. tsc --noEmit clean, eslint clean, verify:design-system OK.
  • LESSON: a Capacitor plugin keyed by a string id has no instance identity. Any teardown you fire for such a resource must be ordered against the next setup, or a late destroy silently kills a live view and the screen reports success. Test it by keeping the bridge's two phases apart — the native call landing, then the JS promise resolving. Resolving creates one at a time hides this class of bug completely: the first draft of these tests passed against the broken code.
  • Same change, UI: the people tray took the less-text pass (count stated once; section heading, duplicate count badge and the two-sentence pin rule removed or reduced to one line in the empty state). Renderer consent copy deliberately untouched — it is a versioned privacy surface.

B24 — Onboarding "pin your entrance" picker opened BLANK (same class as B23, worse trigger)

  • Symptom: the picker reports itself ready over an empty map surface. Same shape as [[B23]]: no error, no spinner, no console output, because the component believes the native map exists.
  • Root cause: identical to B23 — @capacitor/google-maps addresses a native map by string id with no instance identity, and create() cannot be cancelled. An unmount inside the create window left the cleanup with createdMap still null (destroying nothing) while the abandoned create registered its map anyway and then destroyed the id the NEXT run had claimed.
  • Why its trigger is WORSE than B23's: Your Map needed a route-level remount. The picker races itself on an ordinary cold open. The create effect depends on colorScheme, derived from next-themes' resolvedTheme, which is undefined on first render and resolves after hydration — well inside the plugin's ~200 ms create window. The container is also key={colorScheme}, so the element is swapped underneath at the same moment. Probably under-reported only because the picker is a once-per-user onboarding step.
  • Fix: route it through the same per-id lane as Your Map (lib/one-location/native-map-lifecycle). Separate ids get separate lanes, so onboarding and Your Map can never stall each other — covered by its own test.
  • Files: hushh-webapp/components/one-location/onboarding/location-picker-map.tsx; test hushh-webapp/components/one-location/onboarding/__tests__/location-picker-map-native.test.tsx. Branch fix/picker-map-blank, PR #5242.
  • Verified: fails on the unfixed picker (expected false to be true — no native map registered under the id), passes fixed. 23/23 onboarding, 61/61 across onboarding + lifecycle + immersive map. tsc and eslint clean.
  • LESSON (testing this class): the bug only reproduces when the bridge's two phases are kept apart the way a device sequences them — every outstanding native call lands, and only then do the JS promises settle. Settling one create fully before the next is issued hides it completely. My first draft of BOTH B23's and B24's tests passed against the unfixed code for exactly this reason. If a lifecycle test passes on day one, suspect the harness before believing the code.
  • Two jsdom harness traps that cost real time here:
    • Mocking isNative() to true also drives waitForLaidOutBox, which polls a real timer, and jsdom measures every element as 0x0 — so the task hangs and nothing reaches the bridge. Stub the layout wait in component tests; its budget is covered in __tests__/lib/one-location/native-map-lifecycle.test.ts.
    • A mocked module must define every export the code path touches. The DARK branch reads DARK_MAP_STYLES from maps-config; vitest throws on an undefined export, so run 2 died while building the create config, before the bridge — presenting exactly like the bug under test. Symptom to recognise: the component logs that it is calling create, but the mock never records the call.

B25 — Demo people on the shipped map, a dead Everyone, a truncated "Check in", and a close X that did nothing under check-in

  • Symptom (five reports, one shape): the Location map showed fifty fictional people; the "Sharing with N" pill listed names you could not act on; the Check-in pill truncated to the single letter "C" on iPhone; "Everyone" did nothing; and the close X did nothing while the check-in sheet was open. Every one of these is a control that is plainly visible and cannot answer — from a phone that is indistinguishable from broken software.
  • Root causes (all five different, none in the obvious place):
    1. Demo: the client gate (lib/testing/location-map-demo.ts) is correct and explicit opt-in — PR #5297 already fixed it. The leak was the lane: .github/workflows/deploy-uat.yml passed _LOCATION_MAP_DEMO=true. The CI guard in scripts/ci/runtime-contract-check.sh only ever checked deploy-production.yml, so UAT was never covered. UAT is not a private sandbox — it is the frontend every reviewer, tester and demo audience sees.
    2. Sharing rows: inert <li> text. Looks like a list you can act on, answers nothing.
    3. Header truncation: grid-cols-[1fr_auto_1fr] forces the two OUTER columns to equal width, so the left column (one 56px X) was padded to match Check-in + Locate, burning ~95px, and the squeezed centre ate the Check-in label. Measured in Chromium: truncated at 320, 360, 375, 390 AND 430 — every phone width.
    4. Everyone: disabled={visibleMarkers.length === 0} plus a silent early return. Disabled with nothing to frame, silent when the only thing to frame was you — both are exactly a new account's state.
    5. Close X under check-in: the check-in sheet was <Sheet modal ...>, so Radix painted fixed inset-0 z-[711] touch-none across the whole screen. The map header is z-30, so the X, Locate and Check-in stayed perfectly visible through a 22%-black blur and swallowed every tap.
  • Fix: UAT lane → false, and the CI guard now bans _LOCATION_MAP_DEMO=true in every workflow, not just production. Sharing rows → 44px buttons that fly to the person's pin when the sharing is mutual and open the Location screen when it is not. Header → phones get controls on row one at natural widths with Sharing on its own row beneath (grid-cols-[auto_minmax(0,1fr)] + sm:grid-cols-[1fr_auto_1fr]), so sm+ is unchanged. Everyone → never disabled, states when nobody shares yet, still moves the camera. Check-in sheet → modal={false} + new showOverlay={false} opt-out on SheetContent, refuses to dismiss on outside interaction (panning the map is not a dismissal), and caps its height below the header; the sheet close keeps its 32px look with after:-inset-1.5 extending the hit region to 44px.
  • Files: .github/workflows/deploy-uat.yml, scripts/ci/runtime-contract-check.sh, components/one-location/location-immersive-map.tsx, components/one-location/nearby-check-in/nearby-check-in-sheet.tsx, components/ui/sheet.tsx. Tests: __tests__/components/location-immersive-map.test.tsx (+6), new __tests__/components/location-map-chrome.contract.test.tsx (8). Branch fix/location-map-ios-hardening, PR #5305.
  • Verified: typecheck + lint + verify:design-system clean. Full suite 27 failed / 3969 passed vs a 28 / 3954 baseline on clean origin/main — zero new failures, identical failing set minus one flake. All 14 new tests fail on the code they replace (verified by stashing the source). Rendered evidence: real Chromium, real compiled Tailwind, verbatim header markup at 320/360/375/390/430/768/1280 — before FAILs at all five phone widths, after PASSes at all seven. On-device logged-in repro still needs a QA login (auth + vault gated).
  • GOTCHA 1 — a correct feature gate does not mean the feature is off. Check the deploy LANE, not just the code. deploy-uat.yml's substitutions overrode a "false" Cloud Build default, and a CI guard scoped to one workflow file is not a guard.
  • GOTCHA 2 — a modal sheet is a full-screen touch blocker over the HOST screen's chrome, whatever the host's z-index. z-[711] touch-none beats z-30 and every other app layer. Any sheet anchored to a live surface (a map, a canvas, a video) must be modal={false} + showOverlay={false} and must refuse outside-dismissal, or the host's own controls become visible-but-inert. Symptom to recognise: "the X is not working" while the X is clearly on screen.
  • GOTCHA 3 — grid-cols-[1fr_auto_1fr] is a phone-hostile way to centre something. It pads the narrow outer column to match the wide one; on a 375px header that is ~95px of wasted space and a truncated action word. Centre with it from sm up only, and give phones content-sized columns.

B26 — The save-a-place sheet was a floating pad, its Address box would not clear, and its map could vanish

  • Symptom (one report, four causes): the Pin your entrance / Address details flow read as a rectangular pad hovering above the app rather than a sheet attached to the bottom of the screen, with obvious grey strips down both sides. It had no drag-to-dismiss and no grabber. It asked for a PIN / postcode that was already visible in the line directly above the box, plus a Building colour and a "Fill from this address" checkbox governing them.
  • Root causes:
    1. The pad: the surface was a Radix Dialog wearing a sheet's corners, carrying an unconditional mx-auto max-w-[420px]. On a 375–430px phone that cap is invisible, which is exactly why it survived every review; between 421px and 639px — a large phone in landscape, a small tablet, an iPad split view, a browser dragged narrow — it painted a 420px card centred on a wider screen. Being a Dialog is also why there was no gesture: the dialog primitive has no drag handle and no drag-dismiss.
    2. The Address box could not be cleared. addressLineEditedRef.current = true followed by setAddressLineValue("") when the state was already "" makes React bail out of the re-render, so the ref flip is never reflected and the detected address snaps straight back. Select-all-delete did visibly nothing, and the "no address" branch was therefore unreachable once a lookup had succeeded.
    3. The map could collapse to a hairline. h-[min(56vh,420px)] on a flex item whose only child fills it with h-full — min-content height 0 — so a viewport short enough to overflow the pane resolved the overflow by deleting the map.
    4. The keyboard cap double-counted. The sheet is pinned above the keyboard by bottom-[var(--kb-height)]; capping it at 92dvh - kb subtracts the keyboard a second time. On an iPhone SE with a 300px keyboard that left 222px for a 61px header, a 36px minimum body and a 129px footer, and the pinned footer was pushed 3px past the bottom of its own sheet, where overflow-y-hidden clipped it silently.
  • Fix: below 640px it renders the canonical components/ui/sheet.tsx at full viewport width, flush with the bottom edge, home indicator inside the footer's padding, step rail doubling as the grabber; above 640px it stays the centred dialog. addressLineValue became one nullable state. shrink-0 on the map. max-h-[calc((100dvh-var(--kb-height,0px))*0.92)]. PIN derived at save time from the line as it stands; Building colour no longer requested but still passed through for places that carry one; the checkbox deleted with the fields it governed.
  • Files: components/one-location/onboarding/save-location-modal.tsx, save-location-sheet-layout.ts, location-picker-map.tsx, components/ui/sheet.tsx (two additive props), e2e/save-location-sheet.layout.spec.ts, new __tests__/components/shared-sheet-consumers.contract.test.tsx. Branch fix/save-location-native-sheet, PR #5626, issue #5619.
  • GOTCHA 1 — a max-w narrower than a phone is invisible ON a phone. Every review happened at 375–430px, where a 420px cap does nothing. The band that breaks is the one nobody tests: 421–639px. If a surface is a bottom sheet, assert its rendered box is the full viewport width at 480, 540 and 639 too — a class-string assertion cannot see this, and neither can a screenshot taken on an iPhone.
  • GOTCHA 2 — setState(x) when the state is already x does not re-render, so a ref flipped in the same handler never takes effect. The useRef(edited) + useState("") pair is a common prefill idiom and it silently breaks the one case that matters: clearing the field. Use a nullable state (null = untouched) instead.
  • GOTCHA 3 — a fixed-height flex item with no intrinsic content can be shrunk to zero. h-[…] is not a floor. A map, a canvas or a video that fills its box with h-full has a min-content height of 0, so the first short viewport deletes it. shrink-0.
  • GOTCHA 4 — do not subtract --kb-height twice. A surface pinned with bottom-[var(--kb-height)] is already above the keyboard; its height budget is 100dvh - kb, so cap it as a fraction OF THAT, never as Ndvh - kb.

B27 — "A large white strip above the map" on Your Map was the CAMERA, not the layout

  • Symptom: a screenshot of /one/location/map with a blank band between the top of the screen and where the map appears to start, reported as an unfinished-looking layout: "the map starts below a large blank/white region". Reads exactly like a header strip, a reserved toolbar height, or a safe-area padding bug.
  • It is none of those. The surface is h-[100dvh] and the renderer is absolute inset-0 — the box fills the screen at every width and height, and a browser layout contract now proves it (e2e/one-location-map-consent-panel.layout.spec.ts asserts renderer top <= 0.5px). What did not fill it was the picture inside the box.
  • Root cause (arithmetic, not CSS): Google draws a Mercator world 256 * 2^zoom CSS px tall and paints its own near-white backdrop everywhere else. The pre-consent camera was hardcoded { lat: 20, lng: 0 }, zoom: 2. That world is 1024 px tall, and latitude 20 sits 453.9 px down it — so the camera can cover at most 453.9 * 2 ≈ 908 px of viewport height. Past that, the difference is backdrop.
  • Which is why it survived review: the failure is a property of the DEVICE, not the build. iPhone 15 (844 px) → 0 px. iPhone 15 Pro Max (932 px) → 12 px. The 552×1080 window the screenshot came from → 86 px, matching the reported band. A simulator run on the wrong device size proves nothing here.
  • Sibling of a trap already in this file's neighbour: location-immersive-map.tsx documents that the @capacitor/google-maps web shim implements setPadding as fitBounds(bounds, padding) — a zoom-OUT, which snapped z2 → z1 and produced bands at both edges. That was fixed by not calling setPadding on web. It could not fix a starting zoom that was already too low for the box, which is this bug.
  • Fix: lib/one-location/map-world-view.ts derives the neutral view from the box the renderer was actually handed (element.getBoundingClientRect() after waitForLaidOutBox): pick the smallest zoom whose world covers the longest edge, then slide the centre latitude only as far as it must to fit. Prefers latitude 20 and only moves when the arithmetic forces it — 932 px slides the latitude ~4°, 1080 px raises the zoom. A resize/rotation re-fits, but only while renderer consent is pending; once there is a real position the camera belongs to focusSelfPoint.
  • Files: lib/one-location/map-world-view.ts, components/one-location/location-immersive-map.tsx. Tests: __tests__/one-location/map-world-view.test.ts. Branch fix/your-map-full-viewport-avatar-marker, PR #5628, issue #5622.
  • Regression prevention: the guard is a sweep, not a device listoutOfWorldBandPx() is asserted to be 0 for every height from 320 to 2000 px, because the defect was a boundary nobody had computed. The same oracle also reproduces the old camera's 86 px band, so the regression stays visible in the test rather than only in the commit message.
  • LESSON: when a map has an unexplained band, measure the box FIRST and then stop looking at CSS. A raster map at integer zoom cannot fill a box taller than its world, and a fixed zoom is a bet on a viewport size. If you hardcode a camera, hardcode the arithmetic that makes it valid — or derive it.
  • Same change, marker: the owner's own pin became their avatar (HTML over the renderer via projectToMapBox, the pattern the name pills already use). Worth knowing for any future marker work: @capacitor/google-maps exposes only tintColor per marker, and its iconUrl on iOS accepts an https: URL or a bundled public/ asset (ios/Sources/CapacitorGoogleMapsPlugin/Map.swift:693) — not a data: URI, and .svg is rejected outright. A composed avatar therefore cannot be a renderer marker at all; it has to be HTML in the WebView, which composites above the native map view the same way the pills do.

B28 — The Check-In sheet's close X was under the drag handle, and its 500 m ring was Google's default, not ours

  • Symptom (desktop screenshot, but both causes are phone-first): the nearby Check-In panel read as a wall of explanation on both sides of the flow, its Check out now button was large destructive red, and the 500 m radius on the map was drawn as a heavy black outline over a strong grey disc — the strongest visual object on a screen whose whole subject is the map.
  • Root cause 1 — the ring was never ours. location-immersive-map.tsx built the overlay with fillColor: "var(--app-accent-surface)" and strokeColor: "var(--app-accent)" and handed it to @capacitor/google-maps. Neither renderer resolves CSS custom properties. The web shim passes the object verbatim into new google.maps.Circle, which silently falls back to its OWN defaults on an unparseable colour — that is the black ring and grey disc, and it is Google's styling, not a choice anyone made. iOS is worse in a quieter way: Circle.swift does UIColor(hex: hexColor) ?? UIColor.blue, so on device it drew flat blue and nobody could tell it apart from the accent it was supposed to be. strokeOpacity/strokeWeight DID apply, which is why the ring looked deliberate. Fixed by resolving --app-accent off document.documentElement to a real hex at draw time (regex-guarded, with a #007aff fallback, so a token that resolves to another var() or to nothing during first paint never reaches the bridge) — and this keeps the alternate accent theme (#d4a574) working, which a hardcoded hex would not.
  • Root cause 2 — the close X sat under the drag handle. components/ui/sheet.tsx renders the grabber as a full-width sticky top-0 z-[5] flex h-11 touch-none band occupying y=0..44. The close button is a 32px circle at top-4 whose hit region is widened to the platform's 44px minimum by after:-inset-1.5, so it starts at y=10 — three quarters of its target lies under a higher-z, touch-none sibling that swallows the tap. Present on every bottom sheet that shows both, not just this one. Fixed with z-10 on SheetPrimitive.Close.
  • Root cause 3 — the copy. 155 words of persistent panel text, including a two-sentence account of how the nearby roster is built, a privacy paragraph under the primary action, a full postal address on the success card, a 0 badge beside an empty state that already said nobody, and an auto-refresh promise. Cut to 49 (68%), seven strings removed outright.
  • Root cause 4 — Check out now was variant="destructive", solid --app-destructive with white on it. Checking out flips status, destroys the anchor key, keeps the row and can be redone in three taps — a reversible lifecycle step. Now secondary.
  • Fix: nearby-check-in-sheet.tsx (panel + copy), location-immersive-map.tsx (mapAccentHex(), fill 0.06 / stroke 0.35 at 1.5px), components/ui/sheet.tsx (z-10), new check-in-panel-layout.ts (class strings shared with the browser spec so it cannot measure a replica). Branch fix/check-in-panel-plain-copy-redesign, PR #5632, issue #5621.
  • Gesture, resolved by main and not by me: the panel passed dragDismiss={false}, which switched the gesture off AND took the grab handle with it — a phone bottom sheet with no affordance to put it away. It cannot use body drag: it owns an inner scroller, so its own scrollTop is pinned at 0 and every downward swipe over the place list would engage the dismissal. I had added a dragDismiss: boolean | "handle" union for this; PR #5626 had already landed contentDragDismiss for the identical reason, so mine was deleted on merge. See GOTCHA 3.
  • GOTCHA 1 — a CSS variable is not a colour to a native bridge. Anything crossing into @capacitor/google-maps (circles, polylines, marker tints) must be a literal. The failure is silent and asymmetric: web falls back to Google's defaults, iOS falls back to .blue, and BOTH look intentional. Sibling of the setPadding divergence already documented in B27 and capacitor-web-shim-divergence. Resolve tokens with getComputedStyle(document.documentElement).getPropertyValue(...) and guard the result.
  • GOTCHA 2 — a z-[5] touch-none band above a corner button eats it. Related to B25's "close X that did nothing under check-in", but a DIFFERENT cause: that one was the modal scrim over the host screen, this one is the sheet's own drag handle over its own close button. Symptom is identical from the user's side — a visible control that ignores most taps — so check both layers before concluding it is the scrim again.
  • GOTCHA 3 — check whether main already solved it before inventing an API. dragDismiss: boolean | "handle" and contentDragDismiss: boolean are the same feature; main's is better (a separate boolean beats an overloaded prop, and it shipped with useSheetDragHandle()). Two branches touching one shared primitive in the same week is normal here — read what landed on main for that file BEFORE designing an extension to it, not at merge time.
  • GOTCHA 4 — a class-string assertion cannot prove a button is not red. Every button in this design system carries aria-invalid:ring-destructive/20 in its base class, so className.not.toContain("destructive") passes on a fully red button. Assert the FILL token, or better, compare the computed background against a destructive button rendered beside it in a real browser.
  • LESSON: three of the strings this replaced (Stay visible for, Places within 500 m, You're visible nearby) were asserted by nothing anywhere in the repo, and the sheet's own 38-test suite ran in NO pull-request lane — only the merge queue, which review-bypass users skip. A copy change that "cannot break anything" is exactly the one with no test under it. Register the suite in the targeted pack in the same PR.

🧪 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]].

Frequently asked questions

What to verify before installation and use

What does the mobile-bug-log source document cover?

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).

How do I install mobile-bug-log?

The source record exposes this install command: npx skills add https://github.com/hushh-labs/hushh-research --skill ".claude/skills/mobile-bug-log". Inspect the command and pinned source before running it.

Which permission-related actions were detected?

Static rules flagged read-files in the source; the page lists the matching lines and excerpts.

Alternatives

Compare before choosing

Computed 10045,511

coreyhaines31/marketingskills

ab-testing

When the user wants to plan, design, or implement an A/B test or experiment, or build a growth experimentation program. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," "hypothesis," "should I test this," "which version is better," "test two versions," "statistical significance," "how long should I run this test," "growth experiments," "experiment velocity," "experiment backlog," "ICE score," "experimentation program

Computed 100147

oaustegard/claude-skills

featuring

Generate hierarchical _FEATURES.md files that describe what a codebase DOES from a user/consumer perspective, anchored to source symbols via tree-sitting. Supports large complex codebases through feature-driven decomposition into sub-feature files. Uses a multi-pass synthesis: orientation → detail → overview rewrite. Use when someone says "what does this do", "document features", "feature inventory", "_FEATURES.md", or needs to understand a codebase's purpose before modifying it. Complements tre

Computed 1008

narrative-io/narrative-skills-marketplace

design-analysis

Translate a fuzzy analytical question into a rigorous investigation plan. Interrogates the ask, grounds the plan in the available data dictionary, applies analytical best practices, and produces a structured brief of query specifications for a downstream query-writing skill. Plans, does not write SQL. Use when: "why did X drop", "is there a relationship between A and B", "who are our highest-value customers", "what's driving the change in Y", "investigate this trend", "design an analysis for", "

Computed 1007

event4u-app/agent-config

existing-ui-audit

Use BEFORE writing or editing any non-trivial UI — inventories components, design tokens, shadcn primitives, and reusable patterns into state.ui_audit. Hard gate for the ui directive set.