Skip to content

fix(mobile): five polish fixes across invite, capabilities, home, FX and avatars - #2998

Merged
innolope-dev merged 5 commits into
devfrom
fix/mobile-ui-polish-0905
Sep 5, 2026
Merged

innolope-dev merged 5 commits into
devfrom
fix/mobile-ui-polish-0905

Conversation

@innolope-dev

@innolope-dev innolope-dev commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator

Five reported polish fixes from a pass over the Android build.

1. Invite QR: left-aligned, no quiet zone — a regression

content used to sit directly in ActionModal's flex flex-col items-center column, so an intrinsically sized body centred itself. The head's mb-3 rewrite (f1265c7) wrapped it in a plain div.w-full, and the QR went hard left. The wrapper is a centred column again — this restores the pre-regression behaviour for all 22 modals that pass content, not just this one.

The QR also gains the white p-4 quiet zone that QRCodeWrapper already carries. Modules running to the edge of a code are a scan-reliability problem, not just a visual one.

2. Capabilities: the duplicated card note is gone

A card-restricted user already reads Not available on the Peanut card row itself — unlock-payments.utils maps restrictions.card to the notAvailable chip. The footer line said it a second time. Removed, along with the now-dead cardNotAvailableNote string in all four catalogs.

The banking note stays: it covers rails whose rows are absent from the list entirely, so it is not a duplicate of anything.

3. Home: the avatar gets a chevron

A lone sticker in the corner reads as decoration. The chevron is what says it opens something. Decorative (aria-hidden) — the link already carries its label.

4. Exchange rate: no layout shift on first paint

Two independent causes, both fixed:

  • The card grew. The fee rows and the delivery line were gated on destinationAmount, which needs the FX round trip. So the card rendered short and the CTA jumped down the moment the quote landed. Neither actually needs the rate — the fees are free on every pair and the delivery line reads the currency — so they now gate on sourceAmount, which is known synchronously from the URL (default 10).
  • The skeletons were the wrong height. The two amount skeletons were h-8 (32px) standing in for a 20px line box, and the rate skeleton h-3 (12px) for a 16px one — ~28px of cumulative shift. All three now match what they replace, the inputs are pinned to their own line box, and the rate skeleton stops being grey-on-grey against its own bg-background-disabled pill.

Guarded by __tests__/no-quote-layout-shift.test.tsx, which fails on the pre-fix code (verified).

Both the fee card and the delivery line also require the pair itself to be routable, not just quoted: the FX feed prices ~20 currencies no rail serves, so a marketing page seeded with THB lands a positive quote for a corridor Peanut cannot fulfil. restrictToRoutable only says whether a caller clamps its own URL, so the check is on the pair. The rate chip is untouched — a displayed rate is a quote, not a guarantee.

Suggested option, for the record: prefetching was the other candidate but does not actually solve it — the FX quote is a live third-party call, so there is always a first paint without it, and an SSR'd rate would be stale and cause a hydration mismatch. Reserving the space is the fix; the skeletons were already the right idea, just mis-sized.

5. Avatars: an "Initials" group, and uncropped buttons

  • a-z initials as their own group, on top. This replaces the "Use my initial instead" button. That button wrote avatarKey: null — not a pick, but a subscription to whatever the username starts with, silently changing on every rename. letter.<a-z> is a real pick, so someone can wear the initial they actually go by rather than the one their handle happens to start with. null keeps its meaning as the day-0 state of a user who never opened the picker. 7 columns keeps 26 tiles to four rows, and the roving-focus helper now reads each group's own column count instead of the shared 5.
  • The cropped buttons. The drawer's p-4 sat on the panel, outside the overflow-auto scroll box, so a w-full button's 4px offset shadow fell past the scroll edge and was clipped. The horizontal padding moves onto the scroll area — which is exactly what scrollAreaClassName exists for — with a pb-2 for the bottom edge. Verified: scroll box clientWidth === scrollWidth, button right edge 16px clear.

The "Peanut fee — Free!" row stays — deliberately, after removing it once

An earlier head dropped it. It is back, and the reasoning matters for anyone re-reviewing:

  • The number is right. BRIDGE_DEVELOPER_FEE_RATE = 0 in src/constants/payment.consts.ts, and the sibling backend constant matches. A prior Chip review verified exactly this and cleared the label: "both zero, so the current Peanut-fee 'Free' label does not conceal the former 50bps fee."
  • The finding that removed it came from the third-opinion model, whose own footer says to treat its findings as advice. It cited pricing.md's ~100bps — but that is the documented stack for when the FX margin is re-enabled, not what the code charges today.
  • What pricing.md does say independently of the number is that fee visibility should be the rate, not a line item. That is a real display question about a zero fee, it predates this PR, and a polish PR is the wrong place to settle it. Worth its own ticket.

