Best for
- Use when the user asks "what bugs did we fix", "known issues", "list the resolved bugs", "why did X break", when a similar symptom reappears (check if it's a known regression before re-diagnosing), or before shipping a…
hushh-labs/hushh-research/.claude/skills/mobile-bug-log/SKILL.md
Running reference log of every iOS/mobile bug diagnosed + fixed on the `mobile` branch (symptom → root cause → fix → files/commit), plus the recurring build/runtime gotchas that keep biting. Use when the user asks "what bugs did we fix", "known issues", "list the resolved bugs", "why did X break", when a similar symptom reappears (check if it's a known regression before re-diagnosing), or before shipping a mobile build (re-verify the gotchas). ALWAYS append a new entry here whenever another mobi
Decision brief
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).
Compatibility matrix
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/hushh-labs/hushh-research --skill ".claude/skills/mobile-bug-log"Inspect the Agent Skill "mobile-bug-log" from https://github.com/hushh-labs/hushh-research/blob/42522d05abd9f89f9de6b436befe801e0ea0585b/.claude/skills/mobile-bug-log/SKILL.md at commit 42522d05abd9f89f9de6b436befe801e0ea0585b. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.
Workflow
Symptom: top-left back (←) on /one/setup did nothing (bounced straight back).
Ruled out: all /one/setup//index.html ARE exported in the static build + app bundle; tiles use relative routes (no external/uat URLs). The real cause of dead navigation was GOTCHA 1 (backend down → guards error). Don't…
Symptom: on /one, the "Finish setup — X% done" bar disappeared once the user opened "Set up One" and tapped a couple of capability tiles, even though Gmail (and others) were not set up.
Symptom: from the "Set up One" hub (/one/setup), tapping an item (Email/Gmail/Location/Marketplace) → capability opens → top-bar back → Profile (or /one), not back to the hub. User rule: "jaise aaya waise wapas" (retrac…
Symptom: 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…
Permission review
The documentation asks the agent to read local files, directories, or repositories.
`capacitor.config.ts`: `Keyboard.resize` `"none"` → **`"native"`** (the plugin default). WKWebView frame now shrinks by the keyboard height → `100dvh`/`svh` + `position:fixed` bottom elements sit above the keyboard on EVERY screen, no per-sThe documentation asks the agent to read local files, directories, or repositories.
**GOTCHA 3 — check whether main already solved it before inventing an API.** `dragDismiss: boolean | "handle"` and `contentDragDismiss: boolean` are the same feature; main's is better (a separate boolean beats an overloaded prop, and it shiEvidence record
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 96/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 25 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
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).
.env.local has NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 (local dev backend). capacitor.config.ts bakes that into every plugin's backendUrl, and the web layer fetches ${NEXT_PUBLIC_BACKEND_URL}/db/vault/check. On the simulator that resolves to 127.0.0.1:8000, which is Connection refused (NSURLError -1004) unless a local backend is running there. Confirmed via the sim log showing http://127.0.0.1:8000/db/vault/check ... Connection refused..env.local):
export NEXT_PUBLIC_BACKEND_URL="https://consent-protocol-f2gsa4kfsq-uc.a.run.app" before npm run cap:build && npm run cap:sync:ios. Confirm after boot: xcrun simctl spawn <UDID> log show --last 20s --predicate 'process=="App"' | grep -oE "consent-protocol|127.0.0.1:8000" should show consent-protocol, never 127.0.0.1:8000. (UAT url lives in .env.uat.local.)curl -s -o /dev/null -w "%{http_code}" https://consent-protocol-f2gsa4kfsq-uc.a.run.app/ → 200.simctl install fails with Unable to lookup in current state: Shutdown, the sim isn't booted. xcrun simctl boot <UDID>; open -a Simulator; sleep 6 then install/launch. The build itself likely already succeeded (** BUILD SUCCEEDED **) — don't rebuild, just boot + install the existing .app.cap sync; NODE_OPTIONS=--max-old-space-size=8192 for cap:build (OOM); GoogleService-Info.plist required; DerivedData in /tmp/hushh-ios-dd (iCloud FinderInfo breaks codesign).next build HANGS at 0% CPU when the repo is in iCloud-synced ~/Documentsnpm run cap:build (or even next dev, or a plain cp/mv) sits at Creating an optimized production build ... for 20+ min with 0% CPU and .next frozen. Looks like a webpack/worker deadlock or OOM. It is neither.~/Documents (iCloud "Desktop & Documents"). macOS FileProvider (fileproviderd/bird/cloudd) mediates and throttles every file read/write in that tree — and a webpack build does hundreds of thousands of tiny I/Os. With an active iCloud sync backlog the daemons churn at 60–90% CPU while the build blocks on I/O (0% CPU). Not RAM (CPU idle), not disk space, not the webpackBuildWorker flag (toggling it doesn't help), not offloaded files. Confirmed: build/dev/cp/mv ALL crawl; a cp -Rc of the repo did 143 MB in 13 min (~9 h projected).mv out is itself throttled, so the fast path is: git clone <origin> ~/hushh-research-fast (network → non-iCloud disk), copy the few gitignored files (.env.local, .env.uat.local, ios/App/App/GoogleService-Info.plist), npm ci, then build there. On a non-iCloud path the SAME build compiles in ~30s and runs on the sim. Permanent fix: keep the dev repo outside ~/Desktop/~/Documents (or disable iCloud Desktop & Documents sync). See [[hushh-research-keyboard-desktop-inert]] / memory./tmp/hushh-ios-dd if xcodebuild errors file '…FIRAnalytics+OnDevice.h' has been modified since the module file … was built (stale precompiled-module cache from a prior repo path)./one/setup did nothing (bounced straight back).router.push('/one'), but OneOnboardingGuard (components/kai/onboarding/kai-onboarding-guard.tsx) only early-exits when the vault is unlocked (unlockedOnStandardKaiRoute = isVaultUnlocked && !onOnboardingRoute). Vault-locked → runs the async check → on native it transiently reports setup incomplete → router.replace('/one/setup').isNativePlatform() && !onOnboardingRoute && readOneSetupCompletionHint(uid) === true, clear cookies + setChecking(false); return; (trust the in-session hint, skip the bounce). Web unchanged (native-gated).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).@capacitor/keyboard is NOT installed and there's no keyboard-resize handling. iOS WKWebView doesn't shrink 100dvh; it scrolls the whole fixed inset-0 overlay up to reveal the focused input → the fixed header drifts under the status bar.visualViewport keyboard-pin in components/agent/agent-popover-provider.tsx — track keyboard height (window.innerHeight - visualViewport.height - offsetTop), shrink the mobile sheet to h-[calc(100dvh-var(--agent-kb-height))] (top-pinned, bottom-auto), and window.scrollTo(0,0) to undo any iOS scroll. Composer sits above the keyboard, header stays below the status bar. Mobile-scoped (max-sm).a6cb290b1): popover header height includes --app-safe-area-top-effective; composer adds --app-safe-area-bottom-effective; ink-glass back button replaces the top-right X; blue text-primary → luxury gold #9C7434/#D4AF6A, SF Pro type, iOS rounded cards. All isPopover/max-sm-scoped so desktop/web unchanged. File: components/agent/agent-chat-workspace.tsx./one/setup/<capability>/index.html ARE exported in the static build + app bundle; tiles use relative routes (no external/uat URLs). The real cause of dead navigation was GOTCHA #1 (backend down → guards error). Don't chase the tiles; check the backend + guard first./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.app/one/setup/[capability]/one-onboarding-capability-client.tsx handlePrimary (commit d83ed1890) resolved the account-wide master gate PreVaultUserStateService.syncKaiSetupState({ completed: true }) whenever Continue forwarded into a hard-gated /one/* surface (gmail, email→/one/kyc, location, pkm, connected-systems) — done only to stop OneOnboardingGuard bouncing. But setupCompleted === true also makes resolveFinance (lib/services/capability-setup-state-service.ts) report finance = completed pre-vault, so entering ONE capability flipped enough tiles non-actionable that hasSetupRemaining = some(isCapabilitySetupActionable) (one-dashboard-page.tsx) went false → bar gone. NOT a symptom of the palette/UI work.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=setup instead (removed the KaiProfileService.setOnboardingCompleted call from this path too).lib/navigation/routes.ts — added isCapabilityHandoffTarget() (the gated /one/* handoff set: gmail/kyc/location/pkm/connected-systems; excludes finance→/one/setup/kai and consent→/consents).components/kai/onboarding/kai-onboarding-guard.tsx — allow a setup-originated (?from=setup + known gated target) entry through WITHOUT the master gate, at all 3 bounce points (added && !setupOriginatedCapabilityEntry). Preserves d83ed1890's redirect-loop fix without the account-wide side effect; scoped so arbitrary /one/* stays gated.syncKaiSetupState), so the bar stays until the user actually finishes.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.main (web) — a separate PR can port it if the web team wants it.HTTP Error 404: {"detail":"No data found for user"} for fresh users (native only)/one/pkm, "Saved Intelligence", Readable tab) on a fresh/no-data account showed a red error banner HTTP Error 404: {"detail":"No data found for user"} + 0 domains / 0 saved details / 0 memory cards / Last updated Unavailable. iOS/TestFlight only.consent-protocol/api/routes/pkm_routes_shared.py) raises 404 "No … data found for user" for a data-less user as a NORMAL empty condition. The web branches of getMetadata/getEncryptedData/getDomainData in lib/services/personal-knowledge-model-service.ts already map response.status === 404 → emptyMetadata/null. But the native branches call the Capacitor plugin (HushhPersonalKnowledgeModel.*) directly; the iOS Swift (ios/App/App/Plugins/PersonalKnowledgeModelPlugin.swift executeRequest) + Android Kotlin plugins reject("HTTP Error <status>: <body>") for ALL non-2xx, so the 404 throws and pkm-natural-panel.tsx paints bootstrapError. (getDomainManifest is NOT affected — it uses ApiService.apiFetch with its own 404→null; the bug is exactly 3 native call sites, not 4. getAvailableScopes is out of scope: no UI consumer + web also throws on 404, i.e. already symmetric.)bfc8bae3d), single file lib/services/personal-knowledge-model-service.ts: added private static isNativeNoDataError(error) = /^HTTP Error 404\b/.test(message) (anchored to the plugin prefix so 401/403/408/429/5xx, Network error:, JSON parsing error: all keep throwing — no false positives; the plugins reject with a message only, no .code). Wrapped the 3 native calls in try/catch: getMetadata → on no-data result = this.emptyMetadata(userId) (flows through the normal MEDIUM-ttl cache, mirroring web); getEncryptedData/getDomainData → on no-data return null (no cache write, mirroring web). Non-404 errors rethrow. Fixes natural panel + explorer + data-manager + agent-lab transitively.__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.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.resize:"none" + chat-only --agent-kb-height approach only handled the open chat and left EVERY other input screen (register-phone OTP, vault) with zero keyboard avoidance — QA re-reported it. B9 replaces it with global resize:"native". The header-logo half (Bot → /one-quiet-emoji.png) is still valid.<Bot/> "random chatbot icon" instead of the hushh One mark.@capacitor/keyboard was NOT installed, and capacitor.config.ts ios.scrollEnabled: true left the native UIScrollView free to auto-scroll the whole webview up to reveal the focused input. Because the chat sheet is position: fixed, that native scroll dragged the entire overlay (header under the status bar, composer under the keyboard). The JS window.scrollTo(0,0) in the visualViewport hack resets the DOM scroll, not UIScrollView.contentOffset, so it could never win. 100dvh never shrinks (contentInset "never", no interactive-widget — which iOS/WKWebView does NOT support anyway).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); add Keyboard: { resize: "none", style: "LIGHT", resizeOnFullScreen: false } (resize:none keeps 100dvh full so our own sheet-shrink owns avoidance — native would double-shrink + make fixed-bottom UI jump). Type-only import KeyboardResize/KeyboardStyle + as casts (string values need the enum types; type-only = no runtime import).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 authoritative keyboardWillShow.keyboardHeight on isNativePlatform() to set --agent-kb-height (visualViewport kept as web fallback via dynamic-import gate); toggles html.agent-kb-open. The --agent-kb-height var + sheet calc max-sm:h-[calc(100dvh-var(--agent-kb-height))] are unchanged.app/globals.css: html.agent-kb-open .agent-chat-workspace drops the composer home-indicator padding to 0.5rem while typing (keyboard covers that zone).components/agent/agent-chat-workspace.tsx: header <Bot/> → <Image src="/one-quiet-emoji.png" unoptimized> (the 🤫 app-icon mark, already proven under the App:// scheme in AuthStep/vault-lock-guard/register-phone) in a gold-tinted squircle badge. Bot import kept (still used for message avatars ~L879).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).html,body have no overflow-y (globals.css:34-35); all scrolling is in the inner [data-app-scroll-root] overflow-y-auto (providers.tsx:386); top-app-bar listens on that inner root. Verified directly, not just from the plan.KeyboardPlugin registered at runtime; sim runs vs UAT. 9-agent investigation + 3-lens adversarial review workflows. Full logged-in on-device repro (open chat → focus composer → composer stays above keyboard) needs a human login (auth+vault gated). Fix is native-config-level = correct by construction.@capacitor/keyboard (resize none) + native scroll off + inner-overflow scrolling. Reuse --agent-kb-height / keyboardWillShow rather than new visualViewport hacks.resize:"native" decision here is SUPERSEDED by [B21]. native shrinks the whole WKWebView frame every keyboard-animation frame, recomputing every dvh/svh ~60×/sec → severe vault jank; it was reverted (commits 26453505b→78cf3a94f) back to none, which silently re-broke avoidance app-wide. B21 is the current standard: keep resize:"none" (no frame resize → no jank) AND add a global event-driven --kb-height avoidance layer. Do NOT flip resize to native or body again.app/register-phone) AND the One chat composer. B8's chat-only fix did NOT solve it app-wide.Keyboard.resize:"none" (+ ios.scrollEnabled:false). resize:"none" means the WKWebView frame NEVER shrinks → 100dvh stays full-screen and bottom inputs sit behind the keyboard. The ONLY avoidance code was the chat popover's --agent-kb-height subtraction (gated to the open chat), so register-phone/vault/every other input screen had zero avoidance. resize:"none" was chosen in B8 purely to protect the chat's manual subtraction — a one-component concern that broke the whole app.515347b8a):
capacitor.config.ts: Keyboard.resize "none" → "native" (the plugin default). WKWebView frame now shrinks by the keyboard height → 100dvh/svh + position:fixed bottom elements sit above the keyboard on EVERY screen, no per-screen JS. Kept ios.scrollEnabled:false, contentInset:"never", style:"LIGHT". cap sync ios regenerates ios/App/App/capacitor.config.json (the runtime-read file) to resize:"native" — commit both.components/agent/agent-popover-provider.tsx: DELETED the whole custom keyboard machinery (the keyboardInset state + keyboardWillShow/Hide + visualViewport effect + html.agent-kb-open toggle + --agent-kb-height in panelStyle + the now-unused isNativePlatform import). Sheet height max-sm:h-[calc(100dvh-var(--agent-kb-height,0px))] → max-sm:h-[100dvh] (shrinks with the webview). Removing it avoids a DOUBLE-subtract (webview shrinks AND JS subtracts → composer floats a keyboard-height too high).app/globals.css: deleted the dead html.agent-kb-open .agent-chat-workspace block. Kept the base --agent-chat-composer-bottom vars (that resting padding is the correct gap).app/register-phone/page.tsx: the OTP white sheet is normal-flow in a min-h-[100dvh] column whose page root is overflow-hidden. Added max-h-[calc(100dvh-4rem)] overflow-y-auto as a safety net so a tall step on iPhone SE scrolls WITHIN the sheet instead of clipping (resting look unchanged — content is short).Keyboard.resize:"native"resize:"none" + a global event-driven --kb-height layer (B21). What still holds from B9: do NOT use per-screen --agent-kb-height/visualViewport hacks scattered per component — keyboard avoidance must be ONE global mechanism.agent-kb-height|agent-kb-open = 0 hits; typecheck+lint+design-system pass; iOS build + resize:"native" in synced json + sim runs vs UAT; 6-agent read-only investigation (all concur native). On-device logged-in repro (OTP + chat + vault inputs above keyboard, incl. iPhone SE) needs QA login (auth+vault gated)./one dashboard, tap Email / Location / Consent Guardian / Information Marketplace → surface opens → top-bar back goes to Profile instead of the dashboard. Gmail / PKM / Connected-Systems were fine (the clue).backHref from resolveTopShellBreadcrumb() (lib/navigation/top-shell-breadcrumbs.ts), NOT router.back(). For ONE_KYC/ONE_LOCATION/ONE_MARKETPLACE it hardcoded backHref: ROUTES.PROFILE (these surfaces were historically reached from Profile panels) and never read ?from. The dashboard (one-dashboard-page.tsx) navigated with the bare cap.href (no origin). CONSENTS was origin-aware but also fell to a profile panel with no marker. Gmail/PKM/Connected already resolved to ONE_HOME, so they weren't broken.9b5706196) — origin-aware ?from, mirroring the existing Gmail pattern:
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_MARKETPLACE now backHref: normalizeInternalRouteHref(searchParams?.get("from")) || ROUTES.PROFILE, and the leading crumb is "One" (from dashboard) vs "Profile" (fallback). CONSENTS needed no change (already reads from).backHref → ONE_HOME flip: Profile also links to these surfaces (app/profile/page.tsx), so origin-aware preserves Profile→surface→back. No-from → Profile fallback (unchanged).top-shell-breadcrumbs (11) + one-dashboard-page (updated href assertions) + top-app-bar.contract = 24/24; iOS build; on-device (logged-in sim): dashboard → Email → back → dashboard. Pre-existing uncommitted normalizeBreadcrumbPathname/KAI_IMPORT changes in these files are compatible (query stripped before route match; from read from searchParams).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.Three small mobile UX/nav fixes (commit 909ea793d):
/) ("backdoor guy under the CTA"). components/agent/agent-bar.tsx unmountBar gated on routes but never auth (deliberate old comment L219-231). Fix: || (isHomeRoute && runtime?.tier === "anon_onboarding") — hides on the anon welcome only; signed-in users are redirected off / so the bar still shows on /one + all authed surfaces. NOTE: the runtime exposes tier (AgentAccessTier), NOT signedIn — use tier === "anon_onboarding" (the anon-on-/ tier).components/onboarding/IntroStep.tsx L165). onLogin → /login → AuthStep (Firebase social handles new + returning), so "Get Started" is accurate. Refreshed the stale "Get started removed" comments./profile?panel=access) did nothing. Top-bar back (top-app-bar.tsx ~L643) did router.push(backHref) — a same-pathname, query-only nav. The profile page closes its panels ONLY via router.replace(href, { scroll: false }) (profile/page.tsx updateProfileView "replace" / popProfileStack), so a plain push is a no-op on device. Fix: in the back handler, for normalizedPathname === ROUTES.PROFILE && (panel||detail) → router.replace(backHref, { scroll:false }) (mirrors popProfileStack); else router.push(backHref, { scroll:false }). /consents (cross-pathname) already worked.?panel/?detail) driven by useSearchParams → the profile page's own close uses router.replace(.., {scroll:false}). Any code navigating profile panels MUST use that same replace+scroll:false, not a bare push.top-app-bar.contract (updated to assert the new router.push(..,{/router.replace(..,{ back-nav contract), top-shell-breadcrumbs, one-dashboard-page = 24/24; iOS build. On-device: A + B verified on the logged-out welcome (no ask-bar, "Get Started"); C (auth-gated) unit-contract-covered + mirrors the proven panel-close./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).one-onboarding-capability-client.tsx) forwarded gated surfaces with a bare literal ?from=setup. The breadcrumb reads from via normalizeInternalRouteHref, which rejects "setup" (no leading /) → null → falls to the hardcoded default (kyc/location/marketplace → PROFILE, gmail → ONE_HOME). The same "setup" string was a valid guard bypass (kai-onboarding-guard.tsx params.get("from") === "setup") — one token doing two jobs, only the guard tolerated a bare value. (This was a side effect of the B6/finish-setup fix which introduced ?from=setup.)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-unused forwardsToGatedSurface + isOneSetupSurfaceRoute import. Finance keeps its encoded per-capability from.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.?from=/one) + Profile-origin unchanged.from markers MUST be valid internal paths (leading /). normalizeInternalRouteHref silently drops non-path values → breadcrumb falls to the wrong default. Don't invent bare-string markers that the breadcrumb + guard interpret differently.?from gaps on other origins9b5706196 07-06 02:51, 909ea793d 07-06 03:31) landed AFTER the last uploaded build; Capacitor bakes the web bundle into the binary (iosScheme:"App", no OTA), so the fix never shipped to the tester. Ruled out (adversarially): static-export query stripping, per-page back overrides, /consents vault redirect. Access & Sharing's router.replace fix (B11) is confirmed correct — no change needed.?from, so their top-bar back also falls to Profile//one. Top-bar back is breadcrumb-driven — every caller must tag origin.93b0cd9ca) + residual ?from tagging (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 take from via usePathname; RIA branch untouched); permission-locked-state.tsx + kai-invite-handshake.tsx (usePathname → {from}); app-sidebar.tsx ({from:pathname}); command-executor.ts ({from:currentRoute??undefined}). buildConsentCenterHref already supports {from}.consent-sheet-controller.tsx — its open/re-sync useEffect rewrites from (delicate; its own closeConsentSheet already returns to origin); app-bottom-nav.ts — tab-switch semantics (nav stays visible), a UX call not a bug; notification/FCM deep links (fcm-service.ts, one-location/notifications.ts) — cold-start, no origin.app/providers.tsx forced light via next-themes on Capacitor.getPlatform()==="ios" AND ios/App/App/Info.plist set UIUserInterfaceStyle=Light. The toggle persisted to localStorage but next-themes never applied it.ThemeProvider attribute="class" defaultTheme="light" enableSystem on all platforms. StatusBarManager already syncs native SystemBars from resolvedTheme. Contract test updated: __tests__/components/providers-theme-contract.test.ts now asserts NO forced theme anywhere + no UIUserInterfaceStyle in Info.plist.pointercancel swallowed) before md-ripple sees pointerup. Nothing set -webkit-touch-callout:none/user-select:none on actionables.app/globals.css — actionable roles (a, button, [role=button|radio|tab|menuitem|option]) get -webkit-touch-callout:none; -webkit-user-select:none; user-select:none. Inputs keep selection. Also pinned pointer-events:none on md-ripple.morphy-md-ripple itself (defense vs the historical first-tap-swallow, see material-ripple.tsx comment).--bottom-chrome-progress (scroll-hide). A tap landing mid-animation had its target translate away between pointerdown and pointerup → no click event. Wrapper also holds pointer-events-none while hidden.snapKaiBottomChromeVisible() in lib/navigation/kai-bottom-chrome-visibility.ts — onPointerDownCapture on the nav group freezes the chrome at its resting position when progress > 0, so the tap target is stationary at pointerup. File: components/navbar.tsx.aspect-square + self-stretch + h-auto w-auto; WKWebView resolves aspect-ratio against a stretch-derived flex cross size as indefinite → width fell back to content → oval.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.Recipient key unavailable for this location share.)Recipient key unavailable for this location share.; the map never opens and the location never updates. The recipient just sees a dead View button.lib/one-location/encryption.ts, DB hushh-one-location-keys, keyed by Firebase user.uid). Decrypt throws whenever the on-device key is absent or its keyId != envelope.recipientKeyId. iOS runs under iosScheme: "App" where WKWebView IndexedDB is evicted / not persisted across launches → the key is lost → ensureLocationRecipientKey mints a NEW keyId. The backend freezes recipient_key_id on the grant and hard-enforces envelope.recipientKeyId == grant.recipient_key_id (consent-protocol/.../one_location_agent_service.py create_grant/store_encrypted_envelope), so one rotation permanently poisons the grant: the recipient can't decrypt old envelopes AND the sender's publishes get rejected (LOCATION_ENVELOPE_KEY_MISMATCH) → live updates stop. Both the map (LocalMapPreview) and the silent setInterval poll only render when decryptedPoints[grant.id] is set (successful decrypt), so the failure looks like "View does nothing." (userId mismatch ruled out — bootstrap + decrypt both use user.uid.)lib/one-location/encryption.ts: mirror the private key (as JWK; the pair is generated extractable) into the native Keychain via HushhKeychain (@/lib/capacitor), gated by isNative(), key one_location_recipient_key:${userId}, accessible: afterFirstUnlock, non-biometric (no Face ID on the silent poll). readStoredKey now: IndexedDB → if empty & native, restore from Keychain with the SAME keyId and repopulate IndexedDB. IndexedDB stores privateKeyJwk (portable) with legacy CryptoKey fallback + migration. Exported RECIPIENT_KEY_UNAVAILABLE_MESSAGE. Precedent: lib/services/vault-bootstrap-service.ts.app/one/location/page.tsx: added per-grant grantViewErrors state; on RECIPIENT_KEY_UNAVAILABLE_MESSAGE in viewGrantEnvelope (manual + silent poll) it re-registers the current key (bootstrapCurrentUserLocationRecipientKey) and shows an inline "the secure key changed — ask them to share again" notice + Ask to share again button (handleAskReshare → OneLocationService.requestAccess to the grant owner). Same treatment in the redesign chat view_envelope handler (components/one-location/redesign/use-location-chat.ts). Once the owner re-shares, create_grant snapshots the now-durable key and updates resume.lib/one-location/encryption.ts, app/one/location/page.tsx, components/one-location/redesign/use-location-chat.ts; new test lib/one-location/__tests__/encryption.test.ts (+fake-indexeddb devDep). Branch fix/live-location-share-not-updating-recipient.iosScheme:"App" — it gets evicted; back it with the native Keychain (HushhKeychain) and restore on cold start (same pattern as the vault default secret). (2) Test the encryption module in node vitest env (// @vitest-environment node) — jsdom hands out cross-realm ArrayBuffers that Node SubtleCrypto rejects.active recipient key per user, so the last device to register rotates the other out and the non-active device can't decrypt. Fix = vault-synced shared keypair: the ECDH private key is encrypted with the user's vaultKey (AES-256-GCM, identical on every device after unlock) and stored server-side as an opaque encrypted_private_key_jwk blob; every device fetches its own blob (via list_state.myRecipientKey, owner-only) and decrypts → same keyId everywhere, no rotation. Reuses the PKM/vault pattern (lib/vault/encrypt.ts + HushhVault.encryptData). Backend: migration 083, register_recipient_key(encrypted_private_key_jwk=…) (COALESCE-preserving), list_state self-key read (never leaked in recipients). Client: ensureVaultSyncedRecipientKey in encryption.ts (remote-blob-wins → local-backfill → generate), key-bootstrap.ts fetch+register, vaultKey threaded through unlock-warm/page/chat, self-heal now attempts a vault-synced restore+retry before "ask to share again". Requires a backend deploy (migration+route) to UAT/prod before the client relies on it; degrades gracefully if myRecipientKey is absent. Tests: encryption.test.ts cross-device cases (7) + test_one_location_routes.py owner-only-blob assertion. Same branch.type="date" input (h-px w-px opacity-0 pointer-events-none, tabIndex=-1, aria-hidden), opened programmatically via showPicker() / .focus() / .click(). iOS WKWebView only opens the native date wheel from a DIRECT user tap on the date input itself — programmatic showPicker()/click() on a hidden input are ignored (showPicker also throws NotAllowedError without a user gesture, and Safari/WKWebView never supported it for date inputs until very recent versions). So the tap landed on the decorative text input and the real input never received a user gesture.type="date" input is now a full-size invisible overlay (absolute inset-0 h-full w-full opacity-0 cursor-pointer + full-bleed ::-webkit-calendar-picker-indicator) stacked above a purely decorative pointer-events-none display div (shows MM/DD/YYYY text + calendar icon). Every tap lands directly on the date input = a genuine user gesture = iOS opens its native wheel. Removed the programmatic handleOpenDatePicker path entirely.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).simctl install + relaunch (even after simctl uninstall and a clean web CLEAN=1 build), but the simulator keeps showing an OLD version of the screen — changes never appear on device. Looks like a WebView cache, but clearing it does nothing.xcodebuild then finds no simulator destination and fails with error: Unable to find a destination matching … {platform:iOS Simulator} / iOS 26.x is not installed. Please download and install the platform. (2) The failure was masked because the build was invoked as xcodebuild … | tail — a pipeline's exit status is tail's (0), so the failing build looked successful. simctl install then re-installed the previous/stale App.app every time.xcodebuild -project ios/App/App.xcodeproj -scheme App -showdestinations lists NO platform:iOS Simulator device rows (only an ineligible "Any iOS Device" needing the new iOS). And grep the built app vs the synced source: grep -rl "<a NEW string>" /tmp/hushh-ios-dd/Build/Products/Debug-iphonesimulator/App.app/public/_next/static/... vs ios/App/App/public/... — if the App.app has a different/older page-*.js chunk than the synced public/, the native build never bundled your changes.xcodebuild -downloadPlatform iOS (installs the current iOS simulator platform, ~8 GB). Afterwards -showdestinations shows the existing iPhone 16 (18.2) again, so you can build for the SAME sim (no new device / no data loss beyond a normal reinstall). Then build without piping to tail (or check ${PIPESTATUS[0]}) and verify ** BUILD SUCCEEDED **.xcodebuild … | tail/| grep and trust the exit code — the pipe hides build failures. Redirect to a log (&> /tmp/xcbuild.log; echo $?) and grep for BUILD SUCCEEDED.xcodebuild -downloadPlatform iOS) before run-ios-sim/launch.sh can build for the sim.native↔none flip-flop for good515347b8a) set Keyboard.resize:"native" (frame shrinks → avoidance works). That janked the vault (native recomputes every dvh/svh ~60×/sec during the keyboard animation), so it was reverted: 26453505b (→body) then 78cf3a94f (→none). resize:"none" leaves the WKWebView frame full-height AND there was no JS/CSS fallback (B9 had deleted the --agent-kb-height machinery), so every bottom input sat behind the keyboard. Net: native=avoidance-but-jank, none=no-jank-but-hidden — the two kept ping-ponging. Extra drift: ios/App/App/capacitor.config.json still read "native" (stale; cap sync hadn't run after the flip).none, add event-driven avoidance):
components/keyboard-inset-manager.tsx — mount-once native/mobile bridge (mirrors status-bar-manager.tsx, returns null, mounted in app/providers.tsx outer Providers next to <StatusBarManager/>). Native: dynamic-import @capacitor/keyboard, keyboardWillShow/Hide set --kb-height on <html> + toggle .kb-open — once per transition, NOT per frame → zero dvh thrash / zero jank. Also a delegated focusin (capture) net that scrollIntoView({block:"center"}) the focused field (only when .kb-open). Mobile-web fallback via thresholded visualViewport (>120px). Desktop/laptop = firewall: binds nothing, --kb-height stays 0px → every consumer is a no-op.app/globals.css: --kb-height: 0px in :root; agent composer vars (--agent-chat-composer-bottom/-focused-bottom, base + html.native-ios) fold in --kb-height via max() (no home-indicator double-count) so the composer lifts.--kb-height: components/ui/drawer.tsx bottom DrawerContent → bottom:var(--kb-height) + max-h:calc(80vh - var(--kb-height)) (lifting the whole sheet is the ONLY reliable avoidance under resize:none — the viewport never shrinks, so scrollIntoView alone can't clear the keyboard for a bottom-anchored sheet); components/ui/sheet.tsx bottom variant likewise; components/ui/dialog.tsx centered → max-h -= --kb-height and shift up by --kb-height/2.components/vault/vault-unlock-dialog.tsx: vault sheet max-h as a data-[vaul-drawer-direction=bottom]: variant so it deterministically overrides the base drawer max-h and shrinks by --kb-height; the lift comes from the base DrawerContent. Kept repositionInputs={false} (JS owns avoidance; vaul reposition would re-introduce the keyboard-less sim gap).app/register-phone/page.tsx: OTP region maxHeight subtracts --kb-height (+ focusin net centers the field).capacitor.config.ts: kept resize:"none" (+ fixed the stale comment that still described body/native); kept ios.scrollEnabled:false + MyViewController.swift isScrollEnabled=false. npx cap sync ios re-run so ios/App/App/capacitor.config.json regenerates native→none — commit both.DrawerContent via transform: vaul animates open/close with transform: translate3d; a transform offset fights it. Use bottom/max-h (position + layout) which compose with vaul's transform, and are only ever non-zero when a real keyboard is up.resize:"none" (no frame resize → no jank) + ONE global event-driven --kb-height var that fixed/bottom surfaces lift by. Never flip resize to native/body (jank), never scatter per-component visualViewport hacks (B8), and never ship none without the --kb-height layer (B9-revert). Every consumer must be a no-op at --kb-height:0 so desktop/web stays 100% inert (user requirement — the website must not do mobile-keyboard things).main → UAT frontend) and mobile (→ mobile → TestFlight); the avoidance layer is web-safe (inert on desktop, active on mobile web via the visualViewport fallback).components/keyboard-inset-manager.tsx (new), app/providers.tsx, app/globals.css, components/ui/{drawer,sheet,dialog}.tsx, components/vault/vault-unlock-dialog.tsx, components/agent/agent-popover-provider.tsx (comment), app/register-phone/page.tsx, capacitor.config.ts (+ synced ios/App/App/capacitor.config.json). Branch fix/ios-keyboard-avoidance.--kb-height rules (calc(80vh - var(--kb-height,0px)) etc. — Tailwind v4 normalizes the calc operators, so no _ escaping needed). On-device (iPhone 17 sim, iOS 26.5, UAT backend): the Unlock Your Vault hard-gate with the software keyboard OPEN shows the "Enter vault key" input + Unlock button fully ABOVE the keyboard (was hidden behind it before) — the exact reported bug, fixed. No vault jank (resize:none). Other surfaces (OTP/chat/drawer/dialog) share the same mechanism + verified CSS. NOTE: the local next build --webpack hangs when the repo lives in an iCloud-synced ~/Documents folder (FileProvider throttles all build I/O to a crawl / 0% CPU) — build from a non-iCloud path (see GOTCHA below).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.readOnce in lib/capacitor/plugins/location-web.ts trusted the W3C timeout option. Per spec that clock does not start until the permission prompt is resolved, so a prompt the user never answers — or one the browser suppresses without telling the page — fires neither callback, ever. Every caller awaits it (OneLocationService.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's finally was correct all along — it simply could never run. The sampling stage also rejected with a bare { code: 3 } object literal, which every downstream instanceof Error check (locationServicesErrorMessage, LocationBus.isDeniedError) silently degraded.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.app/one/location/page.tsx (the 20s interval publisher and the movement watcher) wrapped the whole for … await in one try. The first recipient that threw aborted the entire tick — and because activeOwnerGrants is stably ordered, the same recipient threw on every tick, starving everyone after them permanently. In the movement watcher it was worse: lastPublishedPointRef.current = point sat after the loop, so it never advanced and the gate re-fired the same failing publish forever.fix/one-location-capture-latency):
location-web.ts — one overallDeadlineAt shared by every stage (timeoutMs + 8s), a LAST_RESORT_RESERVE_MS = 4s so the cached-fix recovery still gets a window, a real setTimeout deadline inside readOnce, and timeoutError() returning an Error (name LocationTimeoutError, code: 3) instead of an object literal.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 via pendingCapture. Plus invalidateCapturedPosition() and a test reset seam.page.tsx — both live loops became Promise.all with a per-grant try/catch; a grant with no usable recipient key now logs instead of skipping silently. Public-link + contact-invite routed through ensureForegroundLocationReady (they were the only share entry points with no permission pre-check). ensureForegroundLocationReady now returns its point unconditionally.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 browser fetch (above the Next proxy's own 45s so its 504 still wins). The native branch was already bounded; the web branch relied on a proxy that only bounds its own upstream call. Composed manually rather than with AbortSignal.any, which is not in every WKWebView this app runs in.recipientForGrant's strict keyId match. The backend freezes a grant against one recipient key and rejects any other envelope (see [B18] LOCATION_ENVELOPE_KEY_MISMATCH), so relaxing the match would trade a silent skip for a guaranteed publish failure. The skip is logged instead.--max-warnings=0) + verify:design-system clean. 39 new tests across location-web-get-current-position.test.ts, capture-current-position.test.ts, api-web-fetch-timeout.test.ts, and a live-loop case in one-location-agent-page.test.tsx. Full suite 29 failed / 3547 passed vs baseline origin/main 29 failed / 3508 passed — failing sets byte-identical, +39 passing, zero regressions. Two tests were confirmed to FAIL against pre-fix code: the acquisition promise had still not settled after 60 simulated seconds, and the multi-recipient case gave expected [ 'key_b' ] to include 'key_d' (the second recipient was never reached).timeout. It does not start until the permission prompt resolves, so it cannot bound an unanswered prompt. Any getCurrentPosition/watchPosition wrapper needs its own setTimeout deadline or it can hang forever.for … await loop over recipients needs the try/catch INSIDE it. With stable ordering, one bad item starves every item after it on every tick, deterministically. Prefer Promise.all + per-item catch.Error instances, never { code: n } literals. Several layers here branch on instanceof Error, and a literal silently degrades every user-facing message.git merge-base --is-ancestor <redesign-sha> origin/main for each redesign commit → must be false. The website deploys from main only (manual workflow_dispatch); the mobile branch (pushed as ankit/iOS-UI-rephrased-v01) never reaches it.lib/feed/use-feed-actionables.ts builds "Needs you" from independent lanes, and a pending connection request arrives on two of them. The connections lane reads ConnectionsService.listRequests({direction:"incoming"}) directly; the consent lane reads the Consent Center's pending surface — which folds those same requests in via _incoming_connection_request_entries (consent-protocol/hushh_mcp/services/consent_center_service.py:1242), calling that very same ConnectionsService.list_requests(direction="incoming") and setting id/request_id to the connection request's own id. Same row, fetched twice. The lanes namespace their keys differently (consent:<id> vs connection:<id>), so nothing deduped them.entry.kind === "connection_request" — the connections lane owns them, because it carries the inline Confirm/Decline and correctly routes scoped requests to the Consent Center Review (a feed shortcut must never turn an omitted scope decision into a silent decline). No coverage lost: both lanes resolve to the same backend source. Removed the then-unreachable connection_request branch in consentSummary.hushh-webapp/lib/feed/use-feed-actionables.ts; regression test hushh-webapp/__tests__/lib/feed/feed-actionables-connection-dedup.test.tsx. Branch fix/feed-duplicate-connection-request.expected [ …(2) ] to have a length of 1 but got 2 and expected 'consent:conn-req-1' to be 'connection:conn-req-1' — reproducing the screenshot's stacking order. tsc --noEmit 0 errors, eslint --max-warnings=0 clean.pending surface is a union, not a disjoint feed — anything added there may already have its own lane.LISTEN consent_audit_new (consent-protocol/server.py:507) and Postgres NOTIFY fans out to all listeners, but the FCM send (api/consent_listener.py:586) has no advisory lock or leader election. Prod runs max-instances=5. Two warm instances → two identical banners. Unconfirmed in logs; not fixed here.mapReady is true, and the full-bleed fallback only renders when status === "unavailable". Seeing NEITHER, over a blank canvas, means the component believed the map was ready over a native view that no longer existed. That combination is the whole diagnosis.@capacitor/google-maps addresses every native map by its string id alone. GoogleMap.create() cannot be cancelled — it waits ~200 ms before the native view is registered (node_modules/@capacitor/google-maps/dist/esm/map.js:116) — and destroy() resolves to maps.removeValue(forKey: id) (ios/Sources/CapacitorGoogleMapsPlugin/CapacitorGoogleMapsPlugin.swift:170) with no check that the caller is the instance that registered it. So an unmount inside a create window left an abandoned create running: the old cleanup found mapRef.current still null and destroyed nothing, the abandoned create registered its map anyway, and its cancelled branch then destroyed MAP_ID — which by then belonged to the map the NEXT mount had created. Reachable from ordinary navigation: both map routes are keyed key={auth.userId ?? "anonymous"}, and the Location hub's check-in tile pushes /one/location/map?action=check-in, which the map route immediately redirects to /one/location/check-in — same component, same MAP_ID, fresh create over an in-flight one.closeMap goes through the lock too. Also wait for a non-zero box before handing the element over — the plugin only retries a zero width (map.js:137), so a container measured mid-layout at full width and zero height had that zero baked into the native frame permanently.hushh-webapp/components/one-location/location-immersive-map.tsx; tests in hushh-webapp/__tests__/components/location-immersive-map.test.tsx ("native map lifecycle"). Branch fix/map-blank-first-view, PR #5235.expect(registry.has("one-location-private-map")).toBe(true) returns false while the screen reports data-map-ready="true", and the event order comes back create:1, create:2, destroyed:1 instead of create:1, destroyed:1, create:2. Wider one-location surface: 64 failed/131 passed vs 64 failed/129 passed on origin/main, failing sets identical by name. tsc --noEmit clean, eslint clean, verify:design-system OK.@capacitor/google-maps addresses a native map by string id with no instance identity, and create() cannot be cancelled. An unmount inside the create window left the cleanup with createdMap still null (destroying nothing) while the abandoned create registered its map anyway and then destroyed the id the NEXT run had claimed.colorScheme, derived from next-themes' resolvedTheme, which is undefined on first render and resolves after hydration — well inside the plugin's ~200 ms create window. The container is also key={colorScheme}, so the element is swapped underneath at the same moment. Probably under-reported only because the picker is a once-per-user onboarding step.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.hushh-webapp/components/one-location/onboarding/location-picker-map.tsx; test hushh-webapp/components/one-location/onboarding/__tests__/location-picker-map-native.test.tsx. Branch fix/picker-map-blank, PR #5242.expected false to be true — no native map registered under the id), passes fixed. 23/23 onboarding, 61/61 across onboarding + lifecycle + immersive map. tsc and eslint clean.isNative() to true also drives waitForLaidOutBox, which polls a real timer, and jsdom measures every element as 0x0 — so the task hangs and nothing reaches the bridge. Stub the layout wait in component tests; its budget is covered in __tests__/lib/one-location/native-map-lifecycle.test.ts.DARK_MAP_STYLES from maps-config; vitest throws on an undefined export, so run 2 died while building the create config, before the bridge — presenting exactly like the bug under test. Symptom to recognise: the component logs that it is calling create, but the mock never records the call.lib/testing/location-map-demo.ts) is correct and explicit opt-in — PR #5297 already fixed it. The leak was the lane: .github/workflows/deploy-uat.yml passed _LOCATION_MAP_DEMO=true. The CI guard in scripts/ci/runtime-contract-check.sh only ever checked deploy-production.yml, so UAT was never covered. UAT is not a private sandbox — it is the frontend every reviewer, tester and demo audience sees.<li> text. Looks like a list you can act on, answers nothing.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.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.<Sheet modal ...>, so Radix painted fixed inset-0 z-[711] touch-none across the whole screen. The map header is z-30, so the X, Locate and Check-in stayed perfectly visible through a 22%-black blur and swallowed every tap.false, and the CI guard now bans _LOCATION_MAP_DEMO=true in every workflow, not just production. Sharing rows → 44px buttons that fly to the person's pin when the sharing is mutual and open the Location screen when it is not. Header → phones get controls on row one at natural widths with Sharing on its own row beneath (grid-cols-[auto_minmax(0,1fr)] + sm:grid-cols-[1fr_auto_1fr]), so sm+ is unchanged. Everyone → never disabled, states when nobody shares yet, still moves the camera. Check-in sheet → modal={false} + new showOverlay={false} opt-out on SheetContent, refuses to dismiss on outside interaction (panning the map is not a dismissal), and caps its height below the header; the sheet close keeps its 32px look with after:-inset-1.5 extending the hit region to 44px..github/workflows/deploy-uat.yml, scripts/ci/runtime-contract-check.sh, components/one-location/location-immersive-map.tsx, components/one-location/nearby-check-in/nearby-check-in-sheet.tsx, components/ui/sheet.tsx. Tests: __tests__/components/location-immersive-map.test.tsx (+6), new __tests__/components/location-map-chrome.contract.test.tsx (8). Branch fix/location-map-ios-hardening, PR #5305.verify:design-system clean. Full suite 27 failed / 3969 passed vs a 28 / 3954 baseline on clean origin/main — zero new failures, identical failing set minus one flake. All 14 new tests fail on the code they replace (verified by stashing the source). Rendered evidence: real Chromium, real compiled Tailwind, verbatim header markup at 320/360/375/390/430/768/1280 — before FAILs at all five phone widths, after PASSes at all seven. On-device logged-in repro still needs a QA login (auth + vault gated).deploy-uat.yml's substitutions overrode a "false" Cloud Build default, and a CI guard scoped to one workflow file is not a guard.modal sheet is a full-screen touch blocker over the HOST screen's chrome, whatever the host's z-index. z-[711] touch-none beats z-30 and every other app layer. Any sheet anchored to a live surface (a map, a canvas, a video) must be modal={false} + showOverlay={false} and must refuse outside-dismissal, or the host's own controls become visible-but-inert. Symptom to recognise: "the X is not working" while the X is clearly on screen.grid-cols-[1fr_auto_1fr] is a phone-hostile way to centre something. It pads the narrow outer column to match the wide one; on a 375px header that is ~95px of wasted space and a truncated action word. Centre with it from sm up only, and give phones content-sized columns.Pin your entrance / Address details flow read as a rectangular pad hovering above the app rather than a sheet attached to the bottom of the screen, with obvious grey strips down both sides. It had no drag-to-dismiss and no grabber. It asked for a PIN / postcode that was already visible in the line directly above the box, plus a Building colour and a "Fill from this address" checkbox governing them.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.addressLineEditedRef.current = true followed by setAddressLineValue("") when the state was already "" makes React bail out of the re-render, so the ref flip is never reflected and the detected address snaps straight back. Select-all-delete did visibly nothing, and the "no address" branch was therefore unreachable once a lookup had succeeded.h-[min(56vh,420px)] on a flex item whose only child fills it with h-full — min-content height 0 — so a viewport short enough to overflow the pane resolved the overflow by deleting the map.bottom-[var(--kb-height)]; capping it at 92dvh - kb subtracts the keyboard a second time. On an iPhone SE with a 300px keyboard that left 222px for a 61px header, a 36px minimum body and a 129px footer, and the pinned footer was pushed 3px past the bottom of its own sheet, where overflow-y-hidden clipped it silently.components/ui/sheet.tsx at full viewport width, flush with the bottom edge, home indicator inside the footer's padding, step rail doubling as the grabber; above 640px it stays the centred dialog. addressLineValue became one nullable state. shrink-0 on the map. max-h-[calc((100dvh-var(--kb-height,0px))*0.92)]. PIN derived at save time from the line as it stands; Building colour no longer requested but still passed through for places that carry one; the checkbox deleted with the fields it governed.components/one-location/onboarding/save-location-modal.tsx, save-location-sheet-layout.ts, location-picker-map.tsx, components/ui/sheet.tsx (two additive props), e2e/save-location-sheet.layout.spec.ts, new __tests__/components/shared-sheet-consumers.contract.test.tsx. Branch fix/save-location-native-sheet, PR #5626, issue #5619.max-w narrower than a phone is invisible ON a phone. Every review happened at 375–430px, where a 420px cap does nothing. The band that breaks is the one nobody tests: 421–639px. If a surface is a bottom sheet, assert its rendered box is the full viewport width at 480, 540 and 639 too — a class-string assertion cannot see this, and neither can a screenshot taken on an iPhone.setState(x) when the state is already x does not re-render, so a ref flipped in the same handler never takes effect. The useRef(edited) + useState("") pair is a common prefill idiom and it silently breaks the one case that matters: clearing the field. Use a nullable state (null = untouched) instead.h-[…] is not a floor. A map, a canvas or a video that fills its box with h-full has a min-content height of 0, so the first short viewport deletes it. shrink-0.--kb-height twice. A surface pinned with bottom-[var(--kb-height)] is already above the keyboard; its height budget is 100dvh - kb, so cap it as a fraction OF THAT, never as Ndvh - kb./one/location/map with a blank band between the top of the screen and where the map appears to start, reported as an unfinished-looking layout: "the map starts below a large blank/white region". Reads exactly like a header strip, a reserved toolbar height, or a safe-area padding bug.h-[100dvh] and the renderer is absolute inset-0 — the box fills the screen at every width and height, and a browser layout contract now proves it (e2e/one-location-map-consent-panel.layout.spec.ts asserts renderer top <= 0.5px). What did not fill it was the picture inside the box.256 * 2^zoom CSS px tall and paints its own near-white backdrop everywhere else. The pre-consent camera was hardcoded { lat: 20, lng: 0 }, zoom: 2. That world is 1024 px tall, and latitude 20 sits 453.9 px down it — so the camera can cover at most 453.9 * 2 ≈ 908 px of viewport height. Past that, the difference is backdrop.location-immersive-map.tsx documents that the @capacitor/google-maps web shim implements setPadding as fitBounds(bounds, padding) — a zoom-OUT, which snapped z2 → z1 and produced bands at both edges. That was fixed by not calling setPadding on web. It could not fix a starting zoom that was already too low for the box, which is this bug.lib/one-location/map-world-view.ts derives the neutral view from the box the renderer was actually handed (element.getBoundingClientRect() after waitForLaidOutBox): pick the smallest zoom whose world covers the longest edge, then slide the centre latitude only as far as it must to fit. Prefers latitude 20 and only moves when the arithmetic forces it — 932 px slides the latitude ~4°, 1080 px raises the zoom. A resize/rotation re-fits, but only while renderer consent is pending; once there is a real position the camera belongs to focusSelfPoint.lib/one-location/map-world-view.ts, components/one-location/location-immersive-map.tsx. Tests: __tests__/one-location/map-world-view.test.ts. Branch fix/your-map-full-viewport-avatar-marker, PR #5628, issue #5622.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.projectToMapBox, the pattern the name pills already use). Worth knowing for any future marker work: @capacitor/google-maps exposes only tintColor per marker, and its iconUrl on iOS accepts an https: URL or a bundled public/ asset (ios/Sources/CapacitorGoogleMapsPlugin/Map.swift:693) — not a data: URI, and .svg is rejected outright. A composed avatar therefore cannot be a renderer marker at all; it has to be HTML in the WebView, which composites above the native map view the same way the pills do.Check out now button was large destructive red, and the 500 m radius on the map was drawn as a heavy black outline over a strong grey disc — the strongest visual object on a screen whose whole subject is the map.location-immersive-map.tsx built the overlay with fillColor: "var(--app-accent-surface)" and strokeColor: "var(--app-accent)" and handed it to @capacitor/google-maps. Neither renderer resolves CSS custom properties. The web shim passes the object verbatim into new google.maps.Circle, which silently falls back to its OWN defaults on an unparseable colour — that is the black ring and grey disc, and it is Google's styling, not a choice anyone made. iOS is worse in a quieter way: Circle.swift does UIColor(hex: hexColor) ?? UIColor.blue, so on device it drew flat blue and nobody could tell it apart from the accent it was supposed to be. strokeOpacity/strokeWeight DID apply, which is why the ring looked deliberate. Fixed by resolving --app-accent off document.documentElement to a real hex at draw time (regex-guarded, with a #007aff fallback, so a token that resolves to another var() or to nothing during first paint never reaches the bridge) — and this keeps the alternate accent theme (#d4a574) working, which a hardcoded hex would not.components/ui/sheet.tsx renders the grabber as a full-width sticky top-0 z-[5] flex h-11 touch-none band occupying y=0..44. The close button is a 32px circle at top-4 whose hit region is widened to the platform's 44px minimum by after:-inset-1.5, so it starts at y=10 — three quarters of its target lies under a higher-z, touch-none sibling that swallows the tap. Present on every bottom sheet that shows both, not just this one. Fixed with z-10 on SheetPrimitive.Close.0 badge beside an empty state that already said nobody, and an auto-refresh promise. Cut to 49 (68%), seven strings removed outright.Check out now was variant="destructive", solid --app-destructive with white on it. Checking out flips status, destroys the anchor key, keeps the row and can be redone in three taps — a reversible lifecycle step. Now secondary.nearby-check-in-sheet.tsx (panel + copy), location-immersive-map.tsx (mapAccentHex(), fill 0.06 / stroke 0.35 at 1.5px), components/ui/sheet.tsx (z-10), new check-in-panel-layout.ts (class strings shared with the browser spec so it cannot measure a replica). Branch fix/check-in-panel-plain-copy-redesign, PR #5632, issue #5621.dragDismiss={false}, which switched the gesture off AND took the grab handle with it — a phone bottom sheet with no affordance to put it away. It cannot use body drag: it owns an inner scroller, so its own scrollTop is pinned at 0 and every downward swipe over the place list would engage the dismissal. I had added a dragDismiss: boolean | "handle" union for this; PR #5626 had already landed contentDragDismiss for the identical reason, so mine was deleted on merge. See GOTCHA 3.@capacitor/google-maps (circles, polylines, marker tints) must be a literal. The failure is silent and asymmetric: web falls back to Google's defaults, iOS falls back to .blue, and BOTH look intentional. Sibling of the setPadding divergence already documented in B27 and capacitor-web-shim-divergence. Resolve tokens with getComputedStyle(document.documentElement).getPropertyValue(...) and guard the result.z-[5] touch-none band above a corner button eats it. Related to B25's "close X that did nothing under check-in", but a DIFFERENT cause: that one was the modal scrim over the host screen, this one is the sheet's own drag handle over its own close button. Symptom is identical from the user's side — a visible control that ignores most taps — so check both layers before concluding it is the scrim again.dragDismiss: boolean | "handle" and contentDragDismiss: boolean are the same feature; main's is better (a separate boolean beats an overloaded prop, and it shipped with useSheetDragHandle()). Two branches touching one shared primitive in the same week is normal here — read what landed on main for that file BEFORE designing an extension to it, not at merge time.aria-invalid:ring-destructive/20 in its base class, so className.not.toContain("destructive") passes on a fully red button. Assert the FILL token, or better, compare the computed background against a destructive button rendered beside it in a real browser.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.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).
consent-protocol / hushh-pda-uat): ENVIRONMENT=uat, secret HUSHH_UAT_PHONE_TEST_CODE=000000, secret HUSHH_UAT_PHONE_TEST_NUMBERS (comma/;/newline-separated allowlist, ref :latest → durable across CI deploys). Handler: consent-protocol/api/routes/account.py /api/account/phone/uat-test/{start,confirm}. Frontend: ApiService.*UatPhoneTestVerification, AccountIdentityService.*UatTestPhoneVerification.!isNative) — fixed in 0c19110c2: lib/firebase/auth-context.tsx startPhoneVerification now tries the UAT-test start on native too, and confirmPhoneVerification routes uat-test-phone: verification ids to the UAT-test confirm before the real Firebase native link. Prod-safe (backend returns ineligible when not UAT/allowlisted → falls through to real Firebase)..codex/skills/repo-operations/references/admin-release-sop.md and the current UAT phone-test
runbook; use the governed workflow so secret rotation, revision provenance, and rollback evidence
remain auditable.Onboarding + agent chat + profile use the luxury palette: onyx #0A0908, champagne gold #D4AF6A (dark), deep gold #9C7434 (light), cream #F4EAD6, ivory #FAF6EE, ink #17130C, positive #12A150, destructive #C94F44. No indigo/blue (#5E5CE6/#8583ff) on redesigned mobile surfaces. The /one dashboard uses the 2a pastel blocks. See [[hushh-research-mobile-branch]].
Frequently asked questions
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).
The source record exposes this install command: npx skills add https://github.com/hushh-labs/hushh-research --skill ".claude/skills/mobile-bug-log". Inspect the command and pinned source before running it.
Static rules flagged read-files in the source; the page lists the matching lines and excerpts.
Alternatives
coreyhaines31/marketingskills
When the user wants to plan, design, or implement an A/B test or experiment, or build a growth experimentation program. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," "hypothesis," "should I test this," "which version is better," "test two versions," "statistical significance," "how long should I run this test," "growth experiments," "experiment velocity," "experiment backlog," "ICE score," "experimentation program
oaustegard/claude-skills
Generate hierarchical _FEATURES.md files that describe what a codebase DOES from a user/consumer perspective, anchored to source symbols via tree-sitting. Supports large complex codebases through feature-driven decomposition into sub-feature files. Uses a multi-pass synthesis: orientation → detail → overview rewrite. Use when someone says "what does this do", "document features", "feature inventory", "_FEATURES.md", or needs to understand a codebase's purpose before modifying it. Complements tre
narrative-io/narrative-skills-marketplace
Translate a fuzzy analytical question into a rigorous investigation plan. Interrogates the ask, grounds the plan in the available data dictionary, applies analytical best practices, and produces a structured brief of query specifications for a downstream query-writing skill. Plans, does not write SQL. Use when: "why did X drop", "is there a relationship between A and B", "who are our highest-value customers", "what's driving the change in Y", "investigate this trend", "design an analysis for", "
event4u-app/agent-config
Use BEFORE writing or editing any non-trivial UI — inventories components, design tokens, shadcn primitives, and reusable patterns into state.ui_audit. Hard gate for the ui directive set.