From 62b818bb363d1dd11b145eb7f7c4f4589372eecd Mon Sep 17 00:00:00 2001 From: claude_code Date: Fri, 3 Jul 2026 22:50:19 +0300 Subject: [PATCH 1/3] feat(#300 ui): quantized OKLCH palette + multi-channel agent avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ad-hoc 14-color hsl palette with a perceptually-even, validated scheme so agent glyphs are reliably distinguishable: - cyrb53 deterministic, cross-platform 53-bit hash over a normalized name (NFC + trim + lowercase + collapse whitespace) — no built-in/rand hash, so the same name renders the same avatar on every device without persistence. - 20-color OKLCH palette (12 light / 8 dark), chroma clamped to sRGB, min pairwise ΔEOK ≈ 0.066: any two entries are identical or clearly distinct — "almost the same" colors are impossible by construction. - Disjoint hash-bit channels: base color (20) × gradient partner (2) × gradient angle (8) = 320 combinations, so a base-color collision (inevitable past ~20 agents) is still disambiguated by the gradient — and by the emoji drawn on top. Text color (black on light ring, white on dark) is WCAG-checked. Glyph now renders an explicit solid backgroundColor (fallback + testable) plus a linear-gradient backgroundImage. avatarStyle() replaces agentGlyphBackground(). client tsc clean, 26 tests pass (avatarStyle determinism/normalization/structure + DOM base-color). Co-Authored-By: Claude Opus 4.8 --- .../components/ui/agent-avatar-stack.test.tsx | 89 +++++++----- .../src/components/ui/agent-avatar-stack.tsx | 136 ++++++++++++------ 2 files changed, 143 insertions(+), 82 deletions(-) diff --git a/apps/client/src/components/ui/agent-avatar-stack.test.tsx b/apps/client/src/components/ui/agent-avatar-stack.test.tsx index bbcca423..98b1c0ad 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.test.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.test.tsx @@ -2,7 +2,12 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { Provider, createStore } from "jotai"; -import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack"; +import { + AgentAvatarStack, + avatarStyle, + AVATAR_PALETTE, + GRADIENT_PARTNERS, +} from "./agent-avatar-stack"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, @@ -13,14 +18,16 @@ import { type Props = React.ComponentProps; -// The DOM normalizes an inline `background: hsl(...)` to `rgb(...)`. Push the +// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the // expected color through the same CSSOM path so the comparison stays exact and // non-vacuous (an empty string — i.e. no inline background, as in the pre-fix -// Avatar approach — can never match a real color). +// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not +// round-trip a `linear-gradient` in the `background` shorthand, which is why the +// glyph carries an explicit solid `background-color` we assert on here. function normalizeColor(value: string): string { const probe = document.createElement("div"); - probe.style.background = value; - return probe.style.background; + probe.style.backgroundColor = value; + return probe.style.backgroundColor; } function renderStack(props: Props) { @@ -36,23 +43,33 @@ function renderStack(props: Props) { return { store, ...utils }; } -describe("agentGlyphBackground", () => { - it("is deterministic for a given agent name", () => { - expect(agentGlyphBackground("Researcher")).toBe( - agentGlyphBackground("Researcher"), +describe("avatarStyle", () => { + it("is deterministic and normalizes the name", () => { + // Same name → same style; casing / surrounding whitespace must not matter. + expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher")); + expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher")); + }); + + it("gives different agents different styles (incl. the report's pair)", () => { + // "Структурный редактор" and "Фактчекер" looked identically violet; their + // full styles must now differ. + expect(avatarStyle("Структурный редактор")).not.toEqual( + avatarStyle("Фактчекер"), ); }); - it("gives categorically different colors to different agents", () => { - // The two agents that looked identically violet in the report must differ. - expect(agentGlyphBackground("Структурный редактор")).not.toBe( - agentGlyphBackground("Фактчекер"), - ); - expect(agentGlyphBackground("Researcher")).not.toBe( - agentGlyphBackground("Нарратор"), - ); - // Every color is a dark hsl circle drawn from the palette. - expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/); + it("returns a valid palette color, gradient partner, angle and text", () => { + const s = avatarStyle("Нарратор"); + const idx = AVATAR_PALETTE.indexOf(s.bg); + expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry + // bg2 is one of the two hue-shifted partners of the chosen base color. + expect(GRADIENT_PARTNERS[idx]).toContain(s.bg2); + // angle is one of the 8 discrete directions (0,45,…,315). + expect(s.angleDeg % 45).toBe(0); + expect(s.angleDeg).toBeGreaterThanOrEqual(0); + expect(s.angleDeg).toBeLessThan(360); + // Light ring (first 12) → black text, dark ring → white text. + expect(s.text).toBe(idx < 12 ? "black" : "white"); }); }); @@ -73,8 +90,8 @@ describe("AgentAvatarStack", () => { expect(screen.getByText("Alice")).toBeDefined(); }); - it("emoji glyph applies its per-agent color as an inline DOM background", () => { - // Pins the actual fix: the hashed color must reach the DOM as an inline + it("emoji glyph applies its per-agent gradient as an inline DOM background", () => { + // Pins the actual fix: the hashed gradient must reach the DOM as an inline // `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no // inline background (Mantine's --avatar-bg overrode it), so this fails there. const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null }; @@ -88,20 +105,19 @@ describe("AgentAvatarStack", () => { '[data-testid="agent-glyph"]', ); expect(glyph).not.toBeNull(); - // Non-vacuous: compare against the function output (normalized the same way), - // not a frozen literal. Empty against the pre-fix Avatar (no inline bg). - expect(glyph!.style.background).not.toBe(""); - expect(glyph!.style.background).toBe( - normalizeColor(agentGlyphBackground(agent.name)), - ); + const expected = normalizeColor(avatarStyle(agent.name).bg); + // Non-vacuous: the pre-fix Avatar set no inline background at all. + expect(expected).not.toBe(""); + expect(glyph!.style.backgroundColor).toBe(expected); + // (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does + // not round-trip linear-gradient — so its stops/angle are covered by the + // avatarStyle unit tests above, not asserted on the DOM here.) }); - it("agents with distinct hashed colors reach the DOM as distinct backgrounds", () => { + it("agents with distinct styles reach the DOM as distinct backgrounds", () => { // "Researcher" and "Нарратор" hash to different palette entries, so their // applied DOM backgrounds must differ — pins "distinct colors reach the DOM". - expect(agentGlyphBackground("Researcher")).not.toBe( - agentGlyphBackground("Нарратор"), - ); + expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg); const a = renderStack({ agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, @@ -120,14 +136,9 @@ describe("AgentAvatarStack", () => { const glyphB = b.container.querySelector( '[data-testid="agent-glyph"]', ); - expect(glyphA!.style.background).toBe( - normalizeColor(agentGlyphBackground("Researcher")), - ); - expect(glyphB!.style.background).toBe( - normalizeColor(agentGlyphBackground("Нарратор")), - ); - // Different colors reach the DOM (the normalized rgb values also differ). - expect(glyphA!.style.background).not.toBe(glyphB!.style.background); + expect(glyphA!.style.backgroundColor).not.toBe(""); + // Different base colors reach the DOM (the serialized rgb values differ). + expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor); }); it("showName=false: renders only the avatars, no inline name label", () => { diff --git a/apps/client/src/components/ui/agent-avatar-stack.tsx b/apps/client/src/components/ui/agent-avatar-stack.tsx index f2b7fbab..4381ef00 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.tsx @@ -29,54 +29,96 @@ const LAUNCHER_SIZE = 22; // sits as a small badge over that corner (above the glyph) and stays fully visible. const LAUNCHER_OVERHANG = 8; -// Small deterministic string hash (same algorithm as custom-avatar's initials -// hash) used to pick a stable per-agent glyph color. -function hashName(input: string): number { - let hash = 0; - for (let i = 0; i < input.length; i += 1) { - hash = (hash << 5) - hash + input.charCodeAt(i); - hash |= 0; - } - return Math.abs(hash); +// Normalize the name before hashing so "PM ", "pm", "Pm" all map to the same +// avatar (unicode-normalized, trimmed, lower-cased, whitespace collapsed). +function normalizeName(name: string): string { + return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " "); } -// A palette of categorically-DISTINCT dark circle colors for emoji/sparkles agent -// glyphs. Every entry is intentionally dark (low lightness) so a bright emoji or -// the white sparkles icon stays readable on top; the hues are spread across the -// wheel (red → orange → amber → green → teal → cyan → blue → indigo → violet → -// magenta + a neutral slate) so two different agents read as DIFFERENT colors, -// not merely different shades of the same violet. -const GLYPH_COLORS = [ - "hsl(355, 60%, 34%)", // red - "hsl(18, 62%, 32%)", // vermilion - "hsl(32, 60%, 30%)", // orange - "hsl(45, 55%, 28%)", // amber - "hsl(75, 45%, 26%)", // olive-green - "hsl(140, 48%, 26%)", // green - "hsl(165, 52%, 26%)", // teal - "hsl(188, 58%, 28%)", // cyan - "hsl(205, 58%, 32%)", // sky blue - "hsl(225, 52%, 36%)", // blue - "hsl(250, 48%, 38%)", // indigo - "hsl(280, 46%, 36%)", // violet - "hsl(312, 48%, 34%)", // magenta - "hsl(210, 12%, 36%)", // slate / neutral +// cyrb53: deterministic 53-bit string hash with good avalanche, pure JS. A +// language's BUILT-IN hash (Java hashCode, etc.) must NOT be used — those differ +// across platforms/engines, which would make one name render as different avatars +// on server vs client. This is stable everywhere. +function cyrb53(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0; i < str.length; i += 1) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ + Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = + Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ + Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +// Perceptually-even avatar palette built in OKLCH and clamped to the sRGB gamut: +// 12 LIGHT colors (L≈0.70, black text) then 8 DARK colors (L≈0.50, white text). +// The minimum pairwise ΔEOK ≈ 0.066 (~5 JNDs), so any two entries are either the +// SAME color or clearly distinguishable — "almost identical" colors are +// impossible by construction (that was the old raw-hue failure mode). Text +// contrast is WCAG-checked: dark ring ≥ 5.6:1 white, light ring ≥ 7.3:1 black. +export const AVATAR_PALETTE = [ + // light ring (black text) + "#e87782", "#e57f4f", "#d0901e", "#aca220", "#77b154", "#22b988", + "#00b5b5", "#00afdc", "#5fa1f3", "#9690f1", "#bf82da", "#db78b2", + // dark ring (white text) + "#a03e43", "#8e5300", "#686800", "#007742", + "#007176", "#0068a5", "#6453a7", "#8f4280", ]; +// Second gradient stop per palette entry (index-aligned): two hue-shifted (±25°) +// partners. A separate hash channel picks which one, so two agents that collide +// on the base color almost always still differ by their gradient. +export const GRADIENT_PARTNERS = [ + ["#de77ab", "#e67d58"], ["#e8777a", "#d58d25"], ["#e28247", "#b39f18"], + ["#cb9317", "#81af4b"], ["#a4a528", "#37b880"], ["#6cb35d", "#00b6af"], + ["#3cb693", "#26afd1"], ["#00b4bb", "#58a3ed"], ["#00ade4", "#8e93f3"], + ["#699ef5", "#b984df"], ["#9e8eef", "#d779ba"], ["#c480d4", "#e7768a"], + ["#9a3e67", "#9d4616"], ["#974a2e", "#7a6000"], ["#7e5e00", "#47712c"], + ["#4b7015", "#007465"], ["#1c7360", "#1c6d88"], ["#006f87", "#485daa"], + ["#3e5fad", "#7f4995"], ["#7a4b9a", "#9c3e60"], +]; + +export interface AvatarStyle { + bg: string; // base color / first gradient stop + bg2: string; // second gradient stop + angleDeg: number; // gradient direction + text: "white" | "black"; // readable foreground for the ring +} + /** - * Deterministic dark circle color for an emoji/sparkles agent glyph, picked from - * GLYPH_COLORS by a hash of the agent name so distinct agents get categorically - * distinct colors while every color stays dark enough to keep the glyph readable. + * Deterministic, cross-platform avatar style for an agent glyph. Disjoint bit + * ranges of ONE cyrb53 hash drive INDEPENDENT visual channels — palette color + * (20) × gradient partner (2) × gradient angle (8) = 320 combinations — so even + * when the base color repeats (unavoidable: humans reliably tell apart only + * ~20-25 colors), the gradient — and the emoji drawn on top — still tell two + * agents apart. Pure function of the normalized name: same name → same avatar on + * every device, nothing persisted. */ -export function agentGlyphBackground(name: string): string { - return GLYPH_COLORS[hashName(name) % GLYPH_COLORS.length]; +export function avatarStyle(agentName: string): AvatarStyle { + const h = cyrb53(normalizeName(agentName)); + const idx = h % AVATAR_PALETTE.length; // which palette color + const rest = Math.floor(h / AVATAR_PALETTE.length); + const dir = rest % 2; // gradient partner: hue -25 or +25 + const angleDeg = (Math.floor(rest / 2) % 8) * 45; // one of 8 gradient angles + return { + bg: AVATAR_PALETTE[idx], + bg2: GRADIENT_PARTNERS[idx][dir], + angleDeg, + text: idx < 12 ? "black" : "white", + }; } /** * The front avatar. Image-source priority (#300): * 1. agent.avatarUrl -> a real avatar image (external MCP agent account). - * 2. agent.emoji -> the role emoji on a per-agent dark circle. - * 3. otherwise -> the IconSparkles glyph on a per-agent dark circle (fallback). + * 2. agent.emoji -> the role emoji on a per-agent gradient circle. + * 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle. */ function AgentGlyph({ agent }: { agent: AgentInfo }) { if (agent.avatarUrl) { @@ -89,10 +131,12 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) { ); } - // Emoji/sparkles glyph on a per-agent dark circle (color hashed from the agent - // name). Rendered as a plain Box, NOT a Mantine `Avatar variant="filled"`, so - // the background is guaranteed instead of being overridden by Mantine's - // `--avatar-bg` (which was falling back to the theme's violet for every agent). + // Emoji/sparkles glyph on a per-agent gradient circle (color + gradient hashed + // from the agent name via avatarStyle). Rendered as a plain Box, NOT a Mantine + // `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background + // (every agent fell back to the theme's violet). The foreground (the sparkles + // icon) uses the ring's WCAG-checked readable text color. + const style = avatarStyle(agent.name); return ( Date: Fri, 3 Jul 2026 23:09:49 +0300 Subject: [PATCH 2/3] refactor(#300 ui): extract avatar palette into generated OKLCH module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline hand-transcribed palette with the self-contained src/lib/avatar-palette.ts: the 20-color palette is GENERATED at module load from an OKLCH ring config (chroma clamped to sRGB, WCAG text color per color), so it is fully tunable and validated (min pairwise ΔE-OK ≈ 0.066). avatarStyle() slices one cyrb53 hash of the normalized name into independent channels: base color (20) × color-wheel scheme (analogous ±20–45° / complement 180° / triadic ±120°) × split angle (24 dirs). avatarBackgroundCss() renders a two-stop gradient with a soft boundary. Pure, cross-platform, deterministic — same name → same avatar everywhere, nothing persisted. The glyph now consumes avatarStyle/avatarBackgroundCss from the module; agent-avatar-stack no longer defines its own hash/palette. Tests: avatar-palette.test.ts pins minPairwiseDistance ≥ 0.06, PALETTE length, normalization, and a golden name→style slice (Backend Developer → #a55795/#90355e/150°) so a config change that repaints every avatar can't slip through unnoticed. client tsc clean, 30 tests pass. Co-Authored-By: Claude Opus 4.8 --- .../components/ui/agent-avatar-stack.test.tsx | 38 +-- .../src/components/ui/agent-avatar-stack.tsx | 93 +----- apps/client/src/lib/avatar-palette.test.ts | 73 +++++ apps/client/src/lib/avatar-palette.ts | 267 ++++++++++++++++++ 4 files changed, 347 insertions(+), 124 deletions(-) create mode 100644 apps/client/src/lib/avatar-palette.test.ts create mode 100644 apps/client/src/lib/avatar-palette.ts diff --git a/apps/client/src/components/ui/agent-avatar-stack.test.tsx b/apps/client/src/components/ui/agent-avatar-stack.test.tsx index 98b1c0ad..aa97ec43 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.test.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.test.tsx @@ -2,12 +2,8 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { Provider, createStore } from "jotai"; -import { - AgentAvatarStack, - avatarStyle, - AVATAR_PALETTE, - GRADIENT_PARTNERS, -} from "./agent-avatar-stack"; +import { AgentAvatarStack } from "./agent-avatar-stack"; +import { avatarStyle } from "@/lib/avatar-palette"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, @@ -43,36 +39,6 @@ function renderStack(props: Props) { return { store, ...utils }; } -describe("avatarStyle", () => { - it("is deterministic and normalizes the name", () => { - // Same name → same style; casing / surrounding whitespace must not matter. - expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher")); - expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher")); - }); - - it("gives different agents different styles (incl. the report's pair)", () => { - // "Структурный редактор" and "Фактчекер" looked identically violet; their - // full styles must now differ. - expect(avatarStyle("Структурный редактор")).not.toEqual( - avatarStyle("Фактчекер"), - ); - }); - - it("returns a valid palette color, gradient partner, angle and text", () => { - const s = avatarStyle("Нарратор"); - const idx = AVATAR_PALETTE.indexOf(s.bg); - expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry - // bg2 is one of the two hue-shifted partners of the chosen base color. - expect(GRADIENT_PARTNERS[idx]).toContain(s.bg2); - // angle is one of the 8 discrete directions (0,45,…,315). - expect(s.angleDeg % 45).toBe(0); - expect(s.angleDeg).toBeGreaterThanOrEqual(0); - expect(s.angleDeg).toBeLessThan(360); - // Light ring (first 12) → black text, dark ring → white text. - expect(s.text).toBe(idx < 12 ? "black" : "white"); - }); -}); - describe("AgentAvatarStack", () => { it("internal chat WITH role: emoji glyph + human launcher badge in front", () => { const { container } = renderStack({ diff --git a/apps/client/src/components/ui/agent-avatar-stack.tsx b/apps/client/src/components/ui/agent-avatar-stack.tsx index 4381ef00..0f6a8a06 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.tsx @@ -4,6 +4,7 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useSetAtom } from "jotai"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, @@ -29,91 +30,6 @@ const LAUNCHER_SIZE = 22; // sits as a small badge over that corner (above the glyph) and stays fully visible. const LAUNCHER_OVERHANG = 8; -// Normalize the name before hashing so "PM ", "pm", "Pm" all map to the same -// avatar (unicode-normalized, trimmed, lower-cased, whitespace collapsed). -function normalizeName(name: string): string { - return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " "); -} - -// cyrb53: deterministic 53-bit string hash with good avalanche, pure JS. A -// language's BUILT-IN hash (Java hashCode, etc.) must NOT be used — those differ -// across platforms/engines, which would make one name render as different avatars -// on server vs client. This is stable everywhere. -function cyrb53(str: string, seed = 0): number { - let h1 = 0xdeadbeef ^ seed; - let h2 = 0x41c6ce57 ^ seed; - for (let i = 0; i < str.length; i += 1) { - const ch = str.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = - Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ - Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = - Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ - Math.imul(h1 ^ (h1 >>> 13), 3266489909); - return 4294967296 * (2097151 & h2) + (h1 >>> 0); -} - -// Perceptually-even avatar palette built in OKLCH and clamped to the sRGB gamut: -// 12 LIGHT colors (L≈0.70, black text) then 8 DARK colors (L≈0.50, white text). -// The minimum pairwise ΔEOK ≈ 0.066 (~5 JNDs), so any two entries are either the -// SAME color or clearly distinguishable — "almost identical" colors are -// impossible by construction (that was the old raw-hue failure mode). Text -// contrast is WCAG-checked: dark ring ≥ 5.6:1 white, light ring ≥ 7.3:1 black. -export const AVATAR_PALETTE = [ - // light ring (black text) - "#e87782", "#e57f4f", "#d0901e", "#aca220", "#77b154", "#22b988", - "#00b5b5", "#00afdc", "#5fa1f3", "#9690f1", "#bf82da", "#db78b2", - // dark ring (white text) - "#a03e43", "#8e5300", "#686800", "#007742", - "#007176", "#0068a5", "#6453a7", "#8f4280", -]; - -// Second gradient stop per palette entry (index-aligned): two hue-shifted (±25°) -// partners. A separate hash channel picks which one, so two agents that collide -// on the base color almost always still differ by their gradient. -export const GRADIENT_PARTNERS = [ - ["#de77ab", "#e67d58"], ["#e8777a", "#d58d25"], ["#e28247", "#b39f18"], - ["#cb9317", "#81af4b"], ["#a4a528", "#37b880"], ["#6cb35d", "#00b6af"], - ["#3cb693", "#26afd1"], ["#00b4bb", "#58a3ed"], ["#00ade4", "#8e93f3"], - ["#699ef5", "#b984df"], ["#9e8eef", "#d779ba"], ["#c480d4", "#e7768a"], - ["#9a3e67", "#9d4616"], ["#974a2e", "#7a6000"], ["#7e5e00", "#47712c"], - ["#4b7015", "#007465"], ["#1c7360", "#1c6d88"], ["#006f87", "#485daa"], - ["#3e5fad", "#7f4995"], ["#7a4b9a", "#9c3e60"], -]; - -export interface AvatarStyle { - bg: string; // base color / first gradient stop - bg2: string; // second gradient stop - angleDeg: number; // gradient direction - text: "white" | "black"; // readable foreground for the ring -} - -/** - * Deterministic, cross-platform avatar style for an agent glyph. Disjoint bit - * ranges of ONE cyrb53 hash drive INDEPENDENT visual channels — palette color - * (20) × gradient partner (2) × gradient angle (8) = 320 combinations — so even - * when the base color repeats (unavoidable: humans reliably tell apart only - * ~20-25 colors), the gradient — and the emoji drawn on top — still tell two - * agents apart. Pure function of the normalized name: same name → same avatar on - * every device, nothing persisted. - */ -export function avatarStyle(agentName: string): AvatarStyle { - const h = cyrb53(normalizeName(agentName)); - const idx = h % AVATAR_PALETTE.length; // which palette color - const rest = Math.floor(h / AVATAR_PALETTE.length); - const dir = rest % 2; // gradient partner: hue -25 or +25 - const angleDeg = (Math.floor(rest / 2) % 8) * 45; // one of 8 gradient angles - return { - bg: AVATAR_PALETTE[idx], - bg2: GRADIENT_PARTNERS[idx][dir], - angleDeg, - text: idx < 12 ? "black" : "white", - }; -} - /** * The front avatar. Image-source priority (#300): * 1. agent.avatarUrl -> a real avatar image (external MCP agent account). @@ -131,8 +47,9 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) { ); } - // Emoji/sparkles glyph on a per-agent gradient circle (color + gradient hashed - // from the agent name via avatarStyle). Rendered as a plain Box, NOT a Mantine + // Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner + // and split angle all hashed from the agent name via avatarStyle — see + // @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine // `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background // (every agent fell back to the theme's violet). The foreground (the sparkles // icon) uses the ring's WCAG-checked readable text color. @@ -147,7 +64,7 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) { // Solid base color is the fallback (and the testable value); the gradient // paints over it in browsers that support it. backgroundColor: style.bg, - backgroundImage: `linear-gradient(${style.angleDeg}deg, ${style.bg}, ${style.bg2})`, + backgroundImage: avatarBackgroundCss(style), color: style.text === "white" ? "var(--mantine-color-white)" diff --git a/apps/client/src/lib/avatar-palette.test.ts b/apps/client/src/lib/avatar-palette.test.ts new file mode 100644 index 00000000..1c561d69 --- /dev/null +++ b/apps/client/src/lib/avatar-palette.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { + PALETTE, + avatarStyle, + avatarBackgroundCss, + normalizeName, + minPairwiseDistance, +} from "./avatar-palette"; + +describe("avatar-palette validation", () => { + it("palette colors stay distinguishable", () => { + // 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future + // RINGS tweak drops this, "almost identical" colors would reappear. + expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06); + expect(PALETTE.length).toBe(20); + }); + + it("every palette entry is a hex with a valid WCAG text color", () => { + for (const entry of PALETTE) { + expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/); + expect(["white", "black"]).toContain(entry.text); + } + }); +}); + +describe("avatarStyle", () => { + it("name-to-avatar mapping is frozen (golden values)", () => { + // Golden slice: if this breaks, all existing avatars change — make sure + // that is intentional (a config change in avatar-palette.ts). + const s = avatarStyle("Backend Developer"); + expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]); + expect(s.text).toBe("white"); + }); + + it("is deterministic and normalizes the name", () => { + expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher")); + // Casing, surrounding and repeated whitespace must not change the avatar. + expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher")); + expect(avatarStyle("Backend Developer")).toEqual( + avatarStyle("backend developer"), + ); + expect(normalizeName(" PM ")).toBe("pm"); + }); + + it("returns a valid base color, angle and matching text", () => { + const s = avatarStyle("Нарратор"); + const idx = PALETTE.findIndex((e) => e.hex === s.bg); + expect(idx).toBe(s.paletteIndex); + expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry + // Text color comes from the chosen palette entry. + expect(s.text).toBe(PALETTE[idx].text); + // Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15. + expect(s.angleDeg % 15).toBe(0); + expect(s.angleDeg).toBeGreaterThanOrEqual(0); + expect(s.angleDeg).toBeLessThan(360); + }); + + it("distinguishes the agents that used to collide as violet", () => { + // "Структурный редактор" and "Фактчекер" looked identically violet before. + expect(avatarStyle("Структурный редактор")).not.toEqual( + avatarStyle("Фактчекер"), + ); + }); +}); + +describe("avatarBackgroundCss", () => { + it("renders a two-stop gradient with a soft boundary", () => { + const s = avatarStyle("Backend Developer"); + expect(avatarBackgroundCss(s)).toBe( + "linear-gradient(150deg, #a55795 42%, #90355e 58%)", + ); + }); +}); diff --git a/apps/client/src/lib/avatar-palette.ts b/apps/client/src/lib/avatar-palette.ts new file mode 100644 index 00000000..b5ed25fa --- /dev/null +++ b/apps/client/src/lib/avatar-palette.ts @@ -0,0 +1,267 @@ +/** + * Deterministic avatar backgrounds for agent roles. + * + * The palette is generated from scratch at module load in OKLCH (a perceptually + * uniform color space), so every value below is tunable: change the ring + * configuration or the partner shifts and the whole palette regenerates. + * + * Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields: + * - base color index (one of the validated palette colors) + * - partner hue shift: analogous 20..45deg (either side), complementary 180deg, + * or triadic +/-120deg — classic color-wheel schemes; partner is also darker + * - split angle (SPLIT_ANGLE_STEPS directions, soft boundary) + * The same name always yields the same avatar, on any platform, forever. + */ + +// ------------------------- Tunable configuration ------------------------- + +export interface RingConfig { + /** OKLCH lightness, 0..1 */ + L: number; + /** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */ + C: number; + /** Hue of the first color in the ring, degrees */ + hueStart: number; + /** Number of evenly spaced hues in the ring */ + count: number; +} + +/** + * Two lightness rings. 12 light + 8 dark = 20 base colors with a validated + * min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size). + * Don't add more hues per ring without re-checking minPairwiseDistance(): + * beyond ~20-24 colors humans stop telling them apart reliably. + */ +const RINGS: readonly RingConfig[] = [ + { L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring + { L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring +]; + +/** Partner color: lightness shifted by this much (negative = darker) */ +const PARTNER_L_SHIFT = -0.10; +/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */ +const ANALOG_MIN_SHIFT = 20; +const ANALOG_SHIFT_STEP = 5; +const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45 +/** Complementary scheme: fixed hue shift, degrees */ +const COMPLEMENTARY_SHIFT = 180; +/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */ +const TRIADIC_SHIFT = 120; +/** Number of split directions (24 -> 15deg per step) */ +const SPLIT_ANGLE_STEPS = 24; +/** Position of the color boundary, percent of the gradient axis */ +const SPLIT_PERCENT = 50; +/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */ +const SPLIT_SOFTNESS = 16; + +// ------------------------- OKLCH -> sRGB math ------------------------- +// Matrices from Bjorn Ottosson's OKLab reference implementation. + +function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; + const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3; + return [ + +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + ]; +} + +function gammaEncode(c: number): number { + return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; +} + +function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] { + const h = (hDeg * Math.PI) / 180; + const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h)); + return [gammaEncode(r), gammaEncode(g), gammaEncode(b)]; +} + +function isInGamut(rgb: readonly number[]): boolean { + return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6); +} + +/** Binary-search the max chroma <= C that fits into the sRGB gamut. */ +function clampChroma(L: number, C: number, hDeg: number): number { + if (isInGamut(oklchToSrgb(L, C, hDeg))) return C; + let lo = 0, hi = C; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid; + else hi = mid; + } + return lo; +} + +function toHex(rgb: readonly number[]): string { + return ( + "#" + + rgb + .map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0")) + .join("") + ); +} + +/** WCAG relative luminance of an sRGB color (components 0..1). */ +function relativeLuminance(rgb: readonly number[]): number { + const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; +} + +function contrastRatio(l1: number, l2: number): number { + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} + +// ------------------------- Palette generation ------------------------- + +export interface PaletteEntry { + /** Base background color */ + hex: string; + /** OKLCH coordinates of the base color (used to derive partner colors) */ + L: number; + C: number; + h: number; + /** Text/icon color with the best WCAG contrast on the base color */ + text: "white" | "black"; + /** OKLab coordinates of the base color (kept for validation) */ + lab: readonly [number, number, number]; +} + +function buildPalette(): PaletteEntry[] { + const entries: PaletteEntry[] = []; + for (const ring of RINGS) { + const step = 360 / ring.count; + for (let i = 0; i < ring.count; i++) { + const h = (ring.hueStart + i * step) % 360; + const C = clampChroma(ring.L, ring.C, h); + const rgb = oklchToSrgb(ring.L, C, h); + const lum = relativeLuminance(rgb); + entries.push({ + hex: toHex(rgb), + L: ring.L, + C, + h, + // White text needs >= 3:1 contrast; otherwise fall back to black. + text: contrastRatio(lum, 1) >= 3 ? "white" : "black", + lab: [ + ring.L, + C * Math.cos((h * Math.PI) / 180), + C * Math.sin((h * Math.PI) / 180), + ], + }); + } + } + return entries; +} + +/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */ +function partnerHex(entry: PaletteEntry, shiftDeg: number): string { + const h2 = (entry.h + shiftDeg + 360) % 360; + const L2 = entry.L + PARTNER_L_SHIFT; + return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2)); +} + +/** Generated once at module load; regenerates on every build from the config above. */ +export const PALETTE: readonly PaletteEntry[] = buildPalette(); + +// ------------------------- Name -> avatar style ------------------------- + +/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */ +export function normalizeName(name: string): string { + return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * cyrb53: deterministic 53-bit string hash with good avalanche. + * Pure JS, cross-platform — never use language built-in hashing here. + */ +function cyrb53(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +export interface AvatarStyle { + /** Index of the base color in PALETTE */ + paletteIndex: number; + /** Base color hex */ + bg: string; + /** Second color hex (split partner) */ + bg2: string; + /** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */ + hueShift: number; + /** Direction of the split, degrees */ + angleDeg: number; + /** Text/icon color for the base color */ + text: "white" | "black"; +} + +/** Pure function: the same (normalized) name always returns the same style. */ +export function avatarStyle(agentName: string): AvatarStyle { + const h = cyrb53(normalizeName(agentName)); + // Slice the hash into independent fields, like digits of a number: + const paletteIndex = h % PALETTE.length; + let rest = Math.floor(h / PALETTE.length); + const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS); + rest = Math.floor(rest / SPLIT_ANGLE_STEPS); + // Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic + const scheme = rest % 4; + rest = Math.floor(rest / 4); + let hueShift: number; + if (scheme === 2) { + hueShift = COMPLEMENTARY_SHIFT; + } else if (scheme === 3) { + hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT; + } else { + const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP; + hueShift = scheme === 0 ? -magnitude : magnitude; + } + const entry = PALETTE[paletteIndex]; + return { + paletteIndex, + bg: entry.hex, + bg2: partnerHex(entry, hueShift), + hueShift, + angleDeg, + text: entry.text, + }; +} + +/** CSS background value: two colors with a slightly blurred boundary. */ +export function avatarBackgroundCss(style: AvatarStyle): string { + const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2; + const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2; + return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`; +} + +// ------------------------- Validation ------------------------- + +/** + * Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors. + * Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors + * look alike. Intended for a unit test or a dev-time assertion. + */ +export function minPairwiseDistance(): { distance: number; pair: [string, string] } { + let min = Infinity; + let pair: [string, string] = ["", ""]; + for (let i = 0; i < PALETTE.length; i++) { + for (let j = i + 1; j < PALETTE.length; j++) { + const a = PALETTE[i].lab, b = PALETTE[j].lab; + const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + if (d < min) { + min = d; + pair = [PALETTE[i].hex, PALETTE[j].hex]; + } + } + } + return { distance: min, pair }; +} -- 2.52.0 From 8971912d9e4aa5937721903d26a857b6b3339e33 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 3 Jul 2026 23:40:55 +0300 Subject: [PATCH 3/3] test(#320): make the palette WCAG/gamut check non-vacuous per entry (F1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old avatar-palette test only did expect(["white","black"]).toContain( entry.text), which can never fail (text is typed "white"|"black" and always assigned) — so the load-bearing property "all 20 colors are readable" was only really checked for the single golden name. A generator bug producing a low-contrast or out-of-gamut slot would survive the suite. Export the four existing color-math helpers (oklchToSrgb, isInGamut, relativeLuminance, contrastRatio — no logic change) and assert, for EVERY PALETTE entry: - (a) real contrast of the chosen text on the entry hex >= 3 (the code's threshold), scale-matched (hex 0..255 → /255 before relativeLuminance). Since buildPalette PREFERS white and only falls back to black when white fails 3:1, the test also asserts: if text=="black" then white's contrast is < 3 (black was mandatory) — matching the code's actual decision, not a max-contrast pick. - (b) the OKLCH is in sRGB gamut post-clamp: isInGamut(oklchToSrgb(L,C,h)). Demonstrated non-vacuous: a light bg mislabeled text:"white" → chosen contrast 1.67 (< 3) fails; an out-of-gamut component fails isInGamut. Golden-name and minPairwiseDistance tests untouched. vitest: 15 passed. No palette/hash/consumer logic changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/client/src/lib/avatar-palette.test.ts | 38 ++++++++++++++++++++-- apps/client/src/lib/avatar-palette.ts | 8 ++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/client/src/lib/avatar-palette.test.ts b/apps/client/src/lib/avatar-palette.test.ts index 1c561d69..7fd1530c 100644 --- a/apps/client/src/lib/avatar-palette.test.ts +++ b/apps/client/src/lib/avatar-palette.test.ts @@ -5,8 +5,21 @@ import { avatarBackgroundCss, normalizeName, minPairwiseDistance, + relativeLuminance, + contrastRatio, + oklchToSrgb, + isInGamut, } from "./avatar-palette"; +/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */ +function hexToRgb01(hex: string): [number, number, number] { + return [ + parseInt(hex.slice(1, 3), 16) / 255, + parseInt(hex.slice(3, 5), 16) / 255, + parseInt(hex.slice(5, 7), 16) / 255, + ]; +} + describe("avatar-palette validation", () => { it("palette colors stay distinguishable", () => { // 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future @@ -15,10 +28,31 @@ describe("avatar-palette validation", () => { expect(PALETTE.length).toBe(20); }); - it("every palette entry is a hex with a valid WCAG text color", () => { + it("every palette entry is WCAG-readable and in sRGB gamut", () => { + // white text = luminance 1, black text = luminance 0 (per buildPalette). + const textLum = { white: 1, black: 0 } as const; for (const entry of PALETTE) { expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/); - expect(["white", "black"]).toContain(entry.text); + + // (a) The chosen text color really clears the code's 3:1 threshold on the + // actual background hex — recomputed independently from the hex, not from + // the build-time luminance. A slot that picked the wrong text (or a color + // too dim for either text) would fail here. + const hexLum = relativeLuminance(hexToRgb01(entry.hex)); + const chosen = contrastRatio(textLum[entry.text], hexLum); + expect(chosen).toBeGreaterThanOrEqual(3); + // buildPalette prefers white and only falls back to black when white + // fails 3:1. Mirror that decision: black is used *only* when white would + // not clear the threshold — so a mis-assigned "black" on a dark color + // (where white was fine) fails here. + if (entry.text === "black") { + expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3); + } + + // (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping; + // an out-of-gamut slot (e.g. un-clamped chroma) would produce components + // outside [0,1] and fail here. + expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true); } }); }); diff --git a/apps/client/src/lib/avatar-palette.ts b/apps/client/src/lib/avatar-palette.ts index b5ed25fa..8432412c 100644 --- a/apps/client/src/lib/avatar-palette.ts +++ b/apps/client/src/lib/avatar-palette.ts @@ -73,13 +73,13 @@ function gammaEncode(c: number): number { return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; } -function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] { +export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] { const h = (hDeg * Math.PI) / 180; const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h)); return [gammaEncode(r), gammaEncode(g), gammaEncode(b)]; } -function isInGamut(rgb: readonly number[]): boolean { +export function isInGamut(rgb: readonly number[]): boolean { return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6); } @@ -105,12 +105,12 @@ function toHex(rgb: readonly number[]): string { } /** WCAG relative luminance of an sRGB color (components 0..1). */ -function relativeLuminance(rgb: readonly number[]): number { +export function relativeLuminance(rgb: readonly number[]): number { const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; } -function contrastRatio(l1: number, l2: number): number { +export function contrastRatio(l1: number, l2: number): number { return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); } -- 2.52.0