diff --git a/apps/ui/e2e/__screens__/agent.png b/apps/ui/e2e/__screens__/agent.png index 8e1947d30..8b371c162 100644 Binary files a/apps/ui/e2e/__screens__/agent.png and b/apps/ui/e2e/__screens__/agent.png differ diff --git a/apps/ui/e2e/__screens__/backlog.png b/apps/ui/e2e/__screens__/backlog.png index c8280f987..3e9675e89 100644 Binary files a/apps/ui/e2e/__screens__/backlog.png and b/apps/ui/e2e/__screens__/backlog.png differ diff --git a/apps/ui/e2e/__screens__/bridge-colheads.png b/apps/ui/e2e/__screens__/bridge-colheads.png index aef6656cc..dba46ca05 100644 Binary files a/apps/ui/e2e/__screens__/bridge-colheads.png and b/apps/ui/e2e/__screens__/bridge-colheads.png differ diff --git a/apps/ui/e2e/__screens__/bridge-empty.png b/apps/ui/e2e/__screens__/bridge-empty.png index c45e34382..0d784156f 100644 Binary files a/apps/ui/e2e/__screens__/bridge-empty.png and b/apps/ui/e2e/__screens__/bridge-empty.png differ diff --git a/apps/ui/e2e/__screens__/bridge-prs.png b/apps/ui/e2e/__screens__/bridge-prs.png index 008c19e64..5c8219f3b 100644 Binary files a/apps/ui/e2e/__screens__/bridge-prs.png and b/apps/ui/e2e/__screens__/bridge-prs.png differ diff --git a/apps/ui/e2e/__screens__/bridge.png b/apps/ui/e2e/__screens__/bridge.png index a87d77755..576aa910e 100644 Binary files a/apps/ui/e2e/__screens__/bridge.png and b/apps/ui/e2e/__screens__/bridge.png differ diff --git a/apps/ui/e2e/__screens__/done.png b/apps/ui/e2e/__screens__/done.png index 6bcf9f99d..959144945 100644 Binary files a/apps/ui/e2e/__screens__/done.png and b/apps/ui/e2e/__screens__/done.png differ diff --git a/apps/ui/e2e/__screens__/right-sidebar.png b/apps/ui/e2e/__screens__/right-sidebar.png index 068646553..01ff4f68f 100644 Binary files a/apps/ui/e2e/__screens__/right-sidebar.png and b/apps/ui/e2e/__screens__/right-sidebar.png differ diff --git a/apps/ui/e2e/__screens__/settings.png b/apps/ui/e2e/__screens__/settings.png index d233ccfed..9cc90843c 100644 Binary files a/apps/ui/e2e/__screens__/settings.png and b/apps/ui/e2e/__screens__/settings.png differ diff --git a/apps/ui/src/App.test.tsx b/apps/ui/src/App.test.tsx index 973483976..5d88e35d7 100644 --- a/apps/ui/src/App.test.tsx +++ b/apps/ui/src/App.test.tsx @@ -272,14 +272,14 @@ describe("coaching tooltips (RIG-2530 T2)", () => { ); 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. + // The visible content is a decorative SVG (aria-hidden, no text); + // 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(), - ); + const glyph = left?.querySelector("svg"); + expect(glyph).not.toBeNull(); + expect(glyph?.getAttribute("aria-hidden")).toBe("true"); + expect(left?.textContent?.trim()).toBe(""); }); test("both sidebar toggles are now live: their coached chords dispatch", async () => { diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 972796f11..6a3a4c148 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -14,6 +14,7 @@ import { CoachTipContent, CoachTipTrigger, } from "./components/CoachTip"; +import { Glyph } from "./components/Glyph"; import { LeftSidebar } from "./components/LeftSidebar"; import { Palette } from "./components/Palette"; import { RightSidebar } from "./components/RightSidebar"; @@ -77,7 +78,7 @@ const App: Component = (props) => {
Compass ADE @@ -95,7 +96,7 @@ const App: Component = (props) => { aria-keyshortcuts={bridgeAria} > Bridge @@ -146,7 +147,7 @@ const App: Component = (props) => { )} onClick={() => store.toggleLeft()} > - ▐ + = (props) => { )} onClick={() => store.toggleRight()} > - ▌ + svg { + display: block; +} + +.r-tab .r-tab-icon[data-kind="avatar"] { font-size: 15px; line-height: 1; } diff --git a/apps/ui/src/components/AgentView.tsx b/apps/ui/src/components/AgentView.tsx index 617aa61ad..31f0f81e1 100644 --- a/apps/ui/src/components/AgentView.tsx +++ b/apps/ui/src/components/AgentView.tsx @@ -9,6 +9,7 @@ import { } from "../store"; import type { Agent, Terminal } from "../stub-data"; import { ChannelView } from "./ChannelView"; +import { Glyph } from "./Glyph"; import { LogPanel } from "./LogPanel"; import { RuntimeMarker } from "./RuntimeMarker"; import { StateDot } from "./StateDot"; @@ -86,7 +87,7 @@ const PaneView: Component<{ pane: Pane; agent: Agent; focused: boolean }> = ( aria-label="Split right" onClick={() => splitWith("row")} > - ⊞▏ +
@@ -265,7 +266,7 @@ export const AgentView: Component = () => { title={`Close ${tab.title}`} onClick={() => store.closeTab(tab.id)} > - ✕ + diff --git a/apps/ui/src/components/Glyph.test.tsx b/apps/ui/src/components/Glyph.test.tsx new file mode 100644 index 000000000..d4d2b69dd --- /dev/null +++ b/apps/ui/src/components/Glyph.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { render } from "@solidjs/testing-library"; +import { GLYPH_NAMES, Glyph } from "./Glyph"; + +// The invariants named by the frozen record (compass-glyph-primitives +// §"The primitive"): every glyph name yields a non-empty cell list, +// and every cell lies within the 11×11 grid. We reach the geometry through the +// rendered SVG so the assertions bind the observable output, not the table. + +function renderedCells(root: Element): Array<[number, number]> { + const cells: Array<[number, number]> = []; + for (const rect of root.querySelectorAll("rect")) { + cells.push([ + Number(rect.getAttribute("x")), + Number(rect.getAttribute("y")), + ]); + } + return cells; +} + +describe("Glyph", () => { + test("is decorative — aria-hidden, no role or label", () => { + const { container } = render(() => ); + const svg = container.querySelector("svg"); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + expect(svg?.getAttribute("role")).toBeNull(); + expect(svg?.getAttribute("aria-label")).toBeNull(); + }); + + for (const name of GLYPH_NAMES) { + test(`${name} lights cells, all within the 11×11 grid`, () => { + const { container } = render(() => ); + const cells = renderedCells(container); + expect(cells.length).toBeGreaterThan(0); + for (const [x, y] of cells) { + expect(Number.isInteger(x)).toBe(true); + expect(Number.isInteger(y)).toBe(true); + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(10); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(10); + } + }); + } +}); diff --git a/apps/ui/src/components/Glyph.tsx b/apps/ui/src/components/Glyph.tsx new file mode 100644 index 000000000..60ffefdf9 --- /dev/null +++ b/apps/ui/src/components/Glyph.tsx @@ -0,0 +1,936 @@ +import { type Component, For } from "solid-js"; + +/** A fixed chrome symbol drawn as an 11×11 1-bit pixel-art grid at one CSS px + * per cell (RIG-3603, design compass-glyph-primitives). Adopts the DL-150 / + * DL-199 technique as-is: one `` per lit cell, + * filled on `currentColor` so the consuming control's color flows through. + * + * The glyph is decorative — `aria-hidden`, no `role="img"` and no + * `aria-label`. It carries no meaning of its own, so a name-bearing label + * belongs on the consuming control (this is the deliberate difference from + * `BadgeGlyph`, whose glyph carries status meaning). */ + +export type GlyphName = + | "status" + | "files" + | "vcs" + | "pr" + | "cross" + | "close" + | "neutral" + | "split-right" + | "split-down" + | "disclosure" + | "disclosure-open" + | "check" + | "role" + | "pin" + | "pin-outline" + | "subscribed" + | "unsubscribed" + | "list" + | "gear" + | "logo" + | "panel-right" + | "panel-left"; + +/** [x, y] of each lit cell (11×11, one CSS px per cell), transcribed from the + * frozen ASCII grids in `design/components.md` §Glyphs (`#` = lit). Keying on + * the exhaustive `GlyphName` union makes a name without a bitmap a compile + * error, not a silent runtime blank. */ +const GLYPH_CELLS: Record< + GlyphName, + ReadonlyArray +> = { + status: [ + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [6, 1], + [7, 1], + [8, 1], + [9, 1], + [1, 2], + [2, 2], + [3, 2], + [4, 2], + [6, 2], + [7, 2], + [8, 2], + [9, 2], + [1, 3], + [2, 3], + [3, 3], + [4, 3], + [6, 3], + [7, 3], + [8, 3], + [9, 3], + [1, 4], + [2, 4], + [3, 4], + [4, 4], + [6, 4], + [7, 4], + [8, 4], + [9, 4], + [1, 6], + [2, 6], + [3, 6], + [4, 6], + [6, 6], + [7, 6], + [8, 6], + [9, 6], + [1, 7], + [2, 7], + [3, 7], + [4, 7], + [6, 7], + [7, 7], + [8, 7], + [9, 7], + [1, 8], + [2, 8], + [3, 8], + [4, 8], + [6, 8], + [7, 8], + [8, 8], + [9, 8], + [1, 9], + [2, 9], + [3, 9], + [4, 9], + [6, 9], + [7, 9], + [8, 9], + [9, 9], + ], + files: [ + [1, 2], + [2, 2], + [3, 2], + [4, 2], + [1, 3], + [2, 3], + [3, 3], + [4, 3], + [1, 4], + [2, 4], + [3, 4], + [4, 4], + [5, 4], + [6, 4], + [7, 4], + [8, 4], + [9, 4], + [1, 5], + [2, 5], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [8, 5], + [9, 5], + [1, 6], + [2, 6], + [3, 6], + [4, 6], + [5, 6], + [6, 6], + [7, 6], + [8, 6], + [9, 6], + [1, 7], + [2, 7], + [3, 7], + [4, 7], + [5, 7], + [6, 7], + [7, 7], + [8, 7], + [9, 7], + [1, 8], + [2, 8], + [3, 8], + [4, 8], + [5, 8], + [6, 8], + [7, 8], + [8, 8], + [9, 8], + [1, 9], + [2, 9], + [3, 9], + [4, 9], + [5, 9], + [6, 9], + [7, 9], + [8, 9], + [9, 9], + ], + vcs: [ + [1, 1], + [2, 1], + [7, 1], + [8, 1], + [1, 2], + [2, 2], + [6, 2], + [7, 2], + [8, 2], + [1, 3], + [2, 3], + [5, 3], + [6, 3], + [1, 4], + [2, 4], + [3, 4], + [4, 4], + [5, 4], + [1, 5], + [2, 5], + [3, 5], + [1, 6], + [2, 6], + [1, 7], + [2, 7], + [1, 8], + [2, 8], + [1, 9], + [2, 9], + ], + pr: [ + [8, 1], + [1, 2], + [2, 2], + [3, 2], + [4, 2], + [5, 2], + [6, 2], + [7, 2], + [8, 2], + [9, 2], + [8, 3], + [2, 7], + [1, 8], + [2, 8], + [3, 8], + [4, 8], + [5, 8], + [6, 8], + [7, 8], + [8, 8], + [9, 8], + [2, 9], + ], + cross: [ + [1, 1], + [2, 1], + [8, 1], + [9, 1], + [1, 2], + [2, 2], + [3, 2], + [7, 2], + [8, 2], + [9, 2], + [2, 3], + [3, 3], + [4, 3], + [6, 3], + [7, 3], + [8, 3], + [3, 4], + [4, 4], + [5, 4], + [6, 4], + [7, 4], + [4, 5], + [5, 5], + [6, 5], + [3, 6], + [4, 6], + [5, 6], + [6, 6], + [7, 6], + [2, 7], + [3, 7], + [4, 7], + [6, 7], + [7, 7], + [8, 7], + [1, 8], + [2, 8], + [3, 8], + [7, 8], + [8, 8], + [9, 8], + [1, 9], + [2, 9], + [8, 9], + [9, 9], + ], + close: [ + [1, 0], + [9, 0], + [1, 1], + [2, 1], + [8, 1], + [9, 1], + [2, 2], + [3, 2], + [7, 2], + [8, 2], + [3, 3], + [4, 3], + [6, 3], + [7, 3], + [4, 4], + [5, 4], + [6, 4], + [5, 5], + [4, 6], + [5, 6], + [6, 6], + [3, 7], + [4, 7], + [6, 7], + [7, 7], + [2, 8], + [3, 8], + [7, 8], + [8, 8], + [1, 9], + [2, 9], + [8, 9], + [9, 9], + [1, 10], + [9, 10], + ], + neutral: [ + [4, 3], + [5, 3], + [6, 3], + [4, 4], + [5, 4], + [6, 4], + [4, 5], + [5, 5], + [6, 5], + ], + "split-right": [ + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [7, 1], + [8, 1], + [9, 1], + [1, 2], + [5, 2], + [9, 2], + [1, 3], + [5, 3], + [9, 3], + [1, 4], + [5, 4], + [9, 4], + [1, 5], + [5, 5], + [9, 5], + [1, 6], + [5, 6], + [9, 6], + [1, 7], + [5, 7], + [9, 7], + [1, 8], + [5, 8], + [9, 8], + [1, 9], + [2, 9], + [3, 9], + [4, 9], + [5, 9], + [6, 9], + [7, 9], + [8, 9], + [9, 9], + ], + "split-down": [ + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [7, 1], + [8, 1], + [9, 1], + [1, 2], + [9, 2], + [1, 3], + [9, 3], + [1, 4], + [9, 4], + [1, 5], + [2, 5], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [8, 5], + [9, 5], + [1, 6], + [9, 6], + [1, 7], + [9, 7], + [1, 8], + [9, 8], + [1, 9], + [2, 9], + [3, 9], + [4, 9], + [5, 9], + [6, 9], + [7, 9], + [8, 9], + [9, 9], + ], + disclosure: [ + [3, 1], + [3, 2], + [4, 2], + [3, 3], + [4, 3], + [5, 3], + [3, 4], + [4, 4], + [5, 4], + [6, 4], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [3, 6], + [4, 6], + [5, 6], + [6, 6], + [3, 7], + [4, 7], + [5, 7], + [3, 8], + [4, 8], + [3, 9], + ], + "disclosure-open": [ + [1, 2], + [2, 2], + [3, 2], + [4, 2], + [5, 2], + [6, 2], + [7, 2], + [8, 2], + [9, 2], + [2, 3], + [3, 3], + [4, 3], + [5, 3], + [6, 3], + [7, 3], + [8, 3], + [3, 4], + [4, 4], + [5, 4], + [6, 4], + [7, 4], + [4, 5], + [5, 5], + [6, 5], + [5, 6], + ], + check: [ + [9, 2], + [8, 3], + [9, 3], + [7, 4], + [8, 4], + [1, 5], + [2, 5], + [6, 5], + [7, 5], + [2, 6], + [3, 6], + [5, 6], + [6, 6], + [3, 7], + [4, 7], + [5, 7], + [4, 8], + ], + role: [ + [5, 2], + [4, 3], + [5, 3], + [6, 3], + [3, 4], + [4, 4], + [5, 4], + [6, 4], + [7, 4], + [2, 5], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [8, 5], + [3, 6], + [4, 6], + [5, 6], + [6, 6], + [7, 6], + [4, 7], + [5, 7], + [6, 7], + [5, 8], + ], + pin: [ + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [7, 1], + [3, 2], + [4, 2], + [5, 2], + [6, 2], + [7, 2], + [3, 3], + [4, 3], + [5, 3], + [6, 3], + [7, 3], + [3, 4], + [4, 4], + [5, 4], + [6, 4], + [7, 4], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [5, 6], + [5, 7], + [5, 8], + ], + "pin-outline": [ + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [7, 1], + [3, 2], + [7, 2], + [3, 3], + [7, 3], + [3, 4], + [7, 4], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [5, 6], + [5, 7], + [5, 8], + ], + subscribed: [ + [4, 1], + [5, 1], + [6, 1], + [3, 2], + [4, 2], + [5, 2], + [6, 2], + [7, 2], + [2, 3], + [3, 3], + [4, 3], + [5, 3], + [6, 3], + [7, 3], + [8, 3], + [2, 4], + [3, 4], + [4, 4], + [5, 4], + [6, 4], + [7, 4], + [8, 4], + [2, 5], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [8, 5], + [2, 6], + [3, 6], + [4, 6], + [5, 6], + [6, 6], + [7, 6], + [8, 6], + [2, 7], + [3, 7], + [4, 7], + [5, 7], + [6, 7], + [7, 7], + [8, 7], + [3, 8], + [4, 8], + [5, 8], + [6, 8], + [7, 8], + [4, 9], + [5, 9], + [6, 9], + ], + unsubscribed: [ + [4, 1], + [5, 1], + [6, 1], + [3, 2], + [4, 2], + [5, 2], + [6, 2], + [7, 2], + [2, 3], + [3, 3], + [4, 3], + [5, 3], + [6, 3], + [7, 3], + [8, 3], + [2, 4], + [3, 4], + [7, 4], + [8, 4], + [2, 5], + [3, 5], + [7, 5], + [8, 5], + [2, 6], + [3, 6], + [7, 6], + [8, 6], + [2, 7], + [3, 7], + [4, 7], + [5, 7], + [6, 7], + [7, 7], + [8, 7], + [3, 8], + [4, 8], + [5, 8], + [6, 8], + [7, 8], + [4, 9], + [5, 9], + [6, 9], + ], + list: [ + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [7, 1], + [8, 1], + [9, 1], + [1, 2], + [9, 2], + [1, 3], + [2, 3], + [3, 3], + [4, 3], + [5, 3], + [6, 3], + [7, 3], + [8, 3], + [9, 3], + [1, 4], + [9, 4], + [1, 5], + [2, 5], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [8, 5], + [9, 5], + [1, 6], + [9, 6], + [1, 7], + [2, 7], + [3, 7], + [4, 7], + [5, 7], + [6, 7], + [7, 7], + [8, 7], + [9, 7], + [1, 8], + [9, 8], + [1, 9], + [2, 9], + [3, 9], + [4, 9], + [5, 9], + [6, 9], + [7, 9], + [8, 9], + [9, 9], + ], + gear: [ + [4, 1], + [5, 1], + [6, 1], + [4, 2], + [5, 2], + [6, 2], + [1, 3], + [2, 3], + [3, 3], + [4, 3], + [5, 3], + [6, 3], + [7, 3], + [8, 3], + [9, 3], + [1, 4], + [2, 4], + [3, 4], + [7, 4], + [8, 4], + [9, 4], + [1, 5], + [2, 5], + [3, 5], + [7, 5], + [8, 5], + [9, 5], + [1, 6], + [2, 6], + [3, 6], + [7, 6], + [8, 6], + [9, 6], + [1, 7], + [2, 7], + [3, 7], + [4, 7], + [5, 7], + [6, 7], + [7, 7], + [8, 7], + [9, 7], + [4, 8], + [5, 8], + [6, 8], + [4, 9], + [5, 9], + [6, 9], + ], + logo: [ + [5, 1], + [4, 2], + [5, 2], + [6, 2], + [3, 3], + [4, 3], + [6, 3], + [7, 3], + [2, 4], + [3, 4], + [7, 4], + [8, 4], + [1, 5], + [2, 5], + [8, 5], + [9, 5], + [2, 6], + [3, 6], + [7, 6], + [8, 6], + [3, 7], + [4, 7], + [6, 7], + [7, 7], + [4, 8], + [5, 8], + [6, 8], + [5, 9], + ], + "panel-right": [ + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [7, 1], + [8, 1], + [9, 1], + [1, 2], + [5, 2], + [6, 2], + [7, 2], + [8, 2], + [9, 2], + [1, 3], + [5, 3], + [6, 3], + [7, 3], + [8, 3], + [9, 3], + [1, 4], + [5, 4], + [6, 4], + [7, 4], + [8, 4], + [9, 4], + [1, 5], + [5, 5], + [6, 5], + [7, 5], + [8, 5], + [9, 5], + [1, 6], + [5, 6], + [6, 6], + [7, 6], + [8, 6], + [9, 6], + [1, 7], + [5, 7], + [6, 7], + [7, 7], + [8, 7], + [9, 7], + [1, 8], + [5, 8], + [6, 8], + [7, 8], + [8, 8], + [9, 8], + [1, 9], + [2, 9], + [3, 9], + [4, 9], + [5, 9], + [6, 9], + [7, 9], + [8, 9], + [9, 9], + ], + "panel-left": [ + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [7, 1], + [8, 1], + [9, 1], + [1, 2], + [2, 2], + [3, 2], + [4, 2], + [5, 2], + [9, 2], + [1, 3], + [2, 3], + [3, 3], + [4, 3], + [5, 3], + [9, 3], + [1, 4], + [2, 4], + [3, 4], + [4, 4], + [5, 4], + [9, 4], + [1, 5], + [2, 5], + [3, 5], + [4, 5], + [5, 5], + [9, 5], + [1, 6], + [2, 6], + [3, 6], + [4, 6], + [5, 6], + [9, 6], + [1, 7], + [2, 7], + [3, 7], + [4, 7], + [5, 7], + [9, 7], + [1, 8], + [2, 8], + [3, 8], + [4, 8], + [5, 8], + [9, 8], + [1, 9], + [2, 9], + [3, 9], + [4, 9], + [5, 9], + [6, 9], + [7, 9], + [8, 9], + [9, 9], + ], +}; + +/** Every glyph name, derived from the table itself so callers that enumerate + * glyphs (the cell-validity test) pick up a new name automatically. */ +export const GLYPH_NAMES = Object.keys(GLYPH_CELLS) as readonly GlyphName[]; + +export const Glyph: Component<{ name: GlyphName }> = (props) => { + return ( + + ); +}; diff --git a/apps/ui/src/components/LeftSidebar.tsx b/apps/ui/src/components/LeftSidebar.tsx index 7b8dc48e3..d360a697f 100644 --- a/apps/ui/src/components/LeftSidebar.tsx +++ b/apps/ui/src/components/LeftSidebar.tsx @@ -18,6 +18,7 @@ import { detectPlatform } from "../keyboard/dispatch"; import { shortcutForAria } from "../keyboard/keymap"; import { type Agent, type AgentTreeNode, agentTree } from "../stub-data"; import { CoachTip, CoachTipContent, CoachTipTrigger } from "./CoachTip"; +import { Glyph } from "./Glyph"; import { RuntimeMarker } from "./RuntimeMarker"; import { StateDot } from "./StateDot"; @@ -51,7 +52,7 @@ const AgentLeaf: Component<{ agent: Agent; badge?: number }> = (props) => { {a().account.handle} - ◆ + @@ -83,7 +84,7 @@ const AgentLeaf: Component<{ agent: Agent; badge?: number }> = (props) => { : store.pinAgent(a().account.id) } > - {pinned() ? "★" : "☆"} + {pinned() ? : } ); @@ -107,7 +108,9 @@ const Branch: Component<{ node: AgentTreeNode }> = (props) => { aria-label={`${collapsed() ? "Expand" : "Collapse"} ${props.node.agent.account.handle}'s agents`} onClick={() => store.toggleAgent(agentId())} > - + + + = (props) => { title="Always subscribed — this subscription is implicit and can't be turned off." aria-label="Always subscribed" > - ◉ + } > @@ -232,7 +235,11 @@ const ChannelRow: Component<{ channel: Channel }> = (props) => { } aria-pressed={subscribed() ? "true" : "false"} > - {subscribed() ? "◉" : "○"} + {subscribed() ? ( + + ) : ( + + )} @@ -276,7 +283,9 @@ const BrowseChannels: Component<{ channels: Channel[] }> = (props) => { onClick={() => setOpen((o) => !o)} aria-expanded={open() ? "true" : "false"} > - + + + browse channels {props.channels.length} @@ -330,7 +339,9 @@ const ChannelsSection: Component = () => { onClick={() => store.toggleSection("channels")} aria-expanded={!collapsed() ? "true" : "false"} > - + + + Channels @@ -387,7 +398,9 @@ const AgentsSection: Component = () => { onClick={() => store.toggleSection("agents")} aria-expanded={!collapsed() ? "true" : "false"} > - + + + Agent workspaces @@ -449,7 +462,7 @@ export const LeftSidebar: Component = () => { aria-keyshortcuts={ariaChord("view.bridge")} > Bridge {inFlightCount()} @@ -465,7 +478,7 @@ export const LeftSidebar: Component = () => { aria-keyshortcuts={ariaChord("view.backlog")} > Backlog {backlogCount()} @@ -484,7 +497,7 @@ export const LeftSidebar: Component = () => { aria-keyshortcuts={ariaChord("view.done")} > Done @@ -499,7 +512,7 @@ export const LeftSidebar: Component = () => { aria-keyshortcuts={ariaChord("view.settings")} > Settings diff --git a/apps/ui/src/components/RightSidebar.activitybar.test.tsx b/apps/ui/src/components/RightSidebar.activitybar.test.tsx new file mode 100644 index 000000000..38f1d6f4d --- /dev/null +++ b/apps/ui/src/components/RightSidebar.activitybar.test.tsx @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { render } from "@solidjs/testing-library"; +import { flush } from "solid-js"; +import { STUB_COMMS_STATE } from "../comms-stub"; +import { StoreContext } from "../context"; +import { type AppStore, createAppStore } from "../store"; +import { testQueryClient } from "../test-support"; +import { RightSidebar } from "./RightSidebar"; + +// Render spec for the activity-bar tab icon (RightSidebar.tsx, the `.r-tab-icon` +// span). The item type split at RIG-3603 into a glyph arm (a fixed 1-bit +// ``) and an avatar arm (a person's initial as text + StateDot); this +// file defends that BOTH arms render as their contract says — no coverage +// existed before. FleetPane/tab loop are reached through the exported +// RightSidebar, the same seam a real activity-bar click uses. +function mountRightSidebar(): { store: AppStore; container: HTMLElement } { + let store!: AppStore; + const { container } = render(() => { + store = createAppStore({ + initialComms: STUB_COMMS_STATE, + queryClient: testQueryClient(), + }); + return ( + + + + ); + }); + return { store, container }; +} + +// The tab button for a given aria-label, so a case targets one arm rather than +// reading the first `.r-tab` and hoping it is the intended one. +function tabByLabel(container: HTMLElement, label: string): HTMLButtonElement { + const button = [ + ...container.querySelectorAll("nav.r-activity .r-tab"), + ].find((b) => b.getAttribute("aria-label") === label); + if (!button) throw new Error(`no activity-bar tab labelled "${label}"`); + return button; +} + +describe("RightSidebar activity bar tab icons", () => { + // pinAgent write-throughs to the process-wide happy-dom localStorage, so clear + // it around every case (the fleet-pane suite's discipline). + beforeEach(() => globalThis.localStorage.clear()); + afterEach(() => globalThis.localStorage.clear()); + + // The glyph arm: a static tab draws a 1-bit `` — an SVG with + // crispEdges and lit `` cells, carrying NO text. A regression to the + // old `{tab.icon}` string would render text and no SVG, reddening both legs. + test("a static tab renders a crispEdges SVG glyph with no text", () => { + const { container } = mountRightSidebar(); + const icon = tabByLabel(container, "Fleet status").querySelector( + ".r-tab-icon", + ); + expect(icon).not.toBeNull(); + expect(icon?.getAttribute("data-kind")).toBe("glyph"); + const svg = icon?.querySelector("svg"); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute("shape-rendering")).toBe("crispEdges"); + // Lit cells prove it is the real bitmap, not an empty box. + expect(svg?.querySelectorAll("rect").length).toBeGreaterThan(0); + // No initial leaked through — the glyph arm is textless. + expect(icon?.textContent?.trim()).toBe(""); + }); + + // The avatar arm: a resolvable fleet tab renders the handle's initial as TEXT + // (no SVG glyph) plus the agent's StateDot badge. "compass-ui" → "C". + test("a resolvable fleet tab renders its initial as text plus a StateDot", () => { + const { store, container } = mountRightSidebar(); + store.pinAgent("acc-compass-ui"); + flush(); + const tab = tabByLabel(container, "compass-ui"); + const icon = tab.querySelector(".r-tab-icon"); + expect(icon?.getAttribute("data-kind")).toBe("avatar"); + expect(icon?.textContent?.trim()).toBe("C"); + // The avatar arm draws text, not a Glyph SVG. + expect(icon?.querySelector("svg")).toBeNull(); + // The live agent badges the tab with a StateDot. + expect(tab.querySelector(".cx-state-dot")).not.toBeNull(); + }); + + // An unreachable pin (an id resolving to no fixture agent) still renders its + // initial, but carries NO StateDot — the absent badge is the visual mark of a + // dead pin (RIG-1645), so this reddens if the tab badges an unresolved agent. + test("an unreachable fleet tab renders its initial but no StateDot", () => { + globalThis.localStorage.setItem( + "compass.pinnedAgents.acc-matt", + JSON.stringify([{ id: "acc-ghost", handle: "ghosthandle" }]), + ); + const { container } = mountRightSidebar(); + flush(); + const tab = tabByLabel(container, "ghosthandle (unreachable)"); + const icon = tab.querySelector(".r-tab-icon"); + expect(icon?.getAttribute("data-kind")).toBe("avatar"); + expect(icon?.textContent?.trim()).toBe("G"); + expect(tab.querySelector(".cx-state-dot")).toBeNull(); + }); + + // D2 — the whole-pixel offset. happy-dom applies no stylesheet and computes + // no layout, so real geometry is NOT observable here. This is a PROXY: it + // parses app.css and asserts the mechanism that guarantees the offset — the + // glyph box is an integer 11px square whose integer margins fill .r-tab's + // 32px content box (34px − 2×1px border, box-sizing: border-box) EXACTLY on + // each axis. With zero free space, flex centering has no slack to halve, so + // the box's offset is its whole-pixel margin, not the 10.5px a centered 11px + // box would take. It proves the declared geometry is whole-pixel; it does NOT + // prove the browser rasterizes it there (that is the T6 visual baseline). + test("the glyph box CSS pins a whole-pixel offset in the 34px tab (D2 proxy)", () => { + const css = readFileSync(join(import.meta.dir, "../app.css"), "utf8"); + const rule = css.match( + /\.r-tab \.r-tab-icon\[data-kind="glyph"\]\s*\{([^}]*)\}/, + )?.[1]; + expect(rule).toBeDefined(); + const decl = (prop: string): string | undefined => + rule + ?.match(new RegExp(`(?:^|[;{\\s])${prop}\\s*:\\s*([^;]+);`))?.[1] + .trim(); + const px = (v: string | undefined): number => { + const n = Number(v?.replace("px", "")); + expect(Number.isInteger(n)).toBe(true); + return n; + }; + const width = px(decl("width")); + const height = px(decl("height")); + expect(width).toBe(11); + expect(height).toBe(11); + // margin shorthand: top right bottom left. + const margins = (decl("margin") ?? "").split(/\s+/); + expect(margins.length).toBe(4); + const [mt, mr, mb, ml] = margins.map((m) => px(m)); + // The 32px content box is filled exactly on each axis — no centering slack. + expect(ml + width + mr).toBe(32); + expect(mt + height + mb).toBe(32); + // Seating: without `display: block` on the SVG itself the glyph rides the + // text baseline and leaves the box the margins above just placed, so this + // geometry would describe the span and not the pixels a user sees. + const seat = css.match( + /\.r-tab \.r-tab-icon\[data-kind="glyph"\]\s*>\s*svg\s*\{([^}]*)\}/, + )?.[1]; + expect(seat).toBeDefined(); + expect(seat).toMatch(/display\s*:\s*block/); + }); +}); diff --git a/apps/ui/src/components/RightSidebar.prpane.test.tsx b/apps/ui/src/components/RightSidebar.prpane.test.tsx new file mode 100644 index 000000000..b67238d05 --- /dev/null +++ b/apps/ui/src/components/RightSidebar.prpane.test.tsx @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { render } from "@solidjs/testing-library"; +import { flush } from "solid-js"; +import { STUB_COMMS_STATE } from "../comms-stub"; +import { StoreContext } from "../context"; +import { type AppStore, createAppStore } from "../store"; +import { testQueryClient } from "../test-support"; +import { RightSidebar } from "./RightSidebar"; + +// Accessible-name regression for the PR-review verdict marks (RIG-3603 F1). +// The verdict mark WAS the only accessible name; once the glyph work replaced +// the bare ✓/✗/• with an `aria-hidden` ``, the name had to move to the +// wrapper `.rv` span (role="img" + aria-label). No test guarded that, so a +// future edit dropping either attribute would silently mute the verdict for +// screen-reader users. This drives the real exported RightSidebar the way a +// user reaches the pane — select an issue with bot reviews, activate the PR +// tab — since PrPane is module-private. +function mountRightSidebar(): { store: AppStore; container: HTMLElement } { + let store!: AppStore; + const { container } = render(() => { + store = createAppStore({ + initialComms: STUB_COMMS_STATE, + queryClient: testQueryClient(), + }); + return ( + + + + ); + }); + return { store, container }; +} + +// Open the PR pane over a given fixture issue, then return its rendered verdict +// marks keyed by chip word (`.rv` carries the word on BOTH `data-v` and its +// aria-label, so `data-v` is a name-independent handle to the mark). +function verdictMarks( + store: AppStore, + container: HTMLElement, + issueId: string, +): Map { + store.selectIssue(issueId); + store.setActiveRightTab("pr"); + flush(); + const marks = new Map(); + for (const el of container.querySelectorAll( + ".pr-reviews .review-chip .rv", + )) { + const chip = el.getAttribute("data-v"); + if (chip) marks.set(chip, el); + } + return marks; +} + +// The observable contract a screen reader consumes: the `.rv` wrapper names the +// verdict (role="img" + aria-label = the chip word), and the inner glyph is +// hidden so the name is not doubled. +function assertNamedMark(mark: HTMLElement, expectedWord: string): void { + expect(mark.getAttribute("role")).toBe("img"); + expect(mark.getAttribute("aria-label")).toBe(expectedWord); + const svg = mark.querySelector("svg"); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); +} + +describe("RightSidebar PR pane — verdict mark accessible name", () => { + // selectIssue/pin state write through to the process-wide happy-dom + // localStorage; clear it around every case (the sibling suite's discipline). + beforeEach(() => globalThis.localStorage.clear()); + afterEach(() => globalThis.localStorage.clear()); + + // ws-1022 (RIG-1022 / PR #453) carries bot reviews greptile→approved, + // cubic→approved, CodeRabbit→commented — the latest-per-author collapse + // leaves both `approved` and `commented` chips on the pane. + test("approved and commented marks are named for a screen reader", () => { + const { store, container } = mountRightSidebar(); + const marks = verdictMarks(store, container, "ws-1022"); + expect(marks.has("approved")).toBe(true); + expect(marks.has("commented")).toBe(true); + assertNamedMark(marks.get("approved") as HTMLElement, "approved"); + assertNamedMark(marks.get("commented") as HTMLElement, "commented"); + }); + + // ws-1023 (RIG-1023 / PR #443) carries greptile→changes_requested and + // CodeRabbit→commented, so it is the reachable source of the `changes` chip + // (VERDICT_CHIP maps "changes_requested" → "changes"). + test("the changes mark is named for a screen reader", () => { + const { store, container } = mountRightSidebar(); + const marks = verdictMarks(store, container, "ws-1023"); + expect(marks.has("changes")).toBe(true); + assertNamedMark(marks.get("changes") as HTMLElement, "changes"); + }); +}); diff --git a/apps/ui/src/components/RightSidebar.tsx b/apps/ui/src/components/RightSidebar.tsx index d5d5ced5d..6c3fb2a5c 100644 --- a/apps/ui/src/components/RightSidebar.tsx +++ b/apps/ui/src/components/RightSidebar.tsx @@ -15,7 +15,7 @@ import { primaryPr, } from "../board-render"; import type { Channel } from "../comms-stub"; -import type { ActivityBarItem } from "../constants"; +import type { AvatarTabItem } from "../constants"; import { useStore } from "../context"; import { type Agent, @@ -27,10 +27,21 @@ import { STUB_FILES, } from "../stub-data"; import { ChannelView } from "./ChannelView"; +import { Glyph, type GlyphName } from "./Glyph"; import { RuntimeMarker } from "./RuntimeMarker"; import { StateDot } from "./StateDot"; -const FILE_ICON: Record = { dir: "▸", file: "·" }; +/** The explorer row icon. A dir gets the `disclosure` glyph; a file keeps `·`, + * which Space Mono covers (record D3), so the two-value `kind` splits cleanly + * without a mixed-type icon map. */ +const FileIcon: Component<{ kind: FileNode["kind"] }> = (props) => ( + + + + + · + +); const STATUS_MARK: Record = { modified: "M", added: "A", @@ -45,7 +56,9 @@ const FileRow: Component<{ node: FileNode; depth: number }> = (props) => ( class="file-row" style={{ "padding-left": `${props.depth * 12 + 6}px` }} > - {FILE_ICON[props.node.kind]} + + + {props.node.name} {(s) => ( @@ -219,6 +232,18 @@ const VERDICT_CHIP: Record = commented: "commented", }; +/** Each verdict's chrome glyph. The mark WAS the only name (record: bare ✓/✗ + * read as nothing once `aria-hidden`), so the `.rv` span carries the verdict + * word as its `aria-label`. */ +const VERDICT_GLYPH: Record< + PullRequest["reviews"][number]["verdict"], + GlyphName +> = { + approved: "check", + changes_requested: "cross", + commented: "neutral", +}; + /** The PR pane body: state badge, checks, bot reviews, thread progress. */ const PrPane: Component<{ pr: PullRequest }> = (props) => { const total = () => props.pr.threads.length; @@ -248,12 +273,13 @@ const PrPane: Component<{ pr: PullRequest }> = (props) => { {(r) => ( {r.author} - - {r.verdict === "approved" - ? "✓" - : r.verdict === "changes_requested" - ? "✗" - : "•"} + + )} @@ -324,7 +350,7 @@ const RepoBranchDropdown: Component = () => { fallback={
{repo().name}
@@ -342,11 +368,11 @@ const RepoBranchDropdown: Component = () => { }} > {repo().name} @@ -385,11 +411,11 @@ const RepoBranchDropdown: Component = () => { }} > {repo().currentBranch} 1}> @@ -426,10 +452,9 @@ const RepoBranchDropdown: Component = () => { * agent's full workspace via store.openAgent. Only rendered for a RESOLVABLE * pin (RIG-1645 P2): the pane arm resolves reachability before choosing this * vs the unreachable block, so there is no unresolved-agentId fallback here. */ -const FleetPane: Component<{ item: ActivityBarItem }> = (props) => { +const FleetPane: Component<{ item: AvatarTabItem }> = (props) => { const store = useStore(); - const agent = (): Agent | undefined => - props.item.agentId ? store.agentById(props.item.agentId) : undefined; + const agent = (): Agent | undefined => store.agentById(props.item.agentId); return ( {(a) => { @@ -461,7 +486,7 @@ const FleetPane: Component<{ item: ActivityBarItem }> = (props) => { * affordance for an unreachable pin, whose left-tree row is gone (the tree * renders the VISIBLE set). Unpinning routes through `store.unpinAgent`, which * drops the pin and falls the active tab back to `status`. */ -const AgentUnreachable: Component<{ item: ActivityBarItem }> = (props) => { +const AgentUnreachable: Component<{ item: AvatarTabItem }> = (props) => { const store = useStore(); return (
@@ -471,13 +496,7 @@ const AgentUnreachable: Component<{ item: ActivityBarItem }> = (props) => { @@ -574,13 +593,13 @@ export const RightSidebar: Component = () => { // item-construction site. Never undefined for a pinned `agent:` tab (that was // the blank-pane gap); undefined only for a non-`agent:` tab or an `agent:` // tab with no matching pin (which falls through to `status`). - const activeFleetItem = (): ActivityBarItem | undefined => { + const activeFleetItem = (): AvatarTabItem | undefined => { const active = store.activeRightTab(); if (!active.startsWith("agent:")) return undefined; return store .rightTabGroups() .flatMap((g) => g.items) - .find((i) => i.id === active); + .find((i): i is AvatarTabItem => i.kind === "avatar" && i.id === active); }; return ( @@ -659,8 +678,14 @@ export const RightSidebar: Component = () => { {(tab) => { + // Only the avatar arm carries an agentId / unreachable + // mark and a StateDot; the glyph arm draws a fixed symbol. const agent = (): Agent | undefined => - tab.agentId ? store.agentById(tab.agentId) : undefined; + tab.kind === "avatar" + ? store.agentById(tab.agentId) + : undefined; + const unreachable = (): boolean => + tab.kind === "avatar" && tab.unreachable === true; return (