fix(mobile): five polish fixes across invite, capabilities, home, FX and avatars - #2998
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Code-analysis diffPainscore total: 7237.64 → 7248.45 (+10.81) 🆕 New findings (29)
…and 9 more. ✅ Resolved (27)
…and 7 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
🖼 Visual diff — 7 screens moved12 of 66 shots changed · 54 identical · baseline
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. |
There was a problem hiding this comment.
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/japanmounts this widget withdestinationCurrency=JPYandrestrictToRoutable=false; the component explicitly treats JPY as display-only because no payment rail supports it. With the default positive source amount,hasAmountis nevertheless true, so this new branch now showsBank fee — Free!,Peanut fee — Free!, andShould arrive in minutesimmediately 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 26letter.<a-z>keys (src/components/Avatar/avatar.utils.ts:36) and the picker saves them straight toPOST /update-userasavatarKey: 'letter.k'(AvatarPicker.tsx:66,128). The file's own header says the letters are NOT in the manifest — they are FE-only art inpublic/avatars/letter/— whilesrc/types/badge-assets.jsonavatarscarries onlybasicsandbadges[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 thesaveFailedtoast — and since this PR also deletes thesave(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 fromdestinationAmount > 0tohasAmount(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
…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.
There was a problem hiding this comment.
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 becauserestrictToRoutableis false. Its URL/default amount makeshasAmounttrue 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 rejectedletter.klocally, 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'spx-4leaves 343px; six 8px gaps leave about 42px per one of these seven tracks. Each tile contains a 48pxsize=smallavatar plusp-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 keepdata-columnsaligned 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 storesletter.kafter the pre-#1529 API rejects it, and device B later savesbasic.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:382moves the fee card fromdestinationAmount > 0(quote landed) tohasAmount(amount typed, default 10 from the URL). The card rendersPeanut fee — Free!on the first paint of every pair, and the new test at__tests__/no-quote-layout-shift.test.tsx:23locks 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:405re-gates the delivery line onhasAmounttoo, soShould arrive in minutes.now renders in states where it previously could not: while the quote is still loading, and — per the new testkeeps them when the rate fetch fails outright(__tests__/no-quote-layout-shift.test.tsx:28) — at the same time asRate 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
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.
There was a problem hiding this comment.
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 usesrestrictToRoutable=false: the FX feed can return a positivedestinationAmount, but Peanut has no THB payment rail. This makeshasQuotetrue, so the widget still rendersBank fee — Free!andShould arrive in minutesfor 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 acceptletter.<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
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.
Five reported polish fixes from a pass over the Android build.
1. Invite QR: left-aligned, no quiet zone — a regression
contentused to sit directly in ActionModal'sflex flex-col items-centercolumn, so an intrinsically sized body centred itself. The head's mb-3 rewrite (f1265c7) wrapped it in a plaindiv.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 passcontent, not just this one.The QR also gains the white
p-4quiet zone thatQRCodeWrapperalready 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.utilsmapsrestrictions.cardto thenotAvailablechip. The footer line said it a second time. Removed, along with the now-deadcardNotAvailableNotestring 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:
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 onsourceAmount, which is known synchronously from the URL (default 10).h-8(32px) standing in for a 20px line box, and the rate skeletonh-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 ownbg-background-disabledpill.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.
restrictToRoutableonly 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
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.nullkeeps 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.p-4sat on the panel, outside theoverflow-autoscroll box, so aw-fullbutton's 4px offset shadow fell past the scroll edge and was clipped. The horizontal padding moves onto the scroll area — which is exactly whatscrollAreaClassNameexists for — with apb-2for the bottom edge. Verified: scroll boxclientWidth === 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:
BRIDGE_DEVELOPER_FEE_RATE = 0insrc/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 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(basemain) is still the real fix — it addsletter.<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
localStorageis blocked (safe-storagereturns null in Android in-app browsers and with site data disabled). Presentation only — it can never grant anything.Test plan
pnpm typecheckclean,prettier --checkclean/dev/surfaces?s=15-a-invitefriendsmodal— QR centred with its quiet zoneNot 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
useAvatarKeytakes the server key as an argument rather than reading it. There is no single source to read —useHomeFlowtakes the user from the redux store, the profile surfaces take it fromauthContext. The first cut readauthContextinternally and returnednullon the home screen, where redux holds the pick.useHomeFlow's existing test caught it;__tests__/useAvatarKey.test.tsnow pins it.