diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index 92801fc..ae74cd5 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -417,7 +417,12 @@ export function Sidebar({ aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup} 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" > - + {agentDisplayName(agent)} diff --git a/packages/web/src/components/ui/agent-avatar.tsx b/packages/web/src/components/ui/agent-avatar.tsx index e7ffbbe..39cf184 100644 --- a/packages/web/src/components/ui/agent-avatar.tsx +++ b/packages/web/src/components/ui/agent-avatar.tsx @@ -1,90 +1,53 @@ /** - * Agent avatar: a pixel identicon deterministically generated from agentId (the same approach - * GitHub's default avatars use). + * Agent avatar: the Agent's initial as colored ink on a light tinted tile + * (the same letter-tile style ProviderLogo uses for user-defined model groups, + * and the same soft 14%-alpha background the old pixel identicon used). * - * 5x5 grid with left-right mirror symmetry (only the left 3 columns are randomized, the right 2 - * columns mirror back), a single soft-hue foreground, and a very light background of the same - * color. No external dependencies — seeds mulberry32 with an FNV-1a hash. + * The color hashes the agentId — not the display name — so it survives + * renames; the initial comes from the display name when the caller has one, + * falling back to the id. The ink switches shade with the theme via the + * --tile-fg / --tile-fg-dark custom properties, keeping ≥ 4.5:1 contrast on + * the tile for every hue in both themes (see lib/avatar.ts). */ +import type { CSSProperties } from "react"; -/** Grid side length (must be odd for left-right symmetry). */ -const N = 5; -/** Number of columns that need randomizing (including the center column). */ -const HALF = Math.ceil(N / 2); - -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; -} - -function mulberry32(a: number): () => number { - return () => { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** Randomly samples points on the left half + center column, mirrored into a full 5x5 boolean grid. */ -function buildGrid(rnd: () => number): boolean[][] { - const grid: boolean[][] = Array.from({ length: N }, () => Array.from({ length: N }, () => false)); - for (let col = 0; col < HALF; col++) { - for (let row = 0; row < N; row++) { - // The center column has a slightly lower fill rate, to avoid a solid vertical line. - const on = rnd() < (col === HALF - 1 ? 0.4 : 0.55); - grid[row]![col] = on; - grid[row]![N - 1 - col] = on; - } - } - return grid; -} +import { avatarInitial, avatarTile } from "../../lib/avatar"; export function AgentAvatar({ id, + name, size = 18, className, }: { id: string; + /** Display name supplying the initial; omitted, the id's initial is used. */ + name?: string; size?: number; className?: string; }) { - const rnd = mulberry32(hashStr(id || "agent")); - const hue = Math.floor(rnd() * 360); - const grid = buildGrid(rnd); - // 24x24 viewport: 2px margin on each side, 4x4 cells. - const cell = 4; - const pad = 2; - const fg = `hsl(${hue} 52% 46%)`; + const tile = avatarTile(id); return ( - - {grid.map((cols, row) => - cols.map((on, col) => - on ? ( - - ) : null, - ), - )} + + + {avatarInitial(name ?? "", id)} + ); } diff --git a/packages/web/src/components/ui/provider-logo.tsx b/packages/web/src/components/ui/provider-logo.tsx index 58a8dd4..7566bba 100644 --- a/packages/web/src/components/ui/provider-logo.tsx +++ b/packages/web/src/components/ui/provider-logo.tsx @@ -7,10 +7,19 @@ * vendor's brand mark (for recognition purposes, not under trademark license; * Qwen's official gradient wordmark is flattened to currentColor monochrome); * Z.AI uses a simplified geometric approximation of its branded glyph (not an - * exact reproduction of the trademark); unknown vendors and custom models use - * a generic cube. All are pure paths, no external image assets. + * exact reproduction of the trademark); custom models use a generic cube. All + * are pure paths, no external image assets. + * + * Vendor ids outside the preset table are user-defined groups: instead of all + * sharing the cube (which made same-named models across groups + * indistinguishable) each renders a letter tile — the group id's initial as + * colored ink on a light tint hashed from the id (the soft tinted-tile style + * AgentAvatar and the Skill tiles use, keeping the row's visual weight close + * to the flat preset glyphs). */ -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; + +import { avatarInitial, avatarTile } from "../../lib/avatar"; interface Glyph { /** Solid marks (brand mark) use fill; line art uses stroke. */ @@ -106,7 +115,35 @@ const GLYPHS: Record = { }; export function ProviderLogo({ provider, className }: { provider: string; className?: string }) { - const glyph = GLYPHS[provider] ?? GLYPHS.custom!; + const glyph = GLYPHS[provider]; + // User-defined group (only the preset `custom` id keeps the generic cube): letter tile. + // SVG text scales with the viewBox, staying crisp at every call-site size; the ink is + // theme-switched via --tile-fg / --tile-fg-dark (≥ 4.5:1 on the tile for every hue, + // see lib/avatar.ts), so the surrounding text-color class only affects preset glyphs. + if (!glyph) { + const tile = avatarTile(provider); + return ( + + + + {avatarInitial(provider)} + + + ); + } return ( {/* Title line: small avatar + name + agentId + active badge */}
- + {/* 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); + } + } + }); +});