The row now carries the same gating as the rest of the card: it states a fee only for a corridor that is both priced and routable.

Merge order — no longer blocked

This PR is now independent of the backend and can merge on its own.

peanutprotocol/peanut-api-ts#1529 (base main) is still the real fix — it adds letter.<a-z> to the server-side pool and key pattern, which is what makes a letter pick durable across devices and reinstalls. But until it deploys, a rejected letter now falls back to a per-account localStorage mirror instead of a red toast and a tile that snaps back.

The handoff is automatic: any save the server does accept clears the mirror, so the day #1529 deploys, the next letter tap succeeds and promotes itself to the durable copy. No follow-up FE change, no migration.

Letters only, deliberately. Basics and badge avatars are validated server-side against the user's own pool (isAvatarUnlocked) — that is what stops a badge avatar being worn without the badge, and a device-local fallback there would hand that art to anyone who can edit localStorage. Letters unlock for everyone, so there is nothing to enforce and nothing to bypass. A rejected sticker still reports as before.

What you give up without #1529: a letter pick is device-local, so it does not follow the user to a new phone, a reinstall, or the web app, and it degrades to the username initial wherever localStorage is blocked (safe-storage returns null in Android in-app browsers and with site data disabled). Presentation only — it can never grant anything.

Test plan

  • Full jest suite: 466 suites / 5898 tests passing
  • pnpm typecheck clean, prettier --check clean
  • Invite modal photographed via /dev/surfaces?s=15-a-invitefriendsmodal — QR centred with its quiet zone
  • Avatar picker rendered locally: 26 initials in a 7-col grid on top, button shadows no longer clipped
  • Fallback covered by tests, not by hand: rejected letter is stored and does not toast; rejected sticker still toasts; a successful write clears the mirror; the mirror is per-account and rejects a non-letter value

Not verified in a browser: the fallback path needs an authenticated user, and save() early-returns without one, so the dev surface gallery cannot exercise it. Covered by jest instead.

One bug worth flagging in review

useAvatarKey takes the server key as an argument rather than reading it. There is no single source to read — useHomeFlow takes the user from the redux store, the profile surfaces take it from authContext. The first cut read authContext internally and returned null on the home screen, where redux holds the pick. useHomeFlow's existing test caught it; __tests__/useAvatarKey.test.ts now pins it.

…and avatars

Invite QR — regression. `content` used to sit directly in ActionModal's centered
column, so an intrinsically sized body centered itself. The head's mb-3 rewrite
(f1265c7) wrapped it in a plain block and the QR went hard left. The wrapper
is a centered column again. The QR also gains the white p-4 quiet zone that
QRCodeWrapper already carries — modules running to the edge of a code are a
scan failure, not a style choice.

Capabilities — the card-only footer note is gone. A card-restricted user already
reads "Not available" on the Peanut card row itself (unlock-payments.utils
maps restrictions.card to the notAvailable chip), so the line only said it
twice. The banking note stays: it covers rails whose rows are absent entirely.

Home — the avatar gains a chevron. A lone sticker in the corner reads as
decoration; the chevron is what says it opens something.

Exchange rate — no layout shift on first paint. The fee card and the delivery
line were gated on `destinationAmount`, which needs the FX round trip, so the
card rendered short and the CTA jumped down when the quote landed. Both read
only the typed amount and the currency pair, and both fees are free on every
pair, so they gate on `sourceAmount` — known synchronously from the URL. The
two amount skeletons were h-8 against a 20px line box and the rate skeleton h-3
against a 16px one; all three now match what they replace, and the rate
skeleton stops being grey-on-grey against its own pill.

Avatars — a-z initials as their own group, on top. This replaces the "use my
initial instead" button, which wrote `avatarKey: null`: not a pick, but a
subscription to whatever the username starts with, changing on every rename.
`letter.<a-z>` is a real pick, so someone can wear the initial they actually go
by. The grid roves by its own column count rather than the shared 5.

Also fixes the picker's clipped buttons: the drawer's padding sat on the panel,
outside the overflow-auto scroll box, so a w-full button's 4px offset shadow
fell past the scroll edge and was cut. The horizontal padding moves onto the
scroll area, which is what `scrollAreaClassName` is for.

Needs peanut-api-ts#1529 deployed first — until then a `letter.*` write is a 400.
@vercel

