Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions apps/ui/src/components/CoachTip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,21 @@ describe("CoachTip (RIG-2530)", () => {

test("label-only: a command with no keymap row renders the label and no chip", async () => {
setPlatform("other");
// Guard the premise: view.backlog has no keymap row on this base.
expect(shortcutFor(cmd("view.backlog"), "other")).toBeUndefined();
// Guard the premise: board.openCardCrossLink has no keymap row (it is
// board-nav dispatched, never a global chord — keymap.test.ts pins this).
expect(
shortcutFor(cmd("board.openCardCrossLink"), "other"),
).toBeUndefined();

const { getByRole, baseElement } = render(() => (
<CoachTip>
<CoachTipTrigger as="button" type="button">
Backlog
Open cross-link
</CoachTipTrigger>
<CoachTipContent label="Backlog" command={cmd("view.backlog")} />
<CoachTipContent
label="Open cross-link"
command={cmd("board.openCardCrossLink")}
/>
</CoachTip>
));

Expand All @@ -121,7 +127,7 @@ describe("CoachTip (RIG-2530)", () => {

const tooltip = tooltipOf(baseElement);
expect(tooltip).not.toBeNull();
expect(tooltip?.textContent).toContain("Backlog");
expect(tooltip?.textContent).toContain("Open cross-link");
expect(tooltip?.querySelector(".cx-palette-shortcut")).toBeNull();
expect(tooltip?.querySelector("kbd")).toBeNull();
});
Expand Down
10 changes: 10 additions & 0 deletions apps/ui/src/components/Palette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,22 @@ describe("Palette (RIG-2483)", () => {
);
const bridge = links.find((b) => b.textContent?.includes("Bridge"));
const settings = links.find((b) => b.textContent?.includes("Settings"));
const backlog = links.find((b) => b.textContent?.includes("Backlog"));
const done = links.find((b) => b.textContent?.includes("Done"));
// view.bridge → Mod+B, view.settings → Mod+, — aria uses the WAI-ARIA
// Control token. The display chord no longer rides a native title (the
// RIG-2530 sweep coaches it via CoachTip); a native title would
// double-tooltip, so it must be absent.
expect(bridge?.getAttribute("aria-keyshortcuts")).toBe("Control+B");
expect(bridge?.getAttribute("title")).toBeNull();
expect(settings?.getAttribute("aria-keyshortcuts")).toBe("Control+,");
expect(settings?.getAttribute("title")).toBeNull();
// view.backlog / view.done are sequence-only (G L / G D): shortcutForAria
// skips the sequence so NO aria-keyshortcuts is emitted, and the RIG-2530
// sweep moved coaching to a CoachTip, so there is no native title either.
expect(backlog?.getAttribute("aria-keyshortcuts")).toBeNull();
expect(backlog?.getAttribute("title")).toBeNull();
expect(done?.getAttribute("aria-keyshortcuts")).toBeNull();
expect(done?.getAttribute("title")).toBeNull();
});
});
7 changes: 5 additions & 2 deletions apps/ui/src/components/ShortcutsOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ describe("ShortcutsOverlay (RIG-2482)", () => {
fireEvent.input(input, { target: { value: "bridge" } });
await flush();
const rows = container.querySelectorAll(".cx-shortcuts-row");
expect(rows.length).toBe(1);
expect(rows[0]?.textContent).toContain("Bridge");
// "bridge" now matches both Mod+B and the G B leader sequence (RIG-2484).
expect(rows.length).toBe(2);
const text = [...rows].map((r) => r.textContent ?? "");
expect(text.every((t) => t.includes("Bridge"))).toBe(true);
expect(text.some((t) => t.includes("G then B"))).toBe(true);
});

test("a no-match query shows the dim empty row and no rows", async () => {
Expand Down
45 changes: 44 additions & 1 deletion apps/ui/src/keyboard/keymap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import type { CommandId } from "./commands";
import {
chordSegments,
DEFAULT_KEYMAP,
formatChordForDisplay,
type KeymapEntry,
leaderPrefixes,
Expand Down Expand Up @@ -37,10 +38,19 @@ describe("shortcutFor", () => {

test("undefined for a command with no keymap row (miss)", () => {
expect(shortcutFor(id("board.openCardCrossLink"), "other")).toBeUndefined();
expect(shortcutFor(id("view.backlog"), "other")).toBeUndefined();
expect(shortcutFor(id("nonexistent.command"), "other")).toBeUndefined();
});

test("a sequence-only command renders its formatted sequence chord", () => {
// view.backlog's only keymap row is the G L sequence (T2, RIG-2484).
expect(shortcutFor(id("view.backlog"), "other")).toBe("G then L");
});

test("a dual-bound command shows its modifier chord (sequence row is later)", () => {
// view.bridge is Mod+B (first) then G B; the modifier row wins.
expect(shortcutFor(id("view.bridge"), "other")).toBe("Ctrl+B");
});

test("returns the FIRST matching row for an id bound more than once", () => {
// Enter is bound to list.openOrSelect (unscoped) AND comms.send (when:main);
// shortcutFor takes the first DEFAULT_KEYMAP row — list.openOrSelect's.
Expand Down Expand Up @@ -105,3 +115,36 @@ describe("formatChordForDisplay", () => {
expect(formatChordForDisplay("G L", "other")).toBe("G then L");
});
});

// DEFAULT_KEYMAP authoring invariants for leader sequences (RIG-2484 §A2).
describe("DEFAULT_KEYMAP sequence authoring invariants", () => {
const MODIFIER = /(?:^|\+)(?:Mod|Shift|Alt|Ctrl|Cmd|Meta)(?:\+|$)/;
const sequenceRows = DEFAULT_KEYMAP.filter(
(e) => chordSegments(e.chord).length > 1,
);
const singleChords = new Set(
DEFAULT_KEYMAP.filter((e) => chordSegments(e.chord).length === 1).map(
(e) => e.chord,
),
);

test("every sequence is exactly two segments", () => {
for (const entry of sequenceRows) {
expect(chordSegments(entry.chord).length).toBe(2);
}
});

test("every segment of a sequence is modifier-less", () => {
for (const entry of sequenceRows) {
for (const segment of chordSegments(entry.chord)) {
expect(MODIFIER.test(segment)).toBe(false);
}
}
});

test("a sequence's first segment is not also a complete single chord", () => {
for (const entry of sequenceRows) {
expect(singleChords.has(chordSegments(entry.chord)[0])).toBe(false);
}
});
});
14 changes: 12 additions & 2 deletions apps/ui/src/keyboard/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function shortcutFor(
platform: Platform,
): string | undefined {
const entry = DEFAULT_KEYMAP.find((e) => e.commandId === id);
return entry ? resolveChord(entry.chord, platform) : undefined;
return entry ? formatChordForDisplay(entry.chord, platform) : undefined;
}

/**
Expand All @@ -122,7 +122,9 @@ export function shortcutForAria(
id: CommandId,
platform: Platform,
): string | undefined {
const entry = DEFAULT_KEYMAP.find((e) => e.commandId === id);
const entry = DEFAULT_KEYMAP.find(
(e) => e.commandId === id && chordSegments(e.chord).length === 1,
);
return entry ? resolveChordAria(entry.chord, platform) : undefined;
}

Expand Down Expand Up @@ -171,6 +173,14 @@ export const DEFAULT_KEYMAP: readonly KeymapEntry[] = [
// non-ASCII-letter keys, so a US `Shift+/` normalizes to `?`.
{ chord: "?", commandId: cmd("view.shortcuts") },

// Go-to sequences (RIG-2484). Leader "G then <letter>" destinations, all
// unscoped/global; each sits AFTER any existing modifier row for the same
// command so shortcutFor/shortcutForAria resolve the modifier chord first.
{ chord: "G B", commandId: cmd("view.bridge") },
{ chord: "G L", commandId: cmd("view.backlog") },
{ chord: "G D", commandId: cmd("view.done") },
{ chord: "G S", commandId: cmd("view.settings") },

// Zones (D5:448-449)
{ chord: "Mod+1", commandId: cmd("zone.focusLeft") },
{ chord: "Mod+2", commandId: cmd("zone.focusMain") },
Expand Down
15 changes: 10 additions & 5 deletions apps/ui/src/keyboard/shortcuts-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,21 @@
* iterated — the keymap is the row source).
* - Group by the resolved COMMAND's `scope` (NOT the keymap `when` field),
* ordered `global, left, main, right, topbar`; keymap order within a group.
* - Render chords via `resolveChord(entry.chord, platform)`.
* - Render chords via `formatChordForDisplay(entry.chord, platform)` (a leader
* sequence renders `"G then B"`; a single chord resolves as before).
* - Substring filter (case-insensitive over the lowercased title, each
* keyword, and the resolved chord); an empty query passes every row.
* keyword, and the formatted chord); an empty query passes every row.
*/

import type { CommandId, CommandRegistry, CommandScope } from "./commands";
import { type KeymapEntry, type Platform, resolveChord } from "./keymap";
import {
formatChordForDisplay,
type KeymapEntry,
type Platform,
} from "./keymap";

export interface ShortcutRow {
readonly chord: string; // platform-resolved via resolveChord
readonly chord: string; // platform-resolved + display-formatted via formatChordForDisplay
readonly title: string; // command.title
readonly commandId: CommandId;
}
Expand Down Expand Up @@ -54,7 +59,7 @@ export function buildShortcutGroups(
const command = registry.get(entry.commandId);
if (!command) continue; // unregistered → dead chord, omit

const chord = resolveChord(entry.chord, platform);
const chord = formatChordForDisplay(entry.chord, platform);
if (needle && !matches(command.title, command.keywords, chord, needle)) {
continue;
}
Expand Down
Loading