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

@@ -2,50 +2,50 @@
*
* The rule this screen exists to enforce: there is no losing screen. A bad run shows
* fewer stars and a slower animal, and the primary button still says "Nochmal" with the
* focus already on it. Nothing here ever says "failed", nothing is red, and the Perlen
* always go up - see `perlenFor` in lib/grading.ts. */
* focus already on it. Nothing here ever says "failed", nothing is red, and the pearls
* always go up - see `pearlsFor` in lib/grading.ts. */
import { useEffect, useRef } from "react";
import { kreaturById } from "../lib/aquarium";
import type { KreaturId } from "../lib/aquarium";
import { creatureById } from "../lib/aquarium";
import type { CreatureId } from "../lib/aquarium";
import type { RunResult } from "../lib/grading";
import { STERNE_SCHWELLEN, sichtbareTiers, tierById, tierIndex, tierProgress } from "../lib/grading";
import type { TierId } from "../lib/grading";
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
import type { AnimalId } from "../lib/grading";
interface Props {
result: RunResult;
/** Set when this run opened the next lesson. */
unlockedTitel: string | null;
unlockedTitle: string | null;
/** Set when this run released a creature into the aquarium. */
neuesTier: KreaturId | null;
bestseit: boolean;
newCreature: CreatureId | null;
isNewBest: boolean;
/** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */
bestEver: TierId | null;
onNochmal: () => void;
onWeiter: () => void;
bestEver: AnimalId | null;
onRetry: () => void;
onContinue: () => void;
/** Null at the end of the curriculum. */
weiterLabel: string | null;
continueLabel: string | null;
}
export function ResultSheet({
result,
unlockedTitel,
neuesTier,
bestseit,
unlockedTitle,
newCreature,
isNewBest,
bestEver,
onNochmal,
onWeiter,
weiterLabel,
onRetry,
onContinue,
continueLabel,
}: Props) {
const tier = tierById(result.tier);
const leiter = sichtbareTiers(result.tier, bestEver);
const erreicht = tierIndex(result.tier);
const nochmal = useRef<HTMLButtonElement>(null);
const animal = animalById(result.animal);
const ladder = visibleAnimals(result.animal, bestEver);
const reached = animalIndex(result.animal);
const retryButton = useRef<HTMLButtonElement>(null);
// Enter repeats the run. Fewest keystrokes between "that was fun" and "again".
useEffect(() => nochmal.current?.focus(), []);
useEffect(() => retryButton.current?.focus(), []);
return (
<div className="overlay backdrop-enter">
@@ -54,21 +54,21 @@ export function ResultSheet({
style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }}
>
<div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}>
{tier.emoji}
{animal.emoji}
</div>
<div style={{ fontSize: 30, fontWeight: 900, color: "var(--ink)", marginTop: 6 }}>
{tier.name}
{animal.name}
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 8, margin: "14px 0 4px" }}>
{[1, 2, 3].map((stern) => (
{[1, 2, 3].map((star) => (
<span
key={stern}
key={star}
style={{
fontSize: 40,
animation: `sterneEnter 320ms ease-out ${140 + stern * 130}ms both`,
filter: result.sterne >= stern ? "none" : "grayscale(1)",
opacity: result.sterne >= stern ? 1 : 0.25,
animation: `sterneEnter 320ms ease-out ${140 + star * 130}ms both`,
filter: result.stars >= star ? "none" : "grayscale(1)",
opacity: result.stars >= star ? 1 : 0.25,
}}
>
@@ -77,9 +77,9 @@ export function ResultSheet({
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
<Stat label="Richtig" value={`${Math.round(result.genauigkeit * 100)}%`} />
<Stat label="Zeichen/Min" value={String(Math.round(result.tempo))} />
<Stat label="Perlen" value={`+${result.perlen}`} />
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
<Stat label="Perlen" value={`+${result.pearls}`} />
</div>
{/* How close the next animal is. A near miss is the strongest reason to press
@@ -95,7 +95,7 @@ export function ResultSheet({
>
<div
style={{
width: `${tierProgress(result.punkte) * 100}%`,
width: `${animalProgress(result.points) * 100}%`,
height: "100%",
background: "var(--accent)",
transition: "width 700ms ease-out",
@@ -103,19 +103,19 @@ export function ResultSheet({
/>
</div>
<TierLeiter tiers={leiter.tiers} erreicht={erreicht} mehrVerborgen={leiter.mehrVerborgen} />
<AnimalLadder animals={ladder.animals} reached={reached} moreHidden={ladder.moreHidden} />
{bestseit && (
{isNewBest && (
<div style={{ color: "var(--accent)", fontWeight: 900, marginTop: 8 }}>
🏅 Neuer Bestwert!
</div>
)}
{unlockedTitel && (
{unlockedTitle && (
<div style={{ color: "var(--ink)", fontWeight: 900, marginTop: 8 }}>
🔓 Neu: {unlockedTitel}
🔓 Neu: {unlockedTitle}
</div>
)}
{neuesTier && (
{newCreature && (
<div
style={{
display: "flex",
@@ -128,34 +128,34 @@ export function ResultSheet({
}}
>
<img
src={kreaturById(neuesTier).bild}
src={creatureById(newCreature).image}
alt=""
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
/>
{kreaturById(neuesTier).artikel} {kreaturById(neuesTier).name} ist ins Aquarium gezogen!
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
</div>
)}
{!result.bestanden && !unlockedTitel && (
{!result.passed && !unlockedTitle && (
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
Mit {Math.round(STERNE_SCHWELLEN.zwei * 100)}% Treffern geht es weiter. Fast!
Mit {Math.round(STAR_THRESHOLDS.two * 100)}% Treffern geht es weiter. Fast!
</div>
)}
<div style={{ display: "flex", gap: 12, marginTop: 22, justifyContent: "center" }}>
<button
ref={nochmal}
onClick={onNochmal}
ref={retryButton}
onClick={onRetry}
style={{
...knopf,
...buttonStyle,
background: "var(--accent)",
color: "var(--paper)",
}}
>
Nochmal
</button>
{weiterLabel && (
<button onClick={onWeiter} style={{ ...knopf, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
{weiterLabel}
{continueLabel && (
<button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
{continueLabel}
</button>
)}
</div>
@@ -170,17 +170,17 @@ export function ResultSheet({
* rungs below are what shows how far she has come. The rungs above are greyed out but
* still legible, because the next animal has to be visible to be worth aiming at.
*
* Above the Delfin the ladder stops: those animals are not shown at all until they are
* Above the dolphin the ladder stops: those animals are not shown at all until they are
* reached. A single "?" says the ladder continues without saying what is on it, which
* keeps the top end a surprise rather than a distant, discouraging number. */
function TierLeiter({
tiers,
erreicht,
mehrVerborgen,
function AnimalLadder({
animals,
reached,
moreHidden,
}: {
tiers: readonly { id: string; name: string; emoji: string }[];
erreicht: number;
mehrVerborgen: boolean;
animals: readonly { id: string; name: string; emoji: string }[];
reached: number;
moreHidden: boolean;
}) {
return (
<div
@@ -195,29 +195,29 @@ function TierLeiter({
}}
aria-label="Tier-Leiter"
>
{tiers.map((eintrag, i) => {
const geschafft = i <= erreicht;
const aktuell = i === erreicht;
{animals.map((entry, i) => {
const achieved = i <= reached;
const isCurrent = i === reached;
return (
<div
key={eintrag.id}
title={eintrag.name}
key={entry.id}
title={entry.name}
style={{
fontSize: aktuell ? 40 : 26,
fontSize: isCurrent ? 40 : 26,
lineHeight: 1,
padding: aktuell ? "0 5px" : 0,
filter: geschafft ? "none" : "grayscale(1)",
padding: isCurrent ? "0 5px" : 0,
filter: achieved ? "none" : "grayscale(1)",
// Bright enough to read as "this is next", dim enough to read as "not yet".
opacity: geschafft ? 1 : 0.42,
transform: aktuell ? "translateY(-3px)" : undefined,
opacity: achieved ? 1 : 0.42,
transform: isCurrent ? "translateY(-3px)" : undefined,
transition: "all 200ms ease",
}}
>
{eintrag.emoji}
{entry.emoji}
</div>
);
})}
{mehrVerborgen && (
{moreHidden && (
<div
title="Da geht noch was!"
style={{ fontSize: 24, opacity: 0.4, marginLeft: 4, filter: "grayscale(1)" }}
@@ -229,7 +229,7 @@ function TierLeiter({
);
}
const knopf: React.CSSProperties = {
const buttonStyle: React.CSSProperties = {
border: "none",
borderRadius: 999,
padding: "13px 26px",