From 288e9ebe190a57b036b91dd29d442108f24794b1 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 18:08:04 -0400 Subject: [PATCH 1/4] feat(ui): add the dot-matrix Glyph primitive and its four chrome bitmaps (RIG-3736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the DL-150 / DL-199 technique for UI chrome symbols: an 11x11 1-bit grid at one CSS px per cell, `shape-rendering="crispEdges"`, one `` per lit cell filled on `currentColor` so the consuming control's color flows through. `GLYPH_CELLS` is keyed on the exhaustive `GlyphName` union, so a name without a bitmap is a compile error rather than a silent runtime blank — the guard `BadgeGlyph`'s `GlyphKey` already provides. The four canonical grids are transcribed into `design/components.md` §Glyphs, which stays the source of truth for the geometry. 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 encodes status. The `vcs` glyph is one orthogonally-connected shape: cells that touch only at a corner read as detached specks at 11px under `crispEdges`, not as a line, so the branch is fused to the trunk rather than meeting it diagonally. `status` and `pr` are deliberately multi-part (four blocks, two arrows). No call-site adopts `` yet; `ActivityBarItem`, the render site, and the chrome audit follow in later commits of this stack. Ref: RIG-3736, RIG-3603. Design: docs/designs/ui/compass-glyph-primitives/design.md (DL-367). Co-authored-by: Matt Wilkinson --- apps/ui/src/components/Glyph.test.tsx | 45 +++++ apps/ui/src/components/Glyph.tsx | 230 ++++++++++++++++++++++++++ apps/ui/src/design/components.md | 87 ++++++++++ 3 files changed, 362 insertions(+) create mode 100644 apps/ui/src/components/Glyph.test.tsx create mode 100644 apps/ui/src/components/Glyph.tsx 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..83ceea4e9 --- /dev/null +++ b/apps/ui/src/components/Glyph.tsx @@ -0,0 +1,230 @@ +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"; + +/** [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], + ], +}; + +/** 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/design/components.md b/apps/ui/src/design/components.md index 324e2f989..8e2eb1dd8 100644 --- a/apps/ui/src/design/components.md +++ b/apps/ui/src/design/components.md @@ -346,6 +346,93 @@ vs an 18px integer-multiple — is resolved: 9px is the intended shipped size. ......... ``` +## Glyphs + +- **Classes:** none of its own — the `` primitive renders a bare + inline SVG that the consuming control positions and colors. Adopted first by + the right-sidebar activity bar (`.r-tab .r-tab-icon[data-kind="glyph"]`). +- **Geometry:** an 11×11 1-bit bitmap grid, `shape-rendering="crispEdges"` (no + anti-aliasing); one `` per lit cell, filled on + `currentColor` so the consumer's color flows through. 11×11 (not the state + dot's 9×9) gives a true center cell and enough cells for pictographs. +- **Accessibility:** glyphs are decorative — `aria-hidden="true"`, no + `role="img"` and no `aria-label`. A name-bearing label belongs on the + consuming control, never on the glyph (this is the deliberate difference from + the axis badge, whose glyph carries status meaning). +- **Names:** the closed set is `status | files | vcs | pr` — the four static + activity-bar tabs. The set grows semantic names (never character names) as + the chrome audit converts further sites. + +### The four canonical glyph grids (11×11) + +`#` = lit cell, `.` = off; one CSS px per cell. Coordinates are `[x, y]` with +`x` = column (0..10), `y` = row (0..10), origin top-left. + +`status` — a status grid (replaces `▦`): four cells in a 2×2 block: + +```text +........... +.####.####. +.####.####. +.####.####. +.####.####. +........... +.####.####. +.####.####. +.####.####. +.####.####. +........... +``` + +`files` — a folder (replaces `🗀`): a tab over a wider body: + +```text +........... +........... +.####...... +.####...... +.#########. +.#########. +.#########. +.#########. +.#########. +.#########. +........... +``` + +`vcs` — a version-control branch (replaces `⎇`): a trunk with a branch +diverging to a top-right node: + +```text +........... +.##....##.. +.##...###.. +.##..##.... +.#####..... +.###....... +.##........ +.##........ +.##........ +.##........ +........... +``` + +`pr` — two opposing horizontal arrows (replaces `⇄`): right over left: + +```text +........... +........#.. +.#########. +........#.. +........... +........... +........... +..#........ +.#########. +..#........ +........... +``` + ## Tabs - **Class:** `.cx-tabs` · `data-orientation="h | v"`, with `.cx-tab` items From b66fc7c6f149a821e22885bfc8be2bbe4c13430f Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 20:13:52 -0400 Subject: [PATCH 2/4] feat(ui): derive the activity-bar avatar initial through one owner (RIG-3737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fleetItemForAgent` and `unreachableFleetItem` each computed the fleet tab's letter with `(handle.at(0) ?? "?").toUpperCase()`. Both now call `avatarInitial`, which implements D1 of the glyph-primitives record. `.at(0)` reads a UTF-16 code unit, so an astral first character came back as half a surrogate pair. The helper takes the first grapheme instead, then NFKD-normalizes it and strips combining marks so an accented handle keeps its letter (`Émile` -> `E`) rather than degrading. The result is clamped to one printable ASCII character. That clamp is the condition on which the e2e Unifont pin can retire: the avatar arm is the only activity-bar text whose glyph is not drawn by us, so bounding it to ASCII removes the last reason to ship a fallback face for this surface. `?` remains only for scripts no Latin letter represents. Those handles do collapse to one tab letter, which the tab's `title` and `aria-label` already compensate for — both carry the full handle, and the icon span is `aria-hidden`. An uppercase that expands (`ß` -> `SS`) keeps its first letter for the same reason: the initial exists to tell agents apart. `icon` keeps its name here; splitting the field into the glyph and avatar arms is the next commit in this stack. Ref: RIG-3737, RIG-3603. Design: docs/designs/ui/compass-glyph-primitives/design.md (DL-367). Co-authored-by: Matt Wilkinson --- apps/ui/src/constants.test.ts | 57 +++++++++++++++++++++++++++++++++++ apps/ui/src/constants.ts | 19 ++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 apps/ui/src/constants.test.ts diff --git a/apps/ui/src/constants.test.ts b/apps/ui/src/constants.test.ts new file mode 100644 index 000000000..51c98365c --- /dev/null +++ b/apps/ui/src/constants.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { avatarInitial } from "./constants"; + +describe("avatarInitial", () => { + test("uppercases a plain handle's first letter", () => { + expect(avatarInitial("Mintaka")).toBe("M"); + }); + + test("uppercases a lowercase handle", () => { + expect(avatarInitial("rigel")).toBe("R"); + }); + + test("empty string falls back to ?", () => { + expect(avatarInitial("")).toBe("?"); + }); + + test("whitespace-only falls back to ?", () => { + expect(avatarInitial(" ")).toBe("?"); + }); + + // An emoji carries no Latin letter, so it clamps to ? on its own merits. + test("emoji-leading handle falls back to ?", () => { + expect(avatarInitial("🚀ocket")).toBe("?"); + }); + + // Guards the grapheme read: this astral char NFKD-folds to plain "A", so a + // regression to .at(0) would split the surrogate pair and return ? instead. + test("astral first character is read whole, not as half a surrogate", () => { + expect(avatarInitial("𝐀lpha")).toBe("A"); + }); + + test("accented Latin handle strips the diacritic", () => { + expect(avatarInitial("Émile")).toBe("E"); + }); + + test("non-Latin script (Cyrillic) falls back to ?", () => { + expect(avatarInitial("Живко")).toBe("?"); + }); + + // ß uppercases to "SS"; we keep the FIRST resulting char, not ?, so a real + // letter still distinguishes the agent. + test("uppercase-expanding character keeps its first char", () => { + expect(avatarInitial("ßravo")).toBe("S"); + }); + + // A digit is a printable ASCII char and survives the clamp — a handle like + // "3pio" tabs as "3", which tells it apart better than ?. + test("digit-leading handle keeps the digit", () => { + expect(avatarInitial("3pio")).toBe("3"); + }); + + // Punctuation is likewise printable ASCII and kept — the derivation only + // falls back to ? for non-ASCII-representable scripts, not for ASCII symbols. + test("punctuation-leading handle keeps the punctuation", () => { + expect(avatarInitial("_hidden")).toBe("_"); + }); +}); diff --git a/apps/ui/src/constants.ts b/apps/ui/src/constants.ts index e849da0d0..c41e98806 100644 --- a/apps/ui/src/constants.ts +++ b/apps/ui/src/constants.ts @@ -123,6 +123,21 @@ export const RIGHT_SIDEBAR_TAB_BY_ID: { export const RIGHT_SIDEBAR_ISSUE_ITEMS: readonly ActivityBarItem[] = Object.values(RIGHT_SIDEBAR_TAB_BY_ID).filter((t) => t.group === "issue"); +/** Derive an agent's activity-bar avatar initial from its handle, per D1 of + * design compass-glyph-primitives. Handles are charset-unconstrained (proto + * `from_handle`, no schema validation), so both activity-bar constructors + * derive through here — and the ASCII clamp is what lets the Unifont pin + * retire. An uppercase that expands (`ß`→`SS`) keeps the first letter rather + * than `?`, since the initial exists to tell agents apart; the tab's title + * carries the full handle either way. */ +export function avatarInitial(handle: string): string { + const first = Array.from(handle.trim())[0]; + if (first === undefined) return "?"; + const folded = first.normalize("NFKD").replace(/\p{M}/gu, "").toUpperCase(); + const ascii = Array.from(folded)[0]; + return ascii !== undefined && /^[\x21-\x7e]$/.test(ascii) ? ascii : "?"; +} + /** Build the fleet activity-bar item for a RESOLVABLE pinned agent (Record A * §T2; RIG-1645 P1). The tab id is the `agent:`-prefixed account id (the open * arm of `RightSidebarTab`); the icon is the agent handle's initial (matching @@ -134,7 +149,7 @@ export const RIGHT_SIDEBAR_ISSUE_ITEMS: readonly ActivityBarItem[] = export function fleetItemForAgent(agent: Agent): ActivityBarItem { return { id: `agent:${agent.account.id}`, - icon: (agent.account.handle.at(0) ?? "?").toUpperCase(), + icon: avatarInitial(agent.account.handle), title: agent.account.handle, group: "fleet", agentId: agent.account.id, @@ -151,7 +166,7 @@ export function fleetItemForAgent(agent: Agent): ActivityBarItem { export function unreachableFleetItem(pin: PinnedAgent): ActivityBarItem { return { id: `agent:${pin.id}`, - icon: (pin.handle.at(0) ?? "?").toUpperCase(), + icon: avatarInitial(pin.handle), title: pin.handle, group: "fleet", agentId: pin.id, From e433981defc02ce7a14d067758c9a281bb3c9751 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 20:52:28 -0400 Subject: [PATCH 3/4] feat(ui): split ActivityBarItem into its glyph and avatar arms (RIG-3738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ActivityBarItem.icon: string` carried two unrelated things: a fixed chrome symbol on the four static tabs, and a person's initial on the fleet tabs. The name fit neither, and `agentId`/`unreachable` sat on the shared interface though only fleet tabs ever have them. The item now splits at the item, not the field: `GlyphTabItem` carries `name: GlyphName` and renders ``; `AvatarTabItem` carries `letter` plus the `agentId` and `unreachable` that only it uses. A field-level union was rejected — it keeps a field called `icon` whose value is sometimes a person's initial. The union, the constructors, the render branch and the CSS land together because they must: the activity-bar loop reads both `tab.icon` and `tab.agentId` off the un-narrowed item, so landing the type without the render branch is a strict-mode error and leaves a red commit mid-stack. Narrowing removed a non-null assertion in `AgentUnreachable` — its `agentId` is now required by the type rather than asserted at the use site. `store.ts` is unchanged: `rightTabGroups` holds a genuinely mixed list, so the union is already the right type there. The glyph box is pinned to a whole-pixel offset. An 11px box flex-centered in the tab's 32px content box lands at 10.5px, which smears every 1px `crispEdges` cell across two device pixels; whole-integer margins that fill the axis exactly leave no free space for centering to halve. The SVG is also seated at the box's top edge: an inline-level replaced box rides the text baseline, so without that it renders 2px below the box the margins just placed. `state-dot.css` carries the same rule for the same reason. Measured in Chromium at dpr 1 and 2: the glyph lands at (11, 11) with no overflow; flex centering would put the box at 11.5px, and without the seating rule the glyph sat at y=13. `bridge-colheads.png` is recaptured here rather than with the rest of the baselines, because this is the commit that repaints those pixels. Its clip is computed from the column-head boxes and runs 1050px wide, so it overlaps the activity bar; at 855x41 the 0.001 diff-pixel RATIO allows about 35 pixels and the four glyphs change 99. The full-page captures contain the same changed pixels but pass, their delta swamped by a budget proportional to mostly unchanged area — so they are recaptured later, with the rest. This surface had no tests. It now covers both render arms, the StateDot's presence and absence, the constructors, and the margin arithmetic the whole-pixel offset depends on. Ref: RIG-3738, RIG-3603. Design: docs/designs/ui/compass-glyph-primitives/design.md (DL-367). Co-authored-by: Matt Wilkinson --- apps/ui/e2e/__screens__/bridge-colheads.png | Bin 2544 -> 2446 bytes apps/ui/src/app.css | 27 +++- .../RightSidebar.activitybar.test.tsx | 146 ++++++++++++++++++ apps/ui/src/components/RightSidebar.tsx | 48 +++--- apps/ui/src/constants.test.ts | 60 ++++++- apps/ui/src/constants.ts | 96 ++++++++---- apps/ui/src/store.test.ts | 4 +- 7 files changed, 328 insertions(+), 53 deletions(-) create mode 100644 apps/ui/src/components/RightSidebar.activitybar.test.tsx diff --git a/apps/ui/e2e/__screens__/bridge-colheads.png b/apps/ui/e2e/__screens__/bridge-colheads.png index aef6656cc7a9b83a90b175c56cd1ea3e9cc34968..dba46ca05239933bb7e8c3e861cb3cae836d6593 100644 GIT binary patch literal 2446 zcmb_e`9Bkm8(%~cC3mFUB1g_xLVX>hP>8g!966FJ_nD(1IhJdVS#lq9ZnVv)v@-X# zAxtQ9N1Ai^uK(fl%kw<%_YcqWyq@Rv9Le`AOt?8kH~|0vw<*Zb3IJfWW7_f@EKF$& zF-!pfPXEu;@Rm(z;p+5?C7335YJ&!cT#S@D$pP^>I~#pkLOMY!OC!q|TW>zr;Fe!* zgwVi{soR!|lcqH|^hmW5=h`q=iww_q0}$VE!efrvr^;FjUU4U8ers)CdLcO=5#NRK zzCExbwX#V2M!U!ZZ0exx6vkgC=8DMz&ZB>BgM?L1B#GDL&Zb=8jV@;c^E>^J(PIH@ zNZwB{frHvKQBV_Ru7H;j87wE5`U8hN&p(**-$M_+e*s>m{y>FL&yJesijdOz9&z5y zBrQ`br+MWNh57Bn0pwY~-I(G`xw+C|m80k)iZ6w{;QD*_=K#$k>gPuB+xPE3wm2O( zd)`G^fI!*@1y0uw5B_v5RvPeZC7z>pP=X2g3KMC5!tucjn=Pfczo=tFMk$(}5}v1+ zEf|b zyQwALj<%O7o|dSF9zHDIz~FF!VL2nSA{Q1rBDQ326v5lp$nhAGygYmS?9g=GY-}`8 z7zy9)X?6#T6nr&?S{ByLQvK_l5PwYG;U1;^zjjpfH7rGpvqp!1L@@P~{Hw)?XAvB6<7W-nPi$dQHU&f)_S8ZWJ^jWM*j zDa3|6dS!p#Td>D#<##5G`#+UsOxm0$A2n563t z^$OF&ztX_eF1z~=vLy!g2Z$*eEbPJLvFWt{oQJBqCMoR=mqNgqGm9$X{G2Hf5ldE(P%?6gnSLo)z?0`_yt zV7#M1IL6o(R>vihl0A2qomVg|X$7rKYU<7|E@RX$^P6`q%$v?N6B+N327*FCtKHTk z>Z@JzR6U$cx+VJYp|NIpe>X_3p>FZi6DzAZ#%JB+BzeqtXRV?1*{aRc#n zvz3T*>r`yW!GxVTZ*l^&!I2VwS%}WCv6Zrl&uFaz1_1~>->c`43c3=xgRu6FT33lF z%k+PCoJ5FFSd15pe_h{vJB637|$WzGLQ;3cJ{*`1X&R$)UFh|LR3~7sC zyIr+FGT=a9sR3Yo<2EQZe?l%W51CsdEYjc8(|F?|?$^0_EEfCzbDUJ-xuNvs+TOcm zW&-1e;yj}^cFpuktExPNW`3`2O^`3qC;6e|9rMi@_v$?ET4X`9%bh8~N7@ekc!wR2 z#%6Sa@-O?mKrdgs?lX_jtdIN7AVG3h6#B7! za`OvyHnHDWvZ2+M=Pvf$df z!0wi7X9$!qrzFL#rFLE6I2>QD5YYnHlpO98R_hL`9vHjC&ILhY{IQzUQczS^$A>lo`N4 zO%?CoqI%4F9F*k>Se7<#UtXp6RmrPmPJjRa$(3)%=^r6?BPV7vw8rhQt>xjzuj-X8 zEG_w74k}@9@_xMoknCD^+jWK}AU!_`5`{v?+W47;6)*}_X(LM3-aca2K~ZMtI@ZU^ z>50BbR_3uDuVk`VRZPAXOUZR3R1i{@NdnrYqPuoDT$F{GHl(DpR{0+(B*FIY>G3;n sCH`N-Lm>d*_&C{^lZ)w@{ogyq3ZS$$jbClua{d>~)X2iH?lvUu|D&LvJpcdz literal 2544 zcmb_e`8(8)6Mu&!Wt|D1NUl~`r0$dGu>w8e$ss! z1d*ib>Q=#=guNUobWOK>%7PlZZ$7t&I>SEgwIB0{SDr!wQkY?)q(du*WYj}+tg`d%?W~0rlaNUK zHW$!Ag!4-{NYO{?1SF0ewhR2y;^sJ1Gg5_`f1#50e-2;q{4D^CB{VfPEiPgj@(2Xk zX)TOA7$H{?T;yk#>l>mSTtT6lmm-mnZUEb*irSEEta8%Z7;P{@F(Z<>mw4g? z`(D^C?nHqYLRWTe1>}hxY`>WpW^G;L?DVS_=uTJ)A>kS>zM3Zp$AhaISRX zi`-}LO}*F*ioD3&9yKcw8?+M<7gP8CF*je>y~_*rY`(YB$==sG{Vj}GpCRngwhp2?UJ;86 z3Z_ygx!?u{y2akXqV%2OdcpEwfBhRpGW|yFn?W;W)YYKh8)q>ljsA9M^trCJ>}tr| z{e^Ci$&g}}G#kLD<+`-wm>{zTfjX?`yG4Yc&J}7#YI^R+!D=TSGMN{Pu52xSzZHJ4 zL8SP5k?z^rPCax7i@Yb6>gl<-EVw!}WKKMXW+WDOyrz2*d!FKRXr4D}8qm_U#R7uq zPdKGvvBn3&XDN`uNSH@0^(3R`E7tyKcM z&@8p?++m(yIGkc5nw*w(4wsy48;XyKiJ>QWZ92TR$zzDW+i%TVReu?}A099?b*nKu z&M6rWv%iku2X&H5_PS+>oTLGBPxzld+;LDY;=S( zIJo*ADWILDz47b1vY-ILDIIOe(Z#(0RKi$`RW*6dfCBdx;c1n zjF5v2qbM9F@f`!=GoO7HN*_KGAMtr@=F4M#$Jg9`akV+nQ;W7J#4QuO;(n$K&qQNgKn4WhJH?$RkZfa~5Ctc)7OjbS84 z40*Ed=t?~>bXy)4Jda_uta-JmVVZuqMvYsy4D^mcN{6-7Y~tZkqzHWd;@KAotMFD%v6yxWdx z-NYF=0N^hn3{^uW_4K&AGT8zR+0P>bNI&&0bE@dxqq9Lj@3uy$iTj^TN#;MGG$eQU zBpAis%-QADM%GO9gLpHm3mniQNZ9@-;1A3OlK|=&`mCSiq4Y@_Fi2%AV z>CQ^Z-I?Ix33+QEtd2#5D+fX|6`#+|nYTIQ0YIhC;T_@obxKA=V(B{}D>4wZi?^J# z?A@$ogaV`FRu-Rt2_Jc&@qMf02w`p}bK>qP{~=jME1Fyx4i1A%6PF)KhS=Q{H&V5w ztq0-q)a9QWAUL}WG~6xwV-LRF)QMB%S@-FN`L2>5N86eTUlL;dS#4bU>}h9px<$#j zK!TsksbaHLz0mY1@;Xhft{E!f8Cmpu(n9Z44VorlWW6m;f0X26m4J$yx1UTmuf z+eu|ySpUQD@%fID%d9qz?-uCnO}4liJaR=v`1EOat;Zi8WMr)HoFR^yNwYt$6ZwMq zlgQontp^kKalWt+Ww-mAzad-EM@1!rqAXkTPT=X&MdMd~D+x)-{|qSBkmh=Jt! z`4V2}brp@uKfK`=Pb4iiG*v!9pXf=9R#6p?d}kjoHGR-kV%GJl>4uJ0nya7g*@g#E zj%fiXue;jXY*qf2E8ePSIt|$o&St(FoKTp+Rv7?9TX4yHnwI<{VRH7SZz^ol?uQCL(Rl`b@CC0#bwT74Mb4n$388=mXKL^G_}dXv{OA;&V#Q0r;RhNZ zkXCC{OVz8d7|5ahrXZoc&rnQ+I6O4X%*O=ZMY@!j)Y$p0r9~dOc645Nd$4!^hhzHi zxYv4dc}bim9DEX)hVL7LOeYn2Ih_=27rG#z?`LlQ=8>y@7IoFpOj5&c@v-f6Znl&P z>JA5Bui^B=a4W8^tNH`sDy3yG%-g7fA`mg87$hw71P{Pd2m04~4&U&4u12ErS~W@N z6cBMrCt$F(N6LC9g*z}dBm*?-G`pp$y`5BkuB1aD=12W(ABIkyW`@=4>;jiGxuf6R zI^5d@<5Kyx1Eo1+>OU0BqNe>u>;<^yAw7s-(5N9P;Q6S(tYoZ(532Tm*3f=EmF=&m xiA$dEZ&cEM2wCg^aBwixH?w@0ne#7UV+RQ3Ghhoq|NEf_(AP17S8Llx{R@s-v{L{8 diff --git a/apps/ui/src/app.css b/apps/ui/src/app.css index a7e771054..d2001e93c 100644 --- a/apps/ui/src/app.css +++ b/apps/ui/src/app.css @@ -2261,7 +2261,32 @@ opacity: 0.55; } -.r-tab .r-tab-icon { +/* The glyph arm is an 11px 1-bit SVG box; the avatar arm is a 15px mono + * letter. They split so the glyph box carries NO font metrics (the SVG is the + * content) and the letter keeps its type. */ +.r-tab .r-tab-icon[data-kind="glyph"] { + display: block; + width: 11px; + height: 11px; + /* D2: an odd 11px box centered in the tab's 32px content box (34px − + * 2×1px border, box-sizing: border-box) lands at (32 − 11) / 2 = 10.5px — + * a half pixel that smears every 1px crispEdges cell across two device + * pixels. Whole-integer margins that fill the content box exactly + * (10 + 11 + 11 = 32 per axis) leave zero free space for centering to + * split, so the box's offset is its whole-pixel margin (10px). */ + margin: 10px 11px 11px 10px; +} + +/* Seat the SVG at the box's top edge. An inline-level replaced box rides the + * parent's text baseline, so the inherited line-height pushes the glyph 2px + * down and out of its box — the whole-pixel margin above would then describe + * the span, not the pixels. state-dot.css carries the same rule for the same + * reason. */ +.r-tab .r-tab-icon[data-kind="glyph"] > svg { + display: block; +} + +.r-tab .r-tab-icon[data-kind="avatar"] { font-size: 15px; line-height: 1; } 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.tsx b/apps/ui/src/components/RightSidebar.tsx index d5d5ced5d..198c1075c 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,6 +27,7 @@ import { STUB_FILES, } from "../stub-data"; import { ChannelView } from "./ChannelView"; +import { Glyph } from "./Glyph"; import { RuntimeMarker } from "./RuntimeMarker"; import { StateDot } from "./StateDot"; @@ -426,10 +427,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 +461,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 +471,7 @@ const AgentUnreachable: Component<{ item: ActivityBarItem }> = (props) => { @@ -574,13 +568,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 +653,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 (
@@ -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.tsx b/apps/ui/src/components/Glyph.tsx index 83ceea4e9..60ffefdf9 100644 --- a/apps/ui/src/components/Glyph.tsx +++ b/apps/ui/src/components/Glyph.tsx @@ -10,7 +10,29 @@ import { type Component, For } from "solid-js"; * belongs on the consuming control (this is the deliberate difference from * `BadgeGlyph`, whose glyph carries status meaning). */ -export type GlyphName = "status" | "files" | "vcs" | "pr"; +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 @@ -205,6 +227,690 @@ const GLYPH_CELLS: Record< [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 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.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 198c1075c..6c3fb2a5c 100644 --- a/apps/ui/src/components/RightSidebar.tsx +++ b/apps/ui/src/components/RightSidebar.tsx @@ -27,11 +27,21 @@ import { STUB_FILES, } from "../stub-data"; import { ChannelView } from "./ChannelView"; -import { Glyph } from "./Glyph"; +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", @@ -46,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) => ( @@ -220,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; @@ -249,12 +273,13 @@ const PrPane: Component<{ pr: PullRequest }> = (props) => { {(r) => ( {r.author} - - {r.verdict === "approved" - ? "✓" - : r.verdict === "changes_requested" - ? "✗" - : "•"} + + )} @@ -325,7 +350,7 @@ const RepoBranchDropdown: Component = () => { fallback={
{repo().name}
@@ -343,11 +368,11 @@ const RepoBranchDropdown: Component = () => { }} > {repo().name} @@ -386,11 +411,11 @@ const RepoBranchDropdown: Component = () => { }} > {repo().currentBranch} 1}> diff --git a/apps/ui/src/design/components.md b/apps/ui/src/design/components.md index 8e2eb1dd8..bf1828d61 100644 --- a/apps/ui/src/design/components.md +++ b/apps/ui/src/design/components.md @@ -433,6 +433,350 @@ diverging to a top-right node: ........... ``` +### T5b glyph grids (RightSidebar + AgentView, 11×11) + +`cross` — a bold ballot X (replaces `✗`, the changes-requested verdict mark): + +```text +........... +.##.....##. +.###...###. +..###.###.. +...#####... +....###.... +...#####... +..###.###.. +.###...###. +.##.....##. +........... +``` + +`close` — a thin X close mark (replaces `✕`, the pane/tab close affordance): + +```text +.#.......#. +.##.....##. +..##...##.. +...##.##... +....###.... +.....#..... +....###.... +...##.##... +..##...##.. +.##.....##. +.#.......#. +``` + +`neutral` — a centered 3×3 dot (replaces `•`, the commented verdict mark): + +```text +........... +........... +........... +....###.... +....###.... +....###.... +........... +........... +........... +........... +........... +``` + +`split-right` — a box divided by a vertical rule (replaces `⊞▏`, split-right): + +```text +........... +.#########. +.#...#...#. +.#...#...#. +.#...#...#. +.#...#...#. +.#...#...#. +.#...#...#. +.#...#...#. +.#########. +........... +``` + +`split-down` — a box divided by a horizontal rule (replaces `⊞▁`, split-down): + +```text +........... +.#########. +.#.......#. +.#.......#. +.#.......#. +.#########. +.#.......#. +.#.......#. +.#.......#. +.#########. +........... +``` + +### T5a glyph grids (LeftSidebar + App, 11×11) + +`disclosure` — a right-pointing triangle (replaces `▸`, the closed disclosure +caret; CSS rotates it 90° open): + +```text +........... +...#....... +...##...... +...###..... +...####.... +...#####... +...####.... +...###..... +...##...... +...#....... +........... +``` + +`disclosure-open` — a down-pointing triangle (replaces `▼`, the expanded +disclosure caret): + +```text +........... +........... +.#########. +..#######.. +...#####... +....###.... +.....#..... +........... +........... +........... +........... +``` + +`check` — a check tick (replaces `✓`, the Done view mark): + +```text +........... +........... +.........#. +........##. +.......##.. +.##...##... +..##.##.... +...###..... +....#...... +........... +........... +``` + +`role` — a filled diamond (replaces `◆`, the non-worker role pip): + +```text +........... +........... +.....#..... +....###.... +...#####... +..#######.. +...#####... +....###.... +.....#..... +........... +........... +``` + +`pin` — a filled push-pin (replaces `★`, the pinned agent state): + +```text +........... +...#####... +...#####... +...#####... +...#####... +...#####... +.....#..... +.....#..... +.....#..... +........... +........... +``` + +`pin-outline` — a hollow push-pin (replaces `☆`, the unpinned agent state): + +```text +........... +...#####... +...#...#... +...#...#... +...#...#... +...#####... +.....#..... +.....#..... +.....#..... +........... +........... +``` + +`subscribed` — a filled disc (replaces `◉`, subscribed / always-subscribed): + +```text +........... +....###.... +...#####... +..#######.. +..#######.. +..#######.. +..#######.. +..#######.. +...#####... +....###.... +........... +``` + +`unsubscribed` — a hollow ring (replaces `○`, joined-not-subscribed): + +```text +........... +....###.... +...#####... +..#######.. +..##...##.. +..##...##.. +..##...##.. +..#######.. +...#####... +....###.... +........... +``` + +`list` — a stacked-rows list (replaces `▤`, the Backlog view): + +```text +........... +.#########. +.#.......#. +.#########. +.#.......#. +.#########. +.#.......#. +.#########. +.#.......#. +.#########. +........... +``` + +`gear` — a settings cog (replaces `⚙`, the Settings view): + +```text +........... +....###.... +....###.... +.#########. +.###...###. +.###...###. +.###...###. +.#########. +....###.... +....###.... +........... +``` + +`logo` — the Compass diamond mark (replaces `◇`, the brand logo): + +```text +........... +.....#..... +....###.... +...##.##... +..##...##.. +.##.....##. +..##...##.. +...##.##... +....###.... +.....#..... +........... +``` + +`panel-left` — a pane frame with the left region filled (replaces `▌`, the +toggle-left-sidebar control): + +```text +........... +.#########. +.#####...#. +.#####...#. +.#####...#. +.#####...#. +.#####...#. +.#####...#. +.#####...#. +.#########. +........... +``` + +`panel-right` — a pane frame with the right region filled (replaces `▐`, the +toggle-right-sidebar control): + +```text +........... +.#########. +.#...#####. +.#...#####. +.#...#####. +.#...#####. +.#...#####. +.#...#####. +.#...#####. +.#########. +........... +``` + +### Chrome conversion audit (T5a + T5b) + +Every chrome site converted from a character to a ``, with the +accessibility decision each one forced. `` is always `aria-hidden`, so a +*name-bearing* site is one where the character WAS the accessible name and the +name had to be moved onto a wrapper or control; a *decorative* site already had +one from adjacent text or an `aria-label`. + +All sites below are **decorative** — the control or its neighbouring text +already carries the name — except the two marked *name-bearing*, where the +character WAS the name and it moved onto the wrapper. + +| Site | Was | Glyph | +| --- | --- | --- | +| `App` brand mark | `◇` | `logo` | +| `App` view tab | `▦` | `status` | +| `App` sidebar toggles | `▌` `▐` | `panel-left` `panel-right` | +| `LeftSidebar` role pip | `◆` | `role` | +| `LeftSidebar` pin toggle | `★` `☆` | `pin` `pin-outline` | +| `LeftSidebar` folder caret | `▼` | `disclosure-open` | +| `LeftSidebar` browse/ws carets | `▸` | `disclosure` | +| `LeftSidebar` subscribe toggle | `◉` `○` | `subscribed` `unsubscribed` | +| `LeftSidebar` views | `▦` `▤` `✓` `⚙` | `status` `list` `check` `gear` | +| `RightSidebar` file row | `▸` | `disclosure` | +| `RightSidebar` repo/branch | `🗀` `⎇` | `files` `vcs` | +| `RightSidebar` dropdown carets | `▾` | `disclosure-open` | +| `AgentView` pane split | `⊞▏` `⊞▁` | `split-right` `split-down` | +| `AgentView` pane/tab close | `✕` | `close` | + +Name-bearing — the glyph is hidden, so the wrapper carries `role="img"` plus an +`aria-label`: + +| Site | Was | Glyph | Announces | +| --- | --- | --- | --- | +| `LeftSidebar` always-subscribed | `◉` | `subscribed` | `Always subscribed` | +| `RightSidebar` verdicts | `✓` `✗` `•` | `check` `cross` `neutral` | verdict | + +Kept as text, covered by the brand face — not conversions: + +| Site | Char | Why | +| --- | --- | --- | +| `RightSidebar` file row | `·` U+00B7 | covered; a glyph would misalign | +| diff stats (3 sites) | `−` U+2212 | in the cmap; pairs with ASCII `+` | + +The two name-bearing rows are the hazard this table exists to catch: a bare +`✓`/`✗` verdict mark reads as nothing once the glyph is hidden, so those sites +carry the verdict word on the wrapper. `RightSidebar.prpane.test.tsx` pins that. + ## Tabs - **Class:** `.cx-tabs` · `data-orientation="h | v"`, with `.cx-tab` items