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,172 @@
/** The map: a single winding path, one world section at a time.
*
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
* waiting is half the reason to finish the world that is open. Each node carries its own
* best animal, star count and a badge for which game it plays, so the map doubles as
* both a path forward and a trophy cabinet. */
import type { Lesson, World } from "../../lib/tippen/curriculum";
import { animalById } from "../../lib/tippen/grading";
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
import { MODE_INFO } from "../../lib/tippen/modeInfo";
import type { Progress } from "../../lib/tippen/progress";
interface Props {
worlds: readonly World[];
lessons: readonly Lesson[];
progress: Progress;
/** Which card the keyboard selection is on. */
selected: number;
onPick: (lesson: Lesson) => void;
}
const NODE_SIZE = 88;
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
const PATH_HALF_WIDTH = 160;
/** What a consolidation node's key-label line says when it has no keys of its own to
* show - a child-friendly word rather than the raw `LessonKind`. */
const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = {
fragments: "Wörter",
words: "Wörter",
sentences: "Sätze",
};
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
return (
<div
className="view-enter"
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
>
<div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
{worlds.map((world) => {
const worldLessons = lessons.filter((lesson) => lesson.world === world.number);
const done = worldLessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
const height = worldLessons.length * NODE_SPACING;
const points = worldLessons.map((_, i) => pointFor(i));
return (
<section key={world.number}>
<div
className="tp-glass-panel"
style={{
position: "sticky",
top: 0,
zIndex: 2,
padding: "10px 18px",
display: "flex",
alignItems: "center",
gap: 10,
marginBottom: 8,
}}
>
<span style={{ fontSize: 22 }}>{world.emoji}</span>
<span style={{ fontSize: 17, fontWeight: 900, color: "var(--paper)" }}>
Welt {world.number} {world.title}
</span>
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
{done}/{worldLessons.length}
</span>
</div>
<div style={{ position: "relative", height, margin: "0 auto" }}>
<svg
style={{ position: "absolute", left: "50%", top: 0, transform: "translateX(-50%)", overflow: "visible" }}
width={PATH_HALF_WIDTH * 2}
height={height}
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
>
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
</svg>
{worldLessons.map((lesson, i) => {
const entry = progress.lessons[lesson.id];
const locked = !entry?.unlocked;
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
const index = lessons.indexOf(lesson);
const point = points[i]!;
const modeInfo = MODE_INFO[lesson.primaryMode];
return (
<button
key={lesson.id}
className="card tp-glass-panel"
data-selected={index === selected}
data-locked={locked}
disabled={locked}
onClick={() => onPick(lesson)}
title={lesson.title}
style={{
position: "absolute",
left: `calc(50% + ${point.x}px)`,
top: point.y,
transform: "translate(-50%, -50%)",
width: NODE_SIZE,
height: NODE_SIZE,
borderRadius: "50%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 0,
textAlign: "center",
}}
>
<span
aria-hidden
style={{
position: "absolute",
top: -4,
right: -4,
fontSize: 15,
filter: locked ? "grayscale(1)" : "none",
opacity: locked ? 0.4 : 0.9,
}}
title={modeInfo.name}
>
{modeInfo.emoji}
</span>
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
{/* The keys themselves: for a pre-reader this is the real label, the
title is decoration. A drill has no new keys, so it says so with
a symbol instead of showing an empty line. */}
<span
style={{
fontSize: 11,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.8,
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
lineHeight: 1.1,
}}
>
{lesson.isDrill
? "🔁"
: lesson.newKeys.length > 0
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
: lesson.kind === "letters"
? "üben"
: CONSOLIDATION_LABEL[lesson.kind]}
</span>
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
{[1, 2, 3].map((star) => (
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
</span>
))}
</span>
</button>
);
})}
</div>
</section>
);
})}
</div>
</div>
);
}