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:
313
web/src/styles/tippen.css
Normal file
313
web/src/styles/tippen.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user