diff --git a/apps/ui/src/components/CoachTip.test.tsx b/apps/ui/src/components/CoachTip.test.tsx
index b836c44c..1fc92c8a 100644
--- a/apps/ui/src/components/CoachTip.test.tsx
+++ b/apps/ui/src/components/CoachTip.test.tsx
@@ -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(() => (
- Backlog
+ Open cross-link
-
+
));
@@ -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();
});
diff --git a/apps/ui/src/components/Palette.test.tsx b/apps/ui/src/components/Palette.test.tsx
index db04868a..45f8aedc 100644
--- a/apps/ui/src/components/Palette.test.tsx
+++ b/apps/ui/src/components/Palette.test.tsx
@@ -245,6 +245,8 @@ 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
@@ -252,5 +254,13 @@ describe("Palette (RIG-2483)", () => {
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();
});
});
diff --git a/apps/ui/src/components/ShortcutsOverlay.test.tsx b/apps/ui/src/components/ShortcutsOverlay.test.tsx
index 8e2c7adc..52e523de 100644
--- a/apps/ui/src/components/ShortcutsOverlay.test.tsx
+++ b/apps/ui/src/components/ShortcutsOverlay.test.tsx
@@ -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 () => {
diff --git a/apps/ui/src/keyboard-e2e.test.tsx b/apps/ui/src/keyboard-e2e.test.tsx
index ee82dbca..13ebff54 100644
--- a/apps/ui/src/keyboard-e2e.test.tsx
+++ b/apps/ui/src/keyboard-e2e.test.tsx
@@ -116,6 +116,35 @@ describe("App-root keyboard spine (RIG-2456)", () => {
expect(store.view()).toBe("settings");
});
+
+ test("G then S lands on Settings (leader sequence, real App wiring)", async () => {
+ setPlatform("other");
+ const { store } = mountApp("/");
+ expect(store.view()).toBe("bridge");
+
+ // The `G S` sequence (keymap.ts) resolves through the same tier-3 path as
+ // `Mod+,` once the T3 runtime arms the leader. Registers nothing — the
+ // spine already registered view.settings.
+ press({ key: "g" });
+ press({ key: "s" });
+ await flush();
+
+ expect(store.view()).toBe("settings");
+ });
+
+ test("G then L lands on Backlog (sequence-only command, real App wiring)", async () => {
+ setPlatform("other");
+ const { store } = mountApp("/");
+ expect(store.view()).toBe("bridge");
+
+ // view.backlog's ONLY keyboard binding is the `G L` sequence; this proves
+ // the leader runtime resolves it end to end with no App-specific setup.
+ press({ key: "g" });
+ press({ key: "l" });
+ await flush();
+
+ expect(store.view()).toBe("backlog");
+ });
});
describe("shortcuts overlay (RIG-2482)", () => {
diff --git a/apps/ui/src/keyboard/dispatch.test.ts b/apps/ui/src/keyboard/dispatch.test.ts
index addbcf7f..ad0ca711 100644
--- a/apps/ui/src/keyboard/dispatch.test.ts
+++ b/apps/ui/src/keyboard/dispatch.test.ts
@@ -1,6 +1,11 @@
-import { afterEach, describe, expect, test } from "bun:test";
+import { afterEach, describe, expect, jest, test } from "bun:test";
import type { Command, CommandId, CommandScope } from "./commands";
-import { detectPlatform, eventToChord, installKeymap } from "./dispatch";
+import {
+ detectPlatform,
+ eventToChord,
+ installKeymap,
+ LEADER_TIMEOUT_MS,
+} from "./dispatch";
import type { Platform } from "./keymap";
import { createCommandRegistry } from "./registry";
import type { RovingGroupHandle } from "./roving";
@@ -445,3 +450,219 @@ describe("installKeymap", () => {
expect(ran).toBe(0);
});
});
+
+// The leader/mnemonic runtime (RIG-2484 T3): "press G, then " sequences
+// armed inside the ONE keydown handler, with a timeout, the editable guard
+// ahead of arming, and dead-sequence fall-through to single-chord resolution.
+describe("installKeymap — leader sequences", () => {
+ let uninstall: (() => void) | null = null;
+
+ afterEach(() => {
+ uninstall?.();
+ uninstall = null;
+ jest.useRealTimers();
+ setPlatform("other");
+ });
+
+ test("g then b runs view.bridge (G B); the arming g is defaultPrevented", () => {
+ const registry = createCommandRegistry();
+ let ran = 0;
+ registry.register(makeCommand("view.bridge", () => ran++));
+ uninstall = installKeymap(registry, () => null);
+
+ const armed = keydown({ key: "g" });
+ expect(armed.defaultPrevented).toBe(true);
+ expect(ran).toBe(0);
+
+ keydown({ key: "b" });
+ expect(ran).toBe(1);
+ // The completion disarmed the leader: a second bare b is a no-op, not a
+ // stuck-pending double-complete.
+ keydown({ key: "b" });
+ expect(ran).toBe(1);
+ });
+
+ test("timeout: after LEADER_TIMEOUT_MS the leader disarms, so b does not complete", () => {
+ jest.useFakeTimers();
+ const registry = createCommandRegistry();
+ let ran = 0;
+ registry.register(makeCommand("view.bridge", () => ran++));
+ uninstall = installKeymap(registry, () => null);
+
+ keydown({ key: "g" });
+ jest.advanceTimersByTime(LEADER_TIMEOUT_MS + 1);
+ keydown({ key: "b" });
+
+ expect(ran).toBe(0);
+ });
+
+ test("editable-guard: g then b in an input arms nothing, runs nothing, prevents nothing", () => {
+ const registry = createCommandRegistry();
+ let ran = 0;
+ registry.register(makeCommand("view.bridge", () => ran++));
+ uninstall = installKeymap(registry, () => null);
+
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ const armed = keydown({ key: "g" }, input);
+ const completed = keydown({ key: "b" }, input);
+
+ expect(armed.defaultPrevented).toBe(false);
+ expect(completed.defaultPrevented).toBe(false);
+ expect(ran).toBe(0);
+ input.remove();
+ });
+
+ test("