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:
@@ -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() : "?";
|
||||
}
|
||||
Reference in New Issue
Block a user