| name | mobile-bug-log |
| description | Running reference log of every iOS/mobile bug diagnosed + fixed on the `mobile` branch (symptom → root cause → fix → files/commit), plus the recurring build/runtime gotchas that keep biting. Use when the user asks "what bugs did we fix", "known issues", "list the resolved bugs", "why did X break", when a similar symptom reappears (check if it's a known regression before re-diagnosing), or before shipping a mobile build (re-verify the gotchas). ALWAYS append a new entry here whenever another mobile bug is resolved. |
| argument-hint | [optional: keyword to filter, e.g. 'keyboard' | 'backend' | 'navigation'] |
| allowed-tools | Read Grep Glob Bash(git log*) Bash(git show*) Bash(grep*) Bash(xcrun simctl*) |
| paths | [".claude/skills/mobile-bug-log/**","hushh-webapp/**"] |
Mobile bug log (hushh One iOS)
The single source of truth for iOS/mobile bugs we've diagnosed and fixed on the mobile branch. Read this FIRST when a mobile symptom reappears — it's probably here. When you fix a new one, append an entry (symptom, root cause, fix, files, commit).
Related memory: [[hushh-research-mobile-branch]], [[hushh-research-ios-build]]. Diagnosis tool: xcrun simctl spawn <UDID> log show --last 120s --predicate 'process == "App"' (the WKWebView console + native network log — this is how most of these were found).
🔴 RECURRING GOTCHA #1 — Native build must point at the UAT backend (NOT localhost)
- Symptom: app can't check vault status ("We could not check your Vault status right now"), and — because guards can't verify state — the top-bar back button and navigation die on EVERY screen. Looks like a UI bug; it isn't.
- Root cause:
.env.local has NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 (local dev backend). capacitor.config.ts bakes that into every plugin's backendUrl, and the web layer fetches ${NEXT_PUBLIC_BACKEND_URL}/db/vault/check. On the simulator that resolves to 127.0.0.1:8000, which is Connection refused (NSURLError -1004) unless a local backend is running there. Confirmed via the sim log showing http://127.0.0.1:8000/db/vault/check ... Connection refused.
- Fix: build the sim against the reachable UAT backend. Inline override (does NOT touch the user's
.env.local):
export NEXT_PUBLIC_BACKEND_URL="https://consent-protocol-f2gsa4kfsq-uc.a.run.app" before npm run cap:build && npm run cap:sync:ios. Confirm after boot: xcrun simctl spawn <UDID> log show --last 20s --predicate 'process=="App"' | grep -oE "consent-protocol|127.0.0.1:8000" should show consent-protocol, never 127.0.0.1:8000. (UAT url lives in .env.uat.local.)
- Verify reachable first:
curl -s -o /dev/null -w "%{http_code}" https://consent-protocol-f2gsa4kfsq-uc.a.run.app/ → 200.
- EVERY sim build must set this or localhost:8000 gets baked back in.
🔴 RECURRING GOTCHA #2 — sim shuts down / stale install
- If
simctl install fails with Unable to lookup in current state: Shutdown, the sim isn't booted. xcrun simctl boot <UDID>; open -a Simulator; sleep 6 then install/launch. The build itself likely already succeeded (** BUILD SUCCEEDED **) — don't rebuild, just boot + install the existing .app.
- Build gotchas (see [[hushh-research-ios-build]]): Node 22 for
cap sync; NODE_OPTIONS=--max-old-space-size=8192 for cap:build (OOM); GoogleService-Info.plist required; DerivedData in /tmp/hushh-ios-dd (iCloud FinderInfo breaks codesign).
🔴 RECURRING GOTCHA #3 — next build HANGS at 0% CPU when the repo is in iCloud-synced ~/Documents
- Symptom:
npm run cap:build (or even next dev, or a plain cp/mv) sits at Creating an optimized production build ... for 20+ min with 0% CPU and .next frozen. Looks like a webpack/worker deadlock or OOM. It is neither.
- Root cause: the working copy lives under
~/Documents (iCloud "Desktop & Documents"). macOS FileProvider (fileproviderd/bird/cloudd) mediates and throttles every file read/write in that tree — and a webpack build does hundreds of thousands of tiny I/Os. With an active iCloud sync backlog the daemons churn at 60–90% CPU while the build blocks on I/O (0% CPU). Not RAM (CPU idle), not disk space, not the webpackBuildWorker flag (toggling it doesn't help), not offloaded files. Confirmed: build/dev/cp/mv ALL crawl; a cp -Rc of the repo did 143 MB in 13 min (~9 h projected).
- Fix: get the repo OUT of the iCloud domain.
mv out is itself throttled, so the fast path is: git clone <origin> ~/hushh-research-fast (network → non-iCloud disk), copy the few gitignored files (.env.local, .env.uat.local, ios/App/App/GoogleService-Info.plist), npm ci, then build there. On a non-iCloud path the SAME build compiles in ~30s and runs on the sim. Permanent fix: keep the dev repo outside ~/Desktop/~/Documents (or disable iCloud Desktop & Documents sync). See [[hushh-research-keyboard-desktop-inert]] / memory.
- Also: clear
/tmp/hushh-ios-dd if xcodebuild errors file '…FIRAnalytics+OnDevice.h' has been modified since the module file … was built (stale precompiled-module cache from a prior repo path).
Resolved bugs
B1 — Set up One back button bounced back to /one/setup (native)
- Symptom: top-left back (←) on
/one/setup did nothing (bounced straight back).
- Root cause: back button primes "setup resolved" +
router.push('/one'), but OneOnboardingGuard (components/kai/onboarding/kai-onboarding-guard.tsx) only early-exits when the vault is unlocked (unlockedOnStandardKaiRoute = isVaultUnlocked && !onOnboardingRoute). Vault-locked → runs the async check → on native it transiently reports setup incomplete → router.replace('/one/setup').
- Fix: native fast-path in the guard — if
isNativePlatform() && !onOnboardingRoute && readOneSetupCompletionHint(uid) === true, clear cookies + setChecking(false); return; (trust the in-session hint, skip the bounce). Web unchanged (native-gated).
- File:
components/kai/onboarding/kai-onboarding-guard.tsx. Note: this only matters once GOTCHA #1 is fixed (guards need a reachable backend to run at all).
B2 — Agent chat header slid under the status bar when the keyboard opened
- Symptom: opening the composer keyboard pushed the "One" chat header up under the Dynamic Island / status bar.
- Root cause:
@capacitor/keyboard is NOT installed and there's no keyboard-resize handling. iOS WKWebView doesn't shrink 100dvh; it scrolls the whole fixed inset-0 overlay up to reveal the focused input → the fixed header drifts under the status bar.
- Fix:
visualViewport keyboard-pin in components/agent/agent-popover-provider.tsx — track keyboard height (window.innerHeight - visualViewport.height - offsetTop), shrink the mobile sheet to h-[calc(100dvh-var(--agent-kb-height))] (top-pinned, bottom-auto), and window.scrollTo(0,0) to undo any iOS scroll. Composer sits above the keyboard, header stays below the status bar. Mobile-scoped (max-sm).
B3 — Agent chat looked "web-forced": header/composer overlapped safe areas, no back button, blue accents
- Fix (commit
a6cb290b1): popover header height includes --app-safe-area-top-effective; composer adds --app-safe-area-bottom-effective; ink-glass back button replaces the top-right X; blue text-primary → luxury gold #9C7434/#D4AF6A, SF Pro type, iOS rounded cards. All isPopover/max-sm-scoped so desktop/web unchanged. File: components/agent/agent-chat-workspace.tsx.
B4 — "Setup tiles do nothing" was NOT a routing/export bug
- Ruled out: all
/one/setup/<capability>/index.html ARE exported in the static build + app bundle; tiles use relative routes (no external/uat URLs). The real cause of dead navigation was GOTCHA #1 (backend down → guards error). Don't chase the tiles; check the backend + guard first.
B6 — "Finish setup" dashboard bar vanished after entering a couple of setup items (Gmail not set up) — QA/TestFlight blocker
- Symptom: on
/one, the "Finish setup — X% done" bar disappeared once the user opened "Set up One" and tapped a couple of capability tiles, even though Gmail (and others) were not set up.
- Root cause:
app/one/setup/[capability]/one-onboarding-capability-client.tsx handlePrimary (commit d83ed1890) resolved the account-wide master gate PreVaultUserStateService.syncKaiSetupState({ completed: true }) whenever Continue forwarded into a hard-gated /one/* surface (gmail, email→/one/kyc, location, pkm, connected-systems) — done only to stop OneOnboardingGuard bouncing. But setupCompleted === true also makes resolveFinance (lib/services/capability-setup-state-service.ts) report finance = completed pre-vault, so entering ONE capability flipped enough tiles non-actionable that hasSetupRemaining = some(isCapabilitySetupActionable) (one-dashboard-page.tsx) went false → bar gone. NOT a symptom of the palette/UI work.
- Fix (commit
ee35b9e12) — decouple "entered a capability" from "finished ALL setup":
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.
- Net: master gate now resolves only on a genuine finish (hub Skip/Continue →
syncKaiSetupState), so the bar stays until the user actually finishes.
- Verified: typecheck + lint +
verify:design-system + capability-client/routes/dashboard/auth-gate vitest all pass; iOS build succeeds + runs on sim against UAT. NOTE: full logged-in on-device repro (login → dashboard → tap tiles → bar persists → hub Skip/Continue → bar hides) still needs a human login (vault passphrase or an allowlisted UAT test number) — the unit tests encode the exact behavior change.
- Same root cause exists on
main (web) — a separate PR can port it if the web team wants it.
B7 — "Personal Data" (PKM) screen showed a red HTTP Error 404: {"detail":"No data found for user"} for fresh users (native only)
- Symptom: dashboard → "Personal Data" (
/one/pkm, "Saved Intelligence", Readable tab) on a fresh/no-data account showed a red error banner HTTP Error 404: {"detail":"No data found for user"} + 0 domains / 0 saved details / 0 memory cards / Last updated Unavailable. iOS/TestFlight only.
- Root cause — web↔native parity gap. The backend (
consent-protocol/api/routes/pkm_routes_shared.py) raises 404 "No … data found for user" for a data-less user as a NORMAL empty condition. The web branches of getMetadata/getEncryptedData/getDomainData in lib/services/personal-knowledge-model-service.ts already map response.status === 404 → emptyMetadata/null. But the native branches call the Capacitor plugin (HushhPersonalKnowledgeModel.*) directly; the iOS Swift (ios/App/App/Plugins/PersonalKnowledgeModelPlugin.swift executeRequest) + Android Kotlin plugins reject("HTTP Error <status>: <body>") for ALL non-2xx, so the 404 throws and pkm-natural-panel.tsx paints bootstrapError. (getDomainManifest is NOT affected — it uses ApiService.apiFetch with its own 404→null; the bug is exactly 3 native call sites, not 4. getAvailableScopes is out of scope: no UI consumer + web also throws on 404, i.e. already symmetric.)
- Fix (commit
bfc8bae3d), single file lib/services/personal-knowledge-model-service.ts: added private static isNativeNoDataError(error) = /^HTTP Error 404\b/.test(message) (anchored to the plugin prefix so 401/403/408/429/5xx, Network error:, JSON parsing error: all keep throwing — no false positives; the plugins reject with a message only, no .code). Wrapped the 3 native calls in try/catch: getMetadata → on no-data result = this.emptyMetadata(userId) (flows through the normal MEDIUM-ttl cache, mirroring web); getEncryptedData/getDomainData → on no-data return null (no cache write, mirroring web). Non-404 errors rethrow. Fixes natural panel + explorer + data-manager + agent-lab transitively.
- Verified: typecheck + lint + new
__tests__/services/pkm-native-no-data.test.ts (9) + 45 PKM regression tests pass; a 9-agent investigation workflow + a 4-lens adversarial review workflow (control-flow / caching-staleness / matcher-precision / parity-completeness) returned SHIP AS-IS, 0 confirmed defects; iOS build runs on sim vs UAT. Full logged-in on-device repro (fresh account → Personal Data → no banner, clean empty state; then add a memory → Refresh → data appears; negative: force a 500 → error still surfaces) needs a human login.
- Same gap exists on
main/web is already correct there (web branch handles 404); this is a native-only parity fix. iOS = immediate target; Android has the identical plugin so it benefits from the same TS fix.
B8 — Chat composer disappears under the keyboard + header showed a generic Bot icon (iOS, QA raised ~20×)
- ⚠️ The KEYBOARD half of this fix was WRONG and is SUPERSEDED by [B9]. The
resize:"none" + chat-only --agent-kb-height approach only handled the open chat and left EVERY other input screen (register-phone OTP, vault) with zero keyboard avoidance — QA re-reported it. B9 replaces it with global resize:"native". The header-logo half (Bot → /one-quiet-emoji.png) is still valid.
- Symptom 1 (critical): opening the keyboard in the "One" chat pushed the composer ("Message One…" + mic + send) UNDER the keyboard — user couldn't see what they typed. The earlier visualViewport keyboard-pin (B2) did NOT reliably fix it. Symptom 2: header next to "One" showed a lucide
<Bot/> "random chatbot icon" instead of the hushh One mark.
- Root cause (keyboard): NATIVE WKWebView scroll-drift, not CSS.
@capacitor/keyboard was NOT installed, and capacitor.config.ts ios.scrollEnabled: true left the native UIScrollView free to auto-scroll the whole webview up to reveal the focused input. Because the chat sheet is position: fixed, that native scroll dragged the entire overlay (header under the status bar, composer under the keyboard). The JS window.scrollTo(0,0) in the visualViewport hack resets the DOM scroll, not UIScrollView.contentOffset, so it could never win. 100dvh never shrinks (contentInset "never", no interactive-widget — which iOS/WKWebView does NOT support anyway).
- Fix (robust, native-layer — commit
01050387a):
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).
- Why scrollEnabled:false is safe:
html,body have no overflow-y (globals.css:34-35); all scrolling is in the inner [data-app-scroll-root] overflow-y-auto (providers.tsx:386); top-app-bar listens on that inner root. Verified directly, not just from the plan.
- Verified: typecheck+lint+design-system pass; iOS build SUCCEEDED with CapacitorKeyboard compiled/linked +
KeyboardPlugin registered at runtime; sim runs vs UAT. 9-agent investigation + 3-lens adversarial review workflows. Full logged-in on-device repro (open chat → focus composer → composer stays above keyboard) needs a human login (auth+vault gated). Fix is native-config-level = correct by construction.
- Note: this is the pattern for ANY future keyboard-avoidance need — the app now has
@capacitor/keyboard (resize none) + native scroll off + inner-overflow scrolling. Reuse --agent-kb-height / keyboardWillShow rather than new visualViewport hacks.
B9 — Keyboard hides the input on EVERY screen (register-phone OTP, chat, …) — the real, app-wide fix (supersedes B8's keyboard half)
- ⚠️ The
resize:"native" decision here is SUPERSEDED by [B21]. native shrinks the whole WKWebView frame every keyboard-animation frame, recomputing every dvh/svh ~60×/sec → severe vault jank; it was reverted (commits 26453505b→78cf3a94f) back to none, which silently re-broke avoidance app-wide. B21 is the current standard: keep resize:"none" (no frame resize → no jank) AND add a global event-driven --kb-height avoidance layer. Do NOT flip resize to native or body again.
- Symptom: the on-screen keyboard covered the focused input on multiple screens — confirmed on the phone-verification/OTP (
app/register-phone) AND the One chat composer. B8's chat-only fix did NOT solve it app-wide.
- Root cause: B8 set
Keyboard.resize:"none" (+ ios.scrollEnabled:false). resize:"none" means the WKWebView frame NEVER shrinks → 100dvh stays full-screen and bottom inputs sit behind the keyboard. The ONLY avoidance code was the chat popover's --agent-kb-height subtraction (gated to the open chat), so register-phone/vault/every other input screen had zero avoidance. resize:"none" was chosen in B8 purely to protect the chat's manual subtraction — a one-component concern that broke the whole app.
- Fix (standard iOS, global — commit
515347b8a):
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).
- KEY LESSON (⚠️ CORRECTED by B21):
the STANDARD keyboard fix is Keyboard.resize:"native" — that recomputes every dvh 60×/sec and janks the vault. The correct standard for THIS app is resize:"none" + a global event-driven --kb-height layer (B21). What still holds from B9: do NOT use per-screen --agent-kb-height/visualViewport hacks scattered per component — keyboard avoidance must be ONE global mechanism.
- Verified: rg
agent-kb-height|agent-kb-open = 0 hits; typecheck+lint+design-system pass; iOS build + resize:"native" in synced json + sim runs vs UAT; 6-agent read-only investigation (all concur native). On-device logged-in repro (OTP + chat + vault inputs above keyboard, incl. iPhone SE) needs QA login (auth+vault gated).
B10 — Back button from dashboard-opened Email/Location/Consent/Marketplace went to Profile (not dashboard)
- Symptom: on
/one dashboard, tap Email / Location / Consent Guardian / Information Marketplace → surface opens → top-bar back goes to Profile instead of the dashboard. Gmail / PKM / Connected-Systems were fine (the clue).
- Root cause: the top-bar back button uses a computed
backHref from resolveTopShellBreadcrumb() (lib/navigation/top-shell-breadcrumbs.ts), NOT router.back(). For ONE_KYC/ONE_LOCATION/ONE_MARKETPLACE it hardcoded backHref: ROUTES.PROFILE (these surfaces were historically reached from Profile panels) and never read ?from. The dashboard (one-dashboard-page.tsx) navigated with the bare cap.href (no origin). CONSENTS was origin-aware but also fell to a profile panel with no marker. Gmail/PKM/Connected already resolved to ONE_HOME, so they weren't broken.
- Fix (commit
9b5706196) — origin-aware ?from, mirroring the existing Gmail pattern:
- 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_MARKETPLACE now backHref: normalizeInternalRouteHref(searchParams?.get("from")) || ROUTES.PROFILE, and the leading crumb is "One" (from dashboard) vs "Profile" (fallback). CONSENTS needed no change (already reads from).
- Why not a blanket
backHref → ONE_HOME flip: Profile also links to these surfaces (app/profile/page.tsx), so origin-aware preserves Profile→surface→back. No-from → Profile fallback (unchanged).
- Verified: typecheck + lint + design-system +
top-shell-breadcrumbs (11) + one-dashboard-page (updated href assertions) + top-app-bar.contract = 24/24; iOS build; on-device (logged-in sim): dashboard → Email → back → dashboard. Pre-existing uncommitted normalizeBreadcrumbPathname/KAI_IMPORT changes in these files are compatible (query stripped before route match; from read from searchParams).
- PATTERN: top-bar back is breadcrumb-driven (
backHref), not history. New surfaces reachable from multiple origins must read ?from (see Gmail) and callers must tag the origin — don't hardcode a single parent.
B11 — Welcome ask-bar (logged-out) + "Log in"→"Get Started" + Access & Sharing back
Three small mobile UX/nav fixes (commit 909ea793d):
- A — agent ask-bar showed on the logged-out welcome (
/) ("backdoor guy under the CTA"). components/agent/agent-bar.tsx unmountBar gated on routes but never auth (deliberate old comment L219-231). Fix: || (isHomeRoute && runtime?.tier === "anon_onboarding") — hides on the anon welcome only; signed-in users are redirected off / so the bar still shows on /one + all authed surfaces. NOTE: the runtime exposes tier (AgentAccessTier), NOT signedIn — use tier === "anon_onboarding" (the anon-on-/ tier).
- B — CTA "Log in" → "Get Started" on the welcome (
components/onboarding/IntroStep.tsx L165). onLogin → /login → AuthStep (Firebase social handles new + returning), so "Get Started" is accurate. Refreshed the stale "Get started removed" comments.
- C — back button on "Access & Sharing" (
/profile?panel=access) did nothing. Top-bar back (top-app-bar.tsx ~L643) did router.push(backHref) — a same-pathname, query-only nav. The profile page closes its panels ONLY via router.replace(href, { scroll: false }) (profile/page.tsx updateProfileView "replace" / popProfileStack), so a plain push is a no-op on device. Fix: in the back handler, for normalizedPathname === ROUTES.PROFILE && (panel||detail) → router.replace(backHref, { scroll:false }) (mirrors popProfileStack); else router.push(backHref, { scroll:false }). /consents (cross-pathname) already worked.
- KEY: profile panels are query-state (
?panel/?detail) driven by useSearchParams → the profile page's own close uses router.replace(.., {scroll:false}). Any code navigating profile panels MUST use that same replace+scroll:false, not a bare push.
- Verified: typecheck + lint + design-system;
top-app-bar.contract (updated to assert the new router.push(..,{/router.replace(..,{ back-nav contract), top-shell-breadcrumbs, one-dashboard-page = 24/24; iOS build. On-device: A + B verified on the logged-out welcome (no ask-bar, "Get Started"); C (auth-gated) unit-contract-covered + mirrors the proven panel-close.
B12 — Setup-hub-opened capability back went to Profile (should retrace to the hub)
- Symptom: from the "Set up One" hub (
/one/setup), tapping an item (Email/Gmail/Location/Marketplace) → capability opens → top-bar back → Profile (or /one), not back to the hub. User rule: "jaise aaya waise wapas" (retrace: hub → item → back → hub → back → dashboard).
- Root cause: the setup-hub handoff (
one-onboarding-capability-client.tsx) forwarded gated surfaces with a bare literal ?from=setup. The breadcrumb reads from via normalizeInternalRouteHref, which rejects "setup" (no leading /) → null → falls to the hardcoded default (kyc/location/marketplace → PROFILE, gmail → ONE_HOME). The same "setup" string was a valid guard bypass (kai-onboarding-guard.tsx params.get("from") === "setup") — one token doing two jobs, only the guard tolerated a bare value. (This was a side effect of the B6/finish-setup fix which introduced ?from=setup.)
- Fix (commit
81db93823) — make the marker a valid path so it works for BOTH the guard and the breadcrumb:
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.
- Result: setup hub → any capability → back → /one/setup; hub → back → dashboard (retrace). Dashboard-opened (
?from=/one) + Profile-origin unchanged.
- PATTERN / gotcha:
from markers MUST be valid internal paths (leading /). normalizeInternalRouteHref silently drops non-path values → breadcrumb falls to the wrong default. Don't invent bare-string markers that the breadcrumb + guard interpret differently.
- Verified: typecheck+lint+design-system; capability-client / top-shell-breadcrumbs (added setup-hub-origin regression lock) / auth-gate / top-app-bar.contract / dashboard = 32/32; iOS build. On-device: hub → Email → back → hub.
B13 — QA re-reported B10/B11 → BUILD STALENESS (not a regression) + residual ?from gaps on other origins
- Symptom: QA re-sent the exact B10/B11 report (verbatim) — dashboard → Email/Location/Consent Guardian/Marketplace → back → Profile; "Access & Sharing back doesn't work".
- Root cause = build staleness, NOT a code regression. A 10-agent read-only investigation (6 probes → adversarial verify → synthesize) + an on-device test proved the committed fixes are CORRECT and work on a fresh build (dashboard → Email → back → dashboard ✓). The fix commits (
9b5706196 07-06 02:51, 909ea793d 07-06 03:31) landed AFTER the last uploaded build; Capacitor bakes the web bundle into the binary (iosScheme:"App", no OTA), so the fix never shipped to the tester. Ruled out (adversarially): static-export query stripping, per-page back overrides, /consents vault redirect. Access & Sharing's router.replace fix (B11) is confirmed correct — no change needed.
- Residual finding (same bug class, fixed): other entry points open the capability screens WITHOUT
?from, so their top-bar back also falls to Profile//one. Top-bar back is breadcrumb-driven — every caller must tag origin.
- Fix — build bump (
93b0cd9ca) + residual ?from tagging (cccf6aab7):
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}.
- Deferred (documented, conscious):
consent-sheet-controller.tsx — its open/re-sync useEffect rewrites from (delicate; its own closeConsentSheet already returns to origin); app-bottom-nav.ts — tab-switch semantics (nav stays visible), a UX call not a bug; notification/FCM deep links (fcm-service.ts, one-location/notifications.ts) — cold-start, no origin.
- KEY GOTCHA: a committed fix that isn't in the archived/uploaded build has NOT shipped. Capacitor has no OTA — bump the build number and re-Archive/upload. "Works on my fresh sim build" ≠ "shipped to the tester." Always confirm the tested build's commit vs the fix commit before re-debugging a "still broken" report.
- Verified: typecheck + lint + design-system; breadcrumbs (agent-origin regression added) / dashboard / top-app-bar.contract / command-executor / consent-sheet-route = 59; iOS build 40 installed; on-device dashboard → Email + Location → back → dashboard.
B14 — Theme toggle dead on iOS (worked on web)
- Symptom: light/dark switching worked on the website but did nothing in the native iOS app.
- Root cause: NOT a bug — iOS was deliberately pinned to light ("daylight" ship):
app/providers.tsx forced light via next-themes on Capacitor.getPlatform()==="ios" AND ios/App/App/Info.plist set UIUserInterfaceStyle=Light. The toggle persisted to localStorage but next-themes never applied it.
- Fix (fix/voice-intelligence-and-native-ui): removed both pins;
ThemeProvider attribute="class" defaultTheme="light" enableSystem on all platforms. StatusBarManager already syncs native SystemBars from resolvedTheme. Contract test updated: __tests__/components/providers-theme-contract.test.ts now asserts NO forced theme anywhere + no UIUserInterfaceStyle in Info.plist.
B15 — Ripple stayed "pressed" after press-and-hold on option tiles (iOS)
- Symptom: long-press on an option tile left the md-ripple pressed overlay stuck until the next interaction.
- Root cause: WKWebView long-press triggers the system callout/text-selection path, which cancels the pointer stream (
pointercancel swallowed) before md-ripple sees pointerup. Nothing set -webkit-touch-callout:none/user-select:none on actionables.
- Fix:
app/globals.css — actionable roles (a, button, [role=button|radio|tab|menuitem|option]) get -webkit-touch-callout:none; -webkit-user-select:none; user-select:none. Inputs keep selection. Also pinned pointer-events:none on md-ripple.morphy-md-ripple itself (defense vs the historical first-tap-swallow, see material-ripple.tsx comment).
B16 — Bottom nav sometimes needed a double tap (iOS)
- Symptom: first tap on a bottom-bar tab intermittently did nothing; second tap worked. Correlated with scrolling.
- Root cause: the nav translates off-screen via
--bottom-chrome-progress (scroll-hide). A tap landing mid-animation had its target translate away between pointerdown and pointerup → no click event. Wrapper also holds pointer-events-none while hidden.
- Fix:
snapKaiBottomChromeVisible() in lib/navigation/kai-bottom-chrome-visibility.ts — onPointerDownCapture on the nav group freezes the chrome at its resting position when progress > 0, so the tap target is stationary at pointerup. File: components/navbar.tsx.
B17 — Search bubble rendered as an oval on iOS (circle on web)
- Symptom: the round Search button next to the bottom pill was visibly non-circular in the native app.
- Root cause: geometry relied on
aspect-square + self-stretch + h-auto w-auto; WKWebView resolves aspect-ratio against a stretch-derived flex cross size as indefinite → width fell back to content → oval.
- Fix: explicit
h-[52px] w-[52px] self-center (matches the stacked pill min-height). Hover styles moved behind [@media(hover:hover)] so first touch can't latch sticky hover. File: components/navbar.tsx.
B18 — Live location never updates on the recipient; "View" opens nothing (Recipient key unavailable for this location share.)
- Symptom: a recipient of a One Location live share taps View and gets
Recipient key unavailable for this location share.; the map never opens and the location never updates. The recipient just sees a dead View button.
- Root cause — device-bound E2E key + custom-scheme IndexedDB eviction. One Location is E2E-encrypted with a per-recipient ECDH P-256 keypair whose private key lived ONLY in the recipient device's IndexedDB (
lib/one-location/encryption.ts, DB hushh-one-location-keys, keyed by Firebase user.uid). Decrypt throws whenever the on-device key is absent or its keyId != envelope.recipientKeyId. iOS runs under iosScheme: "App" where WKWebView IndexedDB is evicted / not persisted across launches → the key is lost → ensureLocationRecipientKey mints a NEW keyId. The backend freezes recipient_key_id on the grant and hard-enforces envelope.recipientKeyId == grant.recipient_key_id (consent-protocol/.../one_location_agent_service.py create_grant/store_encrypted_envelope), so one rotation permanently poisons the grant: the recipient can't decrypt old envelopes AND the sender's publishes get rejected (LOCATION_ENVELOPE_KEY_MISMATCH) → live updates stop. Both the map (LocalMapPreview) and the silent setInterval poll only render when decryptedPoints[grant.id] is set (successful decrypt), so the failure looks like "View does nothing." (userId mismatch ruled out — bootstrap + decrypt both use user.uid.)
- Fix (two parts):
- Durable key (stops the rotation) —
lib/one-location/encryption.ts: mirror the private key (as JWK; the pair is generated extractable) into the native Keychain via HushhKeychain (@/lib/capacitor), gated by isNative(), key one_location_recipient_key:${userId}, accessible: afterFirstUnlock, non-biometric (no Face ID on the silent poll). readStoredKey now: IndexedDB → if empty & native, restore from Keychain with the SAME keyId and repopulate IndexedDB. IndexedDB stores privateKeyJwk (portable) with legacy CryptoKey fallback + migration. Exported RECIPIENT_KEY_UNAVAILABLE_MESSAGE. Precedent: lib/services/vault-bootstrap-service.ts.
- Self-heal on mismatch —
app/one/location/page.tsx: added per-grant grantViewErrors state; on RECIPIENT_KEY_UNAVAILABLE_MESSAGE in viewGrantEnvelope (manual + silent poll) it re-registers the current key (bootstrapCurrentUserLocationRecipientKey) and shows an inline "the secure key changed — ask them to share again" notice + Ask to share again button (handleAskReshare → OneLocationService.requestAccess to the grant owner). Same treatment in the redesign chat view_envelope handler (components/one-location/redesign/use-location-chat.ts). Once the owner re-shares, create_grant snapshots the now-durable key and updates resume.
- Files:
lib/one-location/encryption.ts, app/one/location/page.tsx, components/one-location/redesign/use-location-chat.ts; new test lib/one-location/__tests__/encryption.test.ts (+fake-indexeddb devDep). Branch fix/live-location-share-not-updating-recipient.
- Verified: typecheck + lint clean; new encryption test (4) covers ensure→wipe IndexedDB→restore-from-Keychain-same-keyId→decrypt; full one-location + chat + notifications suites pass (140 tests). On-device logged-in repro is auth+vault gated (needs QA login) — roadmap: 2 UAT-test-number accounts (Sender/Recipient in One Network), Sender shares → Recipient View shows a live map that updates on the poll (sim Freeway Drive); relaunch Recipient app → still decrypts (key restored from Keychain, same keyId); legacy poisoned grant → inline "ask to share again" → Sender re-shares → live updates resume.
- GOTCHAS: (1) for any E2E/at-rest secret that must survive on iOS, do NOT rely on WKWebView IndexedDB/localStorage under
iosScheme:"App" — it gets evicted; back it with the native Keychain (HushhKeychain) and restore on cold start (same pattern as the vault default secret). (2) Test the encryption module in node vitest env (// @vitest-environment node) — jsdom hands out cross-realm ArrayBuffers that Node SubtleCrypto rejects.
- FOLLOW-UP — cross-device consistency (B18b): durability/self-heal above still failed when the SAME account was signed into web + iPhone (the demo case): the backend keeps one
active recipient key per user, so the last device to register rotates the other out and the non-active device can't decrypt. Fix = vault-synced shared keypair: the ECDH private key is encrypted with the user's vaultKey (AES-256-GCM, identical on every device after unlock) and stored server-side as an opaque encrypted_private_key_jwk blob; every device fetches its own blob (via list_state.myRecipientKey, owner-only) and decrypts → same keyId everywhere, no rotation. Reuses the PKM/vault pattern (lib/vault/encrypt.ts + HushhVault.encryptData). Backend: migration 083, register_recipient_key(encrypted_private_key_jwk=…) (COALESCE-preserving), list_state self-key read (never leaked in recipients). Client: ensureVaultSyncedRecipientKey in encryption.ts (remote-blob-wins → local-backfill → generate), key-bootstrap.ts fetch+register, vaultKey threaded through unlock-warm/page/chat, self-heal now attempts a vault-synced restore+retry before "ask to share again". Requires a backend deploy (migration+route) to UAT/prod before the client relies on it; degrades gracefully if myRecipientKey is absent. Tests: encryption.test.ts cross-device cases (7) + test_one_location_routes.py owner-only-blob assertion. Same branch.
B19 — Acquisition date in Add Holding could not be filled on iOS
- Symptom: in Finance → Manage Portfolio → Add/Edit Holding, tapping the "Acquisition Date" field (or its calendar icon) did nothing on iOS; the date could never be entered on device. Worked on desktop Chrome.
- Root cause: the field was a read-only text display plus a HIDDEN 1px
type="date" input (h-px w-px opacity-0 pointer-events-none, tabIndex=-1, aria-hidden), opened programmatically via showPicker() / .focus() / .click(). iOS WKWebView only opens the native date wheel from a DIRECT user tap on the date input itself — programmatic showPicker()/click() on a hidden input are ignored (showPicker also throws NotAllowedError without a user gesture, and Safari/WKWebView never supported it for date inputs until very recent versions). So the tap landed on the decorative text input and the real input never received a user gesture.
- Fix: invert the layering — the REAL
type="date" input is now a full-size invisible overlay (absolute inset-0 h-full w-full opacity-0 cursor-pointer + full-bleed ::-webkit-calendar-picker-indicator) stacked above a purely decorative pointer-events-none display div (shows MM/DD/YYYY text + calendar icon). Every tap lands directly on the date input = a genuine user gesture = iOS opens its native wheel. Removed the programmatic handleOpenDatePicker path entirely.
- File:
components/kai/modals/edit-holding-modal.tsx. Test: __tests__/components/edit-holding-modal-acquisition-date.test.tsx (asserts overlay contract: full-size, no pointer-events-none, no aria-hidden/tabIndex=-1, change updates display, future date rejected).
- GOTCHA: for ANY picker-backed field on iOS (date/time/select), never hide the real input and proxy taps to it programmatically — WKWebView requires the user gesture to land ON the input. Use the invisible full-size overlay pattern instead.
B20 — Sim shows STALE UI after code changes (Xcode upgraded, iOS platform missing)
- Symptom: rebuild +
simctl install + relaunch (even after simctl uninstall and a clean web CLEAN=1 build), but the simulator keeps showing an OLD version of the screen — changes never appear on device. Looks like a WebView cache, but clearing it does nothing.
- Root cause (two compounding): (1) Xcode was upgraded (here to 26.3, SDK iOS 26.2) but the iOS simulator platform was not installed — only an old runtime (iOS 18.2) existed.
xcodebuild then finds no simulator destination and fails with error: Unable to find a destination matching … {platform:iOS Simulator} / iOS 26.x is not installed. Please download and install the platform. (2) The failure was masked because the build was invoked as xcodebuild … | tail — a pipeline's exit status is tail's (0), so the failing build looked successful. simctl install then re-installed the previous/stale App.app every time.
- How to confirm:
xcodebuild -project ios/App/App.xcodeproj -scheme App -showdestinations lists NO platform:iOS Simulator device rows (only an ineligible "Any iOS Device" needing the new iOS). And grep the built app vs the synced source: grep -rl "<a NEW string>" /tmp/hushh-ios-dd/Build/Products/Debug-iphonesimulator/App.app/public/_next/static/... vs ios/App/App/public/... — if the App.app has a different/older page-*.js chunk than the synced public/, the native build never bundled your changes.
- Fix:
xcodebuild -downloadPlatform iOS (installs the current iOS simulator platform, ~8 GB). Afterwards -showdestinations shows the existing iPhone 16 (18.2) again, so you can build for the SAME sim (no new device / no data loss beyond a normal reinstall). Then build without piping to tail (or check ${PIPESTATUS[0]}) and verify ** BUILD SUCCEEDED **.
- GOTCHA 1: never invoke
xcodebuild … | tail/| grep and trust the exit code — the pipe hides build failures. Redirect to a log (&> /tmp/xcbuild.log; echo $?) and grep for BUILD SUCCEEDED.
- GOTCHA 2: after any Xcode major upgrade, the simulator platform must be re-downloaded (
xcodebuild -downloadPlatform iOS) before run-ios-sim/launch.sh can build for the sim.
B21 — Keyboard hides inputs AGAIN (vault unlock, OTP, chat, sheets) — breaks the native↔none flip-flop for good
- Symptom: on TestFlight, focusing any bottom-anchored input (the "Enter vault key" field on the Unlock Your Vault gate, OTP, One chat composer, bottom sheets) left it hidden BEHIND the on-screen keyboard — user couldn't see what they typed. QA re-reported the same class B8/B9 supposedly fixed.
- Root cause — a documented flip-flop, not a new bug. B9 (
515347b8a) set Keyboard.resize:"native" (frame shrinks → avoidance works). That janked the vault (native recomputes every dvh/svh ~60×/sec during the keyboard animation), so it was reverted: 26453505b (→body) then 78cf3a94f (→none). resize:"none" leaves the WKWebView frame full-height AND there was no JS/CSS fallback (B9 had deleted the --agent-kb-height machinery), so every bottom input sat behind the keyboard. Net: native=avoidance-but-jank, none=no-jank-but-hidden — the two kept ping-ponging. Extra drift: ios/App/App/capacitor.config.json still read "native" (stale; cap sync hadn't run after the flip).
- Fix (the loop-breaker — keep
none, add event-driven avoidance):
- NEW
components/keyboard-inset-manager.tsx — mount-once native/mobile bridge (mirrors status-bar-manager.tsx, returns null, mounted in app/providers.tsx outer Providers next to <StatusBarManager/>). Native: dynamic-import @capacitor/keyboard, keyboardWillShow/Hide set --kb-height on <html> + toggle .kb-open — once per transition, NOT per frame → zero dvh thrash / zero jank. Also a delegated focusin (capture) net that scrollIntoView({block:"center"}) the focused field (only when .kb-open). Mobile-web fallback via thresholded visualViewport (>120px). Desktop/laptop = firewall: binds nothing, --kb-height stays 0px → every consumer is a no-op.
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.
- Fixed/bottom-anchored primitives consume
--kb-height: components/ui/drawer.tsx bottom DrawerContent → bottom:var(--kb-height) + max-h:calc(80vh - var(--kb-height)) (lifting the whole sheet is the ONLY reliable avoidance under resize:none — the viewport never shrinks, so scrollIntoView alone can't clear the keyboard for a bottom-anchored sheet); components/ui/sheet.tsx bottom variant likewise; components/ui/dialog.tsx centered → max-h -= --kb-height and shift up by --kb-height/2.
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.
- Why NOT translate/offset the vaul
DrawerContent via transform: vaul animates open/close with transform: translate3d; a transform offset fights it. Use bottom/max-h (position + layout) which compose with vaul's transform, and are only ever non-zero when a real keyboard is up.
- KEY LESSON: for THIS fixed-overlay Capacitor app, keyboard avoidance =
resize:"none" (no frame resize → no jank) + ONE global event-driven --kb-height var that fixed/bottom surfaces lift by. Never flip resize to native/body (jank), never scatter per-component visualViewport hacks (B8), and never ship none without the --kb-height layer (B9-revert). Every consumer must be a no-op at --kb-height:0 so desktop/web stays 100% inert (user requirement — the website must not do mobile-keyboard things).
- Scope: landed for BOTH web (→
main → UAT frontend) and mobile (→ mobile → TestFlight); the avoidance layer is web-safe (inert on desktop, active on mobile web via the visualViewport fallback).
- Files:
components/keyboard-inset-manager.tsx (new), app/providers.tsx, app/globals.css, components/ui/{drawer,sheet,dialog}.tsx, components/vault/vault-unlock-dialog.tsx, components/agent/agent-popover-provider.tsx (comment), app/register-phone/page.tsx, capacitor.config.ts (+ synced ios/App/App/capacitor.config.json). Branch fix/ios-keyboard-avoidance.
- Verified: typecheck (0 errors) + lint (0) + design-system pass; compiled CSS confirmed to contain all
--kb-height rules (calc(80vh - var(--kb-height,0px)) etc. — Tailwind v4 normalizes the calc operators, so no _ escaping needed). On-device (iPhone 17 sim, iOS 26.5, UAT backend): the Unlock Your Vault hard-gate with the software keyboard OPEN shows the "Enter vault key" input + Unlock button fully ABOVE the keyboard (was hidden behind it before) — the exact reported bug, fixed. No vault jank (resize:none). Other surfaces (OTP/chat/drawer/dialog) share the same mechanism + verified CSS. NOTE: the local next build --webpack hangs when the repo lives in an iCloud-synced ~/Documents folder (FileProvider throttles all build I/O to a crawl / 0% CPU) — build from a non-iCloud path (see GOTCHA below).
B5 — Redesign leak check (always run when "it shows on the website")
- How to verify no leak:
git merge-base --is-ancestor <redesign-sha> origin/main for each redesign commit → must be false. The website deploys from main only (manual workflow_dispatch); the mobile branch (pushed as ankit/iOS-UI-rephrased-v01) never reaches it.
🧪 QA test phone numbers (UAT, fixed OTP 000000)
The app has a backend UAT-test phone path so QA can log in without SMS, with a FULL prod-like experience (real backend/vault/app — only the OTP is bypassed).
- Backend (already live on
consent-protocol / hushh-pda-uat): ENVIRONMENT=uat, secret HUSHH_UAT_PHONE_TEST_CODE=000000, secret HUSHH_UAT_PHONE_TEST_NUMBERS (comma/;/newline-separated allowlist, ref :latest → durable across CI deploys). Handler: consent-protocol/api/routes/account.py /api/account/phone/uat-test/{start,confirm}. Frontend: ApiService.*UatPhoneTestVerification, AccountIdentityService.*UatTestPhoneVerification.
- Native was gated off (
!isNative) — fixed in 0c19110c2: lib/firebase/auth-context.tsx startPhoneVerification now tries the UAT-test start on native too, and confirmPhoneVerification routes uat-test-phone: verification ids to the UAT-test confirm before the real Firebase native link. Prod-safe (backend returns ineligible when not UAT/allowlisted → falls through to real Firebase).
- Add numbers (Secret write + Cloud Run deploy — auth config, run manually / outside auto-mode):
P=hushh-pda-uat
CUR="$(gcloud secrets versions access latest --secret=HUSHH_UAT_PHONE_TEST_NUMBERS --project=$P)"
NEW="+1XXXXXXXXXX,+1YYYYYYYYYY"
printf '%s' "${CUR},${NEW}" | gcloud secrets versions add HUSHH_UAT_PHONE_TEST_NUMBERS --data-file=- --project=$P
gcloud run services update consent-protocol --region=us-central1 --project=$P --update-secrets=HUSHH_UAT_PHONE_TEST_NUMBERS=HUSHH_UAT_PHONE_TEST_NUMBERS:latest
Use real-looking numbers (team convention; not 555). 2026-07-05: 20 numbers +19898989898…+19898989879 prepared for QA (secret write pending user run — auto-mode blocked it).
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]].