refactor(#300 ui): extract avatar palette into generated OKLCH module

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 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-07-03 23:09:49 +03:00
parent 62b818bb36
commit 45478098f5
4 changed files with 347 additions and 124 deletions
@@ -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({