Skip to content

feat: motion (IMU) panel, ES60 frame key, shared keyboard canvas - #8

Open
maltmannx wants to merge 17 commits into
mainfrom
feat/motion-imu-and-frame-key
Open

feat: motion (IMU) panel, ES60 frame key, shared keyboard canvas#8
maltmannx wants to merge 17 commits into
mainfrom
feat/motion-imu-and-frame-key

Conversation

@maltmannx

Copy link
Copy Markdown
Member

Adds Studio support for the two new products' extra hardware, plus the capability gating needed so each feature only appears where it exists.

Motion / IMU (PH60SCV2EVO, LIS2DH12)

  • New zmk.motion client contract in src/motion/motionRpc.ts, mirroring the proto proposed in docs/motion-proto.md field-for-field: case-tap actions and the walk-detect key lock.
  • Thresholds are exposed as the raw sensor register semantics (CLICK_THS, ACT_THS, ...) rather than an invented sensitivity scale, so firmware and UI can't drift apart over a mapping table.
  • New "Motion" section with a live meter: lock state, case orientation and a magnitude bar with threshold markers, because a raw count threshold can only honestly be picked by watching the signal while moving.
  • The generated ts-client fork doesn't carry the subsystem yet, and ts-proto encoders silently drop unknown fields, so real RPC cannot be used until it is regenerated. clientSupportsMotion() round-trips a probe request through the codec and switches from the in-memory demo backend to real RPC automatically once the client catches up; real hardware reports "unsupported" until then rather than showing invented state.

ES60 frame key

  • The button is per-layer, so it belongs in the keymap: it needs no subsystem of its own, only two optional KeyPhysicalAttrs fields (kind, label). All reading of those lives in keyboard/keyMeta.ts, so different firmware field names are a one-file change.
  • Renders outlined with a corner label in the keymap canvas, and is excluded from the lighting canvas (dimmed, unselectable) since it has no LED behind it.

Capability-derived navigation

  • The sidebar was a hardcoded list, so unsupported sections appeared and only revealed an empty state once clicked. Availability now comes from the RPC probes for lighting, tap-hold, combos and motion — never from the device name, keeping firmware the single authority.
  • A persisted default view can name a section the current device lacks, so it is resolved rather than trusted (previously a blank main pane).
  • Readiness waits on the motion probe too, so entries can't pop in after the shell is already usable.

Shared keyboard canvas

  • New keyboard/KeyboardCanvas.tsx provides the centred scroll area that doubles as the auto-fit measuring box, plus the zoom control.
  • PhysicalLayout falls back to element.parentElement without an explicit fitContainerRef, which silently broke auto-fit in the lighting views where the layout is nested in a content-sized wrapper. Passing the ref down is the point of the component; the lighting canvas gains auto-fit and zoom it never had.
  • The lighting drawer moves from a flat 360px to the same responsive height ramp as the binding drawer, returning ~120px to the canvas.
  • The combo trigger picker deliberately keeps its own compact fixed-size selector: it is a form control, not a canvas. It already shares PhysicalLayout, which is the part worth having in common.

UI consistency

  • Every on/off control now uses the shared Carbon Toggle: the lighting panel's On|Off segmented pair and the rounded-corners button pairs in both settings views.
  • rounded-mode set border-radius: 50% on the toggle, squashing its 40x22 track into an ellipse; the track now keeps its pill radius and only the knob is circular.

Demo mode gains sideKey and motion toggles so both features are reviewable without hardware, and the in-memory motion firmware drives a self-running signal that plays the whole lock/unlock state machine — it doubles as the reference implementation for the firmware side.

maltmannx and others added 11 commits August 1, 2026 17:28
Adds Studio support for the two new products' extra hardware, plus the
capability gating needed so each feature only appears where it exists.

Motion / IMU (PH60SCV2EVO, LIS2DH12)
- New `zmk.motion` client contract in src/motion/motionRpc.ts, mirroring
  the proto proposed in docs/motion-proto.md field-for-field: case-tap
  actions and the walk-detect key lock.
- Thresholds are exposed as the raw sensor register semantics
  (CLICK_THS, ACT_THS, ...) rather than an invented sensitivity scale, so
  firmware and UI can't drift apart over a mapping table.
