6d25adfef4
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
63 lines
2.4 KiB
TypeScript
63 lines
2.4 KiB
TypeScript
/**
|
|
* 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() : "?";
|
|
}
|