diff --git a/tippen/.gitignore b/tippen/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/tippen/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/tippen/README.md b/tippen/README.md
new file mode 100644
index 0000000..7d2427d
--- /dev/null
+++ b/tippen/README.md
@@ -0,0 +1,107 @@
+# Delfin Tippen
+
+Ein Lernspiel fürs Zehnfingerschreiben auf einer deutschen QWERTZ-Tastatur, gebaut für
+eine Sechsjährige, die Delfine mag. Alles auf Deutsch.
+
+Technisch ein Geschwister des Musik-Players in `../web` — React 19, TypeScript, Vite,
+keine Laufzeit-Abhängigkeiten außer React, dieselbe oklch-Glasoptik und dieselbe
+Nunito — aber **vollständig entkoppelt**: eigenes Vite-Projekt, eigener Dev-Server,
+kein Backend, kein gemeinsamer Build. Was geteilt werden sollte, wurde kopiert.
+
+## Loslegen
+
+```sh
+npm install
+npm run dev # http://localhost:5174
+npm run test # vitest über src/lib
+npx tsc --noEmit # der eigentliche Check
+npm run build # -> dist/
+```
+
+Es braucht eine echte QWERTZ-Tastatur. Der Fortschritt liegt im `localStorage`
+(`delfin-tippen:v1`); zum Zurücksetzen den Key löschen.
+
+## Wie es aufgebaut ist
+
+Die Logik ist DOM-frei und getestet, die Komponenten sind Layout. Das ist dieselbe
+Aufteilung wie in `../web` und der Grund, warum sechs Spielmodi dieselbe Mechanik teilen
+können, ohne sie sechsmal zu implementieren.
+
+| Datei | Wofür |
+|---|---|
+| `lib/fingers.ts` | Die QWERTZ-Belegung: welcher Finger welche Taste, welche Farbe, welche Grundstellungstaste. |
+| `lib/curriculum.ts` | 48 Lektionen in 5 Welten. Höchstens zwei neue Tasten pro Lektion, mit Übungslektionen dazwischen. |
+| `lib/generator.ts` | Was getippt wird — Übungszeilen aus den aktiven Tasten, echte Wörter sobald möglich. |
+| `lib/engine.ts` | Die Tippmechanik: ein Tastendruck rein, ein neuer Zustand und Ereignisse raus. Pur. |
+| `lib/grading.ts` | Zeichen pro Minute, Genauigkeit, Sterne, Tier. |
+| `lib/progress.ts` | Was das Spiel sich merkt, und wann die nächste Lektion aufgeht. |
+| `hooks/useRun.ts` | Der dünne Adapter zwischen echten Tastendrücken und `engine.ts`. |
+
+## Die Entscheidungen, die das Spiel ausmachen
+
+Vier davon sind bewusst anders, als man es zuerst bauen würde. Sie stehen ausführlicher
+in den Dateikommentaren:
+
+**Tempo schaltet nichts frei.** Die nächste Lektion geht bei 93 % Treffern auf — ohne
+jede Geschwindigkeitsbedingung. Wer langsam und sorgfältig tippt, kommt durch den ganzen
+Kurs. Tempo bringt nur das Tier. Zusätzlich geht die nächste Lektion nach fünf Versuchen
+ohnehin auf, damit niemand an einer Taste hängen bleibt.
+
+**`punkte = tempo × genauigkeit³`.** Die Lehrbuchformel `netto = brutto − fehler/minute`
+wird bei einem Anfänger negativ, und ein negatives Ergebnis darf hier nie erscheinen.
+Multiplikativ kann das nicht passieren: 95 % Genauigkeit behalten 86 % des Tempos, 90 %
+behalten 73 %.
+
+**Eine falsche Taste rückt nicht vor und zählt pro Stelle nur einmal.** Es gibt kein
+Backspace und keine zerschossene Zeile zum Anstarren, und zehnmal panisch dieselbe
+falsche Taste ist ein Fehler, nicht zehn.
+
+**Es gibt keinen Verloren-Bildschirm.** Eine schwache Runde zeigt weniger Sterne und ein
+langsameres Tier — nie ein rotes X, nie einen Summer, und die Perlen steigen immer.
+
+## Die sechs Spielmodi
+
+Alle sechs laufen über dieselbe Mechanik (`lib/engine.ts`) und landen in derselben
+Bewertung. Sie unterscheiden sich nur darin, *wie* das Ziel gezeigt wird — deshalb ist
+ein siebter Modus eine Datei und eine Zeile, keine zweite Spiellogik.
+
+| Modus | Was man sieht | Wofür |
+|---|---|---|
+| 🤿 Tauchgang | Eine Zeile mit Cursor | Die Kernübung. Sie zählt fürs Freischalten. |
+| 🫧 Blasenplatzen | Blasen steigen auf, eine pro Buchstabe | Tastenlage, weiche Uhr |
+| 🦑 Quallenalarm | Sechs Quallen, eine leuchtet | Reine Tastenlage — erst suchen, dann tippen |
+| 🐟 Fütterungszeit | Fische mit Wörtern schwimmen zum Delfin | Ganze Wörter statt Buchstaben |
+| 🐬 Delfinrennen | Zwei Bahnen: du gegen deinen Rekord | Tempo, gegen den einzigen fairen Gegner |
+| 🦪 Perlentaucher | Eine Muschel, ein Wort, eine Perlenkette | Genauigkeit — **ohne jede Uhr** |
+
+Beim Delfinrennen ist der Gegner der Geist des eigenen besten Laufs: die Tastenzeiten
+liegen in `progress.lessons[id].ghost` und werden gegen die Uhr abgespielt. Gegen sich
+selbst zu rennen ist das einzige Wettkampfformat, das nicht entmutigen kann — der Gegner
+ist per Definition genau so gut, wie man selbst war. Vor dem ersten Rekord schwimmt eine
+absichtlich schlagbare Krabbe.
+
+## Das Aquarium
+
+Wer eine Welt schafft, bekommt ein Haustier — Clownfisch, Krake, Seepferdchen,
+Schildkröte, Perlmuschel. Es zieht für immer ein und schwimmt ab dann frei hinter jedem
+Bildschirm herum; während einer Runde nur blass, damit es nicht mit der Zeile
+konkurriert. Das Schwimmen ist ein purer, getesteter Schritt in `lib/aquarium.ts`,
+`components/AquariumTiere.tsx` zeichnet nur.
+
+Ein neues Tier: die Zeichnung (auf schwarzem Hintergrund) nach `art/aquarium/` legen,
+
+```sh
+scripts/aquarium-bild.sh art/aquarium/qualle.png public/aquarium/qualle.webp
+```
+
+und in `KREATUREN` eintragen. Das Skript entfernt den Hintergrund als zusammenhängende
+Fläche statt als Farbe — schwarze Augen und Wimpern bleiben stehen — und rechnet die
+Kanten aus dem Schwarz heraus, damit kein dunkler Rand bleibt.
+
+## Stand
+
+Fertig und spielbar: alle 48 Lektionen, alle sechs Modi, Freischalten, Bewertung,
+Tierleiter, Aquarium, Sprachausgabe und die Bildschirmtastatur.
+
+Noch offen: die Geschichten am Ende jeder Welt, der Eltern-Bildschirm (`?eltern=1`) und
+eine Welt 6 mit der Zahlenreihe.
diff --git a/tippen/art/aquarium/clownfisch.png b/tippen/art/aquarium/clownfisch.png
new file mode 100644
index 0000000..fbac340
Binary files /dev/null and b/tippen/art/aquarium/clownfisch.png differ
diff --git a/tippen/art/aquarium/krake.png b/tippen/art/aquarium/krake.png
new file mode 100644
index 0000000..136b0ef
Binary files /dev/null and b/tippen/art/aquarium/krake.png differ
diff --git a/tippen/art/aquarium/perlmuschel.png b/tippen/art/aquarium/perlmuschel.png
new file mode 100644
index 0000000..6bcb129
Binary files /dev/null and b/tippen/art/aquarium/perlmuschel.png differ
diff --git a/tippen/art/aquarium/schildkroete.png b/tippen/art/aquarium/schildkroete.png
new file mode 100644
index 0000000..87b38a6
Binary files /dev/null and b/tippen/art/aquarium/schildkroete.png differ
diff --git a/tippen/art/aquarium/seepferdchen.png b/tippen/art/aquarium/seepferdchen.png
new file mode 100644
index 0000000..cc043a8
Binary files /dev/null and b/tippen/art/aquarium/seepferdchen.png differ
diff --git a/tippen/index.html b/tippen/index.html
new file mode 100644
index 0000000..432eaf0
--- /dev/null
+++ b/tippen/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Delfin Tippen
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tippen/package-lock.json b/tippen/package-lock.json
new file mode 100644
index 0000000..a284872
--- /dev/null
+++ b/tippen/package-lock.json
@@ -0,0 +1,2333 @@
+{
+ "name": "delfin-tippen",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "delfin-tippen",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^5.0.0",
+ "typescript": "^5.9.0",
+ "vite": "^7.1.0",
+ "vitest": "^3.2.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+ "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+ "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+ "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+ "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+ "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+ "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+ "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+ "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+ "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+ "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+ "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+ "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+ "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+ "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+ "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+ "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+ "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+ "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+ "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+ "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+ "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+ "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+ "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
+ "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
+ "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+ "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
+ "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.7",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+ "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
+ "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
+ "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.22",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz",
+ "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.9",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
+ "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.20",
+ "caniuse-lite": "^1.0.30001810",
+ "electron-to-chromium": "^1.5.420",
+ "node-releases": "^2.0.54",
+ "update-browserslist-db": "^1.3.2"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.427",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz",
+ "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.55",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
+ "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
+ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.1",
+ "@rollup/rollup-android-arm64": "4.63.1",
+ "@rollup/rollup-darwin-arm64": "4.63.1",
+ "@rollup/rollup-darwin-x64": "4.63.1",
+ "@rollup/rollup-freebsd-arm64": "4.63.1",
+ "@rollup/rollup-freebsd-x64": "4.63.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+ "@rollup/rollup-linux-arm64-musl": "4.63.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+ "@rollup/rollup-linux-loong64-musl": "4.63.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-musl": "4.63.1",
+ "@rollup/rollup-openbsd-x64": "4.63.1",
+ "@rollup/rollup-openharmony-arm64": "4.63.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+ "@rollup/rollup-win32-x64-gnu": "4.63.1",
+ "@rollup/rollup-win32-x64-msvc": "4.63.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz",
+ "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
+ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.7",
+ "@vitest/mocker": "3.2.7",
+ "@vitest/pretty-format": "^3.2.7",
+ "@vitest/runner": "3.2.7",
+ "@vitest/snapshot": "3.2.7",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.7",
+ "@vitest/ui": "3.2.7",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/tippen/package.json b/tippen/package.json
new file mode 100644
index 0000000..7b1c4eb
--- /dev/null
+++ b/tippen/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "delfin-tippen",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^5.0.0",
+ "typescript": "^5.9.0",
+ "vite": "^7.1.0",
+ "vitest": "^3.2.0"
+ },
+ "allowScripts": {
+ "esbuild@0.28.2": true
+ }
+}
diff --git a/tippen/public/aquarium/clownfisch.webp b/tippen/public/aquarium/clownfisch.webp
new file mode 100644
index 0000000..9323004
Binary files /dev/null and b/tippen/public/aquarium/clownfisch.webp differ
diff --git a/tippen/public/aquarium/krake.webp b/tippen/public/aquarium/krake.webp
new file mode 100644
index 0000000..e97e65a
Binary files /dev/null and b/tippen/public/aquarium/krake.webp differ
diff --git a/tippen/public/aquarium/perlmuschel.webp b/tippen/public/aquarium/perlmuschel.webp
new file mode 100644
index 0000000..0af436b
Binary files /dev/null and b/tippen/public/aquarium/perlmuschel.webp differ
diff --git a/tippen/public/aquarium/schildkroete.webp b/tippen/public/aquarium/schildkroete.webp
new file mode 100644
index 0000000..8ae29d0
Binary files /dev/null and b/tippen/public/aquarium/schildkroete.webp differ
diff --git a/tippen/public/aquarium/seepferdchen.webp b/tippen/public/aquarium/seepferdchen.webp
new file mode 100644
index 0000000..c61d3c9
Binary files /dev/null and b/tippen/public/aquarium/seepferdchen.webp differ
diff --git a/tippen/public/dolphin-mascot.png b/tippen/public/dolphin-mascot.png
new file mode 100644
index 0000000..bf3fb0c
Binary files /dev/null and b/tippen/public/dolphin-mascot.png differ
diff --git a/tippen/public/fonts/nunito-latin-ext.woff2 b/tippen/public/fonts/nunito-latin-ext.woff2
new file mode 100644
index 0000000..584a55b
Binary files /dev/null and b/tippen/public/fonts/nunito-latin-ext.woff2 differ
diff --git a/tippen/public/fonts/nunito-latin.woff2 b/tippen/public/fonts/nunito-latin.woff2
new file mode 100644
index 0000000..e118818
Binary files /dev/null and b/tippen/public/fonts/nunito-latin.woff2 differ
diff --git a/tippen/public/fonts/nunito.css b/tippen/public/fonts/nunito.css
new file mode 100644
index 0000000..5404723
--- /dev/null
+++ b/tippen/public/fonts/nunito.css
@@ -0,0 +1,22 @@
+/* Nunito, self-hosted: one variable file per subset covers 500-900.
+
+ Google Fonts would be a network round trip the mouse may not have, and a
+ cached app shell rendered in the fallback face looks broken. latin-ext is
+ what carries "Hörbücher" and "Käpt'n"; the Cyrillic and Vietnamese subsets
+ Google also offers are dropped, since nothing in this UI can produce them. */
+@font-face {
+ font-family: "Nunito";
+ font-style: normal;
+ font-weight: 500 900;
+ font-display: swap;
+ src: url("/fonts/nunito-latin-ext.woff2") format("woff2");
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: "Nunito";
+ font-style: normal;
+ font-weight: 500 900;
+ font-display: swap;
+ src: url("/fonts/nunito-latin.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/tippen/public/icon-192.png b/tippen/public/icon-192.png
new file mode 100644
index 0000000..a0ef028
Binary files /dev/null and b/tippen/public/icon-192.png differ
diff --git a/tippen/scripts/aquarium-bild.sh b/tippen/scripts/aquarium-bild.sh
new file mode 100755
index 0000000..876ada9
--- /dev/null
+++ b/tippen/scripts/aquarium-bild.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Turns a creature illustration on solid black into a transparent aquarium sprite.
+#
+# scripts/aquarium-bild.sh bild.png public/aquarium/krake.webp
+#
+# The illustrations come out of the image generator as RGB on pure black, with no alpha
+# channel. "Black becomes transparent" is not enough: the eyes and eyelashes are black
+# too, and a creature with see-through eyes looks haunted on a turquoise sea. So the
+# background is found as a *region*, not a colour - every dark area big enough to not be
+# a facial feature - and only that region is removed.
+#
+# The edge is the other half. The art was antialiased onto black, so its outline pixels
+# are the creature's colour already mixed with black. Cutting them hard leaves a dark
+# halo; instead, near the background the opacity follows the brightness and the colour
+# is divided back out of the black (un-premultiplied), which is what the mix actually was.
+set -euo pipefail
+
+if [[ $# -lt 2 ]]; then
+ echo "usage: $0 input.png output.webp [size]" >&2
+ exit 1
+fi
+src=$1
+out=$2
+size=${3:-512}
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+# How far each pixel is from black: its brightest channel.
+magick "$src" -alpha off -channel RGB -separate -evaluate-sequence max "$tmp/hell.png"
+
+# Dark pixels, then drop every dark island under 5000 px back into the body. Measured on
+# the first five creatures: the background is one region of ~1M px, the largest facial
+# feature is ~2000 px, and nothing sits in between.
+magick "$tmp/hell.png" -threshold 8% -negate \
+ -define connected-components:area-threshold=5000 \
+ -define connected-components:mean-color=true \
+ -connected-components 8 "$tmp/grund.png"
+
+# Opacity: solid inside the body, zero out in the background, and in a thin band along
+# the outline a ramp on brightness - the band is what catches the antialiased edge. The
+# ramp starts a little above black, or the generator's faint noise in the background
+# would come back as a faint haze around every creature.
+magick "$tmp/grund.png" -morphology Dilate Disk:3 -negate "$tmp/koerper.png"
+magick "$tmp/grund.png" -negate -morphology Dilate Disk:5 "$tmp/umriss.png"
+magick "$tmp/hell.png" -evaluate Subtract 4% -evaluate Multiply 3.8 "$tmp/rampe.png"
+magick "$tmp/rampe.png" "$tmp/koerper.png" -compose Lighten -composite \
+ "$tmp/umriss.png" -compose Multiply -composite "$tmp/alpha.png"
+
+# Divide the black back out of the edge colours, attach the opacity, cut to the creature
+# and scale down - the sprites are drawn at a few hundred px at most. `-alpha background`
+# blanks the colour of fully transparent pixels, which is what lets `-trim` see them as
+# border at all.
+magick "$src" -alpha off "$tmp/alpha.png" -compose DivideSrc -composite \
+ "$tmp/alpha.png" -alpha off -compose CopyOpacity -composite \
+ -background none -alpha background -trim +repage -resize "${size}x${size}>" \
+ -quality 90 -define webp:alpha-quality=100 "$out"
diff --git a/tippen/src/App.tsx b/tippen/src/App.tsx
new file mode 100644
index 0000000..cb7c2ba
--- /dev/null
+++ b/tippen/src/App.tsx
@@ -0,0 +1,333 @@
+/** The whole app: one state object, one keydown listener for navigation, four screens.
+ *
+ * The same shape as ../../web/src/App.tsx - state lives here, components are layout, and
+ * the keyboard is handled in one place rather than scattered through the tree.
+ *
+ * One rule is worth stating because it is easy to break later: **while a run is going,
+ * every key belongs to the run**. Only Escape and F1 are intercepted here. Otherwise a
+ * lesson that happens to teach `m` would mute the game every time she typed it, and the
+ * mute would look to her like the game breaking. The run's own listener lives in
+ * hooks/useRun.ts and does the typing; this one only handles the screens around it. */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { Aquarium } from "./components/Aquarium";
+import { AppHeader } from "./components/AppHeader";
+import { HelpOverlay } from "./components/HelpOverlay";
+import { LessonMap } from "./components/LessonMap";
+import { ModePicker } from "./components/ModePicker";
+import { ResultSheet } from "./components/ResultSheet";
+import { Stage } from "./components/Stage";
+import { BlasenRun } from "./components/modes/BlasenRun";
+import { FuetternRun } from "./components/modes/FuetternRun";
+import { PerlenRun } from "./components/modes/PerlenRun";
+import { QuallenRun } from "./components/modes/QuallenRun";
+import { RennenRun } from "./components/modes/RennenRun";
+import { TauchgangRun } from "./components/modes/TauchgangRun";
+import type { KreaturId } from "./lib/aquarium";
+import { LESSONS, lessonById, nextLesson } from "./lib/curriculum";
+import type { Lesson, ModeId } from "./lib/curriculum";
+import { lineFor, lineText, letterStream, mulberry32 } from "./lib/generator";
+import type { RunResult } from "./lib/grading";
+import { playFanfare, playPop } from "./lib/pop";
+import { besteTier, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress";
+import type { Progress } from "./lib/progress";
+import { say, stop as stopSpeech } from "./lib/speech";
+import { blasenAnzahlFuer } from "./lib/theme";
+
+type Screen = "aquarium" | "karte" | "lauf";
+
+/** What a mode is handed to draw. Tagged rather than optional-fielded so the render
+ * below narrows on `art` instead of guessing from which keys are present.
+ *
+ * Only two shapes, for six modes: the arcade modes want a stream of single letters, and
+ * everything else wants a line of chunks. That is the whole reason the modes are cheap
+ * to add - they are presentations of one of two targets, all driven by the same engine. */
+type Lauf =
+ | { art: "buchstaben"; letters: readonly string[] }
+ | { art: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
+
+/** Modes that drill one key at a time rather than a line. */
+const BUCHSTABEN_MODI: readonly ModeId[] = ["blasen", "quallen"];
+
+interface Ergebnis {
+ result: RunResult;
+ unlockedTitel: string | null;
+ neuesTier: KreaturId | null;
+ bestseit: boolean;
+}
+
+export function App() {
+ const [progress, setProgress] = useState(loadProgress);
+ const [screen, setScreen] = useState("aquarium");
+ const [lessonId, setLessonId] = useState(null);
+ const [modus, setModus] = useState("tauchgang");
+ const [ergebnis, setErgebnis] = useState(null);
+ const [showHelp, setShowHelp] = useState(false);
+ const [selected, setSelected] = useState(0);
+ /** Bumped to generate a fresh line - a new seed for the same lesson. */
+ const [runde, setRunde] = useState(0);
+
+ useEffect(() => saveProgress(progress), [progress]);
+
+ const lesson = lessonId === null ? null : lessonById(lessonId);
+
+ /** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
+ const weiter = useMemo(() => {
+ const offen = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked);
+ return offen.find((l) => (progress.lessons[l.id]?.bestSterne ?? 0) < 2) ?? offen.at(-1) ?? null;
+ }, [progress]);
+
+ /** The line for this run. Reproducible from the lesson, the mode and the round
+ * counter, so a re-render never reshuffles the text mid-run. */
+ const lauf = useMemo((): Lauf | null => {
+ if (!lesson) return null;
+ const seed = lesson.nummer * 1000 + runde * 7 + (modus === "blasen" ? 3 : 0);
+ const rng = mulberry32(seed);
+ const focusKey = focusKeyFor(progress, lesson.activeKeys);
+ const spaceActive = lesson.activeKeys.includes(" ");
+
+ if (BUCHSTABEN_MODI.includes(modus)) {
+ return {
+ art: "buchstaben",
+ letters: letterStream(lesson.activeKeys, rng, blasenAnzahlFuer(lesson.welt), focusKey, lesson.neueKeys),
+ };
+ }
+ const chunks = lineFor(lesson, rng, {
+ chunks: lesson.chunks,
+ chunkSize: lesson.chunkSize,
+ focusKey,
+ });
+ return { art: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
+ // `progress` is deliberately not a dependency: the focus key is read once when the
+ // line is built, and re-reading it after every keystroke would rebuild the line
+ // underneath her fingers.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [lesson, modus, runde]);
+
+ const starte = useCallback(
+ (ziel: Lesson) => {
+ setLessonId(ziel.id);
+ setModus(ziel.modi.includes(modus) ? modus : "tauchgang");
+ setErgebnis(null);
+ setRunde((r) => r + 1);
+ setScreen("lauf");
+ say(ziel.titel, progress.settings.speech);
+ },
+ [modus, progress.settings.speech],
+ );
+
+ const onFinished = useCallback(
+ (result: RunResult) => {
+ if (!lessonId) return;
+ const outcome = recordRun(progress, lessonId, result);
+ setProgress(outcome.progress);
+ setErgebnis({
+ result,
+ unlockedTitel: outcome.unlockedLessonId
+ ? (lessonById(outcome.unlockedLessonId)?.titel ?? null)
+ : null,
+ neuesTier: outcome.neuesTier,
+ bestseit: outcome.bestseit,
+ });
+ if (progress.settings.sound && (outcome.unlockedLessonId || outcome.neuesTier)) {
+ playFanfare();
+ }
+ },
+ [lessonId, progress],
+ );
+
+ const nochmal = useCallback(() => {
+ setErgebnis(null);
+ setRunde((r) => r + 1);
+ }, []);
+
+ const weiterNachErgebnis = useCallback(() => {
+ const folgend = lessonId ? nextLesson(lessonId) : null;
+ setErgebnis(null);
+ if (folgend && progress.lessons[folgend.id]?.unlocked) starte(folgend);
+ else setScreen("karte");
+ }, [lessonId, progress, starte]);
+
+ const zurueck = useCallback(() => {
+ stopSpeech();
+ if (ergebnis) return setErgebnis(null);
+ if (screen === "lauf") return setScreen("karte");
+ if (screen === "karte") return setScreen("aquarium");
+ }, [ergebnis, screen]);
+
+ // --- navigation keys -----------------------------------------------------
+
+ const latest = useRef({ screen, ergebnis, selected, zurueck, nochmal, weiter, starte });
+ latest.current = { screen, ergebnis, selected, zurueck, nochmal, weiter, starte };
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ const current = latest.current;
+
+ if (event.key === "F1") {
+ event.preventDefault();
+ setShowHelp((open) => !open);
+ return;
+ }
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setShowHelp(false);
+ current.zurueck();
+ return;
+ }
+
+ // Enter repeats a finished run; the result sheet's own button has focus, so this
+ // is only a fallback for when focus has been lost.
+ if (current.ergebnis) {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ current.nochmal();
+ }
+ return;
+ }
+
+ // Everything below is navigation, and must not fire while typing.
+ if (current.screen === "lauf") return;
+
+ if (event.key === "Enter") {
+ event.preventDefault();
+ if (current.screen === "aquarium") {
+ if (current.weiter) current.starte(current.weiter);
+ } else {
+ const lesson = LESSONS[current.selected];
+ if (lesson) current.starte(lesson);
+ }
+ return;
+ }
+
+ if (current.screen !== "karte") return;
+ const schritt =
+ event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
+ if (schritt !== 0) {
+ event.preventDefault();
+ playPop(340);
+ setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + schritt)));
+ }
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, []);
+
+ // Settings keys live outside a run, where they cannot collide with the alphabet.
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (screen === "lauf" && !ergebnis) return;
+ const key = event.key.toLowerCase();
+ if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } }));
+ if (key === "s") setProgress((p) => ({ ...p, settings: { ...p.settings, speech: !p.settings.speech } }));
+ if (key === "h") {
+ setProgress((p) => ({
+ ...p,
+ settings: {
+ ...p.settings,
+ keyboardHint: p.settings.keyboardHint === "off" ? "auto" : "off",
+ },
+ }));
+ }
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [screen, ergebnis]);
+
+ // --- render --------------------------------------------------------------
+
+ const folgend = lessonId ? nextLesson(lessonId) : null;
+
+ // Every mode takes the same bundle; only the drawing differs. Built here so adding a
+ // seventh mode is one line in the switch below rather than eight repeated props.
+ const gemeinsam = {
+ activeKeys: lesson?.activeKeys ?? [],
+ progress,
+ paused: ergebnis !== null,
+ onFinished,
+ };
+ const buchstabenProps = (letters: readonly string[]) => ({ letters, ...gemeinsam });
+ const textProps = (l: Extract) => ({
+ chunks: l.chunks,
+ text: l.text,
+ spaceActive: l.spaceActive,
+ ...gemeinsam,
+ });
+
+ return (
+
+
+ 🦪 {progress.perlen}
+ {!progress.settings.sound && 🔇 }
+
+ }
+ />
+
+ {screen === "aquarium" && (
+ weiter && starte(weiter)}
+ onKarte={() => setScreen("karte")}
+ />
+ )}
+
+ {screen === "karte" && (
+
+ )}
+
+ {screen === "lauf" && lesson && lauf && (
+ <>
+ {lauf.art === "buchstaben" ? (
+ modus === "quallen" ? (
+
+ ) : (
+
+ )
+ ) : modus === "fuettern" ? (
+
+ ) : modus === "perlen" ? (
+
+ ) : modus === "rennen" ? (
+
+ ) : (
+
+ )}
+
+ {
+ setModus(gewaehlt);
+ setRunde((r) => r + 1);
+ }}
+ />
+
+ >
+ )}
+
+ {ergebnis && (
+
+ )}
+
+ {showHelp && setShowHelp(false)} />}
+
+ );
+}
diff --git a/tippen/src/components/AppHeader.tsx b/tippen/src/components/AppHeader.tsx
new file mode 100644
index 0000000..21e4f26
--- /dev/null
+++ b/tippen/src/components/AppHeader.tsx
@@ -0,0 +1,54 @@
+/** The header, matching ../../web/src/components/AppHeader.tsx so the two apps open the
+ * same way. The mascot bobs; `SHOW_BUBBLES` doubles as the decorative-motion switch. */
+
+import type { ReactNode } from "react";
+
+interface Props {
+ title?: string;
+ /** Shown on the right - Perlen, streak, a back hint. */
+ status?: ReactNode;
+ /** Smaller header while a lesson is running, so the target line gets the room. */
+ compact?: boolean;
+ onMascotClick?: () => void;
+}
+
+export function AppHeader({ title = "Delfin Tippen", status, compact = false, onMascotClick }: Props) {
+ const size = compact ? 44 : 64;
+ return (
+
+
+
+ {title}
+
+ {status &&
{status}
}
+
+ );
+}
diff --git a/tippen/src/components/Aquarium.tsx b/tippen/src/components/Aquarium.tsx
new file mode 100644
index 0000000..30381fa
--- /dev/null
+++ b/tippen/src/components/Aquarium.tsx
@@ -0,0 +1,136 @@
+/** The home screen: what she has collected, before she is asked to do anything.
+ *
+ * Deliberately the first thing on opening the app. The reward for finishing a Welt is a
+ * pet that moves in for good - it swims behind every screen from then on (see
+ * AquariumTiere.tsx) - and a reward you can see before you start is worth more than one
+ * you are told about afterwards.
+ *
+ * The panel shows every pet there is to earn: the ones at home in colour, the rest as
+ * pale outlines. The same idea as the tier ladder - the next thing has to be visible to
+ * be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
+
+import { kreaturById } from "../lib/aquarium";
+import type { Lesson } from "../lib/curriculum";
+import { WELTEN } from "../lib/curriculum";
+import { tierById } from "../lib/grading";
+import { besteTier } from "../lib/progress";
+import type { Progress } from "../lib/progress";
+
+interface Props {
+ progress: Progress;
+ /** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
+ weiter: Lesson | null;
+ onWeiter: () => void;
+ onKarte: () => void;
+}
+
+export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
+ const bestesTier = besteTier(progress);
+
+ return (
+
+
+
+ Dein Aquarium
+
+
+
+ {WELTEN.map((welt) => {
+ const kreatur = kreaturById(welt.belohnung);
+ const daheim = progress.aquarium.includes(kreatur.id);
+ return (
+
+ );
+ })}
+
+
+ {progress.aquarium.length === 0 && (
+
+ Schaffe eine ganze Welt und dein erstes Tier zieht ein!
+
+ )}
+
+
+
+
+
+
+
+
+
+ {weiter && (
+
+ ▶ {weiter.titel}
+
+ )}
+
+ 🗺️ Alle Lektionen
+
+
+
+ );
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
diff --git a/tippen/src/components/AquariumTiere.tsx b/tippen/src/components/AquariumTiere.tsx
new file mode 100644
index 0000000..9f77f18
--- /dev/null
+++ b/tippen/src/components/AquariumTiere.tsx
@@ -0,0 +1,119 @@
+/** The pets, swimming freely behind every screen.
+ *
+ * One requestAnimationFrame loop, started once and never restarted - the same approach as
+ * ../../web/src/components/Ambience.tsx. Swimmers live in a ref and each frame is written
+ * straight to its image's `transform`, so sixty frames a second never touch React. React
+ * renders this component only when a pet moves in or the layer is dimmed.
+ *
+ * The layer sits at z-index -1 inside the stage's own stacking context (`isolation` on
+ * .stage): above the gradient, below every screen. That is also what makes the glass
+ * panels frost a pet that drifts behind one, instead of it swimming over the buttons.
+ *
+ * Pets already home when the app opens start somewhere inside the tank; a pet earned
+ * while the app is open swims in from the side, so its arrival is something to see. */
+
+import { useEffect, useRef } from "react";
+
+import { kreaturById, neuerSchwimmer, pose, schwimme } from "../lib/aquarium";
+import type { KreaturId, Schwimmer } from "../lib/aquarium";
+
+interface Props {
+ tiere: readonly KreaturId[];
+ /** 0..1, eased - the pets fade back while she types. */
+ deckkraft: number;
+}
+
+/** A tab in the background resumes with a gap of minutes. Swimming that in one step would
+ * teleport every pet, so a frame never counts for more than this. */
+const MAX_SCHRITT_S = 0.1;
+
+export function AquariumTiere({ tiere, deckkraft }: Props) {
+ const becken = useRef(null);
+ const bilder = useRef(new Map());
+ const schwimmer = useRef(new Map());
+ const aktuell = useRef(tiere);
+ aktuell.current = tiere;
+ /** The pets present at first render - everyone after them is a newcomer. */
+ const daheim = useRef>(new Set(tiere));
+
+ useEffect(() => {
+ // Reduced motion: every pet is still placed and shown, it just holds still.
+ const ruhig = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ let frame = 0;
+ let zuletzt = performance.now();
+
+ const schritt = (jetzt: number) => {
+ const dt = Math.min(MAX_SCHRITT_S, Math.max(0, (jetzt - zuletzt) / 1000));
+ zuletzt = jetzt;
+ const el = becken.current;
+ if (el) {
+ const groesse = { breite: el.clientWidth, hoehe: el.clientHeight };
+ for (const id of aktuell.current) {
+ const bild = bilder.current.get(id);
+ if (!bild) continue;
+ const kreatur = kreaturById(id);
+ const hoehe = kreatur.groesse * groesse.hoehe;
+ const rand = hoehe / 2;
+ const tempo = kreatur.tempo * groesse.breite;
+
+ const vorher = schwimmer.current.get(id);
+ const s = vorher
+ ? ruhig
+ ? vorher
+ : schwimme(vorher, dt, groesse, rand, tempo, Math.random)
+ : neuerSchwimmer(groesse, rand, Math.random, !ruhig && !daheim.current.has(id));
+ schwimmer.current.set(id, s);
+
+ const p = pose(s, kreatur, tempo);
+ bild.style.height = `${hoehe}px`;
+ bild.style.transform =
+ `translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
+ `rotate(${p.drehung}deg) scaleX(${p.spiegel})`;
+ }
+ }
+ frame = requestAnimationFrame(schritt);
+ };
+
+ frame = requestAnimationFrame(schritt);
+ return () => cancelAnimationFrame(frame);
+ }, []);
+
+ return (
+
+ {tiere.map((id) => (
+
{
+ if (bild) bilder.current.set(id, bild);
+ else bilder.current.delete(id);
+ }}
+ src={kreaturById(id).bild}
+ alt=""
+ draggable={false}
+ style={{
+ position: "absolute",
+ left: 0,
+ top: 0,
+ height: 0,
+ // Off-stage until the loop has placed it, so nothing flashes in the corner.
+ transform: "translate(-200vw, 0)",
+ willChange: "transform",
+ filter: "drop-shadow(0 10px 16px oklch(15% 0.05 175 / 0.35))",
+ }}
+ />
+ ))}
+
+ );
+}
diff --git a/tippen/src/components/Bubbles.tsx b/tippen/src/components/Bubbles.tsx
new file mode 100644
index 0000000..3be0e74
--- /dev/null
+++ b/tippen/src/components/Bubbles.tsx
@@ -0,0 +1,41 @@
+/** The decorative bubble field, copied from ../../web/src/components/Bubbles.tsx.
+ * Positions are a fixed table rather than random so they do not reshuffle on render. */
+
+interface Props {
+ count?: number;
+}
+
+/** [left %, size px, opacity, seconds, delay seconds] */
+const BUBBLES: readonly [number, number, number, number, number][] = [
+ [6, 14, 0.18, 13, 0], [17, 9, 0.14, 16, 2.5], [28, 20, 0.12, 11, 5],
+ [39, 7, 0.2, 18, 1.2], [51, 16, 0.13, 14, 7], [62, 11, 0.17, 12, 3.4],
+ [73, 22, 0.1, 17, 6.1], [84, 8, 0.19, 15, 0.8], [93, 13, 0.14, 13, 4.2],
+ [11, 18, 0.11, 19, 8.5], [45, 10, 0.16, 15, 9.3], [68, 6, 0.21, 20, 2],
+];
+
+export function Bubbles({ count = BUBBLES.length }: Props) {
+ // The wrapper is load-bearing, not tidiness. Each bubble starts at `bottom: -40px`,
+ // and an absolutely positioned child still counts toward its container's scrollHeight
+ // - so without this, twelve decorative bubbles made `.stage` 40px taller than the
+ // viewport. `.stage` hides that overflow, but a hidden overflow is still scrollable:
+ // focusing the result sheet's button scrolled the whole app up and pushed the header
+ // off the top of the screen. Clipping them here keeps the stage exactly one screen.
+ return (
+
+ {BUBBLES.slice(0, count).map(([left, size, alpha, seconds, delay], i) => (
+
+ ))}
+
+ );
+}
diff --git a/tippen/src/components/HandHint.tsx b/tippen/src/components/HandHint.tsx
new file mode 100644
index 0000000..ad98f92
--- /dev/null
+++ b/tippen/src/components/HandHint.tsx
@@ -0,0 +1,54 @@
+/** Which finger types the key that is wanted right now, said in words.
+ *
+ * The keyboard shows *where*; this shows *which finger*, because at six the failure mode
+ * is not finding the key, it is finding it with the wrong finger and building the habit
+ * that the whole exercise exists to prevent.
+ *
+ * When the target is a capital it also names the Shift hand - the opposite one, which is
+ * the single rule that separates touch typing from a pinky cramp. */
+
+import { fingerOf, needsShift, shiftHandFor } from "../lib/fingers";
+
+interface Props {
+ nextKey: string | null;
+}
+
+export function HandHint({ nextKey }: Props) {
+ if (!nextKey) return null;
+
+ const finger = fingerOf(nextKey);
+ if (!finger) return null;
+
+ const shiftHand = needsShift(nextKey) ? shiftHandFor(nextKey) : null;
+
+ return (
+
+
+ {finger.label}
+ {shiftHand && (
+ + Umschalttaste {shiftHand}
+ )}
+
+ );
+}
diff --git a/tippen/src/components/HelpOverlay.tsx b/tippen/src/components/HelpOverlay.tsx
new file mode 100644
index 0000000..8f75d01
--- /dev/null
+++ b/tippen/src/components/HelpOverlay.tsx
@@ -0,0 +1,36 @@
+/** The key sheet, on F1. Mirrors what App.tsx actually binds - if a binding changes
+ * there, it changes here. */
+
+interface Props {
+ onClose: () => void;
+}
+
+const TASTEN: readonly [string, string][] = [
+ ["⏎", "Lektion starten · Runde wiederholen"],
+ ["Esc", "Eine Ebene zurück"],
+ ["← →", "Lektion auswählen"],
+ ["H", "Tastatur-Hilfe ein- und ausblenden"],
+ ["M", "Ton an und aus"],
+ ["S", "Sprache an und aus"],
+ ["F1", "Diese Übersicht"],
+];
+
+export function HelpOverlay({ onClose }: Props) {
+ return (
+
+
+
+ ⌨️ Zaubertasten
+
+
+ {TASTEN.map(([taste, was]) => (
+
+ {taste}
+ {was}
+
+ ))}
+
+
+
+ );
+}
diff --git a/tippen/src/components/Keyboard.tsx b/tippen/src/components/Keyboard.tsx
new file mode 100644
index 0000000..df026e5
--- /dev/null
+++ b/tippen/src/components/Keyboard.tsx
@@ -0,0 +1,93 @@
+/** The on-screen QWERTZ keyboard: the scaffold that teaches itself away.
+ *
+ * Every key is tinted with the hue of the finger that types it (lib/fingers.ts), the
+ * next key is highlighted and pulsing, and keys this lesson does not use yet fade back
+ * without disappearing - a map with holes in it is harder to read than a complete one.
+ *
+ * The important behaviour is the fade. In "auto" mode each key's opacity is driven by
+ * how well she knows it (`mastery`), so the hint quietly withdraws exactly where it is
+ * no longer needed and stays put where it is. That is the difference between a crutch
+ * and a scaffold: looking at the screen has to stop being worth it before looking at the
+ * hands does. `H` forces it back on. */
+
+import { KEYBOARD_ROWS, fingerOf, keyForChar, keysInRow, needsShift, shiftHandFor } from "../lib/fingers";
+import type { Progress } from "../lib/progress";
+import { mastery } from "../lib/progress";
+
+interface Props {
+ /** Keys this lesson uses - everything else is dimmed. */
+ activeKeys: readonly string[];
+ /** The key to highlight, or null when nothing is expected. */
+ nextKey: string | null;
+ progress: Progress;
+ mode: "auto" | "on" | "off";
+ /** Key size in px; the whole keyboard scales off it. */
+ size?: number;
+}
+
+/** Rows are offset the way a real keyboard staggers them. */
+const ROW_INDENT: Record = { oben: 0, grund: 14, unten: 30 };
+
+export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Props) {
+ if (mode === "off") return null;
+
+ const active = new Set(activeKeys.map((key) => keyForChar(key)));
+ // "?" is Shift+ß - the key to light up is ß, not a key called "?".
+ const next = nextKey === null ? null : keyForChar(nextKey);
+ const shift = nextKey !== null && needsShift(nextKey);
+ const spaceActive = active.has(" ");
+ const shiftHand = shift && nextKey !== null ? shiftHandFor(nextKey) : null;
+
+ return (
+
+ {KEYBOARD_ROWS.map((row) => (
+
+ {keysInRow(row).map((key) => {
+ const finger = fingerOf(key);
+ const istAktiv = active.has(key);
+ // A key she has mastered fades out; one she is still learning stays bright.
+ // Only ever applied to active keys - dimming an unlearned key would hide the
+ // very thing she needs to find.
+ const gelernt = mode === "auto" && istAktiv ? mastery(progress, key) : 0;
+ return (
+
+ {key.toUpperCase()}
+
+ );
+ })}
+
+ ))}
+
+
+ {/* Both Shifts are drawn, and the one to use lights up - the opposite hand from
+ the letter, which is the rule Welt 4 exists to teach. */}
+
+ ⇧
+
+
+
+ ⇧
+
+
+
+ );
+}
diff --git a/tippen/src/components/LessonMap.tsx b/tippen/src/components/LessonMap.tsx
new file mode 100644
index 0000000..6e68ceb
--- /dev/null
+++ b/tippen/src/components/LessonMap.tsx
@@ -0,0 +1,105 @@
+/** The Karte: four Welten, each a row of lesson cards.
+ *
+ * Locked lessons are dimmed rather than hidden - seeing that there is a Welt called
+ * "Große Buchstaben" waiting is half the reason to finish the one that is open. Each
+ * card carries its own best animal and star count, so the map doubles as the trophy
+ * cabinet. */
+
+import { LESSONS, WELTEN } from "../lib/curriculum";
+import type { Lesson } from "../lib/curriculum";
+import { tierById } from "../lib/grading";
+import type { Progress } from "../lib/progress";
+
+interface Props {
+ progress: Progress;
+ /** Which card the keyboard selection is on. */
+ selected: number;
+ onPick: (lesson: Lesson) => void;
+}
+
+export function LessonMap({ progress, selected, onPick }: Props) {
+ return (
+
+
+ {WELTEN.map((welt) => (
+
+
+ {welt.emoji}
+
+ Welt {welt.nummer} — {welt.titel}
+
+
+ {LESSONS.filter((l) => l.welt === welt.nummer && (progress.lessons[l.id]?.bestSterne ?? 0) >= 2).length}
+ /{LESSONS.filter((l) => l.welt === welt.nummer).length}
+
+
+
+
+ {LESSONS.filter((lesson) => lesson.welt === welt.nummer).map((lesson) => {
+ const fortschritt = progress.lessons[lesson.id];
+ const locked = !fortschritt?.unlocked;
+ const tier = fortschritt?.bestTier ? tierById(fortschritt.bestTier) : null;
+ const index = LESSONS.indexOf(lesson);
+
+ return (
+
onPick(lesson)}
+ style={{ width: 150, padding: 12, flex: "none" }}
+ >
+
+
+ {lesson.nummer}
+
+ {locked ? "🔒" : (tier?.emoji ?? "·")}
+
+
+
+ {lesson.titel}
+
+
+ {/* The keys themselves, big: for a pre-reader this is the real label
+ and the title is decoration. An Übung has no new keys, so it says
+ so with a symbol instead of showing an empty line. */}
+
+ {lesson.istUebung
+ ? "🔁 Übung"
+ : lesson.neueKeys
+ .map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase()))
+ .join(" ")}
+
+
+
+ {[1, 2, 3].map((stern) => (
+ = stern ? 1 : 0.22 }}>
+ ⭐
+
+ ))}
+
+
+ );
+ })}
+
+
+ ))}
+
+
+ );
+}
diff --git a/tippen/src/components/ModePicker.tsx b/tippen/src/components/ModePicker.tsx
new file mode 100644
index 0000000..98ed17e
--- /dev/null
+++ b/tippen/src/components/ModePicker.tsx
@@ -0,0 +1,40 @@
+/** Which game a lesson is played as. The Tauchgang is the measured one; the rest are
+ * the same drill wearing a costume, which is how variety gets discovered without
+ * splitting the curriculum. */
+
+import type { ModeId } from "../lib/curriculum";
+
+interface Props {
+ modi: readonly ModeId[];
+ aktiv: ModeId;
+ onPick: (modus: ModeId) => void;
+}
+
+export const MODUS_INFO: Record = {
+ tauchgang: { emoji: "🤿", name: "Tauchgang" },
+ blasen: { emoji: "🫧", name: "Blasenplatzen" },
+ quallen: { emoji: "🦑", name: "Quallenalarm" },
+ fuettern: { emoji: "🐟", name: "Fütterungszeit" },
+ rennen: { emoji: "🐬", name: "Delfinrennen" },
+ perlen: { emoji: "🦪", name: "Perlentaucher" },
+};
+
+export function ModePicker({ modi, aktiv, onPick }: Props) {
+ return (
+
+ {modi.map((modus) => {
+ const info = MODUS_INFO[modus];
+ return (
+ onPick(modus)}
+ >
+ {info.emoji} {info.name}
+
+ );
+ })}
+
+ );
+}
diff --git a/tippen/src/components/ResultSheet.tsx b/tippen/src/components/ResultSheet.tsx
new file mode 100644
index 0000000..c0f8ffc
--- /dev/null
+++ b/tippen/src/components/ResultSheet.tsx
@@ -0,0 +1,248 @@
+/** 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 Perlen
+ * always go up - see `perlenFor` in lib/grading.ts. */
+
+import { useEffect, useRef } from "react";
+
+import { kreaturById } from "../lib/aquarium";
+import type { KreaturId } 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";
+
+interface Props {
+ result: RunResult;
+ /** Set when this run opened the next lesson. */
+ unlockedTitel: string | null;
+ /** Set when this run released a creature into the aquarium. */
+ neuesTier: KreaturId | null;
+ bestseit: 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;
+ /** Null at the end of the curriculum. */
+ weiterLabel: string | null;
+}
+
+export function ResultSheet({
+ result,
+ unlockedTitel,
+ neuesTier,
+ bestseit,
+ bestEver,
+ onNochmal,
+ onWeiter,
+ weiterLabel,
+}: Props) {
+ const tier = tierById(result.tier);
+ const leiter = sichtbareTiers(result.tier, bestEver);
+ const erreicht = tierIndex(result.tier);
+ const nochmal = useRef(null);
+
+ // Enter repeats the run. Fewest keystrokes between "that was fun" and "again".
+ useEffect(() => nochmal.current?.focus(), []);
+
+ return (
+
+
+
+ {tier.emoji}
+
+
+ {tier.name}
+
+
+
+ {[1, 2, 3].map((stern) => (
+ = stern ? "none" : "grayscale(1)",
+ opacity: result.sterne >= stern ? 1 : 0.25,
+ }}
+ >
+ ⭐
+
+ ))}
+
+
+
+
+
+
+
+
+ {/* How close the next animal is. A near miss is the strongest reason to press
+ Nochmal, so it is worth showing explicitly. */}
+
+
+
+
+ {bestseit && (
+
+ 🏅 Neuer Bestwert!
+
+ )}
+ {unlockedTitel && (
+
+ 🔓 Neu: {unlockedTitel}
+
+ )}
+ {neuesTier && (
+
+
+ {kreaturById(neuesTier).artikel} {kreaturById(neuesTier).name} ist ins Aquarium gezogen!
+
+ )}
+ {!result.bestanden && !unlockedTitel && (
+
+ Mit {Math.round(STERNE_SCHWELLEN.zwei * 100)}% Treffern geht es weiter. Fast!
+
+ )}
+
+
+
+ Nochmal ⏎
+
+ {weiterLabel && (
+
+ {weiterLabel}
+
+ )}
+
+
+
+ );
+}
+
+/** 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 Delfin 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,
+}: {
+ tiers: readonly { id: string; name: string; emoji: string }[];
+ erreicht: number;
+ mehrVerborgen: boolean;
+}) {
+ return (
+
+ {tiers.map((eintrag, i) => {
+ const geschafft = i <= erreicht;
+ const aktuell = i === erreicht;
+ return (
+
+ {eintrag.emoji}
+
+ );
+ })}
+ {mehrVerborgen && (
+
+ ❓
+
+ )}
+
+ );
+}
+
+const knopf: React.CSSProperties = {
+ border: "none",
+ borderRadius: 999,
+ padding: "13px 26px",
+ fontSize: 17,
+ fontWeight: 900,
+ cursor: "pointer",
+};
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
diff --git a/tippen/src/components/Stage.tsx b/tippen/src/components/Stage.tsx
new file mode 100644
index 0000000..1080958
--- /dev/null
+++ b/tippen/src/components/Stage.tsx
@@ -0,0 +1,30 @@
+/** The gradient stage every screen sits on - the app's one full-height container.
+ * `data-blur` is read by app.css to drop the backdrop filters wholesale.
+ *
+ * The pets swim here rather than on the home screen, so they stay with her on the lesson
+ * map and during a run too. */
+
+import type { ReactNode } from "react";
+
+import type { KreaturId } from "../lib/aquarium";
+import { SHOW_AQUARIUM_TIERE, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../lib/theme";
+import { AquariumTiere } from "./AquariumTiere";
+import { Bubbles } from "./Bubbles";
+
+interface Props {
+ children: ReactNode;
+ /** The pets that have moved in so far. */
+ tiere: readonly KreaturId[];
+ /** While she is typing the pets fade back: still there, not competing with the line. */
+ gedaempft: boolean;
+}
+
+export function Stage({ children, tiere, gedaempft }: Props) {
+ return (
+
+ {SHOW_AQUARIUM_TIERE &&
}
+ {SHOW_BUBBLES &&
}
+ {children}
+
+ );
+}
diff --git a/tippen/src/components/Target.tsx b/tippen/src/components/Target.tsx
new file mode 100644
index 0000000..9758b2e
--- /dev/null
+++ b/tippen/src/components/Target.tsx
@@ -0,0 +1,94 @@
+/** The line being typed, drawn chunk by chunk.
+ *
+ * Chunks matter more than they look: twenty-four letters in an unbroken row is
+ * something a six-year-old loses her place in, four letters with a gap after them is
+ * not. In Welt 1 and 2 the gaps are purely visual - the Leertaste has not been taught,
+ * so `spaceActive` is false and the chunks join without one. */
+
+import { chunkOffsets } from "../lib/generator";
+
+interface Props {
+ chunks: readonly string[];
+ spaceActive: boolean;
+ /** Cursor position in the joined text. */
+ index: number;
+ /** True while a wrong key is still being corrected at the cursor. */
+ daneben: boolean;
+ /** Overrides the size picked from the text's own length. */
+ fontSize?: number;
+}
+
+/** Type size and column width for a block of `laenge` characters.
+ *
+ * A round is at least five lines of text, and the whole block has to sit on one screen
+ * above the keyboard without the stage ever scrolling - a line that scrolls out of view
+ * mid-run is worse than no line at all.
+ *
+ * The two move together on purpose. Short blocks (Welt 1) get the biggest letters *and*
+ * the narrowest column, so they still wrap into five lines instead of three long ones -
+ * and a narrow column is easier for a beginner to keep her place in anyway. Long blocks
+ * (Welt 5's sentences) get smaller type and a wider column so ten sentences still fit. */
+function satzspiegel(laenge: number): { groesse: number; breite: number } {
+ if (laenge <= 40) return { groesse: 44, breite: 560 };
+ if (laenge <= 80) return { groesse: 38, breite: 600 };
+ if (laenge <= 130) return { groesse: 32, breite: 760 };
+ if (laenge <= 220) return { groesse: 26, breite: 880 };
+ return { groesse: 22, breite: 960 };
+}
+
+export function Target({ chunks, spaceActive, index, daneben, fontSize }: Props) {
+ const offsets = chunkOffsets(chunks, spaceActive);
+ const laenge = chunks.join("").length + (spaceActive ? chunks.length - 1 : 0);
+ const spiegel = satzspiegel(laenge);
+ const groesse = fontSize ?? spiegel.groesse;
+
+ return (
+
+ {chunks.map((chunk, chunkIndex) => {
+ const start = offsets[chunkIndex] ?? 0;
+ return (
+
+ {[...chunk].map((char, i) => {
+ const at = start + i;
+ const state = at < index ? "fertig" : at === index ? "aktuell" : "offen";
+ // Welt 5's targets are whole sentences, so a chunk can contain spaces of
+ // its own. They need the same visible body as the ones between chunks, or
+ // the cursor lands on nothing and looks lost.
+ const leer = char === " ";
+ return (
+
+ {leer ? (state === "aktuell" ? "␣" : "") : char}
+
+ );
+ })}
+ {/* The space between chunks is a real character once it is taught, so it
+ needs to be typeable and to show the cursor. Before that it is a gap. */}
+ {spaceActive && chunkIndex < chunks.length - 1 && (
+
+ {start + chunk.length === index ? "␣" : ""}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/tippen/src/components/modes/BlasenRun.tsx b/tippen/src/components/modes/BlasenRun.tsx
new file mode 100644
index 0000000..8026779
--- /dev/null
+++ b/tippen/src/components/modes/BlasenRun.tsx
@@ -0,0 +1,147 @@
+/** Blasenplatzen - 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 { useEffect, 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 { say, spellKey } from "../../lib/speech";
+import { useRun } from "../../hooks/useRun";
+import { HandHint } from "../HandHint";
+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 SICHTBAR = 5;
+
+/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
+const LANES = [50, 28, 68, 38, 60, 46];
+
+export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
+ const text = letters.join("");
+ const [geplatzt, setGeplatzt] = useState(null);
+
+ const onEvent = (event: RunEvent) => {
+ // Remember which bubble just popped so it can play its burst before disappearing.
+ if (event.type === "correct") setGeplatzt(event.index);
+ };
+
+ const { state, daneben } = useRun({
+ target: text,
+ sound: progress.settings.sound,
+ paused,
+ onFinished,
+ onEvent,
+ });
+
+ const next = currentChar(state);
+
+ useEffect(() => {
+ if (next) say(spellKey(next), progress.settings.speech);
+ // Every bubble is spoken here, unlike the Tauchgang: one letter at a time is slow
+ // enough that the voice keeps up, and this is the mode a pre-reader plays first.
+ }, [next, progress.settings.speech]);
+
+ return (
+
+
+ {/* The surface line the bubbles rise toward. */}
+
+ {[...text].map((letter, i) => {
+ const abstand = i - state.index;
+ if (abstand < 0 || abstand >= SICHTBAR) return null;
+ const finger = fingerOf(letter);
+ const aktuell = abstand === 0;
+ const groesse = aktuell ? 104 : 68;
+
+ return (
+
+ {letter === " " ? "␣" : letter}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/tippen/src/components/modes/FuetternRun.tsx b/tippen/src/components/modes/FuetternRun.tsx
new file mode 100644
index 0000000..105c8de
--- /dev/null
+++ b/tippen/src/components/modes/FuetternRun.tsx
@@ -0,0 +1,185 @@
+/** Fütterungszeit - words instead of letters.
+ *
+ * Fish swim past carrying short German words; typing one feeds it to the Delfin. This
+ * is the first mode where the unit is a whole word rather than a key, which is the step
+ * from "I can find the letters" to "I can write something", and it is the reason the
+ * word lists in curriculum.ts are real German words a six-year-old knows rather than
+ * pronounceable nonsense.
+ *
+ * The clock is soft, like Blasenplatzen: the fish queue up and wait. Nothing swims off
+ * unfed. */
+
+import { useEffect, useState } from "react";
+
+import { currentChar } from "../../lib/engine";
+import type { RunEvent } from "../../lib/engine";
+import { chunkOffsets } from "../../lib/generator";
+import type { RunResult } from "../../lib/grading";
+import type { Progress } from "../../lib/progress";
+import { say } from "../../lib/speech";
+import { useRun } from "../../hooks/useRun";
+import { HandHint } from "../HandHint";
+import { Keyboard } from "../Keyboard";
+
+interface Props {
+ chunks: readonly string[];
+ text: string;
+ spaceActive: boolean;
+ activeKeys: readonly string[];
+ progress: Progress;
+ paused: boolean;
+ onFinished: (result: RunResult) => void;
+}
+
+const FISCHE = ["🐟", "🐠", "🐡", "🦐", "🦀"];
+
+export function FuetternRun({
+ chunks,
+ text,
+ spaceActive,
+ activeKeys,
+ progress,
+ paused,
+ onFinished,
+}: Props) {
+ const [gefuettert, setGefuettert] = useState(0);
+
+ const { state, daneben } = useRun({
+ target: text,
+ sound: progress.settings.sound,
+ paused,
+ onFinished,
+ onEvent: (event: RunEvent) => {
+ if (event.type === "correct") setGefuettert((n) => n);
+ },
+ });
+
+ const next = currentChar(state);
+ const offsets = chunkOffsets(chunks, spaceActive);
+
+ /** Which word is being typed. A word counts as current until its last letter is done. */
+ const aktuell = Math.max(
+ 0,
+ offsets.findIndex((start, i) => state.index <= start + (chunks[i]?.length ?? 0)),
+ );
+
+ useEffect(() => {
+ setGefuettert(aktuell);
+ }, [aktuell]);
+
+ useEffect(() => {
+ const wort = chunks[aktuell];
+ if (wort) say(wort, progress.settings.speech);
+ }, [aktuell, chunks, progress.settings.speech]);
+
+ return (
+
+
+ {/* The Delfin waits on the left; the fish come to it. */}
+
+ 🐬
+
+
+ {chunks.map((wort, i) => {
+ const abstand = i - aktuell;
+ // Three fish in the water. A fourth at this spacing runs off the right edge,
+ // and a queue you cannot see the end of is not a queue.
+ if (abstand < 0 || abstand > 2) return null;
+ const start = offsets[i] ?? 0;
+ const istZiel = abstand === 0;
+
+ return (
+
+ {FISCHE[i % FISCHE.length]}
+
+ {istZiel
+ ? [...wort].map((char, j) => {
+ const at = start + j;
+ const zustand = at < state.index ? "fertig" : at === state.index ? "aktuell" : "offen";
+ return (
+
+ {char === " " ? " " : char}
+
+ );
+ })
+ : wort}
+
+
+ );
+ })}
+
+
+
+ {gefuettert} von {chunks.length} gefüttert
+
+
+
+
+ );
+}
diff --git a/tippen/src/components/modes/PerlenRun.tsx b/tippen/src/components/modes/PerlenRun.tsx
new file mode 100644
index 0000000..7b40006
--- /dev/null
+++ b/tippen/src/components/modes/PerlenRun.tsx
@@ -0,0 +1,163 @@
+/** Perlentaucher - the mode with no clock.
+ *
+ * Every other mode measures something. This one deliberately does not show a timer, a
+ * progress bar, a streak or a speed: an oyster opens, a word is inside it, and each
+ * correct letter is a pearl on the string. A mistake costs a pearl, and that is the
+ * only pressure there is.
+ *
+ * It exists because a six-year-old having a bad afternoon needs somewhere to go that is
+ * still the same lesson and still counts, but cannot be failed at quickly. Every lesson
+ * offers it, always. The run is still graded the same way underneath - a calm round and
+ * a frantic one land in the same `grade()` - but nothing on screen is urging her on. */
+
+import { useEffect, useState } from "react";
+
+import { currentChar } from "../../lib/engine";
+import type { RunEvent } from "../../lib/engine";
+import { chunkOffsets } from "../../lib/generator";
+import type { RunResult } from "../../lib/grading";
+import type { Progress } from "../../lib/progress";
+import { say } from "../../lib/speech";
+import { useRun } from "../../hooks/useRun";
+import { HandHint } from "../HandHint";
+import { Keyboard } from "../Keyboard";
+
+interface Props {
+ chunks: readonly string[];
+ text: string;
+ spaceActive: boolean;
+ activeKeys: readonly string[];
+ progress: Progress;
+ paused: boolean;
+ onFinished: (result: RunResult) => void;
+}
+
+export function PerlenRun({
+ chunks,
+ text,
+ spaceActive,
+ activeKeys,
+ progress,
+ paused,
+ onFinished,
+}: Props) {
+ const [perlen, setPerlen] = useState(0);
+
+ const onEvent = (event: RunEvent) => {
+ // A pearl per correct letter, one lost per mistake - but never below zero. Watching
+ // the string shrink past empty is the kind of punishment this mode exists to avoid.
+ if (event.type === "correct") setPerlen((n) => n + 1);
+ else if (event.type === "wrong" && event.firstAt) setPerlen((n) => Math.max(0, n - 1));
+ };
+
+ const { state, daneben } = useRun({
+ target: text,
+ sound: progress.settings.sound,
+ paused,
+ onFinished,
+ onEvent,
+ });
+
+ const next = currentChar(state);
+ const offsets = chunkOffsets(chunks, spaceActive);
+
+ // Which word the oyster is holding right now.
+ const aktuellerChunk = offsets.findIndex((start, i) => {
+ const ende = start + (chunks[i]?.length ?? 0);
+ return state.index <= ende;
+ });
+ const wort = chunks[aktuellerChunk === -1 ? chunks.length - 1 : aktuellerChunk] ?? "";
+ const wortStart = offsets[aktuellerChunk === -1 ? chunks.length - 1 : aktuellerChunk] ?? 0;
+
+ useEffect(() => {
+ if (wort) say(wort, progress.settings.speech);
+ }, [wort, progress.settings.speech]);
+
+ return (
+
+ {/* The pearl string. It only ever grows, one bead per letter. */}
+
+ {Array.from({ length: perlen }, (_, i) => (
+
+ ))}
+
+
+ {/* The oyster, holding one word at a time. */}
+
+
🦪
+
+ {[...wort].map((char, i) => {
+ const at = wortStart + i;
+ const state_ = at < state.index ? "fertig" : at === state.index ? "aktuell" : "offen";
+ return (
+
+ {char === " " ? (state_ === "aktuell" ? "␣" : "") : char}
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
diff --git a/tippen/src/components/modes/QuallenRun.tsx b/tippen/src/components/modes/QuallenRun.tsx
new file mode 100644
index 0000000..0537831
--- /dev/null
+++ b/tippen/src/components/modes/QuallenRun.tsx
@@ -0,0 +1,151 @@
+/** Quallenalarm - pure key location, nothing else.
+ *
+ * Six jellyfish drift in the water, each showing a letter. One of them glows: that is
+ * the one to zap. There is no line to read and no word to spell, so the only thing
+ * being exercised is "where does this letter live" - which is exactly the skill the
+ * Tauchgang hides behind reading.
+ *
+ * The decoys matter. Showing only the target turns this into the bubble mode; showing
+ * five wrong letters next to it means she has to find *her* letter before she can type
+ * it, which is the searching step that eventually goes away. */
+
+import { useEffect, useMemo } from "react";
+
+import { currentChar } from "../../lib/engine";
+import { fingerOf } from "../../lib/fingers";
+import { mulberry32 } from "../../lib/generator";
+import type { RunResult } from "../../lib/grading";
+import type { Progress } from "../../lib/progress";
+import { say, spellKey } from "../../lib/speech";
+import { useRun } from "../../hooks/useRun";
+import { HandHint } from "../HandHint";
+import { Keyboard } from "../Keyboard";
+
+interface Props {
+ letters: readonly string[];
+ activeKeys: readonly string[];
+ progress: Progress;
+ paused: boolean;
+ onFinished: (result: RunResult) => void;
+}
+
+/** How many jellyfish are in the water at once, target included. */
+const QUALLEN = 6;
+
+interface Qualle {
+ left: number;
+ top: number;
+ drift: number;
+ groesse: number;
+}
+
+export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
+ const text = letters.join("");
+ const { state, daneben } = useRun({
+ target: text,
+ sound: progress.settings.sound,
+ paused,
+ onFinished,
+ });
+
+ const next = currentChar(state);
+
+ // Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
+ // would make the searching step impossible rather than merely hard.
+ const plaetze = useMemo(() => {
+ const rng = mulberry32(text.length * 31 + 7);
+ return Array.from({ length: QUALLEN }, () => ({
+ left: 10 + rng() * 76,
+ top: 6 + rng() * 66,
+ drift: 3 + rng() * 3,
+ groesse: 74 + rng() * 26,
+ }));
+ }, [text]);
+
+ /** The decoys shown alongside the target: other active keys, never the target itself,
+ * and stable for as long as the target is. */
+ const koeder = useMemo(() => {
+ if (!next) return [];
+ const rng = mulberry32(state.index * 101 + 13);
+ const andere = activeKeys.filter((key) => key !== next && key !== " ");
+ const gemischt = [...andere].sort(() => rng() - 0.5);
+ return gemischt.slice(0, QUALLEN - 1);
+ }, [next, state.index, activeKeys]);
+
+ useEffect(() => {
+ if (next) say(spellKey(next), progress.settings.speech);
+ }, [next, progress.settings.speech]);
+
+ // Which jellyfish carries the target. Moves around so it is not always the same one.
+ const zielPlatz = next ? state.index % QUALLEN : -1;
+
+ return (
+
+
+ {plaetze.map((platz, i) => {
+ const istZiel = i === zielPlatz;
+ const buchstabe = istZiel ? next : koeder[i > zielPlatz ? i - 1 : i];
+ if (!buchstabe) return null;
+ const finger = fingerOf(buchstabe);
+
+ return (
+
+ {buchstabe === " " ? "␣" : buchstabe}
+ {istZiel && daneben && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/tippen/src/components/modes/RennenRun.tsx b/tippen/src/components/modes/RennenRun.tsx
new file mode 100644
index 0000000..e08659c
--- /dev/null
+++ b/tippen/src/components/modes/RennenRun.tsx
@@ -0,0 +1,181 @@
+/** Delfinrennen - a race against her own best run.
+ *
+ * The opponent is the ghost of the fastest run she has ever had on this lesson: the
+ * keystroke timings are stored in `progress.lessons[id].ghost` and replayed against the
+ * clock. Racing yourself is the one competitive format that cannot be demoralising - the
+ * opponent is by definition exactly as good as she was, so the race is always close, and
+ * winning means she actually improved.
+ *
+ * Before a ghost exists there is a Krabbe swimming at a gentle fixed pace. It is beatable
+ * on the first try on purpose.
+ *
+ * The ghost is moved by writing to the DOM node directly from a requestAnimationFrame
+ * loop, the way ../../web/src/components/Ambience.tsx does it - React never re-renders
+ * for it, so the animation cannot compete with the keystroke handling. The player's own
+ * dolphin needs no loop: it only moves when a key is pressed, which is a render anyway. */
+
+import { useEffect, useRef } from "react";
+
+import { currentChar } from "../../lib/engine";
+import type { RunResult } from "../../lib/grading";
+import type { Progress } from "../../lib/progress";
+import { useRun } from "../../hooks/useRun";
+import { HandHint } from "../HandHint";
+import { Keyboard } from "../Keyboard";
+import { Target } from "../Target";
+
+interface Props {
+ chunks: readonly string[];
+ text: string;
+ spaceActive: boolean;
+ activeKeys: readonly string[];
+ progress: Progress;
+ /** The best run's correct keystrokes, or null for a first attempt. */
+ ghost: readonly { key: string; at: number }[] | null;
+ paused: boolean;
+ onFinished: (result: RunResult) => void;
+}
+
+/** The pace of the stand-in opponent, in Zeichen pro Minute. Slow enough that a careful
+ * first-timer beats it - roughly a Schildkröte. */
+const KRABBEN_TEMPO = 30;
+
+export function RennenRun({
+ chunks,
+ text,
+ spaceActive,
+ activeKeys,
+ progress,
+ ghost,
+ paused,
+ onFinished,
+}: Props) {
+ const { state, daneben } = useRun({
+ target: text,
+ sound: progress.settings.sound,
+ paused,
+ onFinished,
+ });
+
+ const next = currentChar(state);
+ const bahn = useRef(null);
+ const gegner = useRef(null);
+
+ // Everything the loop reads lives in a ref, so it is started once and never restarted.
+ const latest = useRef({ ghost, startedAt: state.startedAt, laenge: text.length, fertig: state.finishedAt !== null });
+ latest.current = { ghost, startedAt: state.startedAt, laenge: text.length, fertig: state.finishedAt !== null };
+
+ useEffect(() => {
+ let frame = 0;
+
+ const tick = () => {
+ frame = requestAnimationFrame(tick);
+ const current = latest.current;
+ const node = gegner.current;
+ if (!node) return;
+
+ // The race starts on her first keystroke, not when the screen opens - the same
+ // rule the grading uses, so a pause before starting costs nothing here either.
+ if (current.startedAt === null) {
+ node.style.left = "0%";
+ return;
+ }
+
+ const vergangen = performance.now() - current.startedAt;
+ let anteil: number;
+
+ if (current.ghost && current.ghost.length > 1) {
+ const start = current.ghost[0]!.at;
+ // How many of the ghost's keystrokes have come due by now.
+ let getippt = 0;
+ while (getippt < current.ghost.length && current.ghost[getippt]!.at - start <= vergangen) getippt++;
+ anteil = getippt / current.laenge;
+ } else {
+ anteil = vergangen / 60000 / (1 / KRABBEN_TEMPO) / current.laenge;
+ }
+
+ node.style.left = `${Math.min(1, Math.max(0, anteil)) * 100}%`;
+ };
+
+ frame = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(frame);
+ }, []);
+
+ const meinAnteil = text.length === 0 ? 0 : state.index / text.length;
+
+ return (
+
+
+
+
+ 🐬
+
+
+
+
+
+ {ghost ? "👻" : "🦀"}
+
+
+
+
+
+
+
+
+ );
+}
+
+/** One lane, with the finish line at the right. */
+function Bahn({ label, farbe, children }: { label: string; farbe: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/tippen/src/components/modes/TauchgangRun.tsx b/tippen/src/components/modes/TauchgangRun.tsx
new file mode 100644
index 0000000..7b17658
--- /dev/null
+++ b/tippen/src/components/modes/TauchgangRun.tsx
@@ -0,0 +1,106 @@
+/** Tauchgang - the core drill, and the run that counts for the unlock.
+ *
+ * A line of chunks with a moving cursor, the finger hint above it and the keyboard
+ * below. Everything else in the game is a variation on this; this is the one that is
+ * measured. */
+
+import { useEffect } from "react";
+
+import { currentChar } from "../../lib/engine";
+import type { RunResult } from "../../lib/grading";
+import type { Progress } from "../../lib/progress";
+import { say, spellKey } from "../../lib/speech";
+import { useRun } from "../../hooks/useRun";
+import { HandHint } from "../HandHint";
+import { Keyboard } from "../Keyboard";
+import { Target } from "../Target";
+
+interface Props {
+ chunks: readonly string[];
+ text: string;
+ spaceActive: boolean;
+ activeKeys: readonly string[];
+ progress: Progress;
+ paused: boolean;
+ onFinished: (result: RunResult) => void;
+}
+
+export function TauchgangRun({
+ chunks,
+ text,
+ spaceActive,
+ activeKeys,
+ progress,
+ paused,
+ onFinished,
+}: Props) {
+ const { state, daneben } = useRun({
+ target: text,
+ sound: progress.settings.sound,
+ paused,
+ onFinished,
+ });
+
+ const next = currentChar(state);
+
+ // Say the first key of a line, and any key she gets stuck on. Not every key: a voice
+ // talking over every keystroke is noise, and it would lag behind a good streak.
+ useEffect(() => {
+ if (state.index === 0 && next) say(spellKey(next), progress.settings.speech);
+ }, [state.index, next, progress.settings.speech]);
+
+ useEffect(() => {
+ if (daneben && next) say(spellKey(next), progress.settings.speech);
+ }, [daneben, next, progress.settings.speech]);
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** How far through the line she is - a bar, not a number, because "18 von 24" is a
+ * reading task and a filling bar is not. */
+function Fortschritt({ done, total }: { done: number; total: number }) {
+ return (
+
+ );
+}
diff --git a/tippen/src/hooks/useRun.ts b/tippen/src/hooks/useRun.ts
new file mode 100644
index 0000000..5e975f0
--- /dev/null
+++ b/tippen/src/hooks/useRun.ts
@@ -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/engine";
+import type { RunEvent, RunState } from "../lib/engine";
+import type { RunResult } from "../lib/grading";
+import { playDaneben, playFertig, playRichtig } from "../lib/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. */
+ daneben: boolean;
+ restart: () => void;
+}
+
+export function useRun({ target, sound, onFinished, onEvent, paused = false }: Options): RunHandle {
+ const [state, setState] = useState(() => startRun(target));
+ const [daneben, setDaneben] = 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));
+ setDaneben(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") {
+ setDaneben(false);
+ if (current.sound) playRichtig(runEvent.streak);
+ } else if (runEvent.type === "wrong") {
+ setDaneben(true);
+ if (current.sound) playDaneben();
+ } else {
+ if (current.sound) playFertig();
+ current.onFinished(runEvent.result);
+ }
+ current.onEvent?.(runEvent);
+ }
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, []);
+
+ return { state, daneben, restart };
+}
diff --git a/tippen/src/lib/__tests__/aquarium.test.ts b/tippen/src/lib/__tests__/aquarium.test.ts
new file mode 100644
index 0000000..cb9970e
--- /dev/null
+++ b/tippen/src/lib/__tests__/aquarium.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import { KREATUREN, kreaturAus, kreaturById, neuerSchwimmer, pose, schwimme } from "../aquarium";
+import type { Becken, Schwimmer } from "../aquarium";
+import { WELTEN } from "../curriculum";
+import { mulberry32 } from "../generator";
+
+const BECKEN: Becken = { breite: 1280, hoehe: 800 };
+const RAND = 60;
+const TEMPO = 55;
+const DT = 1 / 60;
+
+function swim(s: Schwimmer, sekunden: number, becken = BECKEN, rng = mulberry32(7)): Schwimmer {
+ for (let t = 0; t < sekunden; t += DT) s = schwimme(s, DT, becken, RAND, TEMPO, rng);
+ return s;
+}
+
+describe("Kreaturen", () => {
+ it("gives every pet exactly one Welt", () => {
+ const belohnungen = WELTEN.map((welt) => welt.belohnung);
+ expect([...belohnungen].sort()).toEqual(KREATUREN.map((k) => k.id).sort());
+ });
+
+ it("looks pets up by id", () => {
+ for (const kreatur of KREATUREN) expect(kreaturById(kreatur.id)).toBe(kreatur);
+ });
+
+ it("reads today's ids and the emoji old saves stored, and nothing else", () => {
+ expect(kreaturAus("krake")).toBe("krake");
+ expect(kreaturAus("🐠")).toBe("clownfisch");
+ expect(kreaturAus("🧜")).toBe("perlmuschel");
+ expect(kreaturAus("🦈")).toBeNull();
+ expect(kreaturAus(42)).toBeNull();
+ });
+});
+
+describe("schwimme", () => {
+ it("never lets a pet's centre leave the tank", () => {
+ const rng = mulberry32(3);
+ let s = neuerSchwimmer(BECKEN, RAND, rng);
+ // Ten minutes of swimming, checked every frame.
+ for (let t = 0; t < 600; t += DT) {
+ s = schwimme(s, DT, BECKEN, RAND, TEMPO, rng);
+ expect(s.x).toBeGreaterThanOrEqual(0);
+ expect(s.x).toBeLessThanOrEqual(BECKEN.breite);
+ expect(s.y).toBeGreaterThanOrEqual(0);
+ expect(s.y).toBeLessThanOrEqual(BECKEN.hoehe);
+ }
+ });
+
+ it("keeps moving rather than settling", () => {
+ const rng = mulberry32(11);
+ const start = neuerSchwimmer(BECKEN, RAND, rng);
+ const spaeter = swim(start, 60, BECKEN, rng);
+ expect(Math.hypot(spaeter.x - start.x, spaeter.y - start.y)).toBeGreaterThan(0);
+ expect(spaeter.zielX !== start.zielX || spaeter.zielY !== start.zielY).toBe(true);
+ });
+
+ it("turns to face the way it swims", () => {
+ const base: Schwimmer = { x: 640, y: 400, vx: 0, vy: 0, zielX: 100, zielY: 400, blick: 1, zeit: 0 };
+ const links = swim(base, 3);
+ expect(links.vx).toBeLessThan(0);
+ expect(links.blick).toBeLessThan(-0.9);
+
+ const rechts = swim({ ...base, zielX: 1180, blick: -1 }, 3);
+ expect(rechts.vx).toBeGreaterThan(0);
+ expect(rechts.blick).toBeGreaterThan(0.9);
+ });
+
+ it("brings a newly earned pet in from outside the tank", () => {
+ const rng = mulberry32(5);
+ const neu = neuerSchwimmer(BECKEN, RAND, rng, true);
+ expect(neu.x < 0 || neu.x > BECKEN.breite).toBe(true);
+ const drinnen = swim(neu, 40, BECKEN, rng);
+ expect(drinnen.x).toBeGreaterThan(0);
+ expect(drinnen.x).toBeLessThan(BECKEN.breite);
+ });
+
+ it("finds a new target when the window shrinks under the old one", () => {
+ const s: Schwimmer = { x: 200, y: 200, vx: 0, vy: 0, zielX: 1200, zielY: 700, blick: 1, zeit: 0 };
+ const klein = { breite: 500, hoehe: 400 };
+ const next = schwimme(s, DT, klein, RAND, TEMPO, mulberry32(1));
+ expect(next.zielX).toBeLessThanOrEqual(klein.breite - RAND);
+ expect(next.zielY).toBeLessThanOrEqual(klein.hoehe - RAND);
+ });
+});
+
+describe("pose", () => {
+ const s: Schwimmer = { x: 10, y: 20, vx: -30, vy: 0, zielX: 0, zielY: 0, blick: -1, zeit: 0 };
+
+ it("mirrors side-view pets to look where they swim", () => {
+ expect(pose(s, kreaturById("clownfisch"), TEMPO).spiegel).toBe(-1);
+ });
+
+ it("never mirrors a front-view pet", () => {
+ expect(pose(s, kreaturById("krake"), TEMPO).spiegel).toBe(1);
+ expect(pose(s, kreaturById("perlmuschel"), TEMPO).spiegel).toBe(1);
+ });
+});
diff --git a/tippen/src/lib/__tests__/curriculum.test.ts b/tippen/src/lib/__tests__/curriculum.test.ts
new file mode 100644
index 0000000..7e3812d
--- /dev/null
+++ b/tippen/src/lib/__tests__/curriculum.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, it } from "vitest";
+
+import { FIRST_LESSON_ID, LESSONS, WELTEN, lessonById, lessonsOfWelt, nextLesson } from "../curriculum";
+import { fingerOf, keyForChar, needsShift } from "../fingers";
+
+describe("LESSONS", () => {
+ it("has unique ids and consecutive numbers", () => {
+ const ids = LESSONS.map((lesson) => lesson.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ LESSONS.forEach((lesson, i) => expect(lesson.nummer).toBe(i + 1));
+ });
+
+ it("starts on the two keys with the tactile bumps", () => {
+ expect(LESSONS[0]!.neueKeys).toEqual(["f", "j"]);
+ expect(FIRST_LESSON_ID).toBe(LESSONS[0]!.id);
+ });
+
+ it("eases in: at most two new keys per lesson, anywhere in the course", () => {
+ // The first draft opened with all four left-hand keys at once, which is a steep
+ // first five minutes for a six-year-old. Two at a time, always.
+ for (const lesson of LESSONS) {
+ expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
+ }
+ });
+
+ it("teaches Welt 1 as mirrored finger pairs, one finger per hand", () => {
+ const paare = lessonsOfWelt(1).filter((lesson) => lesson.neueKeys.length === 2);
+ expect(paare.length).toBe(4);
+ for (const lesson of paare) {
+ const [links, rechts] = lesson.neueKeys.map((key) => fingerOf(key)!);
+ expect(links!.hand).toBe("links");
+ expect(rechts!.hand).toBe("rechts");
+ // Same finger on each hand - "die Zeigefinger", "die Mittelfinger", …
+ expect(links!.id.replace("links-", "")).toBe(rechts!.id.replace("rechts-", ""));
+ }
+ });
+
+ it("has the whole Grundstellung active by the end of Welt 1", () => {
+ const letzte = lessonsOfWelt(1).at(-1)!;
+ for (const key of "asdfjklö") expect(letzte.activeKeys).toContain(key);
+ expect(letzte.activeKeys).toContain(" ");
+ });
+
+ it("makes every round a real block of practice, not a handful of keys", () => {
+ // The first version ran 12 characters in Welt 1 - long enough to finish, too short
+ // for a rhythm to form or for the speed score to mean anything.
+ for (const lesson of LESSONS) {
+ if (lesson.art === "saetze") {
+ expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(10);
+ } else if (lesson.art === "woerter") {
+ expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(25);
+ } else {
+ expect(lesson.chunks * lesson.chunkSize, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(60);
+ }
+ }
+ });
+
+ it("still starts gentler than it ends", () => {
+ const laenge = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
+ expect(laenge(lessonsOfWelt(3)[0]!)).toBeGreaterThan(laenge(lessonsOfWelt(1)[0]!));
+ });
+
+ it("has more sentences to draw from than a round uses, so a round never has to repeat", () => {
+ for (const lesson of LESSONS.filter((l) => l.art === "saetze")) {
+ expect(lesson.woerter.length, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(lesson.chunks);
+ }
+ });
+
+ it("grows activeKeys monotonically - no lesson ever loses a key", () => {
+ let previous: string[] = [];
+ for (const lesson of LESSONS) {
+ for (const key of previous) expect(lesson.activeKeys).toContain(key);
+ previous = [...lesson.activeKeys];
+ }
+ });
+
+ it("introduces every new key into its own activeKeys", () => {
+ for (const lesson of LESSONS) {
+ for (const key of lesson.neueKeys) {
+ if (key === "⇧") continue; // Shift is not a character the generator can emit.
+ expect(lesson.activeKeys).toContain(key);
+ }
+ }
+ });
+
+ it("covers the whole German alphabet plus the umlauts by the end", () => {
+ const final = new Set(LESSONS.at(-1)!.activeKeys);
+ for (const key of "abcdefghijklmnopqrstuvwxyzäöü") expect(final.has(key)).toBe(true);
+ });
+
+ it("assigns every active key to a real finger", () => {
+ for (const lesson of LESSONS) {
+ for (const key of lesson.activeKeys) expect(fingerOf(key)).not.toBeNull();
+ }
+ });
+
+ it("only lists words its own activeKeys can type", () => {
+ // Compared by physical key, not by character: "?" is Shift+ß and "A" is Shift+a,
+ // so a lesson can type them as soon as it has the ß or a key.
+ for (const lesson of LESSONS) {
+ const active = new Set(lesson.activeKeys);
+ for (const wort of lesson.woerter) {
+ for (const char of wort) {
+ expect(
+ active.has(keyForChar(char)),
+ `Lektion ${lesson.nummer} (${lesson.titel}): "${wort}" braucht "${char}" (Taste ${keyForChar(char)})`,
+ ).toBe(true);
+ }
+ }
+ }
+ });
+
+ it("only uses shifted characters once the Umschalttaste is taught", () => {
+ const shiftAb = LESSONS.find((lesson) => lesson.neueKeys.includes("⇧"))!.nummer;
+ for (const lesson of LESSONS) {
+ for (const wort of lesson.woerter) {
+ for (const char of wort) {
+ if (!needsShift(char)) continue;
+ expect(
+ lesson.nummer,
+ `Lektion ${lesson.nummer}: "${wort}" braucht Umschalt für "${char}"`,
+ ).toBeGreaterThanOrEqual(shiftAb);
+ }
+ }
+ }
+ });
+
+ it("only offers word modes where words exist", () => {
+ for (const lesson of LESSONS) {
+ const wortModi = lesson.modi.some((modus) => modus === "fuettern" || modus === "rennen");
+ expect(wortModi).toBe(lesson.woerter.length > 0);
+ }
+ });
+
+ it("always offers the pressure-free Perlentaucher", () => {
+ for (const lesson of LESSONS) expect(lesson.modi).toContain("perlen");
+ });
+
+ it("teaches capitals only once the Umschalttaste exists", () => {
+ for (const lesson of LESSONS) {
+ const hatGross = lesson.woerter.some((wort) => wort !== wort.toLowerCase());
+ if (hatGross) expect(lesson.welt).toBeGreaterThanOrEqual(4);
+ }
+ });
+
+ it("gives every new key a lesson of its own after Welt 1", () => {
+ // One key at a time is the pacing decision: Welt 1 pairs a finger across both
+ // hands, everything after introduces exactly one key or none.
+ for (const lesson of LESSONS) {
+ if (lesson.welt === 1) continue;
+ expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
+ }
+ });
+
+ it("follows every pair of new keys with an Übung", () => {
+ const uebungen = LESSONS.filter((lesson) => lesson.istUebung);
+ expect(uebungen.length).toBeGreaterThanOrEqual(12);
+ // An Übung never introduces anything, and always has something to practise.
+ for (const lesson of uebungen) {
+ expect(lesson.neueKeys).toEqual([]);
+ expect(lesson.activeKeys.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("is long enough to be a real course", () => {
+ expect(LESSONS.length).toBeGreaterThanOrEqual(40);
+ expect(WELTEN.length).toBe(5);
+ });
+
+ it("never leaves a Welt without a run of at least three lessons", () => {
+ for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThanOrEqual(3);
+ });
+});
+
+describe("Welten", () => {
+ it("has lessons in every Welt", () => {
+ for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThan(0);
+ });
+
+ it("gives every Welt its own aquarium creature", () => {
+ const belohnungen = WELTEN.map((welt) => welt.belohnung);
+ expect(new Set(belohnungen).size).toBe(belohnungen.length);
+ });
+});
+
+describe("navigation", () => {
+ it("chains every lesson to the next and stops at the end", () => {
+ for (let i = 0; i < LESSONS.length - 1; i++) {
+ expect(nextLesson(LESSONS[i]!.id)?.id).toBe(LESSONS[i + 1]!.id);
+ }
+ expect(nextLesson(LESSONS.at(-1)!.id)).toBeNull();
+ expect(nextLesson("gibt-es-nicht")).toBeNull();
+ });
+
+ it("looks lessons up by id", () => {
+ expect(lessonById(FIRST_LESSON_ID)?.nummer).toBe(1);
+ expect(lessonById("gibt-es-nicht")).toBeNull();
+ });
+});
diff --git a/tippen/src/lib/__tests__/engine.test.ts b/tippen/src/lib/__tests__/engine.test.ts
new file mode 100644
index 0000000..b2169fa
--- /dev/null
+++ b/tippen/src/lib/__tests__/engine.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest";
+
+import { abandonRun, isTypingKey, press, startRun } from "../engine";
+import type { RunState } from "../engine";
+
+/** Type a whole string, one key per 100ms, and hand back the final state. */
+function typeAll(target: string, keys: string, from = 1000): RunState {
+ let state = startRun(target);
+ [...keys].forEach((key, i) => {
+ [state] = press(state, key, from + i * 100);
+ });
+ return state;
+}
+
+describe("press", () => {
+ it("advances on the right key", () => {
+ const [state, events] = press(startRun("asdf"), "a", 0);
+ expect(state.index).toBe(1);
+ expect(events).toEqual([{ type: "correct", key: "a", index: 0, streak: 1 }]);
+ });
+
+ it("does not advance on the wrong key", () => {
+ const [state, events] = press(startRun("asdf"), "x", 0);
+ expect(state.index).toBe(0);
+ expect(events[0]).toMatchObject({ type: "wrong", key: "x", expected: "a", firstAt: true });
+ });
+
+ it("counts a repeated wrong key at one position only once", () => {
+ const state = typeAll("asdf", "xxxxx");
+ expect(state.missed.size).toBe(1);
+ expect(state.strokes).toHaveLength(5);
+ });
+
+ it("counts wrong keys at different positions separately", () => {
+ const state = typeAll("asdf", "xaysz");
+ expect(state.missed.size).toBe(3);
+ });
+
+ it("accepts a capital where a lowercase letter is wanted", () => {
+ const [state] = press(startRun("asdf"), "A", 0);
+ expect(state.index).toBe(1);
+ });
+
+ it("ignores modifiers and named keys", () => {
+ const start = startRun("asdf");
+ for (const key of ["Shift", "Control", "Backspace", "ArrowLeft", "F1", "Enter"]) {
+ const [state, events] = press(start, key, 0);
+ expect(state).toBe(start);
+ expect(events).toEqual([]);
+ }
+ });
+
+ it("starts the clock on the first keystroke, not before", () => {
+ const fresh = startRun("as");
+ expect(fresh.startedAt).toBeNull();
+ const [state] = press(fresh, "a", 5000);
+ expect(state.startedAt).toBe(5000);
+ });
+
+ it("finishes on the last character and reports a result", () => {
+ let state = startRun("as");
+ [state] = press(state, "a", 0);
+ const [done, events] = press(state, "s", 1000);
+ expect(done.finishedAt).toBe(1000);
+ const finished = events.find((event) => event.type === "finished");
+ expect(finished).toBeDefined();
+ expect(finished?.type === "finished" && finished.result.zeichen).toBe(2);
+ });
+
+ it("does nothing once the run is over", () => {
+ const done = typeAll("as", "as");
+ const [state, events] = press(done, "a", 9999);
+ expect(state).toBe(done);
+ expect(events).toEqual([]);
+ });
+
+ it("tracks and resets the streak", () => {
+ expect(typeAll("asdf", "asd").streak).toBe(3);
+ expect(typeAll("asdf", "asx").streak).toBe(0);
+ });
+});
+
+describe("isTypingKey", () => {
+ it("accepts single characters including umlauts and space", () => {
+ for (const key of ["a", "ö", "ü", "ß", " ", "A"]) expect(isTypingKey(key)).toBe(true);
+ });
+
+ it("rejects named keys", () => {
+ for (const key of ["Enter", "Shift", "Tab", "ArrowUp"]) expect(isTypingKey(key)).toBe(false);
+ });
+});
+
+describe("abandonRun", () => {
+ it("grades what was typed so far", () => {
+ const partial = typeAll("asdfjklö", "asdf");
+ const stopped = abandonRun(partial, 2000);
+ expect(stopped.finishedAt).toBe(2000);
+ expect(stopped.index).toBe(4);
+ });
+
+ it("leaves an untouched run alone", () => {
+ const fresh = startRun("asdf");
+ expect(abandonRun(fresh, 100)).toBe(fresh);
+ });
+});
diff --git a/tippen/src/lib/__tests__/fingers.test.ts b/tippen/src/lib/__tests__/fingers.test.ts
new file mode 100644
index 0000000..6f10f87
--- /dev/null
+++ b/tippen/src/lib/__tests__/fingers.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ FINGERS,
+ GRUNDSTELLUNG,
+ KEYBOARD_ROWS,
+ fingerOf,
+ handOf,
+ homeKeyOf,
+ keysInRow,
+ rowOf,
+ shiftHandFor,
+} from "../fingers";
+
+const ALPHABET = "abcdefghijklmnopqrstuvwxyzäöüß";
+
+describe("fingerOf", () => {
+ it("maps every German letter to exactly one finger", () => {
+ for (const key of ALPHABET) expect(fingerOf(key), key).not.toBeNull();
+ });
+
+ it("is case-insensitive", () => {
+ for (const key of ALPHABET) {
+ // "ß".toUpperCase() is "SS" - two characters, and not a key. `event.key` never
+ // reports that, so the single-character case is the one that has to hold.
+ const gross = key.toUpperCase();
+ if ([...gross].length !== 1) continue;
+ expect(fingerOf(gross)?.id, gross).toBe(fingerOf(key)?.id);
+ }
+ });
+
+ it("returns null for keys that are not on the layout", () => {
+ for (const key of ["Enter", "F1", "€", ""]) expect(fingerOf(key)).toBeNull();
+ });
+
+ it("assigns the Grundstellung to the eight home fingers, left to right", () => {
+ const expected = [
+ "links-klein",
+ "links-ring",
+ "links-mitte",
+ "links-zeige",
+ "rechts-zeige",
+ "rechts-mitte",
+ "rechts-ring",
+ "rechts-klein",
+ ];
+ GRUNDSTELLUNG.forEach((key, i) => expect(fingerOf(key)?.id).toBe(expected[i]));
+ });
+
+ it("gives each finger the home key it actually rests on", () => {
+ for (const key of GRUNDSTELLUNG) expect(homeKeyOf(key)).toBe(key);
+ expect(homeKeyOf(" ")).toBe(" ");
+ });
+
+ it("sends the two index fingers to their stretch keys", () => {
+ for (const key of "rtfgvb") expect(fingerOf(key)?.id).toBe("links-zeige");
+ for (const key of "zuhjnm") expect(fingerOf(key)?.id).toBe("rechts-zeige");
+ });
+});
+
+describe("hands", () => {
+ it("splits the letters into two disjoint, non-empty sets", () => {
+ const links = [...ALPHABET].filter((key) => handOf(key) === "links");
+ const rechts = [...ALPHABET].filter((key) => handOf(key) === "rechts");
+ expect(links.length).toBeGreaterThan(0);
+ expect(rechts.length).toBeGreaterThan(0);
+ expect(links.length + rechts.length).toBe(ALPHABET.length);
+ expect(links.some((key) => rechts.includes(key))).toBe(false);
+ });
+
+ it("shifts with the opposite hand", () => {
+ expect(shiftHandFor("a")).toBe("rechts");
+ expect(shiftHandFor("l")).toBe("links");
+ expect(shiftHandFor("Enter")).toBeNull();
+ });
+});
+
+describe("rows", () => {
+ it("places every letter in a row", () => {
+ for (const key of ALPHABET) expect(rowOf(key), key).not.toBeNull();
+ });
+
+ it("has the Grundstellung in the Grundreihe", () => {
+ for (const key of GRUNDSTELLUNG) expect(rowOf(key)).toBe("grund");
+ });
+
+ it("draws three rows, each with keys", () => {
+ expect(KEYBOARD_ROWS).toHaveLength(3);
+ for (const row of KEYBOARD_ROWS) expect(keysInRow(row).length).toBeGreaterThan(0);
+ });
+});
+
+describe("FINGERS", () => {
+ it("gives every finger a distinct hue so the colours can be named", () => {
+ const hues = Object.values(FINGERS).map((finger) => finger.hue);
+ expect(new Set(hues).size).toBe(hues.length);
+ });
+
+ it("keeps each finger's id and key consistent", () => {
+ for (const [id, finger] of Object.entries(FINGERS)) expect(finger.id).toBe(id);
+ });
+});
diff --git a/tippen/src/lib/__tests__/generator.test.ts b/tippen/src/lib/__tests__/generator.test.ts
new file mode 100644
index 0000000..b8b7cec
--- /dev/null
+++ b/tippen/src/lib/__tests__/generator.test.ts
@@ -0,0 +1,198 @@
+import { describe, expect, it } from "vitest";
+
+import { LESSONS } from "../curriculum";
+import {
+ chunkOffsets,
+ drillChunks,
+ letterStream,
+ lineFor,
+ lineText,
+ mulberry32,
+ wordChunks,
+} from "../generator";
+
+const keys = ["a", "s", "d", "f"];
+
+describe("mulberry32", () => {
+ it("is deterministic for a seed and different across seeds", () => {
+ expect(drillChunks(keys, mulberry32(7))).toEqual(drillChunks(keys, mulberry32(7)));
+ expect(drillChunks(keys, mulberry32(7))).not.toEqual(drillChunks(keys, mulberry32(8)));
+ });
+
+ it("stays in [0, 1)", () => {
+ const rng = mulberry32(3);
+ for (let i = 0; i < 1000; i++) {
+ const value = rng();
+ expect(value).toBeGreaterThanOrEqual(0);
+ expect(value).toBeLessThan(1);
+ }
+ });
+});
+
+describe("drillChunks", () => {
+ it("only ever emits active keys", () => {
+ for (const lesson of LESSONS) {
+ const chunks = drillChunks(lesson.activeKeys, mulberry32(lesson.nummer), { chunks: 20 });
+ const active = new Set(lesson.activeKeys);
+ for (const char of chunks.join("")) expect(active.has(char)).toBe(true);
+ }
+ });
+
+ it("honours the requested shape", () => {
+ const chunks = drillChunks(keys, mulberry32(1), { chunks: 7, chunkSize: 3 });
+ expect(chunks).toHaveLength(7);
+ for (const chunk of chunks) expect(chunk).toHaveLength(3);
+ });
+
+ it("over-represents the focus key once there are enough keys to spare", () => {
+ const viele = [..."asdfjklöei"];
+ const plain = drillChunks(viele, mulberry32(42), { chunks: 200 }).join("");
+ const focused = drillChunks(viele, mulberry32(42), { chunks: 200, focusKey: "e" }).join("");
+ const count = (text: string) => [...text].filter((char) => char === "e").length;
+ expect(count(focused)).toBeGreaterThan(count(plain));
+ });
+
+ it("never lets one key take over a line", () => {
+ // The failure this guards against: on a fresh profile every key looks equally
+ // unpractised, and an unchecked focus weight drilled `a` for half of Lektion 1
+ // while three other fingers went untrained.
+ for (const set of [[..."asdf"], [..."asdfjklö"], [..."asdfjklöei"]]) {
+ for (const focusKey of set) {
+ const text = drillChunks(set, mulberry32(3), { chunks: 200, focusKey }).join("");
+ const anteil = [...text].filter((char) => char === focusKey).length / text.length;
+ expect(anteil, `${focusKey} in ${set.join("")}`).toBeLessThanOrEqual(0.35);
+ }
+ }
+ });
+
+ it("spreads a small key set evenly - every finger gets a turn", () => {
+ const text = drillChunks(keys, mulberry32(11), { chunks: 200, focusKey: "a" }).join("");
+ for (const key of keys) {
+ const anteil = [...text].filter((char) => char === key).length / text.length;
+ expect(anteil, key).toBeGreaterThan(0.15);
+ }
+ });
+
+ it("gives every finger a turn inside a single line, not just on average", () => {
+ // The bag draw exists for exactly this. A real sampled line came out as
+ // `saadaaafasaassfssffsafsf` - one `d` in twenty-four characters - which is
+ // acceptable averaged over a hundred lines and useless for the one line she types.
+ for (let seed = 0; seed < 60; seed++) {
+ const text = drillChunks(keys, mulberry32(seed), { chunks: 6, chunkSize: 4 }).join("");
+ for (const key of keys) {
+ const wie_oft = [...text].filter((char) => char === key).length;
+ expect(wie_oft, `"${key}" in "${text}" (seed ${seed})`).toBeGreaterThanOrEqual(4);
+ }
+ }
+ });
+
+ it("still varies the order between seeds", () => {
+ const a = drillChunks(keys, mulberry32(1), { chunks: 6 }).join("");
+ const b = drillChunks(keys, mulberry32(2), { chunks: 6 }).join("");
+ expect(a).not.toBe(b);
+ });
+
+ it("ignores a focus key that is not active, rather than looping", () => {
+ const chunks = drillChunks(keys, mulberry32(1), { chunks: 5, focusKey: "z" });
+ expect(chunks.join("")).not.toContain("z");
+ });
+
+ it("returns nothing when there is nothing to type", () => {
+ expect(drillChunks([], mulberry32(1))).toEqual([]);
+ expect(drillChunks([" "], mulberry32(1))).toEqual([]);
+ });
+
+ it("never emits a space inside a chunk", () => {
+ const withSpace = ["a", "s", " "];
+ expect(drillChunks(withSpace, mulberry32(5), { chunks: 20 }).join("")).not.toContain(" ");
+ });
+});
+
+describe("wordChunks", () => {
+ it("returns null when a lesson has no words yet", () => {
+ expect(wordChunks([], mulberry32(1))).toBeNull();
+ });
+
+ it("draws only from the given list", () => {
+ const woerter = ["die", "ei", "elf"];
+ const chunks = wordChunks(woerter, mulberry32(2), { chunks: 10 })!;
+ for (const chunk of chunks) expect(woerter).toContain(chunk);
+ });
+
+ it("survives a single-word list", () => {
+ expect(wordChunks(["ei"], mulberry32(1), { chunks: 4 })).toEqual(["ei", "ei", "ei", "ei"]);
+ });
+
+ it("uses every word once before repeating any, across a long round", () => {
+ // A real ten-sentence round from a four-sentence list showed one sentence four times.
+ const woerter = ["a1", "b2", "c3", "d4", "e5", "f6", "g7", "h8"];
+ for (let seed = 0; seed < 40; seed++) {
+ const runde = wordChunks(woerter, mulberry32(seed), { chunks: 8 })!;
+ expect(new Set(runde).size, `seed ${seed}: ${runde.join(" ")}`).toBe(8);
+ }
+ });
+
+ it("never puts the same word twice in a row, even across a bag refill", () => {
+ for (let seed = 0; seed < 60; seed++) {
+ const runde = wordChunks(["eins", "zwei", "drei"], mulberry32(seed), { chunks: 30 })!;
+ for (let i = 1; i < runde.length; i++) {
+ expect(runde[i], `seed ${seed} bei ${i}: ${runde.join(" ")}`).not.toBe(runde[i - 1]);
+ }
+ }
+ });
+});
+
+describe("lineFor", () => {
+ it("prefers real words once a lesson has them", () => {
+ const lesson = LESSONS.find((l) => l.woerter.length > 0)!;
+ const chunks = lineFor(lesson, mulberry32(1));
+ for (const chunk of chunks) expect(lesson.woerter).toContain(chunk);
+ });
+
+ it("falls back to letters for the Grundstellung lessons", () => {
+ const lesson = LESSONS[0]!;
+ const chunks = lineFor(lesson, mulberry32(1));
+ expect(chunks.length).toBeGreaterThan(0);
+ for (const char of chunks.join("")) expect(lesson.activeKeys).toContain(char);
+ });
+
+ it("can be asked for letters even in a word lesson", () => {
+ const lesson = LESSONS.at(-1)!;
+ const chunks = lineFor(lesson, mulberry32(1), { preferWords: false, chunks: 4 });
+ for (const chunk of chunks) expect(lesson.woerter).not.toContain(chunk);
+ });
+});
+
+describe("lineText and chunkOffsets", () => {
+ it("joins with spaces only once the Leertaste is taught", () => {
+ expect(lineText(["as", "df"], true)).toBe("as df");
+ expect(lineText(["as", "df"], false)).toBe("asdf");
+ });
+
+ it("points each chunk at its own first character", () => {
+ const chunks = ["as", "df", "jk"];
+ for (const spaced of [true, false]) {
+ const text = lineText(chunks, spaced);
+ chunkOffsets(chunks, spaced).forEach((offset, i) => {
+ expect(text.slice(offset, offset + chunks[i]!.length)).toBe(chunks[i]);
+ });
+ }
+ });
+});
+
+describe("letterStream", () => {
+ it("produces one active letter per bubble", () => {
+ const stream = letterStream(keys, mulberry32(9), 25);
+ expect(stream).toHaveLength(25);
+ for (const letter of stream) expect(keys).toContain(letter);
+ });
+
+ it("shows every active letter across a round of bubbles", () => {
+ const stream = letterStream(keys, mulberry32(9), 20);
+ for (const key of keys) expect(stream).toContain(key);
+ });
+
+ it("is empty when there is nothing to type", () => {
+ expect(letterStream([], mulberry32(1), 10)).toEqual([]);
+ });
+});
diff --git a/tippen/src/lib/__tests__/grading.test.ts b/tippen/src/lib/__tests__/grading.test.ts
new file mode 100644
index 0000000..bd0a045
--- /dev/null
+++ b/tippen/src/lib/__tests__/grading.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it } from "vitest";
+
+import { press, startRun } from "../engine";
+import type { RunState } from "../engine";
+import {
+ TIERS,
+ UEBERRASCHUNG_AB,
+ grade,
+ isBetter,
+ isPassed,
+ sichtbareTiers,
+ sterneFor,
+ tierFor,
+ tierIndex,
+ tierProgress,
+} from "../grading";
+
+/** A run of `target` where `wrong` positions get one wrong key first, paced so the whole
+ * run takes exactly `dauerMs`. */
+function run(target: string, wrongAt: number[] = [], dauerMs = 60000): RunState {
+ let state = startRun(target);
+ const steps = target.length + wrongAt.length;
+ const tick = dauerMs / Math.max(1, steps - 1);
+ let t = 0;
+ for (let i = 0; i < target.length; i++) {
+ if (wrongAt.includes(i)) {
+ [state] = press(state, target[i] === "x" ? "q" : "x", t);
+ t += tick;
+ }
+ [state] = press(state, target[i]!, t);
+ t += tick;
+ }
+ return state;
+}
+
+describe("grade", () => {
+ it("measures Zeichen pro Minute over exactly one minute", () => {
+ const result = grade(run("a".repeat(60)));
+ expect(result.zeichen).toBe(60);
+ expect(result.tempo).toBeCloseTo(60, 0);
+ expect(result.genauigkeit).toBe(1);
+ });
+
+ it("weights accuracy cubically", () => {
+ // 90 correct, 10 wrong -> 90% accuracy, so 0.9^3 = 0.729 of the raw speed survives.
+ const result = grade(run("a".repeat(90), Array.from({ length: 10 }, (_, i) => i)));
+ expect(result.genauigkeit).toBeCloseTo(0.9, 2);
+ expect(result.punkte / result.tempo).toBeCloseTo(0.729, 3);
+ });
+
+ it("never produces a negative score, however bad the run", () => {
+ const result = grade(run("asdf", [0, 1, 2, 3]));
+ expect(result.punkte).toBeGreaterThanOrEqual(0);
+ });
+
+ it("does not divide by zero on an instant run", () => {
+ let state = startRun("a");
+ [state] = press(state, "a", 1000);
+ const result = grade(state);
+ expect(Number.isFinite(result.tempo)).toBe(true);
+ expect(result.tempo).toBeLessThanOrEqual(60);
+ });
+
+ it("counts a hammered wrong key once", () => {
+ let state = startRun("as");
+ for (const key of ["x", "x", "x", "a", "s"]) [state] = press(state, key, 0);
+ expect(grade(state).fehler).toBe(1);
+ });
+
+ it("awards Perlen even for a bad run", () => {
+ expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).perlen).toBeGreaterThan(0);
+ });
+});
+
+describe("sterne", () => {
+ it("uses the documented accuracy thresholds", () => {
+ expect(sterneFor(1)).toBe(3);
+ expect(sterneFor(0.97)).toBe(3);
+ expect(sterneFor(0.969)).toBe(2);
+ expect(sterneFor(0.93)).toBe(2);
+ expect(sterneFor(0.929)).toBe(1);
+ expect(sterneFor(0.85)).toBe(1);
+ expect(sterneFor(0.849)).toBe(0);
+ });
+
+ it("gates the unlock at two stars and ignores speed entirely", () => {
+ expect(isPassed(0.93)).toBe(true);
+ expect(isPassed(0.929)).toBe(false);
+ });
+});
+
+describe("tierFor", () => {
+ it("returns the right animal at every boundary", () => {
+ for (const tier of TIERS) {
+ expect(tierFor(tier.ab).id).toBe(tier.id);
+ }
+ });
+
+ it("clamps below the slowest and above the fastest", () => {
+ expect(tierFor(0).id).toBe("schnecke");
+ expect(tierFor(-5).id).toBe("schnecke");
+ expect(tierFor(9999).id).toBe("schwertwal");
+ });
+
+ it("is monotone - more Punkte never means a slower animal", () => {
+ let seen = 0;
+ for (let punkte = 0; punkte < 200; punkte += 1) {
+ const index = TIERS.findIndex((tier) => tier.id === tierFor(punkte).id);
+ expect(index).toBeGreaterThanOrEqual(seen);
+ seen = index;
+ }
+ });
+
+ it("reports progress toward the next animal", () => {
+ expect(tierProgress(15)).toBeCloseTo(0, 5);
+ expect(tierProgress(20)).toBeCloseTo(0.5, 5);
+ expect(tierProgress(9999)).toBe(1);
+ });
+});
+
+describe("TIERS", () => {
+ it("gives every tier a distinct name and emoji", () => {
+ expect(new Set(TIERS.map((tier) => tier.emoji)).size).toBe(TIERS.length);
+ expect(new Set(TIERS.map((tier) => tier.name)).size).toBe(TIERS.length);
+ expect(new Set(TIERS.map((tier) => tier.id)).size).toBe(TIERS.length);
+ });
+
+ it("rises in speed with no gaps", () => {
+ for (let i = 1; i < TIERS.length; i++) {
+ expect(TIERS[i]!.ab).toBeGreaterThan(TIERS[i - 1]!.ab);
+ }
+ expect(TIERS[0]!.ab).toBe(0);
+ });
+
+ it("does not use the rating star as an animal", () => {
+ for (const tier of TIERS) expect(tier.emoji).not.toBe("\u2b50");
+ });
+});
+
+describe("sichtbareTiers", () => {
+ it("always shows the ladder up to the Delfin, however slow the run", () => {
+ const { tiers } = sichtbareTiers("schnecke", null);
+ expect(tiers.at(-1)!.id).toBe(UEBERRASCHUNG_AB);
+ expect(tiers.map((tier) => tier.id)).toContain("schnecke");
+ });
+
+ it("keeps the animals above the Delfin hidden until they are reached", () => {
+ const { tiers, mehrVerborgen } = sichtbareTiers("qualle", "delfin");
+ expect(tiers.some((tier) => tier.id === "hai")).toBe(false);
+ expect(tiers.some((tier) => tier.id === "schwertwal")).toBe(false);
+ expect(mehrVerborgen).toBe(true);
+ });
+
+ it("reveals a surprise animal once this run earns it", () => {
+ const { tiers } = sichtbareTiers("hai", null);
+ expect(tiers.at(-1)!.id).toBe("hai");
+ });
+
+ it("keeps a surprise animal revealed on later, slower runs", () => {
+ const { tiers, mehrVerborgen } = sichtbareTiers("krabbe", "hai");
+ expect(tiers.at(-1)!.id).toBe("hai");
+ expect(mehrVerborgen).toBe(true);
+ });
+
+ it("stops promising more once the ladder is complete", () => {
+ expect(sichtbareTiers("schwertwal", "schwertwal").mehrVerborgen).toBe(false);
+ });
+
+ it("has something to keep secret in the first place", () => {
+ expect(tierIndex(UEBERRASCHUNG_AB)).toBeLessThan(TIERS.length - 1);
+ });
+});
+
+describe("isBetter", () => {
+ it("prefers more stars over more Punkte", () => {
+ expect(isBetter({ sterne: 3, punkte: 10 } as never, { sterne: 2, punkte: 500 })).toBe(true);
+ expect(isBetter({ sterne: 2, punkte: 500 } as never, { sterne: 3, punkte: 10 })).toBe(false);
+ });
+
+ it("breaks a tie on Punkte", () => {
+ expect(isBetter({ sterne: 2, punkte: 50 } as never, { sterne: 2, punkte: 49 })).toBe(true);
+ expect(isBetter({ sterne: 2, punkte: 49 } as never, { sterne: 2, punkte: 50 })).toBe(false);
+ });
+});
diff --git a/tippen/src/lib/__tests__/progress.test.ts b/tippen/src/lib/__tests__/progress.test.ts
new file mode 100644
index 0000000..2b4da1d
--- /dev/null
+++ b/tippen/src/lib/__tests__/progress.test.ts
@@ -0,0 +1,238 @@
+import { describe, expect, it } from "vitest";
+
+import { FIRST_LESSON_ID, LESSONS } from "../curriculum";
+import { press, startRun } from "../engine";
+import { grade } from "../grading";
+import type { RunResult } from "../grading";
+import {
+ FLEISS_VERSUCHE,
+ focusKeyFor,
+ freshProgress,
+ mastery,
+ migrate,
+ recordRun,
+ today,
+} from "../progress";
+import type { Progress } from "../progress";
+
+const L1 = LESSONS[0]!.id;
+const L2 = LESSONS[1]!.id;
+
+/** A graded run of `target` with a wrong key at each of `wrongAt`, paced at `tickMs`. */
+function runResult(target: string, wrongAt: number[] = [], tickMs = 1000): RunResult {
+ let state = startRun(target);
+ let t = 0;
+ for (let i = 0; i < target.length; i++) {
+ if (wrongAt.includes(i)) {
+ [state] = press(state, target[i] === "x" ? "q" : "x", t);
+ t += tickMs;
+ }
+ [state] = press(state, target[i]!, t);
+ t += tickMs;
+ }
+ return grade(state);
+}
+
+const perfekt = () => runResult("asdfasdfasdfasdf");
+const schlecht = () => runResult("asdfasdfasdfasdf", [0, 1, 2, 3, 4, 5]);
+
+describe("freshProgress", () => {
+ it("unlocks the first lesson and nothing else", () => {
+ const progress = freshProgress();
+ expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
+ const offen = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked);
+ expect(offen).toHaveLength(1);
+ });
+
+ it("knows every lesson in the curriculum", () => {
+ const progress = freshProgress();
+ for (const lesson of LESSONS) expect(progress.lessons[lesson.id]).toBeDefined();
+ });
+});
+
+describe("recordRun", () => {
+ it("unlocks the next lesson on two stars", () => {
+ const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, perfekt());
+ expect(unlockedLessonId).toBe(L2);
+ expect(progress.lessons[L2]!.unlocked).toBe(true);
+ });
+
+ it("does not unlock below two stars", () => {
+ const result = schlecht();
+ expect(result.sterne).toBeLessThan(2);
+ const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, result);
+ expect(unlockedLessonId).toBeNull();
+ expect(progress.lessons[L2]!.unlocked).toBe(false);
+ });
+
+ it("unlocks after enough tries however bad the score - the Fleiß rule", () => {
+ let progress = freshProgress();
+ for (let i = 0; i < FLEISS_VERSUCHE - 1; i++) {
+ progress = recordRun(progress, L1, schlecht()).progress;
+ expect(progress.lessons[L2]!.unlocked).toBe(false);
+ }
+ const { progress: last, unlockedLessonId } = recordRun(progress, L1, schlecht());
+ expect(unlockedLessonId).toBe(L2);
+ expect(last.lessons[L2]!.unlocked).toBe(true);
+ });
+
+ it("never lets the best animal or star count go down", () => {
+ let progress = recordRun(freshProgress(), L1, perfekt()).progress;
+ const best = progress.lessons[L1]!;
+ progress = recordRun(progress, L1, schlecht()).progress;
+ expect(progress.lessons[L1]!.bestSterne).toBe(best.bestSterne);
+ expect(progress.lessons[L1]!.bestPunkte).toBe(best.bestPunkte);
+ expect(progress.lessons[L1]!.bestTier).toBe(best.bestTier);
+ });
+
+ it("counts every run, good or bad", () => {
+ let progress = freshProgress();
+ for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, schlecht()).progress;
+ expect(progress.lessons[L1]!.runs).toBe(3);
+ });
+
+ it("adds Perlen on every run", () => {
+ const first = recordRun(freshProgress(), L1, schlecht());
+ expect(first.progress.perlen).toBeGreaterThan(0);
+ const second = recordRun(first.progress, L1, schlecht());
+ expect(second.progress.perlen).toBeGreaterThan(first.progress.perlen);
+ });
+
+ it("releases a creature only when a Welt is finished, and only once", () => {
+ let progress = freshProgress();
+ // Welt 1 is lessons 1-3, so finishing lesson 3 is what crosses into Welt 2.
+ const welt1 = LESSONS.filter((lesson) => lesson.welt === 1);
+ let released: string[] = [];
+ for (const lesson of welt1) {
+ const outcome = recordRun(progress, lesson.id, perfekt());
+ progress = outcome.progress;
+ if (outcome.neuesTier) released.push(outcome.neuesTier);
+ }
+ expect(released).toHaveLength(1);
+ expect(progress.aquarium).toEqual(released);
+
+ // Replaying the same lesson must not hand out a second copy.
+ const again = recordRun(progress, welt1.at(-1)!.id, perfekt());
+ expect(again.neuesTier).toBeNull();
+ expect(again.progress.aquarium).toEqual(released);
+ });
+
+ it("records a ghost of the best run for the race", () => {
+ const { progress } = recordRun(freshProgress(), L1, perfekt());
+ expect(progress.lessons[L1]!.ghost?.length).toBe(perfekt().zeichen);
+ });
+
+ it("builds the daily streak and restarts it after a gap", () => {
+ let progress = recordRun(freshProgress(), L1, perfekt(), "2026-09-11").progress;
+ expect(progress.streak).toEqual({ days: 1, lastPlayed: "2026-09-11" });
+
+ // A second run the same day does not double-count.
+ progress = recordRun(progress, L1, perfekt(), "2026-09-11").progress;
+ expect(progress.streak.days).toBe(1);
+
+ progress = recordRun(progress, L1, perfekt(), "2026-09-12").progress;
+ expect(progress.streak.days).toBe(2);
+
+ // A missed day restarts at 1, never at 0.
+ progress = recordRun(progress, L1, perfekt(), "2026-09-20").progress;
+ expect(progress.streak.days).toBe(1);
+ });
+
+ it("folds keystrokes into the per-key stats", () => {
+ const { progress } = recordRun(freshProgress(), L1, runResult("asdf", [1]));
+ expect(progress.keyStats["a"]?.attempts).toBe(1);
+ expect(progress.keyStats["s"]?.errors).toBe(1);
+ expect(progress.keyStats["s"]?.attempts).toBe(2);
+ });
+});
+
+describe("migrate", () => {
+ it("returns a fresh profile for anything unusable", () => {
+ for (const raw of [null, undefined, 42, "nope", {}, { version: 99 }, []]) {
+ const progress = migrate(raw);
+ expect(progress.version).toBe(1);
+ expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
+ }
+ });
+
+ it("keeps a valid save", () => {
+ const stored = recordRun(freshProgress(), L1, perfekt()).progress;
+ const restored = migrate(JSON.parse(JSON.stringify(stored)));
+ expect(restored.lessons[L2]!.unlocked).toBe(true);
+ expect(restored.perlen).toBe(stored.perlen);
+ });
+
+ it("drops lessons that no longer exist", () => {
+ const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, alteLektion: {} } };
+ expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["alteLektion"]).toBeUndefined();
+ });
+
+ it("moves the emoji pets of old saves into today's aquarium", () => {
+ const alt = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] };
+ expect(migrate(JSON.parse(JSON.stringify(alt))).aquarium).toEqual(["clownfisch", "krake"]);
+ });
+
+ it("re-unlocks the first lesson even if the save says otherwise", () => {
+ const broken = freshProgress();
+ broken.lessons[FIRST_LESSON_ID] = { ...broken.lessons[FIRST_LESSON_ID]!, unlocked: false };
+ expect(migrate(broken).lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
+ });
+});
+
+describe("focusKeyFor", () => {
+ it("has no focus key on a lesson that has never been played", () => {
+ // Otherwise "pick an unpractised key" picks whichever sorts first and drills it half
+ // the line, starving the other three fingers on Lektion 1.
+ expect(focusKeyFor(freshProgress(), ["a", "s", "d", "f"])).toBeNull();
+ });
+
+ it("picks an unpractised key before a merely slow one", () => {
+ const progress: Progress = {
+ ...freshProgress(),
+ keyStats: { a: { ema: 5000, attempts: 50, errors: 20 } },
+ };
+ expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
+ });
+
+ it("picks the slowest and most error-prone once all are practised", () => {
+ const progress: Progress = {
+ ...freshProgress(),
+ keyStats: {
+ a: { ema: 300, attempts: 50, errors: 0 },
+ s: { ema: 900, attempts: 50, errors: 10 },
+ },
+ };
+ expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
+ });
+
+ it("ignores the Leertaste and copes with an empty lesson", () => {
+ expect(focusKeyFor(freshProgress(), [" "])).toBeNull();
+ expect(focusKeyFor(freshProgress(), [])).toBeNull();
+ });
+});
+
+describe("mastery", () => {
+ it("is zero until a key has been seen enough times", () => {
+ const progress: Progress = { ...freshProgress(), keyStats: { a: { ema: 200, attempts: 2, errors: 0 } } };
+ expect(mastery(progress, "a")).toBe(0);
+ expect(mastery(progress, "q")).toBe(0);
+ });
+
+ it("rises with speed and accuracy, and stays within 0..1", () => {
+ const stat = (ema: number, errors: number) => ({
+ ...freshProgress(),
+ keyStats: { a: { ema, attempts: 100, errors } },
+ });
+ expect(mastery(stat(1500, 0), "a")).toBe(0);
+ expect(mastery(stat(300, 0), "a")).toBe(1);
+ expect(mastery(stat(900, 0), "a")).toBeCloseTo(0.5, 5);
+ expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
+ });
+});
+
+describe("today", () => {
+ it("formats local time, not UTC", () => {
+ expect(today(new Date(2026, 8, 11, 23, 30))).toBe("2026-09-11");
+ expect(today(new Date(2026, 0, 1, 0, 5))).toBe("2026-01-01");
+ });
+});
diff --git a/tippen/src/lib/aquarium.ts b/tippen/src/lib/aquarium.ts
new file mode 100644
index 0000000..7d337e4
--- /dev/null
+++ b/tippen/src/lib/aquarium.ts
@@ -0,0 +1,208 @@
+/** The aquarium's pets, and how they swim.
+ *
+ * A pet moves in when a Welt is finished and then stays - not in a list on the home
+ * screen, but swimming around behind every screen of the game. A reward that is always
+ * in view, drifting past while she types, is the strongest version of "the reward
+ * persists", and it costs nothing to look at.
+ *
+ * Pets are illustrations, speed trophies are emoji. The ladder in grading.ts changes
+ * with every run; a pet arrives once and never leaves. Keeping the two in different
+ * visual languages is what lets a Schildkröte be both a speed trophy (🐢) and a pet
+ * (the drawing) without a six-year-old having to work out which is which.
+ *
+ * The swimming lives here rather than in the component because it is the part worth
+ * testing: `schwimme` is a pure step - a swimmer and a time slice in, a swimmer out - so
+ * "never leaves the tank" and "looks where it is going" are checkable without a DOM or a
+ * clock. The component only calls it once per frame and writes the transform. */
+
+export type KreaturId = "clownfisch" | "krake" | "seepferdchen" | "schildkroete" | "perlmuschel";
+
+export interface Kreatur {
+ id: KreaturId;
+ name: string;
+ /** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */
+ artikel: "Der" | "Die" | "Das";
+ /** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */
+ bild: string;
+ /** Height as a fraction of the stage height - so a pet is the same size relative to
+ * the sea on a small laptop and on a big screen. */
+ groesse: number;
+ /** Cruising speed as a fraction of the stage width per second. Slow on purpose: these
+ * are in the background of a typing drill, and anything darting reads as an event. */
+ tempo: number;
+ /** Which way the drawing faces. Side-view creatures are mirrored to look where they
+ * swim; front-view ones never are - a krake flipping on every turn looks broken. */
+ blick: "seite" | "vorne";
+}
+
+export const KREATUREN: readonly Kreatur[] = [
+ { id: "clownfisch", name: "Clownfisch", artikel: "Der", bild: "/aquarium/clownfisch.webp", groesse: 0.13, tempo: 0.045, blick: "seite" },
+ { id: "krake", name: "Krake", artikel: "Die", bild: "/aquarium/krake.webp", groesse: 0.17, tempo: 0.025, blick: "vorne" },
+ { id: "seepferdchen", name: "Seepferdchen", artikel: "Das", bild: "/aquarium/seepferdchen.webp", groesse: 0.19, tempo: 0.02, blick: "seite" },
+ { id: "schildkroete", name: "Schildkröte", artikel: "Die", bild: "/aquarium/schildkroete.webp", groesse: 0.17, tempo: 0.032, blick: "seite" },
+ { id: "perlmuschel", name: "Perlmuschel", artikel: "Die", bild: "/aquarium/perlmuschel.webp", groesse: 0.12, tempo: 0.016, blick: "vorne" },
+];
+
+const KREATUR_BY_ID = new Map(KREATUREN.map((kreatur) => [kreatur.id, kreatur]));
+
+export function kreaturById(id: KreaturId): Kreatur {
+ // Every KreaturId is in KREATUREN, so this cannot miss; the fallback only satisfies
+ // `noUncheckedIndexedAccess`.
+ return KREATUR_BY_ID.get(id) ?? KREATUREN[0]!;
+}
+
+/** Saves from before the pets were drawings stored the Welt's emoji. Each Welt kept its
+ * slot, so the old emoji map one-to-one onto the creature that now fills it. */
+const ALTE_EMOJI: Readonly> = {
+ "🐠": "clownfisch",
+ "🐙": "krake",
+ "🦑": "seepferdchen",
+ "🐳": "schildkroete",
+ "🧜": "perlmuschel",
+};
+
+/** A stored aquarium entry as a creature, or `null` for anything unrecognisable. */
+export function kreaturAus(raw: unknown): KreaturId | null {
+ if (typeof raw !== "string") return null;
+ if (KREATUR_BY_ID.has(raw as KreaturId)) return raw as KreaturId;
+ return ALTE_EMOJI[raw] ?? null;
+}
+
+// --- swimming ---------------------------------------------------------------
+
+export interface Becken {
+ breite: number;
+ hoehe: number;
+}
+
+export interface Schwimmer {
+ /** Centre, in px. */
+ x: number;
+ y: number;
+ /** px per second. */
+ vx: number;
+ vy: number;
+ /** Where it is currently drifting towards. */
+ zielX: number;
+ zielY: number;
+ /** -1 looking left … 1 looking right. Eased rather than switched, so a turn is a
+ * visible flip through the middle instead of a jump. */
+ blick: number;
+ /** Seconds swum, for the bob. Started at a random offset so pets do not bob in step. */
+ zeit: number;
+}
+
+/** How long the velocity takes to swing round to a new heading. Over a second, so every
+ * change of course is a lazy curve and nothing ever jerks. */
+const LENK_TAU = 1.4;
+/** How long a turn-around takes. */
+const WENDE_TAU = 0.25;
+/** One gentle bob per this many seconds. */
+const WIPP_PERIODE = 3.6;
+
+function zufallsZiel(becken: Becken, rand: number, rng: () => number): { x: number; y: number } {
+ // A tank smaller than the creature (a collapsed window) still needs a valid target:
+ // the middle.
+ const spanne = (laenge: number) => Math.max(0, laenge - 2 * rand);
+ return {
+ x: rand + rng() * spanne(becken.breite),
+ y: rand + rng() * spanne(becken.hoehe),
+ };
+}
+
+/** A new swimmer. `vonAussen` starts it just past a side edge, so a pet that has only
+ * just been earned visibly swims in rather than popping into existence mid-screen. */
+export function neuerSchwimmer(
+ becken: Becken,
+ rand: number,
+ rng: () => number,
+ vonAussen = false,
+): Schwimmer {
+ const ziel = zufallsZiel(becken, rand, rng);
+ const start = vonAussen
+ ? { x: rng() < 0.5 ? -rand : becken.breite + rand, y: ziel.y }
+ : zufallsZiel(becken, rand, rng);
+ return {
+ x: start.x,
+ y: start.y,
+ vx: 0,
+ vy: 0,
+ zielX: ziel.x,
+ zielY: ziel.y,
+ blick: ziel.x >= start.x ? 1 : -1,
+ zeit: rng() * WIPP_PERIODE,
+ };
+}
+
+/** One time slice of swimming: steer towards the target, pick a new one on arrival, and
+ * turn to face the direction of travel.
+ *
+ * `rand` is half the creature's size - how far its centre stays from the edges - and
+ * `tempo` its cruising speed in px/s. */
+export function schwimme(
+ s: Schwimmer,
+ dt: number,
+ becken: Becken,
+ rand: number,
+ tempo: number,
+ rng: () => number,
+): Schwimmer {
+ let { zielX, zielY } = s;
+ const imBecken = (x: number, y: number) =>
+ x >= rand && x <= becken.breite - rand && y >= rand && y <= becken.hoehe - rand;
+
+ // Arrived, or the window shrank and the target is now outside it: drift somewhere new.
+ const dx = zielX - s.x;
+ const dy = zielY - s.y;
+ if (Math.hypot(dx, dy) < Math.max(rand, 24) || !imBecken(zielX, zielY)) {
+ const ziel = zufallsZiel(becken, rand, rng);
+ zielX = ziel.x;
+ zielY = ziel.y;
+ }
+
+ const richtungX = zielX - s.x;
+ const richtungY = zielY - s.y;
+ const abstand = Math.hypot(richtungX, richtungY) || 1;
+ // Vertical drift at half speed: fish cruise, they do not climb.
+ const sollVx = (richtungX / abstand) * tempo;
+ const sollVy = (richtungY / abstand) * tempo * 0.5;
+
+ const lenk = 1 - Math.exp(-dt / LENK_TAU);
+ const vx = s.vx + (sollVx - s.vx) * lenk;
+ const vy = s.vy + (sollVy - s.vy) * lenk;
+
+ // Only turn round once it is really swimming that way - hovering on the spot must not
+ // make it flicker left and right.
+ const sollBlick = Math.abs(vx) > tempo * 0.2 ? Math.sign(vx) : Math.sign(s.blick) || 1;
+ const blick = s.blick + (sollBlick - s.blick) * (1 - Math.exp(-dt / WENDE_TAU));
+
+ return {
+ x: s.x + vx * dt,
+ y: s.y + vy * dt,
+ vx,
+ vy,
+ zielX,
+ zielY,
+ blick,
+ zeit: s.zeit + dt,
+ };
+}
+
+/** What the component draws for a swimmer: the bob and the tilt layered on top of the
+ * position, and the mirroring for side-view drawings. */
+export function pose(
+ s: Schwimmer,
+ kreatur: Kreatur,
+ tempo: number,
+): { x: number; y: number; spiegel: number; drehung: number } {
+ const wippen = Math.sin((s.zeit / WIPP_PERIODE) * 2 * Math.PI);
+ const seite = kreatur.blick === "seite";
+ // Nose up when rising, down when sinking - a few degrees, in the direction it faces.
+ const neigung = tempo > 0 ? Math.max(-1, Math.min(1, s.vy / tempo)) : 0;
+ return {
+ x: s.x,
+ y: s.y + wippen * 7,
+ spiegel: seite ? s.blick : 1,
+ drehung: seite ? neigung * 10 * Math.sign(s.blick || 1) : wippen * 3,
+ };
+}
diff --git a/tippen/src/lib/curriculum.ts b/tippen/src/lib/curriculum.ts
new file mode 100644
index 0000000..262bf1f
--- /dev/null
+++ b/tippen/src/lib/curriculum.ts
@@ -0,0 +1,308 @@
+/** The lesson plan: 48 Lektionen in 5 Welten.
+ *
+ * The shape follows what every serious Zehnfinger-Kurs does - start on the
+ * Grundstellung `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S,
+ * R, A, T, D, H, U, L …), and drill everything learned so far each time. TIPP10's
+ * German course is the same idea in 18 lessons.
+ *
+ * Where this deviates, it deviates for the age, and the deviation is *pace*. A
+ * six-year-old gets:
+ *
+ * - **one new key per lesson** from Welt 2 onward (Welt 1 pairs the same finger on
+ * both hands, which is one motion, not two);
+ * - **an Übung lesson after every pair of new keys** - no new keys at all, just the
+ * ones she has. Consolidation is where typing actually becomes automatic, and a
+ * course that only ever moves forward never gives it room;
+ * - **short rounds**, growing from twelve characters in Welt 1 to whole sentences in
+ * Welt 5.
+ *
+ * That is 48 short lessons rather than 17 big ones. The curriculum is the same; the
+ * steps between are small enough to climb.
+ *
+ * `activeKeys` is cumulative on purpose: Lektion 30 still drills `f`, or the first
+ * lessons rot while the last ones are learned. */
+
+import type { KreaturId } from "./aquarium";
+import { GRUNDSTELLUNG, LEERTASTE } from "./fingers";
+
+export type ModeId =
+ | "tauchgang"
+ | "blasen"
+ | "quallen"
+ | "fuettern"
+ | "rennen"
+ | "perlen";
+
+/** What a lesson's targets are made of. Decides which modes make sense: Quallenalarm
+ * cannot show a sentence, and Fütterungszeit cannot put one on a fish. */
+export type LessonArt = "buchstaben" | "woerter" | "saetze";
+
+export interface Lesson {
+ id: string;
+ welt: number;
+ nummer: number;
+ titel: string;
+ /** What this lesson is about, in words a six-year-old hears read aloud. */
+ untertitel: string;
+ /** The keys introduced here - what the generator weights toward. Empty for an Übung. */
+ neueKeys: readonly string[];
+ /** Everything typable in this lesson, cumulative. */
+ activeKeys: readonly string[];
+ /** Which game modes this lesson offers, in carousel order. */
+ modi: readonly ModeId[];
+ /** Real German words (or sentences) for the word modes. */
+ woerter: readonly string[];
+ art: LessonArt;
+ /** True for a consolidation lesson - no new keys, just practice. */
+ istUebung: boolean;
+ /** How long one run is. Grows with the curriculum - see `laengeFuer`. */
+ chunks: number;
+ chunkSize: number;
+}
+
+export interface Welt {
+ nummer: number;
+ titel: string;
+ emoji: string;
+ /** The creature that moves into the aquarium when this Welt is finished. */
+ belohnung: KreaturId;
+}
+
+/** A Welt's creature is a pet that stays - a drawing that swims behind every screen from
+ * then on. A tier animal in grading.ts is a speed trophy that changes, and is an emoji.
+ * lib/aquarium.ts has why the two are kept in different visual languages. The pets grow
+ * with the Welten: a small fish first, the pearl clam - the Perlen's own home - last. */
+export const WELTEN: readonly Welt[] = [
+ { nummer: 1, titel: "Die Grundstellung", emoji: "🏝️", belohnung: "clownfisch" },
+ { nummer: 2, titel: "Nach oben", emoji: "🌊", belohnung: "krake" },
+ { nummer: 3, titel: "Nach unten", emoji: "🪸", belohnung: "seepferdchen" },
+ { nummer: 4, titel: "Große Buchstaben", emoji: "👑", belohnung: "schildkroete" },
+ { nummer: 5, titel: "Ganze Sätze", emoji: "📖", belohnung: "perlmuschel" },
+];
+
+/** Letters only - no words can be spelled yet. */
+const BUCHSTABEN_MODI: readonly ModeId[] = ["tauchgang", "blasen", "quallen", "perlen"];
+/** Real words exist: the word modes and the race join in. */
+const WORT_MODI: readonly ModeId[] = ["tauchgang", "fuettern", "blasen", "rennen", "quallen", "perlen"];
+/** Sentences do not fit on a bubble or a fish. */
+const SATZ_MODI: readonly ModeId[] = ["tauchgang", "rennen", "perlen"];
+
+/** Line length by Welt - a full block of text per round, at least five lines of it.
+ *
+ * Deliberately five times what the first version used. Twelve characters was long
+ * enough to *finish*, which is what the first day needs, but far too short to build
+ * anything: a round that ends before the hands settle measures reaction time rather
+ * than typing, and the score bounces around so much that getting better is invisible.
+ * Sixty-plus characters is long enough for a rhythm to appear and for the Zeichen pro
+ * Minute to mean something.
+ *
+ * Welt 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
+function laengeFuer(welt: number): { chunks: number; chunkSize: number } {
+ if (welt === 1) return { chunks: 24, chunkSize: 3 }; // 72 Zeichen
+ if (welt === 2) return { chunks: 25, chunkSize: 4 }; // 100
+ if (welt === 3) return { chunks: 30, chunkSize: 4 }; // 120
+ if (welt === 4) return { chunks: 25, chunkSize: 4 }; // 25 Wörter
+ return { chunks: 10, chunkSize: 4 }; // Welt 5: zehn ganze Sätze
+}
+
+interface PlanEntry {
+ welt: number;
+ titel: string;
+ untertitel: string;
+ /** Empty marks an Übung - consolidation, no new keys. */
+ neu: readonly string[];
+ woerter?: readonly string[];
+ art?: LessonArt;
+}
+
+/** The plan. Everything else is derived from this, so a curriculum change is one edit. */
+const PLAN: readonly PlanEntry[] = [
+ // ---------------------------------------------------------------- Welt 1 --
+ // Two keys per lesson, always the same finger on each hand - one motion, mirrored.
+ // F and J first because they carry the tactile bumps: they are the two keys a child
+ // can find without looking, and every other key is taught as an offset from them.
+ { welt: 1, titel: "F und J", untertitel: "Die Zeigefinger - die Tasten mit den Punkten", neu: ["f", "j"] },
+ { welt: 1, titel: "D und K", untertitel: "Die Mittelfinger", neu: ["d", "k"] },
+ { welt: 1, titel: "Übung: F J D K", untertitel: "Die vier Tasten zusammen", neu: [] },
+ { welt: 1, titel: "S und L", untertitel: "Die Ringfinger", neu: ["s", "l"] },
+ { welt: 1, titel: "Übung: sechs Tasten", untertitel: "Alles bisher zusammen", neu: [] },
+ { welt: 1, titel: "A und Ö", untertitel: "Die kleinen Finger", neu: ["a", "ö"] },
+ { welt: 1, titel: "Übung: die Grundstellung", untertitel: "Alle acht Finger", neu: [] },
+ { welt: 1, titel: "Die Leertaste", untertitel: "Der Daumen kommt dazu", neu: [LEERTASTE] },
+
+ // ---------------------------------------------------------------- Welt 2 --
+ // One key per lesson from here on, an Übung after every two.
+ { welt: 2, titel: "Das E", untertitel: "Mittelfinger links nach oben", neu: ["e"],
+ woerter: ["elf", "alle", "esel", "see", "keks", "fell"] },
+ { welt: 2, titel: "Das I", untertitel: "Mittelfinger rechts nach oben", neu: ["i"],
+ woerter: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
+ { welt: 2, titel: "Übung: E und I", untertitel: "Die neuen Tasten festigen", neu: [],
+ woerter: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
+ { welt: 2, titel: "Das R", untertitel: "Zeigefinger links nach oben", neu: ["r"],
+ woerter: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
+ { welt: 2, titel: "Das U", untertitel: "Zeigefinger rechts nach oben", neu: ["u"],
+ woerter: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
+ { welt: 2, titel: "Übung: R und U", untertitel: "Die Zeigefinger nach oben", neu: [],
+ woerter: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
+ { welt: 2, titel: "Das T", untertitel: "Zeigefinger links weit nach oben", neu: ["t"],
+ woerter: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
+ { welt: 2, titel: "Das Z", untertitel: "Zeigefinger rechts weit nach oben", neu: ["z"],
+ woerter: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
+ { welt: 2, titel: "Übung: T und Z", untertitel: "Weit nach oben greifen", neu: [],
+ woerter: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
+ { welt: 2, titel: "Das O", untertitel: "Ringfinger rechts nach oben", neu: ["o"],
+ woerter: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
+ { welt: 2, titel: "Das W", untertitel: "Ringfinger links nach oben", neu: ["w"],
+ woerter: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
+ { welt: 2, titel: "Übung: W und O", untertitel: "Die Ringfinger nach oben", neu: [],
+ woerter: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
+ { welt: 2, titel: "Das P", untertitel: "Kleiner Finger rechts nach oben", neu: ["p"],
+ woerter: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
+ { welt: 2, titel: "Das Q", untertitel: "Kleiner Finger links nach oben", neu: ["q"],
+ woerter: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
+ { welt: 2, titel: "Das Ü", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["ü"],
+ woerter: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] },
+ { welt: 2, titel: "Übung: die obere Reihe", untertitel: "Die ganze Reihe zusammen", neu: [],
+ woerter: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
+
+ // ---------------------------------------------------------------- Welt 3 --
+ { welt: 3, titel: "Das N", untertitel: "Zeigefinger rechts nach unten", neu: ["n"],
+ woerter: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] },
+ { welt: 3, titel: "Das M", untertitel: "Zeigefinger rechts, neben dem N", neu: ["m"],
+ woerter: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] },
+ { welt: 3, titel: "Übung: N und M", untertitel: "Die neuen Tasten festigen", neu: [],
+ woerter: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] },
+ { welt: 3, titel: "Das G", untertitel: "Zeigefinger links, in der Mitte", neu: ["g"],
+ woerter: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] },
+ { welt: 3, titel: "Das H", untertitel: "Zeigefinger rechts, in der Mitte", neu: ["h"],
+ woerter: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
+ { welt: 3, titel: "Übung: G und H", untertitel: "Die Mitte der Grundreihe", neu: [],
+ woerter: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
+ { welt: 3, titel: "Das C", untertitel: "Mittelfinger links nach unten", neu: ["c"],
+ woerter: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
+ { welt: 3, titel: "Das V", untertitel: "Zeigefinger links nach unten", neu: ["v"],
+ woerter: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
+ { welt: 3, titel: "Übung: C und V", untertitel: "Nach unten greifen", neu: [],
+ woerter: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
+ { welt: 3, titel: "Das B", untertitel: "Zeigefinger links, neben dem V", neu: ["b"],
+ woerter: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
+ { welt: 3, titel: "Das Y", untertitel: "Kleiner Finger links nach unten", neu: ["y"],
+ woerter: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
+ { welt: 3, titel: "Übung: B und Y", untertitel: "Ganz unten links", neu: [],
+ woerter: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
+ { welt: 3, titel: "Das X", untertitel: "Ringfinger links nach unten", neu: ["x"],
+ woerter: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
+ { welt: 3, titel: "Das Ä", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["ä"],
+ woerter: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] },
+ { welt: 3, titel: "Übung: alle Buchstaben", untertitel: "Das ganze Alphabet", neu: [],
+ woerter: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
+
+ // ---------------------------------------------------------------- Welt 4 --
+ { welt: 4, titel: "Umschalttaste rechts", untertitel: "Große Buchstaben der linken Hand", neu: ["⇧"],
+ woerter: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff",
+ "Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] },
+ { welt: 4, titel: "Umschalttaste links", untertitel: "Große Buchstaben der rechten Hand", neu: [],
+ woerter: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe",
+ "Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] },
+ { welt: 4, titel: "Übung: Namen", untertitel: "Namen fangen groß an", neu: [],
+ woerter: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa",
+ "Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] },
+ { welt: 4, titel: "Übung: große und kleine", untertitel: "Beides gemischt", neu: [],
+ woerter: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel",
+ "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] },
+
+ // ---------------------------------------------------------------- Welt 5 --
+ { welt: 5, titel: "Der Punkt", untertitel: "Ringfinger rechts nach unten", neu: ["."], art: "saetze",
+ woerter: [
+ "Das Meer ist tief.", "Der Hund bellt.", "Ich mag Kekse.", "Die Sonne scheint.",
+ "Der Wal ist riesig.", "Wir gehen baden.", "Mama liest ein Buch.", "Der Fisch schwimmt.",
+ "Heute ist es warm.", "Ich habe einen Ball.", "Die Welle ist hoch.", "Papa kocht Suppe.",
+ ] },
+ { welt: 5, titel: "Das Komma", untertitel: "Mittelfinger rechts nach unten", neu: [","], art: "saetze",
+ woerter: [
+ "Ich mag Wale, Delfine und Fische.", "Erst lesen, dann tippen.", "Es ist warm, also baden wir.",
+ "Rot, gelb und blau sind Farben.", "Wenn es regnet, bleiben wir drinnen.",
+ "Der Delfin springt, taucht und spielt.", "Morgen, sagt Papa, fahren wir los.",
+ "Eins, zwei, drei, vier.", "Oma, Opa und ich gehen schwimmen.",
+ "Die Sonne scheint, das Meer glitzert.", "Muscheln, Steine und Sand liegen am Strand.",
+ ] },
+ { welt: 5, titel: "Der Bindestrich", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["-"], art: "saetze",
+ woerter: [
+ "Wir spielen mit dem Wasser-Ball.", "Das ist ein Delfin-Baby.", "Meine Ur-Oma kommt heute.",
+ "Wir bauen eine Sand-Burg.", "Der Fisch-Schwarm ist riesig.", "Ich trage mein T-Shirt.",
+ "Das Schwimm-Bad ist offen.", "Die Bade-Hose ist nass.", "Wir essen ein Eis-Hörnchen.",
+ "Das Segel-Boot ist blau.", "Mein Lieblings-Tier ist der Delfin.",
+ ] },
+ { welt: 5, titel: "Fragezeichen und Ausrufezeichen", untertitel: "Mit der Umschalttaste", neu: ["ß", "1"], art: "saetze",
+ woerter: [
+ "Wo ist der Delfin?", "Das war toll!", "Wie geht es dir?", "Pass auf!",
+ "Kommst du mit?", "Der Wal ist so groß!", "Hast du Hunger?", "Hurra, Ferien!",
+ "Was schwimmt da?", "Schau mal, ein Hai!", "Wie tief ist das Meer?", "Wir haben es geschafft!",
+ ] },
+ { welt: 5, titel: "Übung: ganze Sätze", untertitel: "Alles zusammen", neu: [], art: "saetze",
+ woerter: [
+ "Der Delfin schwimmt sehr schnell.", "Wo ist mein Boot?", "Ich tippe jetzt mit zehn Fingern!",
+ "Das Meer ist blau, tief und kalt.", "Kannst du das auch?", "Wir bauen eine Sand-Burg am Strand.",
+ "Die Möwe fliegt über das Wasser.", "Oma, Opa und ich gehen schwimmen.", "Das ist ja super!",
+ "Wie heißt der große Wal?", "Im Riff wohnen bunte Fische.", "Der Krake hat acht Arme.",
+ ] },
+];
+
+function buildLessons(): Lesson[] {
+ const lessons: Lesson[] = [];
+ const active = new Set();
+
+ PLAN.forEach((entry, i) => {
+ for (const key of entry.neu) active.add(key);
+ // The Leertaste lesson is where the whole Grundstellung comes together, so it
+ // activates every home key - a belt-and-braces guarantee that the four finger-pair
+ // lessons before it really did cover all eight.
+ if (entry.neu.includes(LEERTASTE)) for (const key of GRUNDSTELLUNG) active.add(key);
+
+ // Shift is not a character the generator can emit - the capitals in the word list
+ // are what teaches it - so it never enters activeKeys. The Fragezeichen lesson
+ // reaches its marks with Shift too, so ß and 1 are active as *keys* even though the
+ // characters that appear are ? and !.
+ const activeKeys = [...active].filter((key) => key !== "⇧").sort();
+ const woerter = entry.woerter ?? [];
+ const art: LessonArt = entry.art ?? (woerter.length > 0 ? "woerter" : "buchstaben");
+
+ lessons.push({
+ id: `l${String(i + 1).padStart(2, "0")}`,
+ welt: entry.welt,
+ nummer: i + 1,
+ titel: entry.titel,
+ untertitel: entry.untertitel,
+ neueKeys: entry.neu,
+ activeKeys,
+ modi: art === "saetze" ? SATZ_MODI : art === "woerter" ? WORT_MODI : BUCHSTABEN_MODI,
+ woerter,
+ art,
+ istUebung: entry.neu.length === 0,
+ ...laengeFuer(entry.welt),
+ });
+ });
+
+ return lessons;
+}
+
+export const LESSONS: readonly Lesson[] = buildLessons();
+
+const BY_ID = new Map(LESSONS.map((lesson) => [lesson.id, lesson]));
+
+export function lessonById(id: string): Lesson | null {
+ return BY_ID.get(id) ?? null;
+}
+
+export function lessonsOfWelt(welt: number): readonly Lesson[] {
+ return LESSONS.filter((lesson) => lesson.welt === welt);
+}
+
+/** The lesson after this one, or null at the end of the curriculum. */
+export function nextLesson(id: string): Lesson | null {
+ const index = LESSONS.findIndex((lesson) => lesson.id === id);
+ if (index < 0) return null;
+ return LESSONS[index + 1] ?? null;
+}
+
+export const FIRST_LESSON_ID = LESSONS[0]!.id;
diff --git a/tippen/src/lib/engine.ts b/tippen/src/lib/engine.ts
new file mode 100644
index 0000000..12133e6
--- /dev/null
+++ b/tippen/src/lib/engine.ts
@@ -0,0 +1,124 @@
+/** The typing engine: one keystroke in, a new state and a list of events out.
+ *
+ * Kept pure, the way ../../../web/src/lib/keyboard.ts keeps the player's key map pure -
+ * so every rule below is testable without a DOM, and so the six game modes can all
+ * drive the same logic while differing only in how they *draw* the target.
+ *
+ * Three rules here are deliberate choices for a six-year-old rather than the obvious
+ * implementation, and each one is load-bearing:
+ *
+ * 1. A wrong key does not advance and does not insert. There is no backspace to
+ * manage and no corrupted line to read back; the right key still has to be found.
+ * 2. A wrong key counts once per position. Hammering the same wrong key five times in
+ * a moment of panic is one mistake, not five, so one bad second cannot wreck a run.
+ * 3. The clock starts on the first keystroke, not when the screen opens. Staring at
+ * the screen, getting distracted, or being called away mid-thought is free. */
+
+import { grade, type RunResult } from "./grading";
+
+export interface Stroke {
+ /** What was actually pressed, lowercased for letters. */
+ key: string;
+ /** What was wanted at that position. */
+ expected: string;
+ correct: boolean;
+ /** ms timestamp, from the same clock `press` is called with. */
+ at: number;
+}
+
+export interface RunState {
+ /** The full line being typed. */
+ target: string;
+ /** How far in we are - always an index into `target`, never past its length. */
+ index: number;
+ strokes: Stroke[];
+ /** Positions where at least one wrong key has already been counted. Rule 2. */
+ missed: ReadonlySet;
+ /** Consecutive correct keys, for the streak sound and the bubble chain. */
+ streak: number;
+ startedAt: number | null;
+ finishedAt: number | null;
+}
+
+export type RunEvent =
+ | { type: "correct"; key: string; index: number; streak: number }
+ | { type: "wrong"; key: string; expected: string; index: number; firstAt: boolean }
+ | { type: "finished"; result: RunResult };
+
+export function startRun(target: string): RunState {
+ return {
+ target,
+ index: 0,
+ strokes: [],
+ missed: new Set(),
+ streak: 0,
+ startedAt: null,
+ finishedAt: null,
+ };
+}
+
+/** Keys that are never typing input: pressing Shift to reach a capital must not count
+ * as a stroke of its own, and neither must a stray Alt or a browser shortcut's Meta. */
+const MODIFIERS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]);
+
+/** Is this a key the engine should look at at all? Anything longer than one code point
+ * is a named key ("Enter", "ArrowLeft", "F1") and belongs to the app, not the run.
+ * Backspace is swallowed on purpose: rule 1 means there is nothing to delete. */
+export function isTypingKey(key: string): boolean {
+ if (MODIFIERS.has(key)) return false;
+ return [...key].length === 1;
+}
+
+export function isFinished(state: RunState): boolean {
+ return state.finishedAt !== null;
+}
+
+export function currentChar(state: RunState): string | null {
+ return state.target[state.index] ?? null;
+}
+
+/** Apply one keystroke. Returns the state unchanged (and no events) for anything that
+ * is not typing input, or once the run is over, so the caller can stay dumb. */
+export function press(state: RunState, key: string, now: number): [RunState, RunEvent[]] {
+ if (isFinished(state) || !isTypingKey(key)) return [state, []];
+
+ const expected = state.target[state.index];
+ if (expected === undefined) return [state, []];
+
+ // The layout is what decides case, not the run: typing "A" where "a" is wanted is
+ // correct. Capitals are their own lesson (Welt 4), and that lesson's target text
+ // carries the capital, so this comparison still teaches Shift where it matters.
+ const correct = key.toLowerCase() === expected.toLowerCase();
+ const startedAt = state.startedAt ?? now;
+ const stroke: Stroke = { key, expected, correct, at: now };
+ const strokes = [...state.strokes, stroke];
+
+ if (!correct) {
+ const firstAt = !state.missed.has(state.index);
+ const missed = firstAt ? new Set(state.missed).add(state.index) : state.missed;
+ const next: RunState = { ...state, strokes, missed, streak: 0, startedAt };
+ return [next, [{ type: "wrong", key, expected, index: state.index, firstAt }]];
+ }
+
+ const index = state.index + 1;
+ const streak = state.streak + 1;
+ const done = index >= state.target.length;
+ const next: RunState = {
+ ...state,
+ index,
+ strokes,
+ streak,
+ startedAt,
+ finishedAt: done ? now : null,
+ };
+ const events: RunEvent[] = [{ type: "correct", key, index: state.index, streak }];
+ if (done) events.push({ type: "finished", result: grade(next) });
+ return [next, events];
+}
+
+/** Give up on the rest of the line - what Escape does. The run is still graded on what
+ * was typed, so a half-finished Blasenplatzen round still earns its Perlen. */
+export function abandonRun(state: RunState, now: number): RunState {
+ if (isFinished(state) || state.startedAt === null) return state;
+ return { ...state, finishedAt: now };
+}
diff --git a/tippen/src/lib/fingers.ts b/tippen/src/lib/fingers.ts
new file mode 100644
index 0000000..3e189dc
--- /dev/null
+++ b/tippen/src/lib/fingers.ts
@@ -0,0 +1,165 @@
+/** The German QWERTZ layout, as a finger map.
+ *
+ * This is the source of truth for three things that must never disagree: which finger
+ * the on-screen keyboard colours a key with, which hand the hint names, and which home
+ * key that finger returns to. Keeping them in one frozen table means a wrong finger
+ * assignment is one edit to fix, not three.
+ *
+ * Keys are stored lowercase and compared lowercase - `event.key` for a capital letter
+ * is "A", but it is still typed with the same finger as "a". */
+
+export type Hand = "links" | "rechts";
+
+/** Finger ids, left pinky through right pinky, thumbs last. The order matters: it is
+ * the left-to-right order the parent screen lists them in. */
+export type FingerId =
+ | "links-klein"
+ | "links-ring"
+ | "links-mitte"
+ | "links-zeige"
+ | "rechts-zeige"
+ | "rechts-mitte"
+ | "rechts-ring"
+ | "rechts-klein"
+ | "daumen";
+
+export interface Finger {
+ id: FingerId;
+ hand: Hand;
+ /** What a six-year-old is told out loud: "der kleine Finger links". */
+ label: string;
+ /** The key this finger rests on in the Grundstellung. */
+ home: string;
+ /** oklch hue for the keyboard overlay, so "der grüne Finger" is a thing you can say. */
+ hue: number;
+}
+
+export const FINGERS: Record = {
+ "links-klein": { id: "links-klein", hand: "links", label: "kleiner Finger links", home: "a", hue: 25 },
+ "links-ring": { id: "links-ring", hand: "links", label: "Ringfinger links", home: "s", hue: 70 },
+ "links-mitte": { id: "links-mitte", hand: "links", label: "Mittelfinger links", home: "d", hue: 140 },
+ "links-zeige": { id: "links-zeige", hand: "links", label: "Zeigefinger links", home: "f", hue: 195 },
+ "rechts-zeige": { id: "rechts-zeige", hand: "rechts", label: "Zeigefinger rechts", home: "j", hue: 250 },
+ "rechts-mitte": { id: "rechts-mitte", hand: "rechts", label: "Mittelfinger rechts", home: "k", hue: 290 },
+ "rechts-ring": { id: "rechts-ring", hand: "rechts", label: "Ringfinger rechts", home: "l", hue: 330 },
+ "rechts-klein": { id: "rechts-klein", hand: "rechts", label: "kleiner Finger rechts", home: "ö", hue: 10 },
+ daumen: { id: "daumen", hand: "rechts", label: "Daumen", home: " ", hue: 220 },
+};
+
+/** Which keys each finger owns, in the standard German assignment. The index fingers
+ * carry two columns each (their home column plus the stretch inward), which is why
+ * `links-zeige` has r/t and `rechts-zeige` has z/u. */
+const OWNED: Record = {
+ "links-klein": "^1qay<",
+ "links-ring": "2wsx",
+ "links-mitte": "3edc",
+ "links-zeige": "45rtfgvb",
+ "rechts-zeige": "67zuhjnm",
+ "rechts-mitte": "8ik,",
+ "rechts-ring": "9ol.",
+ "rechts-klein": "0ßpüöä-+#",
+ daumen: " ",
+};
+
+/** Which row a key sits in, for the on-screen keyboard's layout and for the lesson
+ * titles ("nach oben", "nach unten"). */
+export type RowId = "zahlen" | "oben" | "grund" | "unten" | "leer";
+
+const ROWS: Record = {
+ zahlen: "^1234567890ß",
+ oben: "qwertzuiopü+",
+ grund: "asdfghjklöä#",
+ unten: "();
+for (const [finger, keys] of Object.entries(OWNED) as [FingerId, string][]) {
+ for (const key of keys) KEY_TO_FINGER.set(key, finger);
+}
+
+const KEY_TO_ROW = new Map();
+for (const [row, keys] of Object.entries(ROWS) as [RowId, string][]) {
+ for (const key of keys) KEY_TO_ROW.set(key, row);
+}
+
+/** The four rows as the on-screen keyboard draws them, top to bottom. */
+export const KEYBOARD_ROWS: readonly RowId[] = ["oben", "grund", "unten"];
+
+export function keysInRow(row: RowId): readonly string[] {
+ return [...(ROWS[row] ?? "")];
+}
+
+/** Characters that need Shift on a German layout, mapped to the physical key that
+ * carries them. Only the ones this course teaches; the rest of the number row can be
+ * added when Welt 6 exists. */
+const SHIFTED: Record = {
+ "!": "1",
+ '"': "2",
+ "§": "3",
+ $: "4",
+ "%": "5",
+ "&": "6",
+ "/": "7",
+ "(": "8",
+ ")": "9",
+ "=": "0",
+ "?": "ß",
+ "*": "+",
+ ";": ",",
+ ":": ".",
+ _: "-",
+ "'": "#",
+ ">": "<",
+};
+
+/** The physical key that produces `char`.
+ *
+ * A capital is its own lowercase key plus Shift, and "?" is the ß key plus Shift. The
+ * distinction matters in three places: which key the on-screen keyboard lights up,
+ * which finger the hint names, and whether a lesson can actually type a word. Without
+ * it, "Wo ist der Delfin?" looks untypable and lights up nothing. */
+export function keyForChar(char: string): string {
+ const klein = char.toLowerCase();
+ if (klein !== char) return klein;
+ return SHIFTED[char] ?? char;
+}
+
+/** Whether reaching `char` needs a Shift held. */
+export function needsShift(char: string): boolean {
+ return char.toLowerCase() !== char || char in SHIFTED;
+}
+
+/** The finger that types `key`, or `null` for anything off the layout (Enter, F1, …).
+ * Takes either a physical key or a character it produces - "A", "a" and "?" all
+ * resolve. */
+export function fingerOf(key: string): Finger | null {
+ const id = KEY_TO_FINGER.get(keyForChar(key));
+ return id ? FINGERS[id] : null;
+}
+
+export function handOf(key: string): Hand | null {
+ return fingerOf(key)?.hand ?? null;
+}
+
+export function rowOf(key: string): RowId | null {
+ return KEY_TO_ROW.get(keyForChar(key)) ?? null;
+}
+
+/** The home key the typing finger came from - what the hint shows as "zurück nach …". */
+export function homeKeyOf(key: string): string | null {
+ return fingerOf(key)?.home ?? null;
+}
+
+/** The Grundstellung itself, left to right. `LEERTASTE` is separate because the thumb
+ * is the one finger that does not rest on a letter. */
+export const GRUNDSTELLUNG = ["a", "s", "d", "f", "j", "k", "l", "ö"] as const;
+export const LEERTASTE = " ";
+
+/** A shifted character is typed with the Shift on the *opposite* hand - the single rule
+ * that separates real touch typing from hunt-and-peck with a pinky cramp. */
+export function shiftHandFor(key: string): Hand | null {
+ const hand = handOf(key);
+ if (hand === null) return null;
+ return hand === "links" ? "rechts" : "links";
+}
diff --git a/tippen/src/lib/generator.ts b/tippen/src/lib/generator.ts
new file mode 100644
index 0000000..175cbc3
--- /dev/null
+++ b/tippen/src/lib/generator.ts
@@ -0,0 +1,234 @@
+/** What the child actually types: drill lines built from a lesson's active keys.
+ *
+ * Seeded throughout (`mulberry32`), so a line is reproducible - which is what makes it
+ * testable, and what lets the Delfinrennen replay a ghost against the identical text.
+ *
+ * Two ideas borrowed from keybr, simplified to what a six-year-old needs:
+ *
+ * - a *focus key* gets roughly double its natural share of the line, so the letter she
+ * is slowest on is the letter she sees most;
+ * - real words beat pseudo-words for motivation, so as soon as a lesson's active keys
+ * can spell something real, the curated `woerter` list is preferred and `fjfj dkdk`
+ * stops appearing.
+ *
+ * Chunks, not one long string: the line is returned as short groups, because four
+ * letters with a gap after them is something a six-year-old can find her place in and
+ * twenty-four letters in a row is not. Lessons 1 and 2 have no Leertaste yet, so the
+ * gap there is visual only - `lineText` joins without one and she never has to type a
+ * space she has not been taught. */
+
+/** A small, fast, seedable PRNG. Identical seed, identical line. */
+export function mulberry32(seed: number): () => number {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export type Rng = () => number;
+
+export interface LineOptions {
+ /** How many chunks the line has. */
+ chunks?: number;
+ /** Letters per chunk. */
+ chunkSize?: number;
+ /** The key to over-represent, if any. */
+ focusKey?: string | null;
+ /** The keys this lesson introduces. They take the majority of the line; everything
+ * learned earlier keeps appearing as review. */
+ newKeys?: readonly string[];
+}
+
+/** The most of a line one key may ever occupy. Above this it stops being practice and
+ * starts being a stutter - and on a small key set it starves the other fingers. */
+const MAX_FOKUS_ANTEIL = 0.3;
+
+/** How much of a line the lesson's *new* keys should take. The rest is review of
+ * everything learned so far, which is what stops the early lessons rotting while the
+ * late ones are learned.
+ *
+ * Without this, Lektion 2 ("Die rechte Hand") drew evenly from all eight home keys and
+ * spent half the line on the left hand it had already taught - which is not what a
+ * lesson called "die rechte Hand" should drill. */
+const NEU_ANTEIL = 0.6;
+
+/** Copies of each new key needed to reach `NEU_ANTEIL` of the pool, clamped so a lesson
+ * with one new key and many old ones does not bury the review entirely. */
+function neuKopien(neu: number, alt: number): number {
+ if (neu === 0 || alt === 0) return 1;
+ const exakt = (NEU_ANTEIL * alt) / (neu * (1 - NEU_ANTEIL));
+ return Math.max(1, Math.min(6, Math.round(exakt)));
+}
+
+/** How many extra copies of the focus key to add to a pool of `n` letters without its
+ * share passing `MAX_FOKUS_ANTEIL`. Small sets get no boost at all: with four active
+ * keys every one of them is already drilled constantly. */
+function fokusKopien(n: number): number {
+ let kopien = 0;
+ while (kopien < 2 && (1 + kopien + 1) / (n + kopien + 1) <= MAX_FOKUS_ANTEIL) kopien++;
+ return kopien;
+}
+
+/** Build a weighted alphabet: every active key at least once, the lesson's new keys
+ * several times, plus a few extra copies of the focus key. Drawing from this is the
+ * whole weighting mechanic - no rejection sampling, and no chance of an endless loop
+ * when the focus key is the only active key. */
+function weighted(
+ activeKeys: readonly string[],
+ focusKey: string | null | undefined,
+ newKeys: readonly string[] = [],
+): string[] {
+ const letters = activeKeys.filter((key) => key !== " ");
+ if (letters.length === 0) return [];
+
+ // Shift and the Leertaste are taught by the target text, not by the letter pool.
+ const neu = newKeys.filter((key) => letters.includes(key));
+ const alt = letters.filter((key) => !neu.includes(key));
+
+ const pool = [...letters];
+ if (neu.length > 0 && alt.length > 0) {
+ const kopien = neuKopien(neu.length, alt.length);
+ for (const key of neu) for (let i = 1; i < kopien; i++) pool.push(key);
+ }
+
+ if (focusKey && letters.includes(focusKey)) {
+ for (let i = 0; i < fokusKopien(letters.length); i++) pool.push(focusKey);
+ }
+ return pool;
+}
+
+/** Draw from a shuffled bag rather than sampling independently.
+ *
+ * Independent sampling is lumpy over the length of one line, and lumpy is not a
+ * cosmetic problem here: a real generated Lektion-1 line came out as
+ * `saadaaafasaassfssffsafsf` - `d` once in twenty-four characters, so the middle finger
+ * got one repetition while the little finger got nine. Averaged over a hundred lines
+ * that is fine; the child types one line.
+ *
+ * A bag fixes it by construction. Every key is drawn once before any key is drawn
+ * twice, so every finger gets its turn within each pass, and the order inside a pass is
+ * still random. Refilling on empty keeps it going for as long as the line needs. */
+function bagDraw(pool: readonly string[], count: number, rng: Rng): string[] {
+ const out: string[] = [];
+ let bag: string[] = [];
+
+ for (let i = 0; i < count; i++) {
+ if (bag.length === 0) {
+ bag = [...pool];
+ // Fisher-Yates, so every ordering of the bag is equally likely.
+ for (let j = bag.length - 1; j > 0; j--) {
+ const k = Math.floor(rng() * (j + 1));
+ [bag[j], bag[k]] = [bag[k]!, bag[j]!];
+ }
+ }
+ out.push(bag.pop()!);
+ }
+ return out;
+}
+
+/** A line of pseudo-word chunks over the lesson's active keys. Used by every lesson,
+ * and the only option for Welt 1 where nothing real can be spelled yet. */
+export function drillChunks(
+ activeKeys: readonly string[],
+ rng: Rng,
+ options: LineOptions = {},
+): string[] {
+ const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [] } = options;
+ const pool = weighted(activeKeys, focusKey, newKeys);
+ if (pool.length === 0) return [];
+
+ const letters = bagDraw(pool, chunks * chunkSize, rng);
+ const out: string[] = [];
+ for (let i = 0; i < chunks; i++) {
+ out.push(letters.slice(i * chunkSize, (i + 1) * chunkSize).join(""));
+ }
+ return out;
+}
+
+/** A line of real German words (or sentences), or `null` when the lesson has none yet.
+ *
+ * Drawn from the same shuffled bag as the letters. Once a round became twenty-five
+ * words long, independent picks from an eight-word list started clumping: a real Welt 5
+ * round came out with "Das Meer ist blau, tief und kalt." four times in ten sentences.
+ * The bag uses every word once before any word twice, so repeats are as far apart as the
+ * list allows. */
+export function wordChunks(
+ woerter: readonly string[],
+ rng: Rng,
+ options: LineOptions = {},
+): string[] | null {
+ if (woerter.length === 0) return null;
+ const { chunks = 5, focusKey = null } = options;
+
+ // Words containing the focus key go in twice, same trick as the letter pool.
+ const pool = [...woerter];
+ if (focusKey) {
+ for (const wort of woerter) {
+ if (wort.toLowerCase().includes(focusKey.toLowerCase())) pool.push(wort);
+ }
+ }
+
+ const out = bagDraw(pool, chunks, rng);
+ // A bag refill can put the last word of one pass straight after itself. Swap it with a
+ // later word when there is one - but never loop, since a one-word list must still work.
+ for (let i = 1; i < out.length; i++) {
+ if (out[i] !== out[i - 1]) continue;
+ const tausch = out.findIndex((wort, j) => j > i && wort !== out[i]);
+ if (tausch > 0) [out[i], out[tausch]] = [out[tausch]!, out[i]!];
+ }
+ return out;
+}
+
+/** The line a lesson should show, given what it can spell. Word lessons alternate:
+ * `preferWords` lets a mode ask for letters even in a late lesson (Quallenalarm is
+ * always single letters) or for words wherever they exist (Fütterungszeit). */
+export function lineFor(
+ lesson: { activeKeys: readonly string[]; woerter: readonly string[]; neueKeys?: readonly string[] },
+ rng: Rng,
+ options: LineOptions & { preferWords?: boolean } = {},
+): string[] {
+ const { preferWords = true, ...rest } = options;
+ const withNew = { newKeys: lesson.neueKeys ?? [], ...rest };
+ if (preferWords) {
+ const words = wordChunks(lesson.woerter, rng, withNew);
+ if (words) return words;
+ }
+ return drillChunks(lesson.activeKeys, rng, withNew);
+}
+
+/** Join chunks into the string the engine types against. With the Leertaste active the
+ * gaps are real spaces she must type; before that they are purely visual and the text
+ * runs together. */
+export function lineText(chunks: readonly string[], spaceActive: boolean): string {
+ return chunks.join(spaceActive ? " " : "");
+}
+
+/** Where each chunk starts in `lineText(chunks, spaceActive)` - what the Target
+ * component needs to draw the gaps in the right places. */
+export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): number[] {
+ const offsets: number[] = [];
+ let at = 0;
+ for (const chunk of chunks) {
+ offsets.push(at);
+ at += chunk.length + (spaceActive ? 1 : 0);
+ }
+ return offsets;
+}
+
+/** Single letters for Blasenplatzen and Quallenalarm: one key per bubble, drawn from the
+ * same bag, so the arcade modes drill the same spread as the Tauchgang. */
+export function letterStream(
+ activeKeys: readonly string[],
+ rng: Rng,
+ count: number,
+ focusKey?: string | null,
+ newKeys: readonly string[] = [],
+): string[] {
+ const pool = weighted(activeKeys, focusKey, newKeys);
+ if (pool.length === 0) return [];
+ return bagDraw(pool, count, rng);
+}
diff --git a/tippen/src/lib/grading.ts b/tippen/src/lib/grading.ts
new file mode 100644
index 0000000..34d2343
--- /dev/null
+++ b/tippen/src/lib/grading.ts
@@ -0,0 +1,210 @@
+/** Turning a run into a number, a star count and an animal.
+ *
+ * Two decisions here are the whole pedagogy of the game, so they are worth stating:
+ *
+ * *Zeichen pro Minute, not Wörter pro Minute.* German words are long and a six-year-old
+ * types around four words a minute honestly measured. "4" on a results screen reads as
+ * failure; "38 Zeichen pro Minute" reads as a number that visibly grows. Same data,
+ * different message.
+ *
+ * *punkte = tempo × genauigkeit³, not the textbook Netto-WPM.* The standard formula is
+ * `netto = brutto − fehler/minute`, which goes negative for a beginner - the one result
+ * that must never appear. The multiplicative form cannot: 95% accuracy keeps 86% of the
+ * speed, 90% keeps 73%, 80% keeps 51%. Careful-and-slow beats fast-and-sloppy, which is
+ * the habit worth building at this age.
+ *
+ * Crucially, speed only ever buys the *animal*. The unlock gate below is accuracy-only
+ * (see `isPassed`), so a slow, careful child still reaches the end of the curriculum. */
+
+import type { RunState, Stroke } from "./engine";
+
+export interface RunResult {
+ /** Correct keystrokes. */
+ zeichen: number;
+ /** Wrong keystrokes, counted once per position (see engine.ts rule 2). */
+ fehler: number;
+ /** ms from the first keystroke to the last. */
+ dauer: number;
+ /** Zeichen pro Minute. */
+ tempo: number;
+ /** 0..1 */
+ genauigkeit: number;
+ /** `tempo × genauigkeit³`, the number the animal is read off. */
+ punkte: number;
+ sterne: 0 | 1 | 2 | 3;
+ tier: TierId;
+ /** Whether this run unlocks the next lesson on its own. */
+ bestanden: boolean;
+ /** Perlen earned - the aquarium currency. */
+ perlen: number;
+ /** Kept for the Delfinrennen ghost and the per-key stats. */
+ strokes: readonly Stroke[];
+}
+
+export type TierId =
+ | "schnecke"
+ | "krabbe"
+ | "schildkroete"
+ | "qualle"
+ | "fisch"
+ | "pinguin"
+ | "robbe"
+ | "delfin"
+ | "hai"
+ | "schwertwal";
+
+export interface Tier {
+ id: TierId;
+ name: string;
+ emoji: string;
+ /** Lower bound in Punkte (Zeichen pro Minute, accuracy-weighted). */
+ ab: number;
+ /** What the Sprachausgabe says when this tier is reached. */
+ lob: string;
+}
+
+/** Nine Meerestiere, slowest first. Thresholds are calibrated against the "5 WPM pro
+ * Klassenstufe" school rule - roughly 25 Zeichen/min at the end of first grade - with
+ * plenty of headroom above it.
+ *
+ * The Delfin sits deliberately high, at about 24 WPM, one below the top. It is a
+ * realistic second- or third-grade target, which means it stays out of reach and
+ * therefore worth chasing for a long time. Reaching it should feel like an event.
+ *
+ * Every emoji here is checked to render as the animal it names - the first draft used
+ * 🗡️ for a sailfish (it is a dagger) and 🎐 for a jellyfish (it is a wind chime), and
+ * ⭐ for a starfish, which collided with the star rating three lines below it. */
+export const TIERS: readonly Tier[] = [
+ { id: "schnecke", name: "Meeresschnecke", emoji: "🐌", ab: 0, lob: "Die Schnecke ist losgekrochen!" },
+ { id: "krabbe", name: "Krabbe", emoji: "🦀", ab: 15, lob: "Eine Krabbe! Die krabbelt schon los." },
+ { id: "schildkroete", name: "Schildkröte", emoji: "🐢", ab: 25, lob: "Die Schildkröte ist stetig und sicher." },
+ { id: "qualle", name: "Qualle", emoji: "🪼", ab: 40, lob: "Die Qualle gleitet dahin!" },
+ { id: "fisch", name: "Fisch", emoji: "🐟", ab: 55, lob: "Ein Fisch! Der schwimmt richtig flott." },
+ { id: "pinguin", name: "Pinguin", emoji: "🐧", ab: 75, lob: "Ein Pinguin! Der flitzt durchs Wasser." },
+ { id: "robbe", name: "Robbe", emoji: "🦭", ab: 95, lob: "Die Robbe ist schnell und geschickt!" },
+ { id: "delfin", name: "Delfin", emoji: "🐬", ab: 120, lob: "Ein Delfin! Das ist richtig, richtig schnell." },
+ { id: "hai", name: "Hai", emoji: "🦈", ab: 150, lob: "Ein Hai! Unglaublich schnell." },
+ { id: "schwertwal", name: "Schwertwal", emoji: "🐋", ab: 190, lob: "Ein Schwertwal! Schneller wird es im Meer nicht." },
+];
+
+/** The last animal shown on the ladder before a run has been fast enough to earn it.
+ * Everything above the Delfin stays hidden until it is actually reached - see
+ * `sichtbareTiers`. */
+export const UEBERRASCHUNG_AB: TierId = "delfin";
+
+export function tierIndex(id: TierId): number {
+ return TIERS.findIndex((tier) => tier.id === id);
+}
+
+/** Which slice of the ladder a result screen may show.
+ *
+ * Everything up to the Delfin is always visible, earned or not: seeing the animals you
+ * have not reached yet is the whole reason to try again, and a six-year-old needs the
+ * next rung to be visible to aim at it. Above the Delfin the ladder goes dark - those
+ * are a surprise, revealed only once they have actually been reached, and then they stay
+ * revealed. `bestEver` is the fastest animal earned on any lesson so far. */
+export function sichtbareTiers(earned: TierId, bestEver: TierId | null): {
+ tiers: readonly Tier[];
+ /** True when faster animals exist that have not been revealed yet. */
+ mehrVerborgen: boolean;
+} {
+ const grenze = Math.max(
+ tierIndex(UEBERRASCHUNG_AB),
+ tierIndex(earned),
+ bestEver ? tierIndex(bestEver) : -1,
+ );
+ return {
+ tiers: TIERS.slice(0, grenze + 1),
+ mehrVerborgen: grenze < TIERS.length - 1,
+ };
+}
+
+const TIER_BY_ID = new Map(TIERS.map((tier) => [tier.id, tier]));
+
+export function tierById(id: TierId): Tier {
+ // Every TierId comes from TIERS itself, so this cannot miss - but the map lookup is
+ // typed as possibly-undefined and `noUncheckedIndexedAccess` is on.
+ return TIER_BY_ID.get(id) ?? TIERS[0]!;
+}
+
+/** The animal for a score. Walks from the fastest down, so the first match wins. */
+export function tierFor(punkte: number): Tier {
+ for (let i = TIERS.length - 1; i >= 0; i--) {
+ const tier = TIERS[i]!;
+ if (punkte >= tier.ab) return tier;
+ }
+ return TIERS[0]!;
+}
+
+/** How far along the current tier this score is, 0..1 - drives the progress bar that
+ * shows how close the next animal is. The top tier is always full. */
+export function tierProgress(punkte: number): number {
+ const index = TIERS.findIndex((tier) => tier.id === tierFor(punkte).id);
+ const next = TIERS[index + 1];
+ if (!next) return 1;
+ const floor = TIERS[index]!.ab;
+ return Math.min(1, Math.max(0, (punkte - floor) / (next.ab - floor)));
+}
+
+/** Star thresholds, on accuracy alone. The familiar 1/2/3 pattern from every other
+ * game she will ever play, so it needs no explaining. */
+export const STERNE_SCHWELLEN = { eins: 0.85, zwei: 0.93, drei: 0.97 } as const;
+
+export function sterneFor(genauigkeit: number): 0 | 1 | 2 | 3 {
+ if (genauigkeit >= STERNE_SCHWELLEN.drei) return 3;
+ if (genauigkeit >= STERNE_SCHWELLEN.zwei) return 2;
+ if (genauigkeit >= STERNE_SCHWELLEN.eins) return 1;
+ return 0;
+}
+
+/** Two stars unlocks the next lesson. No speed condition anywhere - that is the point. */
+export function isPassed(genauigkeit: number): boolean {
+ return genauigkeit >= STERNE_SCHWELLEN.zwei;
+}
+
+/** One Perle per five correct keys, plus a bonus per star. Small numbers that go up
+ * every single run, including a bad one - the aquarium should never stall. */
+function perlenFor(zeichen: number, sterne: number): number {
+ return Math.floor(zeichen / 5) + sterne * 2;
+}
+
+export function grade(state: RunState): RunResult {
+ const zeichen = state.strokes.filter((stroke) => stroke.correct).length;
+ const fehler = state.missed.size;
+ const start = state.startedAt;
+ const end = state.finishedAt ?? state.strokes.at(-1)?.at ?? start;
+
+ // A run of one keystroke has no elapsed time between first and last. Treating that as
+ // "infinitely fast" would hand out a Segelfisch for a single letter, so anything under
+ // a second of real typing is scored as if it took a second.
+ const dauer = start !== null && end !== null ? Math.max(0, end - start) : 0;
+ const minuten = Math.max(dauer, 1000) / 60000;
+
+ const tempo = zeichen / minuten;
+ const versuche = zeichen + fehler;
+ const genauigkeit = versuche === 0 ? 0 : zeichen / versuche;
+ const punkte = tempo * genauigkeit ** 3;
+ const sterne = sterneFor(genauigkeit);
+
+ return {
+ zeichen,
+ fehler,
+ dauer,
+ tempo,
+ genauigkeit,
+ punkte,
+ sterne,
+ tier: tierFor(punkte).id,
+ bestanden: isPassed(genauigkeit),
+ perlen: perlenFor(zeichen, sterne),
+ strokes: state.strokes,
+ };
+}
+
+/** Which of two results is the better one, for the per-lesson personal best. Stars come
+ * first, Punkte break the tie - so a careful run is never displaced by a sloppy fast
+ * one, and the best animal on a lesson card can never go down. */
+export function isBetter(candidate: RunResult, best: { sterne: number; punkte: number }): boolean {
+ if (candidate.sterne !== best.sterne) return candidate.sterne > best.sterne;
+ return candidate.punkte > best.punkte;
+}
diff --git a/tippen/src/lib/pop.ts b/tippen/src/lib/pop.ts
new file mode 100644
index 0000000..52b19ec
--- /dev/null
+++ b/tippen/src/lib/pop.ts
@@ -0,0 +1,56 @@
+/** Sound feedback, ported verbatim in spirit from ../../web/src/lib/pop.ts - the same
+ * short rising blip the music player uses, so the two apps sound like one family.
+ *
+ * The error sound is the one addition, and it is deliberately not a buzzer: a low, soft,
+ * *falling* blip at a fraction of the volume. A six-year-old who is told "wrong!" twenty
+ * times a minute stops playing. One that hears a quiet "bloop" just tries the next key. */
+
+let context: AudioContext | null = null;
+
+function blip(from: number, to: number, gainStart: number, dauer: number): void {
+ try {
+ context ??= new AudioContext();
+ const now = context.currentTime;
+ const oscillator = context.createOscillator();
+ const gain = context.createGain();
+ oscillator.type = "sine";
+ oscillator.frequency.setValueAtTime(from, now);
+ oscillator.frequency.exponentialRampToValueAtTime(to, now + dauer * 0.55);
+ gain.gain.setValueAtTime(gainStart, now);
+ gain.gain.exponentialRampToValueAtTime(0.001, now + dauer);
+ oscillator.connect(gain).connect(context.destination);
+ oscillator.start();
+ oscillator.stop(now + dauer + 0.01);
+ } catch {
+ // No audio context before the first user gesture, and on some browsers never.
+ }
+}
+
+/** The music player's interaction blip: rising, bright. */
+export function playPop(frequency: number): void {
+ blip(frequency, frequency * 1.8, 0.15, 0.15);
+}
+
+/** A correct key. The pitch climbs with the streak, so a good run audibly builds - it
+ * caps at an octave up, past which it just sounds shrill. */
+export function playRichtig(streak: number): void {
+ const halbtoene = Math.min(streak, 12);
+ blip(440 * 2 ** (halbtoene / 12), 660 * 2 ** (halbtoene / 12), 0.1, 0.09);
+}
+
+/** A wrong key: low, falling, quiet. */
+export function playDaneben(): void {
+ blip(200, 150, 0.06, 0.12);
+}
+
+/** Finishing a line. */
+export function playFertig(): void {
+ blip(520, 900, 0.16, 0.4);
+}
+
+/** A new lesson, a new animal, a new creature in the aquarium. */
+export function playFanfare(): void {
+ [523, 659, 784, 1047].forEach((frequency, i) => {
+ window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
+ });
+}
diff --git a/tippen/src/lib/progress.ts b/tippen/src/lib/progress.ts
new file mode 100644
index 0000000..2d92236
--- /dev/null
+++ b/tippen/src/lib/progress.ts
@@ -0,0 +1,322 @@
+/** Everything the game remembers, in one localStorage blob.
+ *
+ * The music player deliberately keeps no client state - what is playing lives on the
+ * backend. This app has no backend at all, so localStorage is the whole store. One key,
+ * one versioned object, and a `migrate` on read: when the shape changes later, old
+ * saves upgrade instead of a six-year-old losing her aquarium.
+ *
+ * Every read and write is wrapped. A private window, a browser with site data blocked,
+ * a quota that is full - none of those may stop the game from being playable. They just
+ * make it forgetful. */
+
+import { kreaturAus } from "./aquarium";
+import type { KreaturId } from "./aquarium";
+import { FIRST_LESSON_ID, LESSONS, WELTEN, nextLesson } from "./curriculum";
+import type { RunResult, TierId } from "./grading";
+import { isBetter } from "./grading";
+
+const STORAGE_KEY = "delfin-tippen:v1";
+
+/** How many attempts at one lesson unlock the next regardless of score. The safety
+ * valve against getting stuck on a single stubborn key - which at six is the difference
+ * between a game she returns to and one she does not. */
+export const FLEISS_VERSUCHE = 5;
+
+export interface LessonProgress {
+ unlocked: boolean;
+ runs: number;
+ bestSterne: 0 | 1 | 2 | 3;
+ bestTier: TierId | null;
+ bestPunkte: number;
+ /** Best-run keystrokes, replayed as the opponent in Delfinrennen. */
+ ghost: { key: string; at: number }[] | null;
+}
+
+export interface KeyStat {
+ /** Smoothed reaction time in ms - keybr's exponential moving average. Drives the
+ * focus key and how fast the on-screen keyboard hint fades out. */
+ ema: number;
+ attempts: number;
+ errors: number;
+}
+
+export interface Settings {
+ speech: boolean;
+ sound: boolean;
+ keyboardHint: "auto" | "on" | "off";
+}
+
+export interface Progress {
+ version: 1;
+ lessons: Record;
+ keyStats: Record;
+ perlen: number;
+ /** Pets that have moved into the aquarium, in the order they arrived. */
+ aquarium: KreaturId[];
+ streak: { days: number; lastPlayed: string | null };
+ settings: Settings;
+}
+
+function emptyLesson(unlocked: boolean): LessonProgress {
+ return { unlocked, runs: 0, bestSterne: 0, bestTier: null, bestPunkte: 0, ghost: null };
+}
+
+export function freshProgress(): Progress {
+ const lessons: Record = {};
+ for (const lesson of LESSONS) {
+ lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID);
+ }
+ return {
+ version: 1,
+ lessons,
+ keyStats: {},
+ perlen: 0,
+ aquarium: [],
+ streak: { days: 0, lastPlayed: null },
+ settings: { speech: true, sound: true, keyboardHint: "auto" },
+ };
+}
+
+/** Bring any stored value up to the current shape. Anything unrecognisable is thrown
+ * away rather than trusted - a half-valid Progress would crash the lesson map, and a
+ * fresh one merely means starting over. */
+export function migrate(raw: unknown): Progress {
+ const fresh = freshProgress();
+ if (typeof raw !== "object" || raw === null) return fresh;
+ const stored = raw as Partial;
+ if (stored.version !== 1) return fresh;
+
+ const lessons = { ...fresh.lessons };
+ if (typeof stored.lessons === "object" && stored.lessons !== null) {
+ for (const [id, value] of Object.entries(stored.lessons)) {
+ // Lessons that no longer exist in the curriculum are dropped silently.
+ if (!(id in lessons) || typeof value !== "object" || value === null) continue;
+ lessons[id] = { ...emptyLesson(false), ...value };
+ }
+ }
+ // The first lesson is unlocked by definition; a save that says otherwise is wrong.
+ lessons[FIRST_LESSON_ID] = { ...lessons[FIRST_LESSON_ID]!, unlocked: true };
+
+ return {
+ version: 1,
+ lessons,
+ keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {},
+ perlen: typeof stored.perlen === "number" ? stored.perlen : 0,
+ aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
+ streak:
+ typeof stored.streak === "object" && stored.streak !== null
+ ? { days: stored.streak.days ?? 0, lastPlayed: stored.streak.lastPlayed ?? null }
+ : fresh.streak,
+ settings: { ...fresh.settings, ...(stored.settings ?? {}) },
+ };
+}
+
+/** Saves from before the pets were drawings hold emoji here. Those map onto the creature
+ * that fills the same Welt today; duplicates and anything unrecognisable are dropped, so
+ * one stray entry cannot put a broken image in the tank. */
+function migrateAquarium(raw: readonly unknown[]): KreaturId[] {
+ const tiere: KreaturId[] = [];
+ for (const eintrag of raw) {
+ const id = kreaturAus(eintrag);
+ if (id && !tiere.includes(id)) tiere.push(id);
+ }
+ return tiere;
+}
+
+export function loadProgress(): Progress {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (raw === null) return freshProgress();
+ return migrate(JSON.parse(raw));
+ } catch {
+ // Unparseable, unreadable, or no storage at all. Play on without memory.
+ return freshProgress();
+ }
+}
+
+export function saveProgress(progress: Progress): void {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
+ } catch {
+ // Quota or a blocked store. Nothing to do and nothing worth telling a child.
+ }
+}
+
+/** Today as YYYY-MM-DD in local time - `toISOString` would roll over at the wrong hour
+ * and break the streak for anyone playing in the evening. */
+export function today(now: Date = new Date()): string {
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+}
+
+function bumpStreak(streak: Progress["streak"], day: string): Progress["streak"] {
+ if (streak.lastPlayed === day) return streak;
+ const yesterday = new Date(`${day}T12:00:00`);
+ yesterday.setDate(yesterday.getDate() - 1);
+ const consecutive = streak.lastPlayed === today(yesterday);
+ // A missed day restarts at 1, never at 0 - playing today always counts for something.
+ return { days: consecutive ? streak.days + 1 : 1, lastPlayed: day };
+}
+
+/** Fold the per-key reaction times of a run into the stored averages. The EMA weight of
+ * 0.3 is slow enough that one distracted run does not rewrite what she knows. */
+function foldKeyStats(stats: Record, result: RunResult): Record {
+ const next = { ...stats };
+ let previousAt: number | null = null;
+
+ for (const stroke of result.strokes) {
+ const key = stroke.expected.toLowerCase();
+ const current = next[key] ?? { ema: 0, attempts: 0, errors: 0 };
+ const gap = previousAt === null ? null : stroke.at - previousAt;
+ previousAt = stroke.at;
+
+ next[key] = {
+ // Reaction times over two seconds are a pause for thought or an interruption,
+ // not a measure of the key, so they are ignored rather than averaged in.
+ ema: gap !== null && gap < 2000 ? (current.ema === 0 ? gap : current.ema * 0.7 + gap * 0.3) : current.ema,
+ attempts: current.attempts + 1,
+ errors: current.errors + (stroke.correct ? 0 : 1),
+ };
+ }
+ return next;
+}
+
+/** Welt number to the creature it releases, read straight off the Welten table so it
+ * cannot drift from curriculum.ts. */
+const WELT_BELOHNUNG = new Map(WELTEN.map((welt) => [welt.nummer, welt.belohnung]));
+
+export interface RecordOutcome {
+ progress: Progress;
+ /** Set when this run unlocked the following lesson, for the celebration. */
+ unlockedLessonId: string | null;
+ /** Set when this run finished a Welt, for the creature that moved in. */
+ neuesTier: KreaturId | null;
+ bestseit: boolean;
+}
+
+/** Record a finished run: stars, animal, Perlen, key stats, streak, and the unlock.
+ *
+ * The unlock rule, in one place: two stars unlocks the next lesson, and so does the
+ * fifth attempt whatever the score. Speed is nowhere in it. */
+export function recordRun(
+ progress: Progress,
+ lessonId: string,
+ result: RunResult,
+ day: string = today(),
+): RecordOutcome {
+ const before = progress.lessons[lessonId] ?? emptyLesson(true);
+ const runs = before.runs + 1;
+
+ const improved = isBetter(result, { sterne: before.bestSterne, punkte: before.bestPunkte });
+ const lessons = { ...progress.lessons };
+ lessons[lessonId] = {
+ ...before,
+ runs,
+ bestSterne: improved ? result.sterne : before.bestSterne,
+ bestTier: improved ? result.tier : before.bestTier,
+ bestPunkte: improved ? result.punkte : before.bestPunkte,
+ ghost: improved
+ ? result.strokes.filter((s) => s.correct).map((s) => ({ key: s.key, at: s.at }))
+ : before.ghost,
+ };
+
+ const verdient = result.bestanden || runs >= FLEISS_VERSUCHE;
+ const folgend = nextLesson(lessonId);
+ let unlockedLessonId: string | null = null;
+ if (verdient && folgend && !lessons[folgend.id]?.unlocked) {
+ lessons[folgend.id] = { ...(lessons[folgend.id] ?? emptyLesson(false)), unlocked: true };
+ unlockedLessonId = folgend.id;
+ }
+
+ // Finishing the last lesson of a Welt releases that Welt's creature. Checked against
+ // the aquarium so it is only ever awarded once.
+ const aquarium = [...progress.aquarium];
+ let neuesTier: KreaturId | null = null;
+ if (unlockedLessonId && folgend) {
+ const beendet = LESSONS.find((l) => l.id === lessonId);
+ if (beendet && folgend.welt !== beendet.welt) {
+ const belohnung = WELT_BELOHNUNG.get(beendet.welt);
+ if (belohnung && !aquarium.includes(belohnung)) {
+ aquarium.push(belohnung);
+ neuesTier = belohnung;
+ }
+ }
+ }
+
+ return {
+ progress: {
+ ...progress,
+ lessons,
+ aquarium,
+ keyStats: foldKeyStats(progress.keyStats, result),
+ perlen: progress.perlen + result.perlen,
+ streak: bumpStreak(progress.streak, day),
+ },
+ unlockedLessonId,
+ neuesTier,
+ bestseit: improved,
+ };
+}
+
+
+/** How many times a key must be typed before its stats mean anything. */
+const GENUG_VERSUCHE = 3;
+
+/** The key a lesson should drill hardest - the generator's focus key, or `null` for an
+ * even spread.
+ *
+ * `null` on a brand-new lesson is the important case. Every key starts unpractised, so
+ * "pick an unpractised key" would pick whichever sorted first and drill it half the
+ * line - which on Lektion 1 means typing `a` thirteen times out of twenty-four while
+ * three other fingers go untrained. A lesson she has never played gets an even spread;
+ * a focus key only emerges once there is evidence of what she is actually slow at. */
+export function focusKeyFor(progress: Progress, activeKeys: readonly string[]): string | null {
+ const keys = activeKeys.filter((key) => key !== " ");
+ if (keys.length === 0) return null;
+
+ const geuebt = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= GENUG_VERSUCHE);
+ if (geuebt.length === 0) return null;
+
+ // Some keys practised and some not: the gap is the most useful thing to close.
+ const unbekannt = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < GENUG_VERSUCHE);
+ if (unbekannt) return unbekannt;
+
+ let worst: string | null = null;
+ let worstScore = -Infinity;
+ for (const key of keys) {
+ const stat = progress.keyStats[key]!;
+ // Errors weigh heavily: a key she gets wrong matters more than one she is merely
+ // slow on, and 3000ms is well past the point where slow becomes a real hesitation.
+ const score = stat.ema + (stat.errors / stat.attempts) * 3000;
+ if (score > worstScore) {
+ worstScore = score;
+ worst = key;
+ }
+ }
+ return worst;
+}
+
+/** The fastest animal earned on any lesson so far. Drives the aquarium's headline stat
+ * and, on the result screen, how far up the ladder is allowed to be revealed. */
+export function besteTier(progress: Progress): TierId | null {
+ let best: TierId | null = null;
+ let bestPunkte = -1;
+ for (const lesson of Object.values(progress.lessons)) {
+ if (lesson.bestTier && lesson.bestPunkte > bestPunkte) {
+ best = lesson.bestTier;
+ bestPunkte = lesson.bestPunkte;
+ }
+ }
+ return best;
+}
+
+/** How well a key is known, 0..1 - the on-screen keyboard's opacity, so the hint fades
+ * away exactly where she no longer needs it. */
+export function mastery(progress: Progress, key: string): number {
+ const stat = progress.keyStats[key.toLowerCase()];
+ if (!stat || stat.attempts < 5) return 0;
+ const accuracy = 1 - stat.errors / stat.attempts;
+ // 600ms is about where a six-year-old's key press stops being a search.
+ const speed = Math.max(0, Math.min(1, (1200 - stat.ema) / 600));
+ return Math.max(0, Math.min(1, accuracy * speed));
+}
diff --git a/tippen/src/lib/speech.ts b/tippen/src/lib/speech.ts
new file mode 100644
index 0000000..64b9137
--- /dev/null
+++ b/tippen/src/lib/speech.ts
@@ -0,0 +1,77 @@
+/** German speech, for a player who cannot reliably read yet.
+ *
+ * This is the accessibility feature that makes the game usable at six: the target letter
+ * or word is spoken, so not being able to read it is never what stops her. It doubles as
+ * phonics practice - hearing "eff" while pressing f is exactly the association to build.
+ *
+ * Everything here degrades to silence. `speechSynthesis` is missing in some browsers,
+ * has no German voice in others, and on several platforms refuses to speak until the
+ * user has interacted with the page. None of that may throw. */
+
+let stimme: SpeechSynthesisVoice | null = null;
+let gesucht = false;
+
+/** Pick a German voice once. Voices load asynchronously, so this may find nothing on
+ * the first call and succeed later - hence the retry rather than a cached `null`. */
+function germanVoice(): SpeechSynthesisVoice | null {
+ if (stimme) return stimme;
+ try {
+ const voices = window.speechSynthesis.getVoices();
+ if (voices.length === 0) {
+ if (!gesucht) {
+ gesucht = true;
+ window.speechSynthesis.addEventListener("voiceschanged", () => {
+ stimme = null;
+ germanVoice();
+ });
+ }
+ return null;
+ }
+ stimme = voices.find((voice) => voice.lang.startsWith("de")) ?? null;
+ return stimme;
+ } catch {
+ return null;
+ }
+}
+
+export function speechAvailable(): boolean {
+ return typeof window !== "undefined" && "speechSynthesis" in window;
+}
+
+/** Say something, cancelling whatever was being said. A six-year-old clicks fast; a
+ * queue of four stale sentences talking over the game is worse than silence. */
+export function say(text: string, enabled: boolean): void {
+ if (!enabled || !speechAvailable()) return;
+ try {
+ window.speechSynthesis.cancel();
+ const utterance = new SpeechSynthesisUtterance(text);
+ utterance.lang = "de-DE";
+ // Slower than default: the instruction has to be followable by someone who is also
+ // hunting for a key.
+ utterance.rate = 0.9;
+ utterance.pitch = 1.1;
+ const voice = germanVoice();
+ if (voice) utterance.voice = voice;
+ window.speechSynthesis.speak(utterance);
+ } catch {
+ // Blocked, unsupported, or no voice. The game reads fine on screen.
+ }
+}
+
+/** How a single key should be *said*, which is not how it is written. "a" spoken alone
+ * is the letter name; the Leertaste has no letter name at all. */
+export function spellKey(key: string): string {
+ if (key === " ") return "Leertaste";
+ if (key === "⇧") return "Umschalttaste";
+ const upper = key.toUpperCase();
+ // A capital in a target means Shift is the lesson, so name it.
+ return key !== key.toLowerCase() ? `großes ${upper}` : upper;
+}
+
+export function stop(): void {
+ try {
+ window.speechSynthesis.cancel();
+ } catch {
+ // Nothing was speaking.
+ }
+}
diff --git a/tippen/src/lib/theme.ts b/tippen/src/lib/theme.ts
new file mode 100644
index 0000000..d4e6d4f
--- /dev/null
+++ b/tippen/src/lib/theme.ts
@@ -0,0 +1,39 @@
+/** Feature toggles and the one hue this app is built on.
+ *
+ * Same role as ../../web/src/lib/theme.ts: flip a switch here rather than hunting
+ * through components. The hue lives in styles/app.css because CSS is where it is used;
+ * it is repeated here only for the canvas, which cannot read a custom property. */
+
+/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
+export const HUE = 175;
+
+/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs. */
+export const SHOW_GLASS_BLUR = true;
+
+/** The decorative rising bubbles behind everything. */
+export const SHOW_BUBBLES = true;
+
+/** The earned pets swimming behind every screen. The reward that is always in view - off
+ * only to rule it out when chasing a performance problem. */
+export const SHOW_AQUARIUM_TIERE = true;
+
+/** Fade screens in on entry. */
+export const ANIMATE_VIEW_TRANSITIONS = true;
+
+/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
+ * as each one is mastered - the scaffold that removes itself, which is the whole point
+ * of teaching touch typing rather than hunt-and-peck. */
+export const KEYBOARD_HINT_DEFAULT: "auto" | "on" | "off" = "auto";
+
+/** Read the target letters and words aloud. She is six and still learning to read; a
+ * missing reading skill must never block the typing skill. */
+export const SPEECH_DEFAULT = true;
+
+export const SOUND_DEFAULT = true;
+
+/** How many letters one Blasenplatzen or Quallenalarm round sends up - matched to the
+ * Tauchgang's length so a mode swap is not also a difficulty swap. Tauchgang line
+ * length lives on the lesson itself (`laengeFuer` in lib/curriculum.ts). */
+export function blasenAnzahlFuer(welt: number): number {
+ return welt === 1 ? 50 : 100;
+}
diff --git a/tippen/src/main.tsx b/tippen/src/main.tsx
new file mode 100644
index 0000000..ee6783c
--- /dev/null
+++ b/tippen/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import { App } from "./App";
+import "./styles/app.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/tippen/src/styles/app.css b/tippen/src/styles/app.css
new file mode 100644
index 0000000..9024fa7
--- /dev/null
+++ b/tippen/src/styles/app.css
@@ -0,0 +1,392 @@
+/* The Dolphin Beats design system, on its own hue.
+ ../../web/src/styles/app.css is the original; the tokens, the glass recipe, the radius
+ scale and the entrance animations are deliberately identical so the two apps read as
+ siblings. What changes is the hue: the music player's sea is 210, "Mein Zimmer" is
+ 300, Hörbücher are 55. This is 175 - a turquoise lagoon, clearly the same ocean and
+ clearly not the same room.
+ The pink --accent stays exactly as it is. It is the family's signature highlight. */
+
+:root {
+ --ink: oklch(30% 0.04 175);
+ --paper: oklch(97% 0.01 175);
+ --accent: oklch(70% 0.16 340);
+ --accent-dim: oklch(78% 0.14 340);
+ --sea-deep: oklch(20% 0.045 175);
+ --shadow: oklch(15% 0.05 175 / 0.35);
+
+ /* This app's own additions: the feedback colours. Note there is no red - a wrong key
+ is amber and gentle, never an alarm. See `wrongShake` below. */
+ --richtig: oklch(80% 0.17 150);
+ --daneben: oklch(80% 0.13 75);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: "Nunito", system-ui, sans-serif;
+ overscroll-behavior: none;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+}
+
+button {
+ font-family: inherit;
+}
+
+/* ------------------------------------------------------------------ stage -- */
+
+.stage {
+ position: relative;
+ width: 100%;
+ height: 100vh;
+ height: 100dvh;
+ overflow: hidden;
+ background: linear-gradient(
+ 180deg,
+ oklch(55% 0.07 175) 0%,
+ oklch(38% 0.06 175) 45%,
+ var(--sea-deep) 100%
+ );
+ display: flex;
+ flex-direction: column;
+ /* Its own stacking context, so the swimming pets (z-index -1, AquariumTiere.tsx) paint
+ above this gradient and below every screen - rather than below the page itself. */
+ isolation: isolate;
+}
+
+.bubble {
+ position: absolute;
+ bottom: -40px;
+ border-radius: 50%;
+ animation-name: bubbleRise;
+ animation-timing-function: linear;
+ animation-iteration-count: infinite;
+ pointer-events: none;
+}
+
+/* ------------------------------------------------------------- animations -- */
+
+@keyframes bubbleRise {
+ 0% { transform: translateY(0) scale(1); opacity: 0.55; }
+ 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; }
+}
+
+@keyframes dolphinBob {
+ 0%, 100% { transform: translateY(0) rotate(-2deg); }
+ 50% { transform: translateY(-10px) rotate(2deg); }
+}
+
+@keyframes viewEnter {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@keyframes sheetEnter {
+ from { opacity: 0; transform: translateY(24px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@keyframes backdropEnter {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.view-enter { animation: viewEnter 200ms ease-out; }
+.sheet-enter { animation: sheetEnter 240ms ease-out; }
+.backdrop-enter { animation: backdropEnter 150ms ease; }
+
+/* A correct key. Short and bouncy - the reward has to land before the next keystroke. */
+@keyframes correctPop {
+ 0% { transform: scale(1); }
+ 40% { transform: scale(1.25); }
+ 100% { transform: scale(1); }
+}
+
+/* A wrong key. A small wobble, not a buzz: it says "not that one, try again", and
+ deliberately does not say "you failed". */
+@keyframes wrongShake {
+ 0%, 100% { transform: translateX(0); }
+ 25% { transform: translateX(-5px); }
+ 75% { transform: translateX(5px); }
+}
+
+@keyframes keyPulse {
+ 0%, 100% { box-shadow: 0 0 0 0 oklch(97% 0.01 175 / 0.6); }
+ 50% { box-shadow: 0 0 0 9px oklch(97% 0.01 175 / 0); }
+}
+
+@keyframes tierEnter {
+ 0% { opacity: 0; transform: scale(0.4) rotate(-12deg); }
+ 60% { opacity: 1; transform: scale(1.15) rotate(4deg); }
+ 100% { opacity: 1; transform: scale(1) rotate(0); }
+}
+
+@keyframes sterneEnter {
+ 0% { opacity: 0; transform: scale(0.2); }
+ 100% { opacity: 1; transform: scale(1); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ /* Keep the colour feedback, drop the movement. */
+ .bubble,
+ .view-enter,
+ .sheet-enter,
+ .backdrop-enter {
+ animation: none;
+ }
+}
+
+/* ----------------------------------------------------------------- glass -- */
+
+.glass-panel {
+ background: linear-gradient(160deg, oklch(97% 0.01 175 / 0.14), oklch(97% 0.01 175 / 0.05));
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border: 1px solid oklch(97% 0.01 175 / 0.16);
+ border-radius: 22px;
+ box-shadow:
+ 0 8px 24px var(--shadow),
+ inset 0 1px 0 oklch(100% 0 0 / 0.14);
+}
+
+.stage[data-blur="off"] .glass-panel,
+.stage[data-blur="off"] .card,
+.stage[data-blur="off"] .kb-key {
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+}
+
+.pill {
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 800;
+ padding: 9px 15px;
+ border-radius: 999px;
+ white-space: nowrap;
+ background: oklch(97% 0.01 175 / 0.16);
+ color: var(--paper);
+}
+
+.pill[data-active="true"] {
+ background: var(--paper);
+ color: oklch(28% 0.04 175);
+ box-shadow: 0 4px 14px var(--shadow);
+}
+
+.card {
+ backdrop-filter: blur(6px);
+ overflow: hidden;
+ cursor: pointer;
+ transition: transform 0.12s ease;
+ border: none;
+ padding: 0;
+ text-align: left;
+ font: inherit;
+ display: block;
+ width: 100%;
+}
+
+.card[data-selected="true"] {
+ outline: 5px solid var(--paper);
+ outline-offset: 3px;
+ transform: translateY(-3px);
+}
+
+/* A locked lesson is dimmed but never hidden - seeing what comes next is half the
+ reason to finish what is open. */
+.card[data-locked="true"] {
+ cursor: default;
+ opacity: 0.45;
+}
+
+.sheet {
+ background: var(--paper);
+ border-radius: 24px;
+ box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5);
+}
+
+.overlay {
+ position: absolute;
+ inset: 0;
+ background: oklch(15% 0.03 175 / 0.55);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 40px;
+ z-index: 20;
+}
+
+.key-cap {
+ font: 800 12px ui-monospace, Menlo, monospace;
+ background: var(--ink);
+ color: #fff;
+ padding: 5px 12px;
+ border-radius: 8px;
+ min-width: 56px;
+ text-align: center;
+}
+
+/* ------------------------------------------------- die Bildschirmtastatur -- */
+
+.kb {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ align-items: center;
+ transition: opacity 400ms ease;
+}
+
+.kb-row {
+ display: flex;
+ gap: 7px;
+}
+
+.kb-key {
+ width: var(--kb-size, 42px);
+ height: var(--kb-size, 42px);
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: calc(var(--kb-size, 42px) * 0.42);
+ font-weight: 800;
+ color: var(--paper);
+ background: oklch(97% 0.01 175 / 0.1);
+ border: 1px solid oklch(97% 0.01 175 / 0.14);
+ backdrop-filter: blur(6px);
+ transition:
+ background 0.15s ease,
+ transform 0.1s ease;
+}
+
+/* A key that this lesson does not use yet fades back so the eye goes to the ones that
+ matter. It stays visible: the keyboard is a map, and a map with holes is confusing. */
+.kb-key[data-active="false"] {
+ opacity: 0.3;
+}
+
+/* Each finger owns a hue (lib/fingers.ts), so "der grüne Finger" is something you can
+ say out loud to a six-year-old and have her understand it. */
+/* Saturated on purpose. The finger colour is not decoration - it is how a six-year-old
+ is told which finger to use ("der grüne Finger"), so it has to survive being read at
+ arm's length against a dark blue-green background. The first pass was tinted at 50%
+ alpha and every key came out the same murky green. */
+.kb-key[data-finger] {
+ background: linear-gradient(
+ 160deg,
+ oklch(68% 0.19 var(--finger-hue) / 0.92),
+ oklch(52% 0.16 var(--finger-hue) / 0.8)
+ );
+ border-color: oklch(82% 0.13 var(--finger-hue) / 0.75);
+ color: oklch(22% 0.05 var(--finger-hue));
+ text-shadow: 0 1px 0 oklch(100% 0 0 / 0.25);
+}
+
+.kb-key[data-next="true"] {
+ background: var(--paper);
+ color: oklch(25% 0.05 175);
+ transform: translateY(-3px) scale(1.08);
+ animation: keyPulse 1.3s ease-out infinite;
+}
+
+.kb-key[data-home="true"]::after {
+ /* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
+ content: "";
+ position: absolute;
+ transform: translateY(12px);
+ width: 12px;
+ height: 2px;
+ border-radius: 2px;
+ background: currentColor;
+ opacity: 0.55;
+}
+
+.kb-key {
+ position: relative;
+}
+
+.kb-space {
+ width: calc(var(--kb-size, 42px) * 6);
+}
+
+/* ---------------------------------------------------------- die Zielzeile -- */
+
+.ziel {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 12px 26px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ /* Wrapping beats shrinking: the line must never run into the edges of the screen.
+ Target.tsx overrides this per block length (see `satzspiegel`); this is only the
+ fallback for a caller that does not. */
+ max-width: min(900px, 88vw);
+}
+
+.ziel-chunk {
+ display: flex;
+}
+
+.ziel-zeichen {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 0.78em;
+ color: oklch(97% 0.01 175 / 0.45);
+ transition: color 0.12s ease;
+}
+
+.ziel-zeichen[data-state="fertig"] {
+ color: var(--richtig);
+}
+
+.ziel-zeichen[data-state="aktuell"] {
+ color: oklch(25% 0.05 175);
+ background: var(--paper);
+ border-radius: 10px;
+ box-shadow: 0 6px 18px var(--shadow);
+ animation: correctPop 200ms ease-out;
+}
+
+.ziel-zeichen[data-state="aktuell"][data-daneben="true"] {
+ background: var(--daneben);
+ animation: wrongShake 260ms ease;
+}
+
+/* A space inside the target needs a visible body, or the cursor lands on nothing. Only
+ the space at the cursor shows ␣: marking every upcoming one turned a sentence into
+ "Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */
+.ziel-zeichen[data-leer="true"] {
+ min-width: 0.9em;
+}
+
+/* The two Shift keys, drawn either side of the Leertaste. Wider than a letter key so
+ they read as the modifier they are, and dim until a capital actually needs one. */
+.kb-shift {
+ width: calc(var(--kb-size, 42px) * 1.8);
+ background: oklch(97% 0.01 175 / 0.12);
+ font-size: calc(var(--kb-size, 42px) * 0.4);
+}
+
+/* A dome plus three trailing tentacles. Without them the Quallen read as plain circles,
+ and "Quallenalarm" stops being a picture of anything. Drawn in CSS rather than as an
+ emoji so the letter stays centred and legible inside the dome. */
+.qualle::after {
+ content: "";
+ position: absolute;
+ bottom: -11px;
+ left: 26%;
+ right: 26%;
+ height: 16px;
+ opacity: 0.55;
+ background:
+ linear-gradient(currentColor, transparent) left / 3px 100% no-repeat,
+ linear-gradient(currentColor, transparent) center / 3px 100% no-repeat,
+ linear-gradient(currentColor, transparent) right / 3px 100% no-repeat;
+}
diff --git a/tippen/tsconfig.json b/tippen/tsconfig.json
new file mode 100644
index 0000000..5ab68ea
--- /dev/null
+++ b/tippen/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/tippen/tsconfig.tsbuildinfo b/tippen/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..29e2973
--- /dev/null
+++ b/tippen/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumTiere.tsx","./src/components/Bubbles.tsx","./src/components/HandHint.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ModePicker.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BlasenRun.tsx","./src/components/modes/FuetternRun.tsx","./src/components/modes/PerlenRun.tsx","./src/components/modes/QuallenRun.tsx","./src/components/modes/RennenRun.tsx","./src/components/modes/TauchgangRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/speech.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/tippen/vite.config.ts b/tippen/vite.config.ts
new file mode 100644
index 0000000..0981656
--- /dev/null
+++ b/tippen/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// Deliberately standalone: no proxy, no backend, no shared build with ../web. The game
+// is pure client state (localStorage), so `vite dev` on its own is the whole runtime.
+// Port 5174 so it can run next to the music player's 5173 without a clash.
+export default defineConfig({
+ plugins: [react()],
+ server: { port: 5174 },
+ build: { outDir: "dist", emptyOutDir: true },
+});