From 24df2611f07fccd8744516aa6129995bb3b9421c Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 25 Aug 2026 13:32:37 -0400 Subject: [PATCH] feat(keyboard): sequence-grammar chord helpers for leader chords (RIG-2707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three pure, table-independent helpers to `keymap.ts` for the leader/mnemonic-chord grammar frozen in `docs/designs/product/compass-leader-chords/design.md` (T1): - `chordSegments(chord)` — split a chord on its single space; a plain chord yields a one-element array, a sequence like `"G B"` yields `["G", "B"]`. Space is a collision-free separator because the literal Space key normalizes to the `"Space"` token in the dispatcher. - `leaderPrefixes(keymap, platform)` — the resolved first segment of every multi-segment row, derived from the table so the dispatcher never hard-codes a leader key. - `formatChordForDisplay(chord, platform)` — a single chord resolves through `resolveChord` (`"Mod+B"` → `"Cmd+B"`); a sequence joins resolved segments with `" then "` (`"G B"` → `"G then B"`). The single formatter behind every display surface. Extends the `KeymapEntry` doc block with the sequence grammar and its three authoring rules (exactly two segments; every segment modifier-less; the leader prefix never doubles as a complete chord). Pure additions: no production caller and no shipped-helper change (`shortcutFor`/`shortcutForAria` hardening and the `DEFAULT_KEYMAP` sequence rows are T2). `formatChordForDisplay` is the shared root — RIG-2484 T2/T3 and RIG-2530's CoachTip (RIG-2703) both consume it. Tests extend `keymap.test.ts` over a fixture keymap (the real table carries no sequence rows until T2): segment splitting, leader-prefix derivation, and single-vs-sequence display formatting on both platforms. Ledger-impact: none. DL-248..DL-252 for this record landed with the design PR (#544); this impl slice ratifies no new decision. Refs RIG-2707 Co-authored-by: Matt Wilkinson --- apps/ui/src/keyboard/keymap.test.ts | 55 ++++++++++++++++++++++++- apps/ui/src/keyboard/keymap.ts | 63 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/keyboard/keymap.test.ts b/apps/ui/src/keyboard/keymap.test.ts index 5a472d3f..63c0d83a 100644 --- a/apps/ui/src/keyboard/keymap.test.ts +++ b/apps/ui/src/keyboard/keymap.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { CommandId } from "./commands"; -import { shortcutFor, shortcutForAria } from "./keymap"; +import { + chordSegments, + formatChordForDisplay, + type KeymapEntry, + leaderPrefixes, + shortcutFor, + shortcutForAria, +} from "./keymap"; // shortcutFor (RIG-2483, A5/D4) — the single derivation for every shortcut chip: // the first DEFAULT_KEYMAP row bound to an id, resolveChord-resolved. Pure @@ -52,3 +59,49 @@ describe("shortcutForAria", () => { expect(shortcutForAria(id("nonexistent.command"), "other")).toBeUndefined(); }); }); + +// Sequence-grammar helpers (RIG-2484 T1) — pure, table-independent. Tested over +// a FIXTURE keymap because DEFAULT_KEYMAP carries no sequence rows until T2. + +const seqFixture: readonly KeymapEntry[] = [ + { chord: "Mod+B", commandId: id("view.bridge") }, + { chord: "G B", commandId: id("view.bridge") }, + { chord: "G L", commandId: id("view.backlog") }, +]; + +describe("chordSegments", () => { + test("splits a sequence on its single space", () => { + expect(chordSegments("G B")).toEqual(["G", "B"]); + }); + + test("a plain chord yields a one-element array", () => { + expect(chordSegments("Mod+B")).toEqual(["Mod+B"]); + expect(chordSegments("Shift+Enter")).toEqual(["Shift+Enter"]); + }); +}); + +describe("leaderPrefixes", () => { + test("collects the resolved first segment of every sequence row, and nothing else", () => { + const prefixes = leaderPrefixes(seqFixture, "other"); + expect([...prefixes]).toEqual(["G"]); + }); + + test("empty for a table with no sequence rows", () => { + const single: readonly KeymapEntry[] = [ + { chord: "Mod+B", commandId: id("view.bridge") }, + ]; + expect(leaderPrefixes(single, "other").size).toBe(0); + }); +}); + +describe("formatChordForDisplay", () => { + test("a single chord resolves platform-specifically (Mod→Cmd/Ctrl)", () => { + expect(formatChordForDisplay("Mod+B", "mac")).toBe("Cmd+B"); + expect(formatChordForDisplay("Mod+B", "other")).toBe("Ctrl+B"); + }); + + test("a sequence joins resolved segments with ' then '", () => { + expect(formatChordForDisplay("G B", "mac")).toBe("G then B"); + expect(formatChordForDisplay("G L", "other")).toBe("G then L"); + }); +}); diff --git a/apps/ui/src/keyboard/keymap.ts b/apps/ui/src/keyboard/keymap.ts index 91861949..4d5bfb41 100644 --- a/apps/ui/src/keyboard/keymap.ts +++ b/apps/ui/src/keyboard/keymap.ts @@ -47,6 +47,57 @@ export const resolveChord = (chord: string, platform: Platform): string => export const resolveChordAria = (chord: string, platform: Platform): string => chord.replaceAll(MOD, platform === "mac" ? "Meta" : "Control"); +/** + * Split a chord string into its sequence segments on a single space. A plain + * (single-press) chord yields a one-element array; a leader sequence like + * `"G B"` yields `["G", "B"]`. Space is unambiguous as the separator because + * the literal Space key normalizes to the multi-char token `"Space"` + * (`dispatch.ts`), so a raw `" "` never appears as a key name inside a chord. + */ +export function chordSegments(chord: string): string[] { + return chord.split(" "); +} + +/** + * The set of resolved leader keys for `platform`: the `resolveChord`-resolved + * FIRST segment of every multi-segment (sequence) row in `keymap`. Derived from + * the table so the dispatcher never hard-codes a leader key — adding a second + * leader later is a data change, not a runtime change. A single-chord row + * contributes nothing. + */ +export function leaderPrefixes( + keymap: readonly KeymapEntry[], + platform: Platform, +): ReadonlySet { + const prefixes = new Set(); + for (const entry of keymap) { + const segments = chordSegments(entry.chord); + if (segments.length > 1) { + prefixes.add(resolveChord(segments[0], platform)); + } + } + return prefixes; +} + +/** + * The display form of a chord for `platform`. A single chord resolves through + * `resolveChord` (`"Mod+B"` → `"Cmd+B"`); a leader sequence resolves each + * segment and joins them with `" then "` (`"G B"` → `"G then B"`), making + * press order explicit where a bare `"G B"` would read as one simultaneous + * chord. The single formatter behind every display surface (chips, titles, + * the shortcuts overlay). + */ +export function formatChordForDisplay( + chord: string, + platform: Platform, +): string { + const segments = chordSegments(chord); + if (segments.length === 1) return resolveChord(chord, platform); + return segments + .map((segment) => resolveChord(segment, platform)) + .join(" then "); +} + /** * The display chord for a command: the FIRST `DEFAULT_KEYMAP` row bound to `id`, * `resolveChord`-resolved for `platform` (Mod→Cmd/Ctrl). `undefined` when no row @@ -87,6 +138,18 @@ export function shortcutForAria( * scoped entry takes precedence while its zone is active (D5's ranking rule: * "scoped commands rank above global ones when their scope is active"); the * consumer applies that precedence rather than double-firing. + * + * A `chord` may be a LEADER SEQUENCE: two segments separated by one space + * (`"G B"` = press `G` then `B`), resolved for display by + * `formatChordForDisplay` (`"G then B"`) and split by `chordSegments`. The + * dispatcher's leader runtime resolves the completed sequence through the same + * tiers as a single chord. Authoring rules (enforced by a `DEFAULT_KEYMAP` + * invariant test once the first sequence rows land): a sequence is exactly two + * segments; every segment is + * modifier-less (so it inherits the editable-target guard — a modified segment + * would fire while a text field is focused); and a sequence's first segment + * (the leader) must not also be bound as a complete single chord (the leader + * key is reserved, which keeps the runtime's fall-through simple). */ export interface KeymapEntry { readonly chord: string;