feat(web): letter avatars for custom model groups and agents (#31)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-22 22:04:34 +08:00
committed by GitHub
parent c9cd1f9dc5
commit 6d25adfef4
10 changed files with 300 additions and 75 deletions
+62
View File
@@ -0,0 +1,62 @@
/**
* 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() : "?";
}