feat(#300 ui): quantized OKLCH palette + multi-channel agent avatar
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof AgentAvatarStack>;
|
||||
|
||||
// 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<HTMLElement>(
|
||||
'[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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
data-testid="agent-glyph"
|
||||
@@ -100,8 +144,14 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
width: GLYPH_SIZE,
|
||||
height: GLYPH_SIZE,
|
||||
borderRadius: "50%",
|
||||
background: agentGlyphBackground(agent.name),
|
||||
color: "var(--mantine-color-white)",
|
||||
// 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})`,
|
||||
color:
|
||||
style.text === "white"
|
||||
? "var(--mantine-color-white)"
|
||||
: "var(--mantine-color-black)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
Reference in New Issue
Block a user