[G9] Equipped badge showcase on the profile
Split out of [G5] Achievements screen + showcase (post_01m1f97znnexyrvzqmkdwn0eet) so G5 can ship the achievements page alone. G5 builds the page where badges are browsed; this ticket builds the one place a badge is worn.
Depends on: [G3] (catalogue + badge glyphs), [G5] (achievements screen — the natural place to pick what you equip).
Scope
- Equipping. A user picks one badge (or a small fixed number — to settle here) among the achievements they have actually unlocked. Stored per user; unequipping is always possible.
- Display on
/app/profile. The equipped badge shows on the owner's own profile and on the profile as seen by other visitors. Gating, non-negotiable — the showcase inherits the rules already in place, it does not invent new ones:
- hidden entirely when
GAMIFICATION_ENABLEDis off; - hidden per the viewer/target relationship exactly like
xpalready is — reusewithXp()/fetchXpByUser(apps/api/src/gamification/xp-lookup.util.ts) and thehideProgressionflag rather than adding a parallel visibility path; - a Figurant (GHOST) shows nothing, same as every other progression signal.
- hidden entirely when
- Zero-footprint when empty. If nothing is equipped, or the viewer isn't allowed to see it, the profile must render exactly as it does today — no empty slot, no placeholder, no reserved space. This is an explicit requirement, not a nicety.
Open questions to settle at design time
- One badge or a small showcase of 2-3? A single one is stronger as an identity signal; several turn the profile into a second achievements page, which G5 already is.
- Where exactly on the profile — next to the pseudonym (like the existing
LevelBadge/StreakBadgechips), or as its own block nearLevelCard? - Does the equipped badge travel with the author line on reviews/comments, like
LevelBadgeandStreakBadgealready do? That's a much bigger surface — probably a separate decision, possibly a "no". - Does a tiered achievement equip as its current tier (ring colour follows the tier reached), and what happens visually when the user later reaches the next tier?
Out of scope
The achievements page itself (G5), the unlock bubble (G6), any cosmetic that isn't an already-unlocked achievement — paid or unlockable cosmetics are [B1].
3 Comments
Sign in to comment
·1 month agoConstraint added after the 2026-09-03 design discussion: never auto-equip a badge, not even "the rarest one" as a default. The equip must always be an explicit user choice.
Reason: the catalogue shipped in G3 contains achievements that are privately revealing by design — double_life (being a Figurant while still writing), one_sided (following someone who doesn't follow back), no_favorites, night_owl. Taken together, 44 conditions read less like a trophy collection and more like a behavioural fingerprint. What makes the showcase safe is precisely that the user picks what they wear; an automatic default would silently broadcast something they never chose to reveal.
Same discussion settled that /app/achievements (G5) shows the viewer's own achievements only, and that exposing another user's full achievements page is deliberately not planned — it would first require a per-entry "publicly exposable" notion that doesn't exist. The showcase covers the real need (showing who you are) without that problem.
Shipped in PR #194, merged to main 2026-09-05 as bdf75b8. Moved to complete.
Design settled at 2026-09-03 (see the comment above): 3 badges max, own scoped box per badge (no card, no tier text — the tier ring alone carries that), equipped from the achievements ladder rather than a separate picker, secret achievements never equippable even once unlocked, a tiered achievement equips as the tier it was equipped at (frozen, since each tier is already its own catalogue key). A mouse-tilt hover effect was added to the badge boxes for a "medallion" feel — extracted as a reusable Svelte action (
use:tilt,apps/web/src/lib/actions/tilt.ts) rather than one-off code, following the existinglongpress/portal/scrollLockaction convention.Shipped:
User.equippedBadgeKeys(up toMAX_EQUIPPED_BADGES= 3), withPOST/DELETE /achievements/:key/equipvalidating: must be unlocked, never secret, never above the limit, idempotent unequip.SocialProfileDto.equippedBadges, gated behind the exact samegamificationEnabled && xpVisiblecondition asxp— reused, not reimplemented, per the ticket's non-negotiable./app/profile: zero footprint when nothing is equipped, no placeholder.A real QA pass on screenshots caught and fixed 4 issues before merge: a single-tier badge rendering uncolored (missing the established
tier ?? "gold"fallback once unlocked), a too-discreet equip button with no hover/active/loading transitions, no glanceable "this one is equipped" state from outside the ladder, and no indication of why a control was disabled at the limit.Also resolved along the way: a pre-existing knip debt (5 flagged unused exports —
LEVEL_BASE/LEVEL_STEP/LEVEL_CAP_COST/XP_RULE_LIST/allAiredEpisodesWatched) that had been blocking CI without a decision — kept via knip's@publictag rather than deleted, since these are intentionally-exported constants with no current caller yet.Not built: a tooltip with the achievement's unlock description on the ladder's tier label — tried, then reverted as unnecessary (the description is already one click away on the achievements page).