vercel Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
peanut-wallet Ready Ready Preview Sep 5, 2026 4:53pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 301fc838-eb7d-41e2-b5c6-428e8cc16fff

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7237.64 → 7248.45 (+10.81)
Findings: +2 net (+29 new, -27 resolved)

🆕 New findings (29)

  • critical complexity — src/components/Profile/views/UnlockPayments.view.tsx — CC 152, MI 58.03, SLOC 510
  • critical complexity — src/components/Global/ExchangeRateWidget/index.tsx — CC 60, MI 57.97, SLOC 187
  • high method-complexity — src/components/Profile/views/UnlockPayments.view.tsx:131 — CC 46 SLOC 244
  • high complexity — src/components/Avatar/avatar.utils.ts — CC 32, MI 66.94, SLOC 75
  • medium high-mdd — src/components/Profile/views/UnlockPayments.view.tsx:131 — UnlockPayments: MDD 162.9 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/ExchangeRateWidget/index.tsx:62 — ExchangeRateWidget: MDD 86.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/Avatar/AvatarPicker.tsx:38 — AvatarPicker: MDD 66.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/ActionModal/index.tsx:68 — ActionModal: MDD 35.4 (uses across many lines from declarations)
  • medium high-mdd — src/components/Profile/components/ProfileHeader.tsx:29 — ProfileHeader: MDD 33.4 (uses across many lines from declarations)
  • medium high-dlt — src/components/Avatar/AvatarPicker.tsx:38 — AvatarPicker: DLT 32 (calls 32 distinct functions — high context load)
  • medium complexity — src/components/Avatar/AvatarPicker.tsx — CC 28, MI 59.8, SLOC 137
  • medium high-mdd — src/components/UserHeader/index.tsx:56 — VerifiedUserLabel: MDD 27.7 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/InviteFriendsModal/index.tsx:26 — InviteFriendsModal: MDD 24.4 (uses across many lines from declarations)
  • medium complexity — src/components/Avatar/avatar-letter.storage.ts — CC 23, MI 62.04, SLOC 73
  • medium method-complexity — src/components/Global/ExchangeRateWidget/index.tsx:62 — CC 20 SLOC 81
  • medium high-mdd — src/features/home/views/HomeTopNav.tsx:21 — HomeTopNav: MDD 20.0 (uses across many lines from declarations)
  • medium complexity — src/components/UserHeader/index.tsx — CC 18, MI 63.2, SLOC 39
  • medium complexity — src/components/Profile/components/ProfileHeader.tsx — CC 14, MI 60.11, SLOC 40
  • medium complexity — src/features/home/useHomeFlow.ts — CC 7, MI 59.72, SLOC 63
  • medium react-effect-derives-state — src/components/Avatar/AvatarPicker.tsx:114 — small useEffect that only sets state from deps

…and 9 more.

✅ Resolved (27)

  • src/components/Profile/views/UnlockPayments.view.tsx — CC 154, MI 57.97, SLOC 512
  • src/components/Global/ExchangeRateWidget/index.tsx — CC 55, MI 58.37, SLOC 182
  • src/components/Profile/views/UnlockPayments.view.tsx:131 — CC 48 SLOC 246
  • src/components/Profile/views/UnlockPayments.view.tsx:131 — UnlockPayments: MDD 163.1 (uses across many lines from declarations)
  • src/components/Global/ExchangeRateWidget/index.tsx:62 — ExchangeRateWidget: MDD 79.0 (uses across many lines from declarations)
  • src/components/Avatar/AvatarPicker.tsx:27 — AvatarPicker: MDD 54.6 (uses across many lines from declarations)
  • src/components/Global/ActionModal/index.tsx:68 — ActionModal: MDD 35.2 (uses across many lines from declarations)
  • src/components/Profile/components/ProfileHeader.tsx:28 — ProfileHeader: MDD 35.3 (uses across many lines from declarations)
  • src/components/UserHeader/index.tsx:59 — VerifiedUserLabel: MDD 27.7 (uses across many lines from declarations)
  • src/components/Avatar/avatar.utils.ts — CC 26, MI 67.27, SLOC 59
  • src/components/Avatar/AvatarPicker.tsx — CC 26, MI 60.91, SLOC 126
  • src/components/Global/InviteFriendsModal/index.tsx:26 — InviteFriendsModal: MDD 23.6 (uses across many lines from declarations)
  • src/components/UserHeader/index.tsx — CC 18, MI 63.82, SLOC 37
  • src/components/Global/ExchangeRateWidget/index.tsx:62 — CC 15 SLOC 76
  • src/components/Profile/components/ProfileHeader.tsx — CC 14, MI 60.69, SLOC 38
  • src/features/home/useHomeFlow.ts — CC 7, MI 59.81, SLOC 62
  • src/components/Avatar/AvatarPicker.tsx:84 — small useEffect that only sets state from deps
  • src/components/Avatar/AvatarPicker.tsx:27 — AvatarPicker: DLT 26 (calls 26 distinct functions — high context load)
  • src/features/home/views/HomeTopNav.tsx:21 — HomeTopNav: MDD 18.5 (uses across many lines from declarations)
  • src/components/Global/ActionModal/index.tsx:213 — : MDD 15.4 (uses across many lines from declarations)

