From b767a0c1919d725a520ce4b34e27686b30a9fd93 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 01:57:37 +0530 Subject: [PATCH] feat(ui): deterministic avatar initials + hue helper Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/src/avatar.ts | 28 ++++++++++++++++++++++++++ packages/ui/src/index.ts | 1 + packages/ui/test/avatar.test.ts | 35 +++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 packages/ui/src/avatar.ts create mode 100644 packages/ui/test/avatar.test.ts diff --git a/packages/ui/src/avatar.ts b/packages/ui/src/avatar.ts new file mode 100644 index 0000000..3bac0cc --- /dev/null +++ b/packages/ui/src/avatar.ts @@ -0,0 +1,28 @@ +/** Deterministic identity visuals: initials + a stable hue from the name. */ + +export function initials(name: string): string { + const words = name.trim().split(/\s+/).filter((w) => w !== '') + if (words.length === 0) return '?' + const first = words[0]!.charAt(0) + const second = words.length > 1 ? words[1]!.charAt(0) : '' + return (first + second).toUpperCase() +} + +/** FNV-1a over the name → hue bucket; same name always gets the same colour. */ +export function avatarHue(name: string): number { + let h = 0x811c9dc5 + for (let i = 0; i < name.length; i++) { + h ^= name.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return (h >>> 0) % 360 +} + +/** Soft tinted background + readable foreground for both themes. */ +export function avatarColors(name: string): { bg: string; fg: string } { + const h = avatarHue(name) + return { + bg: `hsl(${h} 42% 50% / 0.16)`, + fg: `hsl(${h} 45% 38%)`, + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e0f1319..53bec6d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,4 @@ +export * from './avatar' export * from './components' export * from './theme' export * from './ThemeSwitcher' diff --git a/packages/ui/test/avatar.test.ts b/packages/ui/test/avatar.test.ts new file mode 100644 index 0000000..bcdd8ea --- /dev/null +++ b/packages/ui/test/avatar.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { avatarColors, avatarHue, initials } from '../src/avatar' + +describe('initials', () => { + it('takes the first letter of the first two words', () => { + expect(initials('Acme UK Compliance Group')).toBe('AU') + expect(initials('hamiltel')).toBe('H') + }) + it('falls back to ? for empty input', () => { + expect(initials('')).toBe('?') + expect(initials(' ')).toBe('?') + }) +}) + +describe('avatarHue', () => { + it('is deterministic', () => { + expect(avatarHue('Hamiltel')).toBe(avatarHue('Hamiltel')) + }) + it('stays in [0, 360)', () => { + for (const n of ['a', 'Acme', 'Zed Systems', '৪২']) { + const h = avatarHue(n) + expect(h).toBeGreaterThanOrEqual(0) + expect(h).toBeLessThan(360) + } + }) +}) + +describe('avatarColors', () => { + it('returns hsl strings derived from the hue', () => { + const { bg, fg } = avatarColors('Hamiltel') + const h = avatarHue('Hamiltel') + expect(bg).toContain(`hsl(${h}`) + expect(fg).toContain(`hsl(${h}`) + }) +})