Prod Release Sprint 157 — mobile app fixes · app-locale sync · peanut-shaper badge (2026-08-24) - #2804
Open
jjramirezn wants to merge 96 commits into
Open
Prod Release Sprint 157 — mobile app fixes · app-locale sync · peanut-shaper badge (2026-08-24)#2804jjramirezn wants to merge 96 commits into
jjramirezn wants to merge 96 commits into
Conversation
Support agents have no visibility into a user's live verification state, so they guess where a user is stuck. Adds a support-facing snapshot to the Crisp agent sidebar (session:data), derived entirely from the two backend read-models already on /get-user (`capabilities`, `identityVerification`) — no backend change and no new provider-state interpretation on the client. New agent-only fields: identity_status, email_on_file, verification_gates, verification_rails, failure_reason, pending_actions. Threaded through all three Crisp sinks: web widget (setCrispUserData), the proxy iframe (which receives the whole CrispUserData over the postMessage handshake), and native Capacitor (SupportDrawer). Sidebar only — the user's own composer (message:text) is never touched, so internal reason codes and rail ids stay out of the user's view. Closes #2360.
`shouldIgnoreError` only ever inspected `exception.values[0]`. Sentry orders that array root-cause-first, so for any error carrying a `cause` the wrapper sits at the end — and `fetchWithSentry` always sets `userError.cause`. The `alreadyReported` filter added in 10ee160 to stop double-counting fetch failures has therefore been inert for its own motivating case ever since: PEANUT-UI-SNP (the ServiceUnavailableError wrapper) kept being reported alongside PEANUT-UI-QEY (the timeout it wraps). Sentry confirms the shape — `error.type` on those events reads "Error, ServiceUnavailableError". Scan every value's type and message, and collect extension stack frames from every value rather than just the first. Also suppress Capgo's background-updater chatter, which captureConsoleIntegration promotes to ~95 events/day on native. `disable_auto_update_under_native` and checksum mismatches stay reported: those mean OTA is actually broken for a build, not that one download hiccuped.
Native reported 970 Sentry events in 24h, 691 of them level:info. Almost all of that is instrumentation written to watch the CapacitorHttp → direct-fetch switch (PEANUT-UI-R44). That switch has shipped; the probes have not. - native-canary: five captureMessage calls per app launch (~550 events/day), plus five extra API round-trips on every cold start, to answer a question we already answered. Deleted. - legacy-cookie native transport engaged / native http fallback engaged: once-per-session census notes for the same migration. Deleted. - onesignal subscription snapshot: still useful, but it's a state fact, not a fault — moved to PostHog as notification_subscription_snapshot. The failure variant stays in Sentry, since failing to read the state is a real error. Transport behaviour is unchanged; the fallback and prefer-native paths still work exactly as before, and their tests now assert the paths stay silent.
Same shape as the fetch wrappers. useZeroDev classifies the raw WebAuthn failure, captures it with full context, and throws a curated user-facing PasskeyError — and for a plain user cancel it deliberately captures nothing on web. Three call sites re-report that wrapper: Landing and JoinWaitlist call Sentry.captureException on it directly, and GuestLoginModal console.errors it. The result is a second, context-free event, and LOGIN_CANCELED showing up at error level despite the deliberate silence — PEANUT-UI-QRW and PEANUT-UI-R20, 19 events yesterday.
useZeroDev classifies a WebAuthn failure, captures the raw error with full context, and throws a curated PasskeyError for display. For a plain user cancel it deliberately captures nothing on web — "Cancel saved no state". Four call sites undid that: - InvitesPage fired `void handleLoginClick()` with no catch, so cancelling the prompt became an unhandled rejection. Now caught, and the curated message is surfaced the way every other login entry point surfaces it. - GuestLoginModal console.error'd the wrapper, which captureConsoleIntegration turns into an event. - Landing and JoinWaitlist called Sentry.captureException on the wrapper — a second, context-free copy of an error already reported at the throw site, and the reason LOGIN_CANCELED showed up at error level at all. Landing and JoinWaitlist still report anything that isn't a PasskeyError, so an unexpected failure in the login path is not silenced. PEANUT-UI-QRW and PEANUT-UI-R20: 19 events yesterday, all expected outcomes.
…dentity `capabilities` is optional on /get-user during the capability migration. Deriving gates over the empty fallback state made every operation read `needs-identity`, which a support agent cannot tell apart from a genuinely unverified user — the exact misreading this snapshot exists to prevent. Report an empty `gates` when the read-model is absent; a read-model that is present but empty still derives normally, since needs-identity is the truth there.
5343f1d landed in the same function. Both changes keep their meaning: - Critical-flow captures still bypass every group but userRejected, and the early return now sits ahead of the Capgo check so a money-flow event can never be dropped by it either. - The chain scan is narrowed to exception TYPES only. Class names are exact, so matching them across the chain can only catch our own wrappers. Scanning chained MESSAGES the same way would suppress more, not less — which is the failure 5343f1d fixed, where viem's "Details: Failed to fetch" ate real payment errors through the networkIssues group. values[0].value keeps the matching reach it had before. Added a guard test for that boundary: a wrapper whose message contains a noise pattern is still reported.
Android's Capacitor WebView cannot host a third-party subframe. BridgeWebViewClient.shouldOverrideUrlLoading hands EVERY navigation to Bridge.launchIntent without checking request.isForMainFrame(), and launchIntent cancels the load for any host outside the app origin that isn't listed in server.allowNavigation — which capacitor.config.ts does not set. The Bridge ToS iframe therefore painted pure white inside our verification chrome, no signedAgreementId postMessage ever arrived, and Bridge kept the terms pending. All 39 post-deploy ToS confirmations came from web sessions; the one native user in the set is the failure. Sumsub KYC itself is unaffected on native because SumsubKycWrapper routes Capacitor to the Cordova SDK, not an iframe. allowNavigation was deliberately NOT the fix: it is baked into the binary (every installed app stays broken until a store release), and on android it also registers the host as a WebViewLocalServer authority — its HTML would be proxied through handleProxyRequest with Capacitor's bridge JS injected — and widens the androidBridge trusted-origin set. Android now opens the ToS in the system browser (@capacitor/browser, already in the binary since April) and treats `browserFinished` as a `returned` signal — "the user came back", not "the user accepted". /users/bridge-tos-confirm re-reads has_accepted_terms_of_service from Bridge, so the detour needs no postMessage; confirmBridgeTosAndAwaitRails now returns that verdict and the callers use it to tell a real acceptance from an abandoned one (no phantom completion, no unearned KYC_TOS_ACCEPTED). When the caller observed no acceptance and Bridge says no, the helper stops after the confirm retry instead of arming the 30s submission window and polling rails that will never change. iOS is untouched — its navigation delegate already gates the same detour on targetFrame.isMainFrame, so the iframe works there. The web iframe path is unchanged. Ships over the air; no store release needed.
A fast unmount or visibility change while Browser.addListener was still resolving removed the listener but still opened the Custom Tab — the ToS could pop after its owning flow had closed. Late arrivals now drop the listener and stop.
Before a request exists the QR already encodes the profile payment link
for the entered amount (/{username}/{amount}USDC), so there is no reason
to keep it blurred until the create button is clicked. It now unblurs on
a positive amount and re-blurs when the amount is cleared or zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQHgKufMZzqzwCviP4gXKs
On native the pull refetches via react-query instead of reloading the page, so nothing on screen blinks and the gesture reads as having done nothing. Give the indicator the full state sequence instead: - pulling: the indicator scales in and the arrow rotates toward upright, flipping at the release threshold with a light haptic on the crossing - refreshing: a readable spinner arc (was a thin quarter-circle path) - done: green checkmark with a pop + success haptic, held briefly, and the content fades back in so the screen visibly re-renders Also restyles the indicator to the app's brutalist look (black border + hard shadow), guards Element.animate for WebViews that lack it, honours prefers-reduced-motion for the content fade, and clears pending timeouts on unmount. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zVw46ZCvXYp7b4dpXvJiq
Going /profile → "Exchange rates and fees" → "Try it!" lands the user in the add-money or withdraw root, depending on their balance and unlocked regions. Both roots deliberately reset to /home on back rather than calling router.back(), because their own sub-pages push back to the root and back() ping-pongs there. That is right for a tab-bar entry, but it strands anyone who arrived from another screen: back never returns to the widget they came from. Add a `returnTo` query param the caller sets and the flow roots honour, so the origin travels with the navigation instead of being guessed at the destination: - new `withReturnTo` / `readReturnTo` helpers — same-origin only (reusing sanitizeRedirectURL), and a target pointing at the current page is dropped, since re-pushing the page you are on is a back button that does nothing. - the exchange-rate CTA passes its own path *and* query string, so back restores the currency pair and amount the user was looking at. - add-money and withdraw check it before falling back to /home. The send-flow /send branch and the in-page steps (country list → method selection, amount → method selection) keep priority, so back still unwinds one step at a time. Tests: unit coverage for the helpers (including the off-origin and self-referential rejections) plus back-navigation cases on both flow roots and the exchange-rate CTA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbALh4dJvcBUAinjnwQ53q
…-app-fixes Brings in the pull-to-refresh feedback work (arrow arming, spinner, success checkmark + haptics, content settle) along with the current main base.
Reverses the iOS gate from 4564b40 and adds an iOS-only copy layer that presents the referral programme as cashback. Web and Android are unchanged: every pre-existing catalog string is byte-identical to dev, and the two new UI elements are behind isIOSNative(). Hiding the programme while the backend kept accruing — and while referral.reward.earned kept pushing "You earned $X! <name> joined Peanut with your invite" to the same devices — was the worse position. Guideline 2.3.1 treats hidden-but-discoverable features as grounds for removal. The programme will be disclosed in Notes for Review instead. The old citation was also wrong: appStoreCompliance.ts cited 3.1.5(ii), which is Mining. The clause about offering currency for encouraging downloads is 3.1.5(v). appStoreCompliance.ts is deleted along with its seven call sites — the /rewards and /rewards/invites route guards, the home pill, the profile row, both invite carousel CTAs, the surprise-claim treatment and the receipt points row. Removing the route guard also fixes the referral.reward.earned deep link, which pointed at a guarded route and bounced iOS users to /home. The cross-chain withdraw gate is untouched: it lives in underMaintenance.config.ts, rests on 3.1.5(iii) (Exchanges, which needs per-region licensing), and multi-chain swaps are the strongest crypto-app signal we ship. useAppTranslations wraps useTranslations and prefers an `iosCopy.<key>` override when one exists, falling back to the base string otherwise. Overrides sit inside the namespace they belong to, so call sites keep their existing keys and only the hook name changes. The block is called iosCopy, not ios, because profile.backup.steps.ios is already content — a namespace whose own content sat under `ios` would have had every key silently redirected. Platform is read at render time; the Capacitor bridge is absent during prerender. 45 overrides per full locale plus voseo deltas for es-AR: - rewards -> cashback. "Cashback" as a loanword is the standard term in both LatAm markets. - "used Peanut" -> "paid with Peanut", "the more they use" -> "the more they pay". Ties the money to a transaction rather than a signup; downloading is what 3.1.5(v) names. - A four-step "How cashback works" card on /rewards. The earn instruction used to be a half-sentence on the lifetime total; as its own card it reads better and states the process without pinning a dollar to a person. - Dropped "friends & their friends" from qrPay.claim.inviteQrDescription and "contribute towards your points forever" from the sticker copy — the only two places the UI stated the transitive structure. Push notification copy is deliberately unchanged. PushChannel targets by userId and OneSignal fans one notification out to every subscription a user has, so there is no per-platform copy path and a single notification cannot say two different things to the same person's devices. Tests cover both platforms, the fall-through, all four locales, and two catalog invariants: every override shadows a real base key (a typo would otherwise silently never resolve), and iosCopy stays distinct from the existing ios content key.
…o-dev-20260820-2 chore: back-merge main → dev (2026-08-20 release + #2760)
Removing the route guard took the file's only useEffect with it, and no-unused-vars is an error rather than a warning — the single eslint error in the run, on top of the 65 pre-existing warnings.
feat(rewards): iOS-only cashback copy, referral surfaces restored
…cel drawer Four unrelated mobile-app fixes. **Home tab has no active state.** WalletNavigation compared `usePathname()` to the nav href with `===`. The native build sets `trailingSlash: true`, so the pathname is `/home/` there and the comparison never matched — every active state in the app was silently lost, not just Home. Added `isSameRoute()` next to the other route helpers and routed the mobile and desktop nav through it. **Withdraw's final screen loads twice.** `withdraw/page.tsx` called `React.lazy()` inside the render body for its two native `?country=` views. That hands back a fresh, unresolved lazy on every render, so each re-render re-suspended: React hid the rendered view and swapped in the Suspense fallback (null) until the import re-resolved a microtask later. The screen blanked and loaded again — and the success view triggers a re-render itself when it invalidates the transactions query. Hoisted both to module scope. The regression test asserts the view isn't display:none straight after a re-render; it fails against the old code. **Stray rule under the receipt's last row.** The details card underlines every row and drops the rule on the last one, but `shouldHideBorder` only reaches rows the receipt renders itself. `BridgeDepositInstructions` expands into rows of its own and doesn't take the flag, so the pending bank-deposit receipt ends on a rule sitting directly on the card border. Rows with a second runtime gate (a token icon still being fetched, a rate missing from the payload) fail the same way — the config calls them last, the DOM never gets them. Let the container decide with `[&>*:last-child]:border-b-0`. **Cancel-link confirmation is a modal.** Replaced it with a bottom drawer. It opens from two places — the transaction details drawer and the send-link success page — so `Drawer` gained a `nested` prop that switches to vaul's `NestedRoot`; a plain Root inside a Root double-applies the background scale and fights over the scroll lock. That also retires the `!z-[10]` shuffle the parent drawer needed to get out of the modal's way. Verified in Chromium that the content fits without scrolling from 320x568 up, and that the drawer's `max-h-[80vh] overflow-auto` takes over below that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbALh4dJvcBUAinjnwQ53q
…mobile-app-fixes Brings in five mobile fixes: - back navigation after the exchange-rate widget CTA (?returnTo) - home tab active state on the trailing-slash native build - withdraw's final screen re-suspending and loading twice - stray dashed rule under the receipt's last row - cancel-link confirmation converted from a modal to a drawer
The top strip was forced black in bd9a1b3 so it would stop flipping between black and beige above the pink beta feedback ribbon. That ribbon is now hidden on iOS (e56254e), and the underlying inconsistency was really a sizing/source problem, fixed in 9b3384e by reading Capacitor's natively measured insets. Recolor only — the safe zone keeps h-safe-top, so the natively measured inset on Android 15+ and the env() fallback everywhere else are unchanged. Style.Light pairs dark status-bar icons with the light strip.
fix(mobile): batch of native app fixes
…cation-error-uzgmxr fix(claim): stop blaming the sender, and offer a retry when one would work (dev)
Artwork and manifest entry for the Help Shape Peanut interview badge (catalog entry lands in peanut-api-ts). PNG like OFFRAMP_USER — the vectorized SVG can replace it later without a code change. TASK-21713
…rame fix(native): accept Bridge ToS via the system browser on android
feat(badges): PEANUT_SHAPER asset + share line
users.locale on the BE was never written — notification emails could not know the user's language. LocaleSync (below AppIntlProvider + AuthProvider) sends the resolved app locale to /update-user, deduped per (user, locale) via localStorage, synced on startup resolution and on a manual switch in Settings. Pairs with peanut-api-ts#1387 (locale field + localized badge-unlocked email).
feat: mirror the user's app locale to the BE for localized emails
The root layout mounts the full app provider stack on every route, so the landing page pays for the wallet even though it never uses one. Gate the wallet half on the route and take the analytics init off the critical path. Follow-up to #2788, which shrank the fonts and fixed the hero LCP. The landing page still scored 45 on mobile afterwards (LCP 4.6s, TBT ~4s), and the weight left is JavaScript rather than images. - ContextProvider splits into a core (toast + auth) and AppFlowProviders (kernel client, token context, the six transfer-flow contexts), the latter loaded as its own chunk. KernelClientProvider statically imports the ZeroDev SDK, so mounting it was enough to put the SDK on the landing page. - WagmiProvider moves behind the same gate; the query client moves to config/queryClient so marketing routes keep react-query without wagmi. - App-only globals (rain-cooldown modal, badge toast, app lock, PeanutDebug) move to AppGlobals. Two of them read contexts that now only exist on app routes, so they had to move with the providers. - isMarketingRoute keys on the locale prefix, not the path segment: /withdraw is BOTH a marketing page under a locale and the app's withdraw flow under (mobile-ui), and matching the segment would have stripped the providers off the app route. Unknown paths fall through to the full tree. The ENS client fallback used @justaname.id/react's usePrimaryName, whose onChain path is ENS reverse resolution through a public client — viem, already bundled for wagmi, does the same thing. Dropping the dependency takes siwe and @ensdomains/ensjs out of the client entirely. normalizeEnsName also moves to its own module so client components stop importing ens.utils, which pulls the JustaName SDK for a pure string helper. Analytics keep every event, just later: - posthog session recording starts on idle instead of at init, so rrweb's recorder is no longer fetched and snapshotted mid-load. The opening moment of a replay is no longer captured; everything else is unchanged. - Sentry.init defers to idle behind a buffer that holds anything thrown before the SDK exists and replays it on init, so error coverage is unchanged. - gtag moves to lazyOnload — next emits a preload for afterInteractive scripts, which put 186 KB of gtag.js at High priority ahead of the LCP. - whenIdle also fires on pagehide so a visitor who leaves early still counts. Assets and misc: - landing-countries.svg run through svgo at precision 1: 362 KB -> 43 KB gzipped, pixel-diffed against the original (RMS 2.4, edge antialiasing only). - Londrina Solid is dead — declared with two weights, its CSS variable is referenced nowhere. Removed. - sniglet and the two knerd faces stop preloading; they render below the fold and were competing with the hero image at High priority. - i18n-iso-countries loads on demand, and the /qr-pay prefetch moves out of the root layout so marketing visitors stop paying for it. Not verified locally: this machine ran out of disk before a build completed, so there is no post-change Lighthouse run yet — see the PR description.
Incidents now render an impact line per service — what you can't do and
whether your money is safe — with the classified reason as a short second
clause. The feed no longer carries the provider's own error text, so
"Failed to get price: Company blocked." becomes "Deposits and withdrawals
in Brazilian reais, including Pix, are unavailable. Transfers already
submitted are safe and will complete once service is restored. The
provider is refusing our requests." — translated, in all four locales.
Also addresses review findings:
- The feed was cast with `as StatusSummary`, which is a compile-time claim
about a runtime payload. A 200 carrying `{}` satisfied the type and then
threw inside providers.map, past the point where the page could still
show its fallback — on the one page whose entire job is to render when
things are broken. It is validated now, and a malformed feed falls back.
- Incident times were formatted without a time zone. The page is
server-rendered, so they came out in whatever zone the host runs in with
nothing to interpret them against. Pinned to UTC, and the page says so.
…links - Status moves out of the footer's top nav into "Learn More", directly below Supported Networks. - The manifest shipped two pricing links into that column — the `pricing` resource (/pricing) and the `fees-pricing` article (/help/fees-pricing), rendering as "Pricing" and "Fees & Pricing". The resource keeps the slot under the clearer name "Fees and Pricing"; the article is dropped from the footer only. - Site-directory links set text-decoration-skip-ink: none and a 2px offset. The browser default breaks the rule around descenders, so the "g" in a link read as though it were not underlined at all. - Reserve /status. It is six lowercase characters, so it was recipient-shaped and the catch-all served a payment-profile shell on a 200 instead of the status page — the same trap /pricing, /stories and /content already hit. Reserved here and 301'd to /en/status, as those were. NOTE: like 'stories' and 'content', 'status' is still claimable as a username server-side; the backend needs it on its reserved list too.
ci: stop double-running Tests on every PR commit
…fiat claim minimums Guest bank claims are currently a dead end (GuestBankClaim is hard-disabled and GuestKycNeeded is a signup wall), so naming bank in the no-account sentence re-created the over-promise this branch removes. The copy now promises account-less claims for crypto wallets only, in all three locales. The three fiat minimums now live in one CLAIM_RAIL_MINIMUMS map that validateMinimumAmount, the claim action list and the send-time warning gate all derive from, and a test pins the values equal — the warning copy names the three rails as one class, so a per-rail divergence must force the copy question instead of silently moving the gate. Also drops a useMemo that memoized two comparisons.
A preview exists to review unreleased code, and the backend half of this page ships on the same cycle: production's API answers /status/summary with a 404 until dev reaches main, so every preview rendered the "could not be loaded" state instead of the board under review. Preview builds now read staging, which is also the honest pairing — preview frontend against preview-era backend. Production is untouched and still reads PEANUT_API_URL. `STATUS_API_URL` overrides both. A status page is a monitoring surface and there are legitimate reasons to point it at a backend other than the one the app talks to; it also means this needs no Vercel dashboard change, per-branch or otherwise. VERCEL_ENV is read with the same server/NEXT_PUBLIC fallback pair as src/utils/sentry-env.ts.
Header now matches the marketing pages: full-bleed pink band, drifting clouds, and the Roboto Flex display face at the same weight and uppercase treatment the MDX Hero uses. Content sits on bg-background like the other marketing pages rather than plain white. Not reusing <Hero> itself: it hard-codes a marquee reading "No fees · Instant · 24/7", and scrolling that above a live outage is the wrong thing to say. Its CTA has no counterpart here either. Separately, feed.test.ts declared a helper named `origin`, which collides with the DOM global — jest transpiles without typechecking so it passed, but `tsc` rejects it.
Header now uses the same <Hero> every marketing page uses (privacy, pricing, help) rather than a lookalike, so the pink band, its height and the yellow marquee are identical by construction — measured at 212px band + 60px marquee on both /en/privacy and /en/status. Subtitle is the window label; the metadata sentence wrapped to three lines at the Hero's uppercase 2rem. Adds an operational-share donut: 144px, hand-rolled SVG so the page still ships no client JS, the arc coloured by the worst state present so shape says how much is broken and colour says how badly. The figure weights the app and website at half on their own, with the eight rails splitting the rest. Someone who cannot open Peanut at all has lost more than someone who can use everything except one deposit rail, and a flat per-service average scores those two the same. The summary card holding it is behind SHOW_SUMMARY_CARD and not rendered — the page opens at App & Account. `operationalScore` and `OperationalDonut` are exported and tested directly, so hiding the card costs no coverage.
The page had a bare title and description and nothing else — pasted into a chat during an incident it unfurled as a blank card, which is when the link gets shared most. Routes metadata through the shared `@/app/metadata` helper the other marketing pages use, so it now carries og:title/description/url/site_name, og:locale plus the three alternates, a summary_large_image twitter card, canonical, and hreflang for all four locales. Registers `status` in ROUTE_SLUGS so getAlternatesFor() types it. `statusMetaTitle` is separate from `statusPageTitle`: the H1 stays the bare brand mark, while the meta title also has to say what the page is in a search result or an unfurl. The OG image is the branded dynamic card rather than the generic marketing PNG. Verified rendering at 1200x630. Still noindex — nothing here should compete with the marketing pages, and a stale cached copy of an outage is worse than none. Unfurls are unaffected: og:/twitter: tags are read regardless of robots.
The feed origin followed the deployment: previews read staging. That made the preview report staging's health under a production banner, and today it did exactly that — staging's Manteca credentials started failing authorization at 18:25 UTC, so the preview showed Argentina and Brazil down while production priced both currencies normally. A status page is a monitoring surface rather than part of the app, so it now reads api.peanut.me in every environment. A broken staging deploy can no longer read as a user-facing outage. STATUS_API_URL still overrides, for local work against a fixture or a deliberate review build. Until dev reaches main, production answers /status/summary with a 404 and every environment shows the "could not be loaded" state. That state now renders the same Hero as the loaded page — it is the view on show whenever the feed's own backend is what's down, so it should not look like an error page.
feat(status): public status page at /status
innolope-dev
temporarily deployed
to
content-publish
August 25, 2026 21:44 — with
GitHub Actions
Inactive
Restricted Android in-app browsers expose `localStorage` as null, and some Chrome documents throw SecurityError from the property getter itself. Two startup paths dereferenced it unguarded: - locale-store checked only for `undefined`, so a null storage passed the guard and `getItem` threw. The rejection was then memoized by `localeReady()`, which AppIntlProvider awaits without a rejection handler, leaving supported-language visitors in English (PEANUT-UI-STC). - wagmi's default storage reads `window.localStorage` unguarded inside `getDefaultStorage()`, and our `createConfig` runs at module scope — a throwing getter took the app shell down before it rendered (PEANUT-UI-STF). Both now go through a shared `safe-storage` helper that guards the property access and every storage method, falling through to the browser-language default. `localeReady()` also catches, so a memoized failure can no longer wedge the session.
iOS reclaims the capture device while the app is backgrounded, so the stopped scanner's own `start()` can come back "Camera not found" on resume (PEANUT-UI-SV1). The visibilitychange handler only logged that rejection and left `isCameraReady` false, so the scanner sat on "Starting camera…" forever with no way out but closing the drawer. Route foreground resume through `startCamera` instead: it rebuilds the scanner after the iOS hardware settle delay and, on failure, lands on the existing error view. A `isStartingCameraRef` guard keeps overlapping visibility events — or a retry racing a resume — from starting two cameras at once, and the error view now offers Retry alongside Close and the paste fallback.
…torage-guard fix(startup): survive a null or throwing localStorage
innolope-dev
temporarily deployed
to
content-publish
August 26, 2026 12:12 — with
GitHub Actions
Inactive
Replaces the isStartingCameraRef guard, which reintroduced the black-video state it was meant to prevent: a resume landing mid-start was dropped, the in-flight start() then resolved with no stream (qr-scanner discards it once _active is false), and the hook reported ready over a dead <video>. The same guard also swallowed Retry taps while a start was in flight — on Capacitor a hung getUserMedia pinned it until unmount. Resume now calls the scanner's own start(), which re-arms the instance the hidden branch stopped, and only falls back to a full startCamera rebuild when that rejects. The common foreground path no longer rebuilds the decode worker. Also from the review: - startCamera carries a generation counter re-checked after each await, so a superseded or closed start abandons instead of committing state. - A start that resolves without a stream now fails closed to the error view rather than dismissing the spinner over a black video. - Resume requires a live scannerRef again, so it no longer re-enters the camera flow while the permission modal or error view is up. - retryCamera is wrapped, so callers wiring it to onClick can no longer pass a MouseEvent as preferredCamera. - toggleCamera bails if the scanner was rebuilt during the handover. - handleQRScan's two post-scan restarts get the same rebuild-on-rejection path. Tests use fake timers instead of a real 150ms sleep, and the qr-scanner mock now models the _active/srcObject contract the hook relies on.
Follow-up to #2822. localeReady's catch was silent: neither caller attaches a handler, so the unhandled rejection was the only reason PEANUT-UI-STC was ever seen. It now warns before falling back, which captureConsoleIntegration reports, without reintroducing the memoized-rejection wedge. persistLocale's Cookies.set is guarded too. The sandboxed and opaque-origin documents that make localStorage throw do the same for document.cookie, and LocaleSwitcher calls persistLocale straight from an onClick with nothing upstream to catch it. Adds the first test that loads the real wagmi.config: jest's moduleNameMapper stubs the alias for the whole suite, so nothing exercised the storage wiring that keeps the app shell alive (PEANUT-UI-STF). Required by relative path, which the anchored mapper pattern does not intercept. Dropping the storage line now fails the suite.
…-resume fix(qr-scanner): recover the camera when the app returns to foreground
abalinda
temporarily deployed
to
content-publish
August 26, 2026 14:33 — with
GitHub Actions
Inactive
…ew-followup fix(startup): keep a signal when locale resolution falls back
innolope-dev
requested a deployment
to
content-publish
August 26, 2026 15:06 — with
GitHub Actions
Queued
…-minimum fix(send): stop over-promising account-less claims and surface the $5 fiat minimum at send time
innolope-dev
temporarily deployed
to
content-publish
August 26, 2026 18:56 — with
GitHub Actions
Inactive
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release PR:
dev→mainfor the Sprint 157 prod release.Payload (so far):
Migrations: n/a (FE).
Notion release page + full changelog to follow. Hugo merges this PR, after the BE deploy.
Summary by CodeRabbit
New Features
Bug Fixes