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>
451 lines
15 KiB
CSS
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;
|
|
}
|
|
}
|