Skip to content

feat: badge-linked profile avatars (TASK-22142) - #2929

Merged
abalinda merged 19 commits into
devfrom
feat/22142-badge-avatars
Sep 3, 2026
Merged

feat: badge-linked profile avatars (TASK-22142)#2929
abalinda merged 19 commits into
devfrom
feat/22142-badge-avatars

Conversation

@abalinda

@abalinda abalinda commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Badge-linked profile avatars (v2). The generated dot-face avatar was not launch-ready and derived from name strings. Now the home slot and the self profile show the user's picked avatar — one of twenty basics everyone gets, or one of the one-to-three avatars each badge unlocks — with a privacy-safe fallback: exactly one username initial on the username's color, never the full name or verification data.

Task

TASK-22142 — https://app.notion.com/p/peanutprotocol/Build-badge-linked-avatar-system-v2-3cf83811757981efa8afecf6667d2662

Risks / breaking changes

QA

  • Fixtures (no backend): /home?__fixture=home-avatar (basic frog in the slot), /profile?avatarPicker=true&__fixture=avatar-picker (picker open, Bug Whisperer avatars unlocked, beetle selected). Fixture routes may now carry their own query.
  • Sandbox: pick a basic, roll the die, pick a badge avatar without the badge (API 400 → toast + snap back), "Use my initial instead".
  • Tests: avatar.utils (manifest ↔ files), UserAvatar (key branch, fallback delegation), AvatarPicker (pool, die, save, revert), HomeTopNav, BadgeEarnToast (avatar line + deep link), fixtures registry.

Design

