Self-hosted keybr with a TOTEM/PUQ30 geometry #3
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tracking issue for standing up a local keybr instance that can drive the PUQ30
relearn. Follows on from #2 §5, which priced this at "roughly half a day" and
left it as an option rather than a decision.
Why
The relearn currently drills through Monkeytype custom text fed from
training/stage-*.txt. That works, but it is a static staircase: the stagefiles are hand-generated letter sets and advancing between them is a manual
accuracy judgement.
keybr does the same thing adaptively — it introduces letters one at a time keyed
to per-key accuracy, and generates pseudo-words from a language model for both
enandde. That is precisely whattraining/README.mdbuilds by hand.Two further wins, both currently listed as disabled settings in
training/README.md:that does not match this keyboard." A TOTEM geometry fixes that.
training/finger-map.svg(fromtools/finger_map.py)has to be read from a second window. keybr colours its own board by finger.
keybr.com's hosted site has no custom-layout upload — upstream #463 is
that exact request, still open — so this means running a modified instance.
The hypothesis to verify first
This is probably not a keybr layout problem.
PUQ30 lives in firmware. The host receives an ordinary US keyboard: pressing the
right-hand middle home key sends HID usage
T, which the browser reports ascode: "KeyT". Character and physical code always agree on the base layer.keybr's layout files (
packages/keybr-keyboard-generator/layout/, theLayoutclass) exist for host-side layout emulation — the same thing already switched
off in Monkeytype, and for the same reason.
What is actually missing is a keybr geometry: a 38-key column-staggered
TOTEM board that places each US key code at its real physical position, with the
correct finger/zone per key. Layout stays plain US, emulation stays off.
If that holds, the fork is one geometry file plus a registration rather than a
layout definition and a
generate-layoutsrun.Fallback: if keybr's lesson generator draws its letter ordering from
Layoutrather thanGeometry, add a PUQ30 layout entry as well and commit thegenerated output.
Inputs already exist: key positions in
config/boards/shields/totem/totem.dtsi, column-to-finger assignment intools/finger_map.py.Decisions
ma3s7r0/keybr-totemapplyPatchespatch against a movingmasterrots; a rebasable fork branch lets git do the merging.nix-configstays consumer-only.errata*.cl12.dewith Caddy + ACME, already runs a Forgejo runner and podman.heimdallis LAN/tailscale-only; drills happen from work machines too.buildNpmPackage+ a systemd unit), container as documented fallbackpackage-lock.json, sobuildNpmPackageon the workspace is the normal path and the unit is small. If the workspace build fights back, switch tovirtualisation.oci-containersagainst upstream's own Dockerfile, image built by Forgejo Actions in the fork.keybr.cl12.de, publicWork
packages/keybr-keyboard/lib/andpackages/keybr-lesson/aradzie/keybr.comonto git.cl12.de, aszmkfirmware/zmkandcaksoylar/keymap-draweralready arekeybr-totem: TOTEM geometry — 3×5 + 3×5 column-staggered alpha block, 2 outer keys, 6 thumbskeybr-totem:flake.nixexposingpackages.defaultandnixosModules.default, sonix-confignever sees npmnix-config: flake input,hosts/errata/modules/keybr.nix, Caddy vhost, ACME cert, host importkeybr.cl12.de→ erratatraining/README.mdPrerequisite, already met
#2 flagged niri not setting the
altgr-intlvariant as a blocker — without itevery German lesson would be untypable on the TOTEM. Fixed in
nix-config19775ac(2026-08-27); see #2 (comment).Out of scope
training/puo30/unless it is actually taken.
Notes
Keep
training/stage-*.txt. They are the offline fallback, and the corpusanalysis behind them in
tools/layout_score.pyis still the layout'sjustification. The worst-bigram drill list it produces is also directly reusable
for a targeted-drill mode:
Source read: the hypothesis was wrong, and upstream already supports this case
Read against
aradzie/keybr.com@master. The "geometry, not layout" framing inthe issue body does not survive contact with the source. Both are needed, and
the mechanism is one upstream already built.
Emulation.Reverseis exactly the TOTEMpackages/keybr-keyboard/lib/settings.tsdefines three emulation modes, and thethird is this case verbatim — from the doc comment on
reverseEmulationinpackages/keybr-textinput-events/lib/emulation.ts:Surfaced in the UI as "Keyboard hardware emulates layout"
(
packages/page-practice/lib/settings/KeyboardSettings.tsx:156). So this is notthe Monkeytype situation where emulation must be off — keybr has a third state
for firmware-side layouts, and it is the one to select.
That inverts the plan:
Emulation.Reversetakes the character the OS reportsand looks up which key the selected layout assigns it to, so a PUQ30
character dict is required — it is the lookup table that makes the highlight
land on the right key.
Why the geometry is still needed
Ordering weight comes from the geometry's row zone, not the layout —
Keyboard.getCodePoints()inpackages/keybr-keyboard/lib/keyboard.ts:Home row weighs 1, top row 2, everything else 1000 — that is the adaptive
letter-introduction order, and it is only correct if the geometry says which
keys are home.
KeyShapederivesfinger,handandrowfrom the zone list,and those drive
ZonesLayer(finger colouring) andPointersLayer(whichfinger to use) in
keybr-keyboard-ui.Good news for a 38-key board: the SVG frame is computed, not hardcoded —
getFrameSize()inpackages/keybr-keyboard-ui/lib/shapes.tsxtakes the maxx + w/y + hover the shapes. Fractional coordinates work, so columnstagger is just a per-column
yoffset. An arbitrary geometry renders andauto-sizes.
German needs a second
Layoutentry, not a second dictLayoutcarries its language, andKeyboardOptions.selectableLayouts()filtersby it — so one
Layoutserves one language. Two entries sharing oneCharacterDictis the fix, and upstream already does exactly that:[Layout.EN_JP, LAYOUT_JA_JP_JIS]inload.tsreuses the JIS dict under anEnglish entry.
The umlauts fall out of the AltGr level the dict format already has. Source
format is plain JSON, up to four characters per key — base, shift, AltGr,
shift+AltGr (
packages/keybr-generators/layouts/*.json):The GER layer puts
€ ü ä ö ßon the right home row, so those become the thirdcharacter on the five right-home keys. This mirrors the host's
altgr-intlvariant one-for-one, which is the same table the keymap was verified against.
Revised shape of the work
Three pieces, not one:
packages/keybr-generators/layouts/puq30.json— the character dict, umlautsat the AltGr level. Run
npm run generate-layouts, commit the output.Layoutentries sharing it,Language.ENandLanguage.DE, bothemulate: true, both listing the TOTEM geometry.packages/keybr-keyboard/lib/geometry/totem.ts— 38 keys, positions fromtotem.dtsi, zones fromtools/finger_map.py.Then in the UI: layout PUQ30, geometry TOTEM, emulation "hardware emulates
layout".
Requirement noted
The on-screen board with finger colouring and next-key pointers is a
requirement, not polish — it is the main thing Monkeytype could not give. That
rules out shipping against
Geometry.MATRIXas a first cut: the board has tomatch the real TOTEM for the finger positions to be readable at a glance.
keyboard.colorsandkeyboard.pointersboth default totrue.Packaging: plain nix, and upstream's Docker CMD must not be used as-is
The npm build is the same work either way
Docker does not avoid building the 70-package workspace — upstream's Dockerfile
is just
npm cithennpm run compile && npm run build. The only thing acontainer buys is that
npm ciruns with network access, so there is nonpmDepsHashto maintain and the build is not sandboxed.Tested that constraint rather than guessing it:
npm run buildcompletesfully offline once
npm cihas run — 16s, every asset local, webpack"compiled successfully". So the sandbox is satisfiable and the one argument for
containers goes away. Plain nix it is, and the container drops to a fallback
that is not currently needed.
Two things
buildNpmPackagehas to handle, both small:postinstallishusky && patch-package. Ignoring scripts is right forhusky (it only installs git hooks) but wrong for
patch-package— there arethree real patches under
patches/(debug,objection,ws) that have tobe applied. Needs an explicit step, not the default.
root/index.js, plain built JS. Fine.npm run start-dockerships a login backdoorThis is the reason the deployment cannot just be upstream's image with our
layout dropped in.
packages/devenv/lib/initdb.tsdoes two things, and only the first is wanted:GET /login/{token}is a real route(
packages/server/lib/app/auth/controller.ts:105) and it starts a session forwhatever user the token resolves to. So every start of an upstream-Dockerfile
container re-creates a fixed, publicly known token — anyone who has read
the repo can hit
/login/xyzand land in/accountasuser@localhost.That is fine for the dev container it was written for, and not fine on
keybr.cl12.de.Consequences:
root/index.jsdirectly, neverstart-docker, anddoes schema creation as a separate one-shot that omits the token insert.
while leaving a fixed-token login route reachable would be self-defeating —
at minimum the exposure decision should be made knowing this route exists.
Tailscale-only remains a two-line change.
Status
ma3s7r0/keybr-totem, bookmarkpuq30-totem, two commits on upstream master,nothing pushed yet:
add PUQ30 layout and TOTEM geometryadd kanidm oidc providerWorkspace compiles (70 packages, 0 failures).
@keybr/keyboardis 436/436,including six new tests pinning the behaviour this design depends on: the home
row resolving to
hieaodtrns,a/tas the index home and homing keys, theinner column sharing the index finger, and
ü ä ö ß €resolving by characteronto their GER-layer keys rather than following the
RA(Y)-style code thefirmware sends. The only failing test in the full suite is
@keybr/databasewanting a live MySQL on 3306, which is unrelated — production config is
DATABASE_CLIENT=sqlite, so no database service is needed on errata either.Built and wired. One secret left to add.
Verified, not assumed
Ran against the store-built package, not the dev tree:
@keybr/keyboardtests@keybr/page-accounttests@keybr/databasefails, wanting MySQL on 3306 — pre-existing, unrelatednix build .#keybrGET /and/accountGET /login/xyzGET /auth/oauth-init/kanidmsystem.build.toplevelThe kanidm redirect resolves to exactly the right place:
Three things the source made us change course on
The schema command's exit code is unusable on sqlite, non-deterministically.
The schema commits and then node aborts finalizing better-sqlite3 statements
during teardown (
Assertion failed: (env) != nullptr). Neitherknex.destroy()norprocess.exit(0)avoids it — the destructors run duringisolate disposal regardless. Measured across six runs it returned 0 or 134 at
random, on both fresh and repeat runs, so an exit-code-based
ExecStartPrewould have failed randomly at boot.
nix/module.nixchecks the postconditioninstead: four expected tables present. The command is idempotent
(
createSchemaguards each table withhasTable), so retrying is free.MAIL_*is not optional. The auth controller's DI graph constructsMailgunConfigeagerly andEnv.getStringthrows on a missing property, sowithout it the entire
/authand/loginsubtree 500s — the OAuth routesincluded. Found by getting a 500 on
/auth/oauth-init/kanidm. The module setsplaceholders; only the e-mail login flow would use them and that is not the way
in here.
npm run compilecannot run in the nix sandbox and does not need to. Itgoes through lage, which shells out to
gitto hash the manifest; there isneither git nor a repo in the sandbox. webpack transpiles the TypeScript
itself, so
npm run buildalone produces both bundles. Typechecking stays a CIconcern.
Commits
ma3s7r0/keybr-totem, bookmarkpuq30-totem, pushed, four commits on upstreammaster:
add PUQ30 layout and TOTEM geometryadd schema command, for init without the devenv login tokenadd kanidm oidc providerpackage with nix: buildNpmPackage and a nixos modulema3s7r0/nix-config, bookmarkkeybr, not pushed: flake input, host module,Caddy vhost, ACME cert, kanidm OAuth2 client and a
keybr-usersgroup.ma3s7r0/zmk-config-dongle-totem, bookmarkkeybr-puq30, not pushed:tools/keybr_layout.py, which generates both keybr artifacts fromtotem.dtsiandlayout_score.LAYOUTS. Nothing transcribed by hand, and--layout puo30regenerates for the escape hatch the same way the stage listsalready do.
Blocked on
KEYBR_OIDC_CLIENT_SECRETis missing fromhosts/errata/secrets/secrets.yml,which is the only thing stopping the errata system from building. It wants a
fresh random value — kanidm provisioning sets the client secret from that
file, so anything random works.
Also still to do, deliberately deferred until the service is confirmed working
rather than written against an unverified deployment: a DNS record for
keybr.cl12.de, and thetraining/README.mdrewrite of the daily loop.Done: live at https://keybr.cl12.de, signed in, PUQ30 selectable
All the work items are complete. What follows is what the plan got wrong,
since that is the part worth keeping.
The hypothesis in the issue body was wrong
"This is probably not a keybr layout problem" — it was both, and the
mechanism was one upstream had already built.
Emulation.Reverseexists precisely for firmware-side layouts. Its own doccomment describes this keyboard: "Keyboard layout switching is done in
hardware… we use a layout table and a character code as reported by the OS to
fix the physical key location." Surfaced in the UI as "Keyboard hardware
emulates layout". So emulation is not switched off as it is in Monkeytype —
it is switched to the third mode.
That requires a character dict after all, because reverse emulation looks the
typed character up to recover which key was pressed. And the geometry is still
required, because
Keyboard.getCodePoints()weights letters by the geometry'srow zone — home 1, top 2, everything else 1000 — which is what makes lessons
start on the home row.
So: layout and geometry, not geometry alone.
What was actually built
totem.dtsi+layout_score.LAYOUTStools/keybr_layout.py, this repoEN_PUQ30/DE_PUQ30ma3s7r0/keybr-totemSERVER_HOST,schemasubcommandbuildNpmPackage+ NixOS modulenix/ma3s7r0/nix-configNothing is transcribed by hand:
--layout puo30regenerates for the escapehatch exactly as the stage lists already do.
Four things only the deployment could teach
npm run start-dockerships a login backdoor. It runsdevenv/initdb.ts,which re-inserts a fixed token for
user@localhoston every start, andGET /login/{token}starts a session for whoever presents it. The unit runsroot/index.jsdirectly and aschemasubcommand that omits the token.sqlite is unusable on this build.
better-sqlite3's nativeStatementfinalizer aborts the process —
Assertion failed: (env) != nullptr— landingon either side of a commit. It returned 0 or 134 on identical runs, left 0/4
tables on a real first boot, and killed the worker mid-OAuth-callback, which is
why the sign-in failure appeared as a bare 502 with no logged cause: the abort
ate the log line before it flushed. Switched to MariaDB, which is keybr's own
default (
DATABASE_CLIENTdefaults tomysql; only.env.examplesaysotherwise) and what upstream tests against. Notably upstream's CI passes on
sqlite, so the fault is in this build environment, not their code.
kanidm enforces PKCE, even for confidential clients, and keybr's adapter
never sent a challenge. Implemented properly (S256, verifier in the session)
rather than disabling the requirement.
kanidm subjects are UUIDs.
user_external_id.external_idwas sized forGoogle's 21-digit
sub; 36 characters overflowed it. Widened to 255, the OIDCmaximum.
Corrections to the issue body's decisions
written. The container was never the cheaper option — upstream's Dockerfile
runs the same
npm ci && compile && build. What settled it was testing thatthe build works fully offline, so the nix sandbox was never the obstacle.
ensureUserssocket auth does not work here. keybr uses the oldmysqlnpm driver, which never implemented that handshake and fails with
ER_NOT_SUPPORTED_AUTH_MODE. Still passwordless: the user is switched tonative auth with an empty password, which is no weaker than the postgres
beside it, and the listener is loopback-only.
Caddy. It could not complete a login without a mailer, yet it still inserted
a
UserLoginRequestrow per request from anyone, and the only thing betweena stranger and an account was that delivery happened to be unconfigured.
kanidm is now structurally the only way in.
Beyond the plan
127.0.0.1only (SERVER_HOST, added upstream-side), so it addsnothing to
ma3s7r0/nix-config#118.services.mysqlBackupplususer_stats/. That second path is theone that matters — the typing history is plain files under
user_stats/<000>/<000>/<userid>, not rows in the database, so adatabase-only backup would have preserved the login and lost the training.
training/README.mdnow documents both routes. keybr cannot interleavelanguages — one phonetic model per lesson — so the
-mixfiles andMonkeytype remain the way to drill the DE/EN switch.
Still unverified
Nobody has yet confirmed by eye that the board draws as a 38-key split with the
homing marks on
aandt, or that lessons open on the home row. The tests inkeybr-keyboard/lib/totem.test.tspin both, and the README states them asfact. If the board looks wrong, that is what to correct.
Board confirmed in the browser: it renders correctly. That closes the one item
the summary above left open, so nothing in this issue is now asserted without
having been seen.
The geometry is generated from
config/boards/shields/totem/totem.dtsibytools/keybr_layout.py, so it stays correct by construction — if the shield'sphysical layout ever changes, regenerate rather than edit:
Same for a PUO30 pivot:
--layout puo30, exactly as the stage lists and thefinger map already work.