Feature Requests
Archived

[G6] Reward feedback: unlock animations and satisfying micro-interactions

The part that actually makes the app feel gamified. Split out of the display tickets so the surface can ship without waiting on art direction.

Depends on: [G4] Profile progression display, [G5] Achievements screen.

Scope

  • Define every micro-interaction explicitly — what plays, when, and why: unlock toast, level-up, season/series completion, progress rings, "two episodes left" nudge.
  • Set the cursor deliberately: rewarding, never kitsch, never overdone. This calibration is the actual deliverable, the code is the easy part.
  • Sound, if any, off by default.
  • Respect the "hide my progression" setting and prefers-reduced-motion.

Rule that constrains this ticket

No loss pressure. The streak is displayed; its loss is never notified, and there is no streak-freeze token to collect. No notification whose only purpose is to bring the user back.

2 Comments

Sign in to comment

L
Logan·1 month ago

Shipped in PR #187, merged to main 2026-09-04 as 812c3f8. Moved to complete.

All nine items of the renegotiated scope are on main: the unlock bubble with its two skins and its queue, the level-up detection against a locally stored last-known value, the season/series congratulation, the LevelCard reel cascade, the episode tick, the bubble's deep link into the achievements page, the hero band filling on arrival, and the streak badge animating on a rise only. The stats bars turned out to need nothing — ProgressBar and HistogramBars already grew from zero on mount, so that item was already satisfied before the ticket existed.

Three things shipped with it that were not in the scope, because building it ran into them: /app/profile was dead on a SOCIAL_ENABLED=false instance (everything under SocialController sits behind SocialFeatureGuard, so the profile page had no endpoint at all — own profile now has GET /users/me/profile, and GET /gamification/me serves your own XP outside the social surface); marking an episode watched took about a second because evaluate() checked "already unlocked?" once per candidate sequentially and then ran 18 checks the same way; and avatars were blocked cross-origin because helmet applies Cross-Origin-Resource-Policy: same-origin app-wide.

Two things were tried during review and removed rather than kept: a route crossfade, and a set of extra flourishes (a light sweep on poster hover, a confirmation beat on buttons, a spark burst on gold unlocks). The crossfade was built on the View Transitions API, which tweens its snapshot group between the outgoing and incoming page heights and scales both captures to fit — a short page replacing a long one arrived visibly stretched, and the snapshot overlay collapsed the nav rail by firing mouseleave under a stationary cursor. Rebuilt as a plain opacity animation on the live element, then dropped entirely at merge. The flourishes were judged not good enough to keep; sparks in particular will come back through a real particle library rather than hand-rolled spans.

Still open, tracked elsewhere: notifying an unlock without waiting for a refresh (moved to the websocket ticket).

L
Logan·1 month ago

Scope renegotiated with Logan on 2026-09-03, point by point. The list below supersedes the ticket body.

Dropped from the original scope:

  • "Progress rings" — there are no rings anywhere in the app and none are being introduced. Season and series progress are already bars; those are what get the emphasis. The word came from an early brainstorm, not from a real design.
  • The "two episodes left" nudge — dropped entirely. It is the one item that brushes the "no notification whose only purpose is to bring the user back" guardrail, and Logan did not recognise it as something he had asked for.
  • Sound — none at all. "Off by default" means almost nobody would ever hear it, so it is not worth the audio file, the persisted setting and the autoplay handling before the visual language is settled.

Shipping instead, nine items:

  1. Unlock bubble, top-anchored, sliding in and out from the top. Content is exactly: the achievement medallion with its tier ring, a short "SUCCÈS DÉBLOQUÉ" eyebrow, the name, the XP gained. No description. One at a time, 250ms in, 2s hold, 250ms out; from the 4th consecutive bubble the hold drops to ~1.2s. Every pending unlock plays, no cap. Triggered on app layout mount only. Dismiss with a gesture (the next one follows); clicking goes to the achievements page and ends the sequence. Consumes G2's existing GET /achievements/pending and PATCH /achievements/:id/displayed — no new endpoint, no migration.
  2. Level-up as the same bubble with a different skin ("NIVEAU ATTEINT", the level reached, the level badge, no XP line), in the same queue. Detected at app start by comparing a locally stored last-known level against levelForXp(profile xp): first run records silently, a level drop updates silently and is never announced.
  3. Season/series completion: the existing progress bar fills to 100% with a brief emphasis. No bubble, no new geometry.
  4. LevelCard's ten "reel" cells light up in a ~0.5s cascade on arrival, on /app/profile and on another user's profile when their progression is visible.
  5. Episode check: a tight tick at the moment of marking, with the season bar moving in the same gesture. The most repeated action in the app, and what makes item 3 legible.
  6. Clicking a bubble now points at the achievement it announced (scrolls to its card and briefly highlights it) instead of dropping the user on 42 undifferentiated cards.
  7. The achievements hero band fills its rail and counts up on first render.
  8. RankBars and HistogramBars grow from zero on reveal — extending what ProgressBar already does on mount, not a new behaviour.
  9. Streak badge animates only when the streak increases, compared against a locally stored last-known value. A decrease updates silently: never an animation, never a mention. This is the anti-loss-pressure guardrail, enforced explicitly.

One correction to the ticket body: it asks to respect the "hide my progression" setting. That is wrong here. hideProgression hides your progression from others; the owner always sees their own (see G4's withXp gate order). The bubble is self-addressed, so gating it behind that setting would hide a user's own achievements from them. It is deliberately not implemented, with a comment in the code saying why. GAMIFICATION_ENABLED does gate everything, as usual.