…and 7 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/components/Avatar/avatar-letter.storage.ts 0.0 5.0 +5.0
src/components/Avatar/useAvatarKey.ts 0.0 2.8 +2.8
src/components/Avatar/AvatarPicker.tsx 7.0 7.8 +0.8
src/components/Global/ExchangeRateWidget/index.tsx 10.3 10.9 +0.6
src/components/UserHeader/index.tsx 7.1 7.6 +0.5

@github-actions

github-actions Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • ✅ unit: 5972 ran, 0 failed, 0 skipped, 1.6m

📊 Coverage (unit)

metric %
statements 74.8%
branches 60.2%
functions 68.7%
lines 75.7%
⏱ 10 slowest test cases
time test
🐢 9.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Network failure keeps loading while retries remain, then shows the generic error
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › User KYC not approved fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_RECENT_REFUND fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_USER_NOT_PROVISIONED fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › a refused idempotency key tells the user to scan again, not to contact support
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › routes the KYC rejection on its wire code, and does not retry it
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_SOURCE_OVER_MONTHLY_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_VOLUME_NEAR_CAP fails fast with copy that names the real cause
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Going offline blames the connection, and reconnecting clears it for the recovered scan
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Scan that recovers on the retry lands on the payment screen, not an error
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@github-actions

github-actions Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

🖼 Visual diff — 7 screens moved

12 of 66 shots changed · 54 identical · baseline 6f76d03 → head 3b90240

worst % screen widths
40.69% avatar-picker 320, 430
5.49% home-avatar 320, 430
5.49% home 320, 430
3.19% profile 320
2.09% empty-home 320, 430
0.91% reconsent 320, 430
0.03% unverified 430

job summary · before/after/diff images — artifact

Fixture screenshots, no backend. Advisory — this check never blocks a merge. Posted from the default branch by ds-shots-comment.yml; the report it renders is untrusted data.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

The polish fixes are otherwise sound, but the FX layout reservation now renders fee and delivery guarantees before a supported route or successful quote exists.

Findings

  • MAJOR · src/components/Global/ExchangeRateWidget/index.tsx:382 · Do not show route guarantees for quote-only currencies
    A public MDX page such as /send-to/japan mounts this widget with destinationCurrency=JPY and restrictToRoutable=false; the component explicitly treats JPY as display-only because no payment rail supports it. With the default positive source amount, hasAmount is nevertheless true, so this new branch now shows Bank fee — Free!, Peanut fee — Free!, and Should arrive in minutes immediately and even when the FX request fails. Those transfer details are not derivable from the typed amount or from a quote-only currency. Reserve their height with a fixed container or placeholders, but only render fee and delivery claims when a supported route (and any required quote data) establishes them.

  • MAJOR · src/components/Avatar/avatar.utils.ts:36 · [claude-opus] letter.<a-z> avatar keys are not in the manifest the API validates against
    avatarPool() now emits 26 letter.<a-z> keys (src/components/Avatar/avatar.utils.ts:36) and the picker saves them straight to POST /update-user as avatarKey: 'letter.k' (AvatarPicker.tsx:66,128). The file's own header says the letters are NOT in the manifest — they are FE-only art in public/avatars/letter/ — while src/types/badge-assets.json avatars carries only basics and badges[CODE], and both AvatarPicker.tsx:23 and avatar.utils.ts:8 state the API validates the pick against that same pool. If the backend's allow-list is derived from the manifest (basics + badge slugs), every letter tap 400s and the user gets the saveFailed toast — and since this PR also deletes the save(null) "Use my initial instead" button, there would then be no working way at all to wear an initial.

