A touch-typing trainer for real keyboards, including splits. It times every key you press, works out which keys are slowing you down, and generates lessons from the most frequent English words that use those keys. Your layout and progress live in your own atproto repository, not on our server.
The name lives in app.config.json: title ("keeb, that's it.") is the app's name — the browser tab, the
manifest install label, and the name on the atproto consent screen; heading ("keeb") is the wordmark —
the <h1> on the page and the short label a launcher puts under the icon; name ("keeb") is the
identifier — the repo, the Pages project, the storage prefix and the client id's hostname. heading is
optional because most apps want one string everywhere.
Live: https://keeb.thatsit.app · Repo: https://github.com/thats-it-dev/keeb
npm ci
npm run dev # no sign-in here: the OAuth server only accepts https redirects
npm run verifyAgents: read AGENTS.md.
Scripts: dev, build, preview, typecheck (tsc), test (vitest, including the repo guardrails in
test/guardrails.test.ts), lint (knip: unused files, exports and dependencies), e2e (Playwright on
desktop and phone; builds and previews locally, or BASE_URL=https://… npm run e2e tests a deployed site),
e2e:one (one spec, desktop only: npm run e2e:one -- e2e/x.spec.ts), verify (all of the above, before
a commit), icons, lexicons and lexicons:publish, kit-types (fetches the kit's .d.ts into .kit/;
runs automatically before dev, build and typecheck).
Sign-in can only be exercised on a deploy whose origin is listed in REDIRECT_URIS
(src/atproto/auth.ts) and in the hosted public/.well-known/oauth-client/client-metadata.json. Two
rules the authorization server, not this repo, enforces: the client is application_type: "web", so a
loopback (http://127.0.0.1:…) redirect is refused — it is allowed only for native apps; and a
loopback-shaped redirect must never use the localhost hostname (RFC 8252). Both are checked by
test/oauth-client.test.ts, because the library's own schema is more permissive than the server.
The server also fetches client_id — a URL on the production origin — so a branch preview cannot
complete a login until the build that ships /.well-known/oauth-client/ has landed on main.
Two ideas carry the whole design.
Geometry and keymap are separate. A geometry is where the physical keys sit; a keymap is which
character each position produces. Any geometry pairs with any keymap, so a Go60 running Colemak-DH is
one selection, not a new layout file. Keystrokes are attributed by character, because on a board with
remapped firmware KeyboardEvent.code says nothing useful about where your finger was.
- geometries:
go60(Moergo),ansi60,corne,lily58— insrc/data/ - keymaps: QWERTY, Colemak-DH, Workman, Dvorak, Norman — in
src/data/keymaps.ts
Everything is an integer. Key positions are 1/100 of a key unit, rotations are 1/10 of a degree,
and a key's mean time-to-type is milliseconds × 16 fixed point (ttMs16). atproto's data model has no
floating point type, so the numbers that work in the browser are already the numbers that get stored.
| Path | What lives there |
|---|---|
src/keyboard/ |
geometry model, char resolution, SVG view |
src/stats/ |
per-key samples, EWMA, confidence, the unlock ladder |
src/lesson/ |
word corpus, lesson generator, engine, practice pane |
src/data/ |
shipped layouts, keymaps, IndexedDB store |
scripts/make-corpus.ts |
regenerates src/lesson/corpus.ts from a frequency dump |
- Each keystroke records one sample per key: time since the previous key, and whether it was right. The mean moves 1/16 of the way toward each timed stroke, and a stroke more than four times the current mean counts as that multiple — a keystroke interval is evidence about typing, the gap after you went to make tea is evidence about the kettle.
confidence = goal time-per-key / your time-per-key. 1.0 means you hit the goal on that key. Each key also keepsbestMs16, the fastest that mean has ever run. The curriculum reads the best; every screen reads the current one.- A new learner starts on the six letters the corpus uses most (
e a s r t n), not on a home row. Six letters are the fewest that can spell something; the row they live on is the keymap's business, not the lesson's.keyboardOrderin Settings ranks by row cost instead — home row, then upper, then the rest, most frequent letter first inside a row. - The next letter arrives when every letter already in play has been at goal speed at some point —
best confidence, not this week's. A letter that has just been handed out has never been good enough, so
exactly one letter joins per round. See
src/lesson/ladder.ts. - The lesson is built around
focus: the included letter with the lowest best confidence. It is ringed on the board, named in the readout, marked in the progress list, and present in every word of the line. - Words come from a letter n-gram model trained on the corpus, because six letters of real English is a
shelf of about four words and repeating them is not practice.
realWordsin Settings switches the source to the corpus and lets the model top up a thin pool. Both sources run the same rejection list. - Word weight is
frequency × (1 + weakness), so weak keys come back without an obvious loop. - Progress is written to IndexedDB on every keystroke, keyed
geometry+keymap. Offline is the default; sync is an add-on.
| Milestone | State |
|---|---|
| M0 repo, CSP, PWA shell | shipped on main |
| M1 geometry × keymap model, SVG keyboard view | shipped on main |
| M2 lesson engine, word generator, IndexedDB, practice pane | shipped on main |
M3 layout import — a keyboard's info.json or a layout editor export |
shipped on main |
M4 atproto: OAuth sign-in, settings/progress/session records |
shipped on main |
| M5 publish the lexicons, DNS authority, stats screen | shipped on main |
| M5.1 practice history — read the session records back and chart them | in review |
M6 shareable layouts — publish a layout record, browse and load one |
in review |
| keybr-style guided lesson — six letters, one focus, generated words, unlock on best | in review (see docs/spec.md) |
Namespace app.thatsit.typing.*. Its authority is a DNS TXT record at _lexicon.typing.thatsit.app
carrying did=did:plc:tr7zlhacx3fete5s7cx5tyjd — the NSID minus its last segment, reversed, which is why
the record is not at the apex: resolvers look at exactly one name and do not walk up or down the tree, so a
record placed at _lexicon.thatsit.app would simply never be found.
The schemas themselves are published from lexicons/, generated by npm run lexicons out of
src/atproto/lexicons.ts (the one source; test/lexicon-files.test.ts fails if the directory drifts from
it).
Records are still written with validate: false, and publishing did not change that. I expected the
flag to become unnecessary once the schemas were on the network; it does not. A Bluesky-hosted PDS only
validates against lexicons it holds locally and will not fetch a third-party one on demand, so omitting the
flag fails every write with Unknown lexicon type: app.thatsit.typing.settings — verified against this
account, not theorised. With the flag the record stores fine and reports validationStatus: "unknown",
which is the accepted route for a custom namespace.
So the shapes are enforced here instead, in two passes: test/lexicons.test.ts runs @atproto/lexicon
over the records this code writes, and goat lex validate runs the published schemas over the same files.
Running your own PDS and importing the lexicons into it (com.atproto.admin.importLexicons) is the only
thing that moves validation to the server.
Publishing is deliberate, because a published field name can never be changed:
npm run lexicons:publish # regenerates, then `goat lex publish` (needs `goat account login`)
goat lex status # local == network
goat lex check-dns # the TXT authority resolves back to this accountChanging an already-published schema needs goat lex publish --update; plain publish refuses to overwrite.
That is also why the per-key identifier is keyId and not id: goat flags id as a reserved word across
language code generators, and records written under the old name are still read (see wireKeyId).
Publishing is worth doing for the record, but it does not move validation to the server — see above.
| NSID | Key | Holds |
|---|---|---|
app.thatsit.typing.defs |
— | shared objects: #keyGeometry, #charMap, #keyStats |
app.thatsit.typing.layout |
tid |
a shareable layout: geometry + character map |
app.thatsit.typing.settings |
literal:self |
active layout, target speed, and the two curriculum switches |
app.thatsit.typing.progress |
tid per layout |
per-key samples and each key's fastest mean; additive, so two devices merge by summing |
app.thatsit.typing.session |
tid |
one practice session, oldest pruned past ~50 |
No keystroke ever becomes a record. Records are per-session aggregates.
Three fields are new in this round and all three are optional, which is what lets an old device keep
writing while a new one reads: #keyStats.#row.bestMs16, and keyboardOrder / realWords on settings.
A record that predates them is not migrated — bestMs falls back to the key's current mean, the honest
claim a record with no memory of its own past can make. Adding the fields to the published schemas needs
npm run lexicons:publish with --update; until that is done, nothing breaks, because the PDS is not the
thing validating these writes.
Offline is still the default: signing in is one optional row in Settings, and nothing in the practice loop waits for a network. What sync does, and why:
- A session is the span between two silences. It ends after two minutes without a keystroke, or when the tab closes. One record per session, keyed by its own tid.
- Progress merges, it does not conflict.
mergeadds the sample counts and weights each mean by its own count, so two devices can drift apart for a week and still combine without a coordinator. Two progress records for one layout is a normal accident (two browsers, both first runs): the newest wins outright rather than being averaged with a stale one. - Settings are adopted only on a quiet page. Pulling a remote board in mid-lesson would yank the keyboard out from under the learner, so that merge happens at boot or when you press Sync.
- Failures queue. An unreachable PDS leaves the session in an IndexedDB outbox; the next session, or the next Sync, tries again. Nothing is ever dropped because a server was having a moment.
A board you imported is one app.thatsit.typing.layout record: where the keys are, what each physical row
is for, and what each key produces. Settings, progress and sessions all name a layout by its ids, so a
second device that has never seen your file can still be told which board to draw.
- The record's tid is the board's id. Both devices derive
shared:<rkey>from the same record, so a settings record that saysshared:2222abcmeans the same keyboard everywhere. An id made from a filename would not survive the second browser, which is whereimport:<name>ids stop being useful. - Row roles travel with the positions. Without them a receiving device cannot tell the home row from the number row on a board it has never seen, and the lesson engine, the fingering and the unlock ladder all key off that.
- Characters are per key, not per row position. A shared record fills
Keymap.byKey, because a board that came out of someone's own file has letters on keys the classic 3x6 grid has no slot for. The row grid still decides for the shipped boards, and abyKeyentry wins wherever both exist. - Publishing re-uses one record. The rkey is remembered in the sync state, so pressing Publish twice updates a layout instead of growing a second near-duplicate that the other device would also adopt.
- Boards are adopted before settings. Shared layouts land first, because the check that adopts a remote board only accepts an id it can resolve — and this browser may have got that id from the record it downloaded two lines earlier.
The OAuth client is a first-party web app: client_metadata.json is served from
/.well-known/oauth-client/, the token flow is PKCE with DPoP and no client secret, and the library
keeps its keys and tokens in IndexedDB. The redirect URIs in that file have to match
REDIRECT_URIS in src/atproto/auth.ts — including any branch preview that wants to test a real login.
One consequence of client_id being a URL: the authorization server fetches it from production, so a
preview deploy cannot complete a login until the build that adds /.well-known/oauth-client/ has landed
on main. Test sign-in after merging, not before — a preview's own copy of the document is never read.
connect-src in public/_headers admits https: for this one feature: a learner's PDS is whatever host
they chose, so it cannot be named in advance. script-src, form-action and base-uri stay shut. The
alternative — running the OAuth client inside the service worker, where page CSP does not reach — is
still open.
Settings → layout file takes either kind of JSON a board's config produces, and says what it could not read rather than guessing:
- keyboard-layout-editor
info.json(inside a ZMK or QMK config) describes positions: x, y, size, rotation, which half. It carries no characters, so the keymap stays a separate choice and an imported board pairs with all five layouts. Moergo labels (L_C4R2) give the hand and column; a file without them falls back to the widest gap in the middle. - A MoErgo Layout Editor / keycap export describes characters: a
keyboardfield names the board andlayers[0]lists bindings in that board's matrix order. It needs no positions, but it does need to know the board, so the keyboard named has to be one we ship.&kp LS(MINUS)and&mt ESC GRAVEresolve to the key underneath; macros, layer keys and mouse clicks type nothing.
Because the second format's ordering is inferred, it is checked: the keys outside the typing rows (the number row and the outer column) are compared with what the board says they are, and an import where they disagree is refused rather than drawing a board that lies about your fingers.
Boards and keymaps you import are saved in IndexedDB and appear in the pickers; importing the same filename again replaces that entry rather than adding a twin.
This replaced an earlier plan to parse .keymap text. That was dropped on purpose: a .keymap
makes position implicit in the whitespace alignment of each line, so importing one means guessing
which board it belongs to — and the files above already describe the board.
-
Do not store which letters are unlocked. It is a verdict, and
ladder()recomputes it from the stats on every line. A stored copy is a second answer to the same question, and the two disagree the first time a device syncs numbers the other has not seen. That field used to exist onsettings; it is gone. -
bestMs16merges withmin, nevermaxand never a difference.minis the one combine that is idempotent, commutative and associative, so folding your own progress record back in cannot invent a faster key — and it means a key you once typed at goal speed stays taught through the week you did not. -
The n-gram model needs the rejection list at generation time, not only in the training data. A model trained on clean words will still produce
fuck-shaped strings, because it stores letter patterns and not intentions.isCleanWordis called by bothscripts/make-corpus.tsandsrc/lesson/model.ts. -
A
progressrecord is a shared accumulator, not a device's own diary:flush()writes this browser's whole merged map into it, so the next pull hands our own numbers back. Folding that in as if it were somebody else's evidence added our strokes to our strokes, and every sync doubled the sample and error counts. The rule ismergeAll(local, progressGrowth(seen, remote)), whereseen— the content of that record last accounted for — lives in the sync row and is written in the same IndexedDB transaction as the stats (applyRemoteProgress). Seeded on both sides:flush()on the way out (from the record it wrote, not a live reference to the caller's map), and the fold on the way in. -
Never difference
mean × samples. It looks like a total you can subtract to get "the strokes that arrived in between", but the mean is exponentially weighted: the old observations have already decayed out of it, so the product is not the time they took. Doing it anyway reported a device that got faster as a key with no timing in it (0 ms) and a device that got slower as 1875 ms of a 400 ms stroke. The honest form is inkeyGrowth: the new count is arithmetic, the new mean is not recoverable, so the fold takes the record's current mean and letsmergeweight it by the strokes that are actually new. The bias is the record's own memory — about sixteen strokes — and it shrinks precisely when it would matter most. -
Two practice-time numbers exist, on purpose, and they never merge. The session record carries the wall-clock span of the session (the history chart's speed maths needs that), and the local
dailytally carries keystroke time — gaps between keys, capped at 10 s — which is what the daily goal bar counts, because it must read correctly on a device that has never synced. The goal itself is akeeb:dailyGoalMinlocalStorage slot, deliberately not insettingsand not on the wire.docs/spec.md§13. -
Counts already doubled by older builds stay doubled; the mean recovers on its own because it is sample-weighted, and the sample count only decides how stiff it is.
-
The icon is generated, not stored art:
npm run iconsdraws the Lucidekeyboardglyph analytically (capsules and a rounded-rect ring, one pixel of antialiasing) into the same five PNG sizes plusicon.svg, on the pi-web approach — a small PNG encoder in the script, no canvas, sharp or rasterizer dependency. The Lucide package is a build-time read ofdist/esm/icons/keyboard.mjsand is not a dependency; the path data in the script came from it andicon.svgmirrors the same shapes. The old dot artefacts were committed and the regenerated ones are staged — check the image before shipping. -
It was called TouchType until M4. The name lives in
app.config.json(which also picks the Cloudflare Pages project, so the preview subdomain) and in the OAuth client id, which is a URL on the deployed origin. Progress is stored per origin, so the rename tokeeb.thatsit.appstarted a fresh device record: anything practised on the old subdomain stays there until sync brings it across. -
Lit nested templates need lit's
svgtag or SVG children land in the HTML namespace and paint nothing, silently. Seesrc/keyboard/view.tsandsrc/ui/icons.ts. -
Anything imported must be registered before the saved selection is resolved:
saved()runs in the field initializers against an empty registry, so an imported id falls back to the shipped default unless it is re-read after the IndexedDB load. Both halves of that are tested ine2e/layout.spec.ts. -
Compute derived render values inside
render(), not inupdated()writing to a private field — the latter never repaints on its own. -
Playwright's
hasTouchprojects swallow synthetic keystrokes; the phone project runs without it. -
The corpus is MIT-licensed third-party data (
scripts/make-corpus.tssays where it came from). It is deliberately not the keybr corpus: keybr.com is AGPL-3.0 and this project only reimplements its published behaviour. -
Layout positions for
lily58are our reconstruction, not a vendor file; the UI says so. Fix it from a realinfo.jsonwhen someone has one.