Files
gitmost/apps/client/src/lib/avatar-palette.ts
T
claude code agent 227 8971912d9e test(#320): make the palette WCAG/gamut check non-vacuous per entry (F1)
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) <noreply@anthropic.com>
2026-07-03 23:40:55 +03:00

268 lines
9.6 KiB
TypeScript

/**
* 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;
}
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)];
}
export 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). */
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];
}
export 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 };
}