From 39d6cc4389c88dafa363e5f57aa3c72a09608d96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C4=ABlav=C4=81pi=20Cheesley?=
Date: Mon, 21 Sep 2026 00:43:10 +0100
Subject: [PATCH] Show progress: the ladder, the statistics, and export and
import
The drill could score a lesson but had nowhere to put the result, so stars
were computed, shown once and thrown away, and a reload started the learner
from nothing. This closes that loop and builds the three views around the
drill.
The ladder lists every lesson the layout produced, says which are unlocked and
how many stars each has earned, and has a button on each unlocked rung so an
earlier lesson can be returned to. Unlocking is one-way, so the ladder stays
open behind a learner. A locked rung says what unlocks it and names the lesson,
rather than only looking dimmer: stars are stated in words with the glyph row
marked aria-hidden beside them.
The statistics group weak keys by finger and by row, which is the thing this
project exists to do. A list of missed characters is trivia; the same numbers
gathered under the finger that presses them and the row it reaches for say
"your left ring finger cannot find the upper row", and that is what a lesson
can be built from. The grouping is a pure module, src/stats/keystats.ts, with
no DOM, so the diagnosis is unit-tested without a browser; every level carries
its own numbers, its own wording and a severity band named in words, with the
tint and the little miss-rate bars as reinforcement only. Fingers that need
work lead; the ones that are behaving sit in a disclosure. A key whose
statistics the current layout does not bind is reported separately rather than
dropped, so the totals are never quietly short.
Progress is now actually persisted. Storage is probed rather than assumed,
because localStorage throws in a private window, and when it is unavailable
the page says plainly that progress will not outlive the tab. Export writes a
dated JSON file, and import merges it back, taking the better of the file and
this browser for every field so importing your own export can never cost you
this morning's practice. A partly unreadable import reports what it dropped,
item by item, instead of failing silently or refusing the whole file; only a
file that is not JSON at all is refused, and then the message says so.
Clearing saved progress takes two deliberate goes.
The export is handed over as an object URL, not a relative href. The site
deploys to GitHub Pages from /touchwright/, and a relative href resolves
against the page's own path, so an export that worked at the root would have
pointed at the wrong place one directory down. There is an end-to-end test
that pushes a deeper path and exports from there.
Regression 1 is kept, not undone: every load goes through ProgressStore, which
thaws and validates, and the thawed copy is handed to applyLoadedProgress,
which merges field by field. Nothing from a file or from storage becomes live
state by reference. Clearing empties the session's own Progress object in
place rather than replacing it.
Two end-to-end fixmes are filled in: "keeps progress across a reload" and
"exports progress, clears storage, and imports it back". The other four are
left alone.
nextLessonName is deliberately still null on the drill view. Issue #3 has not
landed, so the result card would otherwise offer "Start " on a
button that could only restart the same sample text. The lesson is chosen on
the ladder instead, and showDrill already has the lesson's id, name and key
set in hand, so wiring generated text in is one line.
Co-Authored-By: Claude Opus 5
---
index.html | 74 ++
src/stats/keystats.ts | 552 ++++++++++++++
src/ui/drill-view.ts | 80 +-
src/ui/load-form.ts | 141 +++-
src/ui/progress-view.ts | 714 ++++++++++++++++++
src/ui/theme.css | 289 +++++++
tests/e2e/pending.spec.ts | 69 +-
tests/e2e/progress.spec.ts | 352 +++++++++
tests/functional/progress-view-wiring.test.ts | 288 +++++++
tests/functional/progress-view.test.ts | 613 +++++++++++++++
tests/unit/keystats.test.ts | 267 +++++++
11 files changed, 3427 insertions(+), 12 deletions(-)
create mode 100644 src/stats/keystats.ts
create mode 100644 src/ui/progress-view.ts
create mode 100644 tests/e2e/progress.spec.ts
create mode 100644 tests/functional/progress-view-wiring.test.ts
create mode 100644 tests/functional/progress-view.test.ts
create mode 100644 tests/unit/keystats.test.ts
diff --git a/index.html b/index.html
index f272359..ee3d788 100644
--- a/index.html
+++ b/index.html
@@ -111,6 +111,80 @@ How that went
+
+
+
+ The ladder
+
+ Built from your own layout. Two stars on a lesson unlocks the next one, and every lesson
+ you have reached stays open, so you can always drop back to an earlier one.
+
+
+
+
+
+
+
+ Where your fingers slip
+
+
+ By finger and row
+
+ Worst first. Each finger is broken down by the row it was reaching for.
+
+
+
+
+
+
+
+ Keep your progress
+
+
+
+ Export progress
+
+
+ Clear saved progress
+
+
+
+
+ Import a progress file (JSON)
+
+
+
+
+
+
+
diff --git a/src/stats/keystats.ts b/src/stats/keystats.ts
new file mode 100644
index 0000000..c9e147a
--- /dev/null
+++ b/src/stats/keystats.ts
@@ -0,0 +1,552 @@
+/**
+ * Weak keys, grouped by finger and by row.
+ *
+ * This is the point of the whole project. A per-character miss list tells a
+ * learner that they keep fluffing `p`, `,` and `q`; grouping the same numbers by
+ * the finger that presses them and the row it has to reach for tells them the
+ * useful thing, which is that their left ring finger cannot find the upper row.
+ * One is a list of characters, the other is a diagnosis, and the diagnosis is
+ * what a lesson can be built from.
+ *
+ * So the shape here is deliberately two levels deep, finger then row, and every
+ * level carries its own numbers, its own wording and its own severity band. The
+ * wording is the primary channel: colour reinforces a band, it never carries it.
+ * `describeKey` supplies the phrasing for an individual key so that this module
+ * and the drill readout never disagree about what to call a position.
+ *
+ * Pure by design, and free of the DOM, so the grouping can be tested without a
+ * browser. It knows about the board and the keymap because a finger and a row are
+ * facts about a position, not about a character: the same `p` is a different
+ * diagnosis on a different layout.
+ *
+ * Two boundary rules, both learned the hard way elsewhere in this codebase:
+ *
+ * Statistics are keyed by code point, never by the character, so every id comes
+ * back through `characterFromKeyStatId` rather than being trusted as a character.
+ * A malformed id throws rather than being skipped, because storage already drops
+ * unreadable ids with a warning and anything that reaches here is meant to be
+ * canonical.
+ *
+ * A key the current layout does not bind cannot be given a finger or a row, and
+ * that is a real state rather than an error: a learner who swaps layouts still
+ * owns those statistics. Those keys are reported separately, in `unplaced`, so
+ * the total is never quietly short.
+ */
+
+import {
+ describeKey,
+ indexKeys,
+ type BoardDefinition,
+ type Finger,
+ type Hand,
+ type KeyPosition,
+ type Row,
+} from '../board/types.js';
+import { characterFromKeyStatId, type KeyStat } from './storage.js';
+
+/**
+ * How a group or a key is doing, in three bands. Words, not a colour and not a
+ * number: the view prints the wording and may tint it, never the other way
+ * round.
+ */
+export type Severity = 'steady' | 'watch' | 'weak';
+
+export const SEVERITY_WORDS: Readonly> = {
+ steady: 'steady',
+ watch: 'worth watching',
+ weak: 'needs work',
+};
+
+export interface WeakKeyThresholds {
+ /**
+ * Below this many presses a miss rate is noise rather than evidence. Absent,
+ * zero and malformed are three different things: a key with no presses at all
+ * is reported as unmeasured, not as perfect.
+ */
+ readonly minAttempts: number;
+ /** At or above this miss rate a key or a group is worth watching. */
+ readonly watchMissRate: number;
+ /** At or above this miss rate it needs work. */
+ readonly weakMissRate: number;
+}
+
+/**
+ * Tuned to be useful rather than flattering. One miss in twenty is worth
+ * mentioning; one in seven is the thing to practise next. Four presses is the
+ * smallest sample this will draw a conclusion from.
+ */
+export const WEAK_KEY_THRESHOLDS: WeakKeyThresholds = {
+ minAttempts: 4,
+ watchMissRate: 0.05,
+ weakMissRate: 0.15,
+};
+
+export class InvalidThresholdsError extends RangeError {
+ override readonly name = 'InvalidThresholdsError';
+ constructor(problem: string) {
+ super(`Weak-key thresholds are not usable: ${problem}`);
+ }
+}
+
+function checkThresholds(thresholds: WeakKeyThresholds): void {
+ const { minAttempts, watchMissRate, weakMissRate } = thresholds;
+ if (!Number.isSafeInteger(minAttempts) || minAttempts < 1) {
+ throw new InvalidThresholdsError(
+ `minAttempts must be a whole number of at least 1, got ${String(minAttempts)}`,
+ );
+ }
+ for (const [name, rate] of [
+ ['watchMissRate', watchMissRate],
+ ['weakMissRate', weakMissRate],
+ ] as const) {
+ if (!Number.isFinite(rate) || rate < 0 || rate > 1) {
+ throw new InvalidThresholdsError(
+ `${name} must be a fraction from 0 to 1, got ${String(rate)}`,
+ );
+ }
+ }
+ if (weakMissRate < watchMissRate) {
+ throw new InvalidThresholdsError('weakMissRate must not be lower than watchMissRate');
+ }
+}
+
+/** One key, with the statistics behind it and the words for where it lives. */
+export interface WeakKey {
+ /** The code-point id the statistics are stored under. */
+ readonly keyId: string;
+ readonly character: string;
+ /** The character named so it can be read aloud: `space`, not a blank. */
+ readonly label: string;
+ readonly position: number;
+ /** Hand, finger, reach and row, from `describeKey`. */
+ readonly description: string;
+ /**
+ * The part of the position its finger-and-row group does not already say: a
+ * reach out of the finger's own column, or which arc of the thumb cluster.
+ * Null when the group heading has said everything there is to say.
+ */
+ readonly qualifier: string | null;
+ readonly hits: number;
+ readonly misses: number;
+ /** Hits plus misses. Zero is a real state: a key that has never been pressed. */
+ readonly attempts: number;
+ /** Misses over attempts, 0 to 1. Zero when there are no attempts. */
+ readonly missRate: number;
+ /** Mean milliseconds per correct press, or null when nothing was sampled. */
+ readonly meanMs: number | null;
+ /** False when there are too few presses to draw a conclusion from. */
+ readonly measured: boolean;
+ readonly severity: Severity;
+ /** One sentence, numbers and all, for a view to print unchanged. */
+ readonly summary: string;
+}
+
+/**
+ * One row's worth of one finger's keys. This is the unit the project is about:
+ * not "you miss `p`" but "your left ring finger cannot find the upper row".
+ */
+export interface RowGroup {
+ readonly hand: Hand;
+ readonly finger: Finger;
+ readonly row: Row;
+ /** `home row`, or `thumb cluster` for the row that has no row. */
+ readonly rowLabel: string;
+ /** The whole diagnosis in words: `left ring finger, upper row`. */
+ readonly label: string;
+ readonly keys: readonly WeakKey[];
+ readonly hits: number;
+ readonly misses: number;
+ readonly attempts: number;
+ readonly missRate: number;
+ readonly severity: Severity;
+ /** The numbers alone, for printing under a heading that already names the row. */
+ readonly counts: string;
+ /** Row-scoped, for printing inside a finger's panel. */
+ readonly summary: string;
+ /** Finger and row both named, for a list that stands on its own. */
+ readonly standaloneSummary: string;
+}
+
+/** One finger of one hand, and every row it reaches for. */
+export interface FingerGroup {
+ readonly hand: Hand;
+ readonly finger: Finger;
+ /** `left ring finger`, or `left thumb`, which has no row to qualify it. */
+ readonly label: string;
+ readonly rows: readonly RowGroup[];
+ readonly hits: number;
+ readonly misses: number;
+ readonly attempts: number;
+ readonly missRate: number;
+ readonly severity: Severity;
+ /** The numbers alone, for printing under a heading that already names it. */
+ readonly counts: string;
+ readonly summary: string;
+}
+
+/** Statistics for a key this layout does not bind. Reported, never dropped. */
+export interface UnplacedKey {
+ readonly keyId: string;
+ readonly character: string;
+ readonly label: string;
+ readonly hits: number;
+ readonly misses: number;
+ readonly attempts: number;
+}
+
+export interface KeyStatsReport {
+ readonly totalHits: number;
+ readonly totalMisses: number;
+ readonly totalAttempts: number;
+ /** Correct presses over all presses, 0 to 1. Zero when nothing was typed. */
+ readonly accuracy: number;
+ /** Keys with at least one press. */
+ readonly keysPressed: number;
+ /** Keys with enough presses to draw a conclusion from. */
+ readonly keysMeasured: number;
+ /** Keys pressed enough times, and never missed. */
+ readonly keysClean: number;
+ /**
+ * Every finger that has pressed anything, worst first, each with its rows.
+ * Fingers that are behaving are still here: a learner comparing one finger
+ * against another needs the whole hand, not only the failures.
+ */
+ readonly fingers: readonly FingerGroup[];
+ /** The rows that need work, worst first, across every finger. */
+ readonly weakest: readonly RowGroup[];
+ readonly unplaced: readonly UnplacedKey[];
+ /** One sentence for the top of the view. Never empty. */
+ readonly summary: string;
+}
+
+export interface KeyStatsInput {
+ readonly keyStats: Readonly>;
+ /** Only the character-to-position map is needed, so a test can pass a stub. */
+ readonly keymap: { readonly charToPosition: ReadonlyMap };
+ readonly board: BoardDefinition;
+ readonly thresholds?: WeakKeyThresholds;
+}
+
+/** The character named so that it can be spoken: a blank reads as nothing. */
+export function nameKeyCharacter(character: string): string {
+ if (character === ' ') return 'space';
+ if (character === '\n') return 'enter';
+ if (character === '\t') return 'tab';
+ return `“${character}”`;
+}
+
+/** How a row reads in a sentence. The thumb cluster has no row, so it is named. */
+export function rowLabel(row: Row): string {
+ return row === 'thumb' ? 'thumb cluster' : `${row} row`;
+}
+
+/**
+ * What a key's group heading does not already say. A finger-and-row heading
+ * covers hand, finger and row, so all that is left is a sideways reach out of the
+ * finger's own column, or which arc of the thumb cluster a thumb key is on.
+ */
+export function qualifierFor(key: KeyPosition): string | null {
+ if (key.kind === 'thumb') return `${key.arc} arc`;
+ return key.reach === 'natural' ? null : `${key.reach} reach`;
+}
+
+/** `left ring finger`. A thumb is a thumb; it is not a "thumb finger". */
+export function fingerLabel(hand: Hand, finger: Finger): string {
+ return finger === 'thumb' ? `${hand} thumb` : `${hand} ${finger} finger`;
+}
+
+function percent(fraction: number): number {
+ return Math.round(fraction * 100);
+}
+
+function bandFor(
+ attempts: number,
+ missRate: number,
+ thresholds: WeakKeyThresholds,
+): { severity: Severity; measured: boolean } {
+ if (attempts < thresholds.minAttempts) return { severity: 'steady', measured: false };
+ if (missRate >= thresholds.weakMissRate) return { severity: 'weak', measured: true };
+ if (missRate >= thresholds.watchMissRate) return { severity: 'watch', measured: true };
+ return { severity: 'steady', measured: true };
+}
+
+/** `3 of 21 presses missed, 14%`, or the honest version when there are none. */
+function countSentence(misses: number, attempts: number): string {
+ if (attempts === 0) return 'not pressed yet';
+ if (misses === 0) {
+ return attempts === 1 ? '1 press, none missed' : `${attempts} presses, none missed`;
+ }
+ return `${misses} of ${attempts} presses missed, ${percent(misses / attempts)}%`;
+}
+
+/** Ordering: worst miss rate first, then whichever was pressed more. */
+function byNeed(
+ left: { missRate: number; attempts: number; misses: number },
+ right: { missRate: number; attempts: number; misses: number },
+): number {
+ if (right.missRate !== left.missRate) return right.missRate - left.missRate;
+ if (right.misses !== left.misses) return right.misses - left.misses;
+ return right.attempts - left.attempts;
+}
+
+interface Totals {
+ hits: number;
+ misses: number;
+ totalMs: number;
+ samples: number;
+}
+
+function addTo(totals: Totals, stat: KeyStat): void {
+ totals.hits += stat.hits;
+ totals.misses += stat.misses;
+ totals.totalMs += stat.totalMs;
+ totals.samples += stat.samples;
+}
+
+function emptyTotals(): Totals {
+ return { hits: 0, misses: 0, totalMs: 0, samples: 0 };
+}
+
+/** Row order as the hand meets them, so a group reads top to bottom. */
+const ROW_ORDER: readonly Row[] = [
+ 'function',
+ 'number',
+ 'upper',
+ 'home',
+ 'lower',
+ 'bottom',
+ 'thumb',
+];
+
+function rowRank(row: Row): number {
+ const rank = ROW_ORDER.indexOf(row);
+ // Every Row is in ROW_ORDER; a new one added to the board types without being
+ // added here would sort to the end rather than vanish.
+ return rank === -1 ? ROW_ORDER.length : rank;
+}
+
+/** Fingers strongest first, so both hands read in the same direction. */
+const FINGER_ORDER: readonly Finger[] = ['thumb', 'index', 'middle', 'ring', 'pinky'];
+
+function fingerRank(finger: Finger): number {
+ const rank = FINGER_ORDER.indexOf(finger);
+ return rank === -1 ? FINGER_ORDER.length : rank;
+}
+
+interface Bucket {
+ readonly hand: Hand;
+ readonly finger: Finger;
+ readonly row: Row;
+ readonly keys: WeakKey[];
+ readonly totals: Totals;
+}
+
+/**
+ * Group per-key statistics by finger and row, and word every level of it.
+ *
+ * The report is complete rather than filtered: every finger that has pressed
+ * anything appears, with a band of its own, because "your right hand is fine" is
+ * as useful a thing to read as "your left ring finger is not". `weakest` is the
+ * filtered view, for a UI that wants to lead with the diagnosis.
+ */
+export function summariseKeyStats(input: KeyStatsInput): KeyStatsReport {
+ const thresholds = input.thresholds ?? WEAK_KEY_THRESHOLDS;
+ checkThresholds(thresholds);
+
+ const boardKeys = indexKeys(input.board);
+ const buckets = new Map();
+ const unplaced: UnplacedKey[] = [];
+ const overall = emptyTotals();
+
+ let keysPressed = 0;
+ let keysMeasured = 0;
+ let keysClean = 0;
+
+ for (const [keyId, stat] of Object.entries(input.keyStats)) {
+ // Throws on a malformed id rather than skipping it: storage already drops
+ // unreadable ids with a warning, so anything here is meant to be canonical
+ // and a silent skip would hide a corrupted store.
+ const character = characterFromKeyStatId(keyId);
+ const attempts = stat.hits + stat.misses;
+
+ addTo(overall, stat);
+ if (attempts > 0) keysPressed += 1;
+
+ const position = input.keymap.charToPosition.get(character);
+ const key: KeyPosition | undefined =
+ position === undefined ? undefined : boardKeys.get(position);
+ if (position === undefined || key === undefined) {
+ // A key this layout does not bind has no finger and no row. Reported
+ // rather than dropped, so the totals are never quietly short.
+ unplaced.push({
+ keyId,
+ character,
+ label: nameKeyCharacter(character),
+ hits: stat.hits,
+ misses: stat.misses,
+ attempts,
+ });
+ continue;
+ }
+
+ const missRate = attempts === 0 ? 0 : stat.misses / attempts;
+ const { severity, measured } = bandFor(attempts, missRate, thresholds);
+ if (measured) {
+ keysMeasured += 1;
+ if (stat.misses === 0) keysClean += 1;
+ }
+
+ const bucketId = `${key.hand}/${key.finger}/${key.row}`;
+ let bucket = buckets.get(bucketId);
+ if (bucket === undefined) {
+ bucket = {
+ hand: key.hand,
+ finger: key.finger,
+ row: key.row,
+ keys: [],
+ totals: emptyTotals(),
+ };
+ buckets.set(bucketId, bucket);
+ }
+ addTo(bucket.totals, stat);
+
+ bucket.keys.push({
+ keyId,
+ character,
+ label: nameKeyCharacter(character),
+ position,
+ description: describeKey(key),
+ qualifier: qualifierFor(key),
+ hits: stat.hits,
+ misses: stat.misses,
+ attempts,
+ missRate,
+ meanMs: stat.samples === 0 ? null : Math.round(stat.totalMs / stat.samples),
+ measured,
+ severity,
+ summary: `${nameKeyCharacter(character)}, ${countSentence(stat.misses, attempts)}`,
+ });
+ }
+
+ const fingers = collectFingers(buckets, thresholds);
+ const weakest = fingers
+ .flatMap((group) => group.rows)
+ .filter((row) => row.severity === 'weak' || row.severity === 'watch')
+ .sort(byNeed);
+
+ const totalAttempts = overall.hits + overall.misses;
+
+ return {
+ totalHits: overall.hits,
+ totalMisses: overall.misses,
+ totalAttempts,
+ accuracy: totalAttempts === 0 ? 0 : overall.hits / totalAttempts,
+ keysPressed,
+ keysMeasured,
+ keysClean,
+ fingers,
+ weakest,
+ unplaced,
+ summary: reportSummary(totalAttempts, overall.misses, weakest, thresholds),
+ };
+}
+
+function collectFingers(
+ buckets: ReadonlyMap,
+ thresholds: WeakKeyThresholds,
+): readonly FingerGroup[] {
+ const byFinger = new Map();
+ for (const bucket of buckets.values()) {
+ const id = `${bucket.hand}/${bucket.finger}`;
+ const existing = byFinger.get(id);
+ if (existing === undefined) {
+ byFinger.set(id, { hand: bucket.hand, finger: bucket.finger, buckets: [bucket] });
+ } else {
+ existing.buckets.push(bucket);
+ }
+ }
+
+ const groups: FingerGroup[] = [];
+ for (const { hand, finger, buckets: owned } of byFinger.values()) {
+ const ownerLabel = fingerLabel(hand, finger);
+ const rows: RowGroup[] = owned
+ .map((bucket): RowGroup => {
+ const attempts = bucket.totals.hits + bucket.totals.misses;
+ const missRate = attempts === 0 ? 0 : bucket.totals.misses / attempts;
+ const { severity } = bandFor(attempts, missRate, thresholds);
+ const counts = countSentence(bucket.totals.misses, attempts);
+ const name = rowLabel(bucket.row);
+ return {
+ hand,
+ finger,
+ row: bucket.row,
+ rowLabel: name,
+ label: `${ownerLabel}, ${name}`,
+ // Worst key first inside the row, so the thing to practise reads first.
+ keys: [...bucket.keys].sort(byNeed),
+ hits: bucket.totals.hits,
+ misses: bucket.totals.misses,
+ attempts,
+ missRate,
+ severity,
+ counts,
+ summary: `${name}: ${counts}`,
+ standaloneSummary: `${ownerLabel}, ${name}: ${counts}`,
+ };
+ })
+ // Worst row first: the diagnosis leads, and the row order is the tie break
+ // so a finger with nothing wrong still reads top to bottom.
+ .sort((left, right) => {
+ const need = byNeed(left, right);
+ return need === 0 ? rowRank(left.row) - rowRank(right.row) : need;
+ });
+
+ const hits = rows.reduce((sum, row) => sum + row.hits, 0);
+ const misses = rows.reduce((sum, row) => sum + row.misses, 0);
+ const attempts = hits + misses;
+ const missRate = attempts === 0 ? 0 : misses / attempts;
+ const { severity } = bandFor(attempts, missRate, thresholds);
+
+ groups.push({
+ hand,
+ finger,
+ label: ownerLabel,
+ rows,
+ hits,
+ misses,
+ attempts,
+ missRate,
+ severity,
+ counts: countSentence(misses, attempts),
+ summary: `${ownerLabel}: ${countSentence(misses, attempts)}`,
+ });
+ }
+
+ return groups.sort((left, right) => {
+ const need = byNeed(left, right);
+ if (need !== 0) return need;
+ const byFingerRank = fingerRank(left.finger) - fingerRank(right.finger);
+ return byFingerRank === 0 ? left.hand.localeCompare(right.hand) : byFingerRank;
+ });
+}
+
+function reportSummary(
+ totalAttempts: number,
+ totalMisses: number,
+ weakest: readonly RowGroup[],
+ thresholds: WeakKeyThresholds,
+): string {
+ if (totalAttempts === 0) {
+ return 'Nothing recorded yet. Type a drill and this fills in.';
+ }
+ if (totalAttempts < thresholds.minAttempts) {
+ return `Only ${totalAttempts} ${totalAttempts === 1 ? 'press' : 'presses'} so far, which is too few to draw a conclusion from.`;
+ }
+
+ const accuracy = percent((totalAttempts - totalMisses) / totalAttempts);
+ const worst = weakest[0];
+ if (worst === undefined) {
+ return `${accuracy}% of ${totalAttempts} presses were right, and no finger and row needs work.`;
+ }
+ return `${accuracy}% of ${totalAttempts} presses were right. Your ${worst.label} is where most of the misses are.`;
+}
diff --git a/src/ui/drill-view.ts b/src/ui/drill-view.ts
index 14975ab..cad06be 100644
--- a/src/ui/drill-view.ts
+++ b/src/ui/drill-view.ts
@@ -41,7 +41,7 @@ import {
type DrillSession,
type Mark,
} from '../drill/index.js';
-import { scoreDrill, type DrillMode, type DrillScore, type StarCount } from '../drill/scoring.js';
+import { scoreDrill, type DrillMode, type DrillScore } from '../drill/scoring.js';
import type { Keymap } from '../keymap/types.js';
import { keyStatId } from '../stats/storage.js';
import { required } from './dom.js';
@@ -105,6 +105,12 @@ export interface DrillViewOptions {
readonly session?: DrillSession;
/** Only a lesson awards stars, which is scoring's rule, not this module's. */
readonly mode?: DrillMode;
+ /**
+ * The ladder id of the lesson this drill belongs to. Stars are written to
+ * progress under it, so a drill with no id earns experience but no stars: the
+ * ladder has nowhere to put them.
+ */
+ readonly lessonId?: string | null;
readonly lessonName?: string | null;
/** The lesson two stars unlocks. Null until the ladder exists. */
readonly nextLessonName?: string | null;
@@ -114,9 +120,26 @@ export interface DrillViewOptions {
* caller's business and this module never draws a keyboard.
*/
readonly onNextKey?: (next: NextKeyFacts | null) => void;
+ /**
+ * Called once a finished drill has been scored and written to progress, so the
+ * caller can persist it and refresh the views around the drill. It is never
+ * called for an unfinished drill.
+ */
+ readonly onScored?: (scored: ScoredDrill) => void;
readonly root?: ParentNode;
}
+/** What one finished drill did to progress. */
+export interface ScoredDrill {
+ readonly score: DrillScore;
+ readonly mode: DrillMode;
+ readonly lessonId: string | null;
+ /** True when this drill beat the stars already recorded for the lesson. */
+ readonly starsImproved: boolean;
+ /** Best stars for this lesson after the drill, or null when there is no lesson. */
+ readonly bestStars: number | null;
+}
+
export type CaptureRelease = 'escape' | 'focus-left' | 'teardown';
export interface DrillView {
@@ -217,7 +240,9 @@ export function nameCharacter(character: string): string {
return `“${character}”`;
}
-function starWords(stars: StarCount): string {
+/** A star count in words, which is the fact; any glyphs elsewhere are decoration. */
+function starWords(stars: number): string {
+ if (stars <= 0) return 'no stars of 3';
return stars === 1 ? '1 star of 3' : `${stars} stars of 3`;
}
@@ -270,6 +295,7 @@ export function createDrillView(options: DrillViewOptions): DrillView {
const boardKeys = indexKeys(options.board);
const mode: DrillMode = options.mode ?? 'lesson';
+ const lessonId = options.lessonId ?? null;
const lessonName = options.lessonName ?? null;
const nextLessonName = options.nextLessonName ?? null;
const limitMs = options.limitMs ?? NO_LIMIT;
@@ -410,6 +436,51 @@ export function createDrillView(options: DrillViewOptions): DrillView {
if (drill !== null && !drill.isFinished) announceWord(drill);
}
+ /**
+ * Write what the drill earned into live progress.
+ *
+ * The stars a learner earns are the whole point of the ladder, and until this
+ * existed they were computed, shown once, and thrown away. Experience is added
+ * for every mode, because a sprint and a repair drill are still practice; stars
+ * are only ever written for a lesson with an id, which is scoring's rule about
+ * modes and the ladder's rule about ids, not this module's.
+ *
+ * Best-of, never last-of: a bad run on a lesson already passed must not take a
+ * rung away. Nothing here catches anything, deliberately — progress arriving
+ * frozen from an external store has to throw loudly rather than silently drop
+ * the write, which is the whole of regression 1.
+ */
+ function recordScore(score: DrillScore): ScoredDrill {
+ const progress = session.progress;
+ let starsImproved = false;
+ let bestStars: number | null = null;
+
+ if (mode === 'lesson' && lessonId !== null) {
+ const previous = progress.stars[lessonId] ?? 0;
+ bestStars = Math.max(previous, score.stars);
+ starsImproved = bestStars > previous;
+ progress.stars[lessonId] = bestStars;
+ }
+
+ progress.xp += score.experienceGained;
+
+ const scored: ScoredDrill = {
+ score,
+ mode,
+ lessonId,
+ starsImproved,
+ bestStars,
+ };
+ options.onScored?.(scored);
+ return scored;
+ }
+
+ /** Only shown when a lesson's best differs from the run just finished. */
+ function bestStarsRow(scored: ScoredDrill): readonly [string, string][] {
+ if (scored.bestStars === null || scored.bestStars === scored.score.stars) return [];
+ return [['Best on this lesson', starWords(scored.bestStars)]];
+ }
+
function showResult(current: Drill, result: DrillResult): void {
const score: DrillScore = scoreDrill({
mode,
@@ -423,6 +494,10 @@ export function createDrillView(options: DrillViewOptions): DrillView {
nextLessonName,
});
+ // Written before anything is rendered, so the result card and the ladder
+ // beneath it are looking at the same numbers.
+ const recorded = recordScore(score);
+
const rows: readonly [string, string][] = [
['Speed', `${score.wordsPerMinute} words per minute`],
[
@@ -432,6 +507,7 @@ export function createDrillView(options: DrillViewOptions): DrillView {
['Stars', mode === 'lesson' ? starWords(score.stars) : 'none, this was not a lesson'],
['Experience', `${score.experienceGained} XP`],
['Ended', result.completed ? 'you typed it through' : 'the time limit ran out'],
+ ...bestStarsRow(recorded),
];
el.resultDetail.replaceChildren(
diff --git a/src/ui/load-form.ts b/src/ui/load-form.ts
index ceb2b38..eedcea6 100644
--- a/src/ui/load-form.ts
+++ b/src/ui/load-form.ts
@@ -11,8 +11,16 @@
*/
import { describeKey, GLOVE80, indexKeys } from '../board/index.js';
+import { createDrillSession, type DrillSession } from '../drill/index.js';
+import { generateLadder, type Lesson } from '../ladder/index.js';
import { parseMoErgoLayoutText } from '../keymap/moergo.js';
import type { Keymap } from '../keymap/types.js';
+import {
+ LocalStorageDriver,
+ MemoryStorageDriver,
+ ProgressStore,
+ type StorageDriver,
+} from '../stats/storage.js';
import {
describeBoardKeys,
fingersOnBoard,
@@ -22,8 +30,48 @@ import {
import { describeFailure, required } from './dom.js';
import { createDrillView, SAMPLE_DRILL_TEXT, type DrillView } from './drill-view.js';
import { summariseKeymap } from './layout-summary.js';
+import { createProgressView, type ProgressView } from './progress-view.js';
+
+/**
+ * Where saved progress lives, and what to tell the learner about it.
+ *
+ * localStorage throws rather than returning null in a private window, so it is
+ * probed rather than assumed. When it is unavailable the trainer still works: the
+ * in-memory driver keeps the session together and the wording says plainly that
+ * progress will not outlive the tab, so exporting is the way to keep it.
+ */
+export interface ChosenStorage {
+ readonly driver: StorageDriver;
+ readonly description: string;
+ readonly persistent: boolean;
+}
-export function wireUp(root: ParentNode = document): void {
+export function chooseStorage(scope: { localStorage?: Storage } = globalThis): ChosenStorage {
+ if (LocalStorageDriver.available(scope) && scope.localStorage !== undefined) {
+ return {
+ driver: new LocalStorageDriver(scope.localStorage),
+ description:
+ 'Your progress is saved in this browser as you practise, so it is still here after a reload. The file below is how it leaves this browser.',
+ persistent: true,
+ };
+ }
+ return {
+ driver: new MemoryStorageDriver(),
+ description:
+ 'This browser will not let the trainer save anything, so your progress lasts only until you close the tab. Export it if you want to keep it.',
+ persistent: false,
+ };
+}
+
+export interface WireUpOptions {
+ /** Injected so a test can drive a store it owns, and watch what is written. */
+ readonly storage?: ChosenStorage;
+ readonly session?: DrillSession;
+ /** Injected so a test can observe an export without a browser download. */
+ readonly download?: (fileName: string, json: string) => void;
+}
+
+export function wireUp(root: ParentNode = document, options: WireUpOptions = {}): void {
const input = required('#layout-file', HTMLInputElement, root);
const status = required('#layout-status', HTMLElement, root);
const error = required('#layout-error', HTMLElement, root);
@@ -35,11 +83,32 @@ export function wireUp(root: ParentNode = document): void {
const legend = required('#board-legend', HTMLElement, root);
const keyList = required('#board-key-list', HTMLElement, root);
const drillSection = required('#drill-section', HTMLElement, root);
+ const ladderSection = required('#ladder-section', HTMLElement, root);
+ const statsSection = required('#stats-section', HTMLElement, root);
+ const progressSection = required('#progress-section', HTMLElement, root);
+ const drillStart = required('#drill-start', HTMLButtonElement, root);
/** The board's state, so a highlight change does not need the keymap again. */
let labels: ReadonlyMap = new Map();
let restingHighlight = GLOVE80.spacePosition;
let drillView: DrillView | null = null;
+ let progressView: ProgressView | null = null;
+
+ /**
+ * Storage, the session and the saved progress are set up once, here, before a
+ * layout is chosen.
+ *
+ * The order matters and is the whole of regression 2: the load has to reach the
+ * session before the first keystroke, and it has to go through `ProgressStore`,
+ * which thaws and validates, so that nothing frozen from an external store can
+ * become live state. The session then decides what an arriving load may touch.
+ */
+ const storage = options.storage ?? chooseStorage();
+ const store = new ProgressStore(storage.driver);
+ const session = options.session ?? createDrillSession();
+ const startupLoad = store.load();
+ const startupApplied = session.applyLoadedProgress(startupLoad.progress);
+ const startupWarnings = [...startupLoad.warnings, ...startupApplied.warnings];
function showError(message: string): void {
error.textContent = message;
@@ -47,6 +116,9 @@ export function wireUp(root: ParentNode = document): void {
summary.hidden = true;
board.hidden = true;
drillSection.hidden = true;
+ ladderSection.hidden = true;
+ statsSection.hidden = true;
+ progressSection.hidden = true;
status.textContent = '';
}
@@ -80,19 +152,29 @@ export function wireUp(root: ParentNode = document): void {
}
/**
- * Builds the drill surface for this layout.
+ * Builds the drill surface for one lesson of the ladder.
*
- * The text is a parameter, not a decision made here: generated text arrives
- * from issue #3 through `SAMPLE_DRILL_TEXT`'s seam, and the ladder will choose
- * the lesson name. Nothing else about this call changes when they land.
+ * ---------------------------------------------------------------------------
+ * SEAM FOR ISSUE #3, drill text generation.
+ *
+ * The lesson is chosen here and its id, name and key set are already in hand;
+ * only the text is still the sample. When #3 lands, `text:` becomes a call to
+ * the generator with `lesson.keys` and `lesson.stage`, and nothing else in this
+ * function or in `drill-view.ts` changes. `nextLessonName` stays null until then
+ * on purpose: the result card would otherwise offer "Start " on
+ * a button that could only restart the same sample text, and a button that lies
+ * is worse than one rung of the ladder being reached from the ladder itself.
+ * ---------------------------------------------------------------------------
*/
- function showDrill(keymap: Keymap): void {
+ function showDrill(keymap: Keymap, lesson: Lesson | null): void {
drillView?.destroy();
drillView = createDrillView({
board: GLOVE80,
keymap,
text: SAMPLE_DRILL_TEXT,
- lessonName: 'the sample drill',
+ session,
+ lessonId: lesson?.id ?? null,
+ lessonName: lesson?.name ?? 'the sample drill',
nextLessonName: null,
root,
onNextKey: (next): void => {
@@ -102,10 +184,51 @@ export function wireUp(root: ParentNode = document): void {
}
highlight(next.position, 'Highlighted on the diagram, the next key');
},
+ onScored: (): void => {
+ // The stars and the experience are already in live progress by now. This
+ // is where they are written down and where the ladder learns about them.
+ progressView?.save();
+ progressView?.refresh();
+ },
});
drillSection.hidden = false;
}
+ /**
+ * Builds the ladder, the statistics and the export and import controls.
+ *
+ * The ladder is generated from the layout in front of the learner, which is the
+ * thing this project is named for, so it cannot exist before a layout is chosen.
+ */
+ function showProgress(keymap: Keymap): void {
+ progressView?.destroy();
+ const lessons = generateLadder(keymap, GLOVE80);
+
+ progressView = createProgressView({
+ board: GLOVE80,
+ keymap,
+ lessons,
+ session,
+ store,
+ root,
+ startupWarnings,
+ storageDescription: storage.description,
+ ...(options.download === undefined ? {} : { download: options.download }),
+ onChooseLesson: ({ lesson }): void => {
+ // Returning to an earlier lesson rebuilds the drill for it. Focus lands on
+ // the start button, so a keyboard learner is left somewhere they can act
+ // rather than on a button whose meaning has just changed.
+ showDrill(keymap, lesson);
+ drillStart.focus();
+ },
+ });
+
+ // The lesson the learner left off on, clamped to a ladder this layout can
+ // actually produce: an imported file may have come from a longer one.
+ const current = lessons[Math.min(Math.max(0, session.progress.lesson), lessons.length - 1)];
+ showDrill(keymap, current ?? null);
+ }
+
function clearError(): void {
error.textContent = '';
error.hidden = true;
@@ -135,7 +258,9 @@ export function wireUp(root: ParentNode = document): void {
render(list, summariseKeymap(keymap, GLOVE80));
summary.hidden = false;
showBoard(keymap);
- showDrill(keymap);
+ // The ladder comes before the drill: it decides which lesson the drill is
+ // for, and a drill with no lesson could not write its stars anywhere.
+ showProgress(keymap);
status.textContent = `Loaded ${keymap.title}.`;
} catch (cause) {
// Surfaced, never swallowed: a parse failure is the user's problem to
diff --git a/src/ui/progress-view.ts b/src/ui/progress-view.ts
new file mode 100644
index 0000000..26b2d10
--- /dev/null
+++ b/src/ui/progress-view.ts
@@ -0,0 +1,714 @@
+/**
+ * The views around the drill: the ladder, the statistics, and the export and
+ * import of progress.
+ *
+ * Three things here are acceptance criteria rather than taste.
+ *
+ * **Everything loaded goes through `ProgressStore`, and nothing is merged by
+ * reference.** Progress handed over by an external store can arrive deeply
+ * frozen, and merging a frozen graph into live state made the statistics
+ * immutable so the first keystroke threw. `ProgressStore.load` and
+ * `ProgressStore.import` both thaw and validate, and the thawed copy is then
+ * handed to `session.applyLoadedProgress`, which merges field by field. No object
+ * from a file ever becomes live state. Regression 1.
+ *
+ * **Colour is never the only channel.** A star count is written in words beside
+ * the row of glyphs, which is `aria-hidden`; a severity band is a word before it
+ * is a tint; and the little miss-rate bars are decoration with the same number
+ * printed next to them.
+ *
+ * **A partly unreadable import is reported, not refused.** `readProgress` keeps
+ * what is usable and returns warnings; those warnings are printed, item by item,
+ * so a learner knows exactly what was dropped. Only a file that is not JSON at
+ * all is refused outright, and then the message says so.
+ *
+ * The grouping of weak keys by finger and row lives in `src/stats/keystats.ts`,
+ * which has no DOM, so the diagnosis can be tested without a browser. This module
+ * only turns it into markup.
+ */
+
+import type { BoardDefinition } from '../board/types.js';
+import type { DrillSession } from '../drill/index.js';
+import {
+ advancesLesson,
+ highestUnlockedLesson,
+ levelFor,
+ STARS_TO_ADVANCE,
+} from '../drill/scoring.js';
+import type { Lesson } from '../ladder/index.js';
+import type { Keymap } from '../keymap/types.js';
+import {
+ summariseKeyStats,
+ SEVERITY_WORDS,
+ type FingerGroup,
+ type KeyStatsReport,
+ type RowGroup,
+ type Severity,
+ type WeakKey,
+} from '../stats/keystats.js';
+import type { ProgressStore } from '../stats/storage.js';
+import { describeFailure, required } from './dom.js';
+
+/** Stars a lesson can be worth. The ladder shows all three, earned or not. */
+export const MAX_STARS = 3;
+
+export interface LessonChoice {
+ readonly lesson: Lesson;
+ /** Index into the ladder, which is also what `progress.lesson` holds. */
+ readonly index: number;
+}
+
+/**
+ * How a file reaches the learner. Injected so that a test can watch the export
+ * without a real browser, and so the default can state why it is a blob.
+ */
+export type DownloadJson = (fileName: string, json: string) => void;
+
+export interface ProgressViewOptions {
+ readonly board: BoardDefinition;
+ readonly keymap: Keymap;
+ /** The generated ladder, in order. */
+ readonly lessons: readonly Lesson[];
+ readonly session: DrillSession;
+ readonly store: ProgressStore;
+ /**
+ * Warnings from the load that ran at startup. Passed in rather than re-read,
+ * because the load has to happen before the first keystroke and this view is
+ * only built once a layout has been chosen.
+ */
+ readonly startupWarnings?: readonly string[];
+ /** Where saved progress lives, in words, for the learner to read. */
+ readonly storageDescription?: string;
+ readonly onChooseLesson?: (choice: LessonChoice) => void;
+ readonly root?: ParentNode;
+ readonly download?: DownloadJson;
+ /** The clock the export filename is dated from. */
+ readonly today?: () => Date;
+}
+
+export interface ProgressView {
+ /** Re-render the ladder and the statistics from live progress. */
+ refresh(): void;
+ /** Write live progress to the store. A failure is shown, never swallowed. */
+ save(): void;
+ destroy(): void;
+}
+
+/**
+ * A blob URL, deliberately.
+ *
+ * The site is served from a subpath on GitHub Pages (`/touchwright/`), and a
+ * relative `href` would be resolved against whatever path the page is on, so an
+ * export that worked at the root would 404 one directory down. An object URL is
+ * absolute and origin-scoped, so it does not care what path the page is served
+ * from.
+ */
+export function downloadJsonFile(fileName: string, json: string): void {
+ let url: string;
+ try {
+ url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
+ } catch (cause) {
+ throw new Error(`This browser would not build a file to download: ${describeFailure(cause)}`, {
+ cause,
+ });
+ }
+
+ try {
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.rel = 'noopener';
+ // Attached before the click: some browsers ignore a click on an anchor that
+ // is not in the document.
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ } finally {
+ // Revoked whether or not the click worked, so a failed export leaks nothing.
+ URL.revokeObjectURL(url);
+ }
+}
+
+function starWords(stars: number): string {
+ if (stars <= 0) return 'no stars yet';
+ return stars === 1 ? `1 star of ${MAX_STARS}` : `${stars} stars of ${MAX_STARS}`;
+}
+
+/** Decoration only: the count beside this is the fact. */
+function starGlyphs(stars: number): string {
+ const earned = Math.max(0, Math.min(MAX_STARS, Math.floor(stars)));
+ return '★'.repeat(earned) + '☆'.repeat(MAX_STARS - earned);
+}
+
+function percentage(fraction: number): string {
+ return `${Math.round(fraction * 100)}%`;
+}
+
+function element(
+ tag: K,
+ className?: string,
+ textContent?: string,
+): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+ if (className !== undefined) node.className = className;
+ if (textContent !== undefined) node.textContent = textContent;
+ return node;
+}
+
+/** A word for every band, so the tint beside it never has to carry the meaning. */
+function bandWord(severity: Severity): string {
+ return SEVERITY_WORDS[severity];
+}
+
+/** A key as a cap: a space has no glyph, so it gets the open-box symbol. */
+function keyGlyph(character: string): string {
+ if (character === ' ') return '␣';
+ if (character === '\n') return '⏎';
+ if (character === '\t') return '⇥';
+ return character;
+}
+
+function definitionList(list: HTMLElement, rows: readonly (readonly [string, string])[]): void {
+ list.replaceChildren(
+ ...rows.flatMap(([term, detail]) => [
+ element('dt', undefined, term),
+ element('dd', undefined, detail),
+ ]),
+ );
+}
+
+export function createProgressView(options: ProgressViewOptions): ProgressView {
+ const root = options.root ?? document;
+ const el = {
+ ladderSection: required('#ladder-section', HTMLElement, root),
+ ladderList: required('#ladder-list', HTMLElement, root),
+ ladderCurrent: required('#ladder-current', HTMLElement, root),
+ statsSection: required('#stats-section', HTMLElement, root),
+ statsSummary: required('#stats-summary', HTMLElement, root),
+ statsTotals: required('#stats-totals', HTMLElement, root),
+ statsWeak: required('#stats-weak', HTMLElement, root),
+ statsUnplaced: required('#stats-unplaced', HTMLElement, root),
+ progressSection: required('#progress-section', HTMLElement, root),
+ where: required('#progress-where', HTMLElement, root),
+ exportButton: required('#progress-export', HTMLButtonElement, root),
+ clearButton: required('#progress-clear', HTMLButtonElement, root),
+ importInput: required('#progress-import', HTMLInputElement, root),
+ status: required('#progress-status', HTMLElement, root),
+ report: required('#progress-report', HTMLElement, root),
+ reportIntro: required('#progress-report-intro', HTMLElement, root),
+ reportList: required('#progress-report-list', HTMLElement, root),
+ error: required('#progress-error', HTMLElement, root),
+ };
+
+ const download = options.download ?? downloadJsonFile;
+ const today = options.today ?? ((): Date => new Date());
+ const lessons = options.lessons;
+
+ /** Clearing saved progress cannot be undone, so it takes two deliberate goes. */
+ let clearArmed = false;
+ const CLEAR_LABEL = 'Clear saved progress';
+ const CLEAR_CONFIRM_LABEL = 'Really clear saved progress';
+
+ function clearFeedback(): void {
+ el.error.textContent = '';
+ el.error.hidden = true;
+ el.report.hidden = true;
+ el.reportIntro.textContent = '';
+ el.reportList.replaceChildren();
+ }
+
+ function showError(message: string): void {
+ // Surfaced, never swallowed, and nothing else on the page is torn down: the
+ // learner's progress is exactly as it was.
+ el.error.textContent = message;
+ el.error.hidden = false;
+ }
+
+ /**
+ * Prints what an import dropped, one item per line. `readProgress` returns
+ * these rather than throwing, precisely so that a partly corrupt file still
+ * hands back the parts that are usable.
+ */
+ function showWarnings(intro: string, warnings: readonly string[]): void {
+ if (warnings.length === 0) {
+ el.report.hidden = true;
+ return;
+ }
+ el.reportIntro.textContent = intro;
+ el.reportList.replaceChildren(...warnings.map((warning) => element('li', undefined, warning)));
+ el.report.hidden = false;
+ }
+
+ function disarmClear(): void {
+ if (!clearArmed) return;
+ clearArmed = false;
+ el.clearButton.textContent = CLEAR_LABEL;
+ delete el.clearButton.dataset['armed'];
+ el.clearButton.removeAttribute('aria-describedby');
+ }
+
+ function save(): void {
+ try {
+ options.store.save(options.session.progress);
+ } catch (cause) {
+ // A private window and a full quota both land here. Surfaced with what to
+ // do about it, and live progress is untouched, so the drill carries on.
+ showError(
+ `Your progress could not be saved in this browser: ${describeFailure(cause)} Export it to keep it.`,
+ );
+ }
+ }
+
+ /** Best stars per lesson, in ladder order, which is what unlocking reads. */
+ function starsInLadderOrder(): readonly number[] {
+ const stars = options.session.progress.stars;
+ return lessons.map((lesson) => stars[lesson.id] ?? 0);
+ }
+
+ function renderLadder(): void {
+ const progress = options.session.progress;
+ const stars = starsInLadderOrder();
+ const unlocked = lessons.length === 0 ? 0 : highestUnlockedLesson(stars);
+ // The saved lesson can point past what is unlocked if a file from a longer
+ // ladder was imported. Clamp for display rather than pretending it is open.
+ const current = Math.min(Math.max(0, progress.lesson), unlocked);
+
+ el.ladderList.replaceChildren(
+ ...lessons.map((lesson, index) =>
+ ladderRung({
+ lesson,
+ index,
+ stars: stars[index] ?? 0,
+ isUnlocked: index <= unlocked,
+ isCurrent: index === current,
+ previousName: lessons[index - 1]?.name ?? null,
+ }),
+ ),
+ );
+
+ // Short on purpose: the rung below repeats the blurb, and this is a live
+ // region, so it should say the thing that changed and stop.
+ const currentLesson = lessons[current];
+ el.ladderCurrent.textContent =
+ currentLesson === undefined
+ ? 'This layout produced no lessons.'
+ : `Current lesson: ${currentLesson.name}, ${current + 1} of ${lessons.length}. ` +
+ `${unlocked + 1} of ${lessons.length} unlocked so far.`;
+ }
+
+ interface RungFacts {
+ readonly lesson: Lesson;
+ readonly index: number;
+ readonly stars: number;
+ readonly isUnlocked: boolean;
+ readonly isCurrent: boolean;
+ readonly previousName: string | null;
+ }
+
+ function ladderRung(facts: RungFacts): HTMLLIElement {
+ const { lesson, stars, isUnlocked, isCurrent } = facts;
+ const item = element('li', 'ladder-rung');
+ item.dataset['state'] = isCurrent ? 'current' : isUnlocked ? 'unlocked' : 'locked';
+ item.dataset['stage'] = lesson.stage;
+ // A step in a sequence, which is what a ladder is.
+ if (isCurrent) item.setAttribute('aria-current', 'step');
+
+ const head = element('p', 'ladder-rung-head');
+ head.append(element('span', 'ladder-rung-name', lesson.name));
+
+ const glyphs = element('span', 'ladder-stars', starGlyphs(stars));
+ glyphs.setAttribute('aria-hidden', 'true');
+ glyphs.dataset['stars'] = String(Math.max(0, Math.min(MAX_STARS, stars)));
+ head.append(glyphs);
+
+ // The count in words is the fact; the glyphs above are decoration.
+ head.append(element('span', 'ladder-stars-words', starWords(stars)));
+ item.append(head);
+
+ const state = element('p', 'ladder-state');
+ if (!isUnlocked) {
+ state.textContent =
+ facts.previousName === null
+ ? `Locked. Earn ${STARS_TO_ADVANCE} stars on the lesson before it to unlock this one.`
+ : `Locked. Earn ${STARS_TO_ADVANCE} stars on ${facts.previousName} to unlock this one.`;
+ } else if (isCurrent) {
+ state.textContent = advancesLesson(stars)
+ ? 'Unlocked, and this is your current lesson. Passed, so the next one is open too.'
+ : 'Unlocked, and this is your current lesson.';
+ } else {
+ state.textContent = advancesLesson(stars)
+ ? 'Unlocked and passed. You can come back to it whenever you like.'
+ : 'Unlocked. Not passed yet.';
+ }
+ item.append(state);
+
+ item.append(element('p', 'ladder-blurb', lesson.blurb));
+
+ const added =
+ lesson.addedKeys.length === 0
+ ? 'No new keys: this one is a stage over the keys you already have.'
+ : `New keys: ${lesson.addedKeys.map(keyGlyph).join(' ')}.`;
+ item.append(
+ element('p', 'ladder-keys', `${added} ${lesson.keys.length} keys in play altogether.`),
+ );
+
+ if (isUnlocked) {
+ const action = element('p', 'ladder-action');
+ const button = element('button', 'button button-outline');
+ button.type = 'button';
+ // Named with the lesson, so every button on the ladder is distinct to
+ // anyone listening rather than a page full of "Practise".
+ button.textContent = isCurrent ? `Practise ${lesson.name} again` : `Practise ${lesson.name}`;
+ button.addEventListener('click', () => {
+ chooseLesson(facts.index);
+ });
+ action.append(button);
+ item.append(action);
+ }
+
+ return item;
+ }
+
+ function chooseLesson(index: number): void {
+ const lesson = lessons[index];
+ if (lesson === undefined) {
+ // Prefer throwing to quietly switching to the wrong lesson.
+ throw new RangeError(`There is no lesson at ladder index ${index}`);
+ }
+ disarmClear();
+ options.session.progress.lesson = index;
+ save();
+ refresh();
+ options.onChooseLesson?.({ lesson, index });
+ }
+
+ function renderStats(): void {
+ const progress = options.session.progress;
+ const report: KeyStatsReport = summariseKeyStats({
+ keyStats: progress.keyStats,
+ keymap: options.keymap,
+ board: options.board,
+ });
+
+ el.statsSummary.textContent = report.summary;
+
+ const level = levelFor(progress.xp);
+ definitionList(el.statsTotals, [
+ ['Presses', `${report.totalAttempts} (${report.totalMisses} missed)`],
+ [
+ 'Accuracy',
+ report.totalAttempts === 0 ? 'nothing to measure yet' : percentage(report.accuracy),
+ ],
+ [
+ 'Keys',
+ `${report.keysPressed} pressed, ${report.keysMeasured} with enough presses to judge, ${report.keysClean} never missed`,
+ ],
+ [
+ 'Experience',
+ `${progress.xp} XP — level ${level.level}, ${level.intoLevel} of ${level.levelSpan} into it`,
+ ],
+ ]);
+
+ renderWeakGroups(report);
+
+ if (report.unplaced.length === 0) {
+ el.statsUnplaced.hidden = true;
+ el.statsUnplaced.textContent = '';
+ } else {
+ const named = report.unplaced.map((key) => key.label).join(', ');
+ el.statsUnplaced.textContent =
+ `${report.unplaced.length} ${report.unplaced.length === 1 ? 'key' : 'keys'} in your statistics ` +
+ `(${named}) are not bound on this layout, so they have no finger or row. They are kept, not grouped.`;
+ el.statsUnplaced.hidden = false;
+ }
+ }
+
+ /**
+ * The diagnosis, worst first: fingers that need work in full, and the ones that
+ * are behaving tucked into a disclosure so the thing to practise leads.
+ */
+ function renderWeakGroups(report: KeyStatsReport): void {
+ if (report.fingers.length === 0) {
+ el.statsWeak.replaceChildren(
+ element(
+ 'p',
+ 'stats-empty',
+ 'Nothing recorded on this layout yet. Finish a drill and the breakdown by finger and row appears here.',
+ ),
+ );
+ return;
+ }
+
+ const needWork = report.fingers.filter((group) => group.severity !== 'steady');
+ const steady = report.fingers.filter((group) => group.severity === 'steady');
+ const children: HTMLElement[] = [];
+
+ if (needWork.length === 0) {
+ children.push(
+ element(
+ 'p',
+ 'stats-empty',
+ 'No finger and row needs work at the moment. Every group below is inside its margin.',
+ ),
+ );
+ } else {
+ children.push(...needWork.map(fingerPanel));
+ }
+
+ if (steady.length > 0) {
+ const details = element('details', 'stats-steady');
+ const summary = element(
+ 'summary',
+ undefined,
+ `${steady.length} ${steady.length === 1 ? 'finger that is' : 'fingers that are'} behaving`,
+ );
+ details.append(summary, ...steady.map(fingerPanel));
+ children.push(details);
+ }
+
+ el.statsWeak.replaceChildren(...children);
+ }
+
+ function fingerPanel(group: FingerGroup): HTMLElement {
+ const panel = element('div', 'stats-finger');
+ panel.dataset['hand'] = group.hand;
+ panel.dataset['finger'] = group.finger;
+ panel.dataset['severity'] = group.severity;
+
+ const heading = element('h4', 'stats-finger-name');
+ heading.append(element('span', 'stats-finger-label', group.label));
+ // The band is a word first. The tint on it is reinforcement.
+ heading.append(element('span', 'stats-band', bandWord(group.severity)));
+ panel.append(heading);
+
+ // The heading above already names the finger, so only the numbers go here.
+ panel.append(element('p', 'stats-finger-summary', `${group.counts}.`));
+
+ const rows = element('ul', 'stats-rows');
+ rows.append(...group.rows.map(rowItem));
+ panel.append(rows);
+
+ return panel;
+ }
+
+ function rowItem(row: RowGroup): HTMLLIElement {
+ const item = element('li', 'stats-row');
+ item.dataset['row'] = row.row;
+ item.dataset['severity'] = row.severity;
+
+ const head = element('p', 'stats-row-head');
+ head.append(element('span', 'stats-row-name', row.rowLabel));
+ head.append(element('span', 'stats-band', bandWord(row.severity)));
+ head.append(missBar(row.missRate));
+ item.append(head);
+
+ item.append(element('p', 'stats-row-summary', `${row.counts}.`));
+
+ const keys = element('ul', 'stats-keys');
+ keys.append(...row.keys.map(keyItem));
+ item.append(keys);
+
+ return item;
+ }
+
+ /** Decoration. The same number is printed beside it in words. */
+ function missBar(missRate: number): HTMLElement {
+ const bar = element('span', 'stats-bar');
+ bar.setAttribute('aria-hidden', 'true');
+ const fill = element('span', 'stats-bar-fill');
+ fill.style.inlineSize = `${Math.round(Math.min(1, Math.max(0, missRate)) * 100)}%`;
+ bar.append(fill);
+ return bar;
+ }
+
+ function keyItem(key: WeakKey): HTMLLIElement {
+ const item = element('li', 'stats-key');
+ item.dataset['severity'] = key.severity;
+
+ const cap = element('span', 'stats-key-cap', keyGlyph(key.character));
+ cap.setAttribute('aria-hidden', 'true');
+ item.append(cap);
+
+ // The finger and the row are in the headings above, so the only part of the
+ // position worth repeating here is what they do not say: a sideways reach, or
+ // which arc of the thumb cluster.
+ const where = key.qualifier === null ? '' : ` (${key.qualifier})`;
+ const pace = key.meanMs === null ? '' : ` About ${key.meanMs} ms a press.`;
+ const judged = key.measured ? '' : ' Too few presses to judge yet.';
+ item.append(element('span', 'stats-key-words', `${key.summary}${where}.${pace}${judged}`));
+
+ return item;
+ }
+
+ function refresh(): void {
+ renderLadder();
+ renderStats();
+ el.ladderSection.hidden = false;
+ el.statsSection.hidden = false;
+ el.progressSection.hidden = false;
+ el.where.textContent =
+ options.storageDescription ??
+ 'Your progress is saved in this browser as you practise, and the file below is how it leaves.';
+ }
+
+ function onExport(): void {
+ disarmClear();
+ clearFeedback();
+ const stamp = today().toISOString().slice(0, 10);
+ const fileName = `touchwright-progress-${stamp}.json`;
+ try {
+ download(fileName, options.store.export(options.session.progress));
+ } catch (cause) {
+ showError(`That export did not happen: ${describeFailure(cause)}`);
+ return;
+ }
+ el.status.textContent = `Exported ${fileName}. Keep it somewhere you will find it again; importing it restores your progress in any browser.`;
+ }
+
+ function onClear(): void {
+ clearFeedback();
+ if (!clearArmed) {
+ clearArmed = true;
+ el.clearButton.textContent = CLEAR_CONFIRM_LABEL;
+ el.clearButton.dataset['armed'] = 'true';
+ el.status.textContent =
+ 'This will delete the progress saved in this browser, and it cannot be undone. Choose the button again to confirm, or press Escape to cancel. Export first if you want to keep it.';
+ return;
+ }
+
+ disarmClear();
+ try {
+ options.store.clear();
+ } catch (cause) {
+ showError(`Saved progress could not be cleared: ${describeFailure(cause)}`);
+ return;
+ }
+
+ // The session's own live Progress object is kept, because that is the object
+ // it records keystrokes into; only its two maps are replaced, with fresh empty
+ // ones of this realm. Nothing from a file or another realm is put in its place.
+ const progress = options.session.progress;
+ progress.lesson = 0;
+ progress.xp = 0;
+ progress.stars = {};
+ progress.keyStats = {};
+
+ refresh();
+ el.status.textContent =
+ 'Saved progress cleared. The ladder is back at the first lesson and the statistics are empty.';
+ }
+
+ function onClearKeydown(event: KeyboardEvent): void {
+ if (event.key !== 'Escape' || !clearArmed) return;
+ disarmClear();
+ el.status.textContent = 'Clearing cancelled. Your saved progress is untouched.';
+ }
+
+ /**
+ * Import, in one pass: read, validate through the store, merge into live state,
+ * save the merged result, then say what happened and what was dropped.
+ *
+ * The merge takes the better of the two for every field, so importing your own
+ * file can never cost you the drills you have done since exporting it.
+ */
+ async function importFile(file: File): Promise {
+ let text: string;
+ try {
+ text = await file.text();
+ } catch (cause) {
+ showError(`Could not read ${file.name}: ${describeFailure(cause)}`);
+ return;
+ }
+
+ let loaded;
+ try {
+ // Through the store, which thaws and validates. Nothing from the file is
+ // ever merged into live state by reference.
+ loaded = options.store.import(text);
+ } catch (cause) {
+ showError(`${file.name} could not be imported. ${describeFailure(cause)}`);
+ return;
+ }
+
+ let applied;
+ try {
+ applied = options.session.applyLoadedProgress(loaded.progress);
+ } catch (cause) {
+ showError(`${file.name} could not be applied: ${describeFailure(cause)}`);
+ return;
+ }
+
+ save();
+ refresh();
+
+ const warnings = [...loaded.warnings, ...applied.warnings];
+ const starsSeen = Object.keys(loaded.progress.stars).length;
+ const parts = [
+ `Imported ${file.name}.`,
+ `${applied.mergedKeys} ${applied.mergedKeys === 1 ? 'key' : 'keys'} of statistics and`,
+ `${starsSeen} ${starsSeen === 1 ? 'lesson' : 'lessons'} with stars were merged in, keeping the better of the file and this browser.`,
+ ];
+ if (!applied.lessonApplied) {
+ parts.push(
+ 'You are partway through a drill, so the lesson in that file was left alone; nothing you are typing was disturbed.',
+ );
+ }
+ if (warnings.length > 0) {
+ parts.push(
+ `${warnings.length} ${warnings.length === 1 ? 'part' : 'parts'} of it could not be read and ${warnings.length === 1 ? 'was' : 'were'} dropped; the rest was kept.`,
+ );
+ }
+ el.status.textContent = parts.join(' ');
+
+ showWarnings(
+ warnings.length === 1
+ ? 'One part of that file was dropped:'
+ : `${warnings.length} parts of that file were dropped:`,
+ warnings,
+ );
+ }
+
+ function onImportChange(): void {
+ disarmClear();
+ clearFeedback();
+ const file = el.importInput.files?.item(0) ?? null;
+ if (file === null) {
+ el.status.textContent = '';
+ return;
+ }
+ el.status.textContent = `Reading ${file.name}…`;
+ void importFile(file);
+ }
+
+ el.exportButton.addEventListener('click', onExport);
+ el.clearButton.addEventListener('click', onClear);
+ el.clearButton.addEventListener('keydown', onClearKeydown);
+ el.importInput.addEventListener('change', onImportChange);
+
+ el.clearButton.textContent = CLEAR_LABEL;
+ clearFeedback();
+ el.status.textContent = '';
+ refresh();
+
+ // Startup warnings last, so they are the thing on screen after first paint
+ // rather than being overwritten by the first render.
+ const startupWarnings = options.startupWarnings ?? [];
+ if (startupWarnings.length > 0) {
+ el.status.textContent =
+ startupWarnings.length === 1
+ ? 'One part of the progress saved in this browser could not be read. Everything else was kept.'
+ : `${startupWarnings.length} parts of the progress saved in this browser could not be read. Everything else was kept.`;
+ showWarnings('From the progress saved in this browser:', startupWarnings);
+ }
+
+ return {
+ refresh,
+ save,
+ destroy(): void {
+ el.exportButton.removeEventListener('click', onExport);
+ el.clearButton.removeEventListener('click', onClear);
+ el.clearButton.removeEventListener('keydown', onClearKeydown);
+ el.importInput.removeEventListener('change', onImportChange);
+ },
+ };
+}
diff --git a/src/ui/theme.css b/src/ui/theme.css
index 4ad4120..a422cd1 100644
--- a/src/ui/theme.css
+++ b/src/ui/theme.css
@@ -469,6 +469,295 @@ dd {
font-weight: 600;
}
+/*
+ * The ladder.
+ *
+ * Every rung states its star count in words; the glyph row above it is
+ * aria-hidden decoration. A locked rung says what unlocks it, so "locked" is
+ * never carried by nothing more than a dimmer border.
+ *
+ * Nothing here is laid out at a fixed width: the rungs are a plain list of blocks
+ * that reflow at 320 CSS pixels, and the head wraps rather than scrolling.
+ */
+
+.ladder-current {
+ padding: 0.5rem 0.75rem;
+ border-inline-start: 0.25rem solid var(--accent);
+ border-radius: var(--radius);
+ background: var(--surface-sunken);
+}
+
+.ladder {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0;
+ margin-block: 0.75rem;
+ list-style: none;
+}
+
+.ladder-rung {
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ border-inline-start: 0.25rem solid var(--border);
+ border-radius: var(--radius);
+}
+
+.ladder-rung[data-state='current'] {
+ border-color: var(--accent);
+ background: var(--surface-sunken);
+}
+
+.ladder-rung[data-state='locked'] {
+ border-inline-start-style: dashed;
+}
+
+.ladder-rung-head {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.5rem;
+ align-items: baseline;
+ margin-block: 0 0.25rem;
+}
+
+.ladder-rung-name {
+ font-size: 1.0625rem;
+ font-weight: 600;
+}
+
+.ladder-stars {
+ font-size: 1.125rem;
+ letter-spacing: 0.05em;
+}
+
+.ladder-stars[data-stars='0'] {
+ color: var(--ink-muted);
+}
+
+.ladder-stars-words,
+.ladder-state {
+ color: var(--ink-muted);
+ font-size: 0.875rem;
+}
+
+.ladder-blurb,
+.ladder-keys,
+.ladder-state {
+ margin-block: 0.25rem;
+}
+
+.ladder-keys {
+ font-family: ui-monospace, monospace;
+ font-size: 0.8125rem;
+}
+
+.ladder-action {
+ margin-block: 0.5rem 0;
+}
+
+/*
+ * Statistics, grouped by finger and row.
+ *
+ * The band word carries the meaning; the tint on it and the bar beside it are
+ * reinforcement, and the bar is aria-hidden with the same number printed as text.
+ * Each finger panel is edged in that finger's own colour, which is the same colour
+ * the board diagram uses, so the two views agree without either needing a key.
+ */
+
+.stats-summary {
+ font-weight: 600;
+}
+
+.stats-empty {
+ color: var(--ink-muted);
+}
+
+.stats-finger {
+ padding: 0.75rem;
+ margin-block: 0.75rem;
+ border: 1px solid var(--border);
+ border-inline-start: 0.25rem solid var(--finger, var(--border));
+ border-radius: var(--radius);
+}
+
+.stats-finger[data-finger='pinky'] {
+ --finger: var(--finger-pinky);
+}
+
+.stats-finger[data-finger='ring'] {
+ --finger: var(--finger-ring);
+}
+
+.stats-finger[data-finger='middle'] {
+ --finger: var(--finger-middle);
+}
+
+.stats-finger[data-finger='index'] {
+ --finger: var(--finger-index);
+}
+
+.stats-finger[data-finger='thumb'] {
+ --finger: var(--finger-thumb);
+}
+
+.stats-finger-name {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.5rem;
+ align-items: baseline;
+ margin-block: 0 0.25rem;
+ font-size: 1rem;
+}
+
+/* Sentence case, not title case: "Right middle finger", never "Right Middle Finger". */
+.stats-finger-label::first-letter,
+.stats-row-name::first-letter {
+ text-transform: uppercase;
+}
+
+.stats-finger-summary,
+.stats-row-summary {
+ margin-block: 0.25rem;
+ color: var(--ink-muted);
+ font-size: 0.875rem;
+ font-weight: 400;
+}
+
+/*
+ * The band, as a word. The tint is a second channel over the top of it, never
+ * the only one, which is why the word itself is in the markup.
+ */
+.stats-band {
+ padding: 0.0625rem 0.375rem;
+ border: 1px solid currentcolor;
+ border-radius: 1rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: lowercase;
+}
+
+[data-severity='weak'] > * > .stats-band,
+[data-severity='weak'] > .stats-band {
+ color: var(--danger);
+}
+
+[data-severity='steady'] > * > .stats-band,
+[data-severity='steady'] > .stats-band {
+ color: var(--ink-muted);
+}
+
+.stats-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0;
+ margin-block: 0.5rem 0;
+ list-style: none;
+}
+
+.stats-row {
+ padding-inline-start: 0.625rem;
+ border-inline-start: 2px solid var(--border);
+}
+
+.stats-row[data-severity='weak'] {
+ border-inline-start-color: var(--danger);
+}
+
+.stats-row-head {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.5rem;
+ align-items: center;
+ margin-block: 0;
+}
+
+.stats-row-name {
+ font-weight: 600;
+}
+
+/* Decoration. The miss rate is printed as a number in the line beneath it. */
+.stats-bar {
+ display: inline-block;
+ inline-size: 4rem;
+ block-size: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: 1rem;
+ background: var(--surface);
+}
+
+.stats-bar-fill {
+ display: block;
+ block-size: 100%;
+ border-radius: 1rem;
+ background: var(--danger);
+}
+
+.stats-keys {
+ padding: 0;
+ margin-block: 0.25rem 0;
+ font-size: 0.875rem;
+ list-style: none;
+}
+
+.stats-key {
+ display: flex;
+ gap: 0.375rem;
+ align-items: baseline;
+ padding-block: 0.125rem;
+}
+
+.stats-key-cap {
+ flex: none;
+ min-inline-size: 1.5rem;
+ padding-inline: 0.25rem;
+ border: 1px solid var(--board-edge);
+ border-radius: 0.25rem;
+ color: var(--board-cap-ink);
+ font-family: ui-monospace, monospace;
+ text-align: center;
+ background: var(--board-cap);
+}
+
+.stats-steady summary {
+ min-height: 1.5rem;
+ padding-block: 0.25rem;
+ cursor: pointer;
+}
+
+/* Export and import. */
+
+.progress-report {
+ padding: 0.75rem;
+ margin-block: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface-sunken);
+}
+
+.progress-report p {
+ margin-block: 0 0.5rem;
+ font-weight: 600;
+}
+
+.progress-report ul {
+ padding-inline-start: 1.25rem;
+ margin-block: 0;
+ font-size: 0.875rem;
+}
+
+#progress-clear[data-armed='true'] {
+ border-style: dashed;
+}
+
+#progress-error {
+ padding: 0.75rem;
+ border: 1px solid var(--danger);
+ border-radius: var(--radius);
+ color: var(--danger);
+ background: var(--surface-sunken);
+}
+
@media (prefers-reduced-motion: reduce) {
*,
*::before,
diff --git a/tests/e2e/pending.spec.ts b/tests/e2e/pending.spec.ts
index e49ae8c..d304c66 100644
--- a/tests/e2e/pending.spec.ts
+++ b/tests/e2e/pending.spec.ts
@@ -1,3 +1,6 @@
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { SAMPLE_DRILL_TEXT } from '../../src/ui/drill-view.js';
import { loadReferenceLayout, REFERENCE_LAYOUT_PATH } from './helpers.js';
@@ -17,6 +20,20 @@ async function tabUntil(page: Page, id: string, limit = 8): Promise {
+ await page.getByRole('button', { name: 'Start drill' }).click();
+ // A delay, so the drill has a measurable pace rather than an unmeasurable one.
+ await page.keyboard.type(SAMPLE_DRILL_TEXT, { delay: 20 });
+ await expect(page.locator('#drill-result')).toBeVisible();
+ await expect(rungs(page).first()).toContainText('3 stars of 3');
+}
+
/**
* The end-to-end journeys the brief requires, named now so they are tracked, and
* marked fixme until the features they cover exist. Each one is filled in as its
@@ -57,9 +74,57 @@ test.describe('the trainer journeys', () => {
test.fixme('runs a sprint to its limit, and offers an untimed option', () => {});
- test.fixme('keeps progress across a reload', () => {});
+ test('keeps progress across a reload', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await earnThreeStars(page);
+
+ await page.reload();
+
+ // The layout is a file on the learner's own disk, so it is chosen again. The
+ // progress is not chosen again: it is simply still there.
+ await loadReferenceLayout(page);
+
+ await expect(rungs(page).first()).toContainText('3 stars of 3');
+ await expect(rungs(page).nth(1)).not.toHaveAttribute('data-state', 'locked');
+ await expect(page.locator('#stats-summary')).not.toContainText('Nothing recorded yet');
+ // The per-key statistics came back too, grouped by finger and row.
+ await expect(page.locator('#stats-weak')).toContainText('finger');
+ });
+
+ test('exports progress, clears storage, and imports it back', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await earnThreeStars(page);
+
+ const downloading = page.waitForEvent('download');
+ await page.getByRole('button', { name: 'Export progress' }).click();
+ const download = await downloading;
+ expect(download.suggestedFilename()).toMatch(/^touchwright-progress-\d{4}-\d{2}-\d{2}\.json$/);
+
+ const saved = join(mkdtempSync(join(tmpdir(), 'touchwright-e2e-')), 'progress.json');
+ await download.saveAs(saved);
- test.fixme('exports progress, clears storage, and imports it back', () => {});
+ // Clearing takes two deliberate goes, because it cannot be undone.
+ await page.getByRole('button', { name: 'Clear saved progress' }).click();
+ await page.getByRole('button', { name: 'Really clear saved progress' }).click();
+ await page.reload();
+ await loadReferenceLayout(page);
+ await expect(rungs(page).first()).toContainText('no stars yet');
+ await expect(rungs(page).nth(1)).toHaveAttribute('data-state', 'locked');
+
+ await page.locator('#progress-import').setInputFiles(saved);
+
+ await expect(page.locator('#progress-status')).toContainText('Imported progress.json');
+ await expect(rungs(page).first()).toContainText('3 stars of 3');
+ await expect(rungs(page).nth(1)).not.toHaveAttribute('data-state', 'locked');
+ await expect(page.locator('#progress-error')).toBeHidden();
+
+ // And the import was saved as well as applied, so it survives the next reload.
+ await page.reload();
+ await loadReferenceLayout(page);
+ await expect(rungs(page).first()).toContainText('3 stars of 3');
+ });
test('completes a drill without ever touching the mouse', async ({ page }) => {
await page.goto('./');
diff --git a/tests/e2e/progress.spec.ts b/tests/e2e/progress.spec.ts
new file mode 100644
index 0000000..8af3caf
--- /dev/null
+++ b/tests/e2e/progress.spec.ts
@@ -0,0 +1,352 @@
+import { mkdtempSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { expect, test, type Download, type Page } from '@playwright/test';
+import { SAMPLE_DRILL_TEXT } from '../../src/ui/drill-view.js';
+import { expectNoAxeViolations, loadReferenceLayout, REFERENCE_LAYOUT_PATH } from './helpers.js';
+
+/**
+ * The ladder, the statistics, and export and import, in a real browser, in both
+ * themes.
+ *
+ * What is here rather than in the functional suite: the download itself, which no
+ * amount of jsdom will produce; axe over the new views; target sizes and focus
+ * rings, which need computed styles; and the journeys that cross a reload or a
+ * second browser context.
+ */
+
+const DATED_EXPORT = /^touchwright-progress-\d{4}-\d{2}-\d{2}\.json$/;
+
+/** Start the drill and type it through cleanly, at a pace worth three stars. */
+async function earnThreeStars(page: Page): Promise {
+ await page.getByRole('button', { name: 'Start drill' }).click();
+ // A delay, so the drill has a measurable pace rather than an unmeasurable one.
+ await page.keyboard.type(SAMPLE_DRILL_TEXT, { delay: 20 });
+ await expect(page.locator('#drill-result')).toBeVisible();
+ await expect(page.locator('#drill-result-detail')).toContainText('3 stars of 3');
+}
+
+function firstRung(page: Page) {
+ return page.locator('#ladder-list .ladder-rung').first();
+}
+
+/** Click export and hand back the download the browser produced. */
+async function exportProgress(page: Page): Promise {
+ const downloading = page.waitForEvent('download');
+ await page.getByRole('button', { name: 'Export progress' }).click();
+ return downloading;
+}
+
+test.describe('the ladder', () => {
+ test('shows every lesson, which are unlocked, and the stars earned', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+
+ const rungs = page.locator('#ladder-list .ladder-rung');
+ await expect(rungs).toHaveCount(13);
+ await expect(rungs.first()).toContainText('Home keys');
+ await expect(rungs.first()).toContainText('no stars yet');
+ await expect(rungs.first()).toHaveAttribute('data-state', 'current');
+
+ // A locked rung says what unlocks it, which is the only channel that works
+ // for someone who cannot see that its edge is dashed.
+ await expect(rungs.nth(1)).toHaveAttribute('data-state', 'locked');
+ await expect(rungs.nth(1)).toContainText('Earn 2 stars on Home keys');
+ await expect(page.locator('#ladder-list button')).toHaveCount(1);
+
+ await earnThreeStars(page);
+
+ await expect(firstRung(page)).toContainText('3 stars of 3');
+ await expect(rungs.nth(1)).not.toHaveAttribute('data-state', 'locked');
+ await expect(page.locator('#ladder-list button')).toHaveCount(2);
+ });
+
+ test('returns to an earlier lesson from the ladder', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await earnThreeStars(page);
+
+ // Move on to the rung that has just unlocked, then come back down.
+ await page.getByRole('button', { name: 'Practise The left thumb' }).click();
+ await expect(page.locator('#ladder-current')).toContainText('The left thumb');
+ await expect(page.locator('#ladder-list .ladder-rung').nth(1)).toHaveAttribute(
+ 'data-state',
+ 'current',
+ );
+
+ await page.getByRole('button', { name: 'Practise Home keys' }).click();
+ await expect(page.locator('#ladder-current')).toContainText('Home keys');
+ await expect(firstRung(page)).toHaveAttribute('data-state', 'current');
+ // Choosing a lesson leaves focus on a control that can be used straight away.
+ await expect(page.locator('#drill-start')).toBeFocused();
+ });
+
+ test('keeps every control at least 24 by 24 CSS pixels', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+
+ const small = await page
+ .locator('#ladder-section button, #progress-section button, #progress-section input')
+ .evaluateAll((elements) =>
+ elements
+ .map((element) => {
+ const box = element.getBoundingClientRect();
+ return { what: element.id || element.textContent, w: box.width, h: box.height };
+ })
+ .filter((entry) => entry.w < 24 || entry.h < 24),
+ );
+ expect(small, 'these controls are below the 24 by 24 minimum').toEqual([]);
+ });
+
+ test('shows a visible focus ring on a ladder button', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+
+ const practise = page.getByRole('button', { name: 'Practise Home keys again' });
+ await practise.focus();
+ const width = await practise.evaluate((element) => getComputedStyle(element).outlineWidth);
+ expect(Number.parseFloat(width)).toBeGreaterThanOrEqual(2);
+ });
+});
+
+test.describe('the statistics', () => {
+ test('groups the keys that slip by finger and row, in words as well as colour', async ({
+ page,
+ }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await page.getByRole('button', { name: 'Start drill' }).click();
+
+ // Type the drill with one deliberate mistake on a key whose finger and row
+ // are worth naming: k is the right middle finger's lower row, and the drill
+ // starts with "ask".
+ await page.keyboard.type('as', { delay: 20 });
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ await page.keyboard.press('j');
+ await page.keyboard.press('Backspace');
+ }
+ await page.keyboard.type(SAMPLE_DRILL_TEXT.slice(2), { delay: 20 });
+ await expect(page.locator('#drill-result')).toBeVisible();
+
+ const weak = page.locator('#stats-weak');
+ await expect(weak).toContainText('right middle finger');
+ await expect(weak).toContainText('lower row');
+ await expect(weak).toContainText('presses missed');
+ await expect(weak).toContainText('“k”');
+
+ // The band is a word, and the bar next to it is decoration with the same
+ // number written out beside it.
+ const band = weak.locator('.stats-band').first();
+ await expect(band).not.toBeEmpty();
+ await expect(weak.locator('.stats-bar').first()).toHaveAttribute('aria-hidden', 'true');
+ await expect(page.locator('#stats-summary')).toContainText('finger');
+ });
+
+ test('says plainly that nothing has been recorded before anything has', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+
+ await expect(page.locator('#stats-summary')).toContainText('Nothing recorded yet');
+ await expect(page.locator('#stats-weak')).toContainText('Finish a drill');
+ });
+});
+
+test.describe('keeping progress', () => {
+ test('downloads an export even when the page is served from a subpath', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await earnThreeStars(page);
+
+ // The deployed site lives at /touchwright/, so anything the export resolves
+ // relative to the page would point at the wrong place. Pushing a deeper path
+ // reproduces that without a second server: an object URL does not care.
+ await page.evaluate(() => {
+ history.replaceState(null, '', 'touchwright/deep/page');
+ });
+ expect(new URL(page.url()).pathname).toContain('/touchwright/deep/');
+
+ const download = await exportProgress(page);
+ expect(download.suggestedFilename()).toMatch(DATED_EXPORT);
+
+ const saved = join(mkdtempSync(join(tmpdir(), 'touchwright-')), 'progress.json');
+ await download.saveAs(saved);
+ const parsed = JSON.parse(await readFile(saved, 'utf8')) as { stars: Record };
+ expect(parsed.stars['home']).toBe(3);
+ await expect(page.locator('#progress-status')).toContainText('Exported');
+ });
+
+ test('imports an exported file into a fresh browser', async ({ page, browser }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await earnThreeStars(page);
+
+ const download = await exportProgress(page);
+ const saved = join(mkdtempSync(join(tmpdir(), 'touchwright-')), 'progress.json');
+ await download.saveAs(saved);
+
+ // A separate context is a separate browser as far as storage is concerned:
+ // nothing is shared, so this is the "new machine" case.
+ const fresh = await browser.newContext();
+ try {
+ const other = await fresh.newPage();
+ await other.goto(page.url().replace(/[^/]*$/, ''));
+ await other.locator('#layout-file').setInputFiles(REFERENCE_LAYOUT_PATH);
+ await expect(other.locator('#ladder-list .ladder-rung').first()).toContainText(
+ 'no stars yet',
+ );
+
+ await other.locator('#progress-import').setInputFiles(saved);
+ await expect(other.locator('#progress-status')).toContainText('Imported progress.json');
+ await expect(other.locator('#ladder-list .ladder-rung').first()).toContainText(
+ '3 stars of 3',
+ );
+
+ // And it is saved in that browser too, so its own next reload keeps it.
+ await other.reload();
+ await other.locator('#layout-file').setInputFiles(REFERENCE_LAYOUT_PATH);
+ await expect(other.locator('#ladder-list .ladder-rung').first()).toContainText(
+ '3 stars of 3',
+ );
+ } finally {
+ await fresh.close();
+ }
+ });
+
+ test('reports what a partly unreadable import dropped, rather than refusing it', async ({
+ page,
+ }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+
+ await page.locator('#progress-import').setInputFiles({
+ name: 'partial.json',
+ mimeType: 'application/json',
+ buffer: Buffer.from(
+ JSON.stringify({
+ version: 99,
+ lesson: 1,
+ xp: 140,
+ stars: { home: 3 },
+ keyStats: {
+ cp112: { hits: 9, misses: 3, totalMs: 900, samples: 9 },
+ 'not-a-key': { hits: 4, misses: 1, totalMs: 0, samples: 0 },
+ },
+ }),
+ ),
+ });
+
+ // Kept: the usable parts arrived.
+ await expect(page.locator('#progress-status')).toContainText('Imported partial.json');
+ await expect(firstRung(page)).toContainText('3 stars of 3');
+ await expect(page.locator('#stats-totals')).toContainText('140 XP');
+
+ // And dropped: named item by item, not silently lost.
+ const report = page.locator('#progress-report');
+ await expect(report).toBeVisible();
+ await expect(report).toContainText('version 99');
+ await expect(report).toContainText('not-a-key');
+ await expect(page.locator('#progress-error')).toBeHidden();
+ });
+
+ test('refuses a file that is not JSON, and keeps the progress it has', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await earnThreeStars(page);
+
+ await page.locator('#progress-import').setInputFiles({
+ name: 'broken.json',
+ mimeType: 'application/json',
+ buffer: Buffer.from('{ not json'),
+ });
+
+ const error = page.locator('#progress-error');
+ await expect(error).toBeVisible();
+ await expect(error).toContainText('not valid JSON');
+ // Nothing was lost by trying.
+ await expect(firstRung(page)).toContainText('3 stars of 3');
+ });
+
+ test('clears saved progress only after a second, deliberate go', async ({ page }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await earnThreeStars(page);
+
+ const clear = page.getByRole('button', { name: 'Clear saved progress' });
+ await clear.click();
+ await expect(page.locator('#progress-status')).toContainText('cannot be undone');
+ // Armed, not done.
+ await expect(firstRung(page)).toContainText('3 stars of 3');
+
+ await page.getByRole('button', { name: 'Really clear saved progress' }).click();
+ await expect(page.locator('#progress-status')).toContainText('cleared');
+ await expect(firstRung(page)).toContainText('no stars yet');
+ await expect(page.locator('#stats-summary')).toContainText('Nothing recorded yet');
+ });
+});
+
+test.describe('the accessibility of the new views', () => {
+ test('is axe clean with the ladder, the statistics and a result on the page', async ({
+ page,
+ }) => {
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await expectNoAxeViolations(page, 'the ladder and statistics before any drill');
+
+ await earnThreeStars(page);
+ await expectNoAxeViolations(page, 'the ladder and statistics after a drill');
+ });
+
+ test('animates nothing in these views when reduced motion is asked for', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.goto('./');
+ await loadReferenceLayout(page);
+ await earnThreeStars(page);
+
+ const moving = await page
+ .locator('#ladder-section *, #stats-section *, #progress-section *')
+ .evaluateAll((elements) =>
+ elements
+ .map((element) => {
+ const computed = getComputedStyle(element);
+ const longest = (value: string): number =>
+ Math.max(...value.split(',').map((part) => Number.parseFloat(part) || 0));
+ return {
+ what: `${element.tagName.toLowerCase()}#${element.id}.${[...element.classList].join('.')}`,
+ transition: longest(computed.transitionDuration),
+ animation: longest(computed.animationDuration),
+ };
+ })
+ .filter((entry) => entry.transition > 0.05 || entry.animation > 0.05),
+ );
+
+ expect(moving, 'these still move under prefers-reduced-motion').toEqual([]);
+ });
+
+ test('drives the ladder and the export controls from the keyboard alone', async ({ page }) => {
+ await page.goto('./');
+ // Choosing the layout file is the one step a test cannot do with keystrokes.
+ await page.locator('#layout-file').setInputFiles(REFERENCE_LAYOUT_PATH);
+ await expect(page.locator('#ladder-section')).toBeVisible();
+
+ // The ladder button takes focus and is activated by Enter, which is all a
+ // keyboard learner needs to drop back to an earlier lesson.
+ const practise = page.getByRole('button', { name: 'Practise Home keys again' });
+ await practise.focus();
+ await expect(practise).toBeFocused();
+ await page.keyboard.press('Enter');
+ await expect(page.locator('#ladder-current')).toContainText('Home keys');
+ await expect(page.locator('#drill-start')).toBeFocused();
+
+ // Export, clear and import are three consecutive tab stops, in that order.
+ await page.locator('#progress-export').focus();
+ await page.keyboard.press('Tab');
+ await expect(page.locator('#progress-clear')).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(page.locator('#progress-import')).toBeFocused();
+
+ // Shift+Tab goes back the other way, so focus is not one-directional either.
+ await page.keyboard.press('Shift+Tab');
+ await expect(page.locator('#progress-clear')).toBeFocused();
+ });
+});
diff --git a/tests/functional/progress-view-wiring.test.ts b/tests/functional/progress-view-wiring.test.ts
new file mode 100644
index 0000000..24574fa
--- /dev/null
+++ b/tests/functional/progress-view-wiring.test.ts
@@ -0,0 +1,288 @@
+/**
+ * The whole loop, wired up: load a layout, type a drill, have the stars written
+ * down, and find them again after a reload.
+ *
+ * This is the layer where "progress survives a reload" can actually be asserted
+ * without a browser: one storage driver, two `wireUp` calls, and the second one is
+ * the reload. The end-to-end suite does the same journey against a real page.
+ */
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { waitFor } from '@testing-library/dom';
+import { afterEach, describe, expect, it } from 'vitest';
+import { createDrillSession, type DrillSession } from '../../src/drill/index.js';
+import {
+ keyStatId,
+ MemoryStorageDriver,
+ ProgressStore,
+ type StorageDriver,
+} from '../../src/stats/storage.js';
+import { SAMPLE_DRILL_TEXT } from '../../src/ui/drill-view.js';
+import { chooseStorage, wireUp, type ChosenStorage } from '../../src/ui/load-form.js';
+import { readReferenceLayout } from '../fixtures/index.js';
+
+const PAGE_HTML = readFileSync(join(process.cwd(), 'index.html'), 'utf8');
+const PAGE_BODY = (() => {
+ const body = /([\s\S]*)<\/body>/.exec(PAGE_HTML);
+ if (body?.[1] === undefined) {
+ throw new Error('index.html has no for the functional tests to mount');
+ }
+ return body[1].replace(/