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
122 changes: 122 additions & 0 deletions apps/ui/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test";
import { flush as flushSync } from "solid-js";
import { STUB_CHANNELS, STUB_MESSAGES, STUB_TOPICS } from "./comms-stub";
import type { CommandId } from "./keyboard/commands";
import { detectPlatform } from "./keyboard/dispatch";
import { shortcutFor } from "./keyboard/keymap";
import { STUB_AGENTS } from "./stub-data";
import { flush, mountApp } from "./test-router";

Expand Down Expand Up @@ -197,3 +200,122 @@ describe("App shell (T7)", () => {
expect(leftPresent()).toBe(true);
});
});

// Coaching-tooltip adoption sweep (RIG-2530 T2). The topbar Bridge tab and the
// two glyph-only sidebar toggles convert from a native `title=` to a CoachTip;
// the toggles' dead chords are registered so they now dispatch. These assert
// the observable adoption contract: the tooltip reveals on focus, no `title`
// double-tooltips, `aria-keyshortcuts` survives, and the glyph toggles keep a
// non-glyph accessible name via the added `aria-label`.

// Kobalte portals its tooltip content on a macrotask, so a focus that opens it
// is observable only after one setTimeout(0).
async function settle(): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, 0);
await promise;
}

describe("coaching tooltips (RIG-2530 T2)", () => {
test("the Bridge tab opens a coaching tooltip on focus showing the label + chord", async () => {
const { container } = mountApp("/backlog");
const tab = navViewTabs(container).find((t) =>
t.textContent?.includes("Bridge"),
);
expect(tab).toBeDefined();

tab?.focus();
await settle();

// Kobalte portals the tooltip content to document.body.
const tooltip =
document.body.querySelector<HTMLElement>('[role="tooltip"]');
expect(tooltip).not.toBeNull();
expect(tooltip?.textContent).toContain("Bridge");
// Chord derived from the keymap, never hand-authored (D4).
const chip = tooltip?.querySelector(".cx-palette-shortcut");
const kbds = Array.from(chip?.querySelectorAll("kbd") ?? []).map(
(k) => k.textContent,
);
expect(shortcutFor("view.bridge" as CommandId, detectPlatform())).toBe(
"Ctrl+B",
);
expect(kbds).toEqual(["Ctrl", "B"]);
});

test("converted controls drop `title` but keep `aria-keyshortcuts`", () => {
const { container } = mountApp();
const bridgeTab = navViewTabs(container).find((t) =>
t.textContent?.includes("Bridge"),
);
expect(bridgeTab?.hasAttribute("title")).toBe(false);
expect(bridgeTab?.getAttribute("aria-keyshortcuts")).toBeTruthy();

for (const label of ["Toggle left sidebar", "Toggle right sidebar"]) {
const toggle = container.querySelector<HTMLElement>(
`.pane-toggle[aria-label="${label}"]`,
);
expect(toggle).not.toBeNull();
expect(toggle?.hasAttribute("title")).toBe(false);
expect(toggle?.getAttribute("aria-keyshortcuts")).toBeTruthy();
}
});

test("the glyph-only sidebar toggles are named by aria-label, not the bare glyph", () => {
const { container } = mountApp();
const left = container.querySelector<HTMLElement>(
'.pane-toggle[aria-label="Toggle left sidebar"]',
);
const right = container.querySelector<HTMLElement>(
'.pane-toggle[aria-label="Toggle right sidebar"]',
);
expect(left).not.toBeNull();
expect(right).not.toBeNull();
// The visible content is a decorative block glyph; the accessible name
// must come from aria-label, never the glyph.
expect(left?.getAttribute("aria-label")).toBe("Toggle left sidebar");
expect(right?.getAttribute("aria-label")).toBe("Toggle right sidebar");
expect(left?.textContent?.trim()).not.toBe("");
expect(left?.getAttribute("aria-label")).not.toBe(
left?.textContent?.trim(),
);
});

test("both sidebar toggles are now live: their coached chords dispatch", async () => {
const { store } = mountApp();
expect(store.leftOpen()).toBe(true);
expect(store.rightOpen()).toBe(true);

// Both commands the sweep coaches resolve in the registry (dispatch path),
// not only the keymap (display path) — the drift the A4 boundary guards.
expect(
store.keyboard.registry.get("sidebar.toggleLeft" as CommandId),
).toBeDefined();
expect(
store.keyboard.registry.get("sidebar.toggleRight" as CommandId),
).toBeDefined();

// Mod+Shift+\ → toggleLeft; Mod+\ → toggleRight (keymap rows), now that
// the commands are registered.
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "\\",
ctrlKey: true,
shiftKey: true,
bubbles: true,
}),
);
await flush();
expect(store.leftOpen()).toBe(false);

window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "\\",
ctrlKey: true,
bubbles: true,
}),
);
await flush();
expect(store.rightOpen()).toBe(false);
});
});
96 changes: 64 additions & 32 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import "./design/components/card.css";
import "./design/components/menu.css";
import "./design/components/shortcuts.css";
import "./app.css";
import {
CoachTip,
CoachTipContent,
CoachTipTrigger,
} from "./components/CoachTip";
import { LeftSidebar } from "./components/LeftSidebar";
import { Palette } from "./components/Palette";
import { RightSidebar } from "./components/RightSidebar";
Expand All @@ -17,7 +22,7 @@ import { UsageBar } from "./components/UsageBar";
import { useStore } from "./context";
import type { CommandId } from "./keyboard/commands";
import { detectPlatform, installKeymap } from "./keyboard/dispatch";
import { shortcutFor, shortcutForAria } from "./keyboard/keymap";
import { shortcutForAria } from "./keyboard/keymap";

// The Compass ADE shell — an Orca-inspired layout over the compass.v1 surface
// (docs/specs/product/compass.md). A CSS grid: a topbar, a left agent-folder
Expand Down Expand Up @@ -58,10 +63,9 @@ const App: Component<RouteSectionProps> = (props) => {
store.keyboard.activeZone,
),
);
// Point-of-use chip parity (RIG-2483, D10): the topbar Bridge tab announces
// its chord via aria-keyshortcuts + title, resolved from the keymap through
// Point-of-use coaching (RIG-2530): the topbar Bridge tab announces its chord
// via aria-keyshortcuts + a CoachTip tooltip, resolved from the keymap through
// shortcutFor (D4) — matching the LeftSidebar view buttons.
const bridgeChord = shortcutFor("view.bridge" as CommandId, detectPlatform());
const bridgeAria = shortcutForAria(
"view.bridge" as CommandId,
detectPlatform(),
Expand All @@ -80,18 +84,24 @@ const App: Component<RouteSectionProps> = (props) => {
<div class="topbar-sep" />

<nav class="view-tabs" aria-label="View">
<button
type="button"
class={["view-tab", { active: store.view() === "bridge" }]}
onClick={() => store.showBridge()}
aria-keyshortcuts={bridgeAria}
title={bridgeChord ? `Bridge (${bridgeChord})` : undefined}
>
<span class="tab-glyph" aria-hidden="true">
</span>
Bridge
</button>
<CoachTip>
<CoachTipTrigger
as="button"
type="button"
class={["view-tab", { active: store.view() === "bridge" }]}
onClick={() => store.showBridge()}
aria-keyshortcuts={bridgeAria}
>
<span class="tab-glyph" aria-hidden="true">
</span>
Bridge
</CoachTipTrigger>
<CoachTipContent
label="Bridge"
command={"view.bridge" as CommandId}
/>
</CoachTip>
<Show when={store.selectedAgent()}>
{(agent) => (
<button
Expand Down Expand Up @@ -119,22 +129,44 @@ const App: Component<RouteSectionProps> = (props) => {
</div>

<div class="pane-toggles">
<button
type="button"
class={["pane-toggle", { active: store.leftOpen() }]}
title="Toggle left sidebar"
onClick={() => store.toggleLeft()}
>
</button>
<button
type="button"
class={["pane-toggle", { active: store.rightOpen() }]}
title="Toggle right sidebar"
onClick={() => store.toggleRight()}
>
</button>
<CoachTip>
<CoachTipTrigger
as="button"
type="button"
class={["pane-toggle", { active: store.leftOpen() }]}
aria-label="Toggle left sidebar"
aria-keyshortcuts={shortcutForAria(
"sidebar.toggleLeft" as CommandId,
detectPlatform(),
)}
onClick={() => store.toggleLeft()}
>
</CoachTipTrigger>
<CoachTipContent
label="Toggle left sidebar"
command={"sidebar.toggleLeft" as CommandId}
/>
</CoachTip>
<CoachTip>
<CoachTipTrigger
as="button"
type="button"
class={["pane-toggle", { active: store.rightOpen() }]}
aria-label="Toggle right sidebar"
aria-keyshortcuts={shortcutForAria(
"sidebar.toggleRight" as CommandId,
detectPlatform(),
)}
onClick={() => store.toggleRight()}
>
</CoachTipTrigger>
<CoachTipContent
label="Toggle right sidebar"
command={"sidebar.toggleRight" as CommandId}
/>
</CoachTip>
</div>
</header>

Expand Down
Loading
Loading