My daily driver is a Sofle RGB split keyboard, and it runs my own QMK userspace: the vendored Miryoku layer definitions plus personal hardware customisations layered on top.
The repository is a qmk_userspace — keymap, layer mapping, hardware support and the qmk.json build targets, with CI building the firmware.
| Fact | Detail |
|---|---|
| Hardware | Sofle rev1, RGB, split |
| Layout | Miryoku (36-key home-row mods) |
| Stack | QMK / C |
| Started | September 2026 |
What's in it
Upstream layer definitions vendored, then adjusted where my hands disagreed with the defaults.
qmk.json targets plus a GitHub Actions workflow, so a firmware build does not depend on my laptop.
In detail
What is actually inside it
.gitmodules pins the Miryoku userspace as a submodule: upstream improvements arrive as a submodule bump instead of a merge conflict in copied code.
AGENTS.md documents the layout and the build for whoever works on it next — including the next version of me.
Home-row modifiers and layer selection mean the layout, not the keyboard, does the work — the hardware is replaceable, the muscle memory is not.
| Detail | Value |
|---|---|
| Base | Sofle rev1, RGB, split |
| Layout | Miryoku (36 keys, home-row mods) |
| Structure | QMK userspace + Miryoku submodule |
Technical notes
The decisions behind it
Why Miryoku?
Why keep the customisations so thin?
By the numbers
The constraints the project actually lives within
MIRYOKU_LAYER_LIST. The OLED prints the active layer by iterating that same list, so its label cannot drift from the layout.KC_NO, apart from two right-hand keys that carry the German umlauts.RGBLIGHT_ENABLE = no is stated explicitly, because enabling both overflows flash by 964 bytes.QUICK_TAP_TERM 0 — a tap can switch straight into a hold, and double-tap-hold auto-repeat is disabled. The defaults were kept; the ordering of the mods is what mattered.["sofle/rev1", "miryoku"] — the single entry in qmk.json that qmk userspace-compile and CI build, published as a rolling latest GitHub Release.The decisions that took the longest
Two of these cost a debugging session
The alphas are QWERTY, but the keyboard is German — isn't that backwards?
No, and it is the least obvious thing in the repo. HID usages are positions, not letters: the firmware sends 0x1C for the key at the US y position and the host layout decides the character. A German layout already maps 0x1C → z and 0x1D → y, so QWERTY alphas plus a de host give German-correct output — the key labelled y types z.
Miryoku's MIRYOKU_ALPHAS=QWERTZ moves the usages to the positions a German keyboard has them, so on a German host the two swaps cancel and every key types its US legend (verified 2026-09-28: "y still prints y, z still prints z"). QWERTZ alphas is therefore the setting for a US host, and wrong here. The keymap's rules.mk pins MIRYOKU_ALPHAS = QWERTY so local builds and CI agree, and any build can still override it on the command line.
Why is the tapping term still Miryoku's 200 ms?
LGUI_T, LALT_T, LCTL_T, LSFT_T — so Shift, the modifier used constantly, sits under the strongest finger (index), and GUI, the rarest, under the weakest (pinky). QUICK_TAP_TERM 0 is the deliberate part: it enables the rapid switch from tap to hold and disables the double-tap-hold auto-repeat. AltGr is exposed as ALGR_T(KC_X) and ALGR_T(KC_DOT), which a German host needs for @, € and the like. All six thumbs are layer-taps — LT(U_NAV,KC_SPC), LT(U_SYM,KC_ENT), LT(U_NUM,KC_BSPC) and so on — so every layer is reachable without leaving the home position.Why is Miryoku vendored into the tree instead of pinned as a submodule?
Because there is still no upstream-maintained QMK userspace port (miryoku#287), and a submodule pointing at a fork's branch would be a worse pin than a copy you can diff and review. users/manna-harbour_miryoku/ is a verbatim copy of the working port from yehorb/qmk_userspace at 2a827f9d (2026-05-27).
The rule is that this tree is never hand-edited — it is third-party code with its own licence, and the only local change inside it is a one-line layer substitution in custom_config.h, the hook Miryoku provides for exactly that. Re-vendoring means diffing the copy against upstream and recording the new commit in the README.
What breaks first when you add something?
Flash, always. The ATmega32u4 gives 28,672 bytes and the build already sits at 27,654 — 96%, 1,018 bytes free. The numbers that matter: RGB Matrix alone is 27,654, RGBLIGHT alone 26,890, and both together are 29,636, i.e. 964 bytes over the limit. Each extra RGB Matrix animation costs roughly 100–400 bytes.
It fits because of LTO_ENABLE = yes in the keymap's rules.mk plus trimming AUDIO_ENABLE, MAGIC_ENABLE, SPACE_CADET_ENABLE and GRAVE_ESC_ENABLE. The Sofle's own keyboard.json already enables the encoder, OLED, mouse keys and extra keys, so only unused core features are switched off.
Why can't config.h just include keymap_german.h?
config.h is pulled into the assembly translation units as well — platforms/avr/xprintf.S includes it — and keymap_german.h drags in keycodes.h, which the assembler rejects with Error: junk at end of line. So the Sofle→Miryoku mapping uses raw KC_* codes and documents the German rendering in comments instead of using QMK's DE_* aliases. QMK headers belong in a .c file, never in the config chain.Both halves run the same firmware?
Yes — MASTER_LEFT and no EE_HANDS, so the identical artifact is flashed to each MCU over its own USB port, one at a time. The consequence is that the two halves are indistinguishable on USB (same VID/PID fc32:0287, no serial) and dfu-programmer simply waits for any ATmega32u4 in DFU mode. Forgetting to move the cable therefore flashes the same half twice — which happened on 2026-09-28.
The kernel log is the ground truth: Sofle → ATm32U4DFU → Sofle means a flash of the same board, while Sofle → Sofle with a new device number means the cable moved. Device numbers increase monotonically, so a cable move is easy to spot.
Pinned, vendored, built without my laptop
How the three inputs fit together, and what CI does with them
Three inputs, pinned separately. QMK itself is a shallow git submodule (.gitmodules sets shallow = true) at b1aea255 — a build input now, not a fork to maintain. Miryoku is deliberately not a submodule: it is a verbatim copy in users/manna-harbour_miryoku/, because no upstream userspace port exists yet and a fork's branch is a worse pin than a diffable copy.
The split is the whole design. Third-party code is never hand-edited; every local twist goes through custom_config.h, which is why the only change inside the vendored tree is one cell in the base layer. Bumping QMK is explicit — git fetch --depth 1 origin master && git checkout FETCH_HEAD inside the submodule, commit, then rebuild both paths and update the pin in the README.
There is a trap worth knowing: QMK_HOME defaults to ~/qmk_firmware, and while the old 2022 fork still lives there a bare qmk compile silently uses that tree. Both build paths pin QMK_HOME/QMK_USERSPACE explicitly, or go through the Makefile, for that reason.
.github/workflows/build_binaries.yaml contains almost no build logic: it calls QMK's reusable qmk_userspace_build.yml@main workflow, passing qmk_repo: qmk/qmk_firmware and qmk_ref: master.
The subtle part is that the reusable workflow only checks QMK out itself if qmk_firmware/requirements.txt is missing — and in this repo the pinned submodule is present, so CI builds against the pin, not against whatever master currently is. It runs qmk userspace-doctor, then qmk userspace-compile over the single qmk.json target inside the ghcr.io/qmk/qmk_cli container.
A second job always runs: it deletes the latest release, recreates it, and uploads every *.hex / *.bin / *.uf2 — so the firmware binaries are a rolling artifact, not a versioned one.
Links
Where to find this one
