From 91e137a013e7adb3929cdd50d4641e4e4317c5b5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:09:16 +0100 Subject: [PATCH] feat: hashColor utility with FNV-1a for deterministic badge/avatar colors --- src/design-system/utils/hashColor.test.ts | 50 +++++++++++++++++++++++ src/design-system/utils/hashColor.ts | 32 +++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/design-system/utils/hashColor.test.ts create mode 100644 src/design-system/utils/hashColor.ts diff --git a/src/design-system/utils/hashColor.test.ts b/src/design-system/utils/hashColor.test.ts new file mode 100644 index 0000000..c68313a --- /dev/null +++ b/src/design-system/utils/hashColor.test.ts @@ -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) + }) +}) diff --git a/src/design-system/utils/hashColor.ts b/src/design-system/utils/hashColor.ts new file mode 100644 index 0000000..0fc6f99 --- /dev/null +++ b/src/design-system/utils/hashColor.ts @@ -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%)`, + } +}