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

@@ -425,6 +425,7 @@ export function BrowseView({
// Fixed regardless of how many lines the title actually needs, so
// every card - and thus every row of covers - is the same height.
const titleLines = showArtist ? 2 : 3;
const hint = album.locked ? (album.tracks[0]?.unlock_hint ?? null) : null;
return (
<div
key={album.id}
@@ -432,6 +433,7 @@ export function BrowseView({
data-nav-index={navIndex}
data-selected={selected === navIndex}
data-current={album.id === currentAlbumId}
data-locked={album.locked}
style={{
background: cardBackground(album),
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
@@ -439,26 +441,34 @@ export function BrowseView({
}}
>
<button
onClick={() => onOpenAlbum(album, navIndex)}
aria-label={podcast ? `${album.title} abspielen` : `${album.title}: Titel wählen`}
onClick={() => !album.locked && onOpenAlbum(album, navIndex)}
disabled={album.locked}
aria-label={
album.locked
? `${album.title}: noch nicht freigeschaltet`
: podcast
? `${album.title} abspielen`
: `${album.title}: Titel wählen`
}
style={{
display: "flex",
width: "100%",
border: "none",
padding: 0,
cursor: "pointer",
cursor: album.locked ? "default" : "pointer",
}}
>
<Cover album={album} size={180} radius="0" label />
<Cover album={album} size={180} radius="0" label locked={album.locked} />
</button>
<button
onClick={() => onPlayAlbum(album, navIndex)}
aria-label={`${album.title} abspielen`}
onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
disabled={album.locked}
aria-label={album.locked ? `${album.title}: noch nicht freigeschaltet` : `${album.title} abspielen`}
style={{
display: "block",
width: "100%",
border: "none",
cursor: "pointer",
cursor: album.locked ? "default" : "pointer",
textAlign: "left",
font: "inherit",
background: "none",
@@ -466,47 +476,77 @@ export function BrowseView({
paddingRight: isBook(album) ? 22 : 12,
}}
>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
height: `${titleLines * 1.2}em`,
display: "-webkit-box",
WebkitLineClamp: titleLines,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{album.title}
</div>
{showArtist && (
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(30% 0.03 210)",
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{album.artist}
</div>
{album.locked ? (
<>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
}}
>
Geheimnis
</div>
{hint && (
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "oklch(40% 0.03 210)",
marginTop: 4,
lineHeight: 1.3,
}}
>
Freigeschaltet nach {hint.lesson_title}" (Welt {hint.world_number})
</div>
)}
</>
) : (
<>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
height: `${titleLines * 1.2}em`,
display: "-webkit-box",
WebkitLineClamp: titleLines,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{album.title}
</div>
{showArtist && (
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(30% 0.03 210)",
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{album.artist}
</div>
)}
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "oklch(40% 0.17 340)",
marginTop: 6,
}}
>
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"}
</div>
</>
)}
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "oklch(40% 0.17 340)",
marginTop: 6,
}}
>
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"}
</div>
</button>
</div>
);