Running reference log of every iOS/mobile bug diagnosed + fixed on the `mobile` branch (symptom → root cause → fix → files/commit), plus the recurring build/runtime gotchas that keep biting. Use when the user asks "what bugs did we fix", "known issues", "list the resolved bugs", "why did X break", when a similar symptom reappears (check if it's a known regression before re-diagnosing), or before shipping a mobile build (re-verify the gotchas). ALWAYS append a new entry here whenever another mobile bug is resolved.
Install
npx skillscat add hushh-labs/hushh-research/mobile-bug-log Install via the SkillsCat registry.
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.localhasNEXT_PUBLIC_BACKEND_URL=http://localhost:8000(local dev backend).capacitor.config.tsbakes that into every plugin'sbackendUrl, and the web layer fetches${NEXT_PUBLIC_BACKEND_URL}/db/vault/check. On the simulator that resolves to127.0.0.1:8000, which is Connection refused (NSURLError -1004) unless a local backend is running there. Confirmed via the sim log showinghttp://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"beforenpm 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 showconsent-protocol, never127.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 installfails withUnable to lookup in current state: Shutdown, the sim isn't booted.xcrun simctl boot <UDID>; open -a Simulator; sleep 6then 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=8192forcap:build(OOM);GoogleService-Info.plistrequired; 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 evennext dev, or a plaincp/mv) sits atCreating an optimized production build ...for 20+ min with 0% CPU and.nextfrozen. 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 thewebpackBuildWorkerflag (toggling it doesn't help), not offloaded files. Confirmed: build/dev/cp/mvALL crawl; acp -Rcof the repo did 143 MB in 13 min (~9 h projected). - Fix: get the repo OUT of the iCloud domain.
mvout 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-ddif xcodebuild errorsfile '…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/setupdid nothing (bounced straight back). - Root cause: back button primes "setup resolved" +
router.push('/one'), butOneOnboardingGuard(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/keyboardis NOT installed and there's no keyboard-resize handling. iOS WKWebView doesn't shrink100dvh; it scrolls the wholefixed inset-0overlay up to reveal the focused input → the fixed header drifts under the status bar. - Fix:
visualViewportkeyboard-pin incomponents/agent/agent-popover-provider.tsx— track keyboard height (window.innerHeight - visualViewport.height - offsetTop), shrink the mobile sheet toh-[calc(100dvh-var(--agent-kb-height))](top-pinned,bottom-auto), andwindow.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; bluetext-primary→ luxury gold#9C7434/#D4AF6A, SF Pro type, iOS rounded cards. AllisPopover/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.htmlARE 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.tsxhandlePrimary(commitd83ed1890) resolved the account-wide master gatePreVaultUserStateService.syncKaiSetupState({ completed: true })whenever Continue forwarded into a hard-gated/one/*surface (gmail, email→/one/kyc, location, pkm, connected-systems) — done only to stopOneOnboardingGuardbouncing. ButsetupCompleted === truealso makesresolveFinance(lib/services/capability-setup-state-service.ts) report finance = completed pre-vault, so entering ONE capability flipped enough tiles non-actionable thathasSetupRemaining = 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":one-onboarding-capability-client.tsx— stop the master-gate write on capability entry; forward hard-gated surfaces with?from=setupinstead (removed theKaiProfileService.setOnboardingCompletedcall from this path too).lib/navigation/routes.ts— addedisCapabilityHandoffTarget()(the gated/one/*handoff set: gmail/kyc/location/pkm/connected-systems; excludes finance→/one/setup/kaiand consent→/consents).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). Preservesd83ed1890'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 bannerHTTP 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) raises404 "No … data found for user"for a data-less user as a NORMAL empty condition. The web branches ofgetMetadata/getEncryptedData/getDomainDatainlib/services/personal-knowledge-model-service.tsalready mapresponse.status === 404→emptyMetadata/null. But the native branches call the Capacitor plugin (HushhPersonalKnowledgeModel.*) directly; the iOS Swift (ios/App/App/Plugins/PersonalKnowledgeModelPlugin.swiftexecuteRequest) + Android Kotlin pluginsreject("HTTP Error <status>: <body>")for ALL non-2xx, so the 404 throws andpkm-natural-panel.tsxpaintsbootstrapError. (getDomainManifestis NOT affected — it usesApiService.apiFetchwith its own 404→null; the bug is exactly 3 native call sites, not 4.getAvailableScopesis out of scope: no UI consumer + web also throws on 404, i.e. already symmetric.) - Fix (commit
bfc8bae3d), single filelib/services/personal-knowledge-model-service.ts: addedprivate 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-dataresult = this.emptyMetadata(userId)(flows through the normal MEDIUM-ttl cache, mirroring web);getEncryptedData/getDomainData→ on no-datareturn 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-heightapproach 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 globalresize:"native". The header-logo half (Bot →/one-quiet-emoji.png) is ALSO SUPERSEDED: that PNG was Noto (Android) artwork baked into a raster, so the mark stayed Android on macOS regardless of the font stack. The header now renders the glyph as text through.hushh-brand-mark, and the asset has been deleted. - 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/keyboardwas NOT installed, andcapacitor.config.ts ios.scrollEnabled: trueleft the nativeUIScrollViewfree to auto-scroll the whole webview up to reveal the focused input. Because the chat sheet isposition: fixed, that native scroll dragged the entire overlay (header under the status bar, composer under the keyboard). The JSwindow.scrollTo(0,0)in the visualViewport hack resets the DOM scroll, notUIScrollView.contentOffset, so it could never win.100dvhnever shrinks (contentInset "never", nointeractive-widget— which iOS/WKWebView does NOT support anyway). - Fix (robust, native-layer — commit
01050387a):npm i @capacitor/keyboard@^8.0.5+cap sync ios(adds CapacitorKeyboard SPM package).capacitor.config.ts:ios.scrollEnabled: false(kills the drift at root); addKeyboard: { resize: "none", style: "LIGHT", resizeOnFullScreen: false }(resize:none keeps 100dvh full so our own sheet-shrink owns avoidance —nativewould double-shrink + make fixed-bottom UI jump). Type-only importKeyboardResize/KeyboardStyle+ascasts (string values need the enum types; type-only = no runtime import).ios/App/App/MyViewController.swift:webView.scrollView.isScrollEnabled = false(belt-and-suspenders).components/agent/agent-popover-provider.tsx: keyboard effect now uses the plugin's authoritativekeyboardWillShow.keyboardHeightonisNativePlatform()to set--agent-kb-height(visualViewport kept as web fallback via dynamic-import gate); toggleshtml.agent-kb-open. The--agent-kb-heightvar + sheet calcmax-sm:h-[calc(100dvh-var(--agent-kb-height))]are unchanged.app/globals.css:html.agent-kb-open .agent-chat-workspacedrops the composer home-indicator padding to 0.5rem while typing (keyboard covers that zone).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.Botimport kept (still used for message avatars ~L879).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,bodyhave nooverflow-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 +
KeyboardPluginregistered 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/keyboardWillShowrather 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].nativeshrinks the whole WKWebView frame every keyboard-animation frame, recomputing everydvh/svh~60×/sec → severe vault jank; it was reverted (commits26453505b→78cf3a94f) back tonone, which silently re-broke avoidance app-wide. B21 is the current standard: keepresize:"none"(no frame resize → no jank) AND add a global event-driven--kb-heightavoidance layer. Do NOT flipresizetonativeorbodyagain. - 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 →100dvhstays full-screen and bottom inputs sit behind the keyboard. The ONLY avoidance code was the chat popover's--agent-kb-heightsubtraction (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):capacitor.config.ts:Keyboard.resize"none"→"native"(the plugin default). WKWebView frame now shrinks by the keyboard height →100dvh/svh+position:fixedbottom elements sit above the keyboard on EVERY screen, no per-screen JS. Keptios.scrollEnabled:false,contentInset:"never",style:"LIGHT".cap sync iosregeneratesios/App/App/capacitor.config.json(the runtime-read file) toresize:"native"— commit both.components/agent/agent-popover-provider.tsx: DELETED the whole custom keyboard machinery (thekeyboardInsetstate +keyboardWillShow/Hide+ visualViewport effect +html.agent-kb-opentoggle +--agent-kb-heightin panelStyle + the now-unusedisNativePlatformimport). Sheet heightmax-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).app/globals.css: deleted the deadhtml.agent-kb-open .agent-chat-workspaceblock. Kept the base--agent-chat-composer-bottomvars (that resting padding is the correct gap).app/register-phone/page.tsx: the OTP white sheet is normal-flow in amin-h-[100dvh]column whose page root isoverflow-hidden. Addedmax-h-[calc(100dvh-4rem)] overflow-y-autoas 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— that recomputes every dvh 60×/sec and janks the vault. The correct standard for THIS app isKeyboard.resize:"native"resize:"none"+ a global event-driven--kb-heightlayer (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 concurnative). 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
/onedashboard, 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
backHreffromresolveTopShellBreadcrumb()(lib/navigation/top-shell-breadcrumbs.ts), NOTrouter.back(). ForONE_KYC/ONE_LOCATION/ONE_MARKETPLACEit hardcodedbackHref: ROUTES.PROFILE(these surfaces were historically reached from Profile panels) and never read?from. The dashboard (one-dashboard-page.tsx) navigated with the barecap.href(no origin).CONSENTSwas origin-aware but also fell to a profile panel with no marker. Gmail/PKM/Connected already resolved toONE_HOME, so they weren't broken. - Fix (commit
9b5706196) — origin-aware?from, mirroring the existing Gmail pattern:- 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. top-shell-breadcrumbs.ts—ONE_KYC/ONE_LOCATION/ONE_MARKETPLACEnowbackHref: normalizeInternalRouteHref(searchParams?.get("from")) || ROUTES.PROFILE, and the leading crumb is "One" (from dashboard) vs "Profile" (fallback). CONSENTS needed no change (already readsfrom).
- Dashboard tiles tag each href:
- Why not a blanket
backHref → ONE_HOMEflip: 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 uncommittednormalizeBreadcrumbPathname/KAI_IMPORT changes in these files are compatible (query stripped before route match;fromread 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.tsxunmountBargated 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 exposestier(AgentAccessTier), NOTsignedIn— usetier === "anon_onboarding"(the anon-on-/tier). - B — CTA "Log in" → "Get Started" on the welcome (
components/onboarding/IntroStep.tsxL165).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) didrouter.push(backHref)— a same-pathname, query-only nav. The profile page closes its panels ONLY viarouter.replace(href, { scroll: false })(profile/page.tsxupdateProfileView"replace" /popProfileStack), so a plain push is a no-op on device. Fix: in the back handler, fornormalizedPathname === ROUTES.PROFILE && (panel||detail)→router.replace(backHref, { scroll:false })(mirrors popProfileStack); elserouter.push(backHref, { scroll:false })./consents(cross-pathname) already worked. - KEY: profile panels are query-state (
?panel/?detail) driven byuseSearchParams→ the profile page's own close usesrouter.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 newrouter.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 readsfromvianormalizeInternalRouteHref, 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.tsxparams.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: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-unusedforwardsToGatedSurface+isOneSetupSurfaceRouteimport. Finance keeps its encoded per-capabilityfrom.kai-onboarding-guard.tsx:setupOriginatedCapabilityEntry→normalizeInternalRouteHref(params.get("from")) === ROUTES.ONE_SETUP && isCapabilityHandoffTarget(pathname)(+ import). Keeps the finish-setup redirect-loop bypass intact.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:
frommarkers MUST be valid internal paths (leading/).normalizeInternalRouteHrefsilently 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 (
9b570619607-06 02:51,909ea793d07-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,/consentsvault redirect. Access & Sharing'srouter.replacefix (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?fromtagging (cccf6aab7):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).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 takefromvia 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}).buildConsentCenterHrefalready supports{from}.
- Deferred (documented, conscious):
consent-sheet-controller.tsx— its open/re-syncuseEffectrewritesfrom(delicate; its owncloseConsentSheetalready 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.tsxforced light via next-themes onCapacitor.getPlatform()==="ios"ANDios/App/App/Info.plistsetUIUserInterfaceStyle=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" enableSystemon all platforms.StatusBarManageralready syncs native SystemBars fromresolvedTheme. Contract test updated:__tests__/components/providers-theme-contract.test.tsnow asserts NO forced theme anywhere + noUIUserInterfaceStylein 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 (
pointercancelswallowed) beforemd-rippleseespointerup. Nothing set-webkit-touch-callout:none/user-select:noneon 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 pinnedpointer-events:noneonmd-ripple.morphy-md-rippleitself (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 holdspointer-events-nonewhile hidden. - Fix:
snapKaiBottomChromeVisible()inlib/navigation/kai-bottom-chrome-visibility.ts—onPointerDownCaptureon the nav group freezes the chrome at its resting position whenprogress > 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 resolvesaspect-ratioagainst 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, DBhushh-one-location-keys, keyed by Firebaseuser.uid). Decrypt throws whenever the on-device key is absent or itskeyId != envelope.recipientKeyId. iOS runs underiosScheme: "App"where WKWebView IndexedDB is evicted / not persisted across launches → the key is lost →ensureLocationRecipientKeymints a NEW keyId. The backend freezesrecipient_key_idon the grant and hard-enforcesenvelope.recipientKeyId == grant.recipient_key_id(consent-protocol/.../one_location_agent_service.pycreate_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 silentsetIntervalpoll only render whendecryptedPoints[grant.id]is set (successful decrypt), so the failure looks like "View does nothing." (userId mismatch ruled out — bootstrap + decrypt both useuser.uid.) - Fix (two parts):
- 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 viaHushhKeychain(@/lib/capacitor), gated byisNative(), keyone_location_recipient_key:${userId},accessible: afterFirstUnlock, non-biometric (no Face ID on the silent poll).readStoredKeynow: IndexedDB → if empty & native, restore from Keychain with the SAME keyId and repopulate IndexedDB. IndexedDB storesprivateKeyJwk(portable) with legacyCryptoKeyfallback + migration. ExportedRECIPIENT_KEY_UNAVAILABLE_MESSAGE. Precedent:lib/services/vault-bootstrap-service.ts. - Self-heal on mismatch —
app/one/location/page.tsx: added per-grantgrantViewErrorsstate; onRECIPIENT_KEY_UNAVAILABLE_MESSAGEinviewGrantEnvelope(manual + silent poll) it re-registers the current key (bootstrapCurrentUserLocationRecipientKey) and shows an inline "the secure key changed — ask them to share again" notice + Ask to share again button (handleAskReshare→OneLocationService.requestAccessto the grant owner). Same treatment in the redesign chatview_envelopehandler (components/one-location/redesign/use-location-chat.ts). Once the owner re-shares,create_grantsnapshots the now-durable key and updates resume.
- Durable key (stops the rotation) —
- Files:
lib/one-location/encryption.ts,app/one/location/page.tsx,components/one-location/redesign/use-location-chat.ts; new testlib/one-location/__tests__/encryption.test.ts(+fake-indexeddbdevDep). Branchfix/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 innodevitest 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
activerecipient 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'svaultKey(AES-256-GCM, identical on every device after unlock) and stored server-side as an opaqueencrypted_private_key_jwkblob; every device fetches its own blob (vialist_state.myRecipientKey, owner-only) and decrypts → same keyId everywhere, no rotation. Reuses the PKM/vault pattern (lib/vault/encrypt.ts+HushhVault.encryptData). Backend: migration083,register_recipient_key(encrypted_private_key_jwk=…)(COALESCE-preserving),list_stateself-key read (never leaked inrecipients). Client:ensureVaultSyncedRecipientKeyinencryption.ts(remote-blob-wins → local-backfill → generate),key-bootstrap.tsfetch+register,vaultKeythreaded 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 ifmyRecipientKeyis absent. Tests: encryption.test.ts cross-device cases (7) +test_one_location_routes.pyowner-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 viashowPicker()/.focus()/.click(). iOS WKWebView only opens the native date wheel from a DIRECT user tap on the date input itself — programmaticshowPicker()/click()on a hidden input are ignored (showPickeralso 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 decorativepointer-events-nonedisplay 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 programmatichandleOpenDatePickerpath 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 aftersimctl uninstalland a clean webCLEAN=1build), 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.
xcodebuildthen finds no simulator destination and fails witherror: 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 asxcodebuild … | tail— a pipeline's exit status istail's (0), so the failing build looked successful.simctl installthen re-installed the previous/staleApp.appevery time. - How to confirm:
xcodebuild -project ios/App/App.xcodeproj -scheme App -showdestinationslists NOplatform:iOS Simulatordevice 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/...vsios/App/App/public/...— if the App.app has a different/olderpage-*.jschunk than the syncedpublic/, the native build never bundled your changes. - Fix:
xcodebuild -downloadPlatform iOS(installs the current iOS simulator platform, ~8 GB). Afterwards-showdestinationsshows 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 totail(or check${PIPESTATUS[0]}) and verify** BUILD SUCCEEDED **. - GOTCHA 1: never invoke
xcodebuild … | tail/| grepand trust the exit code — the pipe hides build failures. Redirect to a log (&> /tmp/xcbuild.log; echo $?) and grep forBUILD SUCCEEDED. - GOTCHA 2: after any Xcode major upgrade, the simulator platform must be re-downloaded (
xcodebuild -downloadPlatform iOS) beforerun-ios-sim/launch.shcan build for the sim.
B21 — Keyboard hides inputs AGAIN (vault unlock, OTP, chat, sheets) — breaks the native↔none flip-flop for good
- Symptom: on TestFlight, focusing any bottom-anchored input (the "Enter vault key" field on the Unlock Your Vault gate, OTP, One chat composer, bottom sheets) left it hidden BEHIND the on-screen keyboard — user couldn't see what they typed. QA re-reported the same class B8/B9 supposedly fixed.
- Root cause — a documented flip-flop, not a new bug. B9 (
515347b8a) setKeyboard.resize:"native"(frame shrinks → avoidance works). That janked the vault (native recomputes everydvh/svh~60×/sec during the keyboard animation), so it was reverted:26453505b(→body) then78cf3a94f(→none).resize:"none"leaves the WKWebView frame full-height AND there was no JS/CSS fallback (B9 had deleted the--agent-kb-heightmachinery), 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.jsonstill read"native"(stale;cap synchadn't run after the flip). - Fix (the loop-breaker — keep
none, add event-driven avoidance):- NEW
components/keyboard-inset-manager.tsx— mount-once native/mobile bridge (mirrorsstatus-bar-manager.tsx, returnsnull, mounted inapp/providers.tsxouterProvidersnext to<StatusBarManager/>). Native: dynamic-import@capacitor/keyboard,keyboardWillShow/Hideset--kb-heighton<html>+ toggle.kb-open— once per transition, NOT per frame → zero dvh thrash / zero jank. Also a delegatedfocusin(capture) net thatscrollIntoView({block:"center"})the focused field (only when.kb-open). Mobile-web fallback via thresholdedvisualViewport(>120px). Desktop/laptop = firewall: binds nothing,--kb-heightstays 0px → every consumer is a no-op. app/globals.css:--kb-height: 0pxin:root; agent composer vars (--agent-chat-composer-bottom/-focused-bottom, base +html.native-ios) fold in--kb-heightviamax()(no home-indicator double-count) so the composer lifts.- Fixed/bottom-anchored primitives consume
--kb-height:components/ui/drawer.tsxbottomDrawerContent→bottom:var(--kb-height)+max-h:calc(80vh - var(--kb-height))(lifting the whole sheet is the ONLY reliable avoidance underresize:none— the viewport never shrinks, soscrollIntoViewalone can't clear the keyboard for a bottom-anchored sheet);components/ui/sheet.tsxbottom variant likewise;components/ui/dialog.tsxcentered →max-h -= --kb-heightand shift up by--kb-height/2. components/vault/vault-unlock-dialog.tsx: vault sheetmax-has adata-[vaul-drawer-direction=bottom]:variant so it deterministically overrides the base drawer max-h and shrinks by--kb-height; the lift comes from the baseDrawerContent. KeptrepositionInputs={false}(JS owns avoidance; vaul reposition would re-introduce the keyboard-less sim gap).app/register-phone/page.tsx: OTP regionmaxHeightsubtracts--kb-height(+ focusin net centers the field).capacitor.config.ts: keptresize:"none"(+ fixed the stale comment that still describedbody/native); keptios.scrollEnabled:false+MyViewController.swift isScrollEnabled=false.npx cap sync iosre-run soios/App/App/capacitor.config.jsonregeneratesnative→none— commit both.
- NEW
- Why NOT translate/offset the vaul
DrawerContentvia transform: vaul animates open/close withtransform: translate3d; atransformoffset fights it. Usebottom/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-heightvar that fixed/bottom surfaces lift by. Never flipresizetonative/body(jank), never scatter per-componentvisualViewporthacks (B8), and never shipnonewithout the--kb-heightlayer (B9-revert). Every consumer must be a no-op at--kb-height:0so 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(+ syncedios/App/App/capacitor.config.json). Branchfix/ios-keyboard-avoidance. - Verified: typecheck (0 errors) + lint (0) + design-system pass; compiled CSS confirmed to contain all
--kb-heightrules (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 localnext build --webpackhangs when the repo lives in an iCloud-synced~/Documentsfolder (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.
readOncein lib/capacitor/plugins/location-web.ts trusted the W3Ctimeoutoption. 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.captureCurrentPosition→HushhLocation.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'sfinallywas correct all along — it simply could never run. The sampling stage also rejected with a bare{ code: 3 }object literal, which every downstreaminstanceof Errorcheck (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 wholefor … awaitin onetry. The first recipient that threw aborted the entire tick — and becauseactiveOwnerGrantsis stably ordered, the same recipient threw on every tick, starving everyone after them permanently. In the movement watcher it was worse:lastPublishedPointRef.current = pointsat 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):location-web.ts— oneoverallDeadlineAtshared by every stage (timeoutMs + 8s), aLAST_RESORT_RESERVE_MS = 4sso the cached-fix recovery still gets a window, a realsetTimeoutdeadline insidereadOnce, andtimeoutError()returning anError(nameLocationTimeoutError,code: 3) instead of an object literal.lib/one-location/service.ts—captureCurrentPosition({ 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 viapendingCapture. PlusinvalidateCapturedPosition()and a test reset seam.page.tsx— both live loops becamePromise.allwith a per-grant try/catch; a grant with no usable recipient key now logs instead of skipping silently. Public-link + contact-invite routed throughensureForegroundLocationReady(they were the only share entry points with no permission pre-check).ensureForegroundLocationReadynow returns its point unconditionally.- 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). lib/services/api-service.ts—fetchWithWebTimeout, a 60s ceiling on the browserfetch(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 withAbortSignal.any, which is not in every WKWebView this app runs in.
- ⚠️ Deliberately NOT changed:
recipientForGrant's strictkeyIdmatch. 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-systemclean. 39 new tests acrosslocation-web-get-current-position.test.ts,capture-current-position.test.ts,api-web-fetch-timeout.test.ts, and a live-loop case inone-location-agent-page.test.tsx. Full suite 29 failed / 3547 passed vs baselineorigin/main29 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 gaveexpected [ '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. AnygetCurrentPosition/watchPositionwrapper needs its ownsetTimeoutdeadline or it can hang forever. - GOTCHA 2 — a
for … awaitloop over recipients needs the try/catch INSIDE it. With stable ordering, one bad item starves every item after it on every tick, deterministically. PreferPromise.all+ per-item catch. - GOTCHA 3 — reject with
Errorinstances, never{ code: n }literals. Several layers here branch oninstanceof 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/mainfor each redesign commit → must be false. The website deploys frommainonly (manualworkflow_dispatch); themobilebranch (pushed asankit/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.tsbuilds "Needs you" from independent lanes, and a pending connection request arrives on two of them. The connections lane readsConnectionsService.listRequests({direction:"incoming"})directly; the consent lane reads the Consent Center'spendingsurface — which folds those same requests in via_incoming_connection_request_entries(consent-protocol/hushh_mcp/services/consent_center_service.py:1242), calling that very sameConnectionsService.list_requests(direction="incoming")and settingid/request_idto the connection request's own id. Same row, fetched twice. The lanes namespace their keys differently (consent:<id>vsconnection:<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-unreachableconnection_requestbranch inconsentSummary. - Files:
hushh-webapp/lib/feed/use-feed-actionables.ts; regression testhushh-webapp/__tests__/lib/feed/feed-actionables-connection-dedup.test.tsx. Branchfix/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 2andexpected 'consent:conn-req-1' to be 'connection:conn-req-1'— reproducing the screenshot's stacking order.tsc --noEmit0 errors,eslint --max-warnings=0clean. - LESSON: when one item shows twice in "Needs you", suspect two lanes over one source before suspecting push delivery. The Consent Center's
pendingsurface 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 runsmax-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
mapReadyis true, and the full-bleed fallback only renders whenstatus === "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-mapsaddresses 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) — anddestroy()resolves tomaps.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 foundmapRef.currentstill null and destroyed nothing, the abandoned create registered its map anyway, and itscancelledbranch then destroyedMAP_ID— which by then belonged to the map the NEXT mount had created. Reachable from ordinary navigation: both map routes are keyedkey={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, sameMAP_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);
closeMapgoes 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 inhushh-webapp/__tests__/components/location-immersive-map.test.tsx("native map lifecycle"). Branchfix/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)returnsfalsewhile the screen reportsdata-map-ready="true", and the event order comes backcreate:1, create:2, destroyed:1instead ofcreate:1, destroyed:1, create:2. Wider one-location surface: 64 failed/131 passed vs 64 failed/129 passed onorigin/main, failing sets identical by name.tsc --noEmitclean,eslintclean,verify:design-systemOK. - 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-mapsaddresses a native map by string id with no instance identity, andcreate()cannot be cancelled. An unmount inside the create window left the cleanup withcreatedMapstill 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 isundefinedon first render and resolves after hydration — well inside the plugin's ~200 ms create window. The container is alsokey={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; testhushh-webapp/components/one-location/onboarding/__tests__/location-picker-map-native.test.tsx. Branchfix/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.tscandeslintclean. - 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()totruealso driveswaitForLaidOutBox, 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_STYLESfrommaps-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 callingcreate, but the mock never records the call.
- Mocking
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):
- 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.ymlpassed_LOCATION_MAP_DEMO=true. The CI guard inscripts/ci/runtime-contract-check.shonly ever checkeddeploy-production.yml, so UAT was never covered. UAT is not a private sandbox — it is the frontend every reviewer, tester and demo audience sees. - Sharing rows: inert
<li>text. Looks like a list you can act on, answers nothing. - 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. - 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. - Close X under check-in: the check-in sheet was
<Sheet modal ...>, so Radix paintedfixed inset-0 z-[711] touch-noneacross the whole screen. The map header isz-30, so the X, Locate and Check-in stayed perfectly visible through a 22%-black blur and swallowed every tap.
- Demo: the client gate (
- Fix: UAT lane →
false, and the CI guard now bans_LOCATION_MAP_DEMO=truein 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]), sosm+ is unchanged. Everyone → never disabled, states when nobody shares yet, still moves the camera. Check-in sheet →modal={false}+ newshowOverlay={false}opt-out onSheetContent, 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 withafter:-inset-1.5extending 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). Branchfix/location-map-ios-hardening, PR #5305. - Verified: typecheck + lint +
verify:design-systemclean. Full suite 27 failed / 3969 passed vs a 28 / 3954 baseline on cleanorigin/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
modalsheet is a full-screen touch blocker over the HOST screen's chrome, whatever the host's z-index.z-[711] touch-nonebeatsz-30and every other app layer. Any sheet anchored to a live surface (a map, a canvas, a video) must bemodal={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 fromsmup 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 detailsflow 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:
- 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. - The Address box could not be cleared.
addressLineEditedRef.current = truefollowed bysetAddressLineValue("")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. - The map could collapse to a hairline.
h-[min(56vh,420px)]on a flex item whose only child fills it withh-full— min-content height 0 — so a viewport short enough to overflow the pane resolved the overflow by deleting the map. - The keyboard cap double-counted. The sheet is pinned above the keyboard by
bottom-[var(--kb-height)]; capping it at92dvh - kbsubtracts 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, whereoverflow-y-hiddenclipped it silently.
- The pad: the surface was a Radix Dialog wearing a sheet's corners, carrying an unconditional
- Fix: below 640px it renders the canonical
components/ui/sheet.tsxat 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.addressLineValuebecame one nullable state.shrink-0on 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. Branchfix/save-location-native-sheet, PR #5626, issue #5619. - GOTCHA 1 — a
max-wnarrower 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 alreadyxdoes not re-render, so a ref flipped in the same handler never takes effect. TheuseRef(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 withh-fullhas a min-content height of 0, so the first short viewport deletes it.shrink-0. - GOTCHA 4 — do not subtract
--kb-heighttwice. A surface pinned withbottom-[var(--kb-height)]is already above the keyboard; its height budget is100dvh - kb, so cap it as a fraction OF THAT, never asNdvh - kb.
B27 — "A large white strip above the map" on Your Map was the CAMERA, not the layout
- Symptom: a screenshot of
/one/location/mapwith 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 isabsolute 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.tsasserts renderertop <= 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^zoomCSS 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 most453.9 * 2 ≈ 908 pxof 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.tsxdocuments that the@capacitor/google-mapsweb shim implementssetPaddingasfitBounds(bounds, padding)— a zoom-OUT, which snapped z2 → z1 and produced bands at both edges. That was fixed by not callingsetPaddingon 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.tsderives the neutral view from the box the renderer was actually handed (element.getBoundingClientRect()afterwaitForLaidOutBox): 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 tofocusSelfPoint. - Files:
lib/one-location/map-world-view.ts,components/one-location/location-immersive-map.tsx. Tests:__tests__/one-location/map-world-view.test.ts. Branchfix/your-map-full-viewport-avatar-marker, PR #5628, issue #5622. - Regression prevention: the guard is a sweep, not a device list —
outOfWorldBandPx()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-mapsexposes onlytintColorper marker, and itsiconUrlon iOS accepts anhttps:URL or a bundledpublic/asset (ios/Sources/CapacitorGoogleMapsPlugin/Map.swift:693) — not adata:URI, and.svgis 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 nowbutton 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.tsxbuilt the overlay withfillColor: "var(--app-accent-surface)"andstrokeColor: "var(--app-accent)"and handed it to@capacitor/google-maps. Neither renderer resolves CSS custom properties. The web shim passes the object verbatim intonew 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.swiftdoesUIColor(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/strokeWeightDID apply, which is why the ring looked deliberate. Fixed by resolving--app-accentoffdocument.documentElementto a real hex at draw time (regex-guarded, with a#007afffallback, so a token that resolves to anothervar()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.tsxrenders the grabber as a full-widthsticky top-0 z-[5] flex h-11 touch-noneband occupying y=0..44. The close button is a 32px circle attop-4whose hit region is widened to the platform's 44px minimum byafter:-inset-1.5, so it starts at y=10 — three quarters of its target lies under a higher-z,touch-nonesibling that swallows the tap. Present on every bottom sheet that shows both, not just this one. Fixed withz-10onSheetPrimitive.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
0badge 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 nowwasvariant="destructive", solid--app-destructivewith white on it. Checking out flipsstatus, destroys the anchor key, keeps the row and can be redone in three taps — a reversible lifecycle step. Nowsecondary. - 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), newcheck-in-panel-layout.ts(class strings shared with the browser spec so it cannot measure a replica). Branchfix/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 ownscrollTopis pinned at 0 and every downward swipe over the place list would engage the dismissal. I had added adragDismiss: boolean | "handle"union for this; PR #5626 had already landedcontentDragDismissfor 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 thesetPaddingdivergence already documented in B27 andcapacitor-web-shim-divergence. Resolve tokens withgetComputedStyle(document.documentElement).getPropertyValue(...)and guard the result. - GOTCHA 2 — a
z-[5] touch-noneband 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"andcontentDragDismiss: booleanare the same feature; main's is better (a separate boolean beats an overloaded prop, and it shipped withuseSheetDragHandle()). 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/20in its base class, soclassName.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.
B29 — Circle/multi-recipient “Change time” changed state where no dialog existed, then stale state hid the server result
- Symptom: in Location → Manage sharing, a per-recipient Change time tap could do nothing for Circle or multi-recipient shares. When an edit reached the API, an extension could fail at the database ceiling and leave the recipient's HCT at its old expiry; the client also threw away the authoritative returned grant and could repaint older data from an in-flight refresh. The only visible editor was incorrectly owned by the Location → Now subtree.
- Root causes: the PostgreSQL grant invariant required
expires_at <= ceiling_expires_at, but the owner-only duration mutation changed onlyexpires_at; the HCT therefore retained the former finite lifetime. In the shared React UI, Manage sharing's early-return surface set editor state but could not render the dialog mounted belowNowHub; the repository's Dialog primitive is non-modal unless opted in. Finally, the mutation awaited whole-state refresh instead of merging its exact PATCH response, leaving a stale-response race. - Fix: the backend now updates the same grant atomically: extensions advance the ceiling, shortenings preserve the later ceiling, until-stopped clears the ceiling/token, and timed edits mint a matching HCT. One feature-root-owned, explicitly modal dialog now serves both Now and Manage sharing, keeps the exact grant id (including Circle provenance), restores focus to the invoking row, and uses the shared safe-area/keyboard-aware dialog primitive. UI and governed voice callers share one reconciliation path: it merges the returned grant immediately without erasing list-state identity projections, invalidates/fences older refreshes, and then performs a post-mutation read.
- Files:
consent-protocol/hushh_mcp/services/one_location_agent_service.py,consent-protocol/tests/services/test_one_location_agent_service.py,consent-protocol/tests/test_one_location_routes.py,docs/reference/architecture/api-contracts.md,hushh-webapp/app/one/location/page.tsx,hushh-webapp/lib/one-location/one-location-state-resource.ts,hushh-webapp/components/one-location/redesign/location-redesign-hub.tsx,hushh-webapp/components/one-location/redesign/share-lanes.tsx,hushh-webapp/__tests__/components/one-location-agent-page.test.tsx,hushh-webapp/__tests__/lib/one-location-state-resource.test.ts. - Regression prevention: focused multi-share modal/focus restoration and exact Circle-grant cache-merge regressions pass; TypeScript plus static Capacitor, surface-map, and plugin-contract gates pass. Responsive duration/share fixtures pass in Chromium and WebKit. Native implementation is shared WebView/React/service code (native HTTP diverges only below the service); Android unit execution is still blocked in this clean worktree because generated Capacitor-Cordova Gradle inputs are absent and the documented
cap:buildwrapper cannot spawnnpxon Windows. iOS build/simulator verification remains macOS-only. - GOTCHA: a state setter proves neither that a surface is mounted nor that a mutation is visible. For controls rendered in route/query early returns, hoist the owning portal above every return; merge the mutation response by exact id; then fence and reconcile background reads. On this codebase, also opt into
modalexplicitly—components/ui/dialog.tsxdeliberately defaults tofalse.
B30 — Theme menu and the Molten Gold picker "disappeared" from the profile panel
- Symptom: in Profile → Preferences the theme menu and the Accent picker did nothing on tap; the founder read it as the theme mode having been removed. The controls were mounted and worked; their lists opened behind the panel.
- Root cause: the layer ladder was inverted. Dialogs had been lifted to
z-[800]/[801]above sheets (711/712) to fix a modal deadlock, but the transient tier was left at shadcn defaults: DropdownMenu212, Popover500, Select520, Tooltip50. Anything that portals to<body>and opens from inside a sheet needs a z-index above the sheet; nothing decided that in one place. - Fix:
--z-*tokens at the top ofapp/globals.css(chrome < sheet < dialog < takeover < system < transient); the nine floating primitives incomponents/uitake their z-index from them (z-(--z-transient)etc.; tailwind-merge 3.5 dedupes that form, so takeover overrides still win). - Files:
hushh-webapp/app/globals.css,hushh-webapp/components/ui/{sheet,drawer,dialog,alert-dialog,dropdown-menu,popover,select,combobox,tooltip}.tsx,hushh-webapp/__tests__/ui/layer-order.contract.test.ts,hushh-webapp/e2e/profile-pane-layer-order.spec.ts,docs/reference/quality/design-system.md. - Regression prevention: the contract test pins the token order and each primitive's token; the e2e spec opens the Accent picker inside the pane, asserts the list is the element under the pointer, picks Molten Gold and checks
html[data-accent="gold"]resolves--app-accentto#d4a574.verify:render-performancerefuses a literal z-index incomponents/ui. - GOTCHA: a control that "does nothing" on a phone is more often covered than broken. Before touching the control, check what portals above it. Also: the reviewer id in the env files may not match the reviewer the local backend on 8001 mints (
identity_mismatch); resolve it at runtime fromwindow.__HUSHH_NATIVE_TEST__.bootstrapUserId.
B31 — Every scroll in the app waited for JavaScript because two gesture listeners were non-passive on window
- Symptom: dropped frames on flicks everywhere, not just near the edges, with nothing heavy in the scroll path itself.
- Root cause:
app-edge-back-gesture.tsxandapp-profile-edge-gesture.tsxregisteredtouchmove/touchend/pointermove/pointeruponwindowwithpassive: false, on every persistent-chrome route. WebKit treats a non-passive touch listener on the document as a synchronous touch region for the whole page, so the compositor waited for the main thread before every scroll frame, although the back gesture only cared about touches that began within 28px of the edge and only calledpreventDefault()after a horizontal axis lock. - Fix: every listener passive; the two
preventDefault()calls removed.stopPropagationstays (it keepsSwipeViewsfrom seeing an edge sequence) andtouch-pan-yon the scroll root already refuses the horizontal pan. Vertical drift during an axis-locked edge drag now scrolls instead of being cancelled; if that reads wrong on a phone, the fix is a nativeUIScreenEdgePanGestureRecognizerbridge, never a non-passive listener. (Embla registers its own non-passivetouchmoveon everySwipeViewsroot, so pager routes still keep a synchronous region there.) - Files:
hushh-webapp/components/app-ui/app-edge-back-gesture.tsx,hushh-webapp/components/app-ui/app-profile-edge-gesture.tsx,hushh-webapp/__tests__/components/app-edge-back-gesture.test.tsx,hushh-webapp/__tests__/components/profile-pane-navigation.test.tsx,hushh-webapp/__tests__/components/profile-pane-contract.test.ts. - Regression prevention: the contract test forbids
passive: falseandpreventDefaultin both files;verify:render-performancerulenon-passive-touch-on-root. - GOTCHA: WebKit decides a touch sequence's tracking at
touchstartfrom the event regions committed in the last layer-tree transaction. Registering a non-passive listener insidetouchstartdoes not help, and an edge-strip element would have to be the hit target (occluding taps in the lane). Passive is the only shape that keeps scrolling asynchronous.
B32 — Three shell engines wrote CSS variables on <html> every scroll frame, and one of them computed colours nothing read
- Symptom: page-wide style recalculation on every scroll frame and during chat streaming; the ambient tint engine woke on every streamed token.
- Root causes:
top-app-bar.tsx,kai-bottom-chrome-visibility.tsandambient-chrome.tseach wrote custom properties ondocument.documentElementper frame (a document-wide style invalidation each), and the top bar readgetBoundingClientRect()after its write (a forced reflow per scroll event). The ambient engine additionally watched the wholedocument.bodysubtree with a MutationObserver and ran up to fortyelementsFromPointhit-tests with computed-style and rect reads on every DOM change anywhere. Its four--ambient-chrome-*variables were declared as:rootdefaults and read by nothing; the only real output was the status-bar tone attribute. The repo had already fixed this exact class once for the tab pill (top-shell-tab-swipe-progress.ts:61-74) but the fix stayed local. - Fix (so far): the ambient engine writes its diagnostic colour on the mask element, snaps its spring (the colour was never visible), and samples only at scroll-idle, resize, theme flip and a route settle reported by the controller; no body observer, no
<html>writes. The top bar and bottom chrome relocations are the next seams;verify:render-performancecarries their current<html>writes in its allowlist so the count can only fall. A global[class*="backdrop-blur"] { will-change }rule that pinned 170 glass surfaces as compositor layers is gone with it. - Files:
hushh-webapp/lib/morphy-ux/ambient-chrome.ts,hushh-webapp/components/app-ui/ambient-chrome-mask.tsx,hushh-webapp/__tests__/lib/ambient-chrome.test.ts,hushh-webapp/app/globals.css,hushh-webapp/__tests__/morphy-ux/blur-promotion.contract.test.ts,hushh-webapp/scripts/architecture/verify-render-performance.mjs. - Regression prevention: engine tests assert no root custom-property writes and only the
<html>class is observed; the blur contract refuses wildcardwill-change; the ratchet'sroot-custom-property-writeandbody-subtree-mutation-observerrules. - GOTCHA: measure whether an engine's output is consumed before rewriting the engine. Four variables written sixty times a second for a year were read by nothing. And a Chromium-on-Mac benchmark reading "120 fps" is the MacBook's panel, not the phone; the in-app probe (
?perf=1, or-CapacitorStorage.hushh_perf_probe 1on iOS) plusxctraceAnimation Hitches are the instruments. Seedocs/reference/mobile/render-performance-charter.md.
B33 — On a phone, the reviewer card ran as whoever was already signed in, then failed at the vault stage or was gated to /register-phone
- Symptom: the XCUITest gesture card passed on the simulator and failed on the physical iPhone with
bootstrap=uid_mismatch(reviewer uid pinned) orbootstrap=vault_unlocked route=/register-phone(uid unpinned). Same build, same backend. - Root causes: two, stacked. (1)
native-test-bootstrap.tsxtreated any user already present as "authenticated" and never minted the reviewer, so a phone signed in as its owner audited as the owner; the sign-in path's own comment stated the opposite contract but it only held when no user existed at mount. (2) The env file'sREVIEWER_UIDis not the uid the UAT backend mints for the reviewer passphrase; pinning it stalls the bootstrap withidentity_mismatch, and leaving it unpinned empties the bridge'sexpectedUserId, which switches off the native phone-mandate bypass inphone-mandate-service.tsand the reviewer (no verified phone) is sent to/register-phone. - Fix: the bootstrap signs the persisted foreign session out once when the audit names another identity, then runs the ordinary reviewer sign-in (
1e1f0e01a);scripts/perf/ios-perf-card.shasks the target backend which uid it mints (scripts/perf/resolve-reviewer-uid.mjs, decoding the review-mode token's uid claim) and pins that (99b3d9ae9). - Files:
hushh-webapp/components/app-ui/native-test-bootstrap.tsx,hushh-webapp/__tests__/components/native-test-bootstrap-persisted-session.test.tsx,hushh-webapp/scripts/perf/resolve-reviewer-uid.mjs,hushh-webapp/scripts/perf/ios-perf-card.sh. - Regression prevention: the new test mounts the bootstrap with a persisted foreign user and fails on the old code; the card no longer carries a uid to guess.
- GOTCHA: a simulator has no persisted owner session, so this class never shows there. Read the status line:
uidcfg=0means no identity was named, andbootstrap_error_class=identitywithuidcfg=1means the device held someone else. Also: an Xcode update that lands mid-session (26.3 to 27.0 on 2026-09-19) makes everyxcrun,xcodebuildand the/usr/bin/gitshim refuse with "You have not agreed to the Xcode license agreements" untilsudo xcodebuild -license accept;/Applications/Xcode.app/Contents/Developer/usr/bin/gitstill works for commits in the meantime.
B34 — First Xcode 27 build crashed at launch on the phone: UIKit now requires the UIScene lifecycle
- Symptom: the app died 13 s after launch, before any route, on iOS 27.0; the XCUITest reported only "com.hushh.app crashed". Same web bundle had run minutes earlier from an Xcode 26 build.
- Root cause:
EXC_BREAKPOINTin__UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoptionduringworkspace:didCreateScene:. Linking againstiPhoneOS27.0.sdkturns the "no scene lifecycle" runtime issue into a trap. The shell was a storyboard-drivenAppDelegatewith noUIApplicationSceneManifestand no scene delegate. - Fix:
Info.plistscene manifest (single scene,Mainstoryboard,SceneDelegate);SceneDelegate.swiftowns the active/background transitions and URL / Universal Link opens (cold start throughconnectionOptions), with the bodies inAppLifecycleHandlersshared with the AppDelegate methods (privacy shield, voice availability publishers, Google Sign-In URL, Capacitor'sApplicationDelegateProxy). Commit515eca1db. - Files:
hushh-webapp/ios/App/App/SceneDelegate.swift,hushh-webapp/ios/App/App/AppDelegate.swift,hushh-webapp/ios/App/App/Info.plist,hushh-webapp/ios/App/App.xcodeproj/project.pbxproj. - Regression prevention: the app launching at all on an iOS 27 SDK build is the test;
verify:capacitor:staticpasses. Any CI or release runner that moves to Xcode 27 needs this commit first. - GOTCHA: read the crash from the xcresult, not the test log:
xcrun xcresulttool export attachments --path <bundle>.xcresult --output-path <dir>drops the.ips; itsfaultingThreadframes name the UIKit evaluator. Under the scene lifecycle,applicationWillResignActive/applicationDidBecomeActive/application(_:open:)on the AppDelegate are no longer called; anything living there silently stops (the privacy shield would have).
B35 — The native export shipped a stale stylesheet: JavaScript referenced rules the CSS did not contain, for three device runs
- Symptom: the Liquid Glass material and the scroll-chrome consumer rule were in
app/globals.cssand in the dev server, and the JS chunks in the phone bundle carried the class names, but the export's CSS file had the same content hash as before those changes:morphy-liquid,--liquid-glossand[data-top-chrome-collapse-consumer]were absent. Rules from two days earlier were present. On the phone the material was invisible and the top mask lagged the bar collapse by the settle write. - Root cause: the webpack persistent cache at
.next/cache/webpack/client-production(shared by the web build and theNEXT_DIST_DIRnative export) served the old CSS asset for the Tailwind entry afterglobals.csschanged. Running@tailwindcss/postcsson the file directly produced the rules, so the pipeline was right and the cache was not. - Fix:
rm -rf .next/cache/webpackand rebuild (new CSS hash, rules present).scripts/native/verify-native-css-fresh.mjsrendersglobals.cssthrough the same pipeline and checks every selector reached the export;npm run cap:sync:iosruns it first andscripts/perf/ios-perf-card.shruns it before measuring. - Regression prevention: the sync and the card refuse a stale export with the fix printed.
- GOTCHA: a bundle can be "fresh" (new JS hashes, fresh
index.html, the stale-out/guard green) and still carry old CSS. Before trusting a device result for a CSS change, grep the export's CSS for a selector the change introduced.cap:sync:androidstill resolveswebDirwithout the native env (it points atout/, which no longer exists); the Android lane must build withNEXT_DIST_DIRset or sync from.next-native-uat.
B36 — The Finance flick was the frosted rows, not the charts; the chat lane measured nothing because it looked on the wrong tab
- Symptom:
kai-chart-flickread p95 43 ms with 13 frames over 50 ms per 9 windows (run O), and stayed at 70 ms/s hitch after the chart tooltip and animation fixes. The chat stream never produced a gesture window in any run. - Root cause: eight advisor-pick rows in
renaissance-market-list.tsxeach carriedbackdrop-blur-[16px]and moved under the flick; a backdrop filter on moving content re-samples the backdrop every frame per row. The five small charts were cheap. For chat,perfSendChatPromptsearched/onefor the composer, which mounts only on/(the Chat tab), so the step timed out silently. - Fix: rows are solid (
--app-card-surface-default-solidplus the neutral Liquid Glass material; founder decision: blur stays on fixed chrome only); the market pane's rawResponsiveContainers passdebounce={CHART_RESIZE_DEBOUNCE_MS}; the truth lane gained achatsection that launches at/, unlocks by passphrase, sends a fixed prompt and holds a 30 s window. - Regression prevention:
backdrop-filter-on-list-rowinverify-render-performance.mjs(abackdrop-blurclass on a keyed list element; allowlist only shrinks);kai-charts-animation.contract.test.tsrequires the debounce on every raw container. - GOTCHA: the lint rule only sees a keyed JSX opening tag; a blur applied through a wrapper component or a CSS class named elsewhere still needs a human eye. Chat, Kai and Location each need their own launch in the attached lane because the vault gate hides the composer and the charts until the section unlocks.
B37 — The bottom-chrome material could not be a class: one !important rule owns the dock and the nav pill
- Symptom: adding
morphy-liquid-neutralto the voice dock or the nav pill changed nothing on the phone in any theme. - Root cause:
.bottom-chrome-surface, .kai-bottom-nav-pillinglobals.csssetsbackground(shorthand) andbox-shadowwith!important; a utility class cannot win against it, and the shorthand also wipes anybackground-imagea class sets. - Fix: the material is folded into that rule:
backgroundbecamebackground-colorplus the glossbackground-image, the rim and depth insets merged into the existingbox-shadowthrough a--bottom-chrome-dropcustom property so light and dark share one declaration, and a live voice session ([data-voice-phase]notidle) adds the accent halo. The blur stays because this chrome is fixed.navbar-agent-trigger.contract.test.tspins the rule header text. - GOTCHA: the neutral material's
box-shadowalso replaces a control's outer shadow; a control that keeps one passes it through--liquid-neutral-drop(the shell controls keep the glass drop that way) instead of stacking ashadow-*utility that the material then beats.
B38 — The passkey sheet ate two minutes of every cold launch on the phone, and XCTest wrote the typed passphrase into its result bundle
- Symptom: every section of the truth lane unlocked about two minutes after launch; one section timed out at 240 s after typing. The recording shows iOS's "Scan QR Code … sign in to 'Agent One'" sheet over the app from launch for ~120 s; when it left, one
typeTexthad delivered five characters, Unlock said "That passphrase did not match", and the step never retyped. - Root cause: the vault gate starts the passkey flow at launch; with no passkey on this phone the system offers the cross-device QR sheet and every XCUITest tap lands on it until it goes away by itself. Separately,
xcrun xcresulttool export attachmentson the run's bundle produced two "Synthesized Event" records containing the typed passphrase: XCTest logs typed strings intoDerivedData/Logs/Test/*.xcresult. - Fix:
perfUnlockVaultcloses the sheet through Springboard (Close/Cancel), starts its clock when the field is on screen, checks the masked length after typing and retypes after a mismatch (3 attempts).ios-perf-card.shpasses-resultBundlePathunder the run's own directory and deletes it plus any bundle xcodebuild dropped under DerivedData for the run, as soon as the test exits. The bundles from earlier runs were deleted by hand. - GOTCHA: a raw
grepof an.xcresultis not authoritative (attachments are stored compressed); treat any bundle from a lane that types a secret as containing it. Product note for the founder: on a phone without a passkey the gate's automatic passkey attempt costs a real person a two-minute sheet or a tap on X; it should be tap-to-use.
B39 — Chat does not stream on the phone: the reply lands in one paint
- Symptom: the probe's
streamwindow (open whiledata-agent-streaming="true"is in the DOM) never opened in two device runs although both halves shipped;message.status === "streaming"was never true on a poll frame. The chat rows read worst 84 to 86 ms with 1 to 2 frames over 50 ms in two 1 s auto-scroll windows. - Root cause: every native API call goes through
CapacitorHttp.request(lib/services/api-service.ts:840-973), which returns the response body whole;apiFetchStreamonly adds anAccept: text/event-streamheader. The SSE events are parsed in one go when the backend finishes, so the message is "streaming" for a frame or two and the whole reply paints at once. Web streams and animates the reveal (useAnimatedAssistantTextruns only while streaming). - Fix (
fd31a0cf5): a generic native byte-stream plugin,HushhStream(iOS URLSession delegate, Android OkHttp; the KaiPlugin SSE pattern), forwards the raw response bytes as they arrive;lib/services/native-sse-fetch.tsturns them into a fetch-compatible streamingResponsefor AG-UI'sHttpAgent. No backend or CORS change. Measured: the reply streams for 18.5 s at p95 17 ms, p99 19 to 23, worst 36, no frame over 50, 5.3 ms/s (was one paint of 84 to 166 ms). - GOTCHA: the WebView's own
fetchwas not an option (WKWebView buffers the body;api-service.tsrecords the same for the Kai import). A new SSE consumer on native goes throughnativeStreamFetch, notapiFetchStream.
B40 — Android truth lane: the passkey sheet, UiDevice gestures that never reach the page, and a stale accessibility tree after unlock
- Symptom: the UIAutomator lane asserted "app window" at launch; when it got past that, its swipes rendered nothing in the WebView (HWUI kept its idle 120 Hz refresh, the probe recorded no window); after the passphrase the "One" tab was never found although the probe showed the app idling on the route for a minute; one diagnostic run's logs were destroyed by the card's passphrase grep.
- Root cause: (1) the gate's passkey attempt puts
com.android.credentialmanagerin front ("No available sign-in"), soBy.pkg(app)never matches; (2) events injected throughUiDevice.swipe/clickdid not reach the page's pointer listeners on this phone, while the shellinputtool's do; (3) after the gate unmounts, UIAutomator's view of the WebView goes stale (no RadioButton nodes); (4) the page exposes the passphrase field's value through accessibility, so logging node texts logs the passphrase. - Fix: cancel the Credential Manager before the window wait and in the unlock loop; every gesture through
device.executeShellCommand("input …"); the field staying gone for four seconds with the app in front and no mismatch banner is the unlock signal, with a positional fallback for the five tab pills; snapshots log counts only; the card clears logcat before and after a run (73fa010ce). - GOTCHA: the phone's secure lock (two-minute timeout) engages between runs; the card keeps the screen on only during a run. Keep the phone unlocked or enable Developer options "Stay awake" for a session. Manual driver runs need the backend-resolved reviewer uid (
resolve-reviewer-uid.mjs); the env file's uid givesbootstrap=uid_mismatch. After a Wi-Fi reconnect the serial becomesip:port(10.0.0.77:33971), notR5CWC0MV4YH.
B41 — Every screen dropped 90 to 150 ms frames at rest: ten timers, each waking the page on its own phase
- Symptom: the probe's idle table read two to six frames over 50 ms per two minutes on every route (feed worst 183 ms, kai 191, location 183, chat 150), with p95 on budget between them.
- Root cause: ten independent
setIntervals (three 45 s feed pollers, the 30 s and 5 min consent reconciles, the map's 5 s marker refresh, 30 s share count and 15 s stale clock, the 5 s grant refresh, a 60 s Puppy poll, 30 ssetNowMsclocks) each fired on its own phase, and a fetch or a state write started inside a frame's script phase. - Fix (Track D):
lib/perf/idle-scheduler.ts: a task with interval N runs at wall-clock multiples of N, so tasks sharing an interval share one wake; the wake runs after rAF + a macrotask, never while hidden, and catches up on return;useCoarseClockreplaces the privatesetNowMs(Date.now())intervals. Lintinterval-state-tickholds the line. - GOTCHA: a task the hook runs by itself (mount, focus, an event) must call
markPeriodicTaskRan(id)or the clock repeats it a moment later. Under vitest fake timers a synchronous task must settle without a microtask (the scheduler does), and tests that advance exactly one interval need the frame's slack.
B42 — The iPhone refuses UI automation after it has slept: "Timed out while enabling automation mode"
- Symptom: every XCUITest launch failed within a minute with that error;
devicectllisted the phone asavailable (paired)rather thanconnected; killingtestmanagerdon the phone did not help. - Root cause: the phone was asleep/locked at the far end of the Wi-Fi tunnel; the automation session needs the phone awake and unlocked when the runner attaches.
- Fix: the founder woke the phone; the next attach succeeded at once. Nothing in the tooling changes this; a lane run needs an awake phone at its start.
- Second cause, same error: on attach the phone can show "Enter iPhone Passcode for 'XCTest', Enable UI Automation" and wait a minute for the device passcode. Only the phone takes it:
devicectlhas no input injection,devicectl manage pairreports already paired, AppleScript UI scripting is denied on the Mac. Screenshot the phone (xcrun devicectl device capture screenshot) when an attach hangs to tell the two causes apart.
B43 — With the keyboard up on the phone: the rise costs one 68 ms frame, typing runs at p95 21 ms, and a tap outside the composer reaches the page as no pointer event
- Symptom (run AD, first keyboard-in-the-loop chat section):
chat-keyboard-showone frame of 68 ms (91 ms/s),chat-keyboard-typep95 21 ms, worst 50 (51 ms/s),chat-keyboard-dismissno probe window at all although the keyboard went away. - Root cause: not attributed yet (the keyboard height variable and the composer's autosize
scrollHeightreads are the suspects); the dismissal tap is consumed by WebKit's end-editing before any pointer event, so the probe saw nothing. - Fix: the probe gained a
viewportwindow (visual viewport resize) so a keyboard rise or fall is measured even without a pointer event; the frame cost is the next Phase 3 item after the tab switch. - Attributed 2026-09-22 (stall table + one-launch experiments,
PERF_EXPERIMENT=): the document is 614 to 732 elements, so a whole-document style pass is not the story. Typing: the 98 to 105 ms frame sits about 150 ms after the last keystroke with no event and no commit under it; withautocorrect-offit is gone (worst 46, 0 over 50). It is WebKit answering the system keyboard's autocorrection and spell-check requests. Keyboard show: 64 to 89 ms at about 110 to 140 ms after the tap, on the first and on a second show, with autocorrect on or off, and still 63 / 69 ms withkb-inset-off(our--kb-heightwrite, layout and lift skipped for the launch). It is the system keyboard's presentation blocking the WebView's frames; the page adds nothing measurable. Dismiss is clean (36 to 38 ms). The composer's backdrop blur was tried as the typing cost and cleared (98 ms with it off). - Standing (closed as system cost): the show stall is not ours. The typing stall is the autocorrection context specifically:
spellcheck-offalone leaves it (95 ms),autocorrect-offremoves it, and autocorrection stays on in a chat composer. Neither is a render defect of the page; the lane keeps recording them so the numbers are read with that in mind.
B44 — The chat composer floated a nav-height above the keyboard (and above the nav at rest), and jumped ahead of the keyboard's rise
- Symptom (founder, iPhone 16e): "the position of the bar is messed up on agent chat; with the keyboard it should be exactly above the keyboard, smoothly". Measured by the lane: composer field bottom 422 pt, keyboard top 560 pt, a 138 pt gap; at rest the bar sat about 96 pt above the navigation pill.
- Root cause, two halves. (1) Double clearance:
0d9eae0e7(main) made the root workspace100% - --app-bottom-shell-heighttall while the root rule inglobals.cssalready pads the composer by the shell height, so the shell was subtracted twice; main fixed it in979897efa(workspace back toflex-1 h-full) and this branch had not taken it. (2)--kb-heightlands once atkeyboardWillShowwith the keyboard's final height and the composer's padding took it in one layout, so the bar teleported to its final spot while the keyboard was still sliding. - Fix: cherry-pick of
979897efa; on native the root composer's padding stays at its resting value and the keyboard lift rides the CSStranslateproperty with the keyboard's duration and curve (--kb-motion-duration250 ms,--kb-motion-curvecubic-bezier(0.38, 0.7, 0.125, 1), the reverse-engineered iOS keyboard curve), independent of the nav-ridetransform, which is held atnonewhile the keyboard is up. Gap after: 19 pt from the field's bottom to the QuickType strip (the bar's own edge about 8 pt above it). - GOTCHA (instrument): XCUITest's
app.keyboardsframe excludes the QuickType strip (key rows at 560, strip at 516); the lane reads the strip's element too. Accessibility snapshots of the WebView run on the page's main thread: never poll (waitForExistence,.frame) inside a measured window; the geometry line is read after the window closes.
B45 — With the keyboard up, an invisible bottom navigation swallowed taps on Send and the mic
- Symptom: after B44 placed the composer where it belongs, the lane's tap on "Send message" did nothing; the probe classed the tap as
bottom-nav; XCTest reported "Computed hit point {-1, -1}" for a button plainly on screen. - Root cause:
html.kb-open [data-app-bottom-shell]fades the shell (opacity: 0; pointer-events: none) but the shell's stack still rides up by--kb-height, and the navigation pill's ownpointer-events: autooverrides the shell'snone. The invisible pill landed exactly over the lifted composer (nav at y 444..519, bar at 450..506). Present on main too; the double clearance had hidden it by pushing the bar above the pill's ghost. - Fix:
visibility: hiddenon the faded shell (a child's pointer-events cannot override it), flipping after the 150 ms fade and returning at once.
B46 — The chat composer rode a beat behind the bottom navigation: two bars, two motions
- Symptom (founder): "the bottom bar and the text input bar are not synchronous and both are performing their own animations, it also does not feel smooth". Measured by the probe's new
bottom_chrome_syncreading (per scroll frame, the vertical divergence between the navigation stack's and the composer's transforms): 73 px, the navigation's whole travel, during every transcript flick. - Root cause (ours, from Phase 2):
9230d6ae4moved the per-frame--bottom-chrome-progresswrite from<html>onto the consumer elements and collected those consumers once, at the shell's mount. The chat composer mounts later (behind the vault gate and a Suspense boundary), so it never joined the per-frame set and only received the settled root value 160 ms after each scroll went quiet. On top of that the composer's ride had a 140 ms eased transition that restarted every frame while the navigation follows the finger with none. - Fix: the writer looks for a composer at most every 250 ms while it holds none and recollects on a hit (regression test: a composer mounted after the first write receives the very next per-frame value); the composer's ride transition is gone (the keyboard lift keeps its own timing on
translate). Verified on the phone: divergence 73 px → see the run in the charter. - GOTCHA: a consumer set collected once is a set that is stale for anything that mounts later. Any future consumer of a per-frame variable either registers itself or the writer must look for it.
B47 — A ghost of the navigation jumped above the keyboard for the frames of its fade
- Symptom: at keyboard open the navigation stack's transform included
-1 * --kb-height, with no transition, so the pill teleported to just above the keyboard and faded there over 150 ms, over the spot the composer was rising into. - Root cause: a leftover from before
html.kb-openhid the shell everywhere; with the shell hidden on every route while the keyboard is up, the lift had no purpose. - Fix: the stack rides the scroll progress only and fades where it stands; the reduced-motion rule pins it with
transform: none.
B48 — The composer's way back down after the keyboard was jittery while its way up was smooth
- Symptom (founder): "when keyboard is closed, this going back down is jittery and not as smooth". Frame pacing during the dismiss was clean (p95 20 to 24 ms), so the probe gained a keyboard trace: from the moment
html.kb-openflips, the composer field's bottom edge and the last transcript row's bottom edge per frame. Show read "43 5 46 46 36 20 12 8 5 4 2 1" px per frame; hide read "33 4 43 74 36 19 13 8 5 4 3": a near-zero step then a double step in the middle of the descent. - Root cause: everything that returned at
keyboardWillHidelanded in one frame mid-descent: the bottom shell and the ambient mask flipping back to visible (two backdrop-blur layers taking their first paint), the transcript's bottom padding collapsing by the keyboard height in a single layout, the composer's 4 px focus padding dropping at blur. The show pays the system keyboard's presentation early and glides after; the hide paid ours in the middle. - Fix: on native the shell is not hidden at all while the keyboard is up (it no longer lifts, so the keyboard simply covers it; the hide stays for mobile web); the transcript's padding rides the keyboard's own clock so the rows travel with the bar; the focus padding is one value on native. Traces after: "27 19 42 49 41 27 16 9 6 4 3 2 1 1" (largest step 49, monotone tail).
- GOTCHA: a first dismiss right after a streamed reply is the case people live; measure that one, not a fresh screen.
B49 — Finishing a bank connection in Chase left the app and landed on the website
- Symptom (founder, iPhone 16e): Plaid Link opened, Chase opened its own native app, the flow completed, and then a browser window came up on
uat.one.hushh.aiinstead of returning to the app. The backend saw the link-token create and never anexchange-public-token, so the connection did not exist. - Root cause: Plaid's OAuth leg leaves the WebView entirely. The bank returns to Plaid's hosted redirect, which continues as a normal Safari navigation; a Universal Link does not fire for a redirect chain, so nothing brought the session back into the app. The app's entitlements (
applinks:uat.one.hushh.ai) and Apple's cached AASA were both verified correct: the link was never offered to them. - Fix: the web Link SDK cannot complete an OAuth bank inside a WebView, so native Link is the answer.
HushhPlaidLinkPlugin(LinkKit 7.1.2) presents Link natively and resolves{publicToken, metadata}or{exit};plaid-link-loader.tsreturns the LinkKit-backed session whenever the plugin is available and the web loader everywhere else. A narrowerHushhOAuthReturnPluginalso cancels a top-frame navigation to{one,uat.one,dev.one}.hushh.ai/oauth/returnand re-enters throughappUrlOpen, which covers non-Plaid returns but not this one. - Status: compiled and registered, not yet exercised on the phone — the founder deferred the bank test. UAT points at Plaid production, so automation never proceeds past Link's first screen and no screenshot is taken while a bank credential screen is open.
B50 — Plaid's Link frame sat under the status bar
- Symptom (founder): "the interface was not correct and not following safe area at the top (overlapping with system icons)".
- Root cause: Plaid's web Link injects its own full-viewport iframe (
#plaid-link-iframe-*) positioned attop: 0. It is outside the app's layout, so every safe-area token the shell applies misses it. - Fix: a native-only rule pins the iframe below
--app-safe-area-top-effectiveand takes the same amount off its height. Native Link (B49) does not use the iframe at all, so this only has to hold for the web fallback.
B51 — Re-tapping the tab you are already on turned the screen white
- Symptom (founder): "on /connect, tapping Connect again glitches, the entire screen goes white". Reproduced on the phone and in the reviewer session.
- Root cause: the static native export is built with
trailingSlash: true, so the live pathname is/one/connect/while the nav item's href is/one/connect. The route-transition engine's same-pathname guard compared the two as strings, decided this was a real navigation, ran the exit beat, and then had no enter to pair with it: the page faded out and stayed out. - Fix:
routePathname()normalises the trailing slash before the comparison, so a re-tap is recognised as the current route and does nothing. Regression test covers the/one/connect/form. - GOTCHA: any future comparison between a live pathname and an authored href on native has to normalise the trailing slash. The web build has no slash and never showed this.
B52 — The Android truth lane measured the vault gate and reported it as the app (every run until 2026-09-22)
- Symptom: the S24 Ultra card read p95 8 ms at 120 Hz on feed, tab switch, pager, chart and map, "certifying". A screenshot mid-run showed "Unlock One" with an empty field and a greyed-out Unlock button.
- Evidence: every probe window on the phone was kind
tapon ~280 to 350 DOM nodes; the iPhone's unlocked feed isscrollwindows on ~700 nodes, its tab switchbottom-nav→route. A tree dump of the gate: the input exposes neither its aria-label nor its placeholder to UIAutomator. - Root cause (three stacked): (1) "the labelled field has been gone 4 s" was the unlock proof, and the label was never visible, so it held from the first poll; (2)
UiObject2.setTextfilled the field without an input event, so React kept Unlock disabled; (3) one sighting of the tab bar counted, and a cold boot can paint the shell for a moment before the gate mounts. The "stale accessibility tree" diagnosis from 2026-09-20 was this: the tree was right, the gate was up.UiAutomation.clearCache()was tried and changed nothing, which is what disproved the stale-tree idea. - Fix: the passphrase goes in as key events (
Instrumentation.sendStringSync, in-process, no shell command line); Unlock is pressed once enabled, re-pressed while the gate stays up; unlock proof is positive only: tab bar present and "Unlock One" absent, held 3 s. The shared summariser now marks a flick / swipe / pan / switch / pane whose windows are all taps "not measured (nothing moved)" and the card certifies nothing. - Status: typing proven on the phone (field filled, Unlock enabled); the submit and the full card are not yet re-run (Android paused by the founder). Every earlier S24 app number is void; the Threads/X HWUI numbers stand.
- GOTCHA: a number that looks better than the phone can do is a finding, not a win. Check the window kinds and DOM size before believing a card.
B53 — A route sweep signed the reviewer out and then recorded every later route "unreachable"
- Symptom: the 93-route iOS sweep went unreachable from
/connected-systemson; a screenshot showed "Welcome to One". - Root cause: an earlier sweep included
/logout, which ended the session; the Release truth lane has no automated sign-in (test mode is Debug-only by design), so every launch after it waited 90 s on the sign-in screen. - Fix: the sweep takes signed-in, non-callback routes only; it aborts on the first launch that sits on the sign-in screen (
PERF_SWEEP_ABORTED reason=signed-out) instead of burning 90 s per route;scripts/perf/ios-reviewer-signin.shrestores the session in one command (Debug bootstrap with-UITestResetAppState false; the Release install lands on top and keeps it). - Tool:
xcrun devicectl device capture screenshot --device <udid> --destination <png>reads the phone's screen from the Mac with no test running.
B54 — On Android the keyboard was subtracted twice: the vault gate collapsed to 44 dp
- Symptom (founder): "the passkey screen is totally broken on android when keyboard is opened". With the keyboard up the gate showed only the top half of the field; heading, Unlock and every link were gone.
- Evidence: the UI tree put the gate's box at [60,217]→[1383,371], 154 px (44 dp), while its content kept full height. The WebView itself had shrunk to 1440×1775 of 3120.
- Root cause:
KeyboardInsetManagerpublished the whole keyboard as--kb-heighton every native platform. iOS keeps its frame (resize "none"), so that is right there; Android shrinks the WebView for the keyboard even withadjustNothing, so every consumer subtracted it again (507 − 62 − 384 − 16 = 45 dp). - Fix: the inset is the keyboard minus what the viewport already gave up, recomputed on resize (Android can resize before or after the plugin event);
kb-openstill means "a keyboard is up" andkb-resizesmarks Android. Onkb-resizesthe tab bar steps aside (iOS: the keyboard covers it), the root composer gives back the bar's room and gets a background band, and the search palette stops reserving the tab bar's height on both platforms (it floated ~160 dp above the keyboard with its top under the status bar). - GOTCHA: the Samsung Edge Panel handle (
CocktailBarService) is a bar on the right edge of every screen; it is not the WebView's scrollbar.
B55 — Settings-row chevrons at four different x positions, values cut to "Equi…" / "+.."
- Root cause: a
max-width: 58%on the trailing column, which sits in anautogrid track sized from that same box, so 58% resolved against the value's own width: each value was clipped to 58% of itself and the box sat at the track's start. - Fix: the bound moved to the track (
fit-content(58%), relative to the row). Measured after: every chevron at x=1280 on the S24, full values on both phones.
B56 — Consent rows printed the stored scope key
- Symptom: "attr.professional.work_preferences.entities._entities.observations._items was revoked." in the Feed.
- Fix: the Feed uses
humanizeConsentScope, which now drops structural segments (entities,_items, …). Open: the chat card's chip ("Saved Places Locations Items Longitude") comes pre-labelled from the backend scope catalog viainformation_request_service.py.
B57 — Android runs "crashed" when a previous run was still alive on the phone
- Symptom:
Process crashedwith a framework NullPointerException inWindowTokenClient.onConfigurationChanged, no app frames; a normal launch was fine. - Root cause: an instrumentation outlives the adb client that started it. Stopping a card on the Mac left its 30-minute hold running on the phone, and the next card's instrumentation tore it down mid-run.
- Fix: the card force-stops the package before starting.
PERF_SKIP_INSTALL=1skips the reinstall (wireless streamed installs of an unchanged APK hung for minutes).
B58 — "Protecting private information…" + spinner in the app switcher and on every resume (iOS)
- Symptom: the app-switcher card, and a brief flash on return, showed a lock icon, "Protecting private information…", and a spinning indicator. It also appeared after Control Center, Notification Center, screenshots, and system prompts.
- Root cause:
HushhSessionPrivacyShieldcovers the WebView on everywillResignActive(correct: it keeps private information out of the switcher snapshot). The cover always drew status text and an animating spinner, so iOS captured a "loading" screen into the snapshot and showed it until JS acknowledged the generation. - Fix: the cover is now the launch screen (
.systemBackground+Splash, aspect-fill). The lock icon, status text and spinner are removed entirely. Only the 8 s recovery panel (explanation + Try again / Restart session) can appear over it. Release fades the cover out over 0.12 s. The generation/document acknowledgement contract is unchanged. File:ios/App/App/Plugins/HushhSessionPrivacyPlugin.swift.
B59 — An accepted sent connection request appeared in My connections only after a manual refresh (iOS)
- Symptom: after someone accepted your request, Connect → My connections did not change until you pulled or tapped refresh.
- Root cause: the only live signal on native is the
connection_request_resolvedpush. The consent SSE fallback is web-only. With notifications off or not yet granted, a dev-signed build, or a delayed APNs delivery, nothing told the mounted screen to reconcile. - Fix:
useOutgoingRequestResolutionWatch(lib/connections/) reads pending outgoing request ids every 5 s. It runs only while a request is pending and the app is visibly active. Any change calls the page's existingreconcileConnectionSurfaces. Push stays the primary path. Test:__tests__/app/connect/page-client.test.tsx, "shows an accepted sent request without a push or manual refresh".
B60 — Launch screen and app-switcher cover showed a tiny old "S" app mark (iOS + Android splash)
- Symptom: after B58, the app-switcher card and cold-launch splash showed a small old gradient "S" icon (about 22 pt) on a blank screen instead of the 🤫 hussh mark used in onboarding and the web icon.
- Root cause: B58 reused the
Splashasset, which was never updated during the rebrand. All six 2732 px PNGs had a ~70 px legacy mark. - Fix: regenerated
Assets.xcassets/Splash.imageset/*with the Apple Color Emoji 🤫, ink-centered at ~390 px (~120 pt on a 3x iPhone with aspect-fill). The light variant uses#FFFFFFand the dark variant keeps#111111. Androidres/drawable*/splash.png(all 26 densities and orientations) got the same mark at ~37.5% of the short edge (~120 dp), with each file's size and background preserved. The launch storyboard, privacy cover code and Android theme are unchanged.
B61 — Launch screen and app-switcher 🤫 mark was too large (iOS + Android splash)
- Symptom: after B60 the 🤫 mark filled about a third of the app-switcher card (~120 pt) and read as oversized next to the rest of the app.
- Root cause: B60 sized the mark at ~390 px on the 2732 px canvas (~37.5% of the short edge on Android) to replace the tiny legacy "S"; that overshot.
- Fix: halved the mark about its own center in all six
Assets.xcassets/Splash.imageset/*PNGs (ink 385x403 -> 194x202 px, ~60 pt on a 3x iPhone) and in all 26 Androidres/drawable*/splash.pngfiles (~18.75% of the short edge, ~60 dp). Canvas size, color mode and background (#FFFFFFlight,#111111iOS dark,#151515Android night) are unchanged, and the existing artwork was resampled rather than re-rendered. Storyboard, privacy cover and Android theme are untouched.
🧪 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, secretHUSHH_UAT_PHONE_TEST_CODE=000000, secretHUSHH_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 in0c19110c2:lib/firebase/auth-context.tsxstartPhoneVerificationnow tries the UAT-test start on native too, andconfirmPhoneVerificationroutesuat-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.mdand 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]].