A portable touch-typing trainer and speed test. One container, no build step, no network, no account. Everything runs in the browser and is stored locally.
Try it live | About the project
Two tabs:
- learn: a 62-lesson curriculum from
f jto symbols and adaptive weak-key drills, written in terms of key positions so the whole course works on QWERTY, Dvorak, Colemak, Colemak-DH, Workman, Norman, QWERTZ and AZERTY. - test: timed / word-count / quote / custom typing tests with a seed, so two people can type byte-identical text and compare scores.
The on-screen keyboard belongs to the learn tab, where it shows you where the keys are. Keys are frosted glass panes that brighten while you hold them, with no jump and no hard shadow. A test gets the whole window for the text instead.
The text area is grey until typed, accent colour once correct, red when wrong.
Six themes: red, green and blue, each in dark and light, plus one you build yourself from six colour wells. Every palette is checked for contrast, and the quiet colours of a custom theme are derived so it cannot end up unreadable.
docker compose up -dThen open http://localhost:8080.
Or without compose:
docker build -t tt . && docker run --rm -p 8080:80 ttChange the port with TT_PORT=9000 docker compose up -d.
There is no build step and no runtime dependency: the image is nginx plus a folder of static files.
Opening app/index.html straight off disk works too. Every script is a plain
classic script with relative paths, so nothing needs an origin. The one thing
that differs is storage: some browsers refuse localStorage on file://, and
when that happens tt falls back to memory for the session and says so in the
settings window, so your settings and history last until you close the tab.
| key | action |
|---|---|
tab |
restart the current text |
esc |
new text (new seed unless one is pinned) |
enter |
on the results screen: next run |
r |
on the results screen: repeat the same text |
ctrl + k |
show / hide the on-screen keyboard |
ctrl + , |
settings |
ctrl + p |
the command palette: everything the app can do, by name |
ctrl + backspace |
delete the whole word |
Every run has a seed. Leave the seed box empty for a fresh random seed each time, or type one to pin it. copy challenge on the results screen copies a URL that reproduces the exact text:
http://localhost:8080/#m=time&v=30&s=k7q2mp&l=english1k&p=0&n=0
Anyone opening that link gets the same words in the same order. Test recipes deliberately exclude the keyboard layout, so a QWERTY player and a Colemak player race the same text.
See docs/SCORING.md for the full derivation. In short:
| metric | definition |
|---|---|
| wpm | correct characters / 5 / minutes |
| raw wpm | every keystroke / 5 / minutes |
| accuracy | correct keystrokes / all keystrokes, judged at the moment of the press |
| consistency | 100 * (1 - tanh(cv + cv³/3 + cv⁵/5)) over per-second raw speed |
| effective wpm | wpm × accuracy³ × (0.9 + 0.1 × consistency) |
| tt score | effective wpm × text difficulty × 10 |
Runs shorter than 10 seconds, under 30 keystrokes, or with idle gaps are recorded but never count as a personal best.
Timing is the part that is easy to get quietly wrong, so it is worth stating exactly what happens:
- All timestamps come from the monotonic
performancetimeline; wall-clock time is never used to measure a duration. - The preferred stamp is
KeyboardEvent.timeStamp, which is when the key event actually occurred, not when JavaScript got round to handling it. Implausible values (legacy epoch stamps, future stamps) fall back toperformance.now(). t0is the first counted keystroke, not the render and not a timer.- The display clock uses
requestAnimationFramebut never accumulates: elapsed is always recomputed asnow − t0 − paused. - In timed mode nothing past the deadline is counted. A keystroke whose own
timestamp is past
t0 + durationis discarded and the run ends with exactly the configured duration. - Losing window focus pauses the clock; paused time is excluded from the result and reported separately, and a long pause makes the run unranked.
- Per-second samples are bucketed from the keystroke log after the fact using exact timestamps, not sampled live.
- themes: red, green and blue in dark and light, plus a custom palette you edit with colour wells and keep. Default is green.
- paper grain: a fine fixed-position noise layer over the whole page. The keyboard tray paints an opaque ground so the caps stay clean against it.
- every control is a keycap: tabs, buttons, chips and the on-screen keys are cut from one material, a light pastel face inset from its base with a hard bottom wall and a soft contact shadow. Inputs are recessed instead.
- typed text colour: a row of pastel accents plus an eyedropper for any colour at all. This is the colour grey text turns once you type it correctly.
- error colour: 6 pastel reds plus a custom picker.
- fonts: 16 stacks, marked in the picker if they are not installed on the machine, with a size slider that is capped against the viewport so it can never overflow a narrow screen.
- caret: line, block, underline or off.
- keyboard: shown in the learn tab only, and a key lights only while you are pressing or clicking it. Finger colouring and a next-key hint are opt-in teaching aids, and the finger that owns the next key is named under the board.
The layout picker changes the on-screen keyboard glyphs and the characters each lesson teaches. Two ways to use it:
- Your OS is already set to that layout: leave remap input off.
- You want to practise a layout without changing your OS: turn remap
input on. Key events are then read by physical position (
event.code) and translated through the selected layout.
index.html the landing page (single self-contained file)
.nojekyll tells Pages to serve the repository verbatim
app/ the whole application (static)
index.html
css/ base, app, keyboard
js/
util.js helpers, namespaced storage
rng.js seeded PRNG (xmur3 + sfc32)
data/ word lists, quotes, layouts, themes, curriculum
generator.js deterministic text generation for every drill and mode
metrics.js scoring
engine.js typing state machine, keystroke log, clock
keyboard.js on-screen keyboard
render.js text surface and caret
chart.js SVG charts
results.js results screen
stats.js stats tab
settings.js settings panel
store.js settings, history, personal bests, per-key profile
app.js wiring
tools/ headless test harness (node, no browser needed)
docs/ scoring and curriculum notes
node tools/t-engine.js
node tools/t-gen.jst-engine.js drives the real engine with synthetic key events on a controlled
clock and checks the timing and scoring arithmetic exactly. t-gen.js checks
that seeded generation is reproducible and prints a sample of every drill.
ctrl + p opens a command palette holding every action and every setting the
app has: tabs, themes, accent and error colours, fonts, layouts, caret styles,
test modes and lengths, word lists, all the switches, and all 62 lessons by
name. Type a few letters, press enter. Nothing in the app needs a mouse.
Settings, history, personal bests, lesson progress and the per-key profile live
in localStorage. Nothing leaves the machine.
The settings dialog has a profile section with two ways to move it:
- export file writes
tt-profile.json, and import file reads it back. - copy profile code gives you a single line of gzipped, base64 text, small enough to paste into a chat or a terminal. Paste it into another container and press apply. A second button copies the same thing without the run history, for when you only want your settings and progress to follow you.
GitHub Pages serves this repository straight from the main branch root, with
no build step and no workflow. .nojekyll turns off Jekyll processing so the
files are published exactly as they are:
| path | serves |
|---|---|
index.html |
the landing page at /tt/ |
app/ |
the application at /tt/app/ |
The app is plain static files with relative paths, so the same directory is what nginx serves inside the container and what Pages serves on the web. There is nothing to keep in sync and nothing to configure.
GPL-3.0-or-later. See LICENSE.