Files
musicmouse/web/src/styles/tippen.css
Martin Bauer f3337975c2 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>
2026-09-19 18:10:11 +02:00

451 lines
15 KiB
CSS

/* The typing game's own corner of the Dolphin Beats design system - ported from the
standalone tippen app's app.css (see its own header: "the tokens, the glass recipe,
the radius scale and the entrance animations are deliberately identical [to
app.css], so the two apps read as siblings. What changes is the hue: the music
player's sea is 210, this is 175 - a turquoise lagoon, clearly the same ocean and
clearly not the same room.").
Classes here are prefixed `tp-` and colours scoped under `.tp-stage` rather than
redefined at `:root`, because app.css already claims the *same* custom property
names (--ink, --paper, --accent, --shadow, ...) for the music player's own blue hue -
redefining them globally would have silently reskinned one app or the other. Anything
below with no hue baked into it - .bubble, .card, .key-cap, .view-enter,
.backdrop-enter, and the dolphinBob/bubbleRise/viewEnter/backdropEnter keyframes - is
identical between the two apps and reused directly from app.css instead of being
duplicated here. */
.tp-stage {
--ink: oklch(30% 0.04 175);
--paper: oklch(97% 0.01 175);
--accent: oklch(70% 0.16 340);
--accent-dim: oklch(78% 0.14 340);
--sea-deep: oklch(20% 0.045 175);
--shadow: oklch(15% 0.05 175 / 0.35);
/* This app's own additions: the feedback colours. Note there is no red - a wrong key
is amber and gentle, never an alarm. */
--correct: oklch(80% 0.17 150);
--wrong: oklch(80% 0.13 75);
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(
180deg,
oklch(55% 0.07 175) 0%,
oklch(38% 0.06 175) 45%,
var(--sea-deep) 100%
);
display: flex;
flex-direction: column;
/* Its own stacking context, so the swimming pets (AquariumCreatures.tsx) paint above
this gradient and below every screen, rather than below the page itself. */
isolation: isolate;
}
/* Locked lessons stay visible, just dimmed - seeing what comes next is half the reason
to finish what is open. Extends the shared .card rule from app.css; harmless on the
music player's own cards, which never set this attribute. */
.card[data-locked="true"] {
cursor: default;
opacity: 0.45;
}
/* None of these five collide with app.css's own keyframes (bubbleRise, dolphinBob,
dolphinSwim, viewEnter, sheetEnter, backdropEnter), so they keep their plain names -
only classes that would otherwise collide are `tp-`-prefixed. */
@keyframes correctPop {
0% { transform: scale(1); }
40% { transform: scale(1.25); }
100% { transform: scale(1); }
}
/* A wrong key. A small wobble, not a buzz: it says "not that one, try again", and
deliberately does not say "you failed". */
@keyframes wrongShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
@keyframes keyPulse {
0%, 100% { box-shadow: 0 0 0 0 oklch(97% 0.01 175 / 0.6); }
50% { box-shadow: 0 0 0 9px oklch(97% 0.01 175 / 0); }
}
@keyframes tierEnter {
0% { opacity: 0; transform: scale(0.4) rotate(-12deg); }
60% { opacity: 1; transform: scale(1.15) rotate(4deg); }
100% { opacity: 1; transform: scale(1) rotate(0); }
}
@keyframes sterneEnter {
0% { opacity: 0; transform: scale(0.2); }
100% { opacity: 1; transform: scale(1); }
}
/* Its own duration (240ms vs app.css's 200ms for the shared .sheet-enter) - kept as
its own class for exactly that reason, but reuses app.css's identical `sheetEnter`
keyframe body. */
.tp-sheet-enter { animation: sheetEnter 240ms ease-out; }
@media (prefers-reduced-motion: reduce) {
/* Keep the colour feedback, drop the movement - covers the shared .bubble/.view-enter/
.backdrop-enter classes too, which app.css does not otherwise handle. */
.bubble,
.view-enter,
.tp-sheet-enter,
.backdrop-enter {
animation: none;
}
}
.tp-glass-panel {
background: linear-gradient(160deg, oklch(97% 0.01 175 / 0.14), oklch(97% 0.01 175 / 0.05));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid oklch(97% 0.01 175 / 0.16);
border-radius: 22px;
box-shadow:
0 8px 24px var(--shadow),
inset 0 1px 0 oklch(100% 0 0 / 0.14);
}
.tp-stage[data-blur="off"] .tp-glass-panel,
.tp-stage[data-blur="off"] .card,
.tp-stage[data-blur="off"] .tp-kb-key {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.tp-pill {
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 800;
padding: 9px 15px;
border-radius: 999px;
white-space: nowrap;
background: oklch(97% 0.01 175 / 0.16);
color: var(--paper);
}
.tp-pill[data-active="true"] {
background: var(--paper);
color: oklch(28% 0.04 175);
box-shadow: 0 4px 14px var(--shadow);
}
.tp-sheet {
background: var(--paper);
border-radius: 24px;
box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5);
}
.tp-overlay {
position: absolute;
inset: 0;
background: oklch(15% 0.03 175 / 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
z-index: 20;
}
/* ------------------------------------------------------- the on-screen keyboard -- */
.tp-kb {
display: flex;
flex-direction: column;
gap: 7px;
align-items: center;
transition: opacity 400ms ease;
}
.tp-kb-row {
display: flex;
gap: 7px;
}
.tp-kb-key {
position: relative;
width: var(--kb-size, 42px);
height: var(--kb-size, 42px);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: calc(var(--kb-size, 42px) * 0.42);
font-weight: 800;
color: var(--paper);
background: oklch(97% 0.01 175 / 0.1);
border: 1px solid oklch(97% 0.01 175 / 0.14);
backdrop-filter: blur(6px);
transition:
background 0.15s ease,
transform 0.1s ease;
}
/* A key that this lesson does not use yet fades back so the eye goes to the ones that
matter. It stays visible: the keyboard is a map, and a map with holes is confusing. */
.tp-kb-key[data-active="false"] {
opacity: 0.3;
}
/* Each finger owns a hue (lib/tippen/fingers.ts), so "der grüne Finger" is something
you can say out loud to a six-year-old and have her understand it. Saturated on
purpose - it has to survive being read at arm's length against a dark background. */
.tp-kb-key[data-finger] {
background: linear-gradient(
160deg,
oklch(68% 0.19 var(--finger-hue) / 0.92),
oklch(52% 0.16 var(--finger-hue) / 0.8)
);
border-color: oklch(82% 0.13 var(--finger-hue) / 0.75);
color: oklch(22% 0.05 var(--finger-hue));
text-shadow: 0 1px 0 oklch(100% 0 0 / 0.25);
}
.tp-kb-key[data-next="true"] {
background: var(--paper);
color: oklch(25% 0.05 175);
transform: translateY(-3px) scale(1.08);
animation: keyPulse 1.3s ease-out infinite;
}
.tp-kb-key[data-home="true"]::after {
/* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
content: "";
position: absolute;
transform: translateY(12px);
width: 12px;
height: 2px;
border-radius: 2px;
background: currentColor;
opacity: 0.55;
}
.tp-kb-space {
width: calc(var(--kb-size, 42px) * 6);
}
/* The two Shift keys, drawn either side of the space bar. Wider than a letter key so
they read as the modifier they are, and dim until a capital actually needs one. */
.tp-kb-shift {
width: calc(var(--kb-size, 42px) * 1.8);
background: oklch(97% 0.01 175 / 0.12);
font-size: calc(var(--kb-size, 42px) * 0.4);
}
/* ------------------------------------------------------------- the target line -- */
.tp-target {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 12px 26px;
font-weight: 800;
letter-spacing: 0.02em;
/* Wrapping beats shrinking: the line must never run into the edges of the screen.
Target.tsx overrides this per block length (see `layoutFor`); this is only the
fallback for a caller that does not. */
max-width: min(900px, 88vw);
}
.tp-target-chunk {
display: flex;
}
.tp-target-char {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0.78em;
/* Fixed at all times, not just while "current": if the current letter alone grew
wider, the whole line would reflow around the cursor as she types, and chunks
would visibly jump between lines mid-word. */
padding: 0 6px;
color: oklch(97% 0.01 175 / 0.45);
transition: color 0.12s ease;
}
.tp-target-char[data-state="done"] {
color: var(--correct);
}
.tp-target-char[data-state="current"] {
color: oklch(25% 0.05 175);
background: var(--paper);
border-radius: 10px;
box-shadow: 0 6px 18px var(--shadow);
animation: correctPop 200ms ease-out;
}
.tp-target-char[data-state="current"][data-wrong="true"] {
background: var(--wrong);
animation: wrongShake 260ms ease;
}
/* A space inside the target needs a visible body, or the cursor lands on nothing. Only
the space at the cursor shows ␣: marking every upcoming one turned a sentence into
"Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */
.tp-target-char[data-blank="true"] {
min-width: 0.9em;
}
/* The world-reward pets, shown small in the map's header: one per world, in colour once
it has moved in and as a white silhouette until then. The same "the next thing has to
be visible to be worth aiming at" rule as the animal ladder on the result sheet. */
.tp-map-creatures {
display: flex;
align-items: center;
gap: 4px;
}
@media (max-width: 520px) {
/* The streak and the best animal say more per pixel on a narrow screen. */
.tp-map-creatures {
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;
}
}