Celebrate a media unlock with a treasure chest that opens

The reward pipeline worked end to end already, but it had nothing to show for
itself: the unlock was one more line at the bottom of the result sheet, a 52px
thumbnail with the generic 520ms pop, below the stars, the stats, the progress
bar, the animal ladder and three other badges. The sound was `playFanfare`, the
same chirp used for a new lesson and a new aquarium pet. It fired correctly and
was impossible to notice.

This is the only reward that reaches outside the game, so it now gets the whole
screen. A chest drops in shut and rattles, the lid swings open on a burst of
light and confetti, the cover art rises out of it, and the tune is a real melody
- two seconds landing on a held major chord - rather than another blip. It is
dismissed by hand, so she can look at what she won for as long as she likes.

On the map, the 15px 🎁 becomes a drawn chest, shut while the reward is unwon
and open with the cover inside once it has been. It is rendered as a sibling of
the lesson node rather than a child, because a locked node is dimmed to 45% and
the chest that most needs to be bright is the one three worlds away.

One real bug behind the missing badge state: `progressFromApi` dropped the
`earned` flag the backend already sends, so the map could not tell a claimed
reward from an unclaimed one. Added, with a test that names it - a hand-written
field mapping loses fields without failing a type check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 18:10:11 +02:00
parent e6f6b15cc7
commit f3337975c2
9 changed files with 1060 additions and 99 deletions

View File

@@ -310,3 +310,141 @@
display: none;
}
}
/* ------------------------------------------------------- the unlock celebration
What happens when a lesson opens real music (RewardUnlockOverlay.tsx). Its own
section because it is the only thing in this app that deliberately interrupts: it
sits above the result sheet and has to be waited out, since the whole point of the
typing game - for a six-year-old - is that it hands back songs. Everything here is
staged rather than simultaneous; the sequence is what makes it read as a chest being
opened rather than as a dialog appearing. */
/* Above .tp-overlay's own layer: the result sheet is already on screen underneath, and
is meant to be revealed when this closes rather than replaced by it. */
.tp-reward-overlay {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 24px;
text-align: center;
overflow: hidden;
background: radial-gradient(
circle at 50% 42%,
oklch(45% 0.07 175 / 0.82) 0%,
oklch(16% 0.04 175 / 0.94) 70%
);
animation: backdropEnter 260ms ease-out;
/* Thrown right back, out of the column the cover rises through - see the note on
.tp-chest-lid. Alone this would look like a lid coming off; with the cover standing
in the middle it reads as the lid flung open to make room for it. */
--tp-chest-open: -108deg;
}
/* The lid's two states. Transitioned rather than keyframed so the map can draw a
permanently open chest with no animation at all - see TreasureChest.tsx. */
.tp-chest-lid {
transform-box: view-box;
transform: rotate(0deg);
transition: transform 620ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* How far the lid swings. The default is for a chest standing on its own (the lesson
map): far enough back to be unmistakably open, but not so far that it stops looking
attached to its hinge - a lid much past -90deg, alone, reads as a flap flying off.
The overlay overrides it above, because there the lid has a cover rising through the
middle of the chest and has to clear out of its way. */
.tp-chest-lid[data-open] {
transform: rotate(var(--tp-chest-open, -62deg));
}
/* The chest arriving: dropped in, with a squash on landing. */
@keyframes chestDrop {
0% { opacity: 0; transform: translateY(-120px) scale(0.7); }
55% { opacity: 1; transform: translateY(0) scale(1.06, 0.9); }
75% { transform: translateY(-6px) scale(0.98, 1.04); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
/* The wait before the lid goes: the chest rattling, because something inside wants out.
This is the beat that earns the opening - a lid that opens the instant the chest lands
is just a transition, not a reveal. */
@keyframes chestRattle {
0%, 100% { transform: translateX(0) rotate(0deg); }
15% { transform: translateX(-3px) rotate(-2.5deg); }
30% { transform: translateX(3px) rotate(2.5deg); }
45% { transform: translateX(-2px) rotate(-1.5deg); }
60% { transform: translateX(2px) rotate(1.5deg); }
80% { transform: translateX(-1px) rotate(-0.5deg); }
}
/* The burst of light at the moment the lid lets go. */
@keyframes rayBurst {
0% { opacity: 0; transform: scale(0.2) rotate(0deg); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: scale(2.4) rotate(140deg); }
}
/* The cover art coming up out of the chest and settling where it can be looked at.
Overshoots on the way up - a reward that rises and stops dead looks placed, not
thrown. */
@keyframes coverRise {
0% { opacity: 0; transform: translateY(46px) scale(0.28) rotate(-14deg); }
45% { opacity: 1; transform: translateY(-26px) scale(1.12) rotate(5deg); }
70% { transform: translateY(2px) scale(0.97) rotate(-2deg); }
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
}
/* The cover's own halo, once it has settled - a slow pulse, so the thing she just won
keeps drawing the eye while the text and the button arrive under it. */
@keyframes coverHalo {
0%, 100% { box-shadow: 0 10px 34px var(--shadow), 0 0 0 0 oklch(92% 0.15 92 / 0.55); }
50% { box-shadow: 0 10px 34px var(--shadow), 0 0 42px 10px oklch(92% 0.15 92 / 0.28); }
}
@keyframes rewardTextEnter {
0% { opacity: 0; transform: translateY(12px); }
100% { opacity: 1; transform: translateY(0); }
}
/* Confetti. Each piece gets its own left/delay/duration/colour/spin from inline style -
the keyframe only owns the fall. `--tp-drift` is how far sideways it wanders, so no
two pieces trace the same line down the screen. */
@keyframes confettiFall {
0% { opacity: 0; transform: translate(0, -8vh) rotate(0deg); }
8% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--tp-drift, 0px), 104vh) rotate(var(--tp-spin, 540deg)); }
}
.tp-confetti-piece {
position: absolute;
top: 0;
will-change: transform;
animation-name: confettiFall;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: both;
}
@media (prefers-reduced-motion: reduce) {
/* The reveal still happens - it is information, not decoration - but nothing travels:
the chest is simply open, the cover simply there, and the confetti does not fall.
RewardUnlockOverlay also skips straight to its final step when this is set, so the
staging never leaves her waiting on animations that were turned off. */
.tp-reward-overlay,
.tp-reward-overlay * {
animation: none !important;
}
.tp-chest-lid {
transition: none;
}
.tp-confetti-piece {
display: none;
}
}