Interactive design and demo of this system (rendered from this branch's art and manifest): https://claude.ai/code/artifact/774884e3-d479-4ece-9423-63d82600e333

Screenshots

Sticker art set (12442e8), rendered plain on the page at 88 / 32 / 24 px. Assets live on the pr-assets-2929 branch, deleted after merge.

State Shot
Badge → its three avatars, every badge with more than 20 holders (16) badge avatars
Basics (20, everyone) basic avatars
Day-0 fallback: the username's first letter as a graffiti sticker (a–z; other initials keep the initials circle) letter avatars
In-app (?__fixture=avatar-picker, ?__fixture=home-avatar, 375×667) ds-shots visual diff on the latest head: #2929 (comment)

Design notes / accepted trade-offs

  • Stickers render plain on the page (no circle, no palette ring): a sticker carries its own colours and edge. The avatarPaletteClass helper went with the circle.

Twenty basic avatars plus three per badge for Bug Whisperer and Offramp User,
drawn for avatar size in the badge house style. badge-assets.json now carries
the avatar slugs from the API catalog, so the UI can build the picker and
assert the files exist without keeping its own list of badge codes.
…ial (TASK-22142)

The generated dot face is gone: it was not launch-ready and drew on name
strings. UserAvatar renders the user's picked character on its palette, or
exactly one character of the username on the username's color — never the
full name or verification data. The dead UserHeader component goes with it.
… to it (TASK-22142)

Two groups — what the user's badges unlocked, then the basics — a tap saves
at once, the die randomizes across the whole pool for free, and a badge that
ships avatars says so in the earn toast with a Choose avatar control that
deep-links to /profile?avatarPicker=true (nuqs URL state). Fixture routes may
now carry their own query so ds-shots can capture the open picker.
@vercel

vercel Bot commented Sep 2, 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 3, 2026 1:22pm UTC

Request Review

@notion-workspace

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Sep 2, 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: ba636752-c667-4d29-8ba8-792cfec3f672

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.

…he generated face

The dot-face avatar (497ab2a) reads as a random emoji on the home chip
and the profile header. Regress to the initials avatar in a first-letter
mode for the user's own identity; contacts keep two-letter initials.
The face is parked until avatar v2 — recover it from 497ab2a.

Claude-Session: https://claude.ai/code/session_01BQuSkygKUt9jbs2N4iCKtk
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7063.56 → 7097.14 (+33.58)
Findings: +11 net (+48 new, -37 resolved)

🆕 New findings (48)

  • critical complexity — src/utils/demo-api.ts — CC 122, MI 58.66, SLOC 1007
  • critical complexity — src/dev/fixtures/registry.ts — CC 1, MI 21.77, SLOC 342
  • high hotspot — src/components/Profile/index.tsx — 43 commits, +303/-254 lines since 6 months ago
  • medium high-mdd — src/components/Profile/index.tsx:31 — Profile: MDD 71.9 (uses across many lines from declarations)
  • medium high-mdd — src/components/Avatar/AvatarPicker.tsx:27 — AvatarPicker: MDD 54.6 (uses across many lines from declarations)
  • medium high-mdd — src/components/Badges/BadgeEarnToast.tsx:37 — BadgeEarnToast: MDD 41.5 (uses across many lines from declarations)
  • medium high-mdd — src/components/Profile/components/ProfileHeader.tsx:28 — ProfileHeader: MDD 32.8 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/query/user.ts:25 — useUserQuery: MDD 32.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/UserHeader/index.tsx:59 — VerifiedUserLabel: MDD 27.7 (uses across many lines from declarations)
  • medium complexity — src/components/Avatar/avatar.utils.ts — CC 26, MI 67.27, SLOC 59
  • medium complexity — src/components/Avatar/AvatarPicker.tsx — CC 26, MI 60.91, SLOC 126
  • medium complexity — src/components/Profile/index.tsx — CC 25, MI 61.55, SLOC 85
  • medium high-mdd — src/hooks/query/user.ts:30 — fetchUser: MDD 25.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/Badges/BadgeEarnToast.tsx:49 — : MDD 24.0 (uses across many lines from declarations)
  • medium high-mdd — src/components/Profile/AvatarWithBadge.tsx:41 — AvatarWithBadge: MDD 24.4 (uses across many lines from declarations)
  • medium complexity — src/components/Profile/AvatarWithBadge.tsx — CC 23, MI 59.27, SLOC 40
  • medium complexity — src/dev/fixtures/active.ts — CC 22, MI 64.36, SLOC 55
  • medium method-complexity — src/components/Profile/AvatarWithBadge.tsx:41 — CC 18 SLOC 29
  • medium complexity — src/components/UserHeader/index.tsx — CC 18, MI 63.82, SLOC 37
  • medium complexity — src/hooks/query/user.ts — CC 18, MI 54.91, SLOC 79

…and 28 more.

✅ Resolved (37)

  • src/utils/demo-api.ts — CC 121, MI 58.78, SLOC 1003
  • src/dev/fixtures/registry.ts — CC 1, MI 22.57, SLOC 319
  • src/components/Profile/index.tsx — 41 commits, +285/-247 lines since 6 months ago
  • src/components/Profile/index.tsx:28 — Profile: MDD 67.0 (uses across many lines from declarations)
  • src/components/Badges/BadgeEarnToast.tsx:35 — BadgeEarnToast: MDD 33.1 (uses across many lines from declarations)
  • src/hooks/query/user.ts:26 — useUserQuery: MDD 31.6 (uses across many lines from declarations)
  • src/components/UserHeader/index.tsx:51 — VerifiedUserLabel: MDD 27.7 (uses across many lines from declarations)
  • src/components/Profile/AvatarWithBadge.tsx:35 — AvatarWithBadge: MDD 25.8 (uses across many lines from declarations)
  • src/hooks/query/user.ts:31 — fetchUser: MDD 24.9 (uses across many lines from declarations)
  • src/components/Profile/index.tsx — CC 24, MI 60.85, SLOC 80
  • src/components/Profile/components/ProfileHeader.tsx:24 — ProfileHeader: MDD 23.3 (uses across many lines from declarations)
  • src/features/home/views/HomeTopNav.tsx:20 — HomeTopNav: MDD 23.0 (uses across many lines from declarations)
  • src/components/Profile/AvatarWithBadge.tsx — CC 22, MI 58.51, SLOC 43
  • src/dev/fixtures/active.ts — CC 21, MI 63.75, SLOC 51
  • src/components/Profile/AvatarWithBadge.tsx:35 — CC 18 SLOC 35
  • src/components/UserHeader/index.tsx — CC 18, MI 64.92, SLOC 34
  • src/hooks/query/user.ts — CC 18, MI 55.88, SLOC 73
  • src/components/Badges/BadgeEarnToast.tsx — CC 15, MI 58.5, SLOC 82
  • src/components/Profile/index.tsx:28 — CC 15 SLOC 62
  • src/components/Profile/components/ProfileHeader.tsx — CC 11, MI 62.43, SLOC 34

…and 17 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/components/Avatar/UserAvatar.tsx 0.0 7.4 +7.4
src/components/Avatar/AvatarPicker.tsx 0.0 7.0 +7.0
src/components/Avatar/avatarPicker.utils.ts 0.0 5.4 +5.4
src/components/Avatar/avatar.utils.ts 0.0 4.8 +4.8
src/components/Profile/avatar-size.consts.ts 0.0 2.5 +2.5
src/components/Avatar/avatar.consts.ts 0.0 2.1 +2.1
src/components/Profile/components/ProfileHeader.tsx 8.2 9.2 +1.1
src/components/Profile/AvatarWithBadge.tsx 7.6 8.3 +0.7
src/app/dev/fixtures/FixtureList.tsx 3.5 4.0 +0.5

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 5600 ran, 0 failed, 0 skipped, 1.8m

📊 Coverage (unit)

metric %
statements 74.8%
branches 60.9%
functions 68.4%
lines 75.8%
⏱ 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 › MANTECA_MERCHANT_VOLUME_NEAR_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_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_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 › 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 › 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 › a refused idempotency key tells the user to scan again, not to contact support
3.1s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
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 2, 2026

Copy link
Copy Markdown
Contributor

🖼 Visual diff — 6 screens moved

12 of 60 shots changed · 48 identical · baseline 8e7f1a2 → head 427292f

worst % screen widths
11.93% hugo-long-username 320, 430
10.99% long-full-name 320, 430
10.30% profile-edit 320, 430
9.87% profile 320, 430
5.84% home 320, 430
2.44% empty-home 320, 430
new screens (2)
  • avatar-picker
  • home-avatar

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.

…142)

