From 02b2195461b3abeafd6282b12fee5d9eefa217e2 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Mon, 6 Jul 2026 09:13:01 +0200
Subject: [PATCH] feat(hotkeys): end-user keymap customization (user remapping)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The second v1.14-deferred accelerator. Lets a USER (not just the developer)
remap shortcuts and have the choice persist (research-10) — pure composition
over the record defaults, NO change to the closed CommandRecord. The
reproducible zero-dependency equivalent already shipped as
docs/hand-written-keymap-override.md (v1.14); this is the tested package layer.
- bindHotkeys(registry, { keymap }) — an optional UserKeymap (sparse
commandId → { replace | add | remove }) layered over each record's default
keybinding at bind time. Default empty → existing callers unaffected.
useHotkeys (React) forwards it and re-binds on keymap identity change (live
remap UI).
- Pure keymap.ts: resolveKeys (override resolution); collectBindings gained a
keymap param and orders user-touched bindings first on a shared key; and
detectConflicts (definite/possible same-key clashes).
- Capture/display primitives: tokenFromEvent (press-to-record), isReservedCombo
/ RESERVED_COMBOS, formatKeybinding, layoutLabel (getLayoutMap w/ fallback).
- keymap.ts is tinykeys-free; bind.ts composes it (two-layer discipline).
- +14 tests (23 in the package; the 9 pre-existing bind tests still pass →
backward-compatible). minor changeset (1.0.1 → 1.1.0).
Left to the app (primitives cover it): the full remap-UI React component,
preset packs, cloud sync, the WCAG "disable character-key shortcuts" toggle.
Claude-Session: https://claude.ai/code/session_016teVFousRsWNhiiPNpG1p9
---
.changeset/hotkeys-keymap.md | 26 +++
.claude/skills/acture-hotkeys/SKILL.md | 2 +-
docs/hand-written-keymap-override.md | 17 +-
docs/roadmap.md | 15 +-
packages/hotkeys/README.md | 30 ++++
packages/hotkeys/src/bind.ts | 44 +++--
packages/hotkeys/src/index.ts | 26 ++-
packages/hotkeys/src/keymap.test.ts | 135 ++++++++++++++++
packages/hotkeys/src/keymap.ts | 215 +++++++++++++++++++++++++
packages/hotkeys/src/react.ts | 8 +-
10 files changed, 491 insertions(+), 27 deletions(-)
create mode 100644 .changeset/hotkeys-keymap.md
create mode 100644 packages/hotkeys/src/keymap.test.ts
create mode 100644 packages/hotkeys/src/keymap.ts
diff --git a/.changeset/hotkeys-keymap.md b/.changeset/hotkeys-keymap.md
new file mode 100644
index 0000000..eab8db2
--- /dev/null
+++ b/.changeset/hotkeys-keymap.md
@@ -0,0 +1,26 @@
+---
+"acture-hotkeys": minor
+---
+
+Add **end-user keymap customization** — let a *user* (not just the developer)
+remap shortcuts and have the choice persist (research-10). Pure composition over
+the record defaults: no change to the closed `CommandRecord`.
+
+- **`bindHotkeys(registry, { keymap })`** — an optional `UserKeymap` (a sparse
+ `commandId → { replace | add | remove }` override map) layered over each
+ record's default `keybinding` at bind time. Default: an empty keymap, so
+ existing callers are unaffected. `useHotkeys` (React) forwards it and re-binds
+ on keymap identity change, so a live remap UI takes effect.
+- **`resolveKeys(cmd, keymap)`** — the pure override resolution; `collectBindings`
+ now accepts a keymap and orders user-touched bindings first on a shared key
+ (VS Code's "user override wins, scope still respected").
+- **`detectConflicts(registry, keymap?)`** — reports same-key clashes
+ (`definite` when no sharer is `when`-scoped, else `possible`) — the
+ highest-value remap affordance.
+- **Capture / display primitives:** `tokenFromEvent` (press-to-record),
+ `isReservedCombo` / `RESERVED_COMBOS` (reject browser-owned combos),
+ `formatKeybinding` (⌘/Ctrl labels), `layoutLabel` (`getLayoutMap` for physical
+ keys, with fallback).
+
+The reproducible zero-dependency equivalent is `docs/hand-written-keymap-override.md`;
+the full remap-UI React component stays the app's to build (the primitives cover it).
diff --git a/.claude/skills/acture-hotkeys/SKILL.md b/.claude/skills/acture-hotkeys/SKILL.md
index 842896c..683725f 100644
--- a/.claude/skills/acture-hotkeys/SKILL.md
+++ b/.claude/skills/acture-hotkeys/SKILL.md
@@ -45,7 +45,7 @@ This is a distinct, *composed* layer on top of the binder above — added when a
- **Press-to-record capture** is the loved UX (Obsidian/JetBrains/games): listen → build the tinykeys token → reject browser/OS-reserved combos (`Cmd+W`, `Ctrl+T`, …) → conflict-check → commit to the keymap store. Persist the `UserKeymap` as JSON (localStorage/IndexedDB/server row); `$mod` keeps tokens portable across a user's machines.
- **WCAG 2.1.4 (Level A) is mandatory once you ship single-key bindings** (`"d"`, `"g i"`): ship a global "disable character-key shortcuts" toggle or default single-key bindings off/focus-scoped. This is a legal requirement, not a nicety (research-10 §3.7).
-**Agent-written vs package-reuse** applies here too, decided per-piece: the `UserKeymap` + `resolveKeys` + keymap-aware `collectBindings` + conflict pass is ~50 hand-writable lines (the reference doc) that a project can own outright; the UI-and-browser-bound pieces — the press-to-record capture component, the `getLayoutMap()` display layer, preset loaders — are where an `acture-hotkeys` helper would earn its keep *if one ships*. Surface that split; it's the user's call (dev-tool-first). Handle every item on the research-10 §5.4 web-gotcha checklist (IME composition, macOS ⌘ keyup, `keydown` not `keypress`, `preventDefault` discipline).
+**Agent-written vs package-reuse** applies here too, decided per-piece — and the package accelerator **now ships**. The `UserKeymap` + `resolveKeys` + keymap-aware `collectBindings` + conflict pass is ~50 hand-writable lines (the reference doc) a project can own outright; OR install `acture-hotkeys`, which now exports the whole layer: `bindHotkeys(registry, { keymap })` (+ React `useHotkeys` re-binds on keymap change), `resolveKeys`, `detectConflicts`, and the capture/display primitives `tokenFromEvent` / `isReservedCombo` / `RESERVED_COMBOS` / `formatKeybinding` / `layoutLabel`. The **full remap-UI React component** stays the app's to build (the primitives cover it). Surface the split; it's the user's call (dev-tool-first). Handle every item on the research-10 §5.4 web-gotcha checklist (IME composition, macOS ⌘ keyup, `keydown` not `keypress`, `preventDefault` discipline).
## When working ON `acture-hotkeys`
diff --git a/docs/hand-written-keymap-override.md b/docs/hand-written-keymap-override.md
index 49db4c8..dd0f6b7 100644
--- a/docs/hand-written-keymap-override.md
+++ b/docs/hand-written-keymap-override.md
@@ -279,13 +279,16 @@ YAGNI applied softly — add these only when a real need appears:
## Faithfulness note
The shapes here — `UserKeymap`, `KeybindingOverride`, `resolveKeys`,
-`collectBindings`, `detectConflicts` — are deliberately the shapes an
-`acture-hotkeys` customization helper would export, and `collectBindings` is a
-superset of the one already in `packages/hotkeys/src/bind.ts` (append a `km`
-argument *after* `tiers`; default it to `EMPTY_KEYMAP` so existing
-`collectBindings(registry, tiers)` calls are unaffected). An agent
-that hand-writes from this doc and later installs the helper finds the migration
-mechanical. If the package contract changes, this doc changes with it.
+`collectBindings`, `detectConflicts` — are deliberately the shapes
+**`acture-hotkeys` now exports** (`bindHotkeys({ keymap })`, `resolveKeys`,
+`detectConflicts`, plus the capture/display primitives `tokenFromEvent` /
+`isReservedCombo` / `formatKeybinding` / `layoutLabel`), and its `collectBindings`
+is a superset of this one (`collectBindings(registry, tiers?, keymap?)`; the
+`keymap` argument defaults to `EMPTY_KEYMAP` so existing
+`collectBindings(registry, tiers)` calls are unaffected). Hand-write from this
+doc, or install the package — the per-consumer choice (dev-tool-first). An agent
+that hand-writes and later installs finds the migration mechanical. If the
+package contract changes, this doc changes with it.
## See also
diff --git a/docs/roadmap.md b/docs/roadmap.md
index eb03402..2004805 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -4,7 +4,7 @@ The live forward-planning surface. `docs/v1_plan.md` and `docs/implementation_pl
**How work proceeds:** phases are over. Work is small, tracked increments. Each picks one or two items from "Next" or "Deferred", ships them, updates this file, and replaces `docs/next_session.md` with the following handoff.
-Last updated: **2026-07-06** (v1.16 — acture-mcp read side completed with the portable `getState` tool; follows v1.15's resources projection).
+Last updated: **2026-07-06** (v1.17 — acture-hotkeys end-user keymap customization, the second v1.14-deferred accelerator).
---
@@ -147,6 +147,19 @@ Completes the read side started in v1.15. MCP **resources** are the correct read
---
+### v1.17 — acture-hotkeys: end-user keymap customization — complete (this increment)
+
+The second v1.14-deferred accelerator. Lets a **user** (not just the developer) remap shortcuts and have the choice persist (research-10) — pure composition over the record defaults, **no change to the closed `CommandRecord`**. The reproducible zero-dependency equivalent already shipped as `docs/hand-written-keymap-override.md` (v1.14); this is the tested `acture-hotkeys` accelerator.
+
+- **`bindHotkeys(registry, { keymap })`** — an optional `UserKeymap` (sparse `commandId → { replace | add | remove }`) layered over each record's default `keybinding` at bind time. Default empty → existing callers unaffected. `useHotkeys` (React) forwards it and re-binds on keymap identity change, so a live remap UI takes effect.
+- **Pure layer (`keymap.ts`):** `resolveKeys(cmd, keymap)` (override resolution); `collectBindings` gained a keymap param and orders user-touched bindings first on a shared key (VS Code's "user override wins, scope still respected"); `detectConflicts(registry, keymap?)` (`definite`/`possible` same-key clashes — the highest-value remap affordance).
+- **Capture / display primitives:** `tokenFromEvent` (press-to-record), `isReservedCombo` / `RESERVED_COMBOS` (reject browser-owned combos), `formatKeybinding` (⌘/Ctrl labels), `layoutLabel` (`getLayoutMap` for physical keys, with fallback).
+- **`McpToolDescriptor`-style discipline held:** the pure `keymap.ts` is tinykeys-free; `bind.ts` composes it. +14 tests (23 in the package; the 9 pre-existing bind tests still pass — backward-compatible). `minor` changeset (`acture-hotkeys` 1.0.1 → 1.1.0).
+- **Left to the app (the primitives cover it):** the full remap-UI React component, preset packs, cloud sync, WCAG "disable character-key shortcuts" toggle — documented, YAGNI-gated (research-10 §5 "deliberately omits").
+- **Still deferred:** the `sideEffect`/`requiresConfirmation` closed-surface question (the confirmation-gate work).
+
+---
+
## Next
**The autonomous v1.12 + v1.13 chain is complete.** The remaining post-v1 items need user direction; the rewritten `docs/next_session.md` surfaces them. The candidates are: **`acture-state-jotai`** (atom-tree ↔ flat-state bridge is non-trivial per research-3; the adapter may not implement `PatchCapableAdapter` cleanly), **`acture-state-valtio`** (proxy-to-patch translation is non-trivial). (**`acture-sandbox`** is no longer a candidate — its isolation-only seam shipped in the extension-system increment; see below.) Pull-forward decisions are the user's; surface options with honest trade-offs when scheduled.
diff --git a/packages/hotkeys/README.md b/packages/hotkeys/README.md
index ce16e58..58bdadb 100644
--- a/packages/hotkeys/README.md
+++ b/packages/hotkeys/README.md
@@ -59,6 +59,36 @@ registry.register(commandB); // keybinding: 'g', when: '!editor.focused'
If you want to *override* a base binding from a plugin, explicitly `unregister(id)` the base command first.
+## End-user customization — remapping shortcuts
+
+Let a **user** (not just the developer) remap shortcuts and have the choice persist. The record's `keybinding` stays the *developer default*; a sparse **`UserKeymap`** overrides it at bind time — pure composition, **no change to the `CommandRecord`** ([research-10](https://github.com/thorwhalen/acture/blob/main/docs/research/acture_research_10%20--%20End-User%20Keyboard-Shortcut%20Customization.md); reproducible core in [`docs/hand-written-keymap-override.md`](https://github.com/thorwhalen/acture/blob/main/docs/hand-written-keymap-override.md)).
+
+```ts
+import { bindHotkeys, type UserKeymap } from 'acture-hotkeys';
+
+const keymap: UserKeymap = {
+ version: 1,
+ overrides: {
+ 'editor.save': { kind: 'replace', keys: ['$mod+s'] }, // rebind
+ 'editor.format': { kind: 'add', keys: ['$mod+Shift+f'] }, // add a second binding
+ 'app.help': { kind: 'remove' }, // unbind
+ },
+};
+
+const stop = bindHotkeys(registry, { keymap }); // omit → record defaults, unchanged
+```
+
+`useHotkeys` forwards `keymap` and re-binds on its identity change, so a live settings UI takes effect. Persist the `UserKeymap` as JSON (localStorage / a server row); `$mod` keeps tokens portable across a user's machines.
+
+Build the remap UI from the exported primitives:
+
+- **`detectConflicts(registry, keymap?)`** → same-key clashes (`definite` / `possible`), for the "Already assigned to X — Reassign / Keep both / Cancel" flow.
+- **`tokenFromEvent(event)`** → a tinykeys token from a `keydown` (press-to-record); **`isReservedCombo(token)`** rejects browser-owned combos (`$mod+w`, `$mod+t`, …) that `preventDefault` can't reclaim.
+- **`formatKeybinding(token)`** → a ⌘/Ctrl label; **`layoutLabel(code)`** → a layout-correct label for physical (`event.code`) bindings via `navigator.keyboard.getLayoutMap()` (with fallback).
+- **`resolveKeys(cmd, keymap)`** / **`collectBindings(registry, tiers, keymap)`** → the resolution, if you build a custom binder.
+
+> **WCAG 2.1.4 (Level A):** if you ship single-key bindings (`"g i"`, `"d"`), also ship a "disable character-key shortcuts" toggle or default them off/focus-scoped — a legal requirement, not a nicety.
+
## Input-aware default
By default, `bindHotkeys` skips firing when the target is an ``, `