Merge the typing game into the music player as a tab, with lock/unlock UI

Moves tippen from a standalone app into web/ as a third tab (audio player /
smarthome / typing), replacing the old single room-toggle corner button with a
vertical icon tab rail. Curriculum and progress now come from the backend
(musicmouse/tippen/*) instead of a build-time YAML import and localStorage.

Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question
mark for locked albums/tracks with a hint on what unlocks them, and
ResultSheet gets a new unlock-animation block alongside the existing
lesson-unlock and aquarium-creature celebrations.

CSS from the two apps is merged carefully: identical rules (bubble/card/
key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is,
while rules that bake in each app's own hue are kept separate under a
`tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so
neither app's look bleeds into the other's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 21:25:42 +02:00
parent f7a5d24d8d
commit 7f5e2733c2
84 changed files with 1577 additions and 4642 deletions

313
web/src/styles/tippen.css Normal file
View File

@@ -0,0 +1,313 @@
/* 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;
}
/* A dome plus three trailing tentacles. Without them the jellyfish read as plain
circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather
than as an emoji so the letter stays centred and legible inside the dome. */
.tp-jellyfish::after {
content: "";
position: absolute;
bottom: -11px;
left: 26%;
right: 26%;
height: 16px;
opacity: 0.55;
background:
linear-gradient(currentColor, transparent) left / 3px 100% no-repeat,
linear-gradient(currentColor, transparent) center / 3px 100% no-repeat,
linear-gradient(currentColor, transparent) right / 3px 100% no-repeat;
}