Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bc6b698
feat(avatars): add the avatar art and its manifest (TASK-22142)
abalinda Sep 2, 2026
1bb0ea5
feat(avatars): wear the picked avatar, fall back to one username init…
abalinda Sep 2, 2026
a164fb9
feat(avatars): picker drawer with a die, badge-earned toast hands off…
abalinda Sep 2, 2026
2df3f33
fix(avatar): own avatar shows the first letter of the username, not t…
abalinda Sep 2, 2026
a08058b
feat(avatars): stack the picker on the first-letter fallback (TASK-22…
abalinda Sep 2, 2026
61ffd41
fix(avatars): palette by class, not inline style (ds-lint ratchet)
abalinda Sep 2, 2026
5b27877
feat(avatars): art for the fifteen badges with more than 20 holders
abalinda Sep 2, 2026
b5a297d
fix(avatars): Support Survivor gets its own set
abalinda Sep 2, 2026
fefef4f
fix(avatars): review batch for #2929
abalinda Sep 2, 2026
10f29ae
feat(avatars): one row of five basics, the dice redeals it (TASK-22142)
abalinda Sep 2, 2026
38c5391
fix(avatars): the server's answer wins after a save burst (Chip, #2929)
abalinda Sep 2, 2026
1898421
fix(avatars): Support Survivor has no set of its own
abalinda Sep 2, 2026
80748c2
Merge remote-tracking branch 'origin/dev' into feat/22142-badge-avatars
abalinda Sep 2, 2026
174df8f
fix(demo-api): keep the picked avatar between update-user and users/m…
abalinda Sep 2, 2026
12442e8
feat(avatars): ship the sticker art set and the letter fallback (TASK…
0xkkonrad Sep 3, 2026
60f9c09
Merge remote-tracking branch 'origin/dev' into feat/22142-badge-avatars
abalinda Sep 3, 2026
7a9778d
fix(avatars): serialize saves, seed the home chip from the username, …
abalinda Sep 3, 2026
e7fbf49
fix(avatars): stickers sit plain on the page, no circle and no palett…
abalinda Sep 3, 2026
427292f
fix(avatars): a tap during the closing refetch drains too
abalinda Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions e2e/shots/fixtures.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { expect, test, type Page } from '@playwright/test'
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { FIXTURE_PARAM, FIXTURE_STORAGE_KEY } from '../../src/dev/fixtures/active'
import { FIXTURE_PARAM, FIXTURE_STORAGE_KEY, fixtureHref } from '../../src/dev/fixtures/active'
import { FIXTURES } from '../../src/dev/fixtures/registry'

const OUT_DIR = process.env.SHOTS_OUT ?? 'e2e/__shots__/current'
Expand Down Expand Up @@ -129,10 +129,7 @@ for (const [name, fixture] of Object.entries(FIXTURES)) {
await page.clock.setFixedTime(FROZEN_NOW)
await page.addInitScript(seenOnceModals)

// a fixture route may already pin url state (?method=bank) — join accordingly
await page.goto(`${fixture.route}${fixture.route.includes('?') ? '&' : '?'}${FIXTURE_PARAM}=${name}`, {
waitUntil: 'domcontentloaded',
})
await page.goto(fixtureHref(fixture.route, name), { waitUntil: 'domcontentloaded' })
await settle(page)

// A build without NEXT_PUBLIC_VERCEL_ENV=preview ignores the param and
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/BETA_TESTER/bubble.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/BETA_TESTER/flask.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/BETA_TESTER/wink.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/BUG_WHISPERER/beetle.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/BUG_WHISPERER/peek.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/BUG_WHISPERER/shell.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_ALPHA/card.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_ALPHA/swipe.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_ALPHA/tape.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_FIRST_SWIPE/card.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_FIRST_SWIPE/chip.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_FIRST_SWIPE/wink.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_SPENT_1K/cheer.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_SPENT_1K/note.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/CARD_SPENT_1K/stack.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/ETHFLORIPA_HUB/eth.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/ETHFLORIPA_HUB/palm.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/EVENT_ALUMNI/board.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/avatars/badge/EVENT_ALUMNI/cap.webp
Binary file added public/avatars/badge/EVENT_ALUMNI/wink.webp
Binary file added public/avatars/badge/IRL_NOMADS/pack.webp
Binary file added public/avatars/badge/IRL_NOMADS/pocket.webp
Binary file added public/avatars/badge/IRL_NOMADS/roll.webp
Binary file added public/avatars/badge/NAIJA/flag.webp
Binary file added public/avatars/badge/NAIJA/wave.webp
Binary file added public/avatars/badge/NAIJA/wink.webp
Binary file added public/avatars/badge/NITA/letter.webp
Binary file added public/avatars/badge/NITA/sparkle.webp
Binary file added public/avatars/badge/NITA/wink.webp
Binary file added public/avatars/badge/OFFRAMP_USER/bolt.webp
Binary file added public/avatars/badge/OFFRAMP_USER/spark.webp
Binary file added public/avatars/badge/OFFRAMP_USER/wink.webp
Binary file added public/avatars/badge/OG_2025_10_12/coin.webp
Binary file added public/avatars/badge/OG_2025_10_12/link.webp
Binary file added public/avatars/badge/OG_2025_10_12/shades.webp
Binary file added public/avatars/badge/SHHHHH/lips.webp
Binary file added public/avatars/badge/SHHHHH/shush.webp
Binary file added public/avatars/badge/SHHHHH/wink.webp
Binary file added public/avatars/badge/WAITLIST_SKIP/key.webp
Binary file added public/avatars/badge/WAITLIST_SKIP/keyhole.webp
Binary file added public/avatars/badge/WAITLIST_SKIP/wink.webp
Binary file added public/avatars/basic/apple.webp
Binary file added public/avatars/basic/avocado.webp
Binary file added public/avatars/basic/cactus.webp
Binary file added public/avatars/basic/cloud.webp
Binary file added public/avatars/basic/cube.webp
Binary file added public/avatars/basic/donut.webp
Binary file added public/avatars/basic/drop.webp
Binary file added public/avatars/basic/egg.webp
Binary file added public/avatars/basic/fish.webp
Binary file added public/avatars/basic/flower.webp
Binary file added public/avatars/basic/frog.webp
Binary file added public/avatars/basic/gem.webp
Binary file added public/avatars/basic/ghost.webp
Binary file added public/avatars/basic/heart.webp
Binary file added public/avatars/basic/leaf.webp
Binary file added public/avatars/basic/moon.webp
Binary file added public/avatars/basic/mushroom.webp
Binary file added public/avatars/basic/planet.webp
Binary file added public/avatars/basic/star.webp
Binary file added public/avatars/basic/sun.webp
Binary file added public/avatars/letter/a.webp
Binary file added public/avatars/letter/b.webp
Binary file added public/avatars/letter/c.webp
Binary file added public/avatars/letter/d.webp
Binary file added public/avatars/letter/e.webp
Binary file added public/avatars/letter/f.webp
Binary file added public/avatars/letter/g.webp
Binary file added public/avatars/letter/h.webp
Binary file added public/avatars/letter/i.webp
Binary file added public/avatars/letter/j.webp
Binary file added public/avatars/letter/k.webp
Binary file added public/avatars/letter/l.webp
Binary file added public/avatars/letter/m.webp
Binary file added public/avatars/letter/n.webp
Binary file added public/avatars/letter/o.webp
Binary file added public/avatars/letter/p.webp
Binary file added public/avatars/letter/q.webp
Binary file added public/avatars/letter/r.webp
Binary file added public/avatars/letter/s.webp
Binary file added public/avatars/letter/t.webp
Binary file added public/avatars/letter/u.webp
Binary file added public/avatars/letter/v.webp
Binary file added public/avatars/letter/w.webp
Binary file added public/avatars/letter/x.webp
Binary file added public/avatars/letter/y.webp
Binary file added public/avatars/letter/z.webp
4 changes: 2 additions & 2 deletions src/app/dev/fixtures/FixtureList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import Card from '@/components/Global/Card'
import DevNoteCard from '@/app/(mobile-ui)/dev/_components/DevNoteCard'
import DevPageShell from '@/app/(mobile-ui)/dev/_components/DevPageShell'
import { clearFixture, FIXTURE_PARAM } from '@/dev/fixtures/active'
import { clearFixture, FIXTURE_PARAM, fixtureHref } from '@/dev/fixtures/active'
import { FIXTURES } from '@/dev/fixtures/registry'

export default function FixtureList() {
Expand All @@ -20,7 +20,7 @@ export default function FixtureList() {
const fixture = FIXTURES[name]
// Plain <a>: a soft navigation would keep the previous fixture's
// React Query cache, so the screen would show stale state.
const href = `${fixture.route}?${FIXTURE_PARAM}=${name}`
const href = fixtureHref(fixture.route, name)
return (
<a key={name} href={href}>
<Card className="cursor-pointer p-4">
Expand Down
170 changes: 170 additions & 0 deletions src/components/Avatar/AvatarPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { updateUserById } from '@/app/actions/users'
import { Button } from '@/components/0_Bruddle/Button'
import { useToast } from '@/components/0_Bruddle/Toast'
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer'
import { useAuth } from '@/context/authContext'
import { twMerge } from '@/utils/tw'
import { badgeAvatarKeys, offerBasics } from './avatar.utils'
import { roveAvatarTiles } from './avatarPicker.utils'
import { UserAvatar } from './UserAvatar'

interface AvatarPickerProps {
open: boolean
onOpenChange: (open: boolean) => void
}

/**
* The profile avatar picker (TASK-22142): what the user's badges unlocked,
* then one row of the basics everyone has. A tap saves at once; the dice
* rerolls the offered row and never the pick; "use my initial" clears it.
* The API validates the pick against the same pool, so a locked key never
* lands even if the manifest and the catalog drift.
*/
export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {
const t = useTranslations('avatar')
const tCommon = useTranslations('common')
const { user, fetchUser } = useAuth()
const { toast } = useToast()

const userId = user?.user.userId
const username = user?.user.username ?? undefined
const saved = user?.user.avatarKey ?? null
const badges = user?.user.badges ?? []
const held = badges.map((badge) => badge.code)
const badgeName = Object.fromEntries(badges.map((badge) => [badge.code, badge.name]))
const unlocked = badgeAvatarKeys(held)

// The tile moves on tap; the slot behind the drawer moves after fetchUser
// lands. `pending` overrides `saved` while a burst drains. Saves are
// SERIALIZED: one POST at a time, always the latest tap next, so the
// server can never commit an older key last. One refetch per burst, in
// a finally, so a thrown save cannot wedge the picker.
const [pending, setPending] = useState<string | null | undefined>(undefined)
const wanted = useRef<string | null | undefined>(undefined)
const draining = useRef(false)
const pick = pending === undefined ? saved : pending

const drain = async () => {
draining.current = true
try {
// a tap that lands while the refetch is in flight queues on
// `wanted`; drain again rather than drop it with the finally
do {
while (wanted.current !== undefined) {
const key = wanted.current
wanted.current = undefined
try {
const { error } = await updateUserById({ userId, avatarKey: key })
if (error) toast({ type: 'error', message: t('saveFailed') })
} catch {
toast({ type: 'error', message: t('saveFailed') })
}
}
await fetchUser()
} while (wanted.current !== undefined)
} finally {
draining.current = false
setPending(undefined)
}
}

const save = (key: string | null) => {
if (!userId) return
setPending(key)
wanted.current = key
if (!draining.current) void drain()
}

// the offered row of five basics: dealt on open, redealt by the dice
const [offer, setOffer] = useState<string[]>([])
useEffect(() => {
if (open) setOffer(offerBasics(saved))
// deal once per open; the pick joins the row by being picked from it
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const rollDice = () => setOffer(offerBasics(pick))

// human labels: "Bug Whisperer · beetle" for a badge avatar, the slug for a basic
const label = (key: string) => {
const [kind, code, slug] = key.split('.')
return kind === 'badge' ? `${badgeName[code] ?? code} · ${slug}` : code
}

const tiles = (keys: string[], groupLabel: string) => {
const focusIndex = Math.max(0, keys.indexOf(pick ?? ''))
return (
<div
role="radiogroup"
aria-label={groupLabel}
className="grid grid-cols-5 gap-2"
onKeyDown={roveAvatarTiles}
>
{keys.map((key, index) => {
const checked = key === pick
return (
<button
key={key}
type="button"
role="radio"
aria-checked={checked}
aria-label={label(key)}
tabIndex={index === focusIndex ? 0 : -1}
onClick={() => save(key)}
className={twMerge(
'flex min-h-11 items-center justify-center rounded-sm border border-border-disabled bg-background-default p-1 focus-visible:outline-[3px] focus-visible:outline-action-focus',
checked && 'border-2 border-border-default shadow-4'
)}
>
<UserAvatar name={username} avatarKey={key} size="small" />
</button>
)
})}
</div>
)
}

return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="p-4">
<DrawerHeader className="p-0 pb-4 text-left">
<DrawerTitle className="text-heading-s text-foreground-primary">{t('title')}</DrawerTitle>
<DrawerDescription>{t('description')}</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-2">
<div className="flex items-baseline justify-between text-label-m text-foreground-secondary uppercase">
<span>{t('fromBadges')}</span>
{unlocked.length > 0 && (
<span className="normal-case">{t('unlocked', { count: unlocked.length })}</span>
)}
</div>
{unlocked.length > 0 ? (
tiles(unlocked, t('fromBadges'))
) : (
<p className="text-body-s text-foreground-secondary">{t('noBadgeAvatars')}</p>
)}
</section>
<section className="flex flex-col gap-2">
<div className="text-label-m text-foreground-secondary uppercase">{t('basics')}</div>
{tiles(offer, t('basics'))}
</section>
<div className="flex flex-col gap-2">
<Button variant="stroke" className="w-full" onClick={rollDice}>
{t('rollDice')}
</Button>
<Button variant="purple" className="w-full" onClick={() => onOpenChange(false)}>
{tCommon('done')}
</Button>
<Button variant="transparent" className="w-full" onClick={() => save(null)}>
{t('useInitial')}
</Button>
</div>
</div>
</DrawerContent>
</Drawer>
)
}
61 changes: 61 additions & 0 deletions src/components/Avatar/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client'

import Image from 'next/image'
import { useTranslations } from 'next-intl'
import AvatarWithBadge from '@/components/Profile/AvatarWithBadge'
import { AVATAR_SIZE_CLASSES, type AvatarSize } from '@/components/Profile/avatar-size.consts'
import { twMerge } from '@/utils/tw'
import { avatarSrc, letterAvatarSrc } from './avatar.utils'

interface UserAvatarProps {
/** Display name: the source of the day-0 letter sticker and of the label. */
name?: string
avatarKey?: string | null
size?: AvatarSize
className?: string
}

/**
* The user's own avatar (TASK-22142): the picked sticker, plain on the page —
* a sticker carries its own colours and edge, so no circle and no palette ring
* (art direction, 2026-09-03). Without a pick, the first letter of the name as
* a sticker. A name that does not start with a-z falls through to the
* first-letter avatar, which stays the one place that renders a bare initial.
*/
export function UserAvatar({ name, avatarKey, size = 'extra-small', className }: UserAvatarProps) {
const t = useTranslations('common')
const picked = avatarKey ? avatarSrc(avatarKey) : null
const letter = picked ? null : letterAvatarSrc(name)
const art = picked ?? letter

if (!art) {
return name ? (
<AvatarWithBadge size={size} name={name} firstLetterOnly className={className} />
) : (
<AvatarWithBadge
size={size}
icon="user"
className={twMerge(
'border border-avatar-yellow-border bg-avatar-yellow text-avatar-yellow-foreground',
className
)}
iconFillColor="var(--color-avatar-yellow-foreground)"
/>
)
}

return (
<span
{...(name
? { role: 'img', 'aria-label': t('userAvatarAlt', { username: name }) }
: { 'aria-hidden': true })}
className={twMerge(
'inline-flex shrink-0 items-center justify-center',
AVATAR_SIZE_CLASSES[size],
className
)}
>
<Image src={art} alt="" width={176} height={176} unoptimized className="size-full object-contain" />
</span>
)
}
Loading
Loading