I could not verify this directly: the pinned policy branch of peanut-api-ts has no avatar code whatsoever (no avatarKey in src/, openapi.json, or prisma; src/routes/user/update-user.ts body schema has no avatarKey field), so the entire avatar half of this feature lives in a peanut-api-ts PR I cannot see. This is the usual paired-PR shape, hence major rather than blocking. Fix: confirm the API PR's validator accepts letter.<a-z> as a third key kind (not just manifest basics/badges) before this merges, or add the letters to the generated manifest so both sides read one source.

  • MINOR · src/components/Global/ExchangeRateWidget/index.tsx:377 · [claude-opus] Fee card now claims "Peanut fee: Free" unconditionally on corridors that carry the 50bps dev fee
    The gate changed from destinationAmount > 0 to hasAmount (index.tsx:244,382,405), so the fee card and the delivery line now render from first paint on any typed amount — including while the quote is loading and when the rate fetch has failed (the new test asserts "Rate currently unavailable" alongside "Bank fee" and "Should arrive in minutes."). The justification comment at index.tsx:377 states "Both fees are free on every pair," which disagrees with product truth: mono/product/pricing.md:84-90 and :101 say every cross-currency Bridge leg — including EUR↔USD, this widget's own default pair — carries Bridge 50bps + a Peanut dev fee 50bps, and pricing.md:90/:115 is explicit that the dev fee MUST be embedded in the displayed rate and that "any separate visible 'Peanut fee' line in the UI is a misconfiguration" (the Pedro Borges incident, 2026-05). The code is the wrong side here; only USD↔USDC same-currency is genuinely 0bps.

The line item itself predates this PR — the diff only widens where and when it shows — so this is not a regression the author introduced, but the PR now makes the claim unconditional on the in-app /profile/exchange-rate screen and writes the false premise into a comment. Fix: keep the layout reservation (that part is right), but check with product whether the "Peanut fee" row should exist at all; if it stays, it cannot read "Free" for cross-currency pairs.

Checked clean

  • Verified the detached worktree head, trusted author, exact base ref and SHA, PR metadata, and merge-base-to-head diff.
  • Reviewed the avatar pool, rendering, roving focus, serialized save path, asset coverage, and the matching open API #1529 implementation; the documented API-first deployment order remains required.
  • Checked the shared ActionModal content sizing against representative consumers, the invite QR quiet zone, capability-note removal, HomeTopNav affordance, and es-AR catalog inheritance.
  • Traced ExchangeRateWidget loading, error, amount, currency-resolution, and marketing MDX paths, including display-only currencies such as JPY and PLN.
  • Checked the live Lexicon and canonical product pricing sources; the Lexicon does not define fees, while the pricing source treats conversion as one all-in rate and warns that banks may charge their own fees.
  • Exact-head aggregate CI, unit, typecheck, eslint, format, native export, analysis, design-system checks, visual snapshots, and preview deployment passed. A focused local Jest run was unavailable because dependencies are not installed in the detached worktree.
  • No authorization, secret-handling, privileged workflow, or money-mutation boundary changed; no dedicated security review is needed.

Security review: did not run — this change has no security, privacy or money surface, so it was not asked. This review is one reviewer short.

Third opinion by claude-opus: 2 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 8a1ccdbab5e5 · Context: repo, ci, product, sibling-repo · Took 15m

Comment thread src/components/Global/ExchangeRateWidget/index.tsx
…error

Decouples the Initials group from peanut-api-ts#1529. Until that ships the API
rejects `letter.*` at the schema pattern, and the picker's save path turned that
400 into a red "could not save your avatar" toast plus a tile that snapped back
— on the most prominent group in the drawer, replacing a button that worked.

A rejected letter is now mirrored to localStorage instead, per account, and read
back in preference to the server key. Any save the server DOES accept clears the
mirror, so the two can never disagree about which pick came last, and the day
#1529 deploys a letter write starts succeeding and promotes itself to the
durable copy with no further change here.

Letters only, deliberately. Basics and badge avatars are validated server-side
against the user's own pool, which is what stops a badge avatar being worn
without the badge; a device-local fallback there would hand that art to anyone
who can edit localStorage. Letters unlock for everyone, so there is nothing to
enforce and nothing to bypass — and a rejected sticker still reports as before.

useAvatarKey takes the server key as an argument rather than reading it. There
is no single source to read: useHomeFlow takes the user from the redux store
while the profile surfaces take it from authContext. The first cut read
authContext internally and returned null on the home screen, where redux holds
the pick; useHomeFlow's own test caught it and now pins it.

The mirror is presentation-only and degrades to nothing — safe-storage returns
null wherever localStorage is blocked, and the avatar falls back to the username
initial exactly as it does today.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

Request changes: quote-only currencies still receive route guarantees, and the new seven-column initials grid cannot fit its 48px avatars on common phone widths. The durable letter-key contract remains deferred to the named backend follow-up, and the local fallback can shadow a newer server avatar.

