-
+
{/* min-w-0: flex children don't shrink below their content by default; needed here to truncate overly long names */}
{agentDisplayName(a)}
diff --git a/packages/web/src/features/benchmark/benchmark-page.tsx b/packages/web/src/features/benchmark/benchmark-page.tsx
index 3da5d7b..947c3d6 100644
--- a/packages/web/src/features/benchmark/benchmark-page.tsx
+++ b/packages/web/src/features/benchmark/benchmark-page.tsx
@@ -87,7 +87,7 @@ function AgentNode({
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
-
+
{name}
diff --git a/packages/web/src/features/chat/draft-view.tsx b/packages/web/src/features/chat/draft-view.tsx
index b7e3dd7..2cac91f 100644
--- a/packages/web/src/features/chat/draft-view.tsx
+++ b/packages/web/src/features/chat/draft-view.tsx
@@ -587,7 +587,12 @@ function AgentSelect({
className={pillClass}
>
{selected ? (
-
+
) : null}
{selected ? agentDisplayName(selected) : S.common.loading}
@@ -613,7 +618,12 @@ function AgentSelect({
}}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"
>
-
+
-
+
{name}
diff --git a/packages/web/src/features/traces/traces-page.tsx b/packages/web/src/features/traces/traces-page.tsx
index 58eb7f9..a01834a 100644
--- a/packages/web/src/features/traces/traces-page.tsx
+++ b/packages/web/src/features/traces/traces-page.tsx
@@ -118,7 +118,7 @@ function AgentNode({
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
-
+
{name}
diff --git a/packages/web/src/lib/avatar.ts b/packages/web/src/lib/avatar.ts
new file mode 100644
index 0000000..f43658b
--- /dev/null
+++ b/packages/web/src/lib/avatar.ts
@@ -0,0 +1,62 @@
+/**
+ * Letter-avatar helpers shared by AgentAvatar and ProviderLogo's user-defined
+ * groups: a deterministic tile color family hashed from a stable key (FNV-1a →
+ * hue) plus the first user-perceived character of a display name.
+ *
+ * The color keys off an id rather than a name so it survives renames. The tile
+ * is a light 14%-alpha tint (the same soft background the old pixel identicon
+ * used) with the initial as colored ink rather than white-on-solid: hsl(h 55%
+ * 28%) in light / hsl(h 55% 73%) in dark, lightness picked so the worst-case
+ * hue keeps ≥ 4.5:1 (WCAG AA) contrast against the tinted tile on every app
+ * surface — verified exhaustively over all 360 hues in test/avatar.test.ts.
+ * Components apply the two inks via the --tile-fg / --tile-fg-dark custom
+ * properties (theme toggled by html.dark).
+ */
+
+/** FNV-1a 32-bit string hash (moved here from agent-avatar.tsx). */
+function hashStr(s: string): number {
+ let h = 2166136261;
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return h >>> 0;
+}
+
+/** Deterministic hue (0-359) for a stable key (agentId / provider id). */
+export function avatarHue(key: string): number {
+ return hashStr(key) % 360;
+}
+
+/** Tile colors for a stable key: translucent tint background + per-theme initial inks. */
+export function avatarTile(key: string): { bg: string; fg: string; fgDark: string } {
+ const h = avatarHue(key);
+ return {
+ bg: `hsl(${h} 55% 50% / 0.14)`,
+ fg: `hsl(${h} 55% 28%)`,
+ fgDark: `hsl(${h} 55% 73%)`,
+ };
+}
+
+/** Grapheme segmenter (granularity is locale-independent); code-point fallback on very old engines. */
+const graphemes =
+ typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
+ : undefined;
+
+function firstGrapheme(s: string): string | undefined {
+ if (!s) return undefined;
+ if (!graphemes) return Array.from(s)[0];
+ for (const g of graphemes.segment(s)) return g.segment;
+ return undefined;
+}
+
+/**
+ * First user-perceived character of `text` — a full grapheme cluster, so CJK,
+ * ZWJ emoji (👩💻) and flags (🇨🇦) stay whole — uppercased when it has a case;
+ * empty/whitespace falls back to `fallback`'s initial, then "?".
+ */
+export function avatarInitial(text: string, fallback?: string): string {
+ const ch = firstGrapheme(text.trim()) ?? firstGrapheme((fallback ?? "").trim());
+ return ch ? ch.toUpperCase() : "?";
+}
diff --git a/packages/web/test/avatar.test.ts b/packages/web/test/avatar.test.ts
new file mode 100644
index 0000000..445b263
--- /dev/null
+++ b/packages/web/test/avatar.test.ts
@@ -0,0 +1,143 @@
+/**
+ * Letter-avatar helper unit tests: hue/tile determinism and spread across
+ * names; initial extraction — grapheme based (CJK / ZWJ emoji / flags stay
+ * whole), uppercasing, and the empty/whitespace → fallback → "?" chain; and
+ * an exhaustive WCAG check that both theme inks keep ≥ 4.5:1 contrast against
+ * the tinted tile over every app surface, for all 360 hues.
+ */
+import { describe, expect, it } from "vitest";
+import { avatarHue, avatarInitial, avatarTile } from "../src/lib/avatar";
+
+describe("avatarHue / avatarTile", () => {
+ it("is deterministic for the same key", () => {
+ expect(avatarHue("my-provider")).toBe(avatarHue("my-provider"));
+ expect(avatarTile("agent-1")).toEqual(avatarTile("agent-1"));
+ });
+
+ it("stays inside the 0-359 hue range", () => {
+ for (const key of ["", "a", "my-provider", "深度求索", "🐧"]) {
+ const hue = avatarHue(key);
+ expect(Number.isInteger(hue)).toBe(true);
+ expect(hue).toBeGreaterThanOrEqual(0);
+ expect(hue).toBeLessThan(360);
+ }
+ });
+
+ it("spreads distinct hues across realistic group names", () => {
+ const names = ["my-llm", "local-vllm", "team-proxy", "backup"];
+ expect(new Set(names.map(avatarHue)).size).toBe(names.length);
+ });
+
+ it("formats the tint background and the two theme inks from the key's hue", () => {
+ const h = avatarHue("x");
+ expect(avatarTile("x")).toEqual({
+ bg: `hsl(${h} 55% 50% / 0.14)`,
+ fg: `hsl(${h} 55% 28%)`,
+ fgDark: `hsl(${h} 55% 73%)`,
+ });
+ });
+});
+
+describe("avatarInitial", () => {
+ it("takes the first character, uppercased", () => {
+ expect(avatarInitial("my-provider")).toBe("M");
+ expect(avatarInitial("Zeta")).toBe("Z");
+ });
+
+ it("keeps case-less characters (CJK) as-is", () => {
+ expect(avatarInitial("深度求索")).toBe("深");
+ });
+
+ it("treats a surrogate-pair character as one initial", () => {
+ expect(avatarInitial("🐧 harness")).toBe("🐧");
+ });
+
+ it("keeps multi-code-point grapheme clusters whole (ZWJ emoji, flags)", () => {
+ expect(avatarInitial("👩💻 dev tools")).toBe("👩💻");
+ expect(avatarInitial("🇨🇦 north")).toBe("🇨🇦");
+ });
+
+ it("trims whitespace before picking the initial", () => {
+ expect(avatarInitial(" agent")).toBe("A");
+ });
+
+ it("falls back to the fallback's initial, then ?", () => {
+ expect(avatarInitial("", "agent-1")).toBe("A");
+ expect(avatarInitial(" ", " x")).toBe("X");
+ expect(avatarInitial("")).toBe("?");
+ expect(avatarInitial(" ", " ")).toBe("?");
+ });
+});
+
+// ---- WCAG contrast of the initial ink on the tinted tile (both themes, all hues) ----
+
+function hslToRgb(h: number, s: number, l: number): [number, number, number] {
+ s /= 100;
+ l /= 100;
+ const c = (1 - Math.abs(2 * l - 1)) * s;
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
+ const m = l - c / 2;
+ const [r, g, b] =
+ h < 60
+ ? [c, x, 0]
+ : h < 120
+ ? [x, c, 0]
+ : h < 180
+ ? [0, c, x]
+ : h < 240
+ ? [0, x, c]
+ : h < 300
+ ? [x, 0, c]
+ : [c, 0, x];
+ return [r + m, g + m, b + m];
+}
+
+const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
+const luminance = ([r, g, b]: [number, number, number]) =>
+ 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
+
+function contrast(a: [number, number, number], b: [number, number, number]): number {
+ const la = luminance(a);
+ const lb = luminance(b);
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+/** The 14%-alpha tile tint composited over a surface (sRGB blend, like the browser). */
+function tileOver(h: number, surface: [number, number, number]): [number, number, number] {
+ const tint = hslToRgb(h, 55, 50);
+ return [0, 1, 2].map((i) => tint[i]! * 0.14 + surface[i]! * 0.86) as [number, number, number];
+}
+
+const hexRgb = (s: string) =>
+ [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255) as [number, number, number];
+
+// App surfaces the tile sits on: light = white / gray-50 rows / gray-100 hover;
+// dark = the true-neutral overrides in styles.css (gray-900/800/700).
+const LIGHT_SURFACES = ["#ffffff", "#f9fafb", "#f3f4f6"].map(hexRgb);
+const DARK_SURFACES = ["#0d0d0d", "#1f1f1f", "#303030"].map(hexRgb);
+
+/** Saturation/lightness actually emitted by avatarTile (parsed so this test can't drift from the implementation). */
+function parseSL(color: string): [number, number] {
+ const m = /^hsl\(\d+ (\d+)% (\d+)%\)$/.exec(color);
+ expect(m, `unexpected ink format: ${color}`).toBeTruthy();
+ return [Number(m![1]), Number(m![2])];
+}
+
+describe("avatarTile contrast (WCAG AA)", () => {
+ it("keeps ≥ 4.5:1 for every hue: light ink on light surfaces, dark ink on dark surfaces", () => {
+ const sample = avatarTile("x");
+ const [fgS, fgL] = parseSL(sample.fg);
+ const [fgDarkS, fgDarkL] = parseSL(sample.fgDark);
+ for (let h = 0; h < 360; h++) {
+ const fg = hslToRgb(h, fgS, fgL);
+ const fgDark = hslToRgb(h, fgDarkS, fgDarkL);
+ for (const surface of LIGHT_SURFACES) {
+ expect(contrast(fg, tileOver(h, surface))).toBeGreaterThanOrEqual(4.5);
+ }
+ for (const surface of DARK_SURFACES) {
+ expect(contrast(fgDark, tileOver(h, surface))).toBeGreaterThanOrEqual(4.5);
+ }
+ }
+ });
+});