From db42007be41868b4415b1122f5732c935a5ce6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C4=ABlav=C4=81pi=20Cheesley?= Date: Tue, 22 Sep 2026 19:08:24 +0100 Subject: [PATCH] Add sprint mode, with a timer bound to the drill it was started for A sprint is a timed run across every key unlocked so far, folded into one synthetic lesson by the new src/drill/sprint.ts, so the text generator is handed a sprint the same way it is handed a rung. It carries no lesson id, so it earns experience and no stars, and the result card names what it was outright rather than leaving it to be inferred from "none, this was not a lesson". The reason this change exists is regression 3, the nastiest failure the prototype shipped. An abandoned sprint left its interval running; the interval then acted on whichever drill was current, and because an untimed drill carried a limit of zero, "elapsed is at least the limit" was true on the very first keystroke. Every drill after that died instantly: a learner would abandon a sprint, go back to a lesson, and watch it end before they had typed anything. Half of that was already fixed in limits.ts, where NO_LIMIT is a real state. The other half is fixed structurally here. startSprintTimer captures the one Drill it is for and never looks a drill up again, and it demands a currentDrill reader which it compares with that captured object by identity on every tick, stopping itself the instant they differ. The binding is a required argument rather than a convention, so there is no way to build a timer that is not bound to a drill, and a teardown that is missed costs one tick of nothing rather than every drill that follows. The three regression todos are filled in, including one that fires a deliberately uncancelled tick at a later drill and proves it does nothing. The repeating callback is injected, so nothing under src/drill reaches for setInterval and every test drives the tick by hand. The default schedule lives in the drill view, which is where the brief says a wall clock belongs. WCAG 2.2.1 applies and the exemption is not claimed. The duration control is built from SPRINT_DURATIONS_MS so the options cannot drift from the durations, the current setting is stated in visible text rather than only shown as a selected option, and the untimed option genuinely never expires -- proved at a century of elapsed time in unit and functional tests and at an hour of faked wall clock in a browser. The countdown is a role="timer", whose implicit live setting is off, so the number is never announced per second; time running out is announced instead at three polite milestones, and the count of those announcements over a whole sprint is asserted. parseSprintLimit refuses an empty control rather than letting Number('') become zero and read as a deliberate choice of untimed. Absent, zero and malformed are three different things. Co-Authored-By: Claude Opus 5 --- docs/accessibility.md | 71 ++- index.html | 45 ++ src/drill/sprint.ts | 381 +++++++++++++++ src/ui/drill-view.ts | 176 ++++++- src/ui/load-form.ts | 110 ++++- src/ui/sprint-controls.ts | 106 +++++ src/ui/theme.css | 29 +- tests/e2e/pending.spec.ts | 66 ++- tests/e2e/sprint.spec.ts | 185 ++++++++ tests/functional/sprint-view.test.ts | 495 ++++++++++++++++++++ tests/regression/prototype-failures.test.ts | 136 +++++- tests/unit/sprint.test.ts | 443 ++++++++++++++++++ 12 files changed, 2192 insertions(+), 51 deletions(-) create mode 100644 src/drill/sprint.ts create mode 100644 src/ui/sprint-controls.ts create mode 100644 tests/e2e/sprint.spec.ts create mode 100644 tests/functional/sprint-view.test.ts create mode 100644 tests/unit/sprint.test.ts diff --git a/docs/accessibility.md b/docs/accessibility.md index 51d5d43..338626b 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -11,22 +11,24 @@ see most of what matters here, so the manual checklist below is the real test. ## Automated coverage -| What | Where | -| ------------------------------------------------------- | ------------------------------------- | -| Axe, WCAG 2.0/2.1/2.2 A and AA rules | `tests/e2e/load-layout.spec.ts` | -| Contrast, both themes, computed styles | `tests/e2e/contrast.spec.ts` | -| Contrast maths | `tests/unit/contrast.test.ts` | -| Reflow at 320, 600 and 1280 px | `tests/e2e/reflow.spec.ts` | -| Visible focus ring | `tests/e2e/load-layout.spec.ts` | -| Keyboard-only reachability | `tests/e2e/load-layout.spec.ts` | -| Adjustable and disableable time limits | `tests/unit/limits.test.ts` | -| Escape releases capture, and Tab still moves focus | `tests/e2e/pending.spec.ts` | -| Marks told apart by decoration, not colour alone | `tests/e2e/drill.spec.ts` | -| The drill surface's name and its description | `tests/e2e/drill.spec.ts` | -| Words never broken across lines, at three widths | `tests/e2e/drill.spec.ts` | -| Both button variants, both themes | `tests/e2e/drill.spec.ts` | -| Nothing moves on the drill surface under reduced motion | `tests/e2e/drill.spec.ts` | -| Drill text, marks, capture and result behaviour | `tests/functional/drill-view.test.ts` | +| What | Where | +| ------------------------------------------------------- | --------------------------------------------- | +| Axe, WCAG 2.0/2.1/2.2 A and AA rules | `tests/e2e/load-layout.spec.ts` | +| Contrast, both themes, computed styles | `tests/e2e/contrast.spec.ts` | +| Contrast maths | `tests/unit/contrast.test.ts` | +| Reflow at 320, 600 and 1280 px | `tests/e2e/reflow.spec.ts` | +| Visible focus ring | `tests/e2e/load-layout.spec.ts` | +| Keyboard-only reachability | `tests/e2e/load-layout.spec.ts` | +| Adjustable and disableable time limits | `tests/unit/limits.test.ts` | +| Sprint duration control, countdown and warnings | `tests/e2e/sprint.spec.ts` | +| A sprint timer never acting on another drill | `tests/regression/prototype-failures.test.ts` | +| Escape releases capture, and Tab still moves focus | `tests/e2e/pending.spec.ts` | +| Marks told apart by decoration, not colour alone | `tests/e2e/drill.spec.ts` | +| The drill surface's name and its description | `tests/e2e/drill.spec.ts` | +| Words never broken across lines, at three widths | `tests/e2e/drill.spec.ts` | +| Both button variants, both themes | `tests/e2e/drill.spec.ts` | +| Nothing moves on the drill surface under reduced motion | `tests/e2e/drill.spec.ts` | +| Drill text, marks, capture and result behaviour | `tests/functional/drill-view.test.ts` | ## Manual checklist @@ -136,11 +138,34 @@ cannot be, so every item here still needs a manual pass. ### Timing -Sprint mode imposes a limit, so WCAG 2.2.1 applies. - -- [ ] A duration control is offered. -- [ ] An untimed option is offered, and it genuinely never expires. -- [ ] The exemption for essential timing is not claimed. +Sprint mode imposes a limit, so WCAG 2.2.1 applies. The exemption for essential +timing is deliberately not claimed: a sprint is a measurement, and a measurement +someone cannot take is not essential to anything. + +- [x] A duration control is offered, built from `SPRINT_DURATIONS_MS` so the + options and the durations cannot drift apart. **Automated** in + `tests/functional/sprint-view.test.ts` and `tests/e2e/sprint.spec.ts`. +- [x] An untimed option is offered, and it genuinely never expires. **Automated** + three times over, at three scales: `hasReachedLimit` at + `Number.MAX_SAFE_INTEGER` in `tests/regression/prototype-failures.test.ts`, + a century of elapsed time through a running sprint timer in + `tests/unit/sprint.test.ts` and `tests/functional/sprint-view.test.ts`, and + an hour of faked wall clock in a real browser in + `tests/e2e/pending.spec.ts`. +- [x] The limit is adjustable before the sprint starts, and the current setting + is stated in visible text rather than only shown as a selected option. + **Automated** in `tests/e2e/sprint.spec.ts`. +- [x] The exemption for essential timing is not claimed. +- [x] The countdown is visible while a timed sprint runs, and is a `role="timer"` + whose implicit live setting is off, so it is never announced per second. + **Automated** in `tests/functional/sprint-view.test.ts` and + `tests/e2e/sprint.spec.ts`. +- [x] Time running out is announced politely at a handful of milestones — a + minute, thirty seconds, ten — so the countdown is never the only channel. + **Automated**: the count of announcements over a whole sprint is asserted, + in `tests/functional/sprint-view.test.ts`. Hearing it is still manual. +- [ ] A sprint feels escapable to someone using a screen reader: the milestones + land without talking over the word announcements. Needs a person. ### Reflow and zoom @@ -166,8 +191,8 @@ exists. - Screen reader testing is manual. There is no automated substitute. The regions, their roles and the text written into them are asserted, and that is as far as a test reaches: no test can hear VoiceOver or NVDA. -- The sprint and ladder views do not exist yet, so the timing section is checked - only at the unit level, through `src/drill/limits.ts`. +- The repair drill does not exist yet, so nothing checks what it says about the + keys it was built from. - Nothing yet checks the drill surface at 400 per cent zoom by hand. Reflow at 320 CSS pixels is automated, which is the same measurement from the other side, but it is not the same experience. diff --git a/index.html b/index.html index ee3d788..f25cf3e 100644 --- a/index.html +++ b/index.html @@ -83,6 +83,18 @@

Practise

+ +
Practise Escape releases the keyboard from anywhere in the drill.

+

@@ -112,6 +125,38 @@

How that went

+ + +