Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
4 changes: 2 additions & 2 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 @@ -110,7 +110,7 @@ for (const [name, fixture] of Object.entries(FIXTURES)) {
await page.clock.setFixedTime(FROZEN_NOW)
await page.addInitScript(seenOnceModals)

await page.goto(`${fixture.route}?${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
151 changes: 151 additions & 0 deletions src/components/Avatar/AvatarPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
'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 writes are in flight. One
// refetch per burst, when the last request lands, and the server's
// answer wins: a failed older request cannot roll back a newer success,
// and a lone failure snaps back to what is saved.
const [pending, setPending] = useState<string | null | undefined>(undefined)
const inFlight = useRef(0)
const pick = pending === undefined ? saved : pending

const save = async (key: string | null) => {
if (!userId) return
setPending(key)
inFlight.current += 1
const { error } = await updateUserById({ userId, avatarKey: key })
Comment thread
abalinda marked this conversation as resolved.
Outdated
Comment thread
abalinda marked this conversation as resolved.
Outdated
Comment thread
abalinda marked this conversation as resolved.
Outdated
Comment thread
abalinda marked this conversation as resolved.
Outdated
inFlight.current -= 1
if (error) toast({ type: 'error', message: t('saveFailed') })
if (inFlight.current === 0) {
await fetchUser()
Comment thread
abalinda marked this conversation as resolved.
Outdated
Comment thread
abalinda marked this conversation as resolved.
Outdated
setPending(undefined)
}
}

// 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={() => void 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={() => void save(null)}>
{t('useInitial')}
</Button>
</div>
</div>
</DrawerContent>
</Drawer>
)
}
69 changes: 69 additions & 0 deletions src/components/Avatar/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'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 { avatarPaletteClass, 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 character on its palette
* triple. Without a pick, the first letter of the name as a sticker, keyed on
* the name so the colour is stable before anything is picked. 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)
// the palette is keyed on whatever identifies the art: the pick, or the
// name behind the letter
const art =
picked && avatarKey
? { src: picked, paletteKey: avatarKey }
: letter && name
? { src: letter, paletteKey: name }
: null

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 overflow-hidden rounded-full border',
avatarPaletteClass(art.paletteKey),
AVATAR_SIZE_CLASSES[size],
className
)}
>
<Image src={art.src} alt="" width={96} height={96} unoptimized className="size-[82%]" />
</span>
)
}
Loading
Loading