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,34 @@
/** The typing game's lesson plan, fetched once - see `lib/tippen/curriculum.ts` for the
* derived Lesson/World shape everything else in the typing game expects. `null` after
* loading means the backend has no `general.tippen` section configured, same as
* `useHomeAssistant`'s `config` for the room page. */
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { Curriculum } from "../lib/tippen/curriculum";
import { fromApi } from "../lib/tippen/curriculum";
export interface TippenCurriculumState {
curriculum: Curriculum | null;
loading: boolean;
}
export function useTippenCurriculum(): TippenCurriculumState {
const [curriculum, setCurriculum] = useState<Curriculum | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
void api.tippenCurriculum().then((loaded) => {
if (cancelled) return;
setCurriculum(loaded ? fromApi(loaded) : null);
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { curriculum, loading };
}

View File

@@ -0,0 +1,57 @@
/** The typing game's progress: fetched once and updated from each run-recording
* response, since the backend is the only place it lives now - there is no local save.
* `enabled` mirrors `useHomeAssistant`'s `config` argument: pass whether the curriculum
* resolved non-null, so this hook stays a no-op until there is something to fetch. */
import { useCallback, useEffect, useState } from "react";
import { api } from "../api/client";
import type { RunResult } from "../lib/tippen/grading";
import type { Progress, RunOutcome, Settings } from "../lib/tippen/progress";
import { progressFromApi, runOutcomeFromApi, toRunInput } from "../lib/tippen/progress";
export interface TippenProgressState {
progress: Progress | null;
loading: boolean;
recordRun: (lessonId: string, result: RunResult) => Promise<RunOutcome>;
saveSettings: (settings: Settings) => Promise<void>;
}
export function useTippenProgress(enabled: boolean): TippenProgressState {
const [progress, setProgress] = useState<Progress | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
void api.tippenProgress().then((loaded) => {
if (cancelled) return;
setProgress(progressFromApi(loaded));
setLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled]);
const recordRun = useCallback(async (lessonId: string, result: RunResult) => {
const response = await api.recordTippenRun(toRunInput(lessonId, result));
const outcome = runOutcomeFromApi(response);
setProgress(outcome.progress);
return outcome;
}, []);
const saveSettings = useCallback(async (settings: Settings) => {
const saved = await api.saveTippenSettings({
sound: settings.sound,
keyboard_hint: settings.keyboardHint,
});
setProgress((previous) =>
previous
? { ...previous, settings: { sound: saved.sound, keyboardHint: saved.keyboard_hint } }
: previous,
);
}, []);
return { progress, loading, recordRun, saveSettings };
}

View File

@@ -0,0 +1,90 @@
/** Mounts the pure engine against real keystrokes.
*
* This is the thin adapter the plan calls for, and the same shape the music player uses
* in ../../web/src/App.tsx: everything the listener reads lives in a ref, so the
* listener is installed exactly once and a re-render never reattaches it. That matters
* more here than there - a listener that is torn down and rebuilt between keystrokes
* drops keys, and dropping a six-year-old's keystroke looks to her like the game is
* broken.
*
* Every mode drives this. They differ in what they draw, not in what typing means. */
import { useCallback, useEffect, useRef, useState } from "react";
import { isTypingKey, press, startRun } from "../lib/tippen/engine";
import type { RunEvent, RunState } from "../lib/tippen/engine";
import type { RunResult } from "../lib/tippen/grading";
import { playWrong, playDone, playCorrect } from "../lib/tippen/pop";
interface Options {
/** The text to type. Changing it restarts the run. */
target: string;
sound: boolean;
/** Called once, when the last character lands. */
onFinished: (result: RunResult) => void;
/** Extra per-event hook for a mode that needs it (popping a bubble, say). */
onEvent?: (event: RunEvent) => void;
/** Paused runs ignore keystrokes - used while the result sheet is up. */
paused?: boolean;
}
export interface RunHandle {
state: RunState;
/** True while the cursor is sitting on a key that was just missed. */
wrong: boolean;
restart: () => void;
}
export function useRun({ target, sound, onFinished, onEvent, paused = false }: Options): RunHandle {
const [state, setState] = useState<RunState>(() => startRun(target));
const [wrong, setWrong] = useState(false);
// Everything the listener needs, kept current without reinstalling it.
const latest = useRef({ state, sound, onFinished, onEvent, paused });
latest.current = { state, sound, onFinished, onEvent, paused };
const restart = useCallback(() => {
setState(startRun(target));
setWrong(false);
}, [target]);
// A new target is a new run - the modes swap the line rather than remounting.
useEffect(() => restart(), [restart]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const current = latest.current;
if (current.paused) return;
// Let the app keep its own keys: Escape leaves, F1 helps, and a browser shortcut
// with a modifier held is the browser's business, not the run's.
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (!isTypingKey(event.key)) return;
// Space scrolls the page and Tab leaves it; both are typing input here.
event.preventDefault();
const [next, events] = press(current.state, event.key, performance.now());
if (events.length === 0) return;
setState(next);
for (const runEvent of events) {
if (runEvent.type === "correct") {
setWrong(false);
if (current.sound) playCorrect(runEvent.streak);
} else if (runEvent.type === "wrong") {
setWrong(true);
if (current.sound) playWrong();
} else {
if (current.sound) playDone();
current.onFinished(runEvent.result);
}
current.onEvent?.(runEvent);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return { state, wrong, restart };
}