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

@@ -9,6 +9,11 @@ import type {
RemoteMapping,
RemoteSlotInput,
Settings,
TippenCurriculum,
TippenProgress,
TippenRunInput,
TippenRunResult,
TippenSettings,
TrackDetail,
} from "./types";
@@ -53,6 +58,11 @@ const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>
/** `null` means the IR remote isn't configured, not an error. */
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
/** `null` means the typing game isn't configured, not an error - the tab still shows,
* just with a "not set up" placeholder instead of a lesson map. */
const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> =>
fetchOrNullOn404<TippenCurriculum>("/tippen/curriculum");
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
* right now" (the backend answers the latter with a 502) - the room page treats a
* device with no state the same way either way, rather than crashing on a poll. */
@@ -103,6 +113,13 @@ export const api = {
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
tippenCurriculum: fetchTippenCurriculum,
tippenProgress: () => request<TippenProgress>("/tippen/progress"),
saveTippenSettings: (settings: TippenSettings) =>
request<TippenSettings>("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }),
recordTippenRun: (body: TippenRunInput) =>
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
};
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;