From 378ccb54e4825b47cd088d39c2993a972848633b Mon Sep 17 00:00:00 2001 From: Eri Meilis Date: Thu, 16 Jul 2026 12:30:17 +0300 Subject: [PATCH 01/42] docs: add Hebrew typing trainer design spec Track design plans in git (un-ignore docs/plans/) and add the design spec for the Hebrew 10-finger typing trainer feature. Also tracks the previously-ignored virtual-typing design docs. Key decisions captured in the spec: - Dedicated Trainer mode; Hebrew (Israeli standard) layout first - Real Hebrew text is primary (no generated pseudo-words); adaptivity via weak-key corpus selection - Staged home-row-first ladder + adaptive weak-key targeting - Switchable guidance modes to wean off the visible keyboard - Hebrew accuracy model: stop-on-error, whole-word metric, sofit and confusable-pair curriculum, meaning-aware error feedback - Both DOM and Rust-tap keystroke capture behind one KeySource setting --- .gitignore | 3 +- .../plans/2026-02-28-virtual-typing-design.md | 99 ++++ docs/plans/2026-02-28-virtual-typing-plan.md | 536 ++++++++++++++++++ ...2026-07-15-hebrew-typing-trainer-design.md | 302 ++++++++++ 4 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-02-28-virtual-typing-design.md create mode 100644 docs/plans/2026-02-28-virtual-typing-plan.md create mode 100644 docs/plans/2026-07-15-hebrew-typing-trainer-design.md diff --git a/.gitignore b/.gitignore index 55cceb4..afbaf1e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ build # Claude and docs .claude -docs +docs/* +!docs/plans/ diff --git a/docs/plans/2026-02-28-virtual-typing-design.md b/docs/plans/2026-02-28-virtual-typing-design.md new file mode 100644 index 0000000..8cdbdaa --- /dev/null +++ b/docs/plans/2026-02-28-virtual-typing-design.md @@ -0,0 +1,99 @@ +# Virtual Keyboard Typing Design + +## Goal + +Make the keyboard overlay functional for typing. Clicking keys on the overlay inputs characters into whatever application currently has focus, like macOS Accessibility Keyboard. + +## Decisions + +- **Focus handling**: Non-activating window (NSPanel) — clicks don't steal focus from target app +- **Key injection**: `rdev::simulate` — sends raw keycodes, macOS applies active input source (Hebrew/English) +- **Modifier behavior**: Sticky modifiers — click Shift, it stays active, next key is modified, then Shift auto-releases. Double-click locks. +- **Input method**: Raw keycodes, not Unicode characters. macOS handles layout mapping. + +## Architecture + +### 1. Non-Activating Window (Rust/macOS) + +In `lib.rs` setup, after window creation, access the native `NSWindow` and set it as non-activating: + +```rust +#[cfg(target_os = "macos")] +{ + use cocoa::appkit::NSWindow; + use cocoa::base::id; + + let ns_window = window.ns_window().unwrap() as id; + unsafe { + // NSWindowStyleMaskNonactivatingPanel = 1 << 7 = 128 + ns_window.setStyleMask_(ns_window.styleMask() | 128); + } +} +``` + +Requires `cocoa` crate added to `Cargo.toml`. + +### 2. Key Simulation (Rust backend) + +New file `src-tauri/src/key_simulator.rs`: + +- Tauri command: `simulate_key(key_code: String, modifiers: Vec)` +- Presses active modifiers, presses/releases target key, releases modifiers +- Uses `rdev::simulate()` which wraps `CGEventPost` on macOS +- Includes `key_from_string()` — inverse of existing `format_key_code()` + +Sequence for clicking "A" with Shift sticky: +``` +Shift↓ → A↓ → A↑ → Shift↑ +``` + +Small delay (~20ms) between simulate calls may be needed for macOS to process events reliably. + +### 3. Sticky Modifier State (React) + +In `Keyboard.tsx`: + +- State: `stickyModifiers: Set` tracks active modifiers +- State: `lockedModifiers: Set` tracks double-click-locked modifiers +- Click modifier key → toggle in stickyModifiers +- Double-click modifier → toggle in lockedModifiers +- Click regular key → invoke `simulate_key` with current modifiers → clear stickyModifiers (keep lockedModifiers) +- Visual: `.key-sticky` class for single-click active, `.key-locked` for double-click locked + +### 4. Data Flow + +``` +User clicks key in overlay + → Key.onClick fires + → Keyboard.handleKeyClick(keyCode) called + → Collects stickyModifiers + lockedModifiers into modifiers array + → invoke("simulate_key", { key_code, modifiers }) + → Rust: rdev::simulate(KeyPress/KeyRelease) sequence + → macOS CGEvent posted to focused app + → React: clear stickyModifiers (keep lockedModifiers) + → Physical keyboard listener picks up the simulated event + → Key shows pressed animation briefly +``` + +### 5. Key Mapping + +Reuse the same key code strings from `keyboard_listener.rs::format_key_code()`. Add inverse function `key_from_string()` with the same mapping table. + +Modifier keys: ShiftLeft, ShiftRight, ControlLeft, Alt (Option), MetaLeft (Command), MetaRight. + +## Files to Create/Modify + +| File | Change | +|------|--------| +| `src-tauri/src/key_simulator.rs` | New — simulate_key command + string-to-Key mapping | +| `src-tauri/src/lib.rs` | Register simulate_key command, add NSPanel non-activating setup | +| `src-tauri/Cargo.toml` | Add `cocoa` crate | +| `src/components/Keyboard.tsx` | Add sticky modifier state, wire onMouseClick to invoke | +| `src/components/Keyboard.css` | Add .key-sticky and .key-locked visual styles | + +## Risks + +- **Accessibility permissions**: `rdev::simulate` requires Accessibility access. We already request this for listening, so it should work. +- **NSPanel conversion**: Converting an existing NSWindow to non-activating after creation may have edge cases. Fallback: create the window as NSPanel from the start. +- **Simulate timing**: macOS may need small delays between simulated key events. Start with 0ms, add delay if events are dropped. +- **Focus edge cases**: Some apps may handle synthetic events differently than real keypresses. diff --git a/docs/plans/2026-02-28-virtual-typing-plan.md b/docs/plans/2026-02-28-virtual-typing-plan.md new file mode 100644 index 0000000..d3c6751 --- /dev/null +++ b/docs/plans/2026-02-28-virtual-typing-plan.md @@ -0,0 +1,536 @@ +# Virtual Keyboard Typing — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the overlay keyboard functional for typing — clicking keys sends characters to the focused app, like macOS Accessibility Keyboard. + +**Architecture:** Non-activating NSPanel window (clicks don't steal focus) + `rdev::simulate` for raw keycode injection (macOS applies active input source for Hebrew/English) + sticky modifier state in React (single-click = active until next key, double-click = locked). + +**Tech Stack:** Rust (Tauri, rdev, cocoa), React/TypeScript, macOS CGEvent API (via rdev) + +--- + +### Task 1: Add `cocoa` crate and make window non-activating (NSPanel) + +**Files:** +- Modify: `src-tauri/Cargo.toml` +- Modify: `src-tauri/src/lib.rs` + +**Step 1: Add cocoa dependency to Cargo.toml** + +In `src-tauri/Cargo.toml`, add to `[target.'cfg(target_os = "macos")'.dependencies]`: + +```toml +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.9" +core-foundation-sys = "0.8" +cocoa = "0.26" +``` + +**Step 2: Add NSPanel non-activating setup in lib.rs** + +In `src-tauri/src/lib.rs`, inside the `.setup(|app| { ... })` closure, after `keyboard_listener::start_listener(handle);`, add: + +```rust +#[cfg(target_os = "macos")] +{ + use cocoa::appkit::NSWindow; + use cocoa::base::id; + + if let Some(window) = app.get_webview_window("main") { + let ns_window = window.ns_window().unwrap() as id; + unsafe { + // NSWindowStyleMaskNonactivatingPanel = 1 << 7 = 128 + // This prevents the window from stealing focus when clicked + ns_window.setStyleMask_(ns_window.styleMask() | 128); + } + } +} +``` + +This requires `use tauri::Manager;` at the top (already imported). + +**Step 3: Build and verify** + +Run: `cd src-tauri && cargo build 2>&1` + +Expected: Compiles with no errors or warnings. The `cocoa` crate downloads and the NSPanel mask is applied. + +**Step 4: Run and verify non-activating behavior** + +Run: `npm run tauri:dev` + +Test: Open a text editor (e.g., TextEdit), type some text to give it focus, then click on the keyboard overlay. **The text editor should keep focus** — the keyboard overlay should NOT activate/steal focus. + +**Step 5: Commit** + +```bash +git add . +git commit -m "feat: make overlay window non-activating with NSPanel" +``` + +--- + +### Task 2: Create key simulator module (Rust backend) + +**Files:** +- Create: `src-tauri/src/key_simulator.rs` + +**Step 1: Create the key_simulator.rs file** + +Create `src-tauri/src/key_simulator.rs` with the full `simulate_key` Tauri command and the inverse `key_from_string` mapping: + +```rust +use rdev::{simulate, EventType, Key}; +use log::{info, error}; +use std::thread; +use std::time::Duration; + +/// Inverse of keyboard_listener::format_key_code() +/// Maps string key codes back to rdev::Key enum +fn key_from_string(key_code: &str) -> Option { + match key_code { + // Letters + "KeyA" => Some(Key::KeyA), + "KeyB" => Some(Key::KeyB), + "KeyC" => Some(Key::KeyC), + "KeyD" => Some(Key::KeyD), + "KeyE" => Some(Key::KeyE), + "KeyF" => Some(Key::KeyF), + "KeyG" => Some(Key::KeyG), + "KeyH" => Some(Key::KeyH), + "KeyI" => Some(Key::KeyI), + "KeyJ" => Some(Key::KeyJ), + "KeyK" => Some(Key::KeyK), + "KeyL" => Some(Key::KeyL), + "KeyM" => Some(Key::KeyM), + "KeyN" => Some(Key::KeyN), + "KeyO" => Some(Key::KeyO), + "KeyP" => Some(Key::KeyP), + "KeyQ" => Some(Key::KeyQ), + "KeyR" => Some(Key::KeyR), + "KeyS" => Some(Key::KeyS), + "KeyT" => Some(Key::KeyT), + "KeyU" => Some(Key::KeyU), + "KeyV" => Some(Key::KeyV), + "KeyW" => Some(Key::KeyW), + "KeyX" => Some(Key::KeyX), + "KeyY" => Some(Key::KeyY), + "KeyZ" => Some(Key::KeyZ), + + // Numbers + "Digit0" => Some(Key::Num0), + "Digit1" => Some(Key::Num1), + "Digit2" => Some(Key::Num2), + "Digit3" => Some(Key::Num3), + "Digit4" => Some(Key::Num4), + "Digit5" => Some(Key::Num5), + "Digit6" => Some(Key::Num6), + "Digit7" => Some(Key::Num7), + "Digit8" => Some(Key::Num8), + "Digit9" => Some(Key::Num9), + + // Special keys + "Space" => Some(Key::Space), + "Escape" => Some(Key::Escape), + "Backspace" => Some(Key::Backspace), + "Tab" => Some(Key::Tab), + "Enter" => Some(Key::Return), + "CapsLock" => Some(Key::CapsLock), + + // Modifiers + "ShiftLeft" => Some(Key::ShiftLeft), + "ShiftRight" => Some(Key::ShiftRight), + "ControlLeft" => Some(Key::ControlLeft), + "ControlRight" => Some(Key::ControlRight), + "Alt" => Some(Key::Alt), + "MetaLeft" => Some(Key::MetaLeft), + "MetaRight" => Some(Key::MetaRight), + + // Punctuation + "Minus" => Some(Key::Minus), + "Equal" => Some(Key::Equal), + "BracketLeft" => Some(Key::LeftBracket), + "BracketRight" => Some(Key::RightBracket), + "Backslash" => Some(Key::BackSlash), + "Semicolon" => Some(Key::SemiColon), + "Quote" => Some(Key::Quote), + "Comma" => Some(Key::Comma), + "Period" => Some(Key::Dot), + "Slash" => Some(Key::Slash), + + // Arrow keys + "ArrowUp" => Some(Key::UpArrow), + "ArrowDown" => Some(Key::DownArrow), + "ArrowLeft" => Some(Key::LeftArrow), + "ArrowRight" => Some(Key::RightArrow), + + _ => None, + } +} + +fn send(event_type: &EventType) { + if let Err(e) = simulate(event_type) { + error!("simulate error: {:?}", e); + } + // Small delay for macOS to process the event + thread::sleep(Duration::from_millis(20)); +} + +#[tauri::command] +pub fn simulate_key(key_code: String, modifiers: Vec) { + info!("simulate_key: {} with modifiers: {:?}", key_code, modifiers); + + let target_key = match key_from_string(&key_code) { + Some(k) => k, + None => { + error!("Unknown key code: {}", key_code); + return; + } + }; + + // Collect modifier keys + let mod_keys: Vec = modifiers + .iter() + .filter_map(|m| key_from_string(m)) + .collect(); + + // Press modifiers + for mk in &mod_keys { + send(&EventType::KeyPress(*mk)); + } + + // Press and release the target key + send(&EventType::KeyPress(target_key)); + send(&EventType::KeyRelease(target_key)); + + // Release modifiers (reverse order) + for mk in mod_keys.iter().rev() { + send(&EventType::KeyRelease(*mk)); + } +} +``` + +**Step 2: Build and verify** + +Run: `cd src-tauri && cargo build 2>&1` + +Expected: Compiles with no errors or warnings. The `simulate_key` function and `key_from_string` mapping are available. + +**Step 3: Commit** + +```bash +git add . +git commit -m "feat: add key simulator module with rdev::simulate" +``` + +--- + +### Task 3: Register simulate_key command in lib.rs + +**Files:** +- Modify: `src-tauri/src/lib.rs` + +**Step 1: Add module declaration and register command** + +In `src-tauri/src/lib.rs`: + +1. Add `mod key_simulator;` at the top (after `mod keyboard_listener;`) +2. Add `key_simulator::simulate_key` to the `invoke_handler`: + +```rust +.invoke_handler(tauri::generate_handler![ + get_active_keyboard_layout, + key_simulator::simulate_key +]) +``` + +**Step 2: Build and verify** + +Run: `cd src-tauri && cargo build 2>&1` + +Expected: Compiles with no errors or warnings. + +**Step 3: Commit** + +```bash +git add . +git commit -m "feat: register simulate_key Tauri command" +``` + +--- + +### Task 4: Add sticky modifier state and click handlers in React + +**Files:** +- Modify: `src/components/Keyboard.tsx` + +This is the biggest task. We need to: +1. Add `stickyModifiers` and `lockedModifiers` state +2. Create a `handleKeyClick(keyCode)` function +3. Pass `onMouseClick` callbacks to every `` component +4. Wire modifier keys to toggle sticky/locked state +5. Wire regular keys to invoke `simulate_key` then clear sticky modifiers + +**Step 1: Update Keyboard.tsx with full click handling** + +Add to imports: + +```typescript +import { invoke } from '@tauri-apps/api/core'; +``` + +Add state inside the `Keyboard` component (after `pressedKeys` state): + +```typescript +const [stickyModifiers, setStickyModifiers] = useState>(new Set()); +const [lockedModifiers, setLockedModifiers] = useState>(new Set()); +``` + +Define the set of modifier key codes: + +```typescript +const MODIFIER_KEYS = new Set([ + 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', + 'Alt', 'MetaLeft', 'MetaRight' +]); +``` + +Add the click handler: + +```typescript +const handleKeyClick = async (keyCode: string) => { + if (MODIFIER_KEYS.has(keyCode)) { + // Toggle sticky modifier + setStickyModifiers(prev => { + const next = new Set(prev); + if (next.has(keyCode)) { + next.delete(keyCode); + } else { + next.add(keyCode); + } + return next; + }); + return; + } + + // Regular key: collect all active modifiers and simulate + const modifiers = [...stickyModifiers, ...lockedModifiers]; + try { + await invoke('simulate_key', { keyCode, modifiers }); + } catch (e) { + console.error('simulate_key failed:', e); + } + + // Clear sticky modifiers (keep locked) + setStickyModifiers(new Set()); +}; +``` + +Add double-click handler for locking modifiers: + +```typescript +const handleModifierDoubleClick = (keyCode: string) => { + setLockedModifiers(prev => { + const next = new Set(prev); + if (next.has(keyCode)) { + next.delete(keyCode); + } else { + next.add(keyCode); + } + return next; + }); + // Remove from sticky if locked + setStickyModifiers(prev => { + const next = new Set(prev); + next.delete(keyCode); + return next; + }); +}; +``` + +Helper to check if a key is in sticky or locked state: + +```typescript +const isKeySticky = (keyCode: string) => stickyModifiers.has(keyCode); +const isKeyLocked = (keyCode: string) => lockedModifiers.has(keyCode); +``` + +**Step 2: Wire onMouseClick to every Key component** + +Each `` needs `onMouseClick={() => handleKeyClick('KeyCode')}` where the key code matches the `format_key_code` strings from `keyboard_listener.rs`. + +For modifier keys, also add `onDoubleClick`. + +The component ID to key code reverse mapping (from `keyMapping.ts`): +- `ka` → `KeyA`, `n1` → `Digit1`, `space` → `Space`, etc. +- `lshift` → `ShiftLeft`, `rshift` → `ShiftRight` + +Every key in Keyboard.tsx that has an `isPressed` prop already has a component ID. Add `onMouseClick` using the corresponding key code string. + +Example for a few keys (apply to ALL keys): + +```tsx + handleKeyClick('Escape')} /> + + handleKeyClick('Digit1')} /> + + handleKeyClick('KeyQ')} /> + + handleKeyClick('ShiftLeft')} + className={isKeySticky('ShiftLeft') ? 'key-sticky' : isKeyLocked('ShiftLeft') ? 'key-locked' : ''} /> +``` + +Complete list of key code mappings for onMouseClick (apply to every Key that has isPressed): + +| Component ID | Key Code for onMouseClick | +|---|---| +| esc | Escape | +| n1-n9 | Digit1-Digit9 | +| n0 | Digit0 | +| mn | Minus | +| eq | Equal | +| backspace | Backspace | +| tab | Tab | +| kq-kz | KeyQ-KeyZ (matching letter) | +| lb | BracketLeft | +| rb | BracketRight | +| bs | Backslash | +| semi | Semicolon | +| quot | Quote | +| enter | Enter | +| lshift | ShiftLeft | +| rshift | ShiftRight | +| comma | Comma | +| dot | Period | +| slash | Slash | +| space | Space | + +Bottom row modifier keys (currently without isPressed/component IDs): +- `caps lock` → CapsLock +- `control` → ControlLeft +- `option` → Alt +- `command` (left) → MetaLeft +- `command` (right) → MetaRight + +Arrow keys: +- chevron-up → ArrowUp +- chevron-down → ArrowDown +- chevron-left → ArrowLeft +- chevron-right → ArrowRight + +Keys like `home`, `pgup`, `pgdn`, `fn1`, `fn2`, `☀` can be left without onMouseClick for now. + +**Step 3: Handle double-click on modifier keys** + +The `Key` component currently only supports `onMouseClick`. We need to handle double-click for modifier locking. Two options: + +**Option A (simpler):** Use `onDoubleClick` on the `
` in Key.tsx. Add `onDoubleClick` prop to `BaseKeyProps`. + +In `Key.tsx`, add to `BaseKeyProps`: + +```typescript +onDoubleClick?: () => void; +``` + +In the `
` element, add: + +```tsx +onDoubleClick={onDoubleClick} +``` + +Then in Keyboard.tsx for modifier keys: + +```tsx + handleKeyClick('ShiftLeft')} + onDoubleClick={() => handleModifierDoubleClick('ShiftLeft')} + className={isKeySticky('ShiftLeft') ? 'key-sticky' : isKeyLocked('ShiftLeft') ? 'key-locked' : ''} /> +``` + +**Step 4: Build and verify** + +Run: `npx tsc --noEmit` + +Expected: No TypeScript errors. + +**Step 5: Run and test the full flow** + +Run: `npm run tauri:dev` + +Test sequence: +1. Open TextEdit, type a few characters to have a cursor position +2. Click "A" on the overlay → "a" appears in TextEdit +3. Click "⇧ shift" on overlay (it should highlight as sticky) → click "A" → "A" (uppercase) appears in TextEdit, shift un-highlights +4. Double-click "⇧ shift" (it should highlight as locked) → click "A", "B", "C" → "ABC" all uppercase → click shift again to unlock +5. Switch macOS input to Hebrew → click "T" on overlay → "א" appears (macOS maps the raw keycode) + +**Step 6: Commit** + +```bash +git add . +git commit -m "feat: add virtual typing with sticky modifiers" +``` + +--- + +### Task 5: Add CSS styles for sticky and locked modifier states + +**Files:** +- Modify: `src/components/Keyboard.css` + +**Step 1: Add sticky and locked CSS classes** + +Add to `src/components/Keyboard.css`: + +```css +/* Sticky modifier — single click active, waiting for next key */ +.key-sticky { + background: linear-gradient(180deg, #4a6fa5 0%, #2d4a7a 100%) !important; + border-color: rgba(100, 160, 255, 0.4) !important; + box-shadow: 0 0 8px rgba(100, 160, 255, 0.3); +} + +/* Locked modifier — double-click locked, stays active */ +.key-locked { + background: linear-gradient(180deg, #5a3a8a 0%, #3d2060 100%) !important; + border-color: rgba(160, 100, 255, 0.4) !important; + box-shadow: 0 0 8px rgba(160, 100, 255, 0.3); +} +``` + +**Step 2: Verify styles appear** + +Run: `npm run tauri:dev` + +Test: Click shift — it should glow blue. Double-click shift — it should glow purple. + +**Step 3: Commit** + +```bash +git add . +git commit -m "feat: add visual styles for sticky and locked modifiers" +``` + +--- + +## Risks & Mitigations + +1. **Accessibility permissions**: `rdev::simulate` needs Accessibility access. We already have it for listening. If not working, check System Settings → Privacy → Accessibility. + +2. **NSPanel conversion**: Setting the non-activating mask after window creation may have edge cases. If clicks still steal focus, we may need to create the window as an NSPanel from the start (requires Tauri plugin or raw window creation). + +3. **Simulate timing**: The 20ms delay between simulated events may need tuning. If events are dropped, increase to 30-40ms. If typing feels sluggish, decrease to 10ms. + +4. **Keyboard listener echo**: Our physical keyboard listener will pick up simulated events, causing the key to flash pressed briefly. This is actually desirable — it provides visual feedback. + +5. **Focus edge cases**: Some apps (terminal emulators, games) may handle synthetic events differently than real keypresses. diff --git a/docs/plans/2026-07-15-hebrew-typing-trainer-design.md b/docs/plans/2026-07-15-hebrew-typing-trainer-design.md new file mode 100644 index 0000000..6efb35a --- /dev/null +++ b/docs/plans/2026-07-15-hebrew-typing-trainer-design.md @@ -0,0 +1,302 @@ +# Hebrew 10-Finger Typing Trainer — Design + +## Goal + +Add a **Trainer mode** to the existing always-on-top keyboard overlay that teaches and +improves **Hebrew 10-finger touch typing** on the Israeli standard layout, taking a user +from absolute beginner to fast typist. + +The market for English trainers is saturated; polished Hebrew touch-typing trainers barely +exist. This app already renders the Israeli Hebrew keymap and detects the Hebrew input +source, so it is uniquely positioned. The differentiators are: + +1. **Adaptive weak-key targeting** — re-weight practice toward the user's slowest/least + accurate keys (the keybr/TIPP10 model). We already own the keystroke stream. +2. **Per-key heatmap on the keyboard itself** — the on-screen keyboard becomes the progress + surface (red→green). No overlay tool does this. +3. **Built-in wean-off-the-overlay progression** — the visible keyboard is a known crutch + (Vanderbilt 2016); the trainer progressively fades its own hints so the user builds + look-away muscle memory. The app teaches you until you no longer need it. +4. **Hebrew-accurate error model** — in Hebrew a single wrong consonant usually produces a + *different real word*, not a recognizable typo. Accuracy is modeled and enforced far more + strictly than any English-oriented trainer does. (See "Hebrew accuracy model".) + +## Locked decisions + +| Topic | Decision | +|---|---| +| Interaction model | Dedicated Trainer mode inside the app (the app is the tutor). | +| Audience | Both beginners and existing typists, via a placement test (level-based curve). | +| Primary layout | **Hebrew (Israeli standard)** first. Engine is layout-agnostic; other layouts are a separate future feature (scope boundary, not deferred work). | +| Text sources | **Real Hebrew text is primary** — common-word lists + prose/quotes + custom paste. Early stages use honest letter/bigram finger drills. **No algorithmic pseudo-words** (they produce nonsense in Hebrew). | +| Adaptivity | Weak-key targeting by **selecting real words/sentences rich in the user's weak keys** (not by generating fake words). | +| Progression | Staged home-row-first key-unlock ladder **plus** adaptive weak-key targeting within each stage; accuracy-first gate to advance. | +| Wean-off | Switchable **guidance modes**: Full → Auto-fade (per-key, confidence-driven) → Manual dim → Hidden. | +| Gamification | Progress visualization + streaks/goals/skill-stars + themed & fault animations + ghost race. | +| Keystroke capture | **Both** a DOM adapter and a Rust-tap adapter behind one `KeySource` interface, chosen by a setting. Default: DOM capture. | +| Error handling | Default **stop-on-error (must correct)**; configurable strictness. | +| Scope | **Nothing deferred** — the full set above is in scope. The build order below sequences the work; it does not cut anything. | + +## Audience & the learning curve + +A **placement test** (~60–90s of mixed drills) estimates starting WPM and accuracy, seeds +the adaptive engine's per-key confidence, and drops the user at the right stage. Re-takeable. + +- **Beginner** → starts at stage 1 (home-row anchors), full guidance. +- **Existing typist** → placement seeds high confidence, skips ahead to weak-key work and + speed/fluency stages, guidance auto-fades quickly. + +The curve is level-based, so both audiences share one ladder; the placement test just sets +the entry point. + +## User experience & flow + +- **Entry/exit.** A Trainer toggle in the overlay control bar and in the menu-bar tray. + Entering Trainer switches the app into the focused practice view; exiting returns it to the + normal always-on-top overlay. +- **Session loop.** + 1. Continue current stage (or pick a stage / mode). + 2. A **practice panel** above the reused keyboard shows the Hebrew target text + **right-to-left** with a caret. Correct chars go green, the current char is highlighted, + errors are handled per the strictness setting (default: block until corrected). + 3. The keyboard highlights the **next key + its finger color**, subject to the active + guidance mode. + 4. Session ends after N characters or seconds → **summary** (WPM, accuracy, whole-word + accuracy, per-key deltas, stars, streak). +- **Guidance modes** switchable at any time (see below). + +## Architecture + +### Window & focus model + +Trainer mode needs keyboard focus so keystrokes are captured cleanly and do not leak into +whatever app sits behind the overlay. On entering Trainer, a Rust command switches the window +to a focusable/activating state; on exit it reverts to the non-activating `NSPanel` overlay +(current behavior). This is the only substantive backend change. + +### Keystroke capture — `KeySource` interface (both adapters) + +We do not yet know which capture path performs best, so we build both behind one interface +and expose the choice as a setting (default: DOM). + +```ts +interface KeyEvent { code: string; ts: number; down: boolean } // code = physical key, e.g. "KeyA" +interface KeySource { + start(onKey: (e: KeyEvent) => void): void; + stop(): void; +} +``` + +- **`DomKeySource`** — listens to DOM `keydown`/`keyup` on a focused, visually-hidden capture + element. `event.code` gives the physical key (input-source-independent); `event.timeStamp` + gives precise timing. Keystrokes do not leak to background apps. Works even if Accessibility + permission is flaky. **Default.** +- **`TauriTapKeySource`** — consumes the existing Rust `CGEventTap` stream. To give discrete, + timestamped keydowns (the current `keyboard-state` event is a throttled state snapshot, not + a keydown stream), the Rust listener emits a new `keydown` event `{ code, ts }`. Reuses the + app's existing native infrastructure. + +The session logic consumes `KeyEvent`s and never cares which adapter produced them. + +### Frontend components (layered on the existing `Keyboard.tsx`) + +- `TrainerMode` — view/session lifecycle container; mounts when Trainer is active. +- `PracticePanel` — RTL target text, caret, live per-char coloring, error state. +- `useTypingSession` — consumes a `KeySource`; maps `event.code` → expected physical key; + records per-key timing and errors; enforces the strictness setting; emits session events. +- `useAdaptiveEngine` — per-key confidence, next-item selection (weak-key weighting), stage + gating. Pure, unit-tested. +- Heatmap / finger-hint layer — new visual props threaded into the existing `Key` component + (heat color, next-key highlight, finger-zone color, guidance-driven fade). `Keyboard.tsx` + stays the single source of truth for the physical→Hebrew key map. +- `SessionSummary`, `StatsView`, `PlacementTest`. + +### Backend (Rust) — minimal + +- `set_trainer_window(active: bool)` — toggles focusable vs non-activating `NSPanel`. +- `keydown` event `{ code, ts }` — only needed by `TauriTapKeySource`. + +### Data model & persistence + +Local JSON in the Tauri app-data dir. No server, no accounts. + +- `KeyStat` per physical key: recent latency samples, error count, attempts → derived + `confidence` (0–1). +- `Progress`: unlocked stages, current stage, per-stage best WPM/accuracy. +- `Streak`: last-practiced date, current/longest streak, daily-goal progress. +- `Settings`: guidance mode, error strictness, capture source, layout. +- `Ghosts`: per-stage/per-text personal-best keystroke timelines (for ghost race). + +## Adaptive engine + +- **Confidence** per key from recent median latency + error rate, decayed over time. +- **Weak-key targeting (by selection, not generation).** Within the active stage, rank the + real-word/sentence corpus by how much each item exercises the lowest-confidence unlocked + keys, and sample those. Sofit and confusable groups get a priority multiplier (see Hebrew + accuracy model). +- **Stage gate (accuracy-first).** Advance only when every key in the stage clears an + accuracy gate first, then a speed floor. Hebrew accuracy gate proposed at **~98%** + (vs the ~95% English convention); speed floor tuned low for beginners. Numbers are + configuration, not hard-coded. +- The placement test seeds initial confidence so existing typists skip ahead. + +## Curriculum — Hebrew stage ladder + +The finger map and physical→Hebrew mapping are **derived from the existing `Keyboard.tsx` +layout data** (single source of truth), not duplicated. Home row (physical A…;) is +`ש ד ג כ ע · י ח ל ך ף`; tactile anchors are `כ` (KeyF) and `ח`… (the F/J bump keys). + +Illustrative ladder (data-driven, tunable): + +1. Index anchors + space: `כ ח` +2. Index inner: `ע י` +3. Middle: `ג ל` +4. Ring: `ד ך` +5. Pinky → full home row: `ש ף` +6–8. Top row groups: `ר א · ו פ · ק ט` etc. +9–11. Bottom row groups: `נ מ · ב ה · ז ס צ` etc. +12. **Sofit finals focus** — `ן ם ך ף ץ` and their regular counterparts (`נ מ כ פ צ`), which + live on *different physical keys* in the Israeli layout and are a classic meaning error. +13. **Confusable pairs** — e.g. `ד/ר`, `כ/ב`, `ה/ח`, `ט/ת`, `ם/ס`, `ן/ו`. +14. Numbers & punctuation. +15. Prose fluency (real text). + +Finger zones are color-coded (index/middle/ring/pinky per hand) and rendered on the keyboard. + +## Text sources + +**Real Hebrew text is the primary source.** Algorithmic generation of pseudo-words produces +nonsense in Hebrew (a consonantal, root-based language), so we do **not** generate fake words. +Adaptivity comes from *selecting* real text rich in the user's weak keys, not from inventing +words. All content is local data files; the engine is layout-agnostic (finger map + frequency +data + word/text corpora keyed by layout). + +1. **Curated common-word lists (primary for word stages).** Real top-N Hebrew words. In any + stage we draw the words whose letters fall within the unlocked set, ranked toward the + user's weak keys. This yields *real words* even mid-curriculum. +2. **Prose & quotes (primary for fluency + engagement).** Bundled public-domain Hebrew text — + literature, Tanakh verses, proverbs, sayings — with natural punctuation and intrinsic + motivation. Passages selected for weak-key density where possible. Needs a small curation + pass (public-domain/licensing check). +3. **Custom paste.** The user pastes their own Hebrew text to drill. In scope. +4. **Early-stage finger drills (honest, not fake words).** When only 2–3 keys are unlocked no + real word exists, so we drill the unlocked keys and bigrams directly (e.g. `כח חכ ככ חח`). + These are explicitly presented as *finger drills*, never disguised as words. As soon as the + unlocked set supports real words, we switch to source 1. + +Adaptive weak-key targeting is therefore implemented as **corpus selection**: rank real +words/sentences by how much they exercise the user's low-confidence keys, and sample those +(the keybr/TIPP10 intent, achieved over *real* Hebrew text). + +## Guidance modes (wean-off) + +A `guidanceMode` setting drives how much the keyboard reveals; overridable anytime: + +- **Full** — highlight next key + finger color + Hebrew legends. +- **Auto-fade** — per key, hints fade as that key's confidence rises (highlight → legend → + blank). The overlay weans the user off itself. +- **Manual dim** — legends dimmed globally. +- **Hidden** — blank keys (look-away / "blind" practice). + +## Gamification & feedback + +- **Progress visualization.** Per-key heatmap (red→green) on the keyboard; live + WPM/accuracy/whole-word accuracy; WPM/accuracy-over-time graphs; session summary. +- **Streaks, goals, skill-stars.** Daily streak + goal; stars/achievements tied to real + milestones (stage at ≥ gate accuracy, a key turning green) — not collectibles. +- **Themed animations & juicy feedback.** Celebratory level-up/streak animations, satisfying + correct-key feedback, playful fault animations on errors, and text-theme-tied backgrounds + (sea/space/…) driven by a theme tag on each practice text. Kept tasteful so they never + distract from typing. +- **Ghost race.** Race your own personal-best WPM ghost — no strangers, no leaderboard (heavy + competition discourages the majority of learners). + +## Hebrew accuracy model + +Hebrew is consonantal and typed without niqqud, so one wrong consonant generally yields a +*different real word*, not a readable typo. Accuracy is therefore modeled and enforced more +strictly than in any English trainer, and this is itself a differentiator. + +- **Accuracy dominates score and gate.** ~98% accuracy gate to advance; speed weighted far + below accuracy. +- **Whole-word correctness metric.** In word/prose modes a word counts correct only if every + letter is right — one wrong consonant = wrong word, mirroring the language. Reported + alongside per-char accuracy. +- **Stop-on-error default.** A wrong key blocks progress until corrected (configurable to + looser 1st/2nd/3rd-error strictness). +- **Confusable & sofit groups are first-class curriculum** (stages 12–13) and get adaptive + priority. +- **Meaning-aware error feedback.** When a mistype produces a *real* Hebrew word (checked + against the word list), flag it as a teaching moment: "you typed ⟨word⟩; target was + ⟨word⟩." In scope. + +## Layout-agnostic design (future) + +Finger maps, frequency data, and word/text lists are keyed by layout id. Adding English (or +Arabic, etc.) is a data drop-in plus RTL/LTR handling, not an engine rewrite. Because the +session matches on physical key codes, training is input-source-independent (the user builds +finger memory regardless of the active OS input source; a gentle "switch to Hebrew to see +letters appear" hint may be shown but correctness never depends on it). + +## Build order (all in scope — nothing deferred) + +This sequences the work so there is a usable build early; it does not cut anything below. + +1. **Foundation:** Trainer mode + focusable window toggle; `KeySource` interface with both + adapters; local persistence; reused keyboard with heatmap/finger/guidance visual props. +2. **Pedagogy core:** staged ladder + adaptive engine; placement test; early finger drills + + real common-word selection; accuracy-first gating; stop-on-error; sofit/confusable + curriculum; per-key heatmap; live + summary stats incl. whole-word accuracy; guidance modes. +3. **Real text + feedback:** prose/quotes corpus; custom paste; progress graphs; meaning-aware + error flagging (real-word mistype callout). +4. **Delight:** streaks/goals/skill-stars; celebratory + fault animations; ghost race; + text-theme-tied animations. + +**Scope boundary (not this feature):** additional layouts (English, Arabic, …). The engine is +layout-agnostic by design, but building non-Hebrew content is a separate future feature — a +boundary, not deferred work from this one. Say so if you want English pulled in here. + +## Testing + +- **Unit (Vitest), high value:** adaptive engine (confidence, weighting, gating), text + selection (unlocked-key filtering, weak-key ranking, early finger-drill fallback), session + scoring (WPM, per-char accuracy, whole-word accuracy), Hebrew error classification (sofit, + confusable, real-word mistype), placement-test seeding. +- **Component:** `PracticePanel` RTL rendering + live correctness coloring + stop-on-error + behavior; keyboard heatmap/finger-hint/guidance-fade visual props. +- **Integration/manual:** window focus toggle (enter/exit Trainer, clean `NSPanel` revert); + both `KeySource` adapters produce equivalent `KeyEvent`s. + +## Risks & open questions + +- **Window focus toggle** edge cases (clean revert to non-activating `NSPanel`). Mitigated by + keeping the Rust change minimal and reverting on every exit. +- **Hebrew data sourcing** — frequency tables, common-word lists, and public-domain prose need + a small curation pass (licensing check for prose/quotes). This is the primary content + effort now that text is real, not generated. +- **Early finger drills are repetitive by nature** — mitigate with short early stages and + quick progression to real words as soon as the unlocked set allows. +- **Animation restraint** — must not distract from typing; keep opt-in/tasteful. +- **Capture-source parity** — the two adapters must agree on physical-key codes and timing + units; covered by tests. + +## Files (anticipated) + +| File | Change | +|---|---| +| `src/trainer/TrainerMode.tsx` | New — trainer view/session container | +| `src/trainer/PracticePanel.tsx` | New — RTL target text + caret + coloring | +| `src/trainer/PlacementTest.tsx` | New — placement flow | +| `src/trainer/SessionSummary.tsx`, `StatsView.tsx` | New — results & stats | +| `src/trainer/useTypingSession.ts` | New — session logic over `KeySource` | +| `src/trainer/useAdaptiveEngine.ts` | New — confidence/weighting/gating (pure) | +| `src/trainer/textSelection.ts` | New — real-text selection: unlocked-key filter, weak-key ranking, early finger-drill fallback (pure) | +| `src/trainer/CustomText.tsx` | New — paste-your-own-Hebrew-text flow | +| `src/trainer/keySource/` | New — `KeySource` interface + `DomKeySource`, `TauriTapKeySource` | +| `src/trainer/data/` | New — Hebrew finger map (derived), letter frequency, common-word lists, public-domain prose/quotes | +| `src/trainer/curriculum.ts` | New — stage ladder, sofit/confusable groups | +| `src/trainer/storage.ts` | New — local JSON persistence | +| `src/components/Key.tsx`, `Keyboard.tsx`, `Keyboard.css` | Modify — heatmap/finger/guidance visual props | +| `src/App.tsx` | Modify — Trainer toggle + mode switch | +| `src-tauri/src/lib.rs` | Modify — `set_trainer_window` command; register | +| `src-tauri/src/keyboard_listener.rs` | Modify — emit `keydown { code, ts }` event | From 51907c8b1ad400c8358c3a7a4556f1f4f669d7b0 Mon Sep 17 00:00:00 2001 From: Eri Meilis Date: Thu, 16 Jul 2026 17:50:13 +0300 Subject: [PATCH 02/42] chore: upgrade TypeScript 5.9.3 -> 7.0.2 - Bump typescript devDependency to ^7.0.2 (latest stable, native compiler). - Add src/vite-env.d.ts (vite/client reference) so side-effect CSS imports type-check under TS 7's stricter module handling (TS2882). - Fix stale Keyboard.test.tsx assertion: the component listens to 'keyboard-state', not the old 'key-pressed'/'key-released'. Verified: tsc --noEmit clean, 11/11 vitest pass, npm run build succeeds. --- package-lock.json | 399 +++++++++++++++++++++++++++++-- package.json | 2 +- src/components/Keyboard.test.tsx | 5 +- src/vite-env.d.ts | 1 + 4 files changed, 382 insertions(+), 25 deletions(-) create mode 100644 src/vite-env.d.ts diff --git a/package-lock.json b/package-lock.json index a05d21b..56ef174 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", "jsdom": "^28.0.0", - "typescript": "^5.9.3", + "typescript": "^7.0.2", "vite": "^7.3.1", "vitest": "^4.0.18" } @@ -126,7 +126,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -476,7 +475,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -517,7 +515,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1704,7 +1701,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1782,7 +1780,6 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1793,11 +1790,350 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", @@ -1946,6 +2282,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1956,6 +2293,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2023,7 +2361,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2184,7 +2521,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.283", @@ -2399,7 +2737,6 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -2476,6 +2813,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -2584,7 +2922,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2627,6 +2964,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2651,7 +2989,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2661,7 +2998,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2674,7 +3010,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -2926,17 +3263,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici": { @@ -2986,7 +3344,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index cd690b4..e657f53 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", "jsdom": "^28.0.0", - "typescript": "^5.9.3", + "typescript": "^7.0.2", "vite": "^7.3.1", "vitest": "^4.0.18" }, diff --git a/src/components/Keyboard.test.tsx b/src/components/Keyboard.test.tsx index 7fe2223..93134f0 100644 --- a/src/components/Keyboard.test.tsx +++ b/src/components/Keyboard.test.tsx @@ -16,11 +16,10 @@ describe('Keyboard Component', () => { expect(container.querySelector('.keyboard')).toBeInTheDocument(); }); - it('sets up event listeners on mount', async () => { + it('sets up the keyboard-state listener on mount', async () => { const { listen } = await import('@tauri-apps/api/event'); render(); - expect(listen).toHaveBeenCalledWith('key-pressed', expect.any(Function)); - expect(listen).toHaveBeenCalledWith('key-released', expect.any(Function)); + expect(listen).toHaveBeenCalledWith('keyboard-state', expect.any(Function)); }); }); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// From c992bbe2d456b4c453fc6a231392d4f9116fb7fa Mon Sep 17 00:00:00 2001 From: Eri Meilis Date: Thu, 16 Jul 2026 17:58:53 +0300 Subject: [PATCH 03/42] docs: add Hebrew typing trainer implementation plan 29 bite-sized TDD tasks across 4 build-order phases (foundation, pedagogy core, real text + feedback, delight) with exact file paths, full code, and test-first steps. --- .../2026-07-16-hebrew-typing-trainer-plan.md | 2724 +++++++++++++++++ 1 file changed, 2724 insertions(+) create mode 100644 docs/plans/2026-07-16-hebrew-typing-trainer-plan.md diff --git a/docs/plans/2026-07-16-hebrew-typing-trainer-plan.md b/docs/plans/2026-07-16-hebrew-typing-trainer-plan.md new file mode 100644 index 0000000..8432c14 --- /dev/null +++ b/docs/plans/2026-07-16-hebrew-typing-trainer-plan.md @@ -0,0 +1,2724 @@ +# Hebrew 10-Finger Typing Trainer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a dedicated Hebrew touch-typing Trainer mode to the existing overlay app, teaching 10-finger typing from beginner to fast with an adaptive weak-key engine, a per-key heatmap on the keyboard, switchable wean-off guidance, and a Hebrew-accurate error model. + +**Architecture:** All pedagogy lives in the React/TypeScript frontend as small pure modules (layout data, adaptive engine, text selection, scoring, error classification) plus thin UI components, driven by keystrokes from a swappable `KeySource` (DOM adapter or Rust-tap adapter). The only backend change is a Tauri command to make the window focusable during training and an unthrottled per-key event for the tap adapter. Everything matches on **physical key codes** (`event.code` style, e.g. `KeyA`), so training is input-source-independent. State persists to local storage behind a small interface. + +**Tech Stack:** Tauri v2.10, React 19, TypeScript 7.0, Vitest 4 (jsdom) + @testing-library/react, Rust (tauri, tauri-nspanel). No new runtime dependencies: persistence uses `localStorage` behind an interface; charts use inline SVG. + +## Global Constraints + +- **Platform:** macOS arm64 only (native keyboard APIs are macOS-specific). Guard native code with `#[cfg(target_os = "macos")]`. +- **Physical-keycode matching:** the trainer compares `KeyEvent.code` (physical, e.g. `KeyA`, `Space`) against the expected key for each Hebrew letter. Never depend on the produced character or the active OS input source. +- **Hebrew is RTL:** all target text and caret rendering use `dir="rtl"`. +- **Accuracy-first, Hebrew-strict:** stage-advance accuracy gate = **0.98**; confidence gate = **0.8**; default error handling = **stop-on-error** (block until corrected). These are constants, tunable in one place. +- **Reuse existing code:** physical→component-id mapping is `mapKeyCodeToComponentId` in `src/utils/keyMapping.ts`; the rendered keyboard is `src/components/Keyboard.tsx` using `src/components/Key.tsx`. Do not duplicate the key map. +- **No new deps** unless a task explicitly adds one (none do). +- **TDD, DRY, YAGNI, frequent commits.** Each task ends with a passing test run and a commit. Run tests with `npm test -- --run `; type-check with `npx tsc --noEmit`. +- **Commit messages:** conventional style (`feat:`, `test:`, `refactor:`, `chore:`), matching recent history. Do not push or open PRs unless asked. + +## File Structure + +``` +src/trainer/ + types.ts # all shared types (single source of truth) + data/ + hebrewLayout.ts # physical→Hebrew letter + finger map, sofit pairs, lookups + words.he.ts # curated common-word list (data) + prose.he.ts # public-domain prose/quotes with theme tags (data) + keySource/ + DomKeySource.ts # DOM keydown/keyup adapter + TauriTapKeySource.ts # Rust 'key-event' adapter + engine/ + confidence.ts # per-key confidence (pure) + gating.ts # stage-advance gate (pure) + curriculum.ts # stage ladder + sofit/confusable groups (pure) + textSelection.ts # real-word selection + finger-drill fallback (pure) + scoring.ts # WPM, per-char accuracy, whole-word accuracy (pure) + errors.ts # Hebrew error classification (pure) + keyboardView.ts # stats+guidance -> per-key visual view (pure) + ghost.ts # ghost-race position (pure) + streak.ts # streak/goal update (pure) + stars.ts # skill-star computation (pure) + storage.ts # TrainerStore interface + memory & localStorage impls + TrainerKeyboardContext.tsx # React context carrying the keyboard view + useTypingSession.ts # session hook over a KeySource + PracticePanel.tsx # RTL target text + caret + live coloring + PlacementTest.tsx # placement flow + seeding + SessionSummary.tsx # end-of-session results + StatsView.tsx # progress graphs (inline SVG) + CustomText.tsx # paste-your-own-text flow + Celebration.tsx # celebratory + fault animation layer + GhostBar.tsx # ghost-race progress bar + ThemeBackground.tsx # text-theme-tied background + TrainerMode.tsx # top-level trainer container (assembles everything) + +src/components/Key.tsx # MODIFY: optional id + trainer visuals via context +src/components/Keyboard.tsx # MODIFY: optional trainerView prop; ids on trainable keys +src/components/Keyboard.css # MODIFY: heat/next/finger/dim/hidden/fault styles +src/App.tsx # MODIFY: Trainer toggle + mode switch +src-tauri/src/lib.rs # MODIFY: set_trainer_mode command; register +src-tauri/src/keyboard_listener.rs # MODIFY: emit unthrottled 'key-event' +``` + +--- + +# Phase 1 — Foundation + +### Task 1: Shared types + Hebrew layout data + +**Files:** +- Create: `src/trainer/types.ts` +- Create: `src/trainer/data/hebrewLayout.ts` +- Test: `src/trainer/data/hebrewLayout.test.ts` + +**Interfaces:** +- Produces: all shared types (below); `HE_LAYOUT: LayoutKey[]`, lookups `byCode`, `byLetter`, `byComponentId`, `SOFIT_PAIRS`, and helpers `codeForLetter(letter): KeyCode | null`, `letterForCode(code): Letter | null`. + +- [ ] **Step 1: Write `types.ts`** (no test needed — types only; verified by `tsc` in later tasks) + +```ts +// src/trainer/types.ts +export type KeyCode = string; // physical key, e.g. "KeyA", "Space" +export type ComponentId = string; // Keyboard/Key id, e.g. "ka", "space" +export type Letter = string; // one Hebrew char, e.g. "ש", or " " for space + +export type FingerId = + | 'l-pinky' | 'l-ring' | 'l-middle' | 'l-index' + | 'r-index' | 'r-middle' | 'r-ring' | 'r-pinky' | 'thumb'; + +export interface LayoutKey { + code: KeyCode; + componentId: ComponentId; + letter: Letter; + finger: FingerId; + isSofit: boolean; +} + +export interface KeyEvent { code: KeyCode; ts: number; down: boolean } + +export interface KeySource { + start(onKey: (e: KeyEvent) => void): void; + stop(): void; +} + +export type GuidanceMode = 'full' | 'auto' | 'dim' | 'hidden'; +export type Strictness = 'stop' | 'markThrough'; +export type CaptureSource = 'dom' | 'tap'; + +export interface KeyStat { code: KeyCode; attempts: number; errors: number; latencies: number[] } + +export interface Settings { + guidanceMode: GuidanceMode; + strictness: Strictness; + captureSource: CaptureSource; + dailyGoalMinutes: number; +} + +export interface Progress { + unlockedStageIndex: number; + currentStageIndex: number; + bestByStage: Record; +} + +export interface StreakState { + lastPracticedISO: string | null; + current: number; + longest: number; + todayMinutes: number; +} + +export interface PosOutcome { code: KeyCode; firstTryCorrect: boolean; latencyMs: number } +export interface SessionLog { words: PosOutcome[][]; startTs: number; endTs: number } + +export interface SessionResult { + wpm: number; + accuracy: number; // per-char first-try, 0..1 + wholeWordAccuracy: number; // words all-first-try / total, 0..1 + durationMs: number; + typedChars: number; + perKey: Record; +} + +export interface TrainerKeyView { + heat?: number; // 0..1 confidence + isNextTarget?: boolean; + finger?: FingerId; + dim?: boolean; + hidden?: boolean; +} +export type KeyboardView = Record; + +export const DEFAULT_SETTINGS: Settings = { + guidanceMode: 'full', + strictness: 'stop', + captureSource: 'dom', + dailyGoalMinutes: 10, +}; +``` + +- [ ] **Step 2: Write the failing test** `src/trainer/data/hebrewLayout.test.ts` + +```ts +import { describe, it, expect } from 'vitest'; +import { HE_LAYOUT, byCode, byLetter, SOFIT_PAIRS, codeForLetter, letterForCode } from './hebrewLayout'; + +describe('hebrewLayout', () => { + it('maps home-row physical keys to the Israeli Hebrew letters', () => { + expect(byCode['KeyA'].letter).toBe('ש'); + expect(byCode['KeyK'].letter).toBe('ל'); + expect(byCode['KeyT'].letter).toBe('א'); + }); + + it('assigns standard touch-typing fingers', () => { + expect(byCode['KeyA'].finger).toBe('l-pinky'); + expect(byCode['KeyF'].finger).toBe('l-index'); + expect(byCode['KeyJ'].finger).toBe('r-index'); + expect(byCode['Space'].finger).toBe('thumb'); + }); + + it('marks the five sofit letters and pairs them with their regular form', () => { + expect(byLetter['ם'].isSofit).toBe(true); + expect(byLetter['מ'].isSofit).toBe(false); + expect(SOFIT_PAIRS).toContainEqual({ sofit: 'ם', regular: 'מ' }); + expect(SOFIT_PAIRS).toHaveLength(5); + }); + + it('round-trips letter<->code for a real word (שלום)', () => { + expect(codeForLetter('ש')).toBe('KeyA'); + expect(codeForLetter('ל')).toBe('KeyK'); + expect(codeForLetter('ו')).toBe('KeyU'); + expect(codeForLetter('ם')).toBe('KeyO'); + expect(letterForCode('KeyA')).toBe('ש'); + }); + + it('every componentId matches keyMapping for its code', () => { + // sanity: componentIds line up with the existing keyboard component ids + expect(byCode['KeyA'].componentId).toBe('ka'); + expect(byCode['Space'].componentId).toBe('space'); + }); +}); +``` + +- [ ] **Step 3: Run it, expect FAIL** — `npm test -- --run src/trainer/data/hebrewLayout.test.ts` → fails (module missing). + +- [ ] **Step 4: Write `hebrewLayout.ts`** + +```ts +// src/trainer/data/hebrewLayout.ts +import type { KeyCode, ComponentId, Letter, LayoutKey } from '../types'; + +// Israeli standard layout. Letters read from the existing Keyboard.tsx secondary +// labels; fingers are the standard touch-typing columns; componentIds match keyMapping.ts. +export const HE_LAYOUT: LayoutKey[] = [ + // home row + { code: 'KeyA', componentId: 'ka', letter: 'ש', finger: 'l-pinky', isSofit: false }, + { code: 'KeyS', componentId: 'ks', letter: 'ד', finger: 'l-ring', isSofit: false }, + { code: 'KeyD', componentId: 'kd', letter: 'ג', finger: 'l-middle', isSofit: false }, + { code: 'KeyF', componentId: 'kf', letter: 'כ', finger: 'l-index', isSofit: false }, + { code: 'KeyG', componentId: 'kg', letter: 'ע', finger: 'l-index', isSofit: false }, + { code: 'KeyH', componentId: 'kh', letter: 'י', finger: 'r-index', isSofit: false }, + { code: 'KeyJ', componentId: 'kj', letter: 'ח', finger: 'r-index', isSofit: false }, + { code: 'KeyK', componentId: 'kk', letter: 'ל', finger: 'r-middle', isSofit: false }, + { code: 'KeyL', componentId: 'kl', letter: 'ך', finger: 'r-ring', isSofit: true }, + { code: 'Semicolon', componentId: 'semi', letter: 'ף', finger: 'r-pinky', isSofit: true }, + // top row + { code: 'KeyE', componentId: 'ke', letter: 'ק', finger: 'l-middle', isSofit: false }, + { code: 'KeyR', componentId: 'kr', letter: 'ר', finger: 'l-index', isSofit: false }, + { code: 'KeyT', componentId: 'kt', letter: 'א', finger: 'l-index', isSofit: false }, + { code: 'KeyY', componentId: 'ky', letter: 'ט', finger: 'r-index', isSofit: false }, + { code: 'KeyU', componentId: 'ku', letter: 'ו', finger: 'r-index', isSofit: false }, + { code: 'KeyI', componentId: 'ki', letter: 'ן', finger: 'r-middle', isSofit: true }, + { code: 'KeyO', componentId: 'ko', letter: 'ם', finger: 'r-ring', isSofit: true }, + { code: 'KeyP', componentId: 'kp', letter: 'פ', finger: 'r-pinky', isSofit: false }, + // bottom row + { code: 'KeyZ', componentId: 'kz', letter: 'ז', finger: 'l-pinky', isSofit: false }, + { code: 'KeyX', componentId: 'kx', letter: 'ס', finger: 'l-ring', isSofit: false }, + { code: 'KeyC', componentId: 'kc', letter: 'ב', finger: 'l-middle', isSofit: false }, + { code: 'KeyV', componentId: 'kv', letter: 'ה', finger: 'l-index', isSofit: false }, + { code: 'KeyB', componentId: 'kb', letter: 'נ', finger: 'l-index', isSofit: false }, + { code: 'KeyN', componentId: 'kn', letter: 'מ', finger: 'r-index', isSofit: false }, + { code: 'KeyM', componentId: 'km', letter: 'צ', finger: 'r-index', isSofit: false }, + { code: 'Comma', componentId: 'comma', letter: 'ת', finger: 'r-middle', isSofit: false }, + { code: 'Period', componentId: 'dot', letter: 'ץ', finger: 'r-ring', isSofit: true }, + // space + { code: 'Space', componentId: 'space', letter: ' ', finger: 'thumb', isSofit: false }, +]; + +export const byCode: Record = Object.fromEntries(HE_LAYOUT.map(k => [k.code, k])); +export const byLetter: Record = Object.fromEntries(HE_LAYOUT.map(k => [k.letter, k])); +export const byComponentId: Record = Object.fromEntries(HE_LAYOUT.map(k => [k.componentId, k])); + +export const SOFIT_PAIRS: Array<{ sofit: Letter; regular: Letter }> = [ + { sofit: 'ך', regular: 'כ' }, + { sofit: 'ם', regular: 'מ' }, + { sofit: 'ן', regular: 'נ' }, + { sofit: 'ף', regular: 'פ' }, + { sofit: 'ץ', regular: 'צ' }, +]; + +export function codeForLetter(letter: Letter): KeyCode | null { + return byLetter[letter]?.code ?? null; +} +export function letterForCode(code: KeyCode): Letter | null { + return byCode[code]?.letter ?? null; +} +``` + +- [ ] **Step 5: Run it, expect PASS.** Then `npx tsc --noEmit` passes. + +- [ ] **Step 6: Commit** + +```bash +git add src/trainer/types.ts src/trainer/data/hebrewLayout.ts src/trainer/data/hebrewLayout.test.ts +git commit -m "feat(trainer): add shared types and Hebrew layout data" +``` + +--- + +### Task 2: `DomKeySource` + +**Files:** +- Create: `src/trainer/keySource/DomKeySource.ts` +- Test: `src/trainer/keySource/DomKeySource.test.ts` + +**Interfaces:** +- Consumes: `KeySource`, `KeyEvent` from `../types`. +- Produces: `class DomKeySource implements KeySource` with `constructor(target?: EventTarget)` (defaults to `window`). + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { DomKeySource } from './DomKeySource'; +import type { KeyEvent } from '../types'; + +describe('DomKeySource', () => { + it('emits down then up with the physical code', () => { + const target = new EventTarget(); + const src = new DomKeySource(target); + const events: KeyEvent[] = []; + src.start(e => events.push(e)); + + target.dispatchEvent(Object.assign(new Event('keydown'), { code: 'KeyA', timeStamp: 100 })); + target.dispatchEvent(Object.assign(new Event('keyup'), { code: 'KeyA', timeStamp: 150 })); + + expect(events).toEqual([ + { code: 'KeyA', ts: 100, down: true }, + { code: 'KeyA', ts: 150, down: false }, + ]); + }); + + it('stops listening after stop()', () => { + const target = new EventTarget(); + const src = new DomKeySource(target); + const events: KeyEvent[] = []; + src.start(e => events.push(e)); + src.stop(); + target.dispatchEvent(Object.assign(new Event('keydown'), { code: 'KeyA', timeStamp: 1 })); + expect(events).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/keySource/DomKeySource.ts +import type { KeySource, KeyEvent } from '../types'; + +export class DomKeySource implements KeySource { + private target: EventTarget; + private onKey: ((e: KeyEvent) => void) | null = null; + private downHandler = (ev: Event) => this.emit(ev as KeyboardEvent, true); + private upHandler = (ev: Event) => this.emit(ev as KeyboardEvent, false); + + constructor(target: EventTarget = window) { + this.target = target; + } + + start(onKey: (e: KeyEvent) => void): void { + this.onKey = onKey; + this.target.addEventListener('keydown', this.downHandler); + this.target.addEventListener('keyup', this.upHandler); + } + + stop(): void { + this.target.removeEventListener('keydown', this.downHandler); + this.target.removeEventListener('keyup', this.upHandler); + this.onKey = null; + } + + private emit(ev: KeyboardEvent, down: boolean): void { + if (!this.onKey) return; + this.onKey({ code: ev.code, ts: ev.timeStamp, down }); + } +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): add DomKeySource keystroke adapter"` + +--- + +### Task 3: `TauriTapKeySource` + +**Files:** +- Create: `src/trainer/keySource/TauriTapKeySource.ts` +- Test: `src/trainer/keySource/TauriTapKeySource.test.ts` + +**Interfaces:** +- Consumes: `KeySource`, `KeyEvent`; Tauri `listen` from `@tauri-apps/api/event`. +- Produces: `class TauriTapKeySource implements KeySource` with `constructor(opts?: { now?: () => number })` (test injects `now`). Stamps `ts` on arrival because the Rust event carries only `{ code, down }`. + +- [ ] **Step 1: Write the failing test** (mock the Tauri event module) + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { KeyEvent } from '../types'; + +let handler: ((e: { payload: { code: string; down: boolean } }) => void) | null = null; +const unlisten = vi.fn(); +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(async (_name: string, cb: any) => { handler = cb; return unlisten; }), +})); + +import { TauriTapKeySource } from './TauriTapKeySource'; + +describe('TauriTapKeySource', () => { + beforeEach(() => { handler = null; unlisten.mockClear(); }); + + it('emits KeyEvents stamped with the injected clock', async () => { + let t = 1000; + const src = new TauriTapKeySource({ now: () => t }); + const events: KeyEvent[] = []; + src.start(e => events.push(e)); + await Promise.resolve(); // let listen() resolve + + handler!({ payload: { code: 'KeyA', down: true } }); + t = 1080; + handler!({ payload: { code: 'KeyA', down: false } }); + + expect(events).toEqual([ + { code: 'KeyA', ts: 1000, down: true }, + { code: 'KeyA', ts: 1080, down: false }, + ]); + }); + + it('unlistens on stop()', async () => { + const src = new TauriTapKeySource(); + src.start(() => {}); + await Promise.resolve(); + src.stop(); + await Promise.resolve(); + expect(unlisten).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/keySource/TauriTapKeySource.ts +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { KeySource, KeyEvent } from '../types'; + +export class TauriTapKeySource implements KeySource { + private now: () => number; + private unlisten: UnlistenFn | null = null; + private stopped = false; + + constructor(opts: { now?: () => number } = {}) { + this.now = opts.now ?? (() => performance.now()); + } + + start(onKey: (e: KeyEvent) => void): void { + this.stopped = false; + listen<{ code: string; down: boolean }>('key-event', (event) => { + onKey({ code: event.payload.code, ts: this.now(), down: event.payload.down }); + }).then((un) => { + if (this.stopped) { un(); return; } + this.unlisten = un; + }); + } + + stop(): void { + this.stopped = true; + this.unlisten?.(); + this.unlisten = null; + } +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): add TauriTapKeySource keystroke adapter"` + +--- + +### Task 4: `TrainerStore` (persistence) + +**Files:** +- Create: `src/trainer/storage.ts` +- Test: `src/trainer/storage.test.ts` + +**Interfaces:** +- Produces: `interface TrainerStore { get(key: string, fallback: T): T; set(key: string, value: T): void }`; `createMemoryStore(): TrainerStore`; `createLocalStorageStore(): TrainerStore`; `STORAGE_KEYS` constants. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { createMemoryStore, createLocalStorageStore, STORAGE_KEYS } from './storage'; + +describe('TrainerStore', () => { + it('memory store round-trips values', () => { + const s = createMemoryStore(); + expect(s.get(STORAGE_KEYS.settings, { a: 1 })).toEqual({ a: 1 }); // fallback + s.set(STORAGE_KEYS.settings, { a: 2 }); + expect(s.get(STORAGE_KEYS.settings, { a: 1 })).toEqual({ a: 2 }); + }); + + it('localStorage store persists JSON and returns fallback on missing/corrupt', () => { + const s = createLocalStorageStore(); + expect(s.get('missing', 42)).toBe(42); + s.set('k', { x: 'שלום' }); + expect(s.get('k', null)).toEqual({ x: 'שלום' }); + localStorage.setItem('bad', '{not json'); + expect(s.get('bad', 'fallback')).toBe('fallback'); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/storage.ts +export interface TrainerStore { + get(key: string, fallback: T): T; + set(key: string, value: T): void; +} + +export const STORAGE_KEYS = { + settings: 'trainer.settings', + progress: 'trainer.progress', + stats: 'trainer.stats', + streak: 'trainer.streak', + ghosts: 'trainer.ghosts', +} as const; + +export function createMemoryStore(): TrainerStore { + const map = new Map(); + return { + get(key: string, fallback: T): T { + const raw = map.get(key); + if (raw == null) return fallback; + try { return JSON.parse(raw) as T; } catch { return fallback; } + }, + set(key: string, value: T): void { map.set(key, JSON.stringify(value)); }, + }; +} + +// localStorage in the Tauri webview persists to the app's data dir. The interface +// lets us swap in @tauri-apps/plugin-store or fs later without touching consumers. +export function createLocalStorageStore(): TrainerStore { + return { + get(key: string, fallback: T): T { + const raw = localStorage.getItem(key); + if (raw == null) return fallback; + try { return JSON.parse(raw) as T; } catch { return fallback; } + }, + set(key: string, value: T): void { localStorage.setItem(key, JSON.stringify(value)); }, + }; +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): add TrainerStore persistence interface + adapters"` + +--- + +### Task 5: Native — focusable window command + unthrottled key event + +**Files:** +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/src/keyboard_listener.rs` + +**Interfaces:** +- Produces (frontend-visible): Tauri command `set_trainer_mode(active: bool)`; Tauri event `"key-event"` with payload `{ code: string, down: bool }` emitted on every physical key press/release (unthrottled), consumed by `TauriTapKeySource`. + +> Native focus behavior is empirical. `DomKeySource` needs the window focusable; `TauriTapKeySource` does not (global tap). This task enables the focusable path and is the reason both capture sources exist — if focus proves unreliable, the tap source is the fallback. + +- [ ] **Step 1: Add the unthrottled event in `keyboard_listener.rs`.** In `start_listener`, inside the `while let Ok((keycode, is_press)) = rx.recv()` loop, right after `let code = keycode_to_string(keycode);`, add an unthrottled emit (independent of the 16ms `keyboard-state` throttle): + +```rust +// Unthrottled per-key event for the trainer's Tauri-tap capture source. +#[derive(Clone, serde::Serialize)] +struct KeyEventPayload { code: String, down: bool } +let _ = app_clone.emit("key-event", KeyEventPayload { code: code.clone(), down: is_press }); +``` + +(Place the `#[derive]` struct at module top-level instead if the linter prefers; keep the `emit` in the loop.) + +- [ ] **Step 2: Add the command in `lib.rs`.** Add near `get_active_keyboard_layout`: + +```rust +#[tauri::command] +fn set_trainer_mode(app: tauri::AppHandle, active: bool) { + #[cfg(target_os = "macos")] + { + use tauri::Manager; + if let Some(window) = app.get_webview_window("main") { + if active { + // Become a regular, focusable app so the webview receives key events + // and keystrokes don't leak to background apps. + let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular); + let _ = window.set_focus(); + } else { + // Revert to the non-activating overlay. + let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory); + } + } + } + #[cfg(not(target_os = "macos"))] + { let _ = (app, active); } +} +``` + +- [ ] **Step 3: Register the command.** In `lib.rs`, extend the handler: + +```rust +.invoke_handler(tauri::generate_handler![ + get_active_keyboard_layout, + key_simulator::simulate_key, + set_trainer_mode +]) +``` + +- [ ] **Step 4: Build & manual-verify (native — no unit test).** + +Run: `npm run tauri:dev` +Expected: app compiles. Temporarily add a dev button that calls `invoke('set_trainer_mode', { active: true })`; confirm (a) the window can take focus and typing lands in a focused DOM `` (not a background app), and (b) `listen('key-event', ...)` fires on every keypress. Then `invoke('set_trainer_mode', { active: false })` restores the non-activating overlay (typing again passes through / does not focus). Remove the dev button. + +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): add set_trainer_mode command and unthrottled key-event"` + +--- + +### Task 6: `Key` trainer visuals + `TrainerKeyboardContext` + +**Files:** +- Create: `src/trainer/TrainerKeyboardContext.tsx` +- Modify: `src/components/Key.tsx` +- Modify: `src/components/Keyboard.css` +- Test: `src/trainer/TrainerKeyboardContext.test.tsx` + +**Interfaces:** +- Produces: `TrainerKeyboardContext` (React context of `KeyboardView | null`), `TrainerKeyboardProvider`, `useTrainerKeyView(id?: ComponentId): TrainerKeyView | null`. `Key` gains optional `id?: ComponentId` on `BaseKeyProps` and renders trainer classes/styles when a view is present. + +- [ ] **Step 1: Write the failing test** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { Key } from '../components/Key'; +import { TrainerKeyboardProvider } from './TrainerKeyboardContext'; + +describe('Key trainer visuals', () => { + it('adds next-target and finger classes from the context view', () => { + const view = { ka: { isNextTarget: true, finger: 'l-pinky' as const, heat: 0.5 } }; + const { container } = render( + + + + ); + const el = container.querySelector('.key')!; + expect(el.className).toContain('key-next-target'); + expect(el.className).toContain('key-finger-l-pinky'); + }); + + it('hides the key legend when view.hidden is set', () => { + const view = { ka: { hidden: true } }; + const { container } = render( + + + + ); + expect(container.querySelector('.key')!.className).toContain('key-hidden'); + }); + + it('renders normally with no provider', () => { + const { container } = render(); + expect(container.querySelector('.key')!.className).not.toContain('key-next-target'); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Create the context** + +```tsx +// src/trainer/TrainerKeyboardContext.tsx +import React, { createContext, useContext } from 'react'; +import type { KeyboardView, TrainerKeyView, ComponentId } from './types'; + +const Ctx = createContext(null); + +export const TrainerKeyboardProvider: React.FC<{ value: KeyboardView | null; children: React.ReactNode }> = + ({ value, children }) => {children}; + +export function useTrainerKeyView(id?: ComponentId): TrainerKeyView | null { + const view = useContext(Ctx); + if (!view || !id) return null; + return view[id] ?? null; +} +``` + +- [ ] **Step 4: Modify `Key.tsx`.** (a) add `id?: string` to `BaseKeyProps`; (b) import the hook; (c) compute trainer classes/style; (d) merge into the root element. + +Add to `BaseKeyProps`: +```ts + id?: string; +``` +Add import at top: +```ts +import { useTrainerKeyView } from '../trainer/TrainerKeyboardContext'; +``` +Inside `Key`, after destructuring props, add: +```ts + const trainerView = useTrainerKeyView(props.id); + const trainerClass = trainerView + ? [ + trainerView.isNextTarget ? 'key-next-target' : '', + trainerView.finger ? `key-finger-${trainerView.finger}` : '', + trainerView.dim ? 'key-dim' : '', + trainerView.hidden ? 'key-hidden' : '', + ].filter(Boolean).join(' ') + : ''; + const trainerStyle = trainerView?.heat != null + ? ({ ['--key-heat' as any]: String(trainerView.heat) }) + : {}; +``` +Change the root `
` to include the class and style: +```tsx +
+``` + +- [ ] **Step 5: Add styles to `Keyboard.css`** (append): + +```css +/* Trainer visuals */ +.key-next-target { outline: 2px solid #4ade80; outline-offset: -2px; box-shadow: 0 0 10px rgba(74,222,128,0.7); } +.key-hidden .key-label-primary, +.key-hidden .key-label-secondary, +.key-hidden .key-label-single { visibility: hidden; } +.key-dim .key-label-primary, +.key-dim .key-label-secondary { opacity: 0.25; } +/* Heatmap: red (0) -> green (1) via --key-heat (0..1) */ +.key[style*="--key-heat"] { background-image: linear-gradient(hsl(calc(var(--key-heat) * 120) 70% 45% / 0.55), hsl(calc(var(--key-heat) * 120) 70% 45% / 0.55)); } +/* Finger zones (subtle left border tint) */ +.key-finger-l-pinky { border-bottom: 3px solid #f87171; } +.key-finger-l-ring { border-bottom: 3px solid #fb923c; } +.key-finger-l-middle { border-bottom: 3px solid #facc15; } +.key-finger-l-index { border-bottom: 3px solid #4ade80; } +.key-finger-r-index { border-bottom: 3px solid #22d3ee; } +.key-finger-r-middle { border-bottom: 3px solid #60a5fa; } +.key-finger-r-ring { border-bottom: 3px solid #a78bfa; } +.key-finger-r-pinky { border-bottom: 3px solid #f472b6; } +.key-finger-thumb { border-bottom: 3px solid #94a3b8; } +/* Fault animation (Task 27) */ +@keyframes key-fault { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } } +.key-fault { animation: key-fault 180ms ease-in-out; background-color: rgba(248,113,113,0.6) !important; } +``` + +- [ ] **Step 6: Run, expect PASS.** `npx tsc --noEmit` passes. +- [ ] **Step 7: Commit** — `git commit -am "feat(trainer): Key trainer visuals via TrainerKeyboardContext"` + +--- + +### Task 7: `Keyboard` accepts a trainer view + ids on trainable keys + +**Files:** +- Modify: `src/components/Keyboard.tsx` +- Test: `src/components/Keyboard.trainer.test.tsx` + +**Interfaces:** +- Consumes: `TrainerKeyboardProvider`, `KeyboardView`. +- Produces: `Keyboard` gains an optional prop `trainerView?: KeyboardView`. When provided, it wraps its output in `TrainerKeyboardProvider`. Every trainable key carries `id=""` (the same string already used in its `isKeyPressed('...')` call). + +- [ ] **Step 1: Write the failing test** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { Keyboard } from './Keyboard'; + +describe('Keyboard trainer view', () => { + it('applies the next-target class to the mapped key', () => { + const { container } = render(); + // The A/ש key must carry the highlight + const highlighted = container.querySelector('.key-next-target'); + expect(highlighted).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** (Keyboard has no `trainerView` prop; keys have no ids). + +- [ ] **Step 3: Modify `Keyboard.tsx`.** + 1. Change the component signature to accept the prop: + ```tsx + import { TrainerKeyboardProvider } from '../trainer/TrainerKeyboardContext'; + import type { KeyboardView } from '../trainer/types'; + + export const Keyboard: React.FC<{ trainerView?: KeyboardView }> = ({ trainerView }) => { + ``` + 2. Add `id=""` to every Key that already has an `isPressed={isKeyPressed('X')}` prop, using the same string `X`. The complete set of ids to add (matching existing `isKeyPressed` calls): `esc, n1..n0, mn, eq, backspace, tab, kq, kw, ke, kr, kt, ky, ku, ki, ko, kp, lb, rb, bs, ka, ks, kd, kf, kg, kh, kj, kk, kl, semi, quot, enter, lshift, kz, kx, kc, kv, kb, kn, km, comma, dot, slash, rshift, space`. Example edits: + ```tsx + handleKeyClick('KeyA')} /> + handleKeyClick('Space')} /> + ``` + 3. Wrap the returned root in the provider: + ```tsx + return ( + +
+ {/* ...existing rows unchanged... */} +
+
+ ); + ``` + +- [ ] **Step 4: Run, expect PASS.** Existing `Keyboard.test.tsx` still passes (prop is optional). +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): Keyboard accepts trainerView and tags trainable keys with ids"` + +--- + +### Task 8: Trainer entry/exit shell (`TrainerMode` skeleton + App toggle) + +**Files:** +- Create: `src/trainer/TrainerMode.tsx` +- Modify: `src/App.tsx` +- Test: `src/trainer/TrainerMode.test.tsx` + +**Interfaces:** +- Consumes: `Keyboard`, `invoke('set_trainer_mode')`. +- Produces: `TrainerMode` component with prop `onExit: () => void`; it calls `set_trainer_mode(true)` on mount and `set_trainer_mode(false)` on unmount, renders the reused `` and an (initially placeholder) panel. `App` renders a "Trainer" button that mounts `TrainerMode` and hides the overlay controls while active. + +- [ ] **Step 1: Write the failing test** (mock invoke) + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +const invoke = vi.fn(async () => {}); +vi.mock('@tauri-apps/api/core', () => ({ invoke })); + +import { TrainerMode } from './TrainerMode'; + +describe('TrainerMode', () => { + it('enters trainer mode on mount and exits on unmount', () => { + const onExit = vi.fn(); + const { unmount } = render(); + expect(invoke).toHaveBeenCalledWith('set_trainer_mode', { active: true }); + unmount(); + expect(invoke).toHaveBeenCalledWith('set_trainer_mode', { active: false }); + }); + + it('calls onExit when the Exit button is clicked', () => { + const onExit = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /exit/i })); + expect(onExit).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement `TrainerMode.tsx`** (skeleton; the session loop is wired in Task 21) + +```tsx +// src/trainer/TrainerMode.tsx +import React, { useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { Keyboard } from '../components/Keyboard'; + +export const TrainerMode: React.FC<{ onExit: () => void }> = ({ onExit }) => { + useEffect(() => { + invoke('set_trainer_mode', { active: true }).catch(console.error); + return () => { invoke('set_trainer_mode', { active: false }).catch(console.error); }; + }, []); + + return ( +
+
+ +
+
{/* PracticePanel mounts here in Task 21 */}
+ +
+ ); +}; +``` + +- [ ] **Step 4: Modify `App.tsx`.** Add trainer state and a toggle button in the control bar: + ```tsx + import { TrainerMode } from './trainer/TrainerMode'; + // ...inside App component state: + const [isTraining, setIsTraining] = useState(false); + // ...early return when training (before the collapsed/overlay returns): + if (isTraining) { + return setIsTraining(false)} />; + } + // ...in the .window-controls div, add: + + ``` + +- [ ] **Step 5: Run, expect PASS.** `npx tsc --noEmit` passes. +- [ ] **Step 6: Commit** — `git commit -am "feat(trainer): trainer entry/exit shell and App toggle"` + +--- + +# Phase 2 — Pedagogy Core + +### Task 9: Curriculum ladder + +**Files:** +- Create: `src/trainer/curriculum.ts` +- Test: `src/trainer/curriculum.test.ts` + +**Interfaces:** +- Produces: `interface Stage { index: number; name: string; newCodes: KeyCode[] }`; `STAGES: Stage[]`; `unlockedCodesForStage(index: number): Set` (cumulative, includes `Space`); `CONFUSABLE_GROUPS: Letter[][]`; `SOFIT_STAGE_INDEX: number`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { STAGES, unlockedCodesForStage, CONFUSABLE_GROUPS, SOFIT_STAGE_INDEX } from './curriculum'; + +describe('curriculum', () => { + it('starts on the home-row index anchors + space', () => { + expect(STAGES[0].newCodes).toEqual(['KeyF', 'KeyJ']); + expect(unlockedCodesForStage(0).has('Space')).toBe(true); + }); + it('accumulates unlocked codes across stages', () => { + const s1 = unlockedCodesForStage(1); + expect(s1.has('KeyF')).toBe(true); // from stage 0 + STAGES[1].newCodes.forEach(c => expect(s1.has(c)).toBe(true)); + }); + it('has a dedicated sofit stage covering the five finals', () => { + const sofit = STAGES[SOFIT_STAGE_INDEX].newCodes; + ['KeyL','KeyO','KeyI','Semicolon','Period'].forEach(c => expect(sofit).toContain(c)); + }); + it('defines confusable letter groups', () => { + expect(CONFUSABLE_GROUPS).toContainEqual(['ד', 'ר']); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/curriculum.ts +import type { KeyCode, Letter } from './types'; + +export interface Stage { index: number; name: string; newCodes: KeyCode[] } + +const STAGE_DEFS: Array<{ name: string; newCodes: KeyCode[] }> = [ + { name: 'Home anchors', newCodes: ['KeyF', 'KeyJ'] }, // כ ח (F/J bumps) + { name: 'Home index', newCodes: ['KeyG', 'KeyH'] }, // ע י + { name: 'Home middle', newCodes: ['KeyD', 'KeyK'] }, // ג ל + { name: 'Home ring', newCodes: ['KeyS'] }, // ד + { name: 'Home pinky', newCodes: ['KeyA'] }, // ש (full home row minus finals) + { name: 'Top index', newCodes: ['KeyR', 'KeyT', 'KeyU', 'KeyY'] }, // ר א ו ט + { name: 'Top middle/ring',newCodes: ['KeyE', 'KeyP'] }, // ק פ + { name: 'Bottom index', newCodes: ['KeyV', 'KeyB', 'KeyN'] }, // ה נ מ + { name: 'Bottom outer', newCodes: ['KeyZ', 'KeyX', 'KeyC', 'KeyM', 'Comma'] }, // ז ס ב צ ת + { name: 'Sofit finals', newCodes: ['KeyL', 'KeyO', 'KeyI', 'Semicolon', 'Period'] }, // ך ם ן ף ץ +]; + +export const STAGES: Stage[] = STAGE_DEFS.map((s, i) => ({ index: i, ...s })); +export const SOFIT_STAGE_INDEX = STAGES.findIndex(s => s.name === 'Sofit finals'); + +export function unlockedCodesForStage(index: number): Set { + const set = new Set(['Space']); + for (let i = 0; i <= index && i < STAGES.length; i++) { + STAGES[i].newCodes.forEach(c => set.add(c)); + } + return set; +} + +// Visually / positionally confusable Hebrew letters (meaning-changing if swapped). +export const CONFUSABLE_GROUPS: Letter[][] = [ + ['ד', 'ר'], ['כ', 'ב'], ['ה', 'ח', 'ת'], ['ט', 'ת'], ['ם', 'ס'], ['ן', 'ו'], ['ג', 'נ'], +]; +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): Hebrew curriculum ladder with sofit and confusable groups"` + +--- + +### Task 10: Confidence model + +**Files:** +- Create: `src/trainer/engine/confidence.ts` +- Test: `src/trainer/engine/confidence.test.ts` + +**Interfaces:** +- Produces: `median(nums: number[]): number`; `accuracyFor(stat: KeyStat): number`; `confidenceFor(stat: KeyStat, targetMs?: number): number` (0..1; 0 when no attempts; weights accuracy 0.6 / speed 0.4; `targetMs` default 250). + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { median, accuracyFor, confidenceFor } from './confidence'; +import type { KeyStat } from '../types'; + +const stat = (o: Partial): KeyStat => ({ code: 'KeyF', attempts: 0, errors: 0, latencies: [], ...o }); + +describe('confidence', () => { + it('median handles odd/even', () => { + expect(median([300, 100, 200])).toBe(200); + expect(median([100, 200, 300, 400])).toBe(250); + expect(median([])).toBe(0); + }); + it('accuracy is 1 - errors/attempts', () => { + expect(accuracyFor(stat({ attempts: 10, errors: 1 }))).toBeCloseTo(0.9); + expect(accuracyFor(stat({ attempts: 0 }))).toBe(0); + }); + it('no attempts => confidence 0', () => { + expect(confidenceFor(stat({}))).toBe(0); + }); + it('perfect accuracy at/under target speed => confidence 1', () => { + expect(confidenceFor(stat({ attempts: 20, errors: 0, latencies: [200, 220, 180] }), 250)).toBeCloseTo(1, 2); + }); + it('errors and slowness reduce confidence', () => { + const c = confidenceFor(stat({ attempts: 20, errors: 4, latencies: [500, 520] }), 250); + expect(c).toBeGreaterThan(0); + expect(c).toBeLessThan(0.7); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/engine/confidence.ts +import type { KeyStat } from '../types'; + +export function median(nums: number[]): number { + if (nums.length === 0) return 0; + const s = [...nums].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +export function accuracyFor(stat: KeyStat): number { + if (stat.attempts === 0) return 0; + return Math.max(0, 1 - stat.errors / stat.attempts); +} + +export function confidenceFor(stat: KeyStat, targetMs = 250): number { + if (stat.attempts === 0) return 0; + const accuracy = accuracyFor(stat); + const med = median(stat.latencies); + const speed = med === 0 ? 1 : Math.min(1, targetMs / med); + return Math.max(0, Math.min(1, 0.6 * accuracy + 0.4 * speed)); +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): per-key confidence model"` + +--- + +### Task 11: Stage-advance gating + +**Files:** +- Create: `src/trainer/engine/gating.ts` +- Test: `src/trainer/engine/gating.test.ts` + +**Interfaces:** +- Consumes: `accuracyFor`, `confidenceFor`, `KeyStat`, `Stage`, `unlockedCodesForStage`. +- Produces: `GATE = { minAccuracy: 0.98, minConfidence: 0.8 }`; `canAdvance(stageIndex: number, statsByCode: Record, gate?): boolean` — true when every unlocked code in the stage meets both thresholds. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { canAdvance, GATE } from './gating'; +import type { KeyStat } from '../types'; + +const good = (code: string): KeyStat => ({ code, attempts: 50, errors: 0, latencies: [180, 200] }); +const weak = (code: string): KeyStat => ({ code, attempts: 50, errors: 10, latencies: [600] }); + +describe('gating', () => { + it('advances when all stage-0 keys are mastered', () => { + const stats = { KeyF: good('KeyF'), KeyJ: good('KeyJ'), Space: good('Space') }; + expect(canAdvance(0, stats)).toBe(true); + }); + it('blocks when any stage key is weak', () => { + const stats = { KeyF: good('KeyF'), KeyJ: weak('KeyJ'), Space: good('Space') }; + expect(canAdvance(0, stats)).toBe(false); + }); + it('blocks when a stage key has no data', () => { + expect(canAdvance(0, { KeyF: good('KeyF'), Space: good('Space') })).toBe(false); + }); + it('gate is Hebrew-strict', () => { + expect(GATE.minAccuracy).toBe(0.98); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/engine/gating.ts +import type { KeyCode, KeyStat } from '../types'; +import { accuracyFor, confidenceFor } from './confidence'; +import { unlockedCodesForStage } from '../curriculum'; + +export const GATE = { minAccuracy: 0.98, minConfidence: 0.8 }; + +export function canAdvance( + stageIndex: number, + statsByCode: Record, + gate = GATE, +): boolean { + const codes = unlockedCodesForStage(stageIndex); + for (const code of codes) { + const stat = statsByCode[code]; + if (!stat || stat.attempts === 0) return false; + if (accuracyFor(stat) < gate.minAccuracy) return false; + if (confidenceFor(stat) < gate.minConfidence) return false; + } + return true; +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): accuracy-first stage gating"` + +--- + +### Task 12: Text selection (real words + finger-drill fallback) + +**Files:** +- Create: `src/trainer/data/words.he.ts` (data) +- Create: `src/trainer/textSelection.ts` +- Test: `src/trainer/textSelection.test.ts` + +**Interfaces:** +- Consumes: `byLetter`, `codeForLetter` from layout; confidences map. +- Produces: `COMMON_WORDS_HE: string[]` (data); `wordIsTypable(word: string, unlocked: Set): boolean`; `weakKeyScore(word: string, confByCode: Record): number`; `selectPractice(opts): string` where + ```ts + interface SelectOpts { + unlocked: Set; + confByCode: Record; + corpus: string[]; + targetChars: number; + rng: () => number; // deterministic in tests + } + ``` + Returns a space-joined practice string of real words; falls back to bigram finger drills when fewer than 3 letter-keys are unlocked or no corpus word is typable. + +- [ ] **Step 1: Create `words.he.ts`** (seed list; expand later) + +```ts +// src/trainer/data/words.he.ts +// Curated common Hebrew words (no niqqud). Ordered roughly by frequency. +// Seed set — expand during content curation. +export const COMMON_WORDS_HE: string[] = [ + 'של', 'את', 'על', 'לא', 'זה', 'הוא', 'היא', 'אני', 'הם', 'גם', + 'כל', 'יש', 'אין', 'מה', 'מי', 'כי', 'אם', 'עם', 'או', 'אבל', + 'שלום', 'בית', 'ילד', 'ילדה', 'אבא', 'אמא', 'מים', 'לחם', 'ספר', 'יום', + 'לילה', 'אור', 'גדול', 'קטן', 'טוב', 'רע', 'חדש', 'ישן', 'הלך', 'בא', + 'ראש', 'עין', 'יד', 'רגל', 'דלת', 'חלון', 'שולחן', 'כיסא', 'עץ', 'פרח', +]; +``` + +- [ ] **Step 2: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { wordIsTypable, weakKeyScore, selectPractice } from './textSelection'; +import { unlockedCodesForStage } from './curriculum'; + +const seededRng = (seq: number[]) => { let i = 0; return () => seq[(i++) % seq.length]; }; + +describe('textSelection', () => { + it('accepts a word only when all its letters are unlocked', () => { + const unlocked = new Set(['KeyA', 'KeyK', 'KeyU', 'KeyO', 'Space']); // ש ל ו ם + expect(wordIsTypable('שלום', unlocked)).toBe(true); + expect(wordIsTypable('בית', unlocked)).toBe(false); // ב not unlocked + }); + + it('scores words higher when they contain low-confidence keys', () => { + const conf = { KeyA: 0.1, KeyK: 0.9, KeyU: 0.9, KeyO: 0.9 }; // ש is weak + const withWeak = weakKeyScore('שלום', conf); + const withoutWeak = weakKeyScore('לו', conf); + expect(withWeak).toBeGreaterThan(withoutWeak); + }); + + it('falls back to finger drills when too few keys are unlocked', () => { + const out = selectPractice({ + unlocked: unlockedCodesForStage(0), // only KeyF, KeyJ, Space + confByCode: {}, corpus: ['שלום', 'בית'], targetChars: 12, rng: seededRng([0.1, 0.9]), + }); + // Only כ (F) and ח (J) available -> no real word -> drill of those letters + expect(out.replace(/\s/g, '').split('').every(ch => ch === 'כ' || ch === 'ח')).toBe(true); + expect(out.length).toBeGreaterThan(0); + }); + + it('produces only typable real words when enough keys are unlocked', () => { + const unlocked = new Set(['KeyA', 'KeyK', 'KeyU', 'KeyO', 'KeyC', 'KeyH', 'KeyT', 'Space']); + const out = selectPractice({ + unlocked, confByCode: {}, corpus: ['שלום', 'בית', 'לו'], targetChars: 20, rng: seededRng([0.5]), + }); + out.split(' ').filter(Boolean).forEach(w => expect(wordIsTypable(w, unlocked)).toBe(true)); + }); +}); +``` + +- [ ] **Step 3: Run, expect FAIL.** + +- [ ] **Step 4: Implement** + +```ts +// src/trainer/textSelection.ts +import type { KeyCode } from './types'; +import { codeForLetter } from './data/hebrewLayout'; + +const MIN_LETTER_KEYS_FOR_WORDS = 3; + +export function wordIsTypable(word: string, unlocked: Set): boolean { + for (const ch of word) { + const code = codeForLetter(ch); + if (!code || !unlocked.has(code)) return false; + } + return true; +} + +export function weakKeyScore(word: string, confByCode: Record): number { + // Higher when the word exercises low-confidence keys. weakness = 1 - confidence. + let score = 0; + for (const ch of word) { + const code = codeForLetter(ch); + if (!code) continue; + const conf = confByCode[code] ?? 0; + score += (1 - conf); + } + return score; +} + +function unlockedLetterKeys(unlocked: Set): KeyCode[] { + return [...unlocked].filter(c => c !== 'Space'); +} + +function fingerDrill(unlocked: Set, targetChars: number, rng: () => number): string { + const letters = unlockedLetterKeys(unlocked) + .map(c => lettersForCode(c)).filter(Boolean) as string[]; + if (letters.length === 0) return ''; + const parts: string[] = []; + let count = 0; + while (count < targetChars) { + const a = letters[Math.floor(rng() * letters.length)]; + const b = letters[Math.floor(rng() * letters.length)]; + const bigram = a + b; + parts.push(bigram); + count += bigram.length + 1; + } + return parts.join(' '); +} + +// local: letter for a code (avoids importing byCode circularly in tests) +import { byCode } from './data/hebrewLayout'; +function lettersForCode(code: KeyCode): string | null { return byCode[code]?.letter ?? null; } + +export interface SelectOpts { + unlocked: Set; + confByCode: Record; + corpus: string[]; + targetChars: number; + rng: () => number; +} + +export function selectPractice(opts: SelectOpts): string { + const { unlocked, confByCode, corpus, targetChars, rng } = opts; + const typable = corpus.filter(w => wordIsTypable(w, unlocked)); + + if (unlockedLetterKeys(unlocked).length < MIN_LETTER_KEYS_FOR_WORDS || typable.length === 0) { + return fingerDrill(unlocked, targetChars, rng); + } + + // Rank by weak-key score, then sample from the top half weighted by rng. + const ranked = [...typable].sort((a, b) => weakKeyScore(b, confByCode) - weakKeyScore(a, confByCode)); + const pool = ranked.slice(0, Math.max(3, Math.ceil(ranked.length / 2))); + + const chosen: string[] = []; + let count = 0; + while (count < targetChars) { + const w = pool[Math.floor(rng() * pool.length)]; + chosen.push(w); + count += w.length + 1; + } + return chosen.join(' '); +} +``` + +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Commit** — `git commit -am "feat(trainer): real-word selection with weak-key ranking and finger-drill fallback"` + +--- + +### Task 13: Session scoring + +**Files:** +- Create: `src/trainer/scoring.ts` +- Test: `src/trainer/scoring.test.ts` + +**Interfaces:** +- Consumes: `SessionLog`, `SessionResult`. +- Produces: `computeSessionResult(log: SessionLog): SessionResult`. WPM = (typedChars / 5) / minutes; accuracy = first-try-correct positions / total positions; wholeWordAccuracy = words with all positions first-try-correct / total words; `perKey` aggregates attempts/errors/median latency per code. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { computeSessionResult } from './scoring'; +import type { SessionLog } from './types'; + +const log: SessionLog = { + startTs: 0, + endTs: 60000, // 1 minute + words: [ + // word "שלום" (4 chars) all first-try + [ + { code: 'KeyA', firstTryCorrect: true, latencyMs: 200 }, + { code: 'KeyK', firstTryCorrect: true, latencyMs: 200 }, + { code: 'KeyU', firstTryCorrect: true, latencyMs: 200 }, + { code: 'KeyO', firstTryCorrect: true, latencyMs: 200 }, + ], + // word "לא" (2 chars) with one miss + [ + { code: 'KeyK', firstTryCorrect: true, latencyMs: 200 }, + { code: 'KeyT', firstTryCorrect: false, latencyMs: 400 }, + ], + ], +}; + +describe('computeSessionResult', () => { + it('computes wpm from typed chars over minutes', () => { + const r = computeSessionResult(log); + // 6 chars / 5 = 1.2 words in 1 minute + expect(r.wpm).toBeCloseTo(1.2, 1); + }); + it('per-char accuracy counts first-try correctness', () => { + expect(computeSessionResult(log).accuracy).toBeCloseTo(5 / 6, 3); + }); + it('whole-word accuracy: a single miss fails the whole word (Hebrew reality)', () => { + expect(computeSessionResult(log).wholeWordAccuracy).toBeCloseTo(0.5, 3); + }); + it('aggregates per-key attempts/errors', () => { + const r = computeSessionResult(log); + expect(r.perKey['KeyK'].attempts).toBe(2); + expect(r.perKey['KeyT'].errors).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/scoring.ts +import type { SessionLog, SessionResult, KeyCode } from './types'; +import { median } from './engine/confidence'; + +export function computeSessionResult(log: SessionLog): SessionResult { + const positions = log.words.flat(); + const typedChars = positions.length; + const correctFirst = positions.filter(p => p.firstTryCorrect).length; + const durationMs = Math.max(1, log.endTs - log.startTs); + const minutes = durationMs / 60000; + + const perKeyLatencies: Record = {}; + const perKey: SessionResult['perKey'] = {}; + for (const p of positions) { + perKey[p.code] ??= { attempts: 0, errors: 0, medianLatency: 0 }; + perKey[p.code].attempts += 1; + if (!p.firstTryCorrect) perKey[p.code].errors += 1; + (perKeyLatencies[p.code] ??= []).push(p.latencyMs); + } + for (const code of Object.keys(perKey)) perKey[code].medianLatency = median(perKeyLatencies[code]); + + const wholeWords = log.words.length; + const perfectWords = log.words.filter(w => w.every(p => p.firstTryCorrect)).length; + + return { + wpm: (typedChars / 5) / minutes, + accuracy: typedChars ? correctFirst / typedChars : 0, + wholeWordAccuracy: wholeWords ? perfectWords / wholeWords : 0, + durationMs, + typedChars, + perKey, + }; +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): session scoring (wpm, per-char + whole-word accuracy)"` + +--- + +### Task 14: Hebrew error classification + +**Files:** +- Create: `src/trainer/errors.ts` +- Test: `src/trainer/errors.test.ts` + +**Interfaces:** +- Consumes: `SOFIT_PAIRS`, `CONFUSABLE_GROUPS`, `COMMON_WORDS_HE`. +- Produces: `errorKind(expected: Letter, typed: Letter): 'sofit' | 'confusable' | 'other'`; `classifyMistype(expectedWord: string, typedWord: string, wordSet: Set): { isRealWord: boolean; typedWord: string }`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { errorKind, classifyMistype } from './errors'; + +describe('errors', () => { + it('detects sofit vs regular confusion', () => { + expect(errorKind('מ', 'ם')).toBe('sofit'); + expect(errorKind('ם', 'מ')).toBe('sofit'); + }); + it('detects confusable pairs', () => { + expect(errorKind('ד', 'ר')).toBe('confusable'); + }); + it('falls back to other', () => { + expect(errorKind('א', 'ב')).toBe('other'); + }); + it('flags when a mistype produced a real (different) word', () => { + const set = new Set(['שלום', 'שלוט']); + expect(classifyMistype('שלום', 'שלוט', set)).toEqual({ isRealWord: true, typedWord: 'שלוט' }); + expect(classifyMistype('שלום', 'שלוx', set).isRealWord).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/errors.ts +import type { Letter } from './types'; +import { SOFIT_PAIRS } from './data/hebrewLayout'; +import { CONFUSABLE_GROUPS } from './curriculum'; + +const sofitSet = new Set(SOFIT_PAIRS.flatMap(p => [p.sofit + p.regular, p.regular + p.sofit])); + +export function errorKind(expected: Letter, typed: Letter): 'sofit' | 'confusable' | 'other' { + if (sofitSet.has(expected + typed)) return 'sofit'; + for (const group of CONFUSABLE_GROUPS) { + if (group.includes(expected) && group.includes(typed)) return 'confusable'; + } + return 'other'; +} + +export function classifyMistype( + expectedWord: string, + typedWord: string, + wordSet: Set, +): { isRealWord: boolean; typedWord: string } { + return { isRealWord: typedWord !== expectedWord && wordSet.has(typedWord), typedWord }; +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): Hebrew error classification (sofit, confusable, real-word)"` + +--- + +### Task 15: `useTypingSession` hook + +**Files:** +- Create: `src/trainer/useTypingSession.ts` +- Test: `src/trainer/useTypingSession.test.ts` + +**Interfaces:** +- Consumes: `KeySource`, `Strictness`, `codeForLetter`, `SessionLog`. +- Produces: `useTypingSession(opts): SessionState` where + ```ts + interface UseSessionOpts { source: KeySource; target: string; strictness: Strictness; onComplete: (log: SessionLog) => void } + interface SessionState { index: number; expectedCodes: KeyCode[]; statuses: ('pending'|'correct'|'error')[]; nextCode: KeyCode | null } + ``` + It builds `expectedCodes` from `target` (each letter → its physical code; a space → `'Space'`), listens to the source, compares `event.code` on key-down, records first-try correctness + latency, enforces stop-on-error, and calls `onComplete` when the last position is correct. + +- [ ] **Step 1: Write the failing test** (drive a fake KeySource) + +```ts +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTypingSession } from './useTypingSession'; +import type { KeySource, KeyEvent, SessionLog } from './types'; + +function fakeSource() { + let cb: ((e: KeyEvent) => void) | null = null; + const src: KeySource = { start: (f) => { cb = f; }, stop: () => { cb = null; } }; + const press = (code: string, ts: number) => act(() => cb!({ code, ts, down: true })); + return { src, press }; +} + +describe('useTypingSession', () => { + it('advances on the correct physical key and completes', () => { + const { src, press } = fakeSource(); + const onComplete = vi.fn(); + // target "לו" -> KeyK, KeyU + const { result } = renderHook(() => useTypingSession({ source: src, target: 'לו', strictness: 'stop', onComplete })); + expect(result.current.nextCode).toBe('KeyK'); + press('KeyK', 100); + expect(result.current.index).toBe(1); + press('KeyU', 300); + const log: SessionLog = onComplete.mock.calls[0][0]; + expect(log.words[0][0]).toMatchObject({ code: 'KeyK', firstTryCorrect: true }); + expect(log.words[0][1].latencyMs).toBe(200); + }); + + it('stop-on-error blocks advance and marks first-try false', () => { + const { src, press } = fakeSource(); + const onComplete = vi.fn(); + const { result } = renderHook(() => useTypingSession({ source: src, target: 'לו', strictness: 'stop', onComplete })); + press('KeyT', 100); // wrong + expect(result.current.index).toBe(0); + expect(result.current.statuses[0]).toBe('error'); + press('KeyK', 150); // correct now + expect(result.current.index).toBe(1); + press('KeyU', 200); + expect(onComplete.mock.calls[0][0].words[0][0].firstTryCorrect).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/useTypingSession.ts +import { useEffect, useRef, useState } from 'react'; +import type { KeySource, KeyEvent, KeyCode, Strictness, SessionLog, PosOutcome } from './types'; +import { codeForLetter } from './data/hebrewLayout'; + +interface UseSessionOpts { source: KeySource; target: string; strictness: Strictness; onComplete: (log: SessionLog) => void } +interface SessionState { index: number; expectedCodes: KeyCode[]; statuses: ('pending'|'correct'|'error')[]; nextCode: KeyCode | null } + +function buildExpected(target: string): { codes: KeyCode[]; wordOf: number[] } { + const codes: KeyCode[] = []; + const wordOf: number[] = []; + let word = 0; + for (const ch of target) { + if (ch === ' ') { codes.push('Space'); wordOf.push(word); word += 1; } + else { const c = codeForLetter(ch); if (c) { codes.push(c); wordOf.push(word); } } + } + return { codes, wordOf }; +} + +export function useTypingSession(opts: UseSessionOpts): SessionState { + const { source, target, strictness, onComplete } = opts; + const { codes, wordOf } = buildExpected(target); + + const [index, setIndex] = useState(0); + const [statuses, setStatuses] = useState<('pending'|'correct'|'error')[]>(() => codes.map(() => 'pending')); + + // Refs so the event handler always sees current values without re-subscribing. + const idx = useRef(0); + const hadErrorHere = useRef(false); + const lastTs = useRef(null); + const startTs = useRef(null); + const outcomes = useRef([]); + + useEffect(() => { + const onKey = (e: KeyEvent) => { + if (!e.down) return; + const i = idx.current; + if (i >= codes.length) return; + if (startTs.current === null) startTs.current = e.ts; + + if (e.code === codes[i]) { + const latency = lastTs.current === null ? 0 : e.ts - lastTs.current; + lastTs.current = e.ts; + outcomes.current.push({ code: codes[i], firstTryCorrect: !hadErrorHere.current, latencyMs: latency }); + hadErrorHere.current = false; + setStatuses(s => { const n = [...s]; n[i] = 'correct'; return n; }); + const next = i + 1; + idx.current = next; setIndex(next); + if (next >= codes.length) { + const words: PosOutcome[][] = []; + outcomes.current.forEach((o, k) => { const w = wordOf[k]; (words[w] ??= []).push(o); }); + onComplete({ words: words.filter(Boolean), startTs: startTs.current!, endTs: e.ts }); + } + } else { + hadErrorHere.current = true; + setStatuses(s => { const n = [...s]; n[i] = 'error'; return n; }); + if (strictness === 'markThrough') { + outcomes.current.push({ code: codes[i], firstTryCorrect: false, latencyMs: 0 }); + const next = i + 1; idx.current = next; setIndex(next); + } + } + }; + source.start(onKey); + return () => source.stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [target]); + + return { index, expectedCodes: codes, statuses, nextCode: codes[index] ?? null }; +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): useTypingSession hook over a KeySource"` + +--- + +### Task 16: `PracticePanel` (RTL text + live coloring) + +**Files:** +- Create: `src/trainer/PracticePanel.tsx` +- Create: `src/trainer/trainer.css` +- Test: `src/trainer/PracticePanel.test.tsx` + +**Interfaces:** +- Consumes: `SessionState`-like props. +- Produces: `PracticePanel({ target, statuses, index })` rendering the target RTL with per-char classes `char-correct | char-error | char-current | char-pending`. + +- [ ] **Step 1: Write the failing test** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { PracticePanel } from './PracticePanel'; + +describe('PracticePanel', () => { + it('renders RTL and marks per-char status', () => { + const { container } = render( + + ); + const root = container.querySelector('.practice-text')!; + expect(root.getAttribute('dir')).toBe('rtl'); + const chars = container.querySelectorAll('.practice-char'); + expect(chars[0].className).toContain('char-correct'); + expect(chars[1].className).toContain('char-current'); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```tsx +// src/trainer/PracticePanel.tsx +import React from 'react'; +import './trainer.css'; + +interface Props { target: string; statuses: ('pending'|'correct'|'error')[]; index: number } + +export const PracticePanel: React.FC = ({ target, statuses, index }) => { + const chars = [...target]; + return ( +
+ {chars.map((ch, i) => { + const status = i === index ? 'current' : statuses[i] ?? 'pending'; + return ( + + {ch === ' ' ? ' ' : ch} + + ); + })} +
+ ); +}; +``` + +```css +/* src/trainer/trainer.css */ +.practice-text { font-size: 28px; line-height: 1.6; direction: rtl; letter-spacing: 2px; padding: 12px; } +.practice-char { padding: 0 1px; } +.char-pending { color: #9ca3af; } +.char-correct { color: #4ade80; } +.char-error { color: #f87171; text-decoration: underline wavy #f87171; } +.char-current { color: #e5e7eb; background: rgba(96,165,250,0.35); border-radius: 3px; } +.trainer-mode { display: flex; flex-direction: column; gap: 8px; padding: 8px; } +.trainer-topbar { display: flex; justify-content: space-between; align-items: center; } +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): PracticePanel with RTL live coloring"` + +--- + +### Task 17: `keyboardView` (stats + guidance → per-key visuals) + +**Files:** +- Create: `src/trainer/keyboardView.ts` +- Test: `src/trainer/keyboardView.test.ts` + +**Interfaces:** +- Consumes: `byCode`, `KeyStat`, `confidenceFor`, `GuidanceMode`, `KeyboardView`. +- Produces: `buildKeyboardView(opts): KeyboardView` where + ```ts + interface ViewOpts { nextCode: KeyCode | null; statsByCode: Record; guidance: GuidanceMode } + ``` + Maps each layout key → `{ heat, finger, isNextTarget, dim, hidden }` keyed by componentId. Guidance rules: `full` → all shown; `hidden` → all hidden (except next target); `dim` → all dimmed; `auto` → a key is hidden once its confidence ≥ 0.8 (the next target always stays visible). + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { buildKeyboardView } from './keyboardView'; +import type { KeyStat } from './types'; + +const strong = (code: string): KeyStat => ({ code, attempts: 50, errors: 0, latencies: [180] }); + +describe('buildKeyboardView', () => { + it('marks the next target and its finger', () => { + const v = buildKeyboardView({ nextCode: 'KeyA', statsByCode: {}, guidance: 'full' }); + expect(v['ka'].isNextTarget).toBe(true); + expect(v['ka'].finger).toBe('l-pinky'); + }); + it('auto guidance hides mastered keys but keeps the next target visible', () => { + const v = buildKeyboardView({ nextCode: 'KeyF', statsByCode: { KeyA: strong('KeyA'), KeyF: strong('KeyF') }, guidance: 'auto' }); + expect(v['ka'].hidden).toBe(true); // mastered, not next + expect(v['kf'].hidden).toBeFalsy(); // next target stays visible + }); + it('hidden guidance hides everything except the next target', () => { + const v = buildKeyboardView({ nextCode: 'KeyA', statsByCode: {}, guidance: 'hidden' }); + expect(v['ks'].hidden).toBe(true); + expect(v['ka'].hidden).toBeFalsy(); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/keyboardView.ts +import type { KeyCode, KeyStat, GuidanceMode, KeyboardView } from './types'; +import { HE_LAYOUT } from './data/hebrewLayout'; +import { confidenceFor } from './engine/confidence'; +import { GATE } from './engine/gating'; + +interface ViewOpts { nextCode: KeyCode | null; statsByCode: Record; guidance: GuidanceMode } + +export function buildKeyboardView({ nextCode, statsByCode, guidance }: ViewOpts): KeyboardView { + const view: KeyboardView = {}; + for (const key of HE_LAYOUT) { + const stat = statsByCode[key.code]; + const conf = stat ? confidenceFor(stat) : 0; + const isNext = key.code === nextCode; + let hidden = false, dim = false; + if (!isNext) { + if (guidance === 'hidden') hidden = true; + else if (guidance === 'dim') dim = true; + else if (guidance === 'auto') hidden = conf >= GATE.minConfidence; + } + view[key.componentId] = { heat: conf, finger: key.finger, isNextTarget: isNext, hidden, dim }; + } + return view; +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): keyboardView builder (heatmap + guidance modes)"` + +--- + +### Task 18: Placement test (seeding) + +**Files:** +- Create: `src/trainer/PlacementTest.tsx` +- Create: `src/trainer/placement.ts` +- Test: `src/trainer/placement.test.ts` + +**Interfaces:** +- Consumes: `SessionResult`, `STAGES`, `Progress`. +- Produces (pure): `seedFromPlacement(result: SessionResult): { unlockedStageIndex: number; currentStageIndex: number }` — maps measured WPM/accuracy to a starting stage (fast+accurate → later stage; slow/inaccurate → stage 0). `PlacementTest` component runs one short session and calls `onDone(seed)`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { seedFromPlacement } from './placement'; +import type { SessionResult } from './types'; + +const r = (wpm: number, accuracy: number): SessionResult => + ({ wpm, accuracy, wholeWordAccuracy: accuracy, durationMs: 60000, typedChars: 100, perKey: {} }); + +describe('seedFromPlacement', () => { + it('beginners start at stage 0', () => { + expect(seedFromPlacement(r(8, 0.7)).currentStageIndex).toBe(0); + }); + it('fast accurate typists skip ahead', () => { + const seed = seedFromPlacement(r(60, 0.98)); + expect(seed.currentStageIndex).toBeGreaterThan(0); + expect(seed.unlockedStageIndex).toBe(seed.currentStageIndex); + }); + it('never seeds beyond the last stage', () => { + const seed = seedFromPlacement(r(200, 1)); + expect(seed.currentStageIndex).toBeLessThanOrEqual(9); // STAGES has 10 entries (0..9) + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement `placement.ts`** + +```ts +// src/trainer/placement.ts +import type { SessionResult } from './types'; +import { STAGES } from './curriculum'; + +export function seedFromPlacement(result: SessionResult): { unlockedStageIndex: number; currentStageIndex: number } { + const maxIndex = STAGES.length - 1; + // Accuracy gate first: only skip ahead when accurate enough to have real technique. + let index = 0; + if (result.accuracy >= 0.95) { + // ~ every 12 wpm of measured speed unlocks one more stage, capped. + index = Math.min(maxIndex, Math.floor(result.wpm / 12)); + } + return { unlockedStageIndex: index, currentStageIndex: index }; +} +``` + +- [ ] **Step 4: Implement `PlacementTest.tsx`** (uses the session hook against a fixed mixed drill) + +```tsx +// src/trainer/PlacementTest.tsx +import React from 'react'; +import type { KeySource, SessionLog } from './types'; +import { useTypingSession } from './useTypingSession'; +import { PracticePanel } from './PracticePanel'; +import { computeSessionResult } from './scoring'; +import { seedFromPlacement } from './placement'; + +const PLACEMENT_TEXT = 'שלום עולם זה מבחן קצר של מהירות הקלדה'; + +export const PlacementTest: React.FC<{ + source: KeySource; + onDone: (seed: { unlockedStageIndex: number; currentStageIndex: number }) => void; +}> = ({ source, onDone }) => { + const handleComplete = (log: SessionLog) => onDone(seedFromPlacement(computeSessionResult(log))); + const s = useTypingSession({ source, target: PLACEMENT_TEXT, strictness: 'markThrough', onComplete: handleComplete }); + return ( +
+

Placement — type this once

+ +
+ ); +}; +``` + +- [ ] **Step 5: Run, expect PASS.** `npx tsc --noEmit` passes. +- [ ] **Step 6: Commit** — `git commit -am "feat(trainer): placement test and stage seeding"` + +--- + +### Task 19: Settings + persistence wiring (`useTrainerState`) + +**Files:** +- Create: `src/trainer/useTrainerState.ts` +- Test: `src/trainer/useTrainerState.test.ts` + +**Interfaces:** +- Consumes: `TrainerStore`, `Settings`, `Progress`, `KeyStat`, `DEFAULT_SETTINGS`, `STORAGE_KEYS`. +- Produces (pure helpers, hook wraps them): `loadSettings(store)`, `saveSettings(store, s)`, `loadProgress(store)`, `saveProgress(store, p)`, `loadStats(store)`, `mergeSessionStats(prev, result): Record` (folds a `SessionResult.perKey` into cumulative `KeyStat`s, keeping a bounded latency window of 20), `saveStats(store, stats)`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { createMemoryStore } from './storage'; +import { loadSettings, saveSettings, mergeSessionStats, loadStats, saveStats } from './useTrainerState'; +import type { SessionResult, KeyStat } from './types'; + +describe('trainer state', () => { + it('persists settings with defaults', () => { + const store = createMemoryStore(); + expect(loadSettings(store).guidanceMode).toBe('full'); + saveSettings(store, { ...loadSettings(store), guidanceMode: 'auto' }); + expect(loadSettings(store).guidanceMode).toBe('auto'); + }); + + it('merges a session into cumulative per-key stats', () => { + const prev: Record = { KeyF: { code: 'KeyF', attempts: 5, errors: 1, latencies: [200] } }; + const result = { perKey: { KeyF: { attempts: 3, errors: 1, medianLatency: 240 } } } as unknown as SessionResult; + const merged = mergeSessionStats(prev, result); + expect(merged.KeyF.attempts).toBe(8); + expect(merged.KeyF.errors).toBe(2); + expect(merged.KeyF.latencies).toContain(240); + }); + + it('round-trips stats through the store', () => { + const store = createMemoryStore(); + const stats = { KeyF: { code: 'KeyF', attempts: 1, errors: 0, latencies: [200] } }; + saveStats(store, stats); + expect(loadStats(store).KeyF.attempts).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// src/trainer/useTrainerState.ts +import type { TrainerStore } from './storage'; +import { STORAGE_KEYS } from './storage'; +import type { Settings, Progress, KeyStat, SessionResult, KeyCode } from './types'; +import { DEFAULT_SETTINGS } from './types'; + +const LATENCY_WINDOW = 20; + +export function loadSettings(store: TrainerStore): Settings { + return store.get(STORAGE_KEYS.settings, DEFAULT_SETTINGS); +} +export function saveSettings(store: TrainerStore, s: Settings): void { + store.set(STORAGE_KEYS.settings, s); +} + +export function loadProgress(store: TrainerStore): Progress { + return store.get(STORAGE_KEYS.progress, { unlockedStageIndex: 0, currentStageIndex: 0, bestByStage: {} }); +} +export function saveProgress(store: TrainerStore, p: Progress): void { + store.set(STORAGE_KEYS.progress, p); +} + +export function loadStats(store: TrainerStore): Record { + return store.get>(STORAGE_KEYS.stats, {}); +} +export function saveStats(store: TrainerStore, stats: Record): void { + store.set(STORAGE_KEYS.stats, stats); +} + +export function mergeSessionStats( + prev: Record, + result: SessionResult, +): Record { + const next: Record = { ...prev }; + for (const [code, s] of Object.entries(result.perKey)) { + const cur = next[code] ?? { code, attempts: 0, errors: 0, latencies: [] }; + const latencies = [...cur.latencies, s.medianLatency].slice(-LATENCY_WINDOW); + next[code] = { code, attempts: cur.attempts + s.attempts, errors: cur.errors + s.errors, latencies }; + } + return next; +} +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): settings/progress/stats persistence helpers"` + +--- + +### Task 20: `SessionSummary` + +**Files:** +- Create: `src/trainer/SessionSummary.tsx` +- Test: `src/trainer/SessionSummary.test.tsx` + +**Interfaces:** +- Consumes: `SessionResult`. +- Produces: `SessionSummary({ result, onNext })` showing WPM, per-char accuracy, whole-word accuracy, and a Continue button. + +- [ ] **Step 1: Write the failing test** + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SessionSummary } from './SessionSummary'; +import type { SessionResult } from './types'; + +const result: SessionResult = { wpm: 42.4, accuracy: 0.965, wholeWordAccuracy: 0.9, durationMs: 60000, typedChars: 200, perKey: {} }; + +describe('SessionSummary', () => { + it('shows rounded stats and fires onNext', () => { + const onNext = vi.fn(); + render(); + expect(screen.getByText(/42/)).toBeTruthy(); + expect(screen.getByText(/97%|96%/)).toBeTruthy(); // accuracy rounded + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + expect(onNext).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```tsx +// src/trainer/SessionSummary.tsx +import React from 'react'; +import type { SessionResult } from './types'; + +const pct = (x: number) => `${Math.round(x * 100)}%`; + +export const SessionSummary: React.FC<{ result: SessionResult; onNext: () => void }> = ({ result, onNext }) => ( +
+
{Math.round(result.wpm)}WPM
+
{pct(result.accuracy)}Accuracy
+
{pct(result.wholeWordAccuracy)}Whole words
+ +
+); +``` + +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): session summary view"` + +--- + +### Task 21: Assemble the core session loop in `TrainerMode` + +**Files:** +- Modify: `src/trainer/TrainerMode.tsx` +- Test: `src/trainer/TrainerMode.loop.test.tsx` + +**Interfaces:** +- Consumes: everything above. Builds a `KeySource` from `settings.captureSource` (`dom` → `DomKeySource`, `tap` → `TauriTapKeySource`), selects practice text for the current stage, runs a session, on completion merges stats, checks `canAdvance`, persists, and shows `SessionSummary`. Placement runs first if `progress` is at defaults and no stats exist. + +- [ ] **Step 1: Write the failing test** (inject a fake store + fake source via props for testability) + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(async () => {}) })); +import { TrainerMode } from './TrainerMode'; +import { createMemoryStore } from './storage'; +import type { KeySource, KeyEvent } from './types'; + +function fakeSource() { + let cb: ((e: KeyEvent) => void) | null = null; + const src: KeySource = { start: f => { cb = f; }, stop: () => { cb = null; } }; + return { src, press: (code: string, ts: number) => act(() => cb!({ code, ts, down: true })) }; +} + +describe('TrainerMode loop', () => { + it('runs a session and shows a summary on completion', () => { + const store = createMemoryStore(); + // Pre-seed progress past placement so it goes straight to a session with known text. + store.set('trainer.progress', { unlockedStageIndex: 0, currentStageIndex: 0, bestByStage: {} }); + store.set('trainer.stats', { KeyF: { code: 'KeyF', attempts: 1, errors: 0, latencies: [200] } }); + const { src, press } = fakeSource(); + render( {}} store={store} makeSource={() => src} fixedTarget="כ" />); + press('KeyF', 100); // 'כ' == KeyF + expect(screen.getByText(/WPM/i)).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Rewrite `TrainerMode.tsx`** to assemble the loop. Add test-injection props (`store?`, `makeSource?`, `fixedTarget?`) that default to real implementations. + +```tsx +// src/trainer/TrainerMode.tsx +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { Keyboard } from '../components/Keyboard'; +import { PracticePanel } from './PracticePanel'; +import { SessionSummary } from './SessionSummary'; +import { PlacementTest } from './PlacementTest'; +import { useTypingSession } from './useTypingSession'; +import { buildKeyboardView } from './keyboardView'; +import { computeSessionResult } from './scoring'; +import { selectPractice } from './textSelection'; +import { COMMON_WORDS_HE } from './data/words.he'; +import { unlockedCodesForStage } from './curriculum'; +import { confidenceFor } from './engine/confidence'; +import { canAdvance } from './engine/gating'; +import { createLocalStorageStore, type TrainerStore } from './storage'; +import { + loadSettings, loadProgress, saveProgress, loadStats, saveStats, mergeSessionStats, +} from './useTrainerState'; +import { DomKeySource } from './keySource/DomKeySource'; +import { TauriTapKeySource } from './keySource/TauriTapKeySource'; +import type { KeySource, KeyCode, KeyStat, Settings, SessionLog, SessionResult } from './types'; +import './trainer.css'; + +interface Props { + onExit: () => void; + store?: TrainerStore; + makeSource?: (s: Settings) => KeySource; + fixedTarget?: string; // test hook; when set, skip selection randomness +} + +export const TrainerMode: React.FC = ({ onExit, store: injStore, makeSource, fixedTarget }) => { + const store = useMemo(() => injStore ?? createLocalStorageStore(), [injStore]); + const settings = useMemo(() => loadSettings(store), [store]); + const [progress, setProgress] = useState(() => loadProgress(store)); + const [stats, setStats] = useState>(() => loadStats(store)); + const [phase, setPhase] = useState<'placement' | 'typing' | 'summary'>( + () => (Object.keys(loadStats(store)).length === 0 ? 'placement' : 'typing'), + ); + const [result, setResult] = useState(null); + + const source = useMemo( + () => (makeSource ? makeSource(settings) + : settings.captureSource === 'tap' ? new TauriTapKeySource() : new DomKeySource()), + [makeSource, settings], + ); + + useEffect(() => { + invoke('set_trainer_mode', { active: true }).catch(console.error); + return () => { invoke('set_trainer_mode', { active: false }).catch(console.error); }; + }, []); + + const target = useMemo(() => { + if (fixedTarget != null) return fixedTarget; + const unlocked = unlockedCodesForStage(progress.currentStageIndex); + const confByCode: Record = {}; + for (const [code, st] of Object.entries(stats)) confByCode[code] = confidenceFor(st); + return selectPractice({ unlocked, confByCode, corpus: COMMON_WORDS_HE, targetChars: 40, rng: Math.random }); + }, [progress.currentStageIndex, stats, fixedTarget, phase]); + + const finishSession = (log: SessionLog) => { + const r = computeSessionResult(log); + const merged = mergeSessionStats(stats, r); + setStats(merged); saveStats(store, merged); + if (canAdvance(progress.currentStageIndex, merged)) { + const next = { ...progress, currentStageIndex: progress.currentStageIndex + 1, unlockedStageIndex: progress.currentStageIndex + 1 }; + setProgress(next); saveProgress(store, next); + } + setResult(r); setPhase('summary'); + }; + + if (phase === 'placement') { + return ( +
+
+ { + const next = { ...progress, ...seed }; setProgress(next); saveProgress(store, next); setPhase('typing'); + }} /> +
+ ); + } + + return ( +
+
+ {phase === 'summary' && result ? ( + <> + { setResult(null); setPhase('typing'); }} /> + + + ) : ( + + )} +
+ ); +}; + +// Inner component so the session hook can drive the keyboard view. +const TypingSession: React.FC<{ + source: KeySource; target: string; strictness: Settings['strictness']; + stats: Record; guidance: Settings['guidanceMode']; + onComplete: (log: SessionLog) => void; +}> = ({ source, target, strictness, stats, guidance, onComplete }) => { + const s = useTypingSession({ source, target, strictness, onComplete }); + const view = buildKeyboardView({ nextCode: s.nextCode, statsByCode: stats, guidance }); + return ( + <> + + + + ); +}; +``` + +> Exactly one `` renders per phase: the inner `TypingSession` renders it with the live `trainerView` during typing; the summary branch renders a plain ``. The placement branch (earlier return) renders none. Do not add a second keyboard. + +- [ ] **Step 4: Run, expect PASS.** `npx tsc --noEmit` passes. Manual: `npm run tauri:dev`, enter Trainer, type the shown Hebrew — keys highlight, wrong keys block, summary appears. Confirm only one keyboard is visible at a time. +- [ ] **Step 5: Commit** — `git commit -am "feat(trainer): assemble core session loop (select→type→score→advance)"` + +--- + +# Phase 3 — Real text + feedback + +### Task 22: Prose/quotes corpus + sentence selection + +**Files:** +- Create: `src/trainer/data/prose.he.ts` (data, theme-tagged) +- Modify: `src/trainer/textSelection.ts` (add `selectSentence`) +- Test: `src/trainer/proseSelection.test.ts` + +**Interfaces:** +- Produces: `PROSE_HE: Array<{ text: string; theme: string; source: string }>`; `selectSentence(opts: { unlocked: Set; corpus: typeof PROSE_HE; rng: () => number }): { text: string; theme: string } | null` — returns a typable passage (all letters unlocked) or null when none fits. + +- [ ] **Step 1: Create `prose.he.ts`** (seed; public-domain — expand during curation) + +```ts +// src/trainer/data/prose.he.ts +// Public-domain Hebrew passages/proverbs. Verify licensing during content curation. +export const PROSE_HE: Array<{ text: string; theme: string; source: string }> = [ + { text: 'איזהו חכם הלומד מכל אדם', theme: 'wisdom', source: 'Pirkei Avot' }, + { text: 'ואהבת לרעך כמוך', theme: 'wisdom', source: 'Leviticus' }, + { text: 'הים הגדול נשקף מן החלון', theme: 'sea', source: 'seed' }, + { text: 'הכוכבים דלקו בשמי הלילה', theme: 'space', source: 'seed' }, +]; +``` + +- [ ] **Step 2: Write the failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { selectSentence } from './textSelection'; +import { PROSE_HE } from './data/prose.he'; +import { codeForLetter } from './data/hebrewLayout'; + +describe('selectSentence', () => { + it('returns null when no passage is fully typable', () => { + const unlocked = new Set(['KeyF', 'Space']); // only כ + expect(selectSentence({ unlocked, corpus: PROSE_HE, rng: () => 0 })).toBeNull(); + }); + it('returns a passage whose every letter is unlocked', () => { + const all = new Set(['Space']); + PROSE_HE.forEach(p => [...p.text].forEach(ch => { const c = codeForLetter(ch); if (c) all.add(c); })); + const picked = selectSentence({ unlocked: all, corpus: PROSE_HE, rng: () => 0 })!; + expect(picked).not.toBeNull(); + [...picked.text].forEach(ch => { if (ch !== ' ') expect(all.has(codeForLetter(ch)!)).toBe(true); }); + }); +}); +``` + +- [ ] **Step 3: Run, expect FAIL.** + +- [ ] **Step 4: Implement `selectSentence`** in `textSelection.ts` + +```ts +import type { KeyCode } from './types'; +// (reuse existing wordIsTypable) + +export function selectSentence(opts: { + unlocked: Set; + corpus: Array<{ text: string; theme: string; source: string }>; + rng: () => number; +}): { text: string; theme: string } | null { + const typable = opts.corpus.filter(p => wordIsTypable(p.text.replace(/ /g, ''), opts.unlocked)); + if (typable.length === 0) return null; + const pick = typable[Math.floor(opts.rng() * typable.length)]; + return { text: pick.text, theme: pick.theme }; +} +``` + +- [ ] **Step 5: Run, expect PASS.** +- [ ] **Step 6: Wire into `TrainerMode`:** for stages at/after `SOFIT_STAGE_INDEX` (or when the user selects a "Prose" mode), prefer `selectSentence`; fall back to `selectPractice` when it returns null. Add a small mode toggle (Drills / Words / Prose) in the trainer topbar that chooses the source. Re-run the loop test. +- [ ] **Step 7: Commit** — `git commit -am "feat(trainer): Hebrew prose/quotes corpus and sentence selection"` + +--- + +### Task 23: Custom text paste + +**Files:** +- Create: `src/trainer/CustomText.tsx` +- Test: `src/trainer/CustomText.test.tsx` + +**Interfaces:** +- Produces: `CustomText({ onUse })` — a textarea + "Practice this" button that sanitizes input (keep Hebrew letters + spaces, drop niqqud and non-typable chars) and calls `onUse(sanitized)`. Exports pure `sanitizeHebrew(input: string): string`. + +- [ ] **Step 1: Write the failing test** + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CustomText, sanitizeHebrew } from './CustomText'; + +describe('CustomText', () => { + it('sanitizes to typable Hebrew letters + spaces', () => { + expect(sanitizeHebrew('שָׁלוֹם, world! 123')).toBe('שלום'); + }); + it('passes sanitized text to onUse', () => { + const onUse = vi.fn(); + render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'שלום עולם' } }); + fireEvent.click(screen.getByRole('button', { name: /practice/i })); + expect(onUse).toHaveBeenCalledWith('שלום עולם'); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** + +```tsx +// src/trainer/CustomText.tsx +import React, { useState } from 'react'; +import { byLetter } from './data/hebrewLayout'; + +export function sanitizeHebrew(input: string): string { + // Keep only letters present in the layout and single spaces; drop niqqud/punctuation. + const kept = [...input].filter(ch => ch === ' ' || byLetter[ch] != null).join(''); + return kept.replace(/\s+/g, ' ').trim(); +} + +export const CustomText: React.FC<{ onUse: (text: string) => void }> = ({ onUse }) => { + const [value, setValue] = useState(''); + return ( +
+