Skip to content

Key-picker rethink (#16): derive the behavior from filled slots (Steps 1–5)#34

Merged
numachang merged 26 commits into
mainfrom
feature/key-picker-rethink
Jun 10, 2026
Merged

Key-picker rethink (#16): derive the behavior from filled slots (Steps 1–5)#34
numachang merged 26 commits into
mainfrom
feature/key-picker-rethink

Conversation

@numachang

Copy link
Copy Markdown
Owner

Closes #16.

This is the culmination PR for the staged key-picker rethink: instead of
naming a behavior first and then filling its parameters, the binding editor now
lets you set what a key does and the behavior is derived. It merges the
long-lived integration branch (feature/key-picker-rethink) into main after all
five steps landed and were quality-confirmed.

The model — Normal / Hold mode

  • Normal — the key's base action, always present: a keycode (incl. a lone
    modifier), None, or Transparent.
  • Hold mode — an optional different action when held: a Modifier (→ &mt) or
    a Layer (→ &lt, or &mo when Normal is empty).
  • The derived behavior name is shown, not chosen. A pure, unit-tested model
    (slots.ts) maps slots ⇄ binding; the output is still a plain BehaviorBinding,
    so there are no firmware or RPC changes.

What landed (Steps 1–5)

  • Step 1 — persistent None / Transparent keycaps inside every key grid, so
    clearing a key never costs a tab round-trip.
  • Step 2 — the Normal / Hold mode slot surface derives &kp / &mt /
    &lt / &mo / &none; binding-lossless round trips, validated against firmware.
  • Step 3 — single-action behaviors become keycap tiles in the Normal grid's
    tab strip
    (a Mouse tab and a consolidated System tab); &sk / &kt become a
    family-generic Sticky / Toggle latch on the slot label; the flavor behaviors
    (Caps Word / Key Repeat / Grave-Escape) are one-tap tiles in Apps/Media/Special.
  • Step 4 — persistent layer switches (&to / &tog / &sl) on a Layer
    tab
    , with Sticky / Toggle reusing the same latch (the layer family).
  • Step 5drain the residual area rather than design a new hierarchy:
    custom hold-taps move to Hold mode with an identity selector (&mt vs
    homerow_mods, &lt vs a custom layer-tap), and custom tile-able behaviors
    gather in a Custom tab (recognized standards keep their curated homes). The
    residual "More behaviors" block auto-hides when empty and remains only as the
    escape hatch for the rare exotic custom that fits no shape.

Coverage & safety

  • Capability-aware throughout — tiles / derivations appear only for behaviors
    the connected firmware exposes.
  • No standard behavior becomes un-settable (design §4a): the residual block is
    drained, not deleted, so an exotic firmware-exposed custom stays settable.
  • The before-main blockers from the PR feat(behaviors): Step 2 — Normal/Hold-mode slot surface (#16) #29 review are resolved (design §8a): a
    non-core binding is named + shows its devicetree code, and the escape hatch is
    kept rather than silently dropped.

Verification

  • Local gate (intermediate PRs don't run CI; this one will): tsc --noEmit clean,
    eslint 0 errors, vitest run 144 passing.
  • Each surface manually verified in a real browser, including a split (Corne-style)
    demo fixture that exercises all four custom routes and an empty residual.

Notes

  • Design note (bilingual, English authoritative): docs/design/key-picker-rethink.md.
  • Built on ZMK Studio (zmkfirmware/zmk-studio,
    Apache-2.0) — thanks to the ZMK community. The "category tabs of tiles" direction
    is adjacent to upstream PR Grid Picker for HID Usage zmkfirmware/zmk-studio#159 (Grid Picker for HID Usage) and could be worth
    proposing upstream once it settles.

🤖 Generated with Claude Code

numachang and others added 25 commits June 6, 2026 14:20
…tep 2)

Add the pure data-model core for the slot-fill binding editor: fill a Tap
slot and a Hold slot, derive the behavior instead of naming it first.

- src/behaviors/slots.ts: Slots types, a capability-aware DerivationRegistry
  (resolved by displayName), and deriveBinding / bindingToSlots per the §2
  table. isModifierUsage decodes ZMK's implicit-modifier encoding (strips the
  high-byte mod bits before matching the eight base usages) so multi-modifier
  holds like LC(LSHFT) survive the binding -> slots -> binding round trip.
- Move validateBinding from BehaviorBindingPicker into parameters.ts (its only
  dependency, validateValue, already lives there) so the tests can assert every
  derived binding passes the firmware-facing check, and to drop a react-refresh
  lint warning.
- src/behaviors/slots.test.ts: 25 tests covering the §2 table, modifier
  detection, capability-awareness, binding-lossless round trips, and that every
  derived binding validates.

No firmware/RPC change: the output is still a BehaviorBinding. This is the
foundation for the slot UI; the existing chip picker is untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
…#16 step 2)

Add SlotBindingPicker: the user sets a always-shown **Normal** action (the key
grid, with the None/Transparent clear tiles and a collapsible right-side
"+ Send with" implicit-modifier column) and an optional **Hold mode** checkbox
that reveals a Modifier | Layer picker. The behavior is derived, not named:
Normal-only = Key Press, Normal+Hold(mod) = Mod-Tap, Normal+Hold(layer) =
Layer-Tap, Hold(layer) alone = Momentary Layer, empty = None.

- slots.ts: a lone modifier decomposes to Normal (it's a keycode, `&kp <mod>`),
  not Hold — so there's no modifier-vs-key heuristic and the round trip stays
  binding-lossless. Add modifierSetFromUsage / usageFromModifierSet for the
  multi-select Hold modifier picker (decodes ZMK's implicit-modifier encoding so
  multi-mod holds like LC(LSHFT) round-trip); drop the now-unused isModifierUsage.
- Only Hold mode carries an on/off toggle; Normal is always present (None is a
  value, not an off state). Labels are Normal / Hold mode — "Tap" is reserved for
  the derived names (Mod-Tap, Layer-Tap), since a plain &kp isn't short-press-only.
- HidUsagePicker gains a `modifiers` config (side / collapsible / label / hint) so
  the Tap-key implicit-mod column can sit on the right, collapsed, and not be
  confused with the Hold-mode modifier picker. Default unchanged for other callers.
- BehaviorBindingPicker hides the derivation-target chips (kp/mt/lt/mo) + the
  clear behaviors; the slot surface and residual "Not yet on the slot surface"
  chips edit one shared local state (no dual-edit sync).

The core behaviors (kp/mt/lt/mo/none/trans) are settable now; layer-in-Normal
(to/tog/sl) and the single-action tiles come in Steps 3-4. Coverage of every
standard ZMK behavior is verified in the design note.

Co-Authored-By: Claude <noreply@anthropic.com>
Rewrite §2 around the converged terminology: a always-present **Normal** slot
(the key's base action) and an optional **Hold mode** (a different action when
held). "Tap" is dropped from the slot labels — it wrongly implies short-press for
a plain &kp — and kept only in the derived names (Mod-Tap, Layer-Tap).

- §2a: why Normal/Hold mode, and why only Hold mode has an on/off toggle.
- §2b: the modifier/layer asymmetry — a lone modifier is a Normal keystroke
  (`&kp <mod>`), but a lone momentary layer is Hold mode (`&mo`), because
  modifiers are keycodes and layers are not; a layer in Normal is instead the
  persistent &to/&tog/&sl. Plus the Windows Sticky-Keys aside.
- §4a: coverage audit — every standard ZMK behavior stays settable (core via
  slots, the rest via the existing param UI / tiles); the model is additive.
- Update §4 (modifier-set helpers), Step 2/4, §8 (resolve the lone-modifier
  question), the status line, and the Japanese §2 mirror.

Co-Authored-By: Claude <noreply@anthropic.com>
…old when adding send-with (#16 step 2)

UI polish for the slot surface and the keymap keycaps, plus one real bug fix:

- HidUsageLabel: show a key's implicit "+ Send with" modifiers as small bordered
  badges to the left of the cap glyph (e.g. `LC(S)` reads "[Ctrl] S"). Strips the
  high-byte mod bits before resolving the label so the modified value no longer
  falls back to a raw `0x…`.
- SlotBindingPicker: the "+ Send with" implicit-modifier column is always shown
  (no longer collapsible) and the NORMAL reverse-lookup label spells out its
  modifiers (e.g. "L Ctrl + Keyboard S").
- Key / Keymap: tighten keycap padding, top-align header'd keys under the pill,
  give the hold preview the same weight/opacity as the tap (uniform legibility),
  and tune the hold/tap row spacing.
- behavior-short-names.json: concise header names so the behavior pill no longer
  truncates ("Mod-Tap", "Lay-Tap", "Sticky", "Caps", "Trans", "To Lyr", ...).

Bug fix: `modifiersChanged` in HidUsagePicker was memoized on `[value]` only,
capturing a stale `onValueChanged`. With the slot surface, adding a "+ Send with"
modifier to a Mod-Tap/Layer-Tap reverted to the pre-hold binding and dropped the
hold. Adding `onValueChanged` to the deps keeps the hold (and clears the stale
react-hooks warning).

Co-Authored-By: Claude <noreply@anthropic.com>
… all send-with badges, fix stale doc comment (#16 step 2)

- HidUsageLabel: remove the leftover `EXPERIMENT:` comment; show all send-with
  badges instead of `slice(0, 3)` so a 4-modifier ("hyper") key no longer
  silently drops its GUI badge.
- slots.ts: rewrite the header-comment table to the converged Normal / Hold-mode
  model (it still described the old Tap/Hold rows incl. the now-wrong
  "(empty) + modifier → &kp <mod>" — a lone modifier is a Normal keystroke, §2b).
- docs(design) §8a: record the before-main blockers raised in review — the slot
  surface rendering for non-core bindings (empty grid silently overwrites to
  &kp), and the silent `deriveBinding → null` no-op.

Co-Authored-By: Claude <noreply@anthropic.com>
feat(behaviors): Step 2 — Normal/Hold-mode slot surface (#16)
)

Tile-ify the single-action behaviors and fold them into the Normal-slot key
grid's tab strip, building toward the converged tile surface incrementally.

- tiles.ts: tileBindingsFor explodes a behavior into one keycap tile per
  finished (param1,param2) binding; null when a param needs a rich picker.
- Mouse + a consolidated System tab (Bluetooth/Output/External Power/System)
  live in the Normal grid's tab strip between International and Other; picking a
  behavior tile sets the whole binding (HidUsagePicker extraTabs/onSelectBinding).
- Collapse the two basic-tier tabs into one host-adaptive "Basic" tab
  (ansi->ANSI, iso->ISO+NUHS/NUBS, jis->JIS, ko->KO); ISO keys appear once that
  layout is selected and stay searchable.
- "+ Send with" moves from a side column to an inline row below the grid
  (Basic / Function+Nav only); drop the "KEY" header and host the search on the
  NORMAL row; the filter now spans the behavior tiles too.
- Slot label shows the devicetree code (formatBinding: &mt LSHFT RET, &mo 1,
  &reset, ...) in a boxed chip, host-layout-aware (JIS Shift+2 = ").
- Keycode-picker DRY cleanup: tab-name constants; promote consumer media keys to
  Apps/Media/Special; group the Other combobox into labelled sections
  (other-sections.ts).

Tests: tiles / formatBinding / other-sections (121 pass). Snapshot files left out.

Co-Authored-By: Claude <noreply@anthropic.com>
- formatBinding: when param1 matches no metadata set, emit just the behavior
  reference rather than borrowing the first set's params (avoids showing a
  param2 that doesn't apply). [review #1]
- Behavior tiles are single-select, so make them `role="radio"` inside a
  `radiogroup` (consistent with the residual behavior chips) instead of
  `aria-pressed` toggles; native button + `title` keeps the visible label and
  drops the now-redundant tooltip. Search-result tiles get their own
  radiogroup. [review #2]
- Annotate the two intentional exhaustive-deps effects with
  `eslint-disable-next-line` so the warnings clear and the intent is explicit.
  [review #4]

Review #3 (declaration-order of the two tab-select effects) was info-only / no
change requested; left as-is.

Co-Authored-By: Claude <noreply@anthropic.com>
feat(behaviors): Step 3a — keycap behavior tiles in the Normal grid (#16)
…les (#16)

Move the Basic "flavor" behaviors off the residual chip area and onto the
Normal slot surface, so the residual "More behaviors" Basic group is gone.

- Sticky Key / Key Toggle become a "Latch" checkbox pair on the SLOTS row
  that wraps the Normal key (&kp K -> &sk K / &kt K). The affected key is
  still picked from the grid; the two are mutually exclusive and disabled
  while Hold mode is on. While a latch is active the grid is key-only: the
  Mouse / System tabs and the one-tap flavor tiles are disabled (greyed),
  but None / Transparent and the keycodes stay live.
- Caps Word / Key Repeat / Grave-Escape become one-tap keycap tiles in a
  2-column block on the Basic tab, with None / Transparent pinned to the
  bottom row. Tiles are 1U and wrap at the largest font that still fits.
- tiles.ts: isParamTileable detects single-key-param behaviors; drop the
  now-unused param-tile helper.
- keymap-parser: add the missing `kt` alias and correct `gresc` to
  "Grave/Escape" so &kt / &gresc render in the slot code label.
- mock fixtures: expose Key Toggle / Key Repeat / Grave-Escape on the demo.

tsc + lint clean; vitest 126 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
…s warnings

PR #32 review: isBasicFinishedTile / isBasicWrapper were used in the
grouping useMemos without being listed as deps, leaving three bare
react-hooks/exhaustive-deps warnings (inconsistent with the file's other
deliberately-suppressed hook). Wrap both in useCallback([hiddenIds]) and
list them in the dependent memos so the rule is satisfied without
suppression and the memos still only recompute when hiddenIds changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Step 3b — Sticky Key / Key Toggle latches + Basic flavor tiles (#16)
Step 3b shipped &sk/&kt as a key-state latch (a checkbox pair on the slot
label that wraps the Normal key), not the originally-planned parameterised
tile. Update §3c, the §5 Step 3b plan, the status block, and the Japanese
mirror to match; note the deviation and the keymap-parser kt/gresc fix.

Co-Authored-By: Claude <noreply@anthropic.com>
…16)

Move To / Toggle / Sticky Layer off the residual chip area onto the Normal
grid's tab strip, as a "Layer" tab (between International and Mouse) — the same
behavior-tab pattern as Step 3a's Mouse / System. Each sub-mode is a labelled
section exploded into one tile per layer; picking a tile sets the whole binding
(&to / &tog / &sl <layer>). The reverse-lookup label, tab auto-jump and keyOnly
disabling are all reused, so slots.ts is unchanged.

- tiles.ts: isLayerTileable / layerTileBindingsFor (unit-tested).
- BehaviorBindingPicker: build the Layer tab; drop these from the residual area
  (with only standard behaviors the residual "More behaviors" is now empty and
  hidden).
- mock fixtures: add Sticky Layer so the demo shows all three sub-modes.
- docs/design: record Step 4 as built (Layer tab, deviation from the slot-model
  framing; custom hold-tap identity deferred to a later step, §8 Q2).

tsc + lint clean; vitest 131 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
…amilies (#16)

Step 4 follow-up: &sl/&tog are the layer analogue of &sk/&kt, so fold the Layer
tab's Toggle/Sticky sections into the existing latch instead of duplicating a
per-layer tile grid three times.

- The Layer tab is now a single "To Layer" section; Sticky/Toggle are applied
  via the latch.
- The latch checkboxes are a generic "Sticky / Toggle" pair and family-aware:
  a key binding maps them to &sk/&kt over &kp, a layer binding to &sl/&tog over
  &to. toggleLatch swaps the behavior id and carries param1; a key-latch unwrap
  keeps the &kp/&none distinction via the slot model.
- BindingTileTab.matchFamily lets &sl/&tog highlight their layer's To-Layer tile
  and jump to the Layer tab.
- keyOnly applies only to a key latch; a layer latch leaves the Layer tab live.

tsc + lint clean; vitest 131 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
…#16)

A key latch (&sk/&kt) put the grid in keyOnly mode, which also disabled the
Layer tab — so you couldn't retarget the latch onto a layer. A layer is a valid
latch target, so:

- keyOnly no longer disables latch-target tabs (those carrying matchFamily, i.e.
  the Layer tab); Mouse / System (not latch targets) and the flavor tiles still
  grey out, and None / Transparent stay live.
- Picking a To-Layer tile while latched preserves the latch and retargets it to
  that layer (&kt M -> click Fn -> &tog 1), mirroring how the latch carries its
  param across the family.

vitest 131 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
)

On the keymap image a paramless behavior had nothing but its name to show, which
rendered as a "behavior badge" pill — making it look like a special setting
rather than a key. Now:

- None renders as a blank cap and Transparent as a ▽ glyph (matching the
  picker's clear tiles).
- Other paramless behaviors (Caps Word / Key Repeat / Grave-Escape / Reset /
  Bootloader / Studio Unlock / Soft Off) render their short name as a centred
  legend, set smaller than a keycode so it never competes with a plain letter.
- Behaviors that take a parameter keep the pill — it identifies the behavior
  above its value (Mod-Tap, Momentary/To/Toggle/Sticky Layer, Sticky Key, ...).

Key gains a hasParam prop (from Keymap); a single-glyph legend (▽) reads larger.

vitest 131 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
A paramless behavior's name is its whole legend; size it down by length so it
stays under a plain keycode legend and within a 1U cap: <=4 chars (▽ / None /
Caps / Boot) 14px, 5-6 (Reset / Repeat / Unlock) 12px, 7+ (Grv/Esc / SoftOff)
10px. Explicit px on purpose — this project overrides theme.fontSize to only
`xs: 0.4rem` (text-xs ≈ 6.4px, text-sm/base undefined → inherit), so the named
steps aren't reliable px.

Co-Authored-By: Claude <noreply@anthropic.com>
…" is gone (#16)

The editor grid reserved a fixed 34em row for the binding picker — sized for the
old design with the residual "More behaviors" area. With that gone (Steps 3b/4)
the slot surface is ~28em, leaving dead space below Send With. Reduce the row to
28em so it fits. Kept fixed (not auto) so selecting a key doesn't resize the
keyboard image above; taller custom-behavior cases still scroll within the area.

Co-Authored-By: Claude <noreply@anthropic.com>
#16)

Caps Word / Key Repeat / Grave-Escape are rarely used, so move them off the
Basic tab's right column and append them into the Apps/Media/Special keycode tab
(their own row after the media keys; jump + highlight + key-only disabling
reused). The Basic tab keeps just the keycodes plus the unchanged None /
Transparent caps. Renames basicBehaviorTiles -> specialTiles and drops the now-
unused tile "column" variant.

tsc + lint clean; vitest 131 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
)

Route every firmware-exposed behavior to a shaped home so the residual
"More behaviors" area empties for ordinary keyboards:

- Custom hold-taps move to Hold mode with an identity selector: the &mt
  shape (mod, key) in the Modifier sub-mode, the &lt shape (layer, key) in
  Layer. When a family has more than one member the surface offers the
  choice (default &mt / &lt); it shows before a modifier/layer is picked
  and the choice is held locally so it can precede the value (§8 Q2).
- Custom tile-able behaviors gather in a new "Custom" tab; recognized
  standards keep their curated homes (Apps/Media/Special / System / Mouse),
  unrecognized customs go to Custom.
- UI polish on the same surface: None/Transparent share the flavor row
  (no stray wrap), and the single-section Layer tab drops its heading.

slots.ts gains modTapIds/layerTapIds and a shared Slots.holdTapId; the
residual block still auto-hides when empty (drain, not delete) so the
exotic custom that fits no shape stays settable (§4a).

Co-Authored-By: Claude <noreply@anthropic.com>
A 42-key (3x6+3) split board carrying one behavior of each custom class —
a custom mod-tap, a custom layer-tap, a parameterless custom, and a custom
enum — so the Hold-mode identity selectors, the Custom tab, and an empty
residual area are all exercisable in the demo.

Co-Authored-By: Claude <noreply@anthropic.com>
Update the key-picker rethink note (English + Japanese mirror): Step 5 is
effectively complete by draining the residual area rather than designing a
new hierarchy; §8 Q2/Q4 and the §8a before-main blockers are resolved; the
flavor tiles' move to Apps/Media/Special and the editor sizing recorded.

Co-Authored-By: Claude <noreply@anthropic.com>
…ix fixture comments (#16)

- Reseed the local hold-tap identity only when the bound *identity* changes
  (deps on `slots.holdTapId`), not on every binding field. A custom identity
  picked before adding a modifier now survives changing the Normal key instead
  of snapping back to the default `&mt`/`&lt` (review item 2). The Hold panel
  still collapses on a Normal-key change via the separate, pre-existing
  `holdModeOn` reset, but the choice is preserved and re-applied.
- Correct stale demo-fixture comments: a parameterless custom (Num Word) routes
  to the Custom tab (unrecognized → classify "Other"), not Apps/Media/Special
  (review item 1).

Co-Authored-By: Claude <noreply@anthropic.com>
Step 4 + Step 5: Layer tab and draining the residual area (#16)
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 10, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
zmk-studio-tweaks 9179241 Jun 10 2026, 07:25 AM

@numachang

Copy link
Copy Markdown
Owner Author

Review notes — verified locally (HEAD c7880f0)

Re-ran the gate against this branch; matches the PR claims. No blockers.

  • tsc --noEmit — clean
  • eslint src0 errors (37 warnings, all in files this PR doesn't touch)
  • vitest run144 passing / 11 files
  • Merge is conflict-clean (tsc + tests passing confirms no stray markers)
  • modifierSetFromUsageusageFromModifierSet round-trips (checked Ctrl+Shift by hand)
  • "Drain, not delete" safety holds: an exotic custom whose param1 is neither hidUsage nor layerId stays settable as a residual chip, per §4a

Non-blocking notes (optional, fine to defer)

  1. enumerateParam explodes range into one tile per value (tiles.ts). Correct for small enums (BT profiles, outputs). A future firmware-extension behavior exposing a wide range would render a very large tile grid — no current behavior triggers this. Known edge, not a fix request.
  2. Shape detection reads only metadata[0] (slots.ts, isModTapShape / isLayerTapShape). Fine for the standard single-set behaviors; a custom hold-tap carrying the matching shape only in a later set wouldn't be recognized as a family member. Acceptable edge.
  3. Mouse classification is a name heuristic (BehaviorBindingPicker.tsx): displayName.toLowerCase().includes("mouse"). Intentional — a custom behavior with "mouse" in its name lands on the Mouse tab regardless of shape.

All within the design note's stated scope.

…16)

Add §8b noting three edges no standard behavior triggers (raised in the
PR #34 review, all non-blocking and in-scope): wide range params explode
into many tiles; shape detection reads only metadata[0]; Mouse routing is
a displayName heuristic (intentional).

Co-Authored-By: Claude <noreply@anthropic.com>
@numachang numachang merged commit 200f24b into main Jun 10, 2026
1 of 2 checks passed
@numachang numachang deleted the feature/key-picker-rethink branch June 10, 2026 07:27
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.

Rethink the key-assignment UI: derive the behavior from filled slots instead of selecting it first

1 participant