This document describes the architecture and implementation plan for a browser-based typing trainer integrated into an existing Astro site. It is written for senior-level engineers and is intended to be used by Codex CLI as a concise but complete technical reference.
The initial scope (Phase 1) is:
/tool section (global.css, layout, spacing).Later phases build on this foundation (additional lesson types, analytics, AI-assisted coaching), but are out of scope for this version of the spec except where they influence current design decisions.
global.css and shared /tool section styles
useReducer, useState, useEffect)Key design principle: strict separation of domain logic from UI. The “typing engine” must be a pure TypeScript module with no React or DOM dependencies.
New Astro page: e.g. src/pages/tool/typing-trainer.astro.
Page uses the same layout component as other /tool pages, for example:
---
import Layout from "../../layouts/ToolLayout.astro";
import { TypingMentor } from "../../components/TypingMentor/TypingMentor";
---
<Layout title="Typing trainer">
<main class="tool-page">
<TypingMentor client:load />
</main>
</Layout>
The TypingMentor React component is responsible for:
Under src/components/TypingMentor:
TypingMentor.tsx – top-level React component for the tool page.Keyboard.tsx – visual keyboard composed of Key components.Key.tsx – individual key rendering.TextExercise.tsx – text to type with caret and error highlights.SessionStats.tsx – small stats panel (time, speed, errors).LessonSelector.tsx – simple selector for lesson(s), can be trivial in Phase 1.Under src/core/typing-mentor (domain logic, no React):
layout.ts – keyboard layouts and key definitions.lesson.ts – lesson and exercise templates.session.ts – state machine for a single typing session.stats.ts – derived metrics (speed, accuracy, etc.).types.ts – shared domain types.This separation should be strict: src/core/typing-mentor/* has no imports from React or component tree.
All types below live in src/core/typing-mentor/types.ts unless further split is justified.
Use KeyboardEvent.code as the primary identifier for keys (physical location), not event.key. This allows multiple logical layouts on the same physical keyboard.
export type LayoutId = "en-US" | "ru-RU"; // extensible, more layouts can be added later
export type KeyCode = string; // e.g. "KeyA", "Space", "Digit1", "ShiftLeft"
export interface KeyDefinition {
code: KeyCode; // corresponds to KeyboardEvent.code
base: string; // default printed symbol (e.g. "a", "ф", "1")
shift?: string; // symbol when Shift is held (e.g. "A", "Ф", "!")
isModifier?: boolean; // Shift, Ctrl, Alt, Meta, CapsLock, etc.
width?: number; // relative width (1 = base unit, 2, 2.25, etc.)
align?: "left" | "center" | "right";
}
export interface KeyboardRow {
keys: KeyDefinition[];
}
export interface KeyboardLayout {
id: LayoutId;
name: string; // "English (US)", "Russian"
rows: KeyboardRow[];
}
Concrete layouts live in layout.ts as constants, e.g. EN_US_LAYOUT, RU_RU_LAYOUT.
Phase 1 will use a simple “fixed text” lesson, but the model is extensible for future lesson types.
export type LessonKind = "fixedText" | "generatedSequence" | "wordList" | "externalText";
export type LessonId = string;
export interface Lesson {
id: LessonId;
title: string;
description?: string;
layoutId: LayoutId;
kind: LessonKind;
difficulty: number; // 1..10, purely indicative
config: LessonConfig;
}
export type LessonConfig =
| FixedTextLessonConfig
| GeneratedSequenceConfig
| WordListConfig
| ExternalTextConfig;
export interface FixedTextLessonConfig {
kind: "fixedText";
text: string;
}
export interface GeneratedSequenceConfig {
kind: "generatedSequence";
characters: string[]; // e.g. [ "ф", "ы", "в" ]
length: number; // length of generated text
}
export interface WordListConfig {
kind: "wordList";
words: string[]; // list of words for this lesson
totalCharacters: number;
}
export interface ExternalTextConfig {
kind: "externalText";
sourceId: string; // placeholder for future integration (user text, URL, etc.)
}
In Phase 1, only FixedTextLessonConfig needs to be implemented; other variants must be typed but can throw NotImplemented if used.
A session represents one run of a single lesson.
export interface Keystroke {
index: number; // index in text (0-based), or -1 for non-text keys
expected: string | null;
actual: string;
time: number; // timestamp (ms since epoch)
isError: boolean;
}
export type SessionStatus = "idle" | "running" | "finished";
export interface SessionState {
lessonId: LessonId;
layoutId: LayoutId;
text: string; // resolved exercise text
position: number; // current caret index (0..text.length)
errors: number; // total error count (see behaviour below)
startedAt: number | null;
finishedAt: number | null;
status: SessionStatus;
keystrokes: Keystroke[];
}
Session is updated exclusively via events, consumed by a pure reducer function.
export type SessionEvent =
| { type: "START" }
| { type: "KEY_PRESS"; char: string; code: KeyCode }
| { type: "BACKSPACE" }
| { type: "FINISH" }
| { type: "RESET"; lesson: Lesson; text?: string };
START – marks session as running; sets startedAt if not set.KEY_PRESS – main event for user input.BACKSPACE – optionally used to correct previous character (Phase 1: behaviour can be simplified).FINISH – marks end of exercise (finishedAt).RESET – reinitialises state for a given lesson.Defined in stats.ts:
export interface SessionStats {
durationMs: number;
charactersTyped: number;
grossSpeedCpm: number; // characters per minute (raw, incl. errors)
netSpeedCpm: number; // characters per minute minus penalties
wpm: number; // words per minute (5 chars per word convention)
accuracy: number; // 0..1
}
calculateStats(state: SessionState): SessionStats:
durationMs – if finishedAt exists, finishedAt - startedAt, otherwise now - startedAt.charactersTyped – number of keystrokes with expected !== null.grossSpeedCpm – charactersTyped / (durationMs / 60000).netSpeedCpm – max(0, (charactersTyped - errors) / (durationMs / 60000)).wpm – netSpeedCpm / 5.accuracy – charactersTyped === 0 ? 1 : (charactersTyped - errors) / charactersTyped.KEY_PRESS on a printable character (length 1 string) is compared against text[position]:
position++,isError = false,errors++,isError = true,position++ (Phase 1: do not force user to correct errors).Enter, Shift, etc.) are ignored by the engine and only used by UI for keyboard highlighting.BACKSPACE behaviour in Phase 1 can be:
position by 1 if position > 0,errors (simple implementation),position === text.length, engine may:
FINISH (via UI wrapper), orFINISH explicitly.Reducer signature (in session.ts):
export function createInitialSession(lesson: Lesson, resolvedText: string): SessionState { /* ... */ }
export function sessionReducer(state: SessionState, event: SessionEvent): SessionState { /* pure */ }
TypingMentorLocation: src/components/TypingMentor/TypingMentor.tsx
Responsibilities:
Lesson selection.SessionState via useReducer and sessionReducer.Keyboard, TextExercise, and SessionStats.Key points:
Root element must be focusable:
<section
className="typing-trainer tool-card"
tabIndex={0}
onFocus={handleFocus}
onBlur={handleBlur}
>
{/* children */}
</section>
Event handling:
keydown: if trainer is active, translate into SessionEvent and update pressedKeys set.keyup: update pressedKeys set only.isActive flag).KeyboardLocation: src/components/TypingMentor/Keyboard.tsx
Props:
interface KeyboardProps {
layout: KeyboardLayout;
pressedKeys: Set<KeyCode>;
isShiftPressed: boolean;
isCapsLockOn: boolean;
expectedChar?: string | null; // optional: highlight key corresponding to current target char
onKeyClick?: (code: KeyCode) => void;
}
Rendering:
KeyboardRow, render a flex row.KeyDefinition, render a Key component.KeyLocation: src/components/TypingMentor/Key.tsx
Props:
interface KeyProps {
definition: KeyDefinition;
isPressed: boolean;
isShiftPressed: boolean;
isCapsLockOn: boolean;
isTarget?: boolean; // highlight expected key for current character
onClick?: (code: KeyCode) => void;
}
Display logic:
definition.isModifier is true → show definition.base as label.isCapsLockOn XOR isShiftPressed → uppercase;isShiftPressed and definition.shift is defined → use shift;base.Styling requirements:
global.css typography and colour tokens (e.g. via CSS variables).kb-keykb-key--modifierkb-key--pressedkb-key--targetTextExerciseLocation: src/components/TypingMentor/TextExercise.tsx
Props:
interface TextExerciseProps {
text: string;
position: number;
keystrokes: Keystroke[];
}
Rendering:
text as a sequence of span elements.text-exercise__chartext-exercise__char--donetext-exercise__char--currenttext-exercise__char--error (if this position has an incorrect keystroke).position) should be visually distinguishable (caret/background).SessionStatsLocation: src/components/TypingMentor/SessionStats.tsx
Props:
interface SessionStatsProps {
stats: SessionStats;
status: SessionStatus;
}
Rendering:
/tool stats cards.LessonSelectorPhase 1 can be minimal: one or a few hardcoded lessons.
Props:
interface LessonSelectorProps {
lessons: Lesson[];
selectedLessonId: LessonId;
onSelectLesson: (id: LessonId) => void;
}
Rendering:
<select> or list of buttons in /tool style.KeyboardEvent.codeKeyDefinition is based on event.code.event.key is used only to obtain the character for typing (char field of KEY_PRESS event).isActive flag set in onFocus / cleared in onBlur.ShiftLeft / ShiftRight:
isShiftPressed = true on keydown,false on keyup (taking into account both keys if needed).CapsLock:
keydown using event.getModifierState("CapsLock") or internal toggle.sessionReducer as KEY_PRESS events.onMouseDown → onKeyClick(definition.code).onKeyClick may optionally dispatch a KEY_PRESS event (for demo) or only animate the key.Phase 1 requirement: at least visual press animation for mouse clicks; mapping to the session is optional but recommended.
Suggested layout:
src/pages/tool/typing-trainer.astrosrc/components/TypingMentor/TypingMentor.tsxsrc/components/TypingMentor/Keyboard.tsxsrc/components/TypingMentor/Key.tsxsrc/components/TypingMentor/TextExercise.tsxsrc/components/TypingMentor/SessionStats.tsxsrc/components/TypingMentor/LessonSelector.tsxsrc/core/typing-mentor/types.tssrc/core/typing-mentor/layout.tssrc/core/typing-mentor/lesson.tssrc/core/typing-mentor/session.tssrc/core/typing-mentor/stats.tsNames are indicative; they can be slightly adjusted to fit existing project conventions, but the separation between /core and /components must be preserved.
.tool-page, card containers, etc.).global.css for:
flex-grow: 0 and flex-basis proportional to width.No inline styles except where necessary for proportional key width; prefer CSS classes and custom properties when possible.
src/core/typing-mentor should be covered with unit tests:
createInitialSession,sessionReducer for typical and edge cases (correct char, wrong char, completion, reset),calculateStats.Even if the project does not yet use a test runner, code should be written in a testable way (pure functions, no hidden globals).
<button> elements to provide intrinsic accessibility semantics.aria-pressed is set for active keys and locked modifiers (e.g. CapsLock).The chosen architecture intentionally leaves room for:
Lesson/Session abstractions.Keystroke[]).SessionStats and aggregated progress history.No changes are needed to the core model to support these; they can be layered on top in subsequent phases.
/tool/typing-trainer, стили и позиционирование в списках инструментов.