Files
musicmouse/tippen/src/components/ResultSheet.tsx
Martin Bauer 498243af46 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>
2026-09-12 00:07:18 +02:00

249 lines
7.9 KiB
TypeScript

/** What a run earned.
*
* 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 pearls
* always go up - see `pearlsFor` in lib/grading.ts. */
import { useEffect, useRef } from "react";
import { creatureById } from "../lib/aquarium";
import type { CreatureId } from "../lib/aquarium";
import type { RunResult } 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. */
unlockedTitle: string | null;
/** Set when this run released a creature into the aquarium. */
newCreature: CreatureId | null;
isNewBest: boolean;
/** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */
bestEver: AnimalId | null;
onRetry: () => void;
onContinue: () => void;
/** Null at the end of the curriculum. */
continueLabel: string | null;
}
export function ResultSheet({
result,
unlockedTitle,
newCreature,
isNewBest,
bestEver,
onRetry,
onContinue,
continueLabel,
}: Props) {
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(() => retryButton.current?.focus(), []);
return (
<div className="overlay backdrop-enter">
<div
className="sheet sheet-enter"
style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }}
>
<div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}>
{animal.emoji}
</div>
<div style={{ fontSize: 30, fontWeight: 900, color: "var(--ink)", marginTop: 6 }}>
{animal.name}
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 8, margin: "14px 0 4px" }}>
{[1, 2, 3].map((star) => (
<span
key={star}
style={{
fontSize: 40,
animation: `sterneEnter 320ms ease-out ${140 + star * 130}ms both`,
filter: result.stars >= star ? "none" : "grayscale(1)",
opacity: result.stars >= star ? 1 : 0.25,
}}
>
</span>
))}
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
<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
Nochmal, so it is worth showing explicitly. */}
<div
style={{
height: 9,
borderRadius: 999,
background: "oklch(88% 0.02 175)",
margin: "18px 0 6px",
overflow: "hidden",
}}
>
<div
style={{
width: `${animalProgress(result.points) * 100}%`,
height: "100%",
background: "var(--accent)",
transition: "width 700ms ease-out",
}}
/>
</div>
<AnimalLadder animals={ladder.animals} reached={reached} moreHidden={ladder.moreHidden} />
{isNewBest && (
<div style={{ color: "var(--accent)", fontWeight: 900, marginTop: 8 }}>
🏅 Neuer Bestwert!
</div>
)}
{unlockedTitle && (
<div style={{ color: "var(--ink)", fontWeight: 900, marginTop: 8 }}>
🔓 Neu: {unlockedTitle}
</div>
)}
{newCreature && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--ink)",
fontWeight: 900,
marginTop: 8,
}}
>
<img
src={creatureById(newCreature).image}
alt=""
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
/>
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
</div>
)}
{!result.passed && !unlockedTitle && (
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
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={retryButton}
onClick={onRetry}
style={{
...buttonStyle,
background: "var(--accent)",
color: "var(--paper)",
}}
>
Nochmal
</button>
{continueLabel && (
<button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
{continueLabel}
</button>
)}
</div>
</div>
</div>
);
}
/** The animal ladder.
*
* Everything she has already passed stays in full colour - the run is a climb, and the
* 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 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 AnimalLadder({
animals,
reached,
moreHidden,
}: {
animals: readonly { id: string; name: string; emoji: string }[];
reached: number;
moreHidden: boolean;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-end",
gap: 4,
flexWrap: "wrap",
marginTop: 14,
minHeight: 44,
}}
aria-label="Tier-Leiter"
>
{animals.map((entry, i) => {
const achieved = i <= reached;
const isCurrent = i === reached;
return (
<div
key={entry.id}
title={entry.name}
style={{
fontSize: isCurrent ? 40 : 26,
lineHeight: 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: achieved ? 1 : 0.42,
transform: isCurrent ? "translateY(-3px)" : undefined,
transition: "all 200ms ease",
}}
>
{entry.emoji}
</div>
);
})}
{moreHidden && (
<div
title="Da geht noch was!"
style={{ fontSize: 24, opacity: 0.4, marginLeft: 4, filter: "grayscale(1)" }}
>
</div>
)}
</div>
);
}
const buttonStyle: React.CSSProperties = {
border: "none",
borderRadius: 999,
padding: "13px 26px",
fontSize: 17,
fontWeight: 900,
cursor: "pointer",
};
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: 25, fontWeight: 900, color: "var(--ink)" }}>{value}</div>
<div style={{ fontSize: 12, fontWeight: 800, color: "var(--ink)", opacity: 0.6 }}>{label}</div>
</div>
);
}