feat: hashColor utility with FNV-1a for deterministic badge/avatar colors

This commit is contained in:
hsiegeln
2026-03-18 09:09:16 +01:00
parent 0f2d730287
commit 91e137a013
2 changed files with 82 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { hashColor, fnv1a } from './hashColor'
describe('fnv1a', () => {
it('returns a number for any string', () => {
expect(typeof fnv1a('test')).toBe('number')
})
it('returns consistent hash for same input', () => {
expect(fnv1a('order-service')).toBe(fnv1a('order-service'))
})
it('returns different hashes for different inputs', () => {
expect(fnv1a('order-service')).not.toBe(fnv1a('payment-service'))
})
})
describe('hashColor', () => {
it('returns bg, text, and border properties', () => {
const result = hashColor('test')
expect(result).toHaveProperty('bg')
expect(result).toHaveProperty('text')
expect(result).toHaveProperty('border')
})
it('returns HSL color strings', () => {
const result = hashColor('order-service')
expect(result.bg).toMatch(/^hsl\(\d+,\s*\d+%,\s*\d+%\)$/)
expect(result.text).toMatch(/^hsl\(\d+,\s*\d+%,\s*\d+%\)$/)
expect(result.border).toMatch(/^hsl\(\d+,\s*\d+%,\s*\d+%\)$/)
})
it('returns consistent colors for same name', () => {
const a = hashColor('VIEWER')
const b = hashColor('VIEWER')
expect(a).toEqual(b)
})
it('returns different hues for different names', () => {
const a = hashColor('VIEWER')
const b = hashColor('OPERATOR')
expect(a.bg).not.toBe(b.bg)
})
it('accepts dark mode parameter', () => {
const light = hashColor('test', 'light')
const dark = hashColor('test', 'dark')
expect(light.bg).not.toBe(dark.bg)
})
})

View File

@@ -0,0 +1,32 @@
const FNV_OFFSET = 2166136261
const FNV_PRIME = 16777619
export function fnv1a(str: string): number {
let hash = FNV_OFFSET
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, FNV_PRIME)
}
return hash >>> 0
}
export function hashColor(
name: string,
theme: 'light' | 'dark' = 'light',
): { bg: string; text: string; border: string } {
const hue = fnv1a(name) % 360
if (theme === 'dark') {
return {
bg: `hsl(${hue}, 35%, 20%)`,
text: `hsl(${hue}, 45%, 75%)`,
border: `hsl(${hue}, 30%, 30%)`,
}
}
return {
bg: `hsl(${hue}, 45%, 92%)`,
text: `hsl(${hue}, 55%, 35%)`,
border: `hsl(${hue}, 35%, 82%)`,
}
}