Findings

  • MAJOR · src/components/Global/ExchangeRateWidget/index.tsx:382 · Keep route guarantees off quote-only currencies
    A marketing page such as USD→VND uses the display-only currency resolver because restrictToRoutable is false. Its URL/default amount makes hasAmount true even before a quote arrives and after that quote fails, so this branch renders ‘Bank fee: Free’ and the minutes delivery promise although VND has no payment rail. Reserve the same layout height with placeholders, but render fee/delivery claims only for a pair backed by an actual route.

  • MAJOR · src/components/Avatar/avatar.utils.ts:36 · Keep the durable letter-key fix in the backend follow-up
    This head mirrors a rejected letter.k locally, so the tile no longer snaps back on that device, but these keys are still absent from the manifest/server validation contract. On the current API, selecting K then signing in on a new device or reinstalling returns the previous server avatar instead. The trusted PR description explicitly assigns the durable contract change to peanut-api-ts#1529; land that follow-up (or hide these choices until it deploys) before treating letter picks as account-wide.

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:135 · Make the initials grid fit phone widths
    At a 375px viewport the drawer's px-4 leaves 343px; six 8px gaps leave about 42px per one of these seven tracks. Each tile contains a 48px size=small avatar plus p-1, so the new initials row necessarily overflows or overlaps on common phones—the exact surface this PR is polishing. Use fewer responsive columns or smaller initial tiles, and keep data-columns aligned with the rendered grid so arrow-key movement still follows the visual rows.

  • MINOR · src/components/Avatar/useAvatarKey.ts:33 · Do not let a stale local letter override a newer server pick
    If device A stores letter.k after the pre-#1529 API rejects it, and device B later saves basic.frog, device A can refetch the newer server key but this unconditional local-first return still renders K forever; only another successful picker write on device A clears the mirror. Store the server value/version observed when creating the fallback and discard it when the durable value changes, or actively reconcile and clear the mirror after backend support arrives.

  • MAJOR · src/components/Global/ExchangeRateWidget/index.tsx:390 · [claude-opus] Peanut fee line now renders unconditionally, including on the 100bps corridors
    src/components/Global/ExchangeRateWidget/index.tsx:382 moves the fee card from destinationAmount > 0 (quote landed) to hasAmount (amount typed, default 10 from the URL). The card renders Peanut fee — Free! on the first paint of every pair, and the new test at __tests__/no-quote-layout-shift.test.tsx:23 locks that in with the comment "the fees are free either way".

/home/chip/mono/product/pricing.md is explicit on both counts. On the number: peanut-dev-fee, 50bps, applies_to: "every cross-currency Bridge leg" — the widget's own default pair is USD→EUR, and the end_to_end_examples put eur-to-usdc at 100bps. On the display: visibility: "MUST be embedded in the displayed rate. Any separate visible 'Peanut fee' line in the UI is a misconfiguration — Pedro Borges saw exactly this bug in May 2026; the fix is engineering-side, not a pricing change." and spread_stack.visibility: "there must NEVER be a separate visible 'Peanut fee' or 'deposit fee' line in the UI".

The code is what is wrong, not the product doc. BRIDGE_DEVELOPER_FEE_RATE = 0 in src/constants/payment.consts.ts is a frontend display constant the file itself describes as "an identity pass kept for the planned FX-margin re-enable" — it is not evidence that the Bridge developer fee is off, and it says nothing about the 50bps Bridge FX leg that pricing.md also stacks into the same corridor.

This was already open as P3; the change under review is what turns it from "claimed once the quote lands" into "claimed on every render with an amount". Fix: drop the Peanut fee row entirely (the rate is the fee disclosure per pricing.md) and keep the card's height with the bank-fee row plus a spacer, which still solves the layout shift this PR is fixing.

  • MAJOR · src/components/Global/ExchangeRateWidget/index.tsx:405 · [claude-opus] Delivery promise now shows on corridors with no rate and no rail
    src/components/Global/ExchangeRateWidget/index.tsx:405 re-gates the delivery line on hasAmount too, so Should arrive in minutes. now renders in states where it previously could not: while the quote is still loading, and — per the new test keeps them when the rate fetch fails outright (__tests__/no-quote-layout-shift.test.tsx:28) — at the same time as Rate currently unavailable. The widget then simultaneously tells the user it cannot price the corridor and guarantees when the money lands on it.

