From ba4525daf336651e16d94873cf87adada747b85b Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Sun, 29 Mar 2026 11:12:13 +0200 Subject: [PATCH] feat(01-04): wire lesson screen UI with keyboard and falling letters - Lesson screen HTML with letter-area and keyboard containers - initLessonScreen: renders keyboard, shows letters, handles keypress - Correct key dissolves letter with animation, advances to next - Wrong key does nothing (per D-03), correct key stays highlighted - startLesson in app.ts: manages lesson lifecycle and cleanup - onComplete: updates progress, unlocks next level, saves to IndexedDB - CSS: floating letter animation, dissolve effect on correct press Co-Authored-By: Claude Opus 4.6 (1M context) --- index.html | 11 ++++++- src/app.ts | 54 ++++++++++++++++++++++++++++++---- src/styles/main.css | 53 ++++++++++++++++++++++++++++++++++ src/ui/screens.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index 67f127c..b780cd6 100644 --- a/index.html +++ b/index.html @@ -28,7 +28,16 @@
-
+
+
+
+ +
+
+ +
+
+
diff --git a/src/app.ts b/src/app.ts index 5f75f12..c391b3d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,9 @@ -import type { ScreenName } from "./types"; -import { openDB, getProgress } from "./storage/db"; -import { initWelcomeScreen } from "./ui/screens"; +import type { ScreenName, KeyboardLayout } from "./types"; +import { openDB, getProgress, saveProgress } from "./storage/db"; +import { initWelcomeScreen, initLessonScreen } from "./ui/screens"; let db: IDBDatabase; +let lessonCleanup: (() => void) | null = null; export function showScreen(name: ScreenName): void { document.querySelectorAll(".screen").forEach((el) => { @@ -18,14 +19,57 @@ export function getDB(): IDBDatabase { return db; } +export async function startLesson( + level: number, + layout: KeyboardLayout, +): Promise { + // Clean up previous lesson if any + if (lessonCleanup) { + lessonCleanup(); + lessonCleanup = null; + } + + showScreen("lesson"); + + lessonCleanup = initLessonScreen(db, level, layout, async (completedLevel: number) => { + // Update progress after lesson completion + const progress = await getProgress(db); + if (!progress) return; + + // Add level to completedLevels if not already there + if (!progress.completedLevels.includes(completedLevel)) { + progress.completedLevels.push(completedLevel); + } + + // Advance to next level if this was the current level (LEVL-02, LEVL-03) + if (completedLevel === progress.currentLevel) { + progress.currentLevel = completedLevel + 1; + } + + // Increment session count + progress.totalSessions++; + + // Add today's date if not already recorded + const today = new Date().toISOString().split("T")[0]!; + if (!progress.sessionDates.includes(today)) { + progress.sessionDates.push(today); + } + + await saveProgress(db, progress); + + // Navigate to forest (per D-07: skip reward in Phase 1) + showScreen("forest"); + }); +} + export async function initApp(): Promise { db = await openDB(); const progress = await getProgress(db); if (progress && progress.selectedCharacter) { - // Returning user — skip welcome, go to forest + // Returning user -- go to forest showScreen("forest"); } else { - // First time — show welcome + // First time -- show welcome initWelcomeScreen(db, showScreen); showScreen("welcome"); } diff --git a/src/styles/main.css b/src/styles/main.css index 96106be..e07486e 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -162,6 +162,59 @@ h3 { cursor: not-allowed; } +/* Lesson screen */ +.lesson { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2rem; + padding: 2rem; + min-height: 100vh; + background: var(--bg-cream); +} + +.lesson__letter-area { + display: flex; + align-items: center; + justify-content: center; + width: 200px; + height: 200px; +} + +.falling-letter { + font-family: "Quicksand", sans-serif; + font-size: 5rem; + font-weight: 700; + color: var(--accent-green); + animation: letter-float 2s ease-in-out infinite; +} + +@keyframes letter-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +.falling-letter--dissolve { + animation: letter-dissolve 0.3s ease-out forwards; +} + +@keyframes letter-dissolve { + 0% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(1.5); + opacity: 0; + } +} + /* Keyboard */ .keyboard { display: flex; diff --git a/src/ui/screens.ts b/src/ui/screens.ts index a281418..6db614c 100644 --- a/src/ui/screens.ts +++ b/src/ui/screens.ts @@ -6,6 +6,12 @@ import type { } from "../types"; import { companions } from "../companion/characters"; import { saveProgress } from "../storage/db"; +import { + createExerciseState, + handleKeyPress, + getCurrentLetter, +} from "../game/typing"; +import { renderKeyboard, highlightKey, pressKey } from "../game/keyboard"; export function initWelcomeScreen( db: IDBDatabase, @@ -67,3 +73,67 @@ export function initWelcomeScreen( navigateTo("forest"); }); } + +export function initLessonScreen( + _db: IDBDatabase, + level: number, + layout: KeyboardLayout, + onComplete: (level: number) => void, +): () => void { + const letterArea = document.getElementById("letter-area")!; + const keyboardContainer = document.getElementById("lesson-keyboard")!; + const state = createExerciseState(level); + + // Render keyboard + renderKeyboard(keyboardContainer, layout, level); + + // Show current letter + function showCurrentLetter(): void { + const letter = getCurrentLetter(state); + if (!letter) return; + letterArea.innerHTML = ""; + const letterEl = document.createElement("div"); + letterEl.className = "falling-letter"; + letterEl.textContent = letter === " " ? "\u2423" : letter.toUpperCase(); + letterArea.appendChild(letterEl); + highlightKey(letter); + } + + showCurrentLetter(); + + // Keyboard handler + function onKeyDown(e: KeyboardEvent): void { + // Ignore modifier keys, function keys, etc. + if (e.key.length > 1 && e.key !== " ") return; + e.preventDefault(); + + const result = handleKeyPress(state, e.key); + pressKey(e.key); + + if (result.correct) { + // Correct: dissolve letter with animation + const currentEl = letterArea.querySelector(".falling-letter"); + if (currentEl) { + currentEl.classList.add("falling-letter--dissolve"); + } + + if (result.exerciseComplete) { + // Lesson complete + document.removeEventListener("keydown", onKeyDown); + onComplete(level); + } else { + // Show next letter after brief delay + setTimeout(() => showCurrentLetter(), 300); + } + } + // Wrong: do nothing to letter (per D-03), correct key already highlighted + } + + document.addEventListener("keydown", onKeyDown); + + // Return cleanup function + return () => { + document.removeEventListener("keydown", onKeyDown); + letterArea.innerHTML = ""; + }; +}