Key-picker rethink (#16): derive the behavior from filled slots (Steps 1–5)#34
Merged
Conversation
…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 < shape (layer, key) in Layer. When a family has more than one member the surface offers the choice (default &mt / <); 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`/`<` (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)
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
zmk-studio-tweaks | 9179241 | Jun 10 2026, 07:25 AM |
Owner
Author
Review notes — verified locally (HEAD
|
…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>
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) intomainafter allfive steps landed and were quality-confirmed.
The model — Normal / Hold mode
modifier), None, or Transparent.
&mt) ora Layer (→
<, or&mowhen Normal is empty).(
slots.ts) maps slots ⇄ binding; the output is still a plainBehaviorBinding,so there are no firmware or RPC changes.
What landed (Steps 1–5)
clearing a key never costs a tab round-trip.
&kp/&mt/</&mo/&none; binding-lossless round trips, validated against firmware.tab strip (a Mouse tab and a consolidated System tab);
&sk/&ktbecome afamily-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.
&to/&tog/&sl) on a Layertab, with Sticky / Toggle reusing the same latch (the layer family).
custom hold-taps move to Hold mode with an identity selector (
&mtvshomerow_mods,<vs a custom layer-tap), and custom tile-able behaviorsgather 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
the connected firmware exposes.
drained, not deleted, so an exotic firmware-exposed custom stays settable.
non-core binding is named + shows its devicetree code, and the escape hatch is
kept rather than silently dropped.
Verification
tsc --noEmitclean,eslint0 errors,vitest run144 passing.demo fixture that exercises all four custom routes and an empty residual.
Notes
docs/design/key-picker-rethink.md.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