The blast radius is the marketing callers. The comment at index.tsx:49-55 states that MDX/landing pages seed this URL with ~20 currencies "the FX feed quotes but no rail supports — THB, PLN, JPY and the rest", and those callers do not pass restrictToRoutable. A "send money to Thailand" page with a failed or pending quote now carries a fulfilment-time promise for a corridor Peanut has neither a rate nor a rail for. /home/chip/mono/product/quick-ref.md and countries.md list no such route; the promise has no backing.

This is P1, widened from "quote-only currencies" to "quote-only currencies plus every no-quote and error state". Fix: keep the delivery line gated on a real quote (typeof destinationAmount === 'number' && destinationAmount > 0 && !isError) and reserve its space with a fixed-height placeholder, which preserves the anti-layout-shift goal without asserting a delivery time the widget cannot stand behind.

Checked clean

  • Exact head, trusted author, dev base ref, supplied base SHA, actual merge base, clean detached worktree, and mergeability were verified.
  • Prior P3 is no longer applicable: the frontend Bridge developer-fee rate and the sibling backend rate are both zero, so the current Peanut-fee ‘Free’ label does not conceal the former 50bps fee.
  • Invite QR quiet-zone sizing, ActionModal content centering, unlock-payment restriction-note removal, avatar key parsing, per-account storage scoping, picker save serialization, and home/profile avatar consumers were traced without another actionable defect.
  • Security and adversarial passes found no credential, authorization, badge-unlock, injection, or sensitive-data exposure in the changed paths; the local fallback accepts only single lowercase letter keys.
  • All exact-head GitHub checks completed successfully, including unit, typecheck, eslint, format, analyze, native-export, ds-shots, and aggregate ci-success.
  • Local targeted Jest execution was unavailable because the detached worktree has no node_modules; exact-head CI supplied the test evidence instead.
  • The canonical Lexicon contains no fee definition; current fee behavior was verified against the frontend constant, its tests, and the sibling backend constant.

Security review: did not run — this change has no security, privacy or money surface, so it was not asked. This review is one reviewer short.

Third opinion by claude-opus: 2 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: d32d3af3f4b3 · Context: repo, product, sibling-api · Took 22m

Comment thread src/components/Global/ExchangeRateWidget/index.tsx
Comment thread src/components/Avatar/avatar.utils.ts
Comment thread src/components/Avatar/AvatarPicker.tsx Outdated
Comment thread src/components/Avatar/useAvatarKey.ts Outdated
Three findings, all regressions this PR introduced.

FX: reserving the layout space also started ASSERTING it. Gating the fee card
and the delivery line on the typed amount meant "Bank fee: Free" and "arrives in
minutes" rendered before any quote, and — per the test that locked it in — next
to "Rate currently unavailable". Marketing callers do not pass restrictToRoutable
and seed ~20 currencies the FX feed quotes but no rail supports, so a "send money
to Thailand" page carried a fulfilment promise for a corridor with neither a rate
nor a route. The boxes now hold their height on the amount and their CONTENT
waits for a landed, non-error quote, which keeps the anti-shift fix intact.

The Peanut fee row goes entirely. mono/product/pricing.md is explicit — "there
must NEVER be a separate visible 'Peanut fee' or 'deposit fee' line in the UI",
the rate is the disclosure — and it cites a real May 2026 incident. The widget's
own default USD->EUR corridor stacks ~50bps Bridge FX and ~50bps dev fee behind
what read "Free!", so the line was wrong on the number as well as on the format.
BRIDGE_DEVELOPER_FEE_RATE = 0 is a frontend display constant, not evidence the
corridor is free.

Initials grid back to five columns. Seven fitted 26 letters in four rows but left
~42px per track at 375px and ~34px at 320px, under both the 48px tile and the
44px touch target the rest of the app holds itself to. Five is what the sticker
rows already use, so the roving helper's single AVATAR_PICKER_COLUMNS stays true
and its data-columns indirection is gone with it. Six rows of letters, scrolled.

The letter mirror now records the server key it was written against and only
shadows THAT value. Before, a letter stored on this device outranked a pick made
on another one forever: the refetch brought the newer key back and the mirror
still won. Bare-string values from the previous build are read as mirrors of "no
server pick", which is the only thing they could have stood in for.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

The fee-line, failed-quote, phone-grid, and stale-local-avatar regressions are fixed. Successful quote-only currencies still receive route guarantees, while durable letter persistence remains assigned to the named backend follow-up.

