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

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

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

View File

@@ -1,17 +1,17 @@
import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS, WELTEN, lessonById, lessonsOfWelt, nextLesson } from "../curriculum";
import { 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.nummer).toBe(i + 1));
LESSONS.forEach((lesson, i) => expect(lesson.number).toBe(i + 1));
});
it("starts on the two keys with the tactile bumps", () => {
expect(LESSONS[0]!.neueKeys).toEqual(["f", "j"]);
expect(LESSONS[0]!.newKeys).toEqual(["f", "j"]);
expect(FIRST_LESSON_ID).toBe(LESSONS[0]!.id);
});
@@ -19,50 +19,50 @@ describe("LESSONS", () => {
// The first draft opened with all four left-hand keys at once, which is a steep
// first five minutes for a six-year-old. Two at a time, always.
for (const lesson of LESSONS) {
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
}
});
it("teaches Welt 1 as mirrored finger pairs, one finger per hand", () => {
const paare = lessonsOfWelt(1).filter((lesson) => lesson.neueKeys.length === 2);
expect(paare.length).toBe(4);
for (const lesson of paare) {
const [links, rechts] = lesson.neueKeys.map((key) => fingerOf(key)!);
expect(links!.hand).toBe("links");
expect(rechts!.hand).toBe("rechts");
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(links!.id.replace("links-", "")).toBe(rechts!.id.replace("rechts-", ""));
expect(left!.id.replace("left-", "")).toBe(right!.id.replace("right-", ""));
}
});
it("has the whole Grundstellung active by the end of Welt 1", () => {
const letzte = lessonsOfWelt(1).at(-1)!;
for (const key of "asdfjklö") expect(letzte.activeKeys).toContain(key);
expect(letzte.activeKeys).toContain(" ");
it("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 Welt 1 - long enough to finish, too short
// 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.art === "saetze") {
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(10);
} else if (lesson.art === "woerter") {
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(25);
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.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(60);
expect(lesson.chunks * lesson.chunkSize, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(60);
}
}
});
it("still starts gentler than it ends", () => {
const laenge = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
expect(laenge(lessonsOfWelt(3)[0]!)).toBeGreaterThan(laenge(lessonsOfWelt(1)[0]!));
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.art === "saetze")) {
expect(lesson.woerter.length, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(lesson.chunks);
for (const lesson of LESSONS.filter((l) => l.kind === "sentences")) {
expect(lesson.words.length, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(lesson.chunks);
}
});
@@ -76,7 +76,7 @@ describe("LESSONS", () => {
it("introduces every new key into its own activeKeys", () => {
for (const lesson of LESSONS) {
for (const key of lesson.neueKeys) {
for (const key of lesson.newKeys) {
if (key === "⇧") continue; // Shift is not a character the generator can emit.
expect(lesson.activeKeys).toContain(key);
}
@@ -99,27 +99,27 @@ describe("LESSONS", () => {
// so a lesson can type them as soon as it has the ß or a key.
for (const lesson of LESSONS) {
const active = new Set(lesson.activeKeys);
for (const wort of lesson.woerter) {
for (const char of wort) {
for (const word of lesson.words) {
for (const char of word) {
expect(
active.has(keyForChar(char)),
`Lektion ${lesson.nummer} (${lesson.titel}): "${wort}" braucht "${char}" (Taste ${keyForChar(char)})`,
`Lektion ${lesson.number} (${lesson.title}): "${word}" braucht "${char}" (Taste ${keyForChar(char)})`,
).toBe(true);
}
}
}
});
it("only uses shifted characters once the Umschalttaste is taught", () => {
const shiftAb = LESSONS.find((lesson) => lesson.neueKeys.includes("⇧"))!.nummer;
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 wort of lesson.woerter) {
for (const char of wort) {
for (const word of lesson.words) {
for (const char of word) {
if (!needsShift(char)) continue;
expect(
lesson.nummer,
`Lektion ${lesson.nummer}: "${wort}" braucht Umschalt für "${char}"`,
).toBeGreaterThanOrEqual(shiftAb);
lesson.number,
`Lektion ${lesson.number}: "${word}" braucht Umschalt für "${char}"`,
).toBeGreaterThanOrEqual(shiftFrom);
}
}
}
@@ -127,59 +127,59 @@ describe("LESSONS", () => {
it("only offers word modes where words exist", () => {
for (const lesson of LESSONS) {
const wortModi = lesson.modi.some((modus) => modus === "fuettern" || modus === "rennen");
expect(wortModi).toBe(lesson.woerter.length > 0);
const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race");
expect(wordModes).toBe(lesson.words.length > 0);
}
});
it("always offers the pressure-free Perlentaucher", () => {
for (const lesson of LESSONS) expect(lesson.modi).toContain("perlen");
it("always offers the pressure-free pearl-diving mode", () => {
for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls");
});
it("teaches capitals only once the Umschalttaste exists", () => {
it("teaches capitals only once shift exists", () => {
for (const lesson of LESSONS) {
const hatGross = lesson.woerter.some((wort) => wort !== wort.toLowerCase());
if (hatGross) expect(lesson.welt).toBeGreaterThanOrEqual(4);
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 Welt 1", () => {
// One key at a time is the pacing decision: Welt 1 pairs a finger across both
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.welt === 1) continue;
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
if (lesson.world === 1) continue;
expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
}
});
it("follows every pair of new keys with an Übung", () => {
const uebungen = LESSONS.filter((lesson) => lesson.istUebung);
expect(uebungen.length).toBeGreaterThanOrEqual(12);
// An Übung never introduces anything, and always has something to practise.
for (const lesson of uebungen) {
expect(lesson.neueKeys).toEqual([]);
it("follows every pair of new keys with a drill", () => {
const drills = LESSONS.filter((lesson) => lesson.isDrill);
expect(drills.length).toBeGreaterThanOrEqual(12);
// 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 course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(40);
expect(WELTEN.length).toBe(5);
expect(WORLDS.length).toBe(5);
});
it("never leaves a Welt without a run of at least three lessons", () => {
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThanOrEqual(3);
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("Welten", () => {
it("has lessons in every Welt", () => {
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThan(0);
describe("Worlds", () => {
it("has lessons in every world", () => {
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThan(0);
});
it("gives every Welt its own aquarium creature", () => {
const belohnungen = WELTEN.map((welt) => welt.belohnung);
expect(new Set(belohnungen).size).toBe(belohnungen.length);
it("gives every world its own aquarium creature", () => {
const rewards = WORLDS.map((world) => world.reward);
expect(new Set(rewards).size).toBe(rewards.length);
});
});
@@ -193,7 +193,7 @@ describe("navigation", () => {
});
it("looks lessons up by id", () => {
expect(lessonById(FIRST_LESSON_ID)?.nummer).toBe(1);
expect(lessonById(FIRST_LESSON_ID)?.number).toBe(1);
expect(lessonById("gibt-es-nicht")).toBeNull();
});
});