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

View File

@@ -0,0 +1,144 @@
/** Jellyfish mode - pure key location, nothing else.
*
* Six jellyfish drift in the water, each showing a letter. One of them glows: that is
* the one to zap. There is no line to read and no word to spell, so the only thing
* being exercised is "where does this letter live" - which is exactly the skill dive
* mode hides behind reading.
*
* The decoys matter. Showing only the target turns this into the bubble mode; showing
* five wrong letters next to it means she has to find *her* letter before she can type
* it, which is the searching step that eventually goes away. */
import { useMemo } from "react";
import { currentChar } from "../../../lib/tippen/engine";
import { fingerOf } from "../../../lib/tippen/fingers";
import { mulberry32 } from "../../../lib/tippen/generator";
import type { RunResult } from "../../../lib/tippen/grading";
import type { Progress } from "../../../lib/tippen/progress";
import { useRun } from "../../../hooks/useTippenRun";
import { Keyboard } from "../Keyboard";
interface Props {
letters: readonly string[];
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
/** How many jellyfish are in the water at once, target included. */
const JELLYFISH_COUNT = 6;
interface Jellyfish {
left: number;
top: number;
drift: number;
size: number;
}
export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
const text = letters.join("");
const { state, wrong } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
});
const next = currentChar(state);
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
// would make the searching step impossible rather than merely hard.
const spots = useMemo<Jellyfish[]>(() => {
const rng = mulberry32(text.length * 31 + 7);
return Array.from({ length: JELLYFISH_COUNT }, () => ({
left: 10 + rng() * 76,
top: 6 + rng() * 66,
drift: 3 + rng() * 3,
size: 74 + rng() * 26,
}));
}, [text]);
/** The decoys shown alongside the target: other active keys, never the target itself,
* and stable for as long as the target is. */
const decoys = useMemo(() => {
if (!next) return [];
const rng = mulberry32(state.index * 101 + 13);
const others = activeKeys.filter((key) => key !== next && key !== " ");
const shuffled = [...others].sort(() => rng() - 0.5);
return shuffled.slice(0, JELLYFISH_COUNT - 1);
}, [next, state.index, activeKeys]);
// Which jellyfish carries the target. Moves around so it is not always the same one.
const targetSlot = next ? state.index % JELLYFISH_COUNT : -1;
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
padding: "0 32px 8px",
minHeight: 0,
}}
>
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
{spots.map((spot, i) => {
const isTarget = i === targetSlot;
const letter = isTarget ? next : decoys[i > targetSlot ? i - 1 : i];
if (!letter) return null;
const finger = fingerOf(letter);
return (
<div
key={i}
className="tp-jellyfish"
style={{
position: "absolute",
left: `${spot.left}%`,
top: `${spot.top}%`,
width: spot.size,
height: spot.size,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: spot.size * (isTarget ? 0.4 : 0.3),
fontWeight: 900,
borderRadius: "50% 50% 42% 42%",
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
background: isTarget
? "var(--paper)"
: `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`,
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${isTarget ? 0.9 : 0.3})`,
boxShadow: isTarget
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
: "0 4px 14px var(--shadow)",
opacity: isTarget ? 1 : 0.55,
transform: isTarget ? "scale(1.12)" : "scale(1)",
animation: `dolphinBob ${spot.drift}s ease-in-out infinite`,
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
}}
>
{letter === " " ? "␣" : letter}
{isTarget && wrong && (
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
)}
</div>
);
})}
</div>
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={36}
/>
</div>
);
}