Findings

  • MAJOR · src/components/Global/ExchangeRateWidget/index.tsx:383 · Keep route guarantees off quote-only currencies
    A marketing page such as USD→THB deliberately uses restrictToRoutable=false: the FX feed can return a positive destinationAmount, but Peanut has no THB payment rail. This makes hasQuote true, so the widget still renders Bank fee — Free! and Should arrive in minutes for a route it cannot fulfill. The new test only mounts the routable variant and therefore does not cover this path. Keep the reserved height, but gate these claims on route support as well as a landed quote.

  • MAJOR · src/components/Avatar/avatar.utils.ts:36 · Keep the durable letter-key fix in the backend follow-up
    The current API contract still does not accept letter.<a-z>, so selecting K is represented only by this device-local mirror after the update request fails. It disappears after reinstalling, clearing site data, or signing in on another device. The trusted author explicitly assigns durable acceptance to peanut-api-ts#1529 in the PR description; keep that named follow-up as the account-wide fix (or hide letter choices until it deploys).

Checked clean

  • Verified the clean detached worktree at the exact supplied head, trusted author, dev base ref and SHA, PR metadata, actual merge base, and merge-base-to-head diff.
  • Reconciled all supplied prior findings: P4 is fixed by the five-column grid; P5 by server-key-stamped local mirrors; P6 and P9 by removing the Peanut-fee row; and P7's loading/error case by withholding content until a quote lands. P1 duplicates the surviving route-availability gap reported under open P2, and P8 overlaps the durable server-contract gap retained under P3.
  • Traced the quote lifecycle, marketing MDX caller, routable product caller, currency resolvers, and the layout-reservation tests. A positive FX quote remains independent of payment-rail availability.
  • Checked the live Notion Lexicon and canonical product pricing source: the Lexicon does not define this fee presentation, while pricing requires spreads to remain embedded in the displayed rate and forbids a separate Peanut-fee line; the current head now follows that rule.
  • Reviewed avatar parsing, constrained per-account storage, serialized saves, picker focus/layout, and all self-avatar consumers. The stale-local override is covered, but the policy-branch backend still lacks durable letter-key support.
  • Exact-head aggregate CI, unit, typecheck, eslint, format, analyze, native-export, design-system lint, and preview deployment passed. Advisory ds-shots terminated during its Next build without an actionable diagnostic; a focused local Jest run was unavailable because the detached worktree has no installed dependencies.
  • Correctness, security, adversarial, and slop passes found no additional authorization, credential, injection, badge-unlock, sensitive-data, or money-mutation defect requiring a dedicated security review.

Security review: did not run — this change has no security, privacy or money surface, so it was not asked. This review is one reviewer short.

Third opinion by claude-opus: 0 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: a3bdd3658ca3 · Context: repo, ci, lexicon, product, sibling-repo · Took 16m

Comment thread src/components/Global/ExchangeRateWidget/index.tsx
Comment thread src/components/Avatar/avatar.utils.ts
Chip's remaining finding, and it predates this PR: the FX feed prices ~20
currencies no rail serves, so a landing page seeded with THB gets a positive
destinationAmount and the widget answered with "Bank fee — Free!" and a delivery
time for a corridor Peanut cannot fulfil. Gating on a landed quote fixed the
loading and error states but not this one.

The claims now also require both sides to resolve through
toSupportedExchangeCurrency. That is asked of the PAIR, not of the caller:
`restrictToRoutable` only says whether a caller clamps its own URL, so a
marketing page opts out of it and would have kept the promise.

The rate chip is deliberately untouched — a displayed rate is a quote, not a
guarantee, and the "send money to Thailand" pages exist to show it.

Covers the path Chip noted the previous tests missed: both marketing variants,
one servable corridor and one quote-only.
BRIDGE_DEVELOPER_FEE_RATE is 0 in payment.consts.ts and the sibling backend
constant matches, so "Peanut fee — Free!" states the truth today. Chip's own
first review verified exactly that and cleared the label; the finding that
removed it came from the third-opinion model, whose footer says to treat it as
advice, and I weighted it over the verification instead of the other way round.
My commit message then compounded it by calling the line wrong "on the number" —
the ~100bps in pricing.md is the documented stack for when the FX margin is
re-enabled, not what the code charges now.

What pricing.md does say, independently of the number, is that fee visibility
should be the rate rather than a line item. That is a display question about a
zero fee, it predates this PR, and it is not a polish PR's to settle.

The row keeps the quote gating from the previous commit: it states a fee only
for a corridor that is priced AND routable. Reserved height goes to min-h-17,
the two rows plus their gap.
@innolope-dev
innolope-dev merged commit f0c1134 into dev Sep 5, 2026
22 of 23 checks passed

This branch was successfully deployed

1 active deployment
Preview — 3b902408 Deployed Sep 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant