From b66fc7c6f149a821e22885bfc8be2bbe4c13430f Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 20:13:52 -0400 Subject: [PATCH] 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,