diff --git a/apps/ui/src/constants.test.ts b/apps/ui/src/constants.test.ts new file mode 100644 index 00000000..51c98365 --- /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 e849da0d..c41e9880 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,