From aa66fe188dbb2e7b0514ea366a065de705376cf6 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 10:26:20 +0530 Subject: [PATCH 1/3] fix: [avatar] order-sensitive getAvatarColor hash, add seed and palette options getAvatarColor summed char codes, so anagrams always got the same color. It now uses 32-bit FNV-1a from a new shared/hash module. The data-view test helper uses the same function. getAvatarColor takes optional seed and palette options. AVATAR_COLOR_PALETTE and GetAvatarColorOptions are exported. Most strings map to a new color after this change. Closes #850 --- .../content/docs/components/avatar/demo.ts | 32 ++++- .../content/docs/components/avatar/index.mdx | 27 ++++- .../content/docs/components/avatar/props.ts | 25 ++++ .../avatar/__tests__/avatar.test.tsx | 110 +++++++++++++++++- packages/raystack/components/avatar/index.tsx | 6 +- packages/raystack/components/avatar/utils.tsx | 42 ++++++- .../components/data-view/__tests__/helpers.ts | 10 +- packages/raystack/index.tsx | 8 +- .../shared/hash/__tests__/hash.test.ts | 13 +++ packages/raystack/shared/hash/hash.ts | 11 ++ packages/raystack/shared/hash/index.ts | 1 + 11 files changed, 268 insertions(+), 17 deletions(-) create mode 100644 packages/raystack/shared/hash/__tests__/hash.test.ts create mode 100644 packages/raystack/shared/hash/hash.ts create mode 100644 packages/raystack/shared/hash/index.ts diff --git a/apps/www/src/content/docs/components/avatar/demo.ts b/apps/www/src/content/docs/components/avatar/demo.ts index cf180a1c8..ad82d2815 100644 --- a/apps/www/src/content/docs/components/avatar/demo.ts +++ b/apps/www/src/content/docs/components/avatar/demo.ts @@ -133,7 +133,33 @@ export const imageDemo = { export const generatedColorDemo = { type: 'code', code: ` - - - ` + function GeneratedColors() { + const people = [ + { name: "Ravi Chopra", initials: "RC" }, + { name: "Alice", initials: "A" }, + { name: "Bob", initials: "B" }, + { name: "amy", initials: "AM" }, + { name: "may", initials: "MA" } + ]; + + return ( + + + {people.map(({ name, initials }) => ( + + ))} + + + {people.map(({ name, initials }) => ( + + ))} + + + ); + }` }; diff --git a/apps/www/src/content/docs/components/avatar/index.mdx b/apps/www/src/content/docs/components/avatar/index.mdx index 5db95b23e..01b1548d9 100644 --- a/apps/www/src/content/docs/components/avatar/index.mdx +++ b/apps/www/src/content/docs/components/avatar/index.mdx @@ -67,7 +67,24 @@ Avatar can display user images with graceful fallback to initials when images fa ### With generated colors -use `getAvatarColor` utility to generate colors based on a string. +`getAvatarColor` derives a color from a string, such as a user's email. The same string always returns the same color, on the server and in the browser. + +```tsx +getAvatarColor(user.email); + +// A different seed maps the same email to a different color. +getAvatarColor(user.email, { seed: workspaceId }); + +// Pick only from these colors. +getAvatarColor(user.email, { palette: ['indigo', 'mint', 'sky'] }); +``` + +`AVATAR_COLOR_PALETTE` lists every color `getAvatarColor` can return. Use it to build a `palette`, for example to leave out `'neutral'`, which `AvatarGroup` uses for the overflow count: + +```tsx +const palette = AVATAR_COLOR_PALETTE.filter(color => color !== 'neutral'); +getAvatarColor(user.email, { palette }); +``` @@ -87,6 +104,14 @@ Groups multiple avatars with overlap and count indicator. +### getAvatarColor + +`getAvatarColor(str: string, options?: GetAvatarColorOptions)` returns one of the colors in `AVATAR_COLOR_PALETTE`. + + + +`getAvatarColor` ignores duplicate and unknown colors in `palette`. If no valid color is left, it uses all of `AVATAR_COLOR_PALETTE` and logs one warning in development. + ### Slots Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): diff --git a/apps/www/src/content/docs/components/avatar/props.ts b/apps/www/src/content/docs/components/avatar/props.ts index d2148e49a..259bebb5a 100644 --- a/apps/www/src/content/docs/components/avatar/props.ts +++ b/apps/www/src/content/docs/components/avatar/props.ts @@ -69,3 +69,28 @@ export interface AvatarGroupProps { /** Additional CSS class names */ className?: string; } + +export interface GetAvatarColorOptions { + /** Mixed into the hash so the same string can map to a different color. A number and its string form give the same color. */ + seed?: string | number; + + /** + * Restricts the result to these colors. Order matters. Duplicates and unknown colors are ignored. If none remain, all colors are used. + * @defaultValue `AVATAR_COLOR_PALETTE` + */ + palette?: Array< + | 'indigo' + | 'orange' + | 'mint' + | 'neutral' + | 'sky' + | 'lime' + | 'grass' + | 'cyan' + | 'iris' + | 'purple' + | 'pink' + | 'crimson' + | 'gold' + >; +} diff --git a/packages/raystack/components/avatar/__tests__/avatar.test.tsx b/packages/raystack/components/avatar/__tests__/avatar.test.tsx index d98417c5e..f84548508 100644 --- a/packages/raystack/components/avatar/__tests__/avatar.test.tsx +++ b/packages/raystack/components/avatar/__tests__/avatar.test.tsx @@ -3,7 +3,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { radiusClasses } from '../../../shared/radius'; import { Avatar, AvatarGroup } from '../avatar'; import styles from '../avatar.module.css'; -import { getAvatarColor } from '../utils'; +import { + AVATAR_COLOR_PALETTE, + type AVATAR_COLORS, + COLORS, + getAvatarColor +} from '../utils'; describe('Avatar', () => { const ogImage = window.Image; @@ -318,6 +323,109 @@ describe('Avatar', () => { const color = getAvatarColor('test'); expect(validColors).toContain(color); }); + + it('maps anagrams to different colors', () => { + expect(getAvatarColor('abc')).toBe('cyan'); + expect(getAvatarColor('cba')).toBe('orange'); + expect(getAvatarColor('amy')).toBe('sky'); + expect(getAvatarColor('may')).toBe('gold'); + expect(getAvatarColor('listen')).toBe('iris'); + expect(getAvatarColor('silent')).toBe('cyan'); + }); + + it('returns a known color for a known string', () => { + expect(getAvatarColor('john.doe@example.com')).toBe('pink'); + }); + + it('returns the same color for the same input and options', () => { + const options = { + seed: 'workspace', + palette: ['mint', 'sky'] + } as const; + const first = getAvatarColor('alice', options); + for (let i = 0; i < 10; i++) { + expect(getAvatarColor('alice', options)).toBe(first); + } + }); + + it('changes the color with the seed', () => { + expect(getAvatarColor('alice', { seed: 'a' })).toBe('iris'); + expect(getAvatarColor('alice', { seed: 'b' })).toBe('crimson'); + expect(getAvatarColor('alice', { seed: 1 })).toBe('mint'); + expect(getAvatarColor('alice', { seed: '1' })).toBe('mint'); + }); + + it('returns only and all colors from the palette', () => { + const palette = ['indigo', 'mint', 'sky'] as const; + const hit = new Set( + Array.from({ length: 200 }, (_, i) => + getAvatarColor(`u${i}`, { palette }) + ) + ); + expect([...hit].sort()).toEqual([...palette].sort()); + }); + + it('returns the only color of a single-color palette', () => { + for (let i = 0; i < 20; i++) { + expect(getAvatarColor(`u${i}`, { palette: ['gold'] })).toBe('gold'); + } + }); + + it('ignores duplicate palette colors', () => { + for (let i = 0; i < 50; i++) { + expect( + getAvatarColor(`u${i}`, { palette: ['sky', 'sky', 'mint'] }) + ).toBe(getAvatarColor(`u${i}`, { palette: ['sky', 'mint'] })); + } + }); + + it('ignores unknown palette colors', () => { + const palette = ['sky', 'teal', 'mint'] as unknown as AVATAR_COLORS[]; + for (let i = 0; i < 50; i++) { + expect(getAvatarColor(`u${i}`, { palette })).toBe( + getAvatarColor(`u${i}`, { palette: ['sky', 'mint'] }) + ); + } + }); + + it('falls back to all colors for an empty palette and warns once', async () => { + vi.resetModules(); + const utils = await import('../utils'); + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + for (let i = 0; i < 20; i++) { + expect(utils.getAvatarColor(`u${i}`, { palette: [] })).toBe( + utils.getAvatarColor(`u${i}`) + ); + } + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('uses every color over many strings', () => { + const hit = new Set( + Array.from({ length: 1000 }, (_, i) => getAvatarColor(`user-${i}`)) + ); + expect(hit.size).toBe(COLORS.length); + }); + + it('returns a valid color for an empty string', () => { + expect(getAvatarColor('')).toBe('neutral'); + }); + + it('has a color variant class for every palette color', () => { + expect(AVATAR_COLOR_PALETTE).toBe(COLORS); + for (const color of COLORS) { + const { container, unmount } = render( + + ); + const className = styles[`avatar-color-${color}`]; + expect(className).toBeTruthy(); + expect(container.firstElementChild).toHaveClass(className); + unmount(); + } + }); }); }); }); diff --git a/packages/raystack/components/avatar/index.tsx b/packages/raystack/components/avatar/index.tsx index 80be713d5..37af781f5 100644 --- a/packages/raystack/components/avatar/index.tsx +++ b/packages/raystack/components/avatar/index.tsx @@ -1,2 +1,6 @@ export { Avatar, AvatarGroup } from './avatar'; -export { getAvatarColor } from './utils'; +export { + AVATAR_COLOR_PALETTE, + type GetAvatarColorOptions, + getAvatarColor +} from './utils'; diff --git a/packages/raystack/components/avatar/utils.tsx b/packages/raystack/components/avatar/utils.tsx index 7d96f9f2d..d3a7ae7ec 100644 --- a/packages/raystack/components/avatar/utils.tsx +++ b/packages/raystack/components/avatar/utils.tsx @@ -1,3 +1,5 @@ +import { fnv1a } from '../../shared/hash'; + export const COLORS = [ 'indigo', 'orange', @@ -16,8 +18,40 @@ export const COLORS = [ export type AVATAR_COLORS = (typeof COLORS)[number]; -export function getAvatarColor(str: string): AVATAR_COLORS { - const hash = str.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - const index = hash % COLORS.length; - return COLORS[index]; +/** All avatar colors, in hash order. Use it to build a `palette` subset. */ +export const AVATAR_COLOR_PALETTE = COLORS; + +export interface GetAvatarColorOptions { + /** Mixed into the hash so the same string can map to a different color. A number and its string form give the same color. */ + seed?: string | number; + /** Restricts the result to these colors. Order matters. Duplicates and unknown colors are ignored. If none remain, all colors are used. */ + palette?: readonly AVATAR_COLORS[]; +} + +const COLOR_SET: ReadonlySet = new Set(COLORS); +let warnedEmptyPalette = false; + +function resolvePalette( + palette?: readonly AVATAR_COLORS[] +): readonly AVATAR_COLORS[] { + if (!palette) return COLORS; + const resolved = [...new Set(palette)].filter(color => COLOR_SET.has(color)); + if (resolved.length > 0) return resolved; + if (process.env.NODE_ENV !== 'production' && !warnedEmptyPalette) { + warnedEmptyPalette = true; + console.warn( + 'getAvatarColor: `palette` has no valid colors. Falling back to all avatar colors.' + ); + } + return COLORS; +} + +export function getAvatarColor( + str: string, + { seed, palette }: GetAvatarColorOptions = {} +): AVATAR_COLORS { + const colors = resolvePalette(palette); + // The separator keeps seed 'ab' + 'c' apart from seed 'a' + 'bc'. + const start = seed === undefined ? undefined : fnv1a(`${seed}\u0000`); + return colors[fnv1a(str, start) % colors.length]; } diff --git a/packages/raystack/components/data-view/__tests__/helpers.ts b/packages/raystack/components/data-view/__tests__/helpers.ts index 7ff24e546..52073760f 100644 --- a/packages/raystack/components/data-view/__tests__/helpers.ts +++ b/packages/raystack/components/data-view/__tests__/helpers.ts @@ -1,3 +1,5 @@ +import { fnv1a } from '~/shared/hash'; + /** * Fixtures shared by the data-view util suites. * @@ -17,13 +19,9 @@ export function seededRandom(seed: number) { /** FNV-1a over the decimal text, so [1, 23] and [12, 3] can't collide. */ export function digest(values: readonly number[]): string { - let hash = 0x811c9dc5; + let hash = fnv1a(''); for (const value of values) { - const text = `${value},`; - for (let i = 0; i < text.length; i++) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } + hash = fnv1a(`${value},`, hash); } return hash.toString(16).padStart(8, '0'); } diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 89156ac6e..9bb47e684 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -5,7 +5,13 @@ export { Accordion } from './components/accordion'; export { AlertDialog } from './components/alert-dialog'; export { Amount, type AmountProps } from './components/amount'; export { AnnouncementBar } from './components/announcement-bar'; -export { Avatar, AvatarGroup, getAvatarColor } from './components/avatar'; +export { + AVATAR_COLOR_PALETTE, + Avatar, + AvatarGroup, + type GetAvatarColorOptions, + getAvatarColor +} from './components/avatar'; export { Badge } from './components/badge'; export { Box } from './components/box'; export { Breadcrumb } from './components/breadcrumb'; diff --git a/packages/raystack/shared/hash/__tests__/hash.test.ts b/packages/raystack/shared/hash/__tests__/hash.test.ts new file mode 100644 index 000000000..d14f52d7c --- /dev/null +++ b/packages/raystack/shared/hash/__tests__/hash.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { fnv1a } from '../hash'; + +describe('fnv1a', () => { + it('matches the FNV-1a 32-bit test vectors', () => { + expect(fnv1a('')).toBe(0x811c9dc5); + expect(fnv1a('a')).toBe(0xe40c292c); + }); + + it('continues from a previous hash', () => { + expect(fnv1a('bc', fnv1a('a'))).toBe(fnv1a('abc')); + }); +}); diff --git a/packages/raystack/shared/hash/hash.ts b/packages/raystack/shared/hash/hash.ts new file mode 100644 index 000000000..c4f128499 --- /dev/null +++ b/packages/raystack/shared/hash/hash.ts @@ -0,0 +1,11 @@ +const FNV_OFFSET_BASIS = 0x811c9dc5; +const FNV_PRIME = 0x01000193; + +/** 32-bit FNV-1a over UTF-16 code units. Pass a previous result as `hash` to continue hashing from it. */ +export function fnv1a(str: string, hash = FNV_OFFSET_BASIS): number { + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = Math.imul(hash, FNV_PRIME); + } + return hash >>> 0; +} diff --git a/packages/raystack/shared/hash/index.ts b/packages/raystack/shared/hash/index.ts new file mode 100644 index 000000000..6719163c3 --- /dev/null +++ b/packages/raystack/shared/hash/index.ts @@ -0,0 +1 @@ +export * from './hash'; From 5bb08403a1861afbd7d1003382083120adb44618 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 5 Oct 2026 13:10:49 +0530 Subject: [PATCH 2/3] fix: [avatar] mix hash bits so a 2-color palette keeps letter order --- .../avatar/__tests__/avatar.test.tsx | 30 +++++++++++-------- packages/raystack/components/avatar/utils.tsx | 9 +++++- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/raystack/components/avatar/__tests__/avatar.test.tsx b/packages/raystack/components/avatar/__tests__/avatar.test.tsx index 411c62a67..a13030e20 100644 --- a/packages/raystack/components/avatar/__tests__/avatar.test.tsx +++ b/packages/raystack/components/avatar/__tests__/avatar.test.tsx @@ -382,16 +382,22 @@ describe('Avatar', () => { }); it('maps anagrams to different colors', () => { - expect(getAvatarColor('abc')).toBe('cyan'); - expect(getAvatarColor('cba')).toBe('orange'); - expect(getAvatarColor('amy')).toBe('sky'); - expect(getAvatarColor('may')).toBe('gold'); - expect(getAvatarColor('listen')).toBe('iris'); - expect(getAvatarColor('silent')).toBe('cyan'); + expect(getAvatarColor('abc')).toBe('iris'); + expect(getAvatarColor('cba')).toBe('neutral'); + expect(getAvatarColor('amy')).toBe('neutral'); + expect(getAvatarColor('may')).toBe('cyan'); + expect(getAvatarColor('night')).toBe('mint'); + expect(getAvatarColor('thing')).toBe('purple'); + }); + + it('maps anagrams to different colors with a 2-color palette', () => { + const palette = ['sky', 'mint'] as const; + expect(getAvatarColor('amy', { palette })).toBe('mint'); + expect(getAvatarColor('may', { palette })).toBe('sky'); }); it('returns a known color for a known string', () => { - expect(getAvatarColor('john.doe@example.com')).toBe('pink'); + expect(getAvatarColor('john.doe@example.com')).toBe('mint'); }); it('returns the same color for the same input and options', () => { @@ -406,10 +412,10 @@ describe('Avatar', () => { }); it('changes the color with the seed', () => { - expect(getAvatarColor('alice', { seed: 'a' })).toBe('iris'); - expect(getAvatarColor('alice', { seed: 'b' })).toBe('crimson'); - expect(getAvatarColor('alice', { seed: 1 })).toBe('mint'); - expect(getAvatarColor('alice', { seed: '1' })).toBe('mint'); + expect(getAvatarColor('alice', { seed: 'a' })).toBe('orange'); + expect(getAvatarColor('alice', { seed: 'b' })).toBe('lime'); + expect(getAvatarColor('alice', { seed: 1 })).toBe('lime'); + expect(getAvatarColor('alice', { seed: '1' })).toBe('lime'); }); it('returns only and all colors from the palette', () => { @@ -468,7 +474,7 @@ describe('Avatar', () => { }); it('returns a valid color for an empty string', () => { - expect(getAvatarColor('')).toBe('neutral'); + expect(getAvatarColor('')).toBe('pink'); }); it('has a color variant class for every palette color', () => { diff --git a/packages/raystack/components/avatar/utils.tsx b/packages/raystack/components/avatar/utils.tsx index d3a7ae7ec..237b1151d 100644 --- a/packages/raystack/components/avatar/utils.tsx +++ b/packages/raystack/components/avatar/utils.tsx @@ -46,6 +46,13 @@ function resolvePalette( return COLORS; } +// The lowest bit of FNV-1a is an XOR of each character's lowest bit, so it +// ignores order. Mixing the high bits in keeps a 2-color palette order-sensitive. +function mix(hash: number): number { + hash ^= hash >>> 16; + return Math.imul(hash, 0x45d9f3b) >>> 0; +} + export function getAvatarColor( str: string, { seed, palette }: GetAvatarColorOptions = {} @@ -53,5 +60,5 @@ export function getAvatarColor( const colors = resolvePalette(palette); // The separator keeps seed 'ab' + 'c' apart from seed 'a' + 'bc'. const start = seed === undefined ? undefined : fnv1a(`${seed}\u0000`); - return colors[fnv1a(str, start) % colors.length]; + return colors[mix(fnv1a(str, start)) % colors.length]; } From 3ecd1b892fc7425592704b3cae5403bd31166387 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 5 Oct 2026 16:34:26 +0530 Subject: [PATCH 3/3] fix: [avatar] address review: AVATAR_COLORS, drop seed, inline hash --- .../content/docs/components/avatar/index.mdx | 13 +- .../content/docs/components/avatar/props.ts | 7 +- .../avatar/__tests__/avatar.test.tsx | 122 ++---------------- .../raystack/components/avatar/avatar.tsx | 4 +- packages/raystack/components/avatar/index.tsx | 3 +- packages/raystack/components/avatar/utils.tsx | 60 +++------ .../components/data-view/__tests__/helpers.ts | 10 +- packages/raystack/index.tsx | 3 +- .../shared/hash/__tests__/hash.test.ts | 13 -- packages/raystack/shared/hash/hash.ts | 11 -- packages/raystack/shared/hash/index.ts | 1 - 11 files changed, 45 insertions(+), 202 deletions(-) delete mode 100644 packages/raystack/shared/hash/__tests__/hash.test.ts delete mode 100644 packages/raystack/shared/hash/hash.ts delete mode 100644 packages/raystack/shared/hash/index.ts diff --git a/apps/www/src/content/docs/components/avatar/index.mdx b/apps/www/src/content/docs/components/avatar/index.mdx index 4bf3671e1..ae3df023a 100644 --- a/apps/www/src/content/docs/components/avatar/index.mdx +++ b/apps/www/src/content/docs/components/avatar/index.mdx @@ -68,22 +68,19 @@ Avatar can display user images with graceful fallback to initials when images fa ### With generated colors -`getAvatarColor` derives a color from a string, such as a user's email. The same string always returns the same color, on the server and in the browser. +`getAvatarColor` derives a color from a string, such as a user's email. The same string always returns the same color, on the server and in the browser. Pass a stable ID, such as `user.id`, so the color does not change when someone is renamed. ```tsx getAvatarColor(user.email); -// A different seed maps the same email to a different color. -getAvatarColor(user.email, { seed: workspaceId }); - // Pick only from these colors. getAvatarColor(user.email, { palette: ['indigo', 'mint', 'sky'] }); ``` -`AVATAR_COLOR_PALETTE` lists every color `getAvatarColor` can return. Use it to build a `palette`, for example to leave out `'neutral'`, which `AvatarGroup` uses for the overflow count: +`AVATAR_COLORS` lists every color `getAvatarColor` can return. Use it to build a `palette`, for example to leave out `'neutral'`, which `AvatarGroup` uses for the overflow count: ```tsx -const palette = AVATAR_COLOR_PALETTE.filter(color => color !== 'neutral'); +const palette = AVATAR_COLORS.filter(color => color !== 'neutral'); getAvatarColor(user.email, { palette }); ``` @@ -113,12 +110,10 @@ Groups multiple avatars with overlap and count indicator. ### getAvatarColor -`getAvatarColor(str: string, options?: GetAvatarColorOptions)` returns one of the colors in `AVATAR_COLOR_PALETTE`. +`getAvatarColor(str: string, options?: GetAvatarColorOptions)` returns one of the colors in `AVATAR_COLORS`. -`getAvatarColor` ignores duplicate and unknown colors in `palette`. If no valid color is left, it uses all of `AVATAR_COLOR_PALETTE` and logs one warning in development. - ### Slots Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): diff --git a/apps/www/src/content/docs/components/avatar/props.ts b/apps/www/src/content/docs/components/avatar/props.ts index 5a0f4448e..964bbdbe2 100644 --- a/apps/www/src/content/docs/components/avatar/props.ts +++ b/apps/www/src/content/docs/components/avatar/props.ts @@ -79,12 +79,9 @@ export interface AvatarGroupProps { } export interface GetAvatarColorOptions { - /** Mixed into the hash so the same string can map to a different color. A number and its string form give the same color. */ - seed?: string | number; - /** - * Restricts the result to these colors. Order matters. Duplicates and unknown colors are ignored. If none remain, all colors are used. - * @defaultValue `AVATAR_COLOR_PALETTE` + * Restricts the result to these colors. Order matters. If empty, all colors are used. + * @defaultValue `AVATAR_COLORS` */ palette?: Array< | 'indigo' diff --git a/packages/raystack/components/avatar/__tests__/avatar.test.tsx b/packages/raystack/components/avatar/__tests__/avatar.test.tsx index a13030e20..60917cfc0 100644 --- a/packages/raystack/components/avatar/__tests__/avatar.test.tsx +++ b/packages/raystack/components/avatar/__tests__/avatar.test.tsx @@ -4,12 +4,7 @@ import { radiusClasses } from '../../../shared/radius'; import { Tooltip } from '../../tooltip'; import { Avatar, AvatarGroup } from '../avatar'; import styles from '../avatar.module.css'; -import { - AVATAR_COLOR_PALETTE, - type AVATAR_COLORS, - COLORS, - getAvatarColor -} from '../utils'; +import { AVATAR_COLORS, getAvatarColor } from '../utils'; describe('Avatar', () => { const ogImage = window.Image; @@ -342,45 +337,6 @@ describe('Avatar', () => { describe('Utility Functions', () => { describe('getAvatarColor', () => { - it('returns consistent color for same string', () => { - const color1 = getAvatarColor('john.doe@example.com'); - const color2 = getAvatarColor('john.doe@example.com'); - expect(color1).toBe(color2); - }); - - it('returns different colors for different strings', () => { - const color1 = getAvatarColor('user1'); - const color2 = getAvatarColor('user2'); - // While not guaranteed to be different, testing with known different hashes - const colors = new Set([ - color1, - color2, - getAvatarColor('user3'), - getAvatarColor('user4') - ]); - expect(colors.size).toBeGreaterThan(1); - }); - - it('returns valid avatar color', () => { - const validColors = [ - 'indigo', - 'orange', - 'mint', - 'neutral', - 'sky', - 'lime', - 'grass', - 'cyan', - 'iris', - 'purple', - 'pink', - 'crimson', - 'gold' - ]; - const color = getAvatarColor('test'); - expect(validColors).toContain(color); - }); - it('maps anagrams to different colors', () => { expect(getAvatarColor('abc')).toBe('iris'); expect(getAvatarColor('cba')).toBe('neutral'); @@ -400,86 +356,26 @@ describe('Avatar', () => { expect(getAvatarColor('john.doe@example.com')).toBe('mint'); }); - it('returns the same color for the same input and options', () => { - const options = { - seed: 'workspace', - palette: ['mint', 'sky'] - } as const; - const first = getAvatarColor('alice', options); - for (let i = 0; i < 10; i++) { - expect(getAvatarColor('alice', options)).toBe(first); - } - }); - - it('changes the color with the seed', () => { - expect(getAvatarColor('alice', { seed: 'a' })).toBe('orange'); - expect(getAvatarColor('alice', { seed: 'b' })).toBe('lime'); - expect(getAvatarColor('alice', { seed: 1 })).toBe('lime'); - expect(getAvatarColor('alice', { seed: '1' })).toBe('lime'); + it('returns a valid color for an empty string', () => { + expect(getAvatarColor('')).toBe('pink'); }); - it('returns only and all colors from the palette', () => { + it('returns only colors from the palette', () => { const palette = ['indigo', 'mint', 'sky'] as const; - const hit = new Set( - Array.from({ length: 200 }, (_, i) => - getAvatarColor(`u${i}`, { palette }) - ) - ); - expect([...hit].sort()).toEqual([...palette].sort()); - }); - - it('returns the only color of a single-color palette', () => { - for (let i = 0; i < 20; i++) { - expect(getAvatarColor(`u${i}`, { palette: ['gold'] })).toBe('gold'); + for (let i = 0; i < 200; i++) { + expect(palette).toContain(getAvatarColor(`u${i}`, { palette })); } }); - it('ignores duplicate palette colors', () => { - for (let i = 0; i < 50; i++) { - expect( - getAvatarColor(`u${i}`, { palette: ['sky', 'sky', 'mint'] }) - ).toBe(getAvatarColor(`u${i}`, { palette: ['sky', 'mint'] })); - } - }); - - it('ignores unknown palette colors', () => { - const palette = ['sky', 'teal', 'mint'] as unknown as AVATAR_COLORS[]; - for (let i = 0; i < 50; i++) { - expect(getAvatarColor(`u${i}`, { palette })).toBe( - getAvatarColor(`u${i}`, { palette: ['sky', 'mint'] }) - ); - } - }); - - it('falls back to all colors for an empty palette and warns once', async () => { - vi.resetModules(); - const utils = await import('../utils'); - const warn = vi - .spyOn(console, 'warn') - .mockImplementation(() => undefined); - for (let i = 0; i < 20; i++) { - expect(utils.getAvatarColor(`u${i}`, { palette: [] })).toBe( - utils.getAvatarColor(`u${i}`) - ); - } - expect(warn).toHaveBeenCalledTimes(1); - warn.mockRestore(); - }); - it('uses every color over many strings', () => { const hit = new Set( Array.from({ length: 1000 }, (_, i) => getAvatarColor(`user-${i}`)) ); - expect(hit.size).toBe(COLORS.length); - }); - - it('returns a valid color for an empty string', () => { - expect(getAvatarColor('')).toBe('pink'); + expect(hit.size).toBe(AVATAR_COLORS.length); }); - it('has a color variant class for every palette color', () => { - expect(AVATAR_COLOR_PALETTE).toBe(COLORS); - for (const color of COLORS) { + it('has a color variant class for every color', () => { + for (const color of AVATAR_COLORS) { const { container, unmount } = render( ); diff --git a/packages/raystack/components/avatar/avatar.tsx b/packages/raystack/components/avatar/avatar.tsx index c145b2ce2..7b4a576bc 100644 --- a/packages/raystack/components/avatar/avatar.tsx +++ b/packages/raystack/components/avatar/avatar.tsx @@ -12,7 +12,7 @@ import { } from 'react'; import { radiusVariants } from '../../shared/radius'; import styles from './avatar.module.css'; -import { AVATAR_COLORS } from './utils'; +import type { AvatarColor } from './utils'; // Matches Base UI's AvatarRoot ImageLoadingStatus union. type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; @@ -156,7 +156,7 @@ export interface AvatarProps fallbackDelay?: AvatarPrimitive.Fallback.Props['delay']; onLoadingStatusChange?: AvatarPrimitive.Image.Props['onLoadingStatusChange']; variant?: 'solid' | 'soft'; - color?: AVATAR_COLORS; + color?: AvatarColor; className?: string; } diff --git a/packages/raystack/components/avatar/index.tsx b/packages/raystack/components/avatar/index.tsx index 37af781f5..eccf8984c 100644 --- a/packages/raystack/components/avatar/index.tsx +++ b/packages/raystack/components/avatar/index.tsx @@ -1,6 +1,7 @@ export { Avatar, AvatarGroup } from './avatar'; export { - AVATAR_COLOR_PALETTE, + AVATAR_COLORS, + type AvatarColor, type GetAvatarColorOptions, getAvatarColor } from './utils'; diff --git a/packages/raystack/components/avatar/utils.tsx b/packages/raystack/components/avatar/utils.tsx index 237b1151d..59fb72161 100644 --- a/packages/raystack/components/avatar/utils.tsx +++ b/packages/raystack/components/avatar/utils.tsx @@ -1,6 +1,4 @@ -import { fnv1a } from '../../shared/hash'; - -export const COLORS = [ +export const AVATAR_COLORS = [ 'indigo', 'orange', 'mint', @@ -16,49 +14,27 @@ export const COLORS = [ 'gold' ] as const; -export type AVATAR_COLORS = (typeof COLORS)[number]; - -/** All avatar colors, in hash order. Use it to build a `palette` subset. */ -export const AVATAR_COLOR_PALETTE = COLORS; +export type AvatarColor = (typeof AVATAR_COLORS)[number]; export interface GetAvatarColorOptions { - /** Mixed into the hash so the same string can map to a different color. A number and its string form give the same color. */ - seed?: string | number; - /** Restricts the result to these colors. Order matters. Duplicates and unknown colors are ignored. If none remain, all colors are used. */ - palette?: readonly AVATAR_COLORS[]; -} - -const COLOR_SET: ReadonlySet = new Set(COLORS); -let warnedEmptyPalette = false; - -function resolvePalette( - palette?: readonly AVATAR_COLORS[] -): readonly AVATAR_COLORS[] { - if (!palette) return COLORS; - const resolved = [...new Set(palette)].filter(color => COLOR_SET.has(color)); - if (resolved.length > 0) return resolved; - if (process.env.NODE_ENV !== 'production' && !warnedEmptyPalette) { - warnedEmptyPalette = true; - console.warn( - 'getAvatarColor: `palette` has no valid colors. Falling back to all avatar colors.' - ); - } - return COLORS; -} - -// The lowest bit of FNV-1a is an XOR of each character's lowest bit, so it -// ignores order. Mixing the high bits in keeps a 2-color palette order-sensitive. -function mix(hash: number): number { - hash ^= hash >>> 16; - return Math.imul(hash, 0x45d9f3b) >>> 0; + /** Restricts the result to these colors. Order matters. If empty, all colors are used. */ + palette?: readonly AvatarColor[]; } export function getAvatarColor( str: string, - { seed, palette }: GetAvatarColorOptions = {} -): AVATAR_COLORS { - const colors = resolvePalette(palette); - // The separator keeps seed 'ab' + 'c' apart from seed 'a' + 'bc'. - const start = seed === undefined ? undefined : fnv1a(`${seed}\u0000`); - return colors[mix(fnv1a(str, start)) % colors.length]; + { palette }: GetAvatarColorOptions = {} +): AvatarColor { + const colors = palette?.length ? palette : AVATAR_COLORS; + // 32-bit FNV-1a + let hash = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + // The lowest bit of FNV-1a is an XOR of each character's lowest bit, so it + // ignores order. Mixing the high bits in keeps a 2-color palette order-sensitive. + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x45d9f3b) >>> 0; + return colors[hash % colors.length]; } diff --git a/packages/raystack/components/data-view/__tests__/helpers.ts b/packages/raystack/components/data-view/__tests__/helpers.ts index 52073760f..7ff24e546 100644 --- a/packages/raystack/components/data-view/__tests__/helpers.ts +++ b/packages/raystack/components/data-view/__tests__/helpers.ts @@ -1,5 +1,3 @@ -import { fnv1a } from '~/shared/hash'; - /** * Fixtures shared by the data-view util suites. * @@ -19,9 +17,13 @@ export function seededRandom(seed: number) { /** FNV-1a over the decimal text, so [1, 23] and [12, 3] can't collide. */ export function digest(values: readonly number[]): string { - let hash = fnv1a(''); + let hash = 0x811c9dc5; for (const value of values) { - hash = fnv1a(`${value},`, hash); + const text = `${value},`; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } } return hash.toString(16).padStart(8, '0'); } diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 9bb47e684..c8018c342 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -6,8 +6,9 @@ export { AlertDialog } from './components/alert-dialog'; export { Amount, type AmountProps } from './components/amount'; export { AnnouncementBar } from './components/announcement-bar'; export { - AVATAR_COLOR_PALETTE, + AVATAR_COLORS, Avatar, + type AvatarColor, AvatarGroup, type GetAvatarColorOptions, getAvatarColor diff --git a/packages/raystack/shared/hash/__tests__/hash.test.ts b/packages/raystack/shared/hash/__tests__/hash.test.ts deleted file mode 100644 index d14f52d7c..000000000 --- a/packages/raystack/shared/hash/__tests__/hash.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { fnv1a } from '../hash'; - -describe('fnv1a', () => { - it('matches the FNV-1a 32-bit test vectors', () => { - expect(fnv1a('')).toBe(0x811c9dc5); - expect(fnv1a('a')).toBe(0xe40c292c); - }); - - it('continues from a previous hash', () => { - expect(fnv1a('bc', fnv1a('a'))).toBe(fnv1a('abc')); - }); -}); diff --git a/packages/raystack/shared/hash/hash.ts b/packages/raystack/shared/hash/hash.ts deleted file mode 100644 index c4f128499..000000000 --- a/packages/raystack/shared/hash/hash.ts +++ /dev/null @@ -1,11 +0,0 @@ -const FNV_OFFSET_BASIS = 0x811c9dc5; -const FNV_PRIME = 0x01000193; - -/** 32-bit FNV-1a over UTF-16 code units. Pass a previous result as `hash` to continue hashing from it. */ -export function fnv1a(str: string, hash = FNV_OFFSET_BASIS): number { - for (let i = 0; i < str.length; i++) { - hash ^= str.charCodeAt(i); - hash = Math.imul(hash, FNV_PRIME); - } - return hash >>> 0; -} diff --git a/packages/raystack/shared/hash/index.ts b/packages/raystack/shared/hash/index.ts deleted file mode 100644 index 6719163c3..000000000 --- a/packages/raystack/shared/hash/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './hash';