diff --git a/python-backend/tests/test_tippen_curriculum.py b/python-backend/tests/test_tippen_curriculum.py index 9b8dd57..fd18862 100644 --- a/python-backend/tests/test_tippen_curriculum.py +++ b/python-backend/tests/test_tippen_curriculum.py @@ -7,7 +7,17 @@ from pathlib import Path import pytest from ruamel.yaml import YAML -from musicmouse.tippen.curriculum import CurriculumError, load_curriculum +from musicmouse.tippen.curriculum import ( + ELIGIBLE_MODES, + CurriculumError, + first_lesson_id, + lesson_by_id, + load_curriculum, + next_lesson, +) + +#: The real, shipped curriculum - see python-backend/tippen-curriculum.yml.example. +_EXAMPLE = Path(__file__).parent.parent / "tippen-curriculum.yml.example" VALID: dict = { "worlds": [ @@ -150,3 +160,168 @@ def test_unlocks_is_carried_through_unresolved(tmp_path: Path) -> None: curriculum = load_curriculum(_write(tmp_path, {"worlds": worlds})) assert curriculum.lessons[0].unlocks == "~/Music/Musik/Album/05Track.mp3" assert curriculum.lessons[1].unlocks is None + + +# ------------------------------------------------------------- the real curriculum +# +# Content-shape invariants the real, shipped curriculum must hold - ported from the +# frontend's old curriculum.test.ts, which checked these against the same YAML back +# when it was parsed client-side. The pedagogical content itself did not change in the +# move to the backend; what moved is the parsing/deriving logic these tests actually +# exercise (buildLessons's Python port, `_build_lessons`), so the coverage still earns +# its keep here. A few of the original checks needed real-keyboard finger/hand mapping +# (`fingers.ts`, never ported to Python, since nothing backend-side needs it) and are +# not repeated. + + +@pytest.fixture(scope="module") +def curriculum(): + return load_curriculum(_EXAMPLE) + + +def test_has_unique_ids_and_consecutive_numbers(curriculum) -> None: + ids = [lesson.id for lesson in curriculum.lessons] + assert len(set(ids)) == len(ids) + for i, lesson in enumerate(curriculum.lessons): + assert lesson.number == i + 1 + + +def test_starts_on_the_two_keys_with_the_tactile_bumps(curriculum) -> None: + assert curriculum.lessons[0].new_keys == ("f", "j") + assert first_lesson_id(curriculum) == curriculum.lessons[0].id + + +def test_eases_in_at_most_two_new_keys_per_lesson(curriculum) -> None: + for lesson in curriculum.lessons: + assert len(lesson.new_keys) <= 2, f"{lesson.number}: {lesson.title}" + + +def test_makes_every_round_a_real_block_of_practice(curriculum) -> None: + for lesson in curriculum.lessons: + where = f"{lesson.number}: {lesson.title}" + if lesson.kind == "sentences": + assert lesson.chunks >= 10, where + elif lesson.kind == "words": + assert lesson.chunks >= 25, where + else: + assert lesson.chunks * lesson.chunk_size >= 60, where + + +def test_has_more_sentences_to_draw_from_than_a_round_uses(curriculum) -> None: + for lesson in curriculum.lessons: + if lesson.kind != "sentences": + continue + assert len(lesson.words) >= lesson.chunks, f"{lesson.number}: {lesson.title}" + + +def test_active_keys_grow_monotonically(curriculum) -> None: + previous: set[str] = set() + for lesson in curriculum.lessons: + active = set(lesson.active_keys) + assert previous <= active + previous = active + + +def test_every_new_key_enters_its_own_active_keys(curriculum) -> None: + for lesson in curriculum.lessons: + for key in lesson.new_keys: + if key == "⇧": # Shift is not a character the generator can emit. + continue + assert key in lesson.active_keys, f"{lesson.number}: {lesson.title}" + + +def test_covers_the_whole_alphabet_plus_umlauts_by_the_end(curriculum) -> None: + final = set(curriculum.lessons[-1].active_keys) + for key in "abcdefghijklmnopqrstuvwxyzäöü": + assert key in final + + +def test_plays_a_mode_that_fits_its_kind(curriculum) -> None: + for lesson in curriculum.lessons: + where = f"{lesson.number}: {lesson.title}" + assert lesson.primary_mode in ELIGIBLE_MODES[lesson.kind], where + for mode in lesson.bonus_modes: + assert mode in ELIGIBLE_MODES[lesson.kind], where + + +def test_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None: + arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"] + for i in range(1, len(arcade)): + where = f"{arcade[i].number}: {arcade[i].title}" + assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where + + +def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None: + for lesson in curriculum.lessons: + expected = sorted( + mode + for mode in ELIGIBLE_MODES[lesson.kind] + if mode in ("feed", "race") and mode != lesson.primary_mode + ) + assert sorted(lesson.bonus_modes) == expected + + +def test_mixes_games_instead_of_always_diving(curriculum) -> None: + words = [lesson for lesson in curriculum.lessons if lesson.kind == "words"] + sentences = [lesson for lesson in curriculum.lessons if lesson.kind == "sentences"] + assert any(lesson.primary_mode == "feed" for lesson in words) + assert any(lesson.primary_mode == "race" for lesson in words) + assert any(lesson.primary_mode == "race" for lesson in sentences) + + +def test_distinguishes_a_mixed_round_from_a_drill(curriculum) -> None: + mixed = [lesson for lesson in curriculum.lessons if lesson.emphasis == "mixed"] + assert len(mixed) > 0 + for lesson in mixed: + assert lesson.new_keys == () + assert lesson.is_drill is False + + +def test_every_isolated_round_has_something_new_to_drill(curriculum) -> None: + for lesson in curriculum.lessons: + if lesson.emphasis == "isolated": + assert len(lesson.new_keys) > 0, f"{lesson.number}: {lesson.title}" + + +def test_teaches_capitals_only_once_shift_exists(curriculum) -> None: + for lesson in curriculum.lessons: + has_uppercase = any(word != word.lower() for word in lesson.words) + if has_uppercase: + assert lesson.world >= 4, f"{lesson.number}: {lesson.title}" + + +def test_follows_every_pair_of_new_keys_with_a_drill(curriculum) -> None: + drills = [lesson for lesson in curriculum.lessons if lesson.is_drill] + assert len(drills) >= 15 + for lesson in drills: + assert lesson.new_keys == () + assert len(lesson.active_keys) > 0 + + +def test_is_a_real_duolingo_length_course(curriculum) -> None: + assert len(curriculum.lessons) >= 80 + assert len(curriculum.worlds) == 5 + + +def test_every_world_has_at_least_three_lessons(curriculum) -> None: + for world in curriculum.worlds: + count = sum(1 for lesson in curriculum.lessons if lesson.world == world.number) + assert count >= 3, world.title + + +def test_every_world_has_its_own_creature(curriculum) -> None: + rewards = [world.reward for world in curriculum.worlds] + assert len(set(rewards)) == len(rewards) + + +def test_navigation_chains_every_lesson_to_the_next(curriculum) -> None: + lessons = curriculum.lessons + for i in range(len(lessons) - 1): + assert next_lesson(curriculum, lessons[i].id).id == lessons[i + 1].id + assert next_lesson(curriculum, lessons[-1].id) is None + assert next_lesson(curriculum, "gibt-es-nicht") is None + + +def test_navigation_looks_lessons_up_by_id(curriculum) -> None: + assert lesson_by_id(curriculum, first_lesson_id(curriculum)).number == 1 + assert lesson_by_id(curriculum, "gibt-es-nicht") is None diff --git a/tippen/.gitignore b/tippen/.gitignore deleted file mode 100644 index b947077..0000000 --- a/tippen/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/tippen/README.md b/tippen/README.md deleted file mode 100644 index 7d2427d..0000000 --- a/tippen/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# 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 deleted file mode 100644 index fbac340..0000000 Binary files a/tippen/art/aquarium/clownfisch.png and /dev/null differ diff --git a/tippen/art/aquarium/krake.png b/tippen/art/aquarium/krake.png deleted file mode 100644 index 136b0ef..0000000 Binary files a/tippen/art/aquarium/krake.png and /dev/null differ diff --git a/tippen/art/aquarium/perlmuschel.png b/tippen/art/aquarium/perlmuschel.png deleted file mode 100644 index 6bcb129..0000000 Binary files a/tippen/art/aquarium/perlmuschel.png and /dev/null differ diff --git a/tippen/art/aquarium/schildkroete.png b/tippen/art/aquarium/schildkroete.png deleted file mode 100644 index 87b38a6..0000000 Binary files a/tippen/art/aquarium/schildkroete.png and /dev/null differ diff --git a/tippen/art/aquarium/seepferdchen.png b/tippen/art/aquarium/seepferdchen.png deleted file mode 100644 index cc043a8..0000000 Binary files a/tippen/art/aquarium/seepferdchen.png and /dev/null differ diff --git a/tippen/index.html b/tippen/index.html deleted file mode 100644 index 432eaf0..0000000 --- a/tippen/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - Delfin Tippen - - - - - - - - - -
- - - diff --git a/tippen/package-lock.json b/tippen/package-lock.json deleted file mode 100644 index 57d4136..0000000 --- a/tippen/package-lock.json +++ /dev/null @@ -1,2349 +0,0 @@ -{ - "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", - "yaml": "^2.6.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" - }, - "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - } - } -} diff --git a/tippen/package.json b/tippen/package.json deleted file mode 100644 index a664250..0000000 --- a/tippen/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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", - "yaml": "^2.6.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/dolphin-mascot.png b/tippen/public/dolphin-mascot.png deleted file mode 100644 index bf3fb0c..0000000 Binary files a/tippen/public/dolphin-mascot.png and /dev/null differ diff --git a/tippen/public/fonts/nunito-latin-ext.woff2 b/tippen/public/fonts/nunito-latin-ext.woff2 deleted file mode 100644 index 584a55b..0000000 Binary files a/tippen/public/fonts/nunito-latin-ext.woff2 and /dev/null differ diff --git a/tippen/public/fonts/nunito-latin.woff2 b/tippen/public/fonts/nunito-latin.woff2 deleted file mode 100644 index e118818..0000000 Binary files a/tippen/public/fonts/nunito-latin.woff2 and /dev/null differ diff --git a/tippen/public/fonts/nunito.css b/tippen/public/fonts/nunito.css deleted file mode 100644 index 5404723..0000000 --- a/tippen/public/fonts/nunito.css +++ /dev/null @@ -1,22 +0,0 @@ -/* 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 deleted file mode 100644 index a0ef028..0000000 Binary files a/tippen/public/icon-192.png and /dev/null differ diff --git a/tippen/scripts/aquarium-bild.sh b/tippen/scripts/aquarium-bild.sh deleted file mode 100755 index 876ada9..0000000 --- a/tippen/scripts/aquarium-bild.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/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/data/curriculum.yaml b/tippen/src/data/curriculum.yaml deleted file mode 100644 index 1ab8275..0000000 --- a/tippen/src/data/curriculum.yaml +++ /dev/null @@ -1,422 +0,0 @@ -# The lesson plan, as data rather than code, so wording, word lists and pacing can be -# tweaked here without touching curriculum.ts. -# -# Every lesson: title, subtitle (read aloud). Then either: -# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were -# already active: the first time a key appears its lesson is "isolated" (heavy -# weight, alone); the second (identical) appearance is "mixed" (lighter weight, -# blended with everything learned so far). Two lessons with the same `keys` back to -# back is exactly how you write "isolated, then mixed" - no separate flag needed. -# - drill: true - a pure review round, no new content, whatever is active so far. -# - kind + words - a fragments/words/sentences consolidation round (dive mode by -# default; `mode:` overrides it, see curriculum.ts for which modes fit which kind). -# -# Letter order follows German letter frequency, adapted to a home-row-first, mirrored -# pace for a six-year-old - unchanged from the original course. - -worlds: - - number: 1 - title: "Die Grundstellung" - emoji: "🏝️" - reward: clownfish - lessons: - - title: "F und J" - subtitle: "Die Zeigefinger - die Tasten mit den Punkten" - keys: [f, j] - - title: "F und J üben" - subtitle: "Die neuen Tasten festigen" - keys: [f, j] - - title: "D und K" - subtitle: "Die Mittelfinger" - keys: [d, k] - - title: "D und K üben" - subtitle: "Die neuen Tasten festigen" - keys: [d, k] - - title: "Übung: F J D K" - subtitle: "Die vier Tasten zusammen" - drill: true - - title: "S und L" - subtitle: "Die Ringfinger" - keys: [s, l] - - title: "S und L üben" - subtitle: "Die neuen Tasten festigen" - keys: [s, l] - - title: "Übung: sechs Tasten" - subtitle: "Alles bisher zusammen" - drill: true - - title: "A und Ö" - subtitle: "Die kleinen Finger" - keys: [a, ö] - - title: "A und Ö üben" - subtitle: "Die neuen Tasten festigen" - keys: [a, ö] - - title: "Übung: die Grundstellung" - subtitle: "Alle acht Finger" - drill: true - - title: "Erste kleine Wörter" - subtitle: "Echte Wörter mit acht Tasten" - kind: fragments - words: [da, ja, das, dass, als, all, fall, falls, lass, saal, kalk, salsa, jass] - - title: "Die Leertaste" - subtitle: "Der Daumen kommt dazu" - keys: [" "] - - title: "Übung: Grundstellung mit Leertaste" - subtitle: "Jetzt mit dem Daumen" - drill: true - - title: "Wörter mit Leertaste" - subtitle: "Kleine Wörter, kleine Sätze" - kind: fragments - words: ["lass das", "das da", "da ja", "fall da", "kalk da", "saal da", "ja lass das", "als da"] - - - number: 2 - title: "Nach oben" - emoji: "🌊" - reward: octopus - lessons: - - title: "Das E" - subtitle: "Mittelfinger links nach oben" - keys: [e] - - title: "E üben" - subtitle: "Die neue Taste festigen" - keys: [e] - - title: "Das I" - subtitle: "Mittelfinger rechts nach oben" - keys: [i] - - title: "I üben" - subtitle: "Die neue Taste festigen" - keys: [i] - - title: "Übung: E und I" - subtitle: "Beide Mittelfinger nach oben" - drill: true - - title: "Das R" - subtitle: "Zeigefinger links nach oben" - keys: [r] - - title: "R üben" - subtitle: "Die neue Taste festigen" - keys: [r] - - title: "Das U" - subtitle: "Zeigefinger rechts nach oben" - keys: [u] - - title: "U üben" - subtitle: "Die neue Taste festigen" - keys: [u] - - title: "Übung: R und U" - subtitle: "Beide Zeigefinger nach oben" - drill: true - - title: "Kleine Wörter: E I R U" - subtitle: "Erste echte Wörter mit der oberen Reihe" - kind: fragments - words: [die, sie, elf, eis, esel, see, keks, fiel, lied, rad, reis, eier, rufe, feuer, sauer, lauf] - - title: "Das T" - subtitle: "Zeigefinger links weit nach oben" - keys: [t] - - title: "T üben" - subtitle: "Die neue Taste festigen" - keys: [t] - - title: "Das Z" - subtitle: "Zeigefinger rechts weit nach oben" - keys: [z] - - title: "Z üben" - subtitle: "Die neue Taste festigen" - keys: [z] - - title: "Übung: T und Z" - subtitle: "Weit nach oben greifen" - drill: true - - title: "Das O" - subtitle: "Ringfinger rechts nach oben" - keys: [o] - - title: "O üben" - subtitle: "Die neue Taste festigen" - keys: [o] - - title: "Das W" - subtitle: "Ringfinger links nach oben" - keys: [w] - - title: "W üben" - subtitle: "Die neue Taste festigen" - keys: [w] - - title: "Übung: O und W" - subtitle: "Beide Ringfinger nach oben" - drill: true - - title: "Kleine Wörter: T Z O W" - subtitle: "Noch mehr echte Wörter" - kind: fragments - mode: feed - words: [tier, tafel, kette, leiter, zeit, salz, zelt, katze, rot, tor, los, foto, wo, wald, zwei, wolke] - - title: "Das P" - subtitle: "Kleiner Finger rechts nach oben" - keys: [p] - - title: "P üben" - subtitle: "Die neue Taste festigen" - keys: [p] - - title: "Das Q" - subtitle: "Kleiner Finger links nach oben" - keys: [q] - - title: "Q üben" - subtitle: "Die neue Taste festigen" - keys: [q] - - title: "Das Ü" - subtitle: "Kleiner Finger rechts, ganz außen" - keys: [ü] - - title: "Ü üben" - subtitle: "Die neue Taste festigen" - keys: [ü] - - title: "Übung: P Q Ü" - subtitle: "Die kleinen Finger nach oben" - drill: true - - title: "Übung: die obere Reihe" - subtitle: "Die ganze Reihe zusammen" - kind: words - mode: feed - words: [wolke, zeit, pause, prüfe, qualle, torte, reiter, würfel] - - title: "Übung: Welt 2 komplett" - subtitle: "Alles aus der oberen Reihe" - drill: true - - - number: 3 - title: "Nach unten" - emoji: "🪸" - reward: seahorse - lessons: - - title: "Das N" - subtitle: "Zeigefinger rechts nach unten" - keys: [n] - - title: "N üben" - subtitle: "Die neue Taste festigen" - keys: [n] - - title: "Das M" - subtitle: "Zeigefinger rechts, neben dem N" - keys: [m] - - title: "M üben" - subtitle: "Die neue Taste festigen" - keys: [m] - - title: "Übung: N und M" - subtitle: "Die neuen Tasten festigen" - drill: true - - title: "Das G" - subtitle: "Zeigefinger links, in der Mitte" - keys: [g] - - title: "G üben" - subtitle: "Die neue Taste festigen" - keys: [g] - - title: "Das H" - subtitle: "Zeigefinger rechts, in der Mitte" - keys: [h] - - title: "H üben" - subtitle: "Die neue Taste festigen" - keys: [h] - - title: "Übung: G und H" - subtitle: "Die Mitte der Grundreihe" - drill: true - - title: "Kleine Wörter: N M G H" - subtitle: "Erste echte Wörter nach unten" - kind: fragments - words: [nase, nein, kind, wind, sonne, mama, mond, meer, maus, gut, gans, regen, hase, haus, hund, hupe] - - title: "Das C" - subtitle: "Mittelfinger links nach unten" - keys: [c] - - title: "C üben" - subtitle: "Die neue Taste festigen" - keys: [c] - - title: "Das V" - subtitle: "Zeigefinger links nach unten" - keys: [v] - - title: "V üben" - subtitle: "Die neue Taste festigen" - keys: [v] - - title: "Übung: C und V" - subtitle: "Nach unten greifen" - drill: true - - title: "Das B" - subtitle: "Zeigefinger links, neben dem V" - keys: [b] - - title: "B üben" - subtitle: "Die neue Taste festigen" - keys: [b] - - title: "Das Y" - subtitle: "Kleiner Finger links nach unten" - keys: [y] - - title: "Y üben" - subtitle: "Die neue Taste festigen" - keys: [y] - - title: "Übung: B und Y" - subtitle: "Ganz unten links" - drill: true - - title: "Kleine Wörter: C V B Y" - subtitle: "Noch mehr echte Wörter" - kind: fragments - words: [koch, milch, schule, chaos, vier, vase, voll, vater, baum, boot, bunt, brot, yoga, baby, typ, pony] - - title: "Das X" - subtitle: "Ringfinger links nach unten" - keys: [x] - - title: "X üben" - subtitle: "Die neue Taste festigen" - keys: [x] - - title: "Das Ä" - subtitle: "Kleiner Finger rechts, ganz außen" - keys: [ä] - - title: "Ä üben" - subtitle: "Die neue Taste festigen" - keys: [ä] - - title: "Übung: X und Ä" - subtitle: "Die letzten beiden Tasten" - drill: true - - title: "Übung: alle Buchstaben" - subtitle: "Das ganze Alphabet" - kind: words - mode: race - words: [delfin, wasser, xylofon, bäume, vogel, qualle, muschel, tauchen] - - title: "Übung: Welt 3 komplett" - subtitle: "Alles aus der unteren Reihe" - drill: true - - - number: 4 - title: "Große Buchstaben" - emoji: "👑" - reward: turtle - lessons: - - title: "Umschalttaste rechts, Teil 1" - subtitle: "Große Buchstaben der linken Hand" - keys: ["⇧"] - kind: words - words: [Delfin, Wal, Fisch, Baum] - - title: "Umschalttaste rechts, Teil 2" - subtitle: "Noch mehr große Buchstaben" - kind: words - words: [Garten, Ente, Vogel, Riff] - - title: "Umschalttaste links, Teil 1" - subtitle: "Große Buchstaben der rechten Hand" - kind: words - words: [Haus, Kind, Mond, Nase] - - title: "Umschalttaste links, Teil 2" - subtitle: "Noch mehr große Buchstaben" - kind: words - words: [Lampe, Onkel, Uhr, Puppe] - - title: "Übung: Namen, Teil 1" - subtitle: "Namen fangen groß an" - kind: words - words: [Anna, Lena, Paul, Mia, Emil, Jonas, Tom, Lisa] - - title: "Übung: Namen, Teil 2" - subtitle: "Noch mehr Namen" - kind: words - mode: feed - words: [Ben, Nora, Finn, Ida, Max, Ella, Oskar, Greta] - - title: "Übung: große und kleine" - subtitle: "Beides gemischt" - kind: words - words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] - - title: "Übung: Welt 4 komplett" - subtitle: "Groß und klein zusammen" - kind: words - drill: true - words: [Delfin, Haus, Anna, Ben, "Das Meer", "Der Wal", Mond, Riff] - - - number: 5 - title: "Ganze Sätze" - emoji: "📖" - reward: pearlmussel - lessons: - - title: "Der Punkt" - subtitle: "Ringfinger rechts nach unten" - keys: ["."] - - title: "Punkt üben" - subtitle: "Die neue Taste festigen" - keys: ["."] - - title: "Erste Sätze mit Punkt" - subtitle: "Ein Satz, ein Punkt" - kind: sentences - words: - - "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." - - title: "Das Komma" - subtitle: "Mittelfinger rechts nach unten" - keys: [","] - - title: "Komma üben" - subtitle: "Die neue Taste festigen" - keys: [","] - - title: "Sätze mit Komma" - subtitle: "Zwei Gedanken, ein Satz" - kind: sentences - mode: race - words: - - "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." - - title: "Der Bindestrich" - subtitle: "Kleiner Finger rechts, ganz außen" - keys: ["-"] - - title: "Bindestrich üben" - subtitle: "Die neue Taste festigen" - keys: ["-"] - - title: "Sätze mit Bindestrich" - subtitle: "Zwei Wörter, ein Strich" - kind: sentences - words: - - "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." - - title: "Fragezeichen und Ausrufezeichen" - subtitle: "Mit der Umschalttaste" - keys: ["ß", "1"] - - title: "Fragezeichen und Ausrufezeichen üben" - subtitle: "Die neuen Tasten festigen" - keys: ["ß", "1"] - - title: "Fragen und Rufe" - subtitle: "Wie klingt ein Satz?" - kind: sentences - words: - - "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!" - - title: "Übung: ganze Sätze" - subtitle: "Alles zusammen" - kind: sentences - drill: true - mode: race - words: - - "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." diff --git a/tippen/src/lib/__tests__/curriculum.test.ts b/tippen/src/lib/__tests__/curriculum.test.ts deleted file mode 100644 index 64a3bf3..0000000 --- a/tippen/src/lib/__tests__/curriculum.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, 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.number).toBe(i + 1)); - }); - - it("starts on the two keys with the tactile bumps", () => { - expect(LESSONS[0]!.newKeys).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.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2); - } - }); - - it("teaches world 1 as mirrored finger pairs, one finger per hand", () => { - const pairs = lessonsOfWorld(1).filter((lesson) => lesson.newKeys.length === 2); - expect(pairs.length).toBe(4); - for (const lesson of pairs) { - const [left, right] = lesson.newKeys.map((key) => fingerOf(key)!); - expect(left!.hand).toBe("left"); - expect(right!.hand).toBe("right"); - // Same finger on each hand - "die Zeigefinger", "die Mittelfinger", … - expect(left!.id.replace("left-", "")).toBe(right!.id.replace("right-", "")); - } - }); - - it("has the whole home row active by the end of world 1", () => { - const last = lessonsOfWorld(1).at(-1)!; - for (const key of "asdfjklö") expect(last.activeKeys).toContain(key); - expect(last.activeKeys).toContain(" "); - }); - - it("makes every round a real block of practice, not a handful of keys", () => { - // The first version ran 12 characters in world 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.kind === "sentences") { - expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(10); - } else if (lesson.kind === "words") { - expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(25); - } else { - expect(lesson.chunks * lesson.chunkSize, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(60); - } - } - }); - - it("still starts gentler than it ends", () => { - const length = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize; - expect(length(lessonsOfWorld(3)[0]!)).toBeGreaterThan(length(lessonsOfWorld(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.kind === "sentences")) { - expect(lesson.words.length, `${lesson.number}: ${lesson.title}`).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.newKeys) { - 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 word of lesson.words) { - for (const char of word) { - expect( - active.has(keyForChar(char)), - `Lektion ${lesson.number} (${lesson.title}): "${word}" braucht "${char}" (Taste ${keyForChar(char)})`, - ).toBe(true); - } - } - } - }); - - it("only uses shifted characters once shift is taught", () => { - const shiftFrom = LESSONS.find((lesson) => lesson.newKeys.includes("⇧"))!.number; - for (const lesson of LESSONS) { - for (const word of lesson.words) { - for (const char of word) { - if (!needsShift(char)) continue; - expect( - lesson.number, - `Lektion ${lesson.number}: "${word}" braucht Umschalt für "${char}"`, - ).toBeGreaterThanOrEqual(shiftFrom); - } - } - } - }); - - it("plays a mode that actually fits its kind", () => { - for (const lesson of LESSONS) { - expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(lesson.primaryMode); - for (const mode of lesson.bonusModes) { - expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(mode); - } - } - }); - - it("never plays the same arcade game twice in a row", () => { - const arcade = LESSONS.filter((lesson) => lesson.kind === "letters"); - for (let i = 1; i < arcade.length; i++) { - expect(arcade[i]!.primaryMode, `${arcade[i]!.number}: ${arcade[i]!.title}`).not.toBe(arcade[i - 1]!.primaryMode); - } - }); - - it("offers every eligible mode this lesson isn't gated on, as a bonus", () => { - for (const lesson of LESSONS) { - const expected = ELIGIBLE_MODES[lesson.kind].filter( - (mode) => (mode === "feed" || mode === "race") && mode !== lesson.primaryMode, - ); - expect([...lesson.bonusModes].sort()).toEqual([...expected].sort()); - } - }); - - it("mixes the games instead of always diving - feed and race take a turn as the required mode too", () => { - const words = LESSONS.filter((l) => l.kind === "words"); - const sentences = LESSONS.filter((l) => l.kind === "sentences"); - expect(words.some((l) => l.primaryMode === "feed")).toBe(true); - expect(words.some((l) => l.primaryMode === "race")).toBe(true); - expect(sentences.some((l) => l.primaryMode === "race")).toBe(true); - }); - - it("distinguishes a mixed round from a drill - both have no new keys, only one is a review", () => { - const mixed = LESSONS.filter((lesson) => lesson.emphasis === "mixed"); - expect(mixed.length).toBeGreaterThan(0); - for (const lesson of mixed) { - expect(lesson.newKeys).toEqual([]); - expect(lesson.isDrill).toBe(false); - } - }); - - it("gives every isolated round something new to drill", () => { - for (const lesson of LESSONS) { - if (lesson.emphasis === "isolated") expect(lesson.newKeys.length).toBeGreaterThan(0); - } - }); - - it("teaches capitals only once shift exists", () => { - for (const lesson of LESSONS) { - const hasUppercase = lesson.words.some((word) => word !== word.toLowerCase()); - if (hasUppercase) expect(lesson.world).toBeGreaterThanOrEqual(4); - } - }); - - it("gives every new key a lesson of its own after world 1", () => { - // One key at a time is the pacing decision: world 1 pairs a finger across both - // hands, everything after introduces exactly one key or none. - for (const lesson of LESSONS) { - if (lesson.world === 1) continue; - expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2); - } - }); - - it("follows every pair of new keys with a drill", () => { - const drills = LESSONS.filter((lesson) => lesson.isDrill); - expect(drills.length).toBeGreaterThanOrEqual(15); - // A drill never introduces anything, and always has something to practise. - for (const lesson of drills) { - expect(lesson.newKeys).toEqual([]); - expect(lesson.activeKeys.length).toBeGreaterThan(0); - } - }); - - it("is long enough to be a real, Duolingo-length course", () => { - expect(LESSONS.length).toBeGreaterThanOrEqual(80); - expect(WORLDS.length).toBe(5); - }); - - it("never leaves a world without a run of at least three lessons", () => { - for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThanOrEqual(3); - }); -}); - -describe("Worlds", () => { - it("has lessons in every world", () => { - for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThan(0); - }); - - it("gives every world its own aquarium creature", () => { - const rewards = WORLDS.map((world) => world.reward); - expect(new Set(rewards).size).toBe(rewards.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)?.number).toBe(1); - expect(lessonById("gibt-es-nicht")).toBeNull(); - }); -}); diff --git a/tippen/src/lib/__tests__/progress.test.ts b/tippen/src/lib/__tests__/progress.test.ts deleted file mode 100644 index ff88dd5..0000000 --- a/tippen/src/lib/__tests__/progress.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -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 { - DILIGENCE_ATTEMPTS, - 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 perfect = () => runResult("asdfasdfasdfasdf"); -const bad = () => 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 unlocked = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked); - expect(unlocked).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, perfect()); - expect(unlockedLessonId).toBe(L2); - expect(progress.lessons[L2]!.unlocked).toBe(true); - }); - - it("does not unlock below two stars", () => { - const result = bad(); - expect(result.stars).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 diligence rule", () => { - let progress = freshProgress(); - for (let i = 0; i < DILIGENCE_ATTEMPTS - 1; i++) { - progress = recordRun(progress, L1, bad()).progress; - expect(progress.lessons[L2]!.unlocked).toBe(false); - } - const { progress: last, unlockedLessonId } = recordRun(progress, L1, bad()); - 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, perfect()).progress; - const best = progress.lessons[L1]!; - progress = recordRun(progress, L1, bad()).progress; - expect(progress.lessons[L1]!.bestStars).toBe(best.bestStars); - expect(progress.lessons[L1]!.bestPoints).toBe(best.bestPoints); - expect(progress.lessons[L1]!.bestAnimal).toBe(best.bestAnimal); - }); - - it("counts every run, good or bad", () => { - let progress = freshProgress(); - for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, bad()).progress; - expect(progress.lessons[L1]!.runs).toBe(3); - }); - - it("adds pearls on every run", () => { - const first = recordRun(freshProgress(), L1, bad()); - expect(first.progress.pearls).toBeGreaterThan(0); - const second = recordRun(first.progress, L1, bad()); - expect(second.progress.pearls).toBeGreaterThan(first.progress.pearls); - }); - - it("releases a creature only when a world is finished, and only once", () => { - let progress = freshProgress(); - // World 1 is lessons 1-3, so finishing lesson 3 is what crosses into world 2. - const world1 = LESSONS.filter((lesson) => lesson.world === 1); - let released: string[] = []; - for (const lesson of world1) { - const outcome = recordRun(progress, lesson.id, perfect()); - progress = outcome.progress; - if (outcome.newCreature) released.push(outcome.newCreature); - } - expect(released).toHaveLength(1); - expect(progress.aquarium).toEqual(released); - - // Replaying the same lesson must not hand out a second copy. - const again = recordRun(progress, world1.at(-1)!.id, perfect()); - expect(again.newCreature).toBeNull(); - expect(again.progress.aquarium).toEqual(released); - }); - - it("records a ghost of the best run for the race", () => { - const { progress } = recordRun(freshProgress(), L1, perfect()); - expect(progress.lessons[L1]!.ghost?.length).toBe(perfect().characters); - }); - - it("builds the daily streak and restarts it after a gap", () => { - let progress = recordRun(freshProgress(), L1, perfect(), "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, perfect(), "2026-09-11").progress; - expect(progress.streak.days).toBe(1); - - progress = recordRun(progress, L1, perfect(), "2026-09-12").progress; - expect(progress.streak.days).toBe(2); - - // A missed day restarts at 1, never at 0. - progress = recordRun(progress, L1, perfect(), "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(2); - expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true); - } - }); - - it("keeps a valid save", () => { - const stored = recordRun(freshProgress(), L1, perfect()).progress; - const restored = migrate(JSON.parse(JSON.stringify(stored))); - expect(restored.lessons[L2]!.unlocked).toBe(true); - expect(restored.pearls).toBe(stored.pearls); - }); - - it("drops lessons that no longer exist", () => { - const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, oldLesson: {} } }; - expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["oldLesson"]).toBeUndefined(); - }); - - it("moves the emoji pets of old saves into today's aquarium", () => { - const old = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] }; - expect(migrate(JSON.parse(JSON.stringify(old))).aquarium).toEqual(["clownfish", "octopus"]); - }); - - 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); - }); - - it("restarts the lesson path from an old version, but keeps the aquarium, pearls and streak", () => { - // Version 1's lesson ids don't correspond to today's much finer-grained curriculum, - // so there is nothing sensible to remap them onto - she starts the path over, but - // does not lose what she already earned. - const old = { - version: 1, - lessons: { l09: { unlocked: true, runs: 3, bestStars: 3, bestAnimal: "orca", bestPoints: 999, ghost: null } }, - keyStats: { a: { ema: 400, attempts: 10, errors: 1 } }, - pearls: 42, - aquarium: ["clownfish", "octopus"], - streak: { days: 5, lastPlayed: "2024-01-01" }, - settings: { sound: false, keyboardHint: "off" }, - }; - const restored = migrate(JSON.parse(JSON.stringify(old))); - expect(restored.version).toBe(2); - // "l09" exists in the new curriculum too, just as a different lesson - its old - // three-star, level-99 progress must not carry over onto whatever l09 means now. - expect(restored.lessons["l09"]).toEqual({ unlocked: false, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null }); - expect(restored.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true); - expect(Object.values(restored.lessons).filter((l) => l.unlocked)).toHaveLength(1); - expect(restored.pearls).toBe(42); - expect(restored.aquarium).toEqual(["clownfish", "octopus"]); - expect(restored.keyStats["a"]?.attempts).toBe(10); - expect(restored.streak).toEqual({ days: 5, lastPlayed: "2024-01-01" }); - expect(restored.settings).toEqual({ sound: false, keyboardHint: "off" }); - }); -}); - -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 lesson 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 space bar 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/curriculum.ts b/tippen/src/lib/curriculum.ts deleted file mode 100644 index 52c7735..0000000 --- a/tippen/src/lib/curriculum.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** The lesson plan: content lives in data/curriculum.yaml, this file only derives and - * validates it. - * - * The shape follows what every serious ten-finger course does - start on the home row - * `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 for the Duolingo-style path this app - * wants: every new key gets an **isolated** round (heavy weight, alone) before a - * **mixed** round (lighter weight, blended with everything learned so far) - the same - * new-key / mixed-key / real-content progression typing.com and typingstudy.com use, - * just written as short rounds a six-year-old can climb one at a time. A drill lesson - * follows every couple of keys, and periodically a **fragments** round turns the - * newly-active keys into real short German words - the bridge between drilling letters - * and typing whole words or sentences. - * - * `activeKeys` is cumulative on purpose: lesson 80 still drills `f`, or the first - * lessons rot while the last ones are learned. */ - -import { parse } from "yaml"; - -import curriculumYaml from "../data/curriculum.yaml?raw"; -import type { CreatureId } from "./aquarium"; -import { HOME_ROW, SPACE_KEY } from "./fingers"; - -/** What a lesson's targets are made of. `fragments` is short real syllables/mini-words - - * the bridge between drilling isolated letters and typing whole words or sentences. */ -export type LessonKind = "letters" | "fragments" | "words" | "sentences"; - -/** Which game a lesson can be played as. `pearls` is gone - every other mode measures - * something, and that one deliberately didn't. */ -export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race"; - -export interface Lesson { - id: string; - world: number; - number: number; - title: string; - /** What this lesson is about, in words a six-year-old hears read aloud. */ - subtitle: string; - kind: LessonKind; - /** The keys that became active *this* lesson - empty for a mixed round or a drill. - * Used to size how gently a round starts (see World 1's mirrored pairs) and to check - * the course never introduces more than it should. */ - newKeys: readonly string[]; - /** The keys this round is about, whether or not they are new - what the generator - * over-represents. Equal to `newKeys` for an isolated round, the same keys again - * (already active) for the mixed round that follows it, empty for a drill. */ - spotlightKeys: readonly string[]; - /** Isolated: newly-active keys, drilled alone. Mixed: the same keys, blended with - * everything else. `null` for a drill or for anything that isn't a letters round. */ - emphasis: "isolated" | "mixed" | null; - /** Everything typable in this lesson, cumulative. */ - activeKeys: readonly string[]; - /** The one mode that gates progress this round - passing a run in this mode is what - * can unlock the next lesson. Mostly `dive` for non-letters kinds, but `feed`/`race` - * take a periodic turn so the games stay mixed, not just `dive` end to end. */ - primaryMode: ModeId; - /** Extra replays, offered on the result sheet once this lesson is passed. Never gate - * anything - they exist so a favourite game can be played again. */ - bonusModes: readonly ModeId[]; - /** Real German words, fragments or sentences for the non-letters kinds. */ - words: readonly string[]; - /** True for a pure review round - no fresh content, just practice. Independent of - * `newKeys`: a mixed round also has no new keys but is not a drill. */ - isDrill: boolean; - /** How long one run is. Grows with the curriculum - see `lengthFor`. */ - chunks: number; - chunkSize: number; -} - -export interface World { - number: number; - title: string; - emoji: string; - /** The creature that moves into the aquarium when this world is finished. */ - reward: CreatureId; -} - -/** A world's creature is a pet that stays - a drawing that swims behind every screen - * from then on. An 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. */ -const CREATURE_IDS: readonly CreatureId[] = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]; - -/** Which modes make sense for a kind. Letters rounds are single keys, so only the - * arcade modes fit; fragments/words can be fed to a fish; only words and sentences are - * long enough for a race. */ -export const ELIGIBLE_MODES: Record = { - letters: ["bubbles", "jellyfish"], - fragments: ["dive", "feed"], - words: ["dive", "feed", "race"], - sentences: ["dive", "race"], -}; - -/** Line length by world and kind - a full block of text per round. Letters rounds are - * never rendered through `dive` any more (they are bubbles/jellyfish), but the length is - * still computed for type uniformity and so the "still starts gentler than it ends" - * shape is preserved if a letters round is ever asked for one. */ -function lengthFor(world: number, kind: LessonKind): { chunks: number; chunkSize: number } { - if (kind === "fragments") return { chunks: 16, chunkSize: 4 }; // 64 - if (kind === "sentences") return { chunks: 10, chunkSize: 4 }; // ten whole sentences - if (kind === "words") return { chunks: 25, chunkSize: 4 }; - // kind === "letters" - if (world === 1) return { chunks: 24, chunkSize: 3 }; // 72 characters - if (world === 2) return { chunks: 25, chunkSize: 4 }; // 100 - return { chunks: 30, chunkSize: 4 }; // 120, world 3 onward -} - -interface YamlLesson { - title: string; - subtitle: string; - kind?: LessonKind; - keys?: readonly string[]; - drill?: boolean; - mode?: ModeId; - words?: readonly string[]; -} - -interface YamlWorld { - number: number; - title: string; - emoji: string; - reward: CreatureId; - lessons: readonly YamlLesson[]; -} - -interface YamlRoot { - worlds: readonly YamlWorld[]; -} - -/** Turns a YAML typo into a clear startup error instead of a silently wrong lesson. */ -function validatePlan(worlds: readonly YamlWorld[]): void { - if (worlds.length !== CREATURE_IDS.length) { - throw new Error(`curriculum.yaml: expected ${CREATURE_IDS.length} worlds, found ${worlds.length}`); - } - const rewards = new Set(); - for (const world of worlds) { - if (rewards.has(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} reuses reward "${world.reward}"`); - if (!CREATURE_IDS.includes(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} has an unknown reward "${world.reward}"`); - rewards.add(world.reward); - - world.lessons.forEach((entry, i) => { - const where = `world ${world.number}, lesson ${i + 1} (${entry.title})`; - const words = entry.words ?? []; - const kind: LessonKind = entry.kind ?? "letters"; - if (words.length > 0 && entry.kind === undefined) { - throw new Error(`${where}: has words but no explicit kind`); - } - if (kind === "letters" && words.length > 0) throw new Error(`${where}: kind "letters" cannot have words`); - if (kind !== "letters" && words.length === 0) throw new Error(`${where}: kind "${kind}" needs a non-empty words list`); - if (entry.drill && entry.keys?.length) throw new Error(`${where}: a drill cannot also introduce keys`); - if ((entry.keys?.length ?? 0) > 2) throw new Error(`${where}: at most two keys per lesson`); - if (entry.mode && !ELIGIBLE_MODES[kind].includes(entry.mode)) { - throw new Error(`${where}: mode "${entry.mode}" does not fit kind "${kind}"`); - } - }); - } -} - -function loadPlan(): readonly YamlWorld[] { - const root = parse(curriculumYaml) as YamlRoot; - validatePlan(root.worlds); - return root.worlds; -} - -function buildLessons(worlds: readonly YamlWorld[]): Lesson[] { - const lessons: Lesson[] = []; - const active = new Set(); - const seenBefore = new Set(); - // Letters rounds alternate bubbles/jellyfish across the *whole* course, not per world - // or per pair - a fragments/words lesson in between does not reset the count, so the - // arcade game never repeats twice in a row even across a consolidation gap. - let lastArcade: "bubbles" | "jellyfish" = "jellyfish"; // so lesson 1 opens on bubbles - - for (const world of worlds) { - for (const entry of world.lessons) { - const keys = entry.keys ?? []; - const newKeys = keys.filter((key) => !seenBefore.has(key)); - for (const key of keys) { - seenBefore.add(key); - active.add(key); - } - // The space-bar lesson is where the whole home row comes together, so it - // activates every home key - belt-and-braces confirmation that the four - // finger-pair lessons before it really did cover all eight. - if (keys.includes(SPACE_KEY)) for (const key of HOME_ROW) 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. - const activeKeys = [...active].filter((key) => key !== "⇧").sort(); - const words = entry.words ?? []; - const kind: LessonKind = entry.kind ?? "letters"; - const isDrill = entry.drill === true; - - const emphasis: Lesson["emphasis"] = - kind !== "letters" || isDrill ? null : newKeys.length > 0 ? "isolated" : "mixed"; - - let primaryMode: ModeId; - if (kind === "letters") { - primaryMode = entry.mode ?? (lastArcade === "bubbles" ? "jellyfish" : "bubbles"); - lastArcade = primaryMode as "bubbles" | "jellyfish"; - } else { - primaryMode = entry.mode ?? "dive"; - } - // Bonus replays are only ever feed/race - dive is the plain default, not a treat - // worth offering separately, and bubbles/jellyfish already alternate on their own. - const bonusModes = ELIGIBLE_MODES[kind].filter( - (mode) => (mode === "feed" || mode === "race") && mode !== primaryMode, - ); - - lessons.push({ - id: `l${String(lessons.length + 1).padStart(2, "0")}`, - world: world.number, - number: lessons.length + 1, - title: entry.title, - subtitle: entry.subtitle, - kind, - newKeys, - spotlightKeys: kind === "letters" && !isDrill ? keys : [], - emphasis, - activeKeys, - primaryMode, - bonusModes, - words, - isDrill, - ...lengthFor(world.number, kind), - }); - } - } - - return lessons; -} - -const PLAN = loadPlan(); - -export const WORLDS: readonly World[] = PLAN.map((world) => ({ - number: world.number, - title: world.title, - emoji: world.emoji, - reward: world.reward, -})); - -export const LESSONS: readonly Lesson[] = buildLessons(PLAN); - -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 lessonsOfWorld(world: number): readonly Lesson[] { - return LESSONS.filter((lesson) => lesson.world === world); -} - -/** 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/progress.ts b/tippen/src/lib/progress.ts deleted file mode 100644 index 2fc127f..0000000 --- a/tippen/src/lib/progress.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** 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 { creatureFromRaw } from "./aquarium"; -import type { CreatureId } from "./aquarium"; -import { FIRST_LESSON_ID, LESSONS, WORLDS, nextLesson } from "./curriculum"; -import type { RunResult, AnimalId } 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 DILIGENCE_ATTEMPTS = 5; - -export interface LessonProgress { - unlocked: boolean; - runs: number; - bestStars: 0 | 1 | 2 | 3; - bestAnimal: AnimalId | null; - bestPoints: number; - /** Best-run keystrokes, replayed as the opponent in race mode. */ - 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 { - sound: boolean; - keyboardHint: "auto" | "on" | "off"; -} - -export interface Progress { - version: 2; - lessons: Record; - keyStats: Record; - pearls: number; - /** Pets that have moved into the aquarium, in the order they arrived. */ - aquarium: CreatureId[]; - streak: { days: number; lastPlayed: string | null }; - settings: Settings; -} - -function emptyLesson(unlocked: boolean): LessonProgress { - return { unlocked, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 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: 2, - lessons, - keyStats: {}, - pearls: 0, - aquarium: [], - streak: { days: 0, lastPlayed: null }, - settings: { sound: true, keyboardHint: "auto" }, - }; -} - -/** Everything that survives a curriculum rebuild - the pets, the currency, the per-key - * stats and the streak - pulled defensively off whatever shape was stored, old or new. */ -function carryForward(stored: Record, fresh: Progress): Omit { - return { - keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? (stored.keyStats as Progress["keyStats"]) : {}, - pearls: typeof stored.pearls === "number" ? stored.pearls : 0, - aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [], - streak: - typeof stored.streak === "object" && stored.streak !== null - ? { - days: (stored.streak as Progress["streak"]).days ?? 0, - lastPlayed: (stored.streak as Progress["streak"]).lastPlayed ?? null, - } - : fresh.streak, - settings: { ...fresh.settings, ...((stored.settings as Partial) ?? {}) }, - }; -} - -/** 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 Record; - - // Version 1's lesson ids don't correspond to today's much finer-grained curriculum - - // "l09" used to be "Das E" and might be anything now - so there is nothing sensible to - // remap. She keeps her aquarium, pearls, key stats and streak, and starts the (longer, - // gentler) path over from the first lesson. - if (stored.version !== 2) { - return { version: 2, lessons: fresh.lessons, ...carryForward(stored, fresh) }; - } - - const lessons = { ...fresh.lessons }; - if (typeof stored.lessons === "object" && stored.lessons !== null) { - for (const [id, value] of Object.entries(stored.lessons as Record)) { - // 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: 2, lessons, ...carryForward(stored, fresh) }; -} - -/** Saves from before the pets were drawings hold emoji here. Those map onto the creature - * that fills the same world today; duplicates and anything unrecognisable are dropped, - * so one stray entry cannot put a broken image in the tank. */ -function migrateAquarium(raw: readonly unknown[]): CreatureId[] { - const creatures: CreatureId[] = []; - for (const entry of raw) { - const id = creatureFromRaw(entry); - if (id && !creatures.includes(id)) creatures.push(id); - } - return creatures; -} - -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; -} - -/** World number to the creature it releases, read straight off the worlds table so it - * cannot drift from curriculum.ts. */ -const WORLD_REWARD = new Map(WORLDS.map((world) => [world.number, world.reward])); - -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 world, for the creature that moved in. */ - newCreature: CreatureId | null; - isNewBest: boolean; -} - -/** Record a finished run: stars, animal, pearls, 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, { stars: before.bestStars, points: before.bestPoints }); - const lessons = { ...progress.lessons }; - lessons[lessonId] = { - ...before, - runs, - bestStars: improved ? result.stars : before.bestStars, - bestAnimal: improved ? result.animal : before.bestAnimal, - bestPoints: improved ? result.points : before.bestPoints, - ghost: improved - ? result.strokes.filter((s) => s.correct).map((s) => ({ key: s.key, at: s.at })) - : before.ghost, - }; - - const earned = result.passed || runs >= DILIGENCE_ATTEMPTS; - const next = nextLesson(lessonId); - let unlockedLessonId: string | null = null; - if (earned && next && !lessons[next.id]?.unlocked) { - lessons[next.id] = { ...(lessons[next.id] ?? emptyLesson(false)), unlocked: true }; - unlockedLessonId = next.id; - } - - // Finishing the last lesson of a world releases that world's creature. Checked - // against the aquarium so it is only ever awarded once. - const aquarium = [...progress.aquarium]; - let newCreature: CreatureId | null = null; - if (unlockedLessonId && next) { - const finished = LESSONS.find((l) => l.id === lessonId); - if (finished && next.world !== finished.world) { - const reward = WORLD_REWARD.get(finished.world); - if (reward && !aquarium.includes(reward)) { - aquarium.push(reward); - newCreature = reward; - } - } - } - - return { - progress: { - ...progress, - lessons, - aquarium, - keyStats: foldKeyStats(progress.keyStats, result), - pearls: progress.pearls + result.pearls, - streak: bumpStreak(progress.streak, day), - }, - unlockedLessonId, - newCreature, - isNewBest: improved, - }; -} - - -/** How many times a key must be typed before its stats mean anything. */ -const ENOUGH_ATTEMPTS = 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 lesson 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 practiced = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= ENOUGH_ATTEMPTS); - if (practiced.length === 0) return null; - - // Some keys practised and some not: the gap is the most useful thing to close. - const unpracticed = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < ENOUGH_ATTEMPTS); - if (unpracticed) return unpracticed; - - 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 overallBestAnimal(progress: Progress): AnimalId | null { - let best: AnimalId | null = null; - let bestPoints = -1; - for (const lesson of Object.values(progress.lessons)) { - if (lesson.bestAnimal && lesson.bestPoints > bestPoints) { - best = lesson.bestAnimal; - bestPoints = lesson.bestPoints; - } - } - 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/main.tsx b/tippen/src/main.tsx deleted file mode 100644 index ee6783c..0000000 --- a/tippen/src/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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/tsconfig.json b/tippen/tsconfig.json deleted file mode 100644 index 5ab68ea..0000000 --- a/tippen/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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 deleted file mode 100644 index f57c3ba..0000000 --- a/tippen/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumCreatures.tsx","./src/components/Bubbles.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BubblesRun.tsx","./src/components/modes/DiveRun.tsx","./src/components/modes/FeedRun.tsx","./src/components/modes/JellyfishRun.tsx","./src/components/modes/RaceRun.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/lessonPath.ts","./src/lib/modeInfo.ts","./src/lib/pop.ts","./src/lib/progress.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__/lessonPath.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 deleted file mode 100644 index 0981656..0000000 --- a/tippen/vite.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 }, -}); diff --git a/tippen/public/aquarium/clownfisch.webp b/web/public/aquarium/clownfisch.webp similarity index 100% rename from tippen/public/aquarium/clownfisch.webp rename to web/public/aquarium/clownfisch.webp diff --git a/tippen/public/aquarium/krake.webp b/web/public/aquarium/krake.webp similarity index 100% rename from tippen/public/aquarium/krake.webp rename to web/public/aquarium/krake.webp diff --git a/tippen/public/aquarium/perlmuschel.webp b/web/public/aquarium/perlmuschel.webp similarity index 100% rename from tippen/public/aquarium/perlmuschel.webp rename to web/public/aquarium/perlmuschel.webp diff --git a/tippen/public/aquarium/schildkroete.webp b/web/public/aquarium/schildkroete.webp similarity index 100% rename from tippen/public/aquarium/schildkroete.webp rename to web/public/aquarium/schildkroete.webp diff --git a/tippen/public/aquarium/seepferdchen.webp b/web/public/aquarium/seepferdchen.webp similarity index 100% rename from tippen/public/aquarium/seepferdchen.webp rename to web/public/aquarium/seepferdchen.webp diff --git a/web/src/App.tsx b/web/src/App.tsx index 527f273..789ef69 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -22,6 +22,8 @@ import { PlayerBar } from "./components/PlayerBar"; import { PlayView } from "./components/PlayView"; import { RemoteAssignPopup } from "./components/RemoteAssignPopup"; import { RoomView } from "./components/RoomView"; +import { TabRail } from "./components/TabRail"; +import { TippenApp } from "./components/TippenApp"; import { useGridColumns } from "./hooks/useGridColumns"; import { useLibrary } from "./hooks/useLibrary"; import { usePlayerState } from "./hooks/usePlayerState"; @@ -401,8 +403,8 @@ export function App() { } run(browseBackActions(ui)); }; - const onToggleRoom = () => - setUi((previous) => ({ ...previous, page: previous.page === "room" ? "music" : "room" })); + const onSelectPage = (page: UiState["page"]) => + setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page })); return (
@@ -497,6 +499,10 @@ export function App() { {ui.page === "room" && haConfig && } + {ui.page === "typing" && ( + setUi((previous) => ({ ...previous, page: "music" }))} /> + )} + {openAlbum && state && ( )} - {haConfig && ( - - {ui.page === "room" ? "🎵" : "💡"} - - )} + {ui.showHelp && ( setUi((previous) => ({ ...previous, showHelp: false }))} /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7999be0..32e4b88 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -9,6 +9,11 @@ import type { RemoteMapping, RemoteSlotInput, Settings, + TippenCurriculum, + TippenProgress, + TippenRunInput, + TippenRunResult, + TippenSettings, TrackDetail, } from "./types"; @@ -53,6 +58,11 @@ const fetchHaConfig = (): Promise => fetchOrNullOn404 /** `null` means the IR remote isn't configured, not an error. */ const fetchLircConfig = (): Promise => fetchOrNullOn404("/lirc"); +/** `null` means the typing game isn't configured, not an error - the tab still shows, + * just with a "not set up" placeholder instead of a lesson map. */ +const fetchTippenCurriculum = (): Promise => + fetchOrNullOn404("/tippen/curriculum"); + /** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable * right now" (the backend answers the latter with a 502) - the room page treats a * device with no state the same way either way, rather than crashing on a poll. */ @@ -103,6 +113,13 @@ export const api = { remoteMapping: () => request("/remote/mapping"), saveRemoteMapping: (slots: Record) => request("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }), + + tippenCurriculum: fetchTippenCurriculum, + tippenProgress: () => request("/tippen/progress"), + saveTippenSettings: (settings: TippenSettings) => + request("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }), + recordTippenRun: (body: TippenRunInput) => + request("/tippen/runs", { method: "POST", body: JSON.stringify(body) }), }; export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index ac761b0..2e8b660 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -34,11 +34,24 @@ export interface TrackDetail { curve: TrackCurves | null; } +/** Which typing lesson unlocks a still-locked track - the browse view's "unlocks + * when..." hint. Mirrors `UnlockHintOut`. */ +export interface UnlockHint { + lesson_id: string; + lesson_title: string; + world_number: number; + world_title: string; +} + export interface Track { title: string; /** Seconds, read from the file's tags at scan time. */ duration: number; analysis: TrackAnalysis | null; + /** A typing-reward track not yet earned. Only ever `true` when the typing game is + * configured on the backend. */ + locked: boolean; + unlock_hint: UnlockHint | null; } export interface Album { @@ -56,6 +69,8 @@ export interface Album { has_cover: boolean; duration: number; tracks: Track[]; + /** Every track is still locked - show a question mark instead of cover art. */ + locked: boolean; } export interface PlayerState { @@ -135,3 +150,131 @@ export interface RemoteSlotInput { export interface LircConfig { connected: boolean; } + +// --------------------------------------------------------------------------- tippen +// +// The typing game. `null` from `GET /tippen/curriculum` means the whole feature isn't +// configured, like `HaConfig` above - the tab still shows, just with nothing behind it. +// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`. + +export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences"; +export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race"; + +/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the + * configured path matches nothing in the current library. */ +export interface TippenReward { + resolved: boolean; + album_id: string | null; + has_cover: boolean; + kind: "tracks" | "episode" | null; +} + +export interface TippenLesson { + id: string; + world: number; + number: number; + title: string; + subtitle: string; + kind: TippenLessonKind; + new_keys: string[]; + spotlight_keys: string[]; + emphasis: "isolated" | "mixed" | null; + active_keys: string[]; + primary_mode: TippenModeId; + bonus_modes: TippenModeId[]; + words: string[]; + is_drill: boolean; + chunks: number; + chunk_size: number; + reward: TippenReward; +} + +export interface TippenWorld { + number: number; + title: string; + emoji: string; + reward: string; +} + +export interface TippenCurriculum { + worlds: TippenWorld[]; + lessons: TippenLesson[]; +} + +export interface TippenGhostStroke { + key: string; + at: number; +} + +export interface TippenLessonProgress { + unlocked: boolean; + runs: number; + best_stars: 0 | 1 | 2 | 3; + best_animal: string | null; + best_points: number; + /** Derived server-side, not stored - two stars, or five attempts regardless. */ + earned: boolean; + ghost: TippenGhostStroke[] | null; +} + +export interface TippenKeyStat { + ema: number; + attempts: number; + errors: number; +} + +export interface TippenStreak { + days: number; + last_played: string | null; +} + +export interface TippenSettings { + sound: boolean; + keyboard_hint: "auto" | "on" | "off"; +} + +export interface TippenProgress { + lessons: Record; + key_stats: Record; + pearls: number; + aquarium: string[]; + streak: TippenStreak; + settings: TippenSettings; +} + +export interface TippenStroke { + key: string; + expected: string; + correct: boolean; + at: number; +} + +/** A run the client already graded - see `lib/tippen/grading.ts`. Grading stays + * client-side; the backend only owns progress bookkeeping. */ +export interface TippenRunInput { + lesson_id: string; + stars: 0 | 1 | 2 | 3; + animal: string; + points: number; + passed: boolean; + pearls: number; + strokes: TippenStroke[]; +} + +/** The literal track/episode this run's lesson names in its own `unlocks:` - what the + * unlock animation shows, via the existing `/api/albums/{id}/cover`. */ +export interface TippenUnlockedReward { + album_id: string; + title: string; + has_cover: boolean; + kind: "album" | "book" | "podcast_episode"; +} + +export interface TippenRunResult { + progress: TippenProgress; + unlocked_lesson_id: string | null; + unlocked_lesson_title: string | null; + new_creature: string | null; + is_new_best: boolean; + unlocked_reward: TippenUnlockedReward | null; +} diff --git a/web/src/components/AlbumModal.tsx b/web/src/components/AlbumModal.tsx index d873a4f..d950db9 100644 --- a/web/src/components/AlbumModal.tsx +++ b/web/src/components/AlbumModal.tsx @@ -67,7 +67,13 @@ export function AlbumModal({ >
- +
-
- {track.title} -
-
- {clock(track.duration)} +
+
+ {track.locked ? "❓ Noch gesperrt" : track.title} +
+ {track.locked && track.unlock_hint && ( +
+ Freigeschaltet nach „{track.unlock_hint.lesson_title}" (Welt{" "} + {track.unlock_hint.world_number}) +
+ )}
+ {!track.locked && ( +
+ {clock(track.duration)} +
+ )} ); })} diff --git a/web/src/components/BrowseView.tsx b/web/src/components/BrowseView.tsx index 3d95a83..79eb452 100644 --- a/web/src/components/BrowseView.tsx +++ b/web/src/components/BrowseView.tsx @@ -425,6 +425,7 @@ export function BrowseView({ // Fixed regardless of how many lines the title actually needs, so // every card - and thus every row of covers - is the same height. const titleLines = showArtist ? 2 : 3; + const hint = album.locked ? (album.tracks[0]?.unlock_hint ?? null) : null; return (
); diff --git a/web/src/components/Cover.tsx b/web/src/components/Cover.tsx index fd2595b..ffc996b 100644 --- a/web/src/components/Cover.tsx +++ b/web/src/components/Cover.tsx @@ -26,9 +26,12 @@ interface Props { style?: CSSProperties; /** Print the title over generated art. Off for thumbnails, where it would not fit. */ label?: boolean; + /** A reward-gated album/track not yet earned - shows a question mark instead of the + * real art or title, regardless of `has_cover`. */ + locked?: boolean; } -export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) { +export function Cover({ album, size, fit = "width", radius, className, style, label, locked }: Props) { const book = isBook(album); const defaultRadius = book ? "6px 18px 18px 6px" : "16px"; const box: CSSProperties = @@ -49,7 +52,7 @@ export function Cover({ album, size, fit = "width", radius, className, style, la ...style, }} > - {!album.has_cover && label && ( + {locked ? (
- {album.title} + ❓
- )} - {album.has_cover && ( - + ) : ( + <> + {!album.has_cover && label && ( +
+ {album.title} +
+ )} + {album.has_cover && ( + + )} + )}
); diff --git a/web/src/components/TabRail.tsx b/web/src/components/TabRail.tsx new file mode 100644 index 0000000..ff18cea --- /dev/null +++ b/web/src/components/TabRail.tsx @@ -0,0 +1,65 @@ +/** The tab rail: icon-only, vertically centered on the right edge - audio player and + * typing always shown, the smarthome tab only when Home Assistant is configured, same + * gate the old single toggle button used. Replaces that button now that there are + * three destinations instead of two. */ + +import { PAGE_ICON, PAGE_LABEL } from "../lib/theme"; +import type { UiState } from "../lib/keyboard"; + +interface Props { + page: UiState["page"]; + onSelect: (page: UiState["page"]) => void; + showRoom: boolean; +} + +const PAGES: readonly UiState["page"][] = ["music", "typing", "room"]; + +export function TabRail({ page, onSelect, showRoom }: Props) { + const pages = PAGES.filter((candidate) => candidate !== "room" || showRoom); + + return ( +
+ {pages.map((candidate) => { + const active = candidate === page; + return ( + + ); + })} +
+ ); +} diff --git a/tippen/src/App.tsx b/web/src/components/TippenApp.tsx similarity index 54% rename from tippen/src/App.tsx rename to web/src/components/TippenApp.tsx index dbfb118..f061a46 100644 --- a/tippen/src/App.tsx +++ b/web/src/components/TippenApp.tsx @@ -1,55 +1,54 @@ -/** The whole app: one state object, one keydown listener for navigation, four screens. +/** The typing game, as a tab of the music player rather than its own app. * - * 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. + * Ported from the standalone tippen app's App.tsx - same shape (one state object, one + * keydown listener for navigation, four screens) - with two changes: curriculum and + * progress are now fetched from the backend instead of a build-time YAML import and + * localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and + * `onExit` is the new base case for "back" - Escape/the map's own navigation peel one + * layer at a time, same as before, but the aquarium screen is no longer the floor: one + * more Escape leaves the tab entirely, back to the music player. * - * 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. */ + * The rule that still matters most: **while a run is going, every key belongs to the + * run**. This component's own keydown listener only ever intercepts Escape and F1 - the + * run's own listener lives in hooks/useTippenRun.ts. The music player's own global + * keydown listener (lib/keyboard.ts) is kept out of this entirely: it bails out + * immediately whenever the typing tab is active, so the two never fight over a key. */ 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 { ResultSheet } from "./components/ResultSheet"; -import { Stage } from "./components/Stage"; -import { BubblesRun } from "./components/modes/BubblesRun"; -import { FeedRun } from "./components/modes/FeedRun"; -import { JellyfishRun } from "./components/modes/JellyfishRun"; -import { RaceRun } from "./components/modes/RaceRun"; -import { DiveRun } from "./components/modes/DiveRun"; -import type { CreatureId } 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 { overallBestAnimal, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress"; -import type { Progress } from "./lib/progress"; -import { bubbleCountFor } from "./lib/theme"; +import { useTippenCurriculum } from "../hooks/useTippenCurriculum"; +import { useTippenProgress } from "../hooks/useTippenProgress"; +import { Aquarium } from "./tippen/Aquarium"; +import { AppHeader } from "./tippen/AppHeader"; +import { HelpOverlay } from "./tippen/HelpOverlay"; +import { LessonMap } from "./tippen/LessonMap"; +import { ResultSheet } from "./tippen/ResultSheet"; +import { Stage } from "./tippen/Stage"; +import { BubblesRun } from "./tippen/modes/BubblesRun"; +import { DiveRun } from "./tippen/modes/DiveRun"; +import { FeedRun } from "./tippen/modes/FeedRun"; +import { JellyfishRun } from "./tippen/modes/JellyfishRun"; +import { RaceRun } from "./tippen/modes/RaceRun"; +import type { CreatureId } from "../lib/tippen/aquarium"; +import type { Lesson, ModeId } from "../lib/tippen/curriculum"; +import { lessonById, nextLesson } from "../lib/tippen/curriculum"; +import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator"; +import type { RunResult } from "../lib/tippen/grading"; +import { playFanfare, playPop } from "../lib/tippen/pop"; +import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress"; +import type { UnlockedReward } from "../lib/tippen/progress"; +import { bubbleCountFor } from "../lib/tippen/theme"; type Screen = "aquarium" | "map" | "run"; -/** What a mode is handed to draw. Tagged rather than optional-fielded so the render - * below narrows on `kind` instead of guessing from which keys are present. - * - * Only two shapes, for five 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. */ +/** What a mode is handed to draw - see the original App.tsx for why there are only two + * shapes for five modes. */ type RunTarget = | { kind: "letters"; letters: readonly string[] } | { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean }; -/** Modes that drill one key at a time rather than a line. */ const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"]; -/** How much of a letters round the spotlighted key(s) should take. An isolated round - - * the key's very first lesson - drills it hard; the mixed round right after blends it - * back in with everything else, which is the whole point of "isolated then mixed". */ const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 }; interface Outcome { @@ -57,10 +56,18 @@ interface Outcome { unlockedTitle: string | null; newCreature: CreatureId | null; isNewBest: boolean; + unlockedReward: UnlockedReward | null; } -export function App() { - const [progress, setProgress] = useState(loadProgress); +interface Props { + onExit: () => void; +} + +export function TippenApp({ onExit }: Props) { + const { curriculum, loading: curriculumLoading } = useTippenCurriculum(); + const configured = curriculum !== null; + const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured); + const [screen, setScreen] = useState("aquarium"); const [lessonId, setLessonId] = useState(null); const [mode, setMode] = useState("dive"); @@ -70,20 +77,19 @@ export function App() { /** Bumped to generate a fresh line - a new seed for the same lesson. */ const [round, setRound] = useState(0); - useEffect(() => saveProgress(progress), [progress]); - - const lesson = lessonId === null ? null : lessonById(lessonId); + const lesson = lessonId !== null && curriculum ? lessonById(curriculum, lessonId) : null; /** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */ const nextUp = useMemo(() => { - const unlocked = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked); + if (!curriculum || !progress) return null; + const unlocked = curriculum.lessons.filter((l) => progress.lessons[l.id]?.unlocked); return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null; - }, [progress]); + }, [curriculum, 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 run = useMemo((): RunTarget | null => { - if (!lesson) return null; + if (!lesson || !progress) return null; const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0); const rng = mulberry32(seed); const focusKey = focusKeyFor(progress, lesson.activeKeys); @@ -105,15 +111,9 @@ export function App() { ), }; } - const chunks = lineFor(lesson, rng, { - chunks: lesson.chunks, - chunkSize: lesson.chunkSize, - focusKey, - }); + const chunks = lineFor(lesson, rng, { chunks: lesson.chunks, chunkSize: lesson.chunkSize, focusKey }); return { kind: "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. + // `progress` is deliberately not a dependency - see the original App.tsx. // eslint-disable-next-line react-hooks/exhaustive-deps }, [lesson, mode, round]); @@ -127,22 +127,24 @@ export function App() { const onFinished = useCallback( (result: RunResult) => { - if (!lessonId) return; - const recorded = recordRun(progress, lessonId, result); - setProgress(recorded.progress); - setOutcome({ - result, - unlockedTitle: recorded.unlockedLessonId - ? (lessonById(recorded.unlockedLessonId)?.title ?? null) - : null, - newCreature: recorded.newCreature, - isNewBest: recorded.isNewBest, + if (!lessonId || !progress) return; + void recordRun(lessonId, result).then((recorded) => { + setOutcome({ + result, + unlockedTitle: recorded.unlockedLessonTitle, + newCreature: recorded.newCreature, + isNewBest: recorded.isNewBest, + unlockedReward: recorded.unlockedReward, + }); + if ( + progress.settings.sound && + (recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward) + ) { + playFanfare(); + } }); - if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) { - playFanfare(); - } }, - [lessonId, progress], + [lessonId, progress, recordRun], ); const retry = useCallback(() => { @@ -150,10 +152,9 @@ export function App() { setRound((r) => r + 1); }, []); - /** A bonus replay in a mode this lesson didn't gate progress on - feed or race, - * offered on the result sheet once the lesson is passed. Never touches the unlock: - * `onFinished` still runs underneath, so a great bonus run can only improve the best - * score, not change what is unlocked. */ + /** A bonus replay in a mode this lesson didn't gate progress on - never touches the + * unlock: `onFinished` still runs underneath, so a great bonus run can only improve + * the best score, not change what is unlocked. */ const playBonus = useCallback((bonusMode: ModeId) => { setMode(bonusMode); setOutcome(null); @@ -161,22 +162,23 @@ export function App() { }, []); const continueAfterResult = useCallback(() => { - const next = lessonId ? nextLesson(lessonId) : null; + const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null; setOutcome(null); - if (next && progress.lessons[next.id]?.unlocked) start(next); + if (next && progress?.lessons[next.id]?.unlocked) start(next); else setScreen("map"); - }, [lessonId, progress, start]); + }, [lessonId, curriculum, progress, start]); const goBack = useCallback(() => { if (outcome) return setOutcome(null); if (screen === "run") return setScreen("map"); if (screen === "map") return setScreen("aquarium"); - }, [outcome, screen]); + if (screen === "aquarium") return onExit(); + }, [outcome, screen, onExit]); // --- navigation keys ----------------------------------------------------- - const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start }); - latest.current = { screen, outcome, selected, goBack, retry, nextUp, start }; + const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum }); + latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum }; useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -206,25 +208,26 @@ export function App() { // Everything below is navigation, and must not fire while typing. if (current.screen === "run") return; + if (!current.curriculum) return; if (event.key === "Enter") { event.preventDefault(); if (current.screen === "aquarium") { if (current.nextUp) current.start(current.nextUp); } else { - const lesson = LESSONS[current.selected]; + const lesson = current.curriculum.lessons[current.selected]; if (lesson) current.start(lesson); } return; } if (current.screen !== "map") return; - // The map is a vertical path now, so "next" is down rather than to the right. + // The map is a vertical path, so "next" is down rather than to the right. const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; if (step !== 0) { event.preventDefault(); playPop(340); - setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + step))); + setSelected((index) => Math.min(current.curriculum!.lessons.length - 1, Math.max(0, index + step))); } }; @@ -236,34 +239,44 @@ export function App() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (screen === "run" && !outcome) return; + if (!progress) return; const key = event.key.toLowerCase(); - if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } })); + if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound }); if (key === "h") { - setProgress((p) => ({ - ...p, - settings: { - ...p.settings, - keyboardHint: p.settings.keyboardHint === "off" ? "auto" : "off", - }, - })); + void saveSettings({ + ...progress.settings, + keyboardHint: progress.settings.keyboardHint === "off" ? "auto" : "off", + }); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [screen, outcome]); + }, [screen, outcome, progress, saveSettings]); // --- render -------------------------------------------------------------- - const next = lessonId ? nextLesson(lessonId) : null; + if (curriculumLoading || (configured && progressLoading) || !progress) { + return ( + + + + + ); + } - // 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 shared = { - activeKeys: lesson?.activeKeys ?? [], - progress, - paused: outcome !== null, - onFinished, - }; + if (!curriculum) { + return ( + + + + + ); + } + + const next = lessonId ? nextLesson(curriculum, lessonId) : null; + + // Every mode takes the same bundle; only the drawing differs. + const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished }; const letterProps = (letters: readonly string[]) => ({ letters, ...shared }); const textProps = (r: Extract) => ({ chunks: r.chunks, @@ -287,6 +300,7 @@ export function App() { {screen === "aquarium" && ( nextUp && start(nextUp)} @@ -295,7 +309,7 @@ export function App() { )} {screen === "map" && ( - + )} {screen === "run" && lesson && run && ( @@ -321,15 +335,14 @@ export function App() { result={outcome.result} unlockedTitle={outcome.unlockedTitle} newCreature={outcome.newCreature} + unlockedReward={outcome.unlockedReward} isNewBest={outcome.isNewBest} bestEver={overallBestAnimal(progress)} bonusModes={outcome.result.passed ? lesson.bonusModes : []} onPlayBonus={playBonus} onRetry={retry} onContinue={continueAfterResult} - continueLabel={ - next && progress.lessons[next.id]?.unlocked ? `${next.title} ▶` : "Zur Karte" - } + continueLabel={next && progress.lessons[next.id]?.unlocked ? `${next.title} ▶` : "Zur Karte"} /> )} @@ -337,3 +350,23 @@ export function App() { ); } + +function LoadingScreen({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} diff --git a/tippen/src/components/AppHeader.tsx b/web/src/components/tippen/AppHeader.tsx similarity index 100% rename from tippen/src/components/AppHeader.tsx rename to web/src/components/tippen/AppHeader.tsx diff --git a/tippen/src/components/Aquarium.tsx b/web/src/components/tippen/Aquarium.tsx similarity index 87% rename from tippen/src/components/Aquarium.tsx rename to web/src/components/tippen/Aquarium.tsx index f767e3c..02ab6d1 100644 --- a/tippen/src/components/Aquarium.tsx +++ b/web/src/components/tippen/Aquarium.tsx @@ -9,14 +9,14 @@ * pale outlines. The same idea as the animal 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 { creatureById } from "../lib/aquarium"; -import type { Lesson } from "../lib/curriculum"; -import { WORLDS } from "../lib/curriculum"; -import { animalById } from "../lib/grading"; -import { overallBestAnimal } from "../lib/progress"; -import type { Progress } from "../lib/progress"; +import { creatureById } from "../../lib/tippen/aquarium"; +import type { Lesson, World } from "../../lib/tippen/curriculum"; +import { animalById } from "../../lib/tippen/grading"; +import { overallBestAnimal } from "../../lib/tippen/progress"; +import type { Progress } from "../../lib/tippen/progress"; interface Props { + worlds: readonly World[]; progress: Progress; /** The lesson the "Weiter üben" button jumps to - the first unfinished one. */ nextLesson: Lesson | null; @@ -24,7 +24,7 @@ interface Props { onOpenMap: () => void; } -export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props) { +export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) { const bestAnimal = overallBestAnimal(progress); return ( @@ -42,7 +42,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props) }} >
@@ -60,7 +60,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props) alignItems: "center", }} > - {WORLDS.map((world) => { + {worlds.map((world) => { const creature = creatureById(world.reward); const owned = progress.aquarium.includes(creature.id); return ( @@ -118,7 +118,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props) ▶ {nextLesson.title} )} -
diff --git a/tippen/src/components/AquariumCreatures.tsx b/web/src/components/tippen/AquariumCreatures.tsx similarity index 97% rename from tippen/src/components/AquariumCreatures.tsx rename to web/src/components/tippen/AquariumCreatures.tsx index 08338ef..1898591 100644 --- a/tippen/src/components/AquariumCreatures.tsx +++ b/web/src/components/tippen/AquariumCreatures.tsx @@ -14,8 +14,8 @@ import { useEffect, useRef } from "react"; -import { creatureById, createSwimmer, pose, stepSwimmer } from "../lib/aquarium"; -import type { CreatureId, Swimmer } from "../lib/aquarium"; +import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium"; +import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium"; interface Props { creatures: readonly CreatureId[]; diff --git a/tippen/src/components/Bubbles.tsx b/web/src/components/tippen/Bubbles.tsx similarity index 100% rename from tippen/src/components/Bubbles.tsx rename to web/src/components/tippen/Bubbles.tsx diff --git a/tippen/src/components/HelpOverlay.tsx b/web/src/components/tippen/HelpOverlay.tsx similarity index 85% rename from tippen/src/components/HelpOverlay.tsx rename to web/src/components/tippen/HelpOverlay.tsx index 6238ce8..21de8c1 100644 --- a/tippen/src/components/HelpOverlay.tsx +++ b/web/src/components/tippen/HelpOverlay.tsx @@ -16,8 +16,8 @@ const KEYS: readonly [string, string][] = [ export function HelpOverlay({ onClose }: Props) { return ( -
-
+
+
⌨️ Zaubertasten
diff --git a/tippen/src/components/Keyboard.tsx b/web/src/components/tippen/Keyboard.tsx similarity index 82% rename from tippen/src/components/Keyboard.tsx rename to web/src/components/tippen/Keyboard.tsx index a7b7f3f..4781756 100644 --- a/tippen/src/components/Keyboard.tsx +++ b/web/src/components/tippen/Keyboard.tsx @@ -10,9 +10,9 @@ * 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"; +import { KEYBOARD_ROWS, fingerOf, keyForChar, keysInRow, needsShift, shiftHandFor } from "../../lib/tippen/fingers"; +import type { Progress } from "../../lib/tippen/progress"; +import { mastery } from "../../lib/tippen/progress"; interface Props { /** Keys this lesson uses - everything else is dimmed. */ @@ -39,9 +39,9 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro const shiftHand = shift && nextKey !== null ? shiftHandFor(nextKey) : null; return ( -