From 9a540c4da0c8ba9975efb0bab98e9088ce34becd Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 23 Aug 2026 16:13:09 -0400 Subject: [PATCH] docs(product): design leader/mnemonic keyboard chords, focus-context-gated (RIG-2484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designs adoption of Linear's leader/mnemonic keyboard-chord model in Compass, focus-context-gated. Matt ratified the leader-vs-modifier fork (2026-08-23); this record designs the HOW and ships as its own PR to freeze on merge, per the design workflow. ## What this designs Builds on the SHIPPED discoverability net (all merged to main): RIG-2482 `?` overlay (#526), RIG-2483 Cmd/Ctrl+K palette + point-of-use chips (#529), RIG-2529 tier-3 scope gate (#519). Every chord this record adds is already findable (overlay) and has a fallback (palette). - **A1** — the collision guard is already built: modifier-less chords inherit the editable-target guard (`dispatch.ts:117-122`); focus context, not a per-route switch, is the surface-awareness mechanism (Linear/Slack/Zulip converge). Wave 1 extends the guard to `` arm-guard) → T4 (dispatcher doc header + ledger). Each task carries red-green tests; the timeout test uses Bun fake timers (verified to fire `setTimeout` under the pinned Bun 1.4.0, so no clock-injection seam is added). ## Process - Red-teamed by a design-critic pass (0 blocking-fork; 8 improvements + 4 nits folded, including the ``/`combobox`/`listbox`/`menu`), so focus context — not a per-route switch — is the surface-awareness mechanism (Linear/Slack/Zulip converge here); "lighter on comms" is EMERGENT (a focused composer dormant-gates bare chords), never a per-route disable flag. Composes with the three-tier resolution (DL-222) unchanged. | Active (Matt, 2026-08-23) | [leader chords §A1](compass-leader-chords/design.md#a1--the-collision-guard-is-already-built-focus-gating-via-the-editable-target-guard) | +| DL-249 | A leader sequence is authored as space-separated segments in the EXISTING `KeymapEntry.chord` string (`"G B"`), no new field — collision-free because the Space key is already normalized to the token `"Space"` (`dispatch.ts:45`), so no event-derived chord contains a space and sequence rows are inert in the `DEFAULT_KEYMAP.filter` (`dispatch.ts:112-114`); the leader-prefix set is DERIVED from the table via `leaderPrefixes`, never hard-coded; authoring rules (T1 invariant test): exactly two modifier-less segments, and a leader prefix never doubles as a complete single chord. A parallel `sequence?: string[]` field was rejected (forks every consumer for zero gain). | Active (Matt, 2026-08-23) | [leader chords §A2](compass-leader-chords/design.md#a2--sequence-authoring-space-separated-segments-in-keymapentrychord) | +| DL-250 | The leader runtime is pending-leader closure state INSIDE the one `installKeymap` keydown handler (never a second listener, honoring DL-223): guard-FIRST ordering (a modifier-less key on an editable/interactive target types, never arms — the guard moves ahead of the `matching.length===0` early return at `dispatch.ts:115`), arm on a leader-set hit (`!event.repeat`) with a `LEADER_TIMEOUT_MS` disarm timeout, completion resolves the two-segment chord through the UNCHANGED three tiers (DL-222), dead sequences fall through RE-ENTERING arming (a re-pressed leader re-arms), Escape disarms, Mod-chords disarm-and-run, the uninstaller clears the timer. A separate leader-mode dispatcher was rejected (duplicate guard + tier logic). | Active (Matt, 2026-08-23) | [leader chords §A3](compass-leader-chords/design.md#a3--the-leader-runtime-pending-state-inside-the-one-keydown-handler) | +| DL-251 | A leader sequence is NEVER written into `aria-keyshortcuts` (WAI-ARIA has no sequential-press grammar — `"G B"` reads as "G OR B"): the shipped scanners harden ASYMMETRICALLY (refining DL-234's `shortcutFor` contract) — `shortcutForAria` (`keymap.ts:70-76`) SKIPS sequence rows so a sequence-only command emits no attribute, while `shortcutFor` (`keymap.ts:57-63`) FORMATS them via one pure `formatChordForDisplay` (`"G then B"`) so point-of-use display (sidebar `title`, palette chip) is preserved. A dual-bound command keeps its modifier chord in both. Minting parallel replacement helpers was rejected (leaves the shipped ones emitting the bug). | Active (Matt, 2026-08-23) | [leader chords §A5](compass-leader-chords/design.md#a5--display-and-aria-keyshortcuts-for-sequences) | +| DL-252 | Wave-1 leader allocation is `G B`/`G L`/`G D`/`G S` → view.bridge/backlog/done/settings — all four commands ALREADY registered by the shipped spine (`spine.ts:79-126`) and wired (`store.ts:1973-1980`), so wave 1 adds ONLY keymap rows (no spine/store change); `view.agentWorkspace` gets no `G A` (spine registers no such command). Zero bare letters in wave 1 (the board's verbs are group-relative; Linear's a/s/p act on issue fields Compass's board doesn't yet edit). Coexists additively with all existing `Mod+*` bindings (DL-152); deprecating overlapping modifier chords is a deferred later call (record OQ5). | Active (Matt, 2026-08-23) | [leader chords §A4](compass-leader-chords/design.md#a4--bare-letter-and-sequence-allocation-wave-1) | ## Bridge spawn control diff --git a/docs/designs/product/compass-leader-chords/design.md b/docs/designs/product/compass-leader-chords/design.md new file mode 100644 index 00000000..ef010913 --- /dev/null +++ b/docs/designs/product/compass-leader-chords/design.md @@ -0,0 +1,575 @@ +# Design: Leader/Mnemonic Keyboard Chords, Focus-Context-Gated (RIG-2484) + +Status: Draft + +Parent: RIG-1661 (keyboard-first product). Builds on the SHIPPED discoverability +net — all three impl PRs are merged to `main`: RIG-2482 `?` overlay (PR #526), +RIG-2483 Cmd+K palette + point-of-use chips (PR #529), RIG-2529 tier-3 scope +gate (PR #519). The net is live CODE now, not just frozen design records, so +every chord this record adds is already findable (the `?` overlay is a +render-time join over the live keymap, `apps/ui/src/keyboard/shortcuts-model.ts:44-78`) +and has a discoverable fallback (the palette). That was the precondition; it is +met at the code level. + +## Problem / Intent + +Compass's keymap is 100% modifier chords: every row of `DEFAULT_KEYMAP` +(`apps/ui/src/keyboard/keymap.ts:100-155`) is either `Mod+*`/`F6`, the bare `?` +overlay chord (`keymap.ts:109`), or a group-relative navigation key (arrows, +Enter, Space, Home/End) — e.g. `{ chord: "Mod+B", commandId: cmd("view.bridge") }` +(`keymap.ts:102`). The dispatcher resolves exactly ONE `KeyboardEvent` to ONE +chord string (`eventToChord`, `apps/ui/src/keyboard/dispatch.ts:36-53`) against +that table; the install handler is stateless across events — no two-key pending +state anywhere in `installKeymap` (`dispatch.ts:110-168`). Modifier space burns +fast (`Mod+\` vs `Mod+Shift+\`, `keymap.ts:116-117`, is already a strain; +`Mod+1..3` are spent on zones, `keymap.ts:112-114`), and Linear-class keyboard +products don't spend it: they use bare letters and "G then key" leader sequences +that read like little sentences. + +**Matt has RATIFIED the fork (2026-08-23): adopt Linear's leader/mnemonic model, +focus-context-gated.** Heavy bare-letter + leader coverage on the board and other +non-text surfaces; the comms/channel surfaces naturally get fewer *active* bare +chords because a composer is usually focused there — an emergent property of +focus gating, NOT a per-route switch. This record designs HOW: the +leader-sequence runtime, the sequence authoring shape, the collision guard, the +bare-letter allocation, and coexistence with today's `Mod+*` bindings. The +whether is decided and not re-argued here. + +## Global Constraints + +- **SolidJS ^1.9.13 (v1)** — `apps/ui/package.json:25` `"solid-js": "^1.9.13"`. + Vite + TS strict. v1-legal forward idioms only (no second convention). +- **Biome 2.5.4 pinned** — root `package.json:15` `"@biomejs/biome": "2.5.4"` via + catalog (a workspace-ROOT dev dependency; `apps/ui` declares no Biome dep of its + own). +- **Command ids** `noun.verbCamel`; board-scoped ids `board.*`. +- **Chords authored with the `Mod` token** — `keymap.ts:24-26`: *"Chords are + authored with `Mod`, which resolves to `Cmd` on macOS and `Ctrl` everywhere + else. Authoring with `Mod` (never a literal `Ctrl`/`Cmd`) is the convention + that keeps the keymap portable."* Leader sequences are a new authoring shape + this record defines (§A2); segments inside a sequence follow the same token + rules. +- **One keydown path** — `App.tsx:54-60` installs the single window listener + over the spine and wraps the uninstaller in `onCleanup`; the leader runtime + lives INSIDE that listener's handler. NEVER a second window keydown listener. +- **Compose with the three-tier model** — the dispatcher's ratified resolution + (tier 1 active roving group → tier 2 `when`-scoped zone → tier 3 window-global, + `dispatch.ts:124-167`) and the documented scoped-over-global precedence + (`keymap.ts:86-89`: *"When the same chord is bound both with and without a + `when`, the scoped entry takes precedence while its zone is active … the + consumer applies that precedence rather than double-firing."*) are unchanged. + A completed leader sequence resolves through the SAME tiers as a single chord. +- **a11y — the shipped aria helpers are the seam** — the discoverability net + shipped `resolveChordAria` (`keymap.ts:47-48`), `shortcutFor` (`keymap.ts:57-63`), + and `shortcutForAria` (`keymap.ts:70-76`), and wired the latter two into three + live `aria-keyshortcuts` writers (`App.tsx:88`, `LeftSidebar.tsx:453,469,487,500`, + and the literal `"Space"` at `Bridge.tsx:525`). `aria-keyshortcuts` values must + stay WAI-ARIA-valid; a press-then-press sequence has NO representation in the + attribute's grammar (§A5 rules on this, and it is a real hazard because + `shortcutForAria` today would emit a sequence string verbatim). +- **Tests** run `cd apps/ui && bun test --conditions browser `; dispatcher + tests dispatch real `KeyboardEvent`s on the window/focused element per + `apps/ui/src/keyboard/dispatch.test.ts:9-12`. +- **@kobalte/core ^0.13.13** (`apps/ui/package.json:15`) where the shipped + palette uses it; this record itself adds no Kobalte surface. + +## Approach + +### A1 — The collision guard is ALREADY BUILT: focus gating via the editable-target guard + +The load-bearing observation, stated first because everything else leans on it: +the dispatcher already suppresses every modifier-less chord while focus is in a +text field, and bare-letter/leader chords are modifier-less, so they inherit the +guard with zero new code: + +> ```ts +> // Editable-target guard: a modifier-less chord (arrows, Enter, Space, +> // Home/End, and bare Shift combos) never fires while focus is in a text +> // field — the composer keeps its local keys. Mod/Ctrl/Alt chords are +> // global and are NOT guarded. +> const hasCommandModifier = event.metaKey || event.ctrlKey || event.altKey; +> if (!hasCommandModifier && isEditableTarget(event.target)) return; +> ``` +> +> — `apps/ui/src/keyboard/dispatch.ts:117-122` + +with `isEditableTarget` covering `HTMLInputElement`, `HTMLTextAreaElement`, and +`isContentEditable` (`dispatch.ts:66-72`). Wave 1 EXTENDS `isEditableTarget` (a +small refinement to the shipped helper) to also cover a focused native ``'s type-ahead), and because arming unconditionally `preventDefault`s the +key (§A3 step 4), a bare `g` on a focused `` already fall through to native behavior — and is the +right boundary for "focus is somewhere with its own key handling". + +**Decision (ratified by Matt's ruling): focus gating IS the surface-awareness +mechanism. There is no per-route switch.** This is exactly what Linear, Slack, +and Zulip all converge on: single-letter/leader chords live in "navigation mode" +(no text field focused) and yield entirely to text entry — Linear's help says to +press Escape to clear active inputs before using shortcuts; Slack's single-letter +message actions "do not work while you are typing in the message input field"; +Zulip's design principle is "with the compose box closed, there is no need to use +the Ctrl key all the time". "Lighter on comms surfaces" — Matt's instinct — is +EMERGENT: a channel page usually has the composer focused, so bare chords are +mostly dormant there, while the board (no text inputs) gets the full set. Nobody +should ever add a redundant per-route disable flag; this record makes that a +ledgered decision. + +Compass already has the Slack-style path INTO navigation mode: `F6 → zone.cycle` +(`keymap.ts:115`) rotates focus between zones, the direct parallel of Slack's F6 +rotor out of the composer. + +### A2 — Sequence authoring: space-separated segments in `KeymapEntry.chord` + +A leader sequence is authored as a single `chord` string whose segments are +separated by one space: `{ chord: "G B", commandId: cmd("view.bridge") }`. No +new `KeymapEntry` field. + +- `KeymapEntry` (`keymap.ts:91-95`) keeps its exact shape — `chord`, `commandId`, + `when?` — so every existing consumer (the dispatcher's `DEFAULT_KEYMAP.filter` + at `dispatch.ts:112-114`, the overlay's keymap join `buildShortcutGroups` at + `shortcuts-model.ts:53-70`, `shortcutFor`/`shortcutForAria` at `keymap.ts:57-76`) + continues to typecheck unchanged. +- Each segment is itself a full Mod-token chord, so `resolveChord` + (`keymap.ts:37-38`, a pure `replaceAll(MOD, …)`) already resolves a sequence + string correctly — `"Mod+G B"` would become `"Cmd+G B"` — though in practice + every segment of a leader sequence is modifier-less by construction (a modified + segment would bypass the editable guard, §A1, and is banned by the authoring + rule below). +- Space is unambiguous as a separator because the literal Space key is already + normalized to the multi-char token `"Space"` (`dispatch.ts:45`: `if (key === " ") + key = "Space";`), so a raw `" "` can never appear as a key name inside a chord + string. +- New pure helpers in `keymap.ts` (exact signatures in T1) split a sequence into + segments and derive the leader-prefix set FROM the table, so the dispatcher + never hard-codes `G`: adding a second leader later (e.g. an action leader) is a + data change, not a runtime change. + +**Authoring rules** (enforced by a T1 unit test over `DEFAULT_KEYMAP`, the same +way the table is already the single source of truth, `keymap.ts:1-10`): sequences +are exactly two segments; every segment of a sequence is modifier-less (guard +inheritance, §A1); a sequence's first segment must not also be bound as a +complete single chord (the leader key is reserved — see A3 fall-through for why +this keeps the runtime simple). + +### A3 — The leader runtime: pending state inside the ONE keydown handler + +The net-new control flow. Today the handler resolves one event to one chord and +is stateless across events (`dispatch.ts:110-168`). The leader runtime adds a +small closure state to `installKeymap`: + +```ts +type PendingLeader = { leader: string; timer: number } | null; +``` + +Per keydown, ordered. The one structural subtlety: a bare leader key (`g`) +produces the chord `"G"`, which has NO single-chord row in `DEFAULT_KEYMAP`, so +today's `matching.length === 0` early return (`dispatch.ts:115`) would fire +before anything could arm. The runtime therefore resolves arming/completion +around that early return, not after it. + +1. **Normalize** via the existing `eventToChord` (`dispatch.ts:36-53`), + unchanged. +2. **Editable guard FIRST, before any leader logic.** A modifier-less key in a + text field must type, never arm or complete: the existing guard + (`dispatch.ts:121-122`) moves ahead of the pending-leader branch, so `g` in + the composer inserts "g" and the runtime stays disarmed. Its `isEditableTarget` + is extended (§A1, T3) to also cover a focused `` + (`SettingsView.tsx:166`) or ARIA widget with its own key handling has focus + (§A1). +- Move the editable guard (`dispatch.ts:117-122`) ahead of both leader logic and + the `matching.length === 0` early return (`dispatch.ts:115`) for modifier-less + keys; keep its semantics byte-identical for single chords. +- Arming (leader-set hit, `!event.repeat`, preventDefault + stopPropagation), + completion (two-segment resolve through tiers 1→2→3 unchanged, + `dispatch.ts:124-167`), dead-sequence fall-through that RE-ENTERS arming (a + re-pressed leader re-arms; other keys resolve as their own single chord), Escape + disarm, pure-modifier-key transparency, Mod-chord disarm, timeout disarm + (`LEADER_TIMEOUT_MS = 1000`, OQ1), timer cleared by the uninstaller + (`dispatch.ts:171`). +- `Interfaces:` (public signature unchanged — the runtime is internal closure + state; the timeout test uses Bun fake timers, verified to fire `setTimeout` + under the pinned Bun 1.4.0, so NO clock-injection seam is added) + + ```ts + // dispatch.ts + export const LEADER_TIMEOUT_MS: number; // 1000, OQ1 + export function installKeymap( + registry: CommandRegistry, + active: () => RovingGroupHandle | null, + activeZone?: () => FocusZone | null, + ): () => void; // unchanged + ``` + +- Test cycle (red → green), extend `dispatch.test.ts` (real KeyboardEvents on + window/focused elements, `dispatch.test.ts:9-12`): + - `g` then `b` runs `G B`; the arming `g` is `defaultPrevented`. + - **Timeout red-green:** `g`, `jest.useFakeTimers()` + `advanceTimersByTime` + past `LEADER_TIMEOUT_MS`, then `b` — command NOT run (Bun 1.4.0 fake timers + fire `setTimeout` — verified). + - **Editable-guard non-regression:** focus an ``, `g` then `b` — nothing + armed/run/prevented; existing single-chord guard tests stay green. + - **``, press `g` — not armed, not + `defaultPrevented` (native typeahead intact). + - **ARIA-widget non-regression:** focus an element inside a `role="listbox"`/ + `combobox` (the shipped Kobalte palette Search is a listbox), press `g` — + not armed, not `defaultPrevented` (the role half of the extended §A1 guard, + symmetric with the ``/combobox arm-guard), red-green + timeout (Bun fake timers) + guard non-regression + `g s`/`g l` e2e press +- [ ] T4 — dispatcher doc header; ledger delta rides the PR (driver flips) + +## Open Questions + +1. **Leader key = `G`, timeout = 1000 ms.** (Load-bearing.) `G` is the + Linear/Zulip "go-to" convention and collides with nothing in `DEFAULT_KEYMAP` + (`keymap.ts:100-155` has no bare-letter rows). 1000 ms is a guess between + "typing rhythm" and "read the next key off the overlay"; Vim's default + `timeoutlen` is 1000 ms. Recommend: `G`, 1000 ms, constant exported so it's a + one-line retune. +2. **Timeout at all vs pending-until-Escape?** (Load-bearing — it shapes the + runtime: the `PendingLeader` timer field, the timeout-disarm arm, the + uninstaller's timer clear, and the red-green timeout test exist only if a + timeout does; a "no timeout" ruling deletes that machinery.) Ships a default + regardless — keep the timeout: it is safer against a forgotten armed state + swallowing a later `b`, and Linear does not appear to time out aggressively. + Recommend: keep the timeout. +3. **Visual pending-leader hint?** (Non-load-bearing.) Wave 1 ships none + (Linear/Zulip parity). A later nicety: a small `G …` chip in the topbar while + armed. Recommend: defer. +4. **Bare-letter reservation list for future board verbs.** (Non-load-bearing + now; load-bearing before anyone allocates one.) Proposal to reserve, mirroring + Linear: `a` assign, `s` status, `p` priority, `c` create, `i` assign-to-me, + `/` search-focus, and `j`/`k` as list aliases. Nothing ships in wave 1 (§A4); + ratifying the reservation now prevents first-come squatting later. +5. **Migration policy for existing `Mod+*` bindings.** (Non-load-bearing — this + record ships keep-both (§A6) and nothing in the design depends on the choice; a + later deprecation is its own record.) Options: (a) keep both indefinitely — + recommended (modifier chords keep the fire-while-typing property sequences + can't have); (b) deprecate overlapping `Mod+*` (`Mod+B`, `Mod+,`) after the + sequences prove out. Recommend (a).