The one-letter own-avatar now comes from AvatarWithBadge's firstLetterOnly
mode (#2924, cherry-picked underneath). UserAvatar only adds the picked-key
branch and otherwise renders that same fallback, so the fallback lives in
one place. HomeTopNav and the self ProfileHeader pass the pick through.
The picked-avatar circle used an inline style for its triple; the ratchet
counts inline styles and only lets them go down. Emit the seven avatar
triples as full class literals instead, like the rest of the app.
Three avatars per badge (prod holder counts, 2026-09-02), drawn for the
circle in the house style; SUPPORT_SURVIVOR reuses the Bug Whisperer set.
Manifest regenerated from the API catalog.
Support Survivor shared Bug Whisperer's three avatars, so the 14 users who
hold both saw six identical tiles. Bandage, headset and beer are its own
(API bbec8dd4 declares the same slugs).

@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 — changes requested

One major finding remains: P1 is still present, while P2 is fixed at this head.

Findings

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:61 · Serialize avatar saves so the last tap wins
    Tap avatar A and then B before the first request settles. Both updates run concurrently; if A completes after B, the server ends on A, and each completion also refreshes the user, so the older refresh can overwrite the optimistic B selection. A failed older request can likewise roll the UI back after B succeeds. Serialize or coalesce avatar mutations and only roll back or refresh the request that is still current; add a deferred-promise test proving the final tap wins when completions reverse.

  • BLOCKING · src/types/badge-assets.json:97 · [claude-opus] SUPPORT_SURVIVOR avatar slugs disagree with the API's generated manifest
    src/types/badge-assets.json declares "SUPPORT_SURVIVOR": ["beetle", "shell", "peek"] (line 97), and the art shipped in this PR matches it: public/avatars/badge/SUPPORT_SURVIVOR/{beetle,shell,peek}.svg. The other half of this change, peanut-api-ts#1498, declares the opposite — BADGE_CATALOG gives SUPPORT_SURVIVOR avatars: ['bandage', 'headset', 'beer'], and its generated docs/badge-assets.json carries the same three, with an explicit comment that this set must NOT be a copy of Bug Whisperer's because 14 users hold both badges and would otherwise see six tiles of the same three faces. So the pair PR does not handle this; it is where the disagreement comes from.

This is a real break in both directions, and neither repo's CI catches it. At runtime, a SUPPORT_SURVIVOR holder opening the picker sees tiles keyed badge.SUPPORT_SURVIVOR.beetle|shell|peek; POST /update-user validates against avatarPoolOf(heldBadges), which contains only badge.SUPPORT_SURVIVOR.bandage|headset|beer, so every tap on that group 400s with 'Avatar not unlocked' and the picker snaps back with the generic save-failed toast. Conversely the three keys the API does bless resolve to no art here — avatarSrc returns null and UserAvatar silently drops to the first-letter fallback. On the API side, validateUiAssets would flag the three missing files, but it only runs when a peanut-ui root is found, and per the comment above resolveUiRoot API CI has no peanut-ui checkout, so pnpm badge:check passes there. On this side, avatar.utils.test.ts only asserts that every slug in the vendored manifest has a file in public/ — true, because both were hand-written together — so it cannot see the drift either.

The file header of avatar.utils.ts states this manifest is regenerated by pnpm badge:check --write-manifest in peanut-api-ts and 'never hand-edited', i.e. it claims to be a verbatim copy of the API artifact; that claim is what makes this blocking rather than a normal open-pair mismatch.

Fix: take whichever set the API PR lands with (currently bandage/headset/beer), copy the API's docs/badge-assets.json into src/types/badge-assets.json verbatim, and rename or redraw the three SVGs under public/avatars/badge/SUPPORT_SURVIVOR/ to match. If the intent was actually to reuse Bug Whisperer's faces, the change belongs in the API's BADGE_CATALOG so the generated manifest is the one that moves.

Checked clean

  • Verified the supplied detached worktree head, trusted author, dev base ref, exact base SHA, and PR merge base.
  • Rechecked P1: every tap still starts an independent update and refresh, with no serialization or stale-response guard; the tests cover only one settled save at a time.
  • Rechecked P2: UserAvatar now uses static palette token classes instead of an inline style; the local design-system ratchet and exact-head ds-lint check both pass.
  • Reviewed avatar key whitelisting, badge-derived unlock pools, optimistic rollback, user refresh propagation, profile deep-link state, badge-earned handoff, and fallback rendering.
  • Inspected all 71 local avatar SVGs for active content and external references; none contain scripts, event handlers, foreign objects, images, uses, styles, or hrefs.
  • Checked locale key parity for the full app bundles, manifest-to-file coverage, diff whitespace, focused tests, and the cross-repository API boundary available from the sibling policy checkout.
  • Exact-head CI is complete: ci-success, unit, typecheck, eslint, format, analyze, ds-lint, ds-shots, human-authors, bot-approval, preview deployment, and review checks all succeeded.

Second opinion by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Third opinion by claude-opus: 1 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: 5b27877336f4 · Context: repo, api · Took 21m (queued 4m)

Comment thread src/components/Avatar/AvatarPicker.tsx Outdated
Comment thread src/types/badge-assets.json Outdated
@abalinda
abalinda marked this pull request as ready for review September 2, 2026 22:34
Copilot AI lite review requested due to automatic review settings September 2, 2026 22:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Guard manifest lookups against prototype names; pending-override save with
one refetch per burst; roving focus from the pick; one source for the
picker URL param; human tile labels and an accessible picture branch;
explicit toast tap targets; shared avatar size map; fixtureHref for
query-carrying fixture routes; DS shadow token on the selected tile.
The manifest now lists every catalog badge, [] where art is pending.
Twenty basics at once was a wall. The picker now offers five — the current
pick if it is a basic plus random fill — and the dice rerolls that row and
never the pick, Split's semantics: the dice changes what is offered, not
who you are. Dice, not die, everywhere users read it.
Two taps whose responses land out of order: the refetch fires once when the
last request lands, so the newest write is what the tile shows, and a failed
older request can no longer roll back a newer success. Deferred-promise
tests cover both orders. Also prettier on the copied manifest.
It is the legacy code of Bug Whisperer, so it unlocks nothing; the manifest
lists it as [] like every badge still waiting for art.
…e (TASK-22142)

In demo and fixture mode a pick made through the picker vanished on the
refetch that follows it, so the tile snapped back. The demo user now holds
avatarKey (string sets, null clears, absent leaves it), and users/me returns
it; fixture overrides still merge on top.

@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 — changes requested

Request changes: the P1/P3 save race and P4 fallback mismatch remain, and demo-mode refetch discards saved picks. P2 is fixed; P5 is not reachable behind the protected-route hydration gate.

Findings

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:54 · Serialize avatar writes so the last tap wins
    The in-flight counter only postpones the refetch; it does not order the POSTs. If A is tapped, then B, and B commits before slower A, the server ends on A and the final fetch faithfully rolls the UI back to the first tap. The deferred test hard-codes B into mockFetchUser instead of modeling server state. Queue or coalesce writes so B is sent after A settles (or version them server-side), and test the backing state under both completion orders.

  • MINOR · src/features/home/views/HomeTopNav.tsx:38 · Keep the fallback initial tied to the username
    HomeTopNav receives avatarName, which useHomeFlow still replaces with fullName when showFullName is true and also uses fullName when the username is absent. With username alice, fullName Bea, and no avatar key, home renders B while the self profile renders A, contrary to this PR's username-initial fallback. Pass the username specifically to UserAvatar and keep display-name preferences out of the avatar seed.

  • MAJOR · src/utils/demo-api.ts:388 · Read the mutable demo avatar during auth refresh
    This mutable /users/me response is bypassed by useUserQuery, whose demo-mode branch dispatches the static DEMO_USER directly. After a demo user picks an avatar, POST /update-user sets demoAvatarKey, but AvatarPicker immediately awaits fetchUser(); that refresh restores static DEMO_USER and clearing pending snaps back to the initial. Route demo-mode fetchUser through the demo /users/me handler (or share the same mutable profile state) and cover save followed by the real auth refresh.

  • MINOR · src/i18n/app/messages/es-AR.json:5 · [moonshotai/kimi-k3] es-AR bundles drop userAvatarAlt, which the new UserAvatar still uses
    UserAvatar (added in this PR) calls useTranslations('common') and sets the picked avatar's role="img" label via t('userAvatarAlt', { username: name }); en.json keeps common.userAvatarAlt unchanged. This PR deletes the key from es-AR.json (and es-AR.marketing.json) — apparently as DotFaceAvatar cleanup, but DotFaceAvatar was replaced, not the key. For es-AR users the aria-label now falls back to the English string, or, where a locale is not merged over en, next-intl's missing-message fallback renders the raw key path as the accessible name. Fix: restore "userAvatarAlt": "Avatar de {username}" in both es-AR files, or point UserAvatar at a key that exists in every locale.

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:58 · [moonshotai/kimi-k3] A rejected avatar save leaks the in-flight counter and wedges the picker
    In save(), inFlight.current += 1 is followed by const { error } = await updateUserById(...) with no try/finally, and the call site is fire-and-forget (void save(key)). If the action rejects — offline/network failure or any thrown transport error, as opposed to a 400 returned as { error } — the decrement, the error toast, fetchUser() and setPending(undefined) are all skipped and the rejection is unhandled. The counter never returns to 0, so every subsequent tap still writes to the API but never triggers the reconciling fetchUser() or clears pending: the failed pick stays checked with no error shown, and the home slot / profile header keep rendering the stale avatar until a full page reload. Fix: wrap the call in try/catch (toast on catch) and do inFlight.current -= 1 plus the inFlight.current === 0 reconciliation in a finally block.

Checked clean

  • Exact detached head, trusted author, dev base SHA, and merge base matched the supplied review target.
  • SUPPORT_SURVIVOR now has an empty manifest entry and no asset directory; all 20 basic and 48 declared badge SVGs are present, with no executable or external SVG references.
  • Protected /profile rendering waits for the user query before mounting Profile, so the prior P5 initial-hydration scenario is not reachable on the shipped route.
  • Avatar path construction rejects unknown and prototype-derived keys; badge-toast routing and self-versus-public profile avatar behavior were reviewed.
  • All checks at the exact head completed green, including unit, typecheck, eslint, format, ds-lint, ds-shots, analyze, preview, and provenance gates.

Second opinion by moonshotai/kimi-k3: 2 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

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: 174df8fb9503 · Context: repo, peanut-api-ts sibling · Took 18m (queued 2m)

Comment thread src/components/Avatar/AvatarPicker.tsx Outdated
Comment thread src/features/home/views/HomeTopNav.tsx Outdated
Comment thread src/utils/demo-api.ts
Comment thread src/i18n/app/messages/es-AR.json
Comment thread src/components/Avatar/AvatarPicker.tsx Outdated
…-22142)

Art direction ruling: the geometric vectors are replaced by the illustration
pipeline's sticker art, so an avatar looks like the rest of Peanut's badge art
instead of a second, flatter system next to it.

WebP, not SVG, because the art is drawn, not diagrammed. These render at 32px
on home and 88px on the profile header; a 176px WebP is exact at both and stays
one file. Vectorizing raster art of this kind costs far more bytes than it
saves. `avatarSrc` and the tests build `.webp` paths now; nothing else changes,
since the manifest names slugs and never file types.

Every slug and directory in the manifest is unchanged, so this is an art-only
diff in one repo: no peanut-api-ts edit, no manifest regen, no contract to
sequence. Slug words and the art they now name have drifted apart (basic.apple
draws a cherry) — that is an API-side rename, tracked separately.

Day 0 gets art too: a user who has not picked wears their initial as a graffiti
sticker rather than a letter in a circle. `letterAvatarSrc` is deliberately not
a manifest entry — a letter is never a pick, only what an unpicked user looks
like. A name that does not start with a-z still falls through to the
first-letter avatar, and AvatarWithBadge is untouched, so counterparty rows keep
their initials.

UserHeader linked to /profile but drew a bare initial, so a user's own avatar
changed shape between /home and that header. It renders UserAvatar now.
@0xkkonrad

Copy link
Copy Markdown
Contributor

@/tmp/claude-1000/-workspaces-sandbox/d309fdf1-950a-4136-8a45-848ea17601d6/scratchpad/comment.md

@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 — changes requested

Request changes: rapid avatar taps can still persist an earlier choice, and the home fallback can still disagree with the self-profile fallback by deriving its initial from the full name. The manifest, demo refresh, and locale concerns are resolved at this head.

Findings

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:54 · Serialize avatar writes so the last tap wins
    Two rapid taps still start two independent POSTs. If A is tapped first, B second, B commits first, and slower A commits last, the database ends on A; waiting for the in-flight count to reach zero only makes the final refetch reveal A. The deferred tests force the refetch mock to return B instead of applying each write in completion order. Queue or coalesce saves so B cannot be sent until A finishes, then refetch once after the queue drains.

  • MINOR · src/features/home/views/HomeTopNav.tsx:38 · Keep the fallback initial tied to the username
    For username alice, full name Zelda Example, showFullName=true, and no avatar key, useHomeFlow passes the full name as avatarName, so this line renders the Z sticker on home while the self profile passes the username and renders A. Pass the username to UserAvatar here, retaining the generic fallback only when no username exists.

  • BLOCKING · src/components/Avatar/avatar.utils.ts:52 · [claude-opus] Avatar art ships as .webp, but the API half checks and documents .svg
    peanut-api-ts#1498 — the other half of TASK-22142 — builds the cross-repo asset paths itself, with the extension baked in:

    scripts/check-badge-catalog.ts, validateUiAssets():
    for (const slug of badge.avatars) check(code, /avatars/badge/${code}/${slug}.svg)
    for (const slug of BASIC_AVATAR_SLUGS) check('basic', /avatars/basic/${slug}.svg)

and docs/BADGES.md in the same PR tells every future badge author to "Add 1–3 profile avatars next to it under public/avatars/badge/<CODE>/<slug>.svg".

This PR ships 0 .svg and 94 .webp under public/avatars (48 badge + 20 basic + 26 letter), and src/components/Avatar/avatar.utils.ts:52,54,85 builds .webp paths. The manifest itself (src/types/badge-assets.json) matches the API's byte-for-byte, so the slugs agree — only the file type does not, and the API is the side that decides the file type when it constructs the path.

Where it breaks, concretely:

  1. pnpm badge:check in peanut-api-ts resolves a UI root automatically from a sibling peanut-ui checkout (scripts/check-badge-catalog.ts:78-81), and BADGES.md:305 tells devs to run --ui-root ../peanut-ui --require-ui. With both PRs on disk that run throws Missing UI badge assets listing all 68 badge+basic paths. API CI runs pnpm badge:check with no UI root (.github/workflows/tests.yaml:89), so CI stays green and this only bites on a developer machine and on the very check that is supposed to be the cross-repo guard — it validates nothing real today.
  2. Durably: a badge author who follows BADGES.md adds <slug>.svg, badge:check passes, and the picker then renders a broken tile, because avatarSrc only ever asks for <slug>.webp.

The head commit says "Every slug and directory in the manifest is unchanged, so this is an art-only diff in one repo: no peanut-api-ts edit, no manifest regen, no contract to sequence" and "nothing else changes, since the manifest names slugs and never file types". The manifest indeed names only slugs — but the API's validator and its docs supply the extension, so the WebP ruling is not confined to this repo.

Fix: this needs a paired edit in peanut-api-ts#1498 before that PR merges — change the two .svg literals in validateUiAssets to .webp and the public/avatars/badge/<CODE>/<slug>.svg line in docs/BADGES.md to .webp. If the extension is meant to be the UI's to choose, put it in the manifest (e.g. an avatars.ext field) instead of hardcoding it on the API side. I can only see peanut-api-ts#1498 as posted, not its current head, so if that PR has already been moved to .webp this is already handled.

Checked clean

  • Verified the detached worktree HEAD, exact merge base, trusted PR author, base ref, and base SHA without fetching issue or review comments.
  • Re-evaluated P1 and P3 as the same rapid-tap race; the in-flight counter delays refetching but does not serialize backend writes, so P3 remains actionable.
  • Re-evaluated P2 against paired API commit b165c6c8: the UI manifest matches the generated API manifest semantically, SUPPORT_SURVIVOR is empty in both, and exact-head unit and asset checks pass.
  • Re-evaluated P4 and confirmed the home fallback still receives the full-name-derived avatarName while ProfileHeader uses the username.
  • Re-evaluated P5: demo /users/me now reads the mutable demoAvatarKey and the exact-head unit test covers set, preserve-on-omission, and clear.
  • Re-evaluated P6: es-AR is a delta catalog merged over es-419, so removing the duplicate userAvatarAlt override does not drop the resolved key.
  • Re-evaluated P7: updateUserById catches fetch and JSON failures and resolves them as { error }, so its production contract does not reject and cannot leak the counter on that path.
  • Reviewed avatar key validation and path construction, badge unlock rendering, picker failure and keyboard paths, profile/home integration, badge-earned deep linking, demo fixtures, and removed DotFaceAvatar references.
  • Exact-head CI completed successfully for unit, typecheck, ESLint, format, design-system lint and screenshots, analysis, preview deployment, provenance, and aggregate success checks.
  • Focused tests could not be rerun locally because the detached worktree has no node_modules; exact-head CI supplied the test evidence and no worktree files were changed.

Second opinion by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Third opinion by claude-opus: 1 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: 12442e8f3373 · Context: repo, other_repository · Took 21m

Comment thread src/components/Avatar/AvatarPicker.tsx Outdated
Comment thread src/features/home/views/HomeTopNav.tsx Outdated
Comment thread src/components/Avatar/avatar.utils.ts

@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

Three findings remain: concurrent taps can persist the older avatar, the home fallback can use the full-name initial, and the paired API asset validator still requires SVG while this UI ships WebP. CI is green at the exact head.

Findings

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:54 · Concurrent saves can commit the older tap last
    Tap avatar A, then B while A is pending. Both update-user calls run concurrently; if B completes first and A completes last, A is the final database write. The single refetch then restores A, so the first tap wins. The new test hard-codes the mock server to B and cannot catch this. Serialize or coalesce writes so only one is active and any newer pending key is sent after it.

  • MINOR · src/features/home/views/HomeTopNav.tsx:38 · Fallback avatar still follows the full name
    For username bob, full name Alice Example, and showFullName=true, useHomeFlow supplies the full name as avatarName, so this renders the A sticker and hashes its palette from the full name. The fallback is meant to stay keyed to the username; pass username to UserAvatar independently of the displayed name.

  • MAJOR · src/components/Avatar/avatar.utils.ts:52 · Cross-repo asset check still expects SVG
    The paired API's documented pnpm badge:check --ui-root ... --require-ui builds .svg paths for all 68 profile avatars. This PR's resolver and every added avatar file use .webp, so that check reports every avatar missing even though the UI's own test passes. Align the paired checker/docs and the UI asset format before rollout, or ship the SVG files the contract names.

  • MAJOR · src/components/Avatar/avatar.utils.ts:52 · [claude-opus] Avatar art is .webp here, .svg in the API half's asset check
    This PR ships every avatar as WebP (public/avatars/basic/.webp, public/avatars/badge//.webp, public/avatars/letter/*.webp) and avatarSrc/letterAvatarSrc build .webp paths (src/components/Avatar/avatar.utils.ts:52, :54, :85). The manifest carries slugs only, so the extension is the unwritten half of the contract — and the other side spells it .svg.

Evidence, from the matching open PR peanut-api-ts#1498 (not in the pinned checkout, so this is the pair's other half): scripts/check-badge-catalog.ts gains for (const slug of badge.avatars) check(code, /avatars/badge/${code}/${slug}.svg) and for (const slug of BASIC_AVATAR_SLUGS) check('basic', /avatars/basic/${slug}.svg), and docs/BADGES.md documents public/avatars/badge/<CODE>/<slug>.svg as the required art. validateUiAssets already runs against any sibling UI checkout — resolveUiRoot auto-detects ../peanut-ui even with no --ui-root flag (scripts/check-badge-catalog.ts in the pinned checkout, resolveUiRoot). So once both halves land, pnpm badge:check on any machine with both repos checked out throws Missing UI badge assets, listing all 71 declared avatar slugs, and the docs tell the next badge author to commit SVGs that this repo will never render.

Fix on one side, not both: either change the two .svg literals and the BADGES.md path in peanut-api-ts#1498 to .webp, or re-export the art as SVG here. WebP looks deliberate (the files are 6–17KB raster stickers), so the API half is the one to change. Note this is a pairing, not a merge blocker for this repo alone: nothing in peanut-ui breaks at runtime, and the UI-side test in src/components/Avatar/tests/avatar.utils.test.ts already asserts the .webp files exist.

Checked clean

  • Detached HEAD, exact base, merge base, trusted author, and PR target all match the supplied values.
  • CI at the exact head is green, including unit, typecheck, eslint, format, native-export, ds-shots, and ci-success.
  • P2 is fixed: the UI and paired API avatar manifests agree semantically, SUPPORT_SURVIVOR has no set, and all 68 declared avatar assets exist as WebP.
  • P5 is fixed: demo update-user mutates demoAvatarKey and users/me reads that mutable value.
  • P6 does not apply: es-AR is a delta catalog merged over es-419, which contains the new avatar strings.
  • P7 does not apply: updateUserById catches transport and parsing failures and returns an error result, so the counter decrement path still executes.
  • Unknown and traversal-shaped avatar keys cannot form image paths because avatarSrc accepts only manifest-listed slugs.
  • Profile/self surfaces, badge-toast handoff, picker keyboard behavior, fixtures, and demo-state wiring were reviewed.

Second opinion by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Third opinion by claude-opus: 1 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: 60f9c09026a1 · Context: repo, sibling-repo, ci · Took 12m

Comment thread src/components/Avatar/AvatarPicker.tsx Outdated
Comment thread src/features/home/views/HomeTopNav.tsx Outdated
Comment thread src/components/Avatar/avatar.utils.ts
Comment thread src/components/Avatar/avatar.utils.ts
…demo fetch reads the demo profile (Chip, #2929)

Saves now go one at a time, always the latest tap next, with one refetch in
a finally, so the server can never commit an older key last and a thrown
save cannot wedge the picker. The home chip's letter and palette seed from
the username like the profile header, not the display name. In demo mode
fetchUser reads the demo /users/me handler, so a pick survives the refetch
that follows it.

@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

One last-tap-wins race remains in the serialized avatar save drain; the other supplied prior findings are fixed at this head.

Findings

  • MAJOR · src/components/UserHeader/index.tsx:21 · [moonshotai/kimi-k3] UserHeader calls useAuth() with no visible import
    src/components/UserHeader/index.tsx line ~21 adds const { user: authenticatedUser } = useAuth(), but the import hunk (lines 1–6) only swaps AvatarWithBadge for UserAvatar — no import { useAuth } from '@/context/authContext' is added anywhere in the diff. Unless the file already imported useAuth (nothing in the old hunk suggests it did — the component previously took only username from props), this is a TS/compile error that breaks the build, and there is no UserHeader test in the diff to catch it. Fix: add the useAuth import (and cover UserHeader with a render test).

Answered by a human, not raised again

These are still present as far as this review can tell. You resolved the thread or deferred the fix, so Chip files them instead of repeating them.

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:54 · Drain taps queued during the final refetchtask

Checked clean

  • Confirmed the detached worktree head, merge base, trusted author, and dev base exactly match the supplied values.
  • Rechecked the supplied prior findings: username fallback identity, demo profile refresh, Support Survivor's empty set, WebP paths, and es-AR inheritance are fixed at this head.
  • Checked avatar-key parsing and public-path construction against malformed and unlisted keys; paths are manifest-whitelisted rather than interpolated from arbitrary input.
  • Checked manifest/art parity: all 68 selectable manifest assets exist with no extras, and all 94 avatar files including letter fallbacks have RIFF WebP signatures.
  • Reviewed profile, home, badge-toast, fixture, and locale integration for regressions.
  • Exact-head CI completed successfully for unit, typecheck, eslint, format, native export, design-system lint, visual snapshots, analysis, and preview deployment.
  • A focused local Jest rerun was unavailable because the detached worktree has no node_modules; the exact-head unit CI run is successful.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

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: 7a9778d96524 · Context: repo, product, sibling-repo · Took 13m (queued 2m)

Comment thread src/components/UserHeader/index.tsx
…e ring

Art direction, 2026-09-03: a sticker carries its own colours and edge, so
UserAvatar renders the picked or letter sticker at full size with nothing
behind it. The palette-triple hash and its classes go with the circle.

@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

One major concurrency defect remains: a selection made during the final profile refetch is dropped and never sent.

Findings

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:64 · Drain taps queued during the final refetch — also flagged by moonshotai/kimi-k3
    After the last POST completes, drain exits the loop and awaits fetchUser(). If the user taps another avatar during that await, save stores the new key in wanted.current but sees draining.current === true, so it does not start another drain. When the refetch resolves, finally clears the optimistic pick without consuming that key: the server keeps the previous avatar and the UI snaps back. Before clearing pending, restart or continue the drain whenever wanted.current was populated during the refetch, and cover that timing with a deferred-refetch test.

  • MAJOR · src/components/Avatar/AvatarPicker.tsx:64 · [claude-opus] Tap during the closing refetch is dropped, and no test covers it
    The picker mutates persisted user state (users.avatar_key via POST /update-user), so the hard rule applies to every path through drain. The burst path is tested well — sends one save at a time… and a rejected first save… both pin the serialized ordering — but the window after the loop drains is not.

In src/components/Avatar/AvatarPicker.tsx:54-67 the while (wanted.current !== undefined) loop exits, then await fetchUser() runs with draining.current still true. A tap during that await goes through save, which sets wanted.current = key and then skips starting a drain because draining.current is true (line 75). The loop it was queued for has already ended, so the POST is never sent. The finally then does setPending(undefined) (line 67), so the tile visibly snaps back to the previously committed key and the user's last tap is silently lost — no toast, no retry.

The untested case, exactly: tap A, settle A's POST, tap B while fetchUser() is still in flight, then resolve fetchUser(). Expected: a POST for B is sent and B ends up committed. Actual: mockUpdateUserById is called once, with A.

Fix: drain again after the refetch instead of dropping what queued during it — e.g. wrap the loop-plus-refetch in do { …loop…; await fetchUser() } while (wanted.current !== undefined), and only clear draining/pending once wanted.current is undefined. Add the four-step test above alongside the existing serialization tests.

Checked clean

  • Confirmed the detached worktree HEAD and merge base exactly match the supplied head and base SHAs, then reviewed the complete base-to-head diff.
  • Checked avatar key parsing, badge unlock filtering, URL-state entry points, profile/home rendering, keyboard behavior, failure handling, and demo persistence; no additional correctness or security defect survived adversarial review.
  • Verified the UI manifest declares 20 basic and 48 badge avatars, SUPPORT_SURVIVOR declares none and has no asset directory, and every declared avatar plus all 26 letter fallbacks is present.
  • Verified es-AR resolves missing avatar strings through its es-419 base catalog.
  • Exact-head CI is green for typecheck, lint, formatting, unit tests, native export, design-system checks, and screenshots; the older failed aggregate belonged to a superseded run whose required jobs were canceled.
  • git diff --check passes.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Third opinion by claude-opus: 1 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: e7fbf4919679 · Context: repo, sibling-api, ci · Took 8m (queued 3m)

Comment thread src/components/Avatar/AvatarPicker.tsx Outdated
Chip: after the last POST the drain awaited fetchUser with draining still
set, so a tap in that window queued on wanted and was cleared by the
finally without ever being sent. The loop now repeats until nothing is
queued after the refetch; test covers tap A, settle A, tap B mid-refetch.

@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

No actionable defects remain at this head; the closing-refetch race and all earlier concerns are fixed or refuted.

Findings

  • MINOR · src/components/UserHeader/index.tsx:35 · [moonshotai/kimi-k3] UserHeader renders the authenticated user's avatar under whatever username prop it is given
    UserHeader takes a username prop and displays it, but unconditionally passes authenticatedUser?.user.avatarKey to UserAvatar. If this component is ever rendered for anyone other than the logged-in user (e.g. reused on a counterparty or search-result header), it shows the viewer's picked avatar next to someone else's name, misattributing identity. Fix: compare username to authenticatedUser?.user.username (case-insensitively, as ProfileHeader does) and only pass avatarKey when they match, otherwise render the plain initial avatar.

Checked clean

  • Confirmed the detached worktree HEAD, exact base SHA, merge base, trusted author, and dev target.
  • Reviewed the complete 131-file diff across avatar selection, profile/home integration, badge toast routing, demo state, fixtures, translations, tests, and assets.
  • Verified serialized last-tap-wins saves, queued taps during the closing refetch, failed-save recovery, and the focused concurrency tests.
  • Verified manifest/path guards, prototype and traversal rejection, 68 unique safe selectable keys, and Support Survivor's empty avatar set.
  • Decoded all 94 added WebP assets: every file is a valid 176x176 RGBA WebP.
  • Verified username-based fallback identity, es-AR inheritance through es-419, mutable demo avatar refetches, and picker deep-link query composition.
  • Checked exact-head CI: unit, typecheck, eslint, format, design-system, analysis, provenance, and ownership checks succeeded.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Third opinion: did not run — claude-api_error. This review is one reviewer short.

Exact head: 427292f585c3 · Context: repo · Took 12m (queued 11m)

@abalinda
abalinda merged commit 7217950 into dev Sep 3, 2026
25 checks passed
@0xkkonrad

Copy link
Copy Markdown
Contributor

Folded in the approved profile-header change (9e6f579): on your own profile the avatar, the username line and the share pill are now one 72px pill — avatar segment opens the picker, the rest shares, two hit areas inside one border rather than a nested button. Other people's profiles keep the stacked header.

Spec: https://claude.ai/code/artifact/45ec1b09-b7c5-4d7c-a0f1-d6bfd8162d00

Also returned the verified check to text-success-1 (it had become stock text-green-500).

abalinda added a commit that referenced this pull request Sep 3, 2026
Conflicts in useHomeFlow: dev (#2929, TASK-22142) removed avatarName and
seeds the top-nav chip from the username directly, which supersedes this
branch's avatar-seeding fix. Took dev's version of the hook and its test.
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.

3 participants