Anglicize tippen's codebase and finish pending UI/curriculum cleanup

- Rename all German identifiers, types, mode ids, file names, CSS classes
  and data-attributes to English throughout tippen/src; only user-facing
  text (lesson titles, word lists, labels, spoken praise) stays German.
- Add a word/nonsense-word list to the "Übung: die Grundstellung" home-row
  lesson in the curriculum.
- Remove the unused HandHint component and speech.ts, and carry forward
  the in-progress App.tsx/component/generator/progress edits from other
  sessions.
- Refresh the regenerated music-library cache index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 00:07:18 +02:00
parent f97de193d8
commit 498243af46
38 changed files with 1425 additions and 1599 deletions

View File

@@ -0,0 +1,138 @@
/** Bubbles mode - the first arcade mode, and the proof that the engine is
* mode-agnostic: same `useRun`, same grading, same unlock. Only the drawing differs.
*
* Each letter of the line is a bubble. The bubble at the cursor is the one nearest the
* surface; popping it lets the ones below rise. Position is derived from distance to the
* cursor rather than from a clock, which has two consequences worth stating: the rise is
* a CSS transition instead of a requestAnimationFrame loop, and - more importantly -
* taking your time costs nothing. The plan calls this a soft clock. Speed still shows up
* in the animal, because `grade` is measuring the keystrokes either way, but no bubble
* ever escapes and nothing is ever lost. At six, "you were too slow" is the fastest way
* to end a session. */
import { useState } from "react";
import { currentChar } from "../../lib/engine";
import type { RunEvent } from "../../lib/engine";
import { fingerOf } from "../../lib/fingers";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { useRun } from "../../hooks/useRun";
import { Keyboard } from "../Keyboard";
interface Props {
letters: readonly string[];
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
/** How many bubbles are in the water at once. More than five and the column of letters
* reads as a wall of text; fewer and there is nothing to look forward to. */
const VISIBLE_COUNT = 5;
/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
const LANES = [50, 28, 68, 38, 60, 46];
export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
const text = letters.join("");
const [popped, setPopped] = useState<number | null>(null);
const onEvent = (event: RunEvent) => {
// Remember which bubble just popped so it can play its burst before disappearing.
if (event.type === "correct") setPopped(event.index);
};
const { state, wrong } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
onEvent,
});
const next = currentChar(state);
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 18,
padding: "0 32px 8px",
minHeight: 0,
}}
>
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
{/* The surface line the bubbles rise toward. */}
<div
style={{
position: "absolute",
top: 8,
left: 0,
right: 0,
height: 2,
background: "oklch(97% 0.01 175 / 0.25)",
}}
/>
{[...text].map((letter, i) => {
const distance = i - state.index;
if (distance < 0 || distance >= VISIBLE_COUNT) return null;
const finger = fingerOf(letter);
const isCurrent = distance === 0;
const size = isCurrent ? 104 : 68;
return (
<div
key={i}
style={{
position: "absolute",
left: `${LANES[i % LANES.length]}%`,
// Distance from the cursor is distance from the surface. The transition
// is what makes popping one visibly lift the rest.
top: `${6 + distance * 19}%`,
transform: "translate(-50%, 0)",
width: size,
height: size,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: size * 0.42,
fontWeight: 900,
color: isCurrent ? "oklch(25% 0.05 175)" : "var(--paper)",
background: isCurrent
? "var(--paper)"
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`,
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${isCurrent ? 0.9 : 0.35})`,
boxShadow: isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
opacity: 1 - distance * 0.13,
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
animation:
popped === i
? "correctPop 200ms ease-out"
: isCurrent && wrong
? "wrongShake 260ms ease"
: undefined,
}}
>
{letter === " " ? "␣" : letter}
</div>
);
})}
</div>
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={38}
/>
</div>
);
}