- New "Motion" section with a live meter: lock state, case orientation and
  a magnitude bar with threshold markers, because a raw count threshold
  can only honestly be picked by watching the signal while moving.
- The generated ts-client fork doesn't carry the subsystem yet, and
  ts-proto encoders silently drop unknown fields, so real RPC cannot be
  used until it is regenerated. `clientSupportsMotion()` round-trips a
  probe request through the codec and switches from the in-memory demo
  backend to real RPC automatically once the client catches up; real
  hardware reports "unsupported" until then rather than showing invented
  state.

ES60 frame key
- The button is per-layer, so it belongs in the keymap: it needs no
  subsystem of its own, only two optional KeyPhysicalAttrs fields
  (`kind`, `label`). All reading of those lives in keyboard/keyMeta.ts, so
  different firmware field names are a one-file change.
- Renders outlined with a corner label in the keymap canvas, and is
  excluded from the lighting canvas (dimmed, unselectable) since it has no
  LED behind it.

Capability-derived navigation
- The sidebar was a hardcoded list, so unsupported sections appeared and
  only revealed an empty state once clicked. Availability now comes from
  the RPC probes for lighting, tap-hold, combos and motion — never from
  the device name, keeping firmware the single authority.
- A persisted default view can name a section the current device lacks, so
  it is resolved rather than trusted (previously a blank main pane).
- Readiness waits on the motion probe too, so entries can't pop in after
  the shell is already usable.

Shared keyboard canvas
- New keyboard/KeyboardCanvas.tsx provides the centred scroll area that
  doubles as the auto-fit measuring box, plus the zoom control.
- PhysicalLayout falls back to `element.parentElement` without an explicit
  fitContainerRef, which silently broke auto-fit in the lighting views
  where the layout is nested in a content-sized wrapper. Passing the ref
  down is the point of the component; the lighting canvas gains auto-fit
  and zoom it never had.
- The lighting drawer moves from a flat 360px to the same responsive
  height ramp as the binding drawer, returning ~120px to the canvas.
- The combo trigger picker deliberately keeps its own compact fixed-size
  selector: it is a form control, not a canvas. It already shares
  PhysicalLayout, which is the part worth having in common.

UI consistency
- Every on/off control now uses the shared Carbon `Toggle`: the lighting
  panel's On|Off segmented pair and the rounded-corners button pairs in
  both settings views.
- rounded-mode set `border-radius: 50%` on the toggle, squashing its
  40x22 track into an ellipse; the track now keeps its pill radius and
  only the knob is circular.

Demo mode gains `sideKey` and `motion` toggles so both features are
reviewable without hardware, and the in-memory motion firmware drives a
self-running signal that plays the whole lock/unlock state machine — it
doubles as the reference implementation for the firmware side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for making the studio unlock gesture a first-class, user-managed
thing. Read-only for now: changing the gesture needs a guided
verify-before-replace flow and two firmware additions, both specified in
docs/unlock-combo.md.

Why the unlock binding needs special handling
- `core` has no unlock RPC by design, so unlocking is always physical and
  Studio can only ever change the gesture, never bootstrap it.
- `resetSettings` requires being unlocked too, so it is not a recovery
  path: losing the last `&studio_unlock` binding means the keyboard can't
  be edited again without reflashing.

Unlock paths
- New src/unlock collects every way a keyboard can be unlocked — reserved
  combos plus any `&studio_unlock` still bound in the keymap — so a future
  edit path can refuse to take that count to zero.
- The Device page lists them as key chips, marking slots the firmware
  protects via `editableBehavior: false`. That flag is the real invariant:
  our own UI guard can be bypassed by any other client, firmware's can't.
- The reserved slot is hidden from the Combos page; it isn't a shortcut the
  user authored. Slot numbering still uses the firmware index, and an
  all-reserved keyboard now says so instead of claiming combos are
  unsupported.
- Behaviors are identified by `displayName`, since the proto carries no
  stable id — a known weakness the doc asks firmware to close.

Lock screens now name the gesture (Fn + \) instead of saying "press the
studio-unlock combo". It can only be the factory default: nothing about the
keyboard is readable while locked, which is also the most likely way for
someone to get stranded after changing it. Both surfaces are unverified at
runtime because the demo firmware always reports unlocked.

Device vs Preferences
- "Quick settings" → "Device" (what lives on the keyboard: identity, active
  layout, unlock shortcut, factory reset) and "Settings" → "Preferences"
  (what lives in this app). The rule: anything that issues an RPC belongs on
  the device side.
- `core.resetSettings` moves accordingly — it was sitting with the app
  preferences.
- Nav ids change with the views, so persisted ones are migrated on read to
  avoid silently relocating someone's default view. The quick-controls block
  on the Device page is still a deliberate duplicate, pending a call on
  whether the shortcut is worth the blurred boundary.

Demo firmware
- Reserves slot 0 for unlock (Fn + \, non-editable behavior) so the whole
  path is reviewable without hardware.
- `&kp` parameters are now encoded as real HID usages (`page << 16 | id`)
  instead of bare ids, and media keys moved to the consumer page where they
  belong. The bare ids made every label resolve to "?" and stopped live
  keypress highlighting from ever matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lets the user replace the studio unlock gesture without ever risking access
to the keyboard, and finishes the Device/Preferences split.

Verify before replacing
- The reserved slot is rewritten only after the candidate gesture has been
  seen to fire, so a working gesture is never given up on the strength of a
  guess. `resetSettings` needs an unlocked keyboard, so there is no way back
  from a wrong guess short of reflashing.
- Verification borrows a spare combo slot, binds the candidate positions to
  `&kp F13`, and watches for that keystroke arriving at the host — the same
  channel the key tester uses. The keyboard is never locked during the test,
  so a gesture the user turns out not to be able to perform strands nobody.
  The borrowed slot is released on success, on cancel, and on unmount.
- Locking and retrying instead would be ambiguous: during such a test both
  the old and new gestures work, so pressing the old one would falsely
  confirm the new one. Firmware reporting which combo unlocked the keyboard
  would fix that properly; docs/unlock-combo.md asks for it.
- Only `key_positions` is written. The behavior is echoed back exactly as
  reported, since a differing behavior is (rightly) rejected on a reserved
  slot. `require_prior_idle_ms` is raised to 200ms when the chord has no
  layer or modifier key, because an all-letters chord can otherwise fire
  mid-typing and unlock the keyboard behind the user's back — with a
  positions-only retry for firmware that refuses the field.
- If the host never delivers F13, a 30s timeout offers to apply anyway,
  naming the consequence and whether another unlock path still exists.
- The two slots are snapshotted when the flow opens: writing the probe makes
  the borrowed slot stop looking spare, which would otherwise tear the flow
  down halfway through its own test.

Device page
- Quick controls removed; they duplicated Preferences and blurred the line
  the rename was meant to draw.
- Two columns where there's room, so the page uses the width instead of
  crowding a 560px strip.

Demo firmware
- `setCombo` no longer hands back `editableBehavior: true` on every write.
  That silently stripped the reserved slot's protection, which is the one
  guarantee that keeps a keyboard from losing its only way in. The flags now
  stay firmware-owned and writes that violate them are refused.

Known demo gap, pre-existing: the mock transport can't push notifications, so
`unsavedChangesStatusChanged` never fires and the footer doesn't flag combo or
keymap writes as unsaved. Real firmware does emit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…risky

Addresses three points from testing the change flow on hardware.

The test combo never fired
- The probe borrowed the *spare* slot's `layerMask` instead of the unlock
  combo's. `0` means "all layers" here, so a spare slot carrying a restricted
  mask produces a combo that cannot fire on the layer the user is actually
  on — and the test just silently never completes. `slowRelease` was
  inherited from the wrong side too. Both now mirror the unlock combo, which
  is the condition the test is supposed to reproduce.
- The demo couldn't have caught this: its unused slots happen to carry
  `layerMask: 0`, so the probe worked there by coincidence.

Made the failure legible instead of silent
- While waiting, the flow now shows which test key it's listening for and
  which keystrokes are actually reaching Studio. If ordinary keys arrive but
  the test key doesn't, the chord isn't firing on the keyboard; if nothing
  arrives, the keystrokes aren't reaching Studio at all. That's a firmware
  problem versus a focus problem, and it was previously indistinguishable.
- The timeout message now says which of those two it is, and offers to retry
  with a different test key: no key is delivered on every platform (F13-F15
  don't exist on many keyboards, Pause is absent on most Macs), so being
  stuck on one the OS eats was a dead end.

Change button reads as risky
- Warning outline rather than a plain secondary button. The flow refuses to
  give up a working gesture, so it isn't destructive, but it's still the
  keyboard's only key — it shouldn't look like changing the theme.

Device page keeps the unlock panel below the device info in a single column,
rather than moving it into a second column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing showed keys arriving when pressed separately but nothing at all when
pressed together. That is the combo *working*: ZMK swallows the keypresses it
consumes and runs the bound behavior instead, so the only thing going missing
was the F13 substitute keystroke.

- The probe ladder now drops to ordinary letters (Z, Q) after F13. The F-row
  above F12 is the tidy choice because nothing is bound to it, but it's also
  the least reliable — those keys are absent from most keyboards and plenty of
  systems never surface them to the browser. Letters always arrive, and the
  probe listener swallows the keystroke so nothing is typed anywhere.
- Candidates that are part of the chord being tested are filtered out, or a
  key that merely passed through would be mistaken for the combo firing.
  Reading a usage out of param1 is gated on the binding actually being
  keypress-shaped, since a layer key's param1 is a layer number.
- The timeout now explains the swallowing, because "no keystrokes reaching
  Studio" while pressing the chord means the opposite of what it looks like.
  The retry button names the key it will switch to, and clears the timeout
  state so the next attempt gets a full window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The combo fires and swallows its keys but emits neither F13 nor Z. Since a
letter is delivered by every platform, the missing piece isn't key delivery —
the trigger is landing and the bound behavior isn't running.

The `&kp` parameter encoding was ruled out first: `(page << 16) | id` is what
HidUsageGrid uses for every working binding the app writes, and 0x70068 /
0x7001D match it.

So the remaining unknown is whether the behavior reaches the slot at all, which
`setCombo`'s own "ok" can't answer and the local combo list can't either —
`applyConfig` updates it optimistically, so it always agrees with what we sent.
`useCombos` gains `readCombo`, a plain `getCombo`, and the waiting screen now
prints what firmware actually holds:

  Slot #3 · keys [59,67] · behavior 1 · param1 0x70068 · layers 0x0 · 200ms

If the behavior or params come back empty or different, the flow says so and
names it a firmware issue rather than leaving the user to conclude the feature
is broken. Positions are checked the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing revealed the wrong assumption: shipping firmware reserves no unlock
combo slot. Its factory gesture is a keymap binding (`&studio_unlock` on a
layer), so after a settings reset there is no combo bound to unlock at all —
and the flow simply hid its own entry point, which reads as a regression.

It also explains the earlier confusion. The F+J combo that made the button
appear was hand-made by the user and bound to studio unlock, so pressing F+J
fired *that* combo: keys swallowed, no keystroke emitted, probe never involved.

- The flow gains a `create` mode: a free slot is bound to `&studio_unlock` once
  the gesture is confirmed. One slot is enough, since the slot used for the
  probe becomes the unlock combo — no second write, nothing to release. This is
  purely additive (the keymap gesture stays), so it cannot strand anyone, and it
  gets a plain button while replacing an existing combo keeps the warning
  outline.
- Overlapping combos are now called out. Exact duplicates were already blocked,
  but a combo merely *sharing* a key competes with the candidate and may win,
  which is precisely how this looked broken. The warning names the slot.
- The demo defaults to no reserved unlock slot so it matches real firmware; the
  reserved-slot design is behind a new demo toggle, off by default. The previous
  default is why the demo couldn't reproduce any of this.
- docs/unlock-combo.md no longer claims the reserved slot exists today, and
  documents both paths plus the read-back and probe-ladder steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Retry with a different test key" set `probeIndex` and re-armed in the same
handler, so the write closed over the previous render's probe. The label moved
but the keyboard didn't, one step apart forever: choosing Q emitted Z.

Worse than a cosmetic lag — the listener was derived from the same stale value,
watching the newly chosen key while the keyboard emitted the previous one. Every
retry was guaranteed to fail, which is why the first attempt looked dead and no
amount of switching helped.

Split the two ideas that were conflated: `probeIndex` points at the candidate to
arm *next*, `armedProbe` is what's actually on the keyboard. The listener, the
on-screen label and the read-back comparison all derive from `armedProbe`, which
is only set after the write succeeds — so Studio never watches for a key the
keyboard isn't sending. `arm()` takes the candidate as an argument rather than
reading state that hasn't updated yet.

Verified: retry advertising "Z" now arms Z (read-back `param1 0x7001d`), a
stale F13 no longer counts, and the armed key confirms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F13 never reached the browser in testing on Windows, so the default path was a
30-second dead end before the user could reach a key that works. Ordinary
letters go first now — they're the only candidates every platform delivers, and
the probe listener swallows the keystroke so nothing is typed anywhere. The
F-row above F12 stays as a fallback since it's tidier when it does work.

Also corrected the closing hint, which told the user to press "Save to
keyboard". Nothing in the app marks the session dirty on a combo write — no
subscriber for `combosChanged`, and `extraSaveEnabled` only covers lighting and
motion — so that button may not be showing at all. Whether combos survive a
power cycle on their own is a firmware question, so the hint now says to use the
button if it's there and otherwise to power-cycle once and check, rather than
asserting a control that may not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In change mode the existing unlock combo keeps its old trigger throughout the
test, so if the candidate shares keys with it, ZMK has two competing combos and
the unlock one tends to win: the chord swallows its keys and emits nothing,
because studio unlock produces no keystroke. Indistinguishable from a dead test.

The default case walked straight into it — the picker pre-selects the current
gesture, so pressing "Test this shortcut" without changing anything tested a
chord the unlock combo was guaranteed to take. Create mode was unaffected, which
is why it worked there and not here.

Root cause was conflating two questions that need different answers:

- end state: would the finished combo duplicate another? The slot being
  rewritten is excluded, since reusing its own keys is fine.
- the test: the probe sits in a borrowed slot while every other combo is still
  live, unlock combo included. Nothing is excluded but the probe's own slot.

Overlap detection now uses the second rule, and two cases are blocked outright
rather than warned about, since neither test can conclude anything: a chord
identical to the gesture already in use (nothing to confirm), and one the live
unlock combo would shadow. Each says which it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Persistence is confirmed: firmware marks the session unsaved on a combo write,
so the header's Save button appears on its own, and `keymap.saveChanges` carries
the combo through a power cycle. Studio doesn't need to track combo dirtiness
itself — doing so would risk a false "unsaved" state. The closing hint says to
save rather than hedging about whether the button is there.

Also captured the four device-only findings, since each cost a debugging round
and all of them present identically as "the test does nothing":

- a firing combo swallows its keys, so keys arriving separately but not together
  means the chord works and only the substitute keystroke is missing — the
  opposite of how it reads;
- `&studio_unlock` emits nothing, so any combo bound to it looks dead, and an
  overlapping one silently wins the chord under test;
- `layer_mask == 0` means "all layers", and an unused slot may carry a
  restricted mask;
- F13 never reached the browser on Windows.

None of these are reproducible against the demo firmware, which is the reason
they all surfaced late.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploying zmk-studio with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7236380
Status:🚫  Build failed.

View logs

maltmannx and others added 4 commits August 2, 2026 16:17
Motion's live meter now keeps a peak marker. Calibrating a tap threshold
means reading the transient, and a bar that tracks the signal live has
already fallen back before you can read it — the marker is pushed up
instantly, held ~0.9s, then bled away, never below the live value so it
reads as the bar carrying it rather than a second independent thing.

Toggle gains Carbon's two real sizes (48x24 and 32x16-with-checkmark)
instead of one in-between 40x22. "Require flat" takes the small one: it's
a row among sliders, not the switch that owns a section.

Preferences moves to a proper Carbon content switcher for theme and
language. The selected item is inverse, not the interactive blue — that's
what tells a switcher apart from a button group, and it means an accent
change leaves it alone. Default view outgrew Carbon's five-item ceiling
(it scales with the keyboard's capabilities), so that one is a Select.

Combos get "clear slot" at the bottom of the slot rail, where the layer
rail keeps its destructive actions, replacing the field-level button that
only cleared positions and needed a separate Save. It writes immediately;
a rail button that appears inert until you find Save on the far side of
the pane isn't worth the symmetry.

Two removals:

- Rounded-corners beta, along with the blanket `* { border-radius }`
  rule, its six opt-outs, and Badge's `square` prop that existed only to
  escape it.

- "Follow browser" accent. The only web API for it is the CSS
  `AccentColor` keyword, which Chromium doesn't implement (measured:
  CSS.supports is false on Chrome 148) — and since Studio needs Web
  Serial/WebHID, every browser that can reach a keyboard is Chromium, so
  the option could never have worked for a real user. `Highlight` is the
  near miss: it parses, but Chromium answers with a fixed #0078D7 rather
  than the OS accent (#0078D4 on the test machine), which would have
  shipped a hard-coded blue dressed up as a preference. A stored "system"
  accent migrates to Blue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bon's grid

Three things, one of them serious.

**The unlock combo vanished after a power cycle, and then there was no way
back.** Two separate defects, both mine:

A combo write is a `combos` call, but the header's unsaved indicator reads
`keymap.checkUnsavedChanges`. Studio was relying on firmware to raise the
keymap flag for another subsystem's write. Last round I recorded that as
"confirmed on hardware" off a single session where the Save button
appeared — that was one observation dressed up as a fact, and this round
contradicted it: the combo lived in RAM, was never written, and went away
with the power. Studio now marks itself dirty on every combo write, so the
Save button cannot fail to appear, and the unlock flow saves inline and
reads the slot back instead of pointing at a button on another screen.

Then the recovery path was blocked. `findSpareComboSlot` refused any slot
carrying `&studio_unlock`, while `collectUnlockPaths` only counts a slot as
an unlock path if it has key positions. A slot with the behavior and no
positions — exactly what a failed save leaves — was therefore neither, so
the Device page offered nothing, and the "free a slot on the Combos page"
hint pointed at a slot that was already free. Such a slot is now used last
and never refused. Covered by four cases: orphaned slot recovers, an
untouched slot is still preferred, firmware-reserved and occupied slots are
still refused.

What Studio still cannot do is prove persistence: `saveChanges` returning
ok and a `get_combo` readback both only describe the live slot. So the copy
says "written", not "permanent", and tells the user to power-cycle and
check while their old unlock method still works. The doc note that claimed
otherwise is retracted, and an unsaved-state signal for `combos` is written
up as a firmware ask.

**Motion settings rebuilt as a Carbon form.** The old shape put a fixed
label column beside whatever control the setting needed, so a 22px toggle,
a 35px segmented control, a native slider and a padded button each set
their own row height — nothing lined up and there was no baseline to read
down. Carbon stacks label over control and fixes the control height, which
is where the rhythm comes from. Now: label → 40px control → optional helper
text, in an auto-fit grid. Measured: every control 40px, controls in a row
share a y to the pixel, 3 columns at 1440px and 2 at 1280px. Register names
(CLICK_THS…) ride on the label line rather than becoming helper text, so
they don't cost height. Section headings are 14px semibold with a rule,
not 11px letterspaced caps, which isn't a Carbon pattern. The native slider
also got Carbon's 4px track and 14px handle — its default height was part
of why rows sat at different levels.

The lock panel still scrolls 55px at exactly 720px tall (0 at 900px).
Closing that last gap would mean dropping the two helper texts that explain
what the settings do, which trades the wrong thing.

**"Require flat" is back on the default toggle** — the small one was the
wrong call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… save gap

**Motion layout.** The grid was the wrong answer — two columns of fields read
as a form to fill in, not as settings to adjust. Now it's one column with the
same shape as the Device page: label → helper → control, a rule between
entries, capped at 680px because a slider stops being readable much past that.
Values moved onto the label line, where Carbon's slider puts its number input
anyway; that right-aligns every value in the list to one edge and keeps a
slider entry a line shorter. Sliders gained min/max end labels.

The live meter is full width now (bar 802px at 1280, 1122px at 1600, up from
~380) with a 32px track and a 22px readout.

Those two pull against each other in a 600px pane, so the lock state card moved
into the capability rail. It's a status readout that applies to both sections,
the rail had 400px going spare, and every pixel it doesn't spend in the content
column is one the settings list gets. Net: the meter block dropped 255px → 149px
while getting bigger, tap overflow 194px → 101px, lock 451px → 368px.

The lock panel does still scroll — 380px visible of 748px at 1280x720, 88px of
overflow at 1600x1000. Seven settings and three section headings in one column is
just tall; the Device page behaves the same way. If that's too much, the next
lever is collapsing the meter to the bar alone once you scroll, which needs a
real mechanism rather than more padding-shaving, so it's worth deciding
deliberately.

**Require flat** is back on the default toggle.

**Combo persistence is a firmware gap, written up in docs/combo-persistence.md.**
The `zmk.combos` subsystem is list/get/set plus a `combos_changed` notification —
there is no save or commit request anywhere in it, and `keymap.save_changes`
persists the keymap subtree only. So `set_combo` reaches the runtime array and
nothing Studio can call reaches storage. The note covers what firmware needs:
a settings handler modelled on ZMK's keymap.c; a stable `zmk_behavior_local_id_t`
rather than the `behavior_dev` pointer or Studio's per-build `behavior_id`, both
of which are meaningless after a reboot or a rebuild; resolving in `h_commit`
because behaviors aren't registered during `settings_load()`; `settings_delete`
on clear, or the old key positions come back at boot; an unsaved-state signal so
Studio can drop its client-side guess; and checking `settings_save_one` for
-ENOSPC so a combo that can never persist doesn't report success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…in fades

**Unlock table's doubled bottom edge.** Rules were on the bottom of every row,
so the last one landed on top of the container's own border. Moved to the top
of every row but the first. Most visible with a single row, which is the state
before a combo has been set up. Now 1px top and bottom, measured.

**Factory reset has a confirmation and a busy state.** Click once and the button
becomes Carbon's solid danger red reading "click again to erase everything",
with a Cancel and a line saying what goes; it disarms itself after 6s so a
stale red button isn't left lying around. Confirming disables it and shows
"Restoring…" plus "don't unplug the keyboard" for as long as the RPC takes,
which is the part that stops the double-clicking. `resetSettings` returns a
promise now so the button can await it. It also clears the motion and combo
dirty flags, which it wasn't doing — the header claimed unsaved changes for
subsystems that had just been wiped.

**Drawers resize by dragging their top edge**, keymap and lighting both, via a
shared ResizableDrawer. Height persists per panel and double-click restores the
default. `maxHeight: 70%` rather than clamping the stored number, so a height
saved on a tall window can't squash the canvas on a short one, and the user's
number is never quietly rewritten. `panel-resizing` on <html> suppresses the
canvas's 240ms scale easing during the drag — without it every frame restarts
the curve and the keyboard trails the pointer. The fit recalc also moved to the
leading edge of its per-frame throttle for the same reason.

**No more load-in fade** on keycap legends when switching layer (it was keyed on
the layer index, so all 100+ keycaps replayed at once), nor on the binding
picker's behavior list, hint pane, or hold-tap mode row.

**Content switchers default to Carbon's `sm`** (32px, was 40) with 12px inline
padding. Font size untouched at 14px; checked that no label truncates, including
the five-item language row.

Verified in the demo: single-row unlock table symmetric at 1px; reset states
idle → armed (solid #da1e28) → working (disabled, hint shown); both drawers
carry a handle with independently persisted heights and a 70% cap; zero
`animate-fade-in` elements anywhere; switcher items 32px at 14px with no
ellipsis.

One thing I could not verify here: the keyboard re-fitting continuously *during*
a drag. The Browser pane isn't compositing, so requestAnimationFrame and
ResizeObserver never deliver in it — proven by arming both and seeing zero
callbacks. The fit math and a recalc firing are confirmed (scale matched the
hand-computed height-bound fit), and it's the same path window resizing already
uses, but the per-frame updates need a look on a real display.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alt-0191
alt-0191 force-pushed the feat/motion-imu-and-frame-key branch from e7e3352 to cbabcc4 Compare August 24, 2026 11:04
@alt-0191
alt-0191 force-pushed the feat/motion-imu-and-frame-key branch from cbabcc4 to 50ae195 Compare August 24, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants