diff --git a/frontend-modern/src/utils/__tests__/tagColors.test.ts b/frontend-modern/src/utils/__tests__/tagColors.test.ts index 54a4584f5..172df971e 100644 --- a/frontend-modern/src/utils/__tests__/tagColors.test.ts +++ b/frontend-modern/src/utils/__tests__/tagColors.test.ts @@ -1,71 +1,59 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { getTagColorWithSpecial } from '../tagColors'; describe('tagColors', () => { - describe('getTagColorWithSpecial', () => { - it('returns consistent colors for special tags in light mode', () => { - const production = getTagColorWithSpecial('production', false); - expect(production).toEqual({ - bg: 'rgb(254, 226, 226)', - text: 'rgb(153, 27, 27)', - border: 'rgb(239, 68, 68)', - }); + describe('getTagColorWithSpecial', () => { + it('matches Proxmox fallback colors for tags', () => { + expect(getTagColorWithSpecial('production', false)).toEqual({ + bg: 'rgb(191.3, 176.60000000000002, 238.89999999999998)', + text: '#000000', + border: 'rgb(191.3, 176.60000000000002, 238.89999999999998)', + }); - const staging = getTagColorWithSpecial('STAGING', false); // case insensitive - expect(staging).toEqual({ - bg: 'rgb(254, 243, 199)', - text: 'rgb(146, 64, 14)', - border: 'rgb(245, 158, 11)', - }); - }); - - it('returns consistent colors for special tags in dark mode', () => { - const backup = getTagColorWithSpecial('backup', true); - expect(backup).toEqual({ - bg: 'rgb(30, 58, 138)', - text: 'rgb(191, 219, 254)', - border: 'rgb(59, 130, 246)', - }); - }); - - it('generates hash-based colors for non-special tags', () => { - const color1 = getTagColorWithSpecial('mytag', false); - const color2 = getTagColorWithSpecial('mytag', false); - - // Consistency - expect(color1).toEqual(color2); - - // Values (H, S, L format) - expect(color1.bg).toMatch(/hsl\(\d+, 65%, 60%\)/); - expect(color1.text).toMatch(/hsl\(\d+, 65%, 25%\)/); - expect(color1.border).toMatch(/hsl\(\d+, 65%, 50%\)/); - }); - - it('generates different colors for different tags', () => { - const colorA = getTagColorWithSpecial('tagA', false); - const colorB = getTagColorWithSpecial('tagB', false); - - expect(colorA.bg).not.toBe(colorB.bg); - }); - - it('prefers proxmox-supplied colors over fallback palettes', () => { - const color = getTagColorWithSpecial('Production', false, { - production: '#112233', - }); - - expect(color).toEqual({ - bg: '#112233', - text: '#ffffff', - border: '#112233', - }); - }); - - it('generates dark mode hash-based colors', () => { - const color = getTagColorWithSpecial('custom', true); - - expect(color.bg).toMatch(/hsl\(\d+, 55%, 35%\)/); - expect(color.text).toMatch(/hsl\(\d+, 55%, 85%\)/); - expect(color.border).toMatch(/hsl\(\d+, 55%, 45%\)/); - }); + expect(getTagColorWithSpecial('STAGING', false)).toEqual({ + bg: 'rgb(103.10000000000001, 103.10000000000001, 175.9)', + text: '#ffffff', + border: 'rgb(103.10000000000001, 103.10000000000001, 175.9)', + }); }); + + it('is deterministic for the same tag', () => { + const color1 = getTagColorWithSpecial('mytag', false); + const color2 = getTagColorWithSpecial('mytag', true); + + expect(color1).toEqual(color2); + expect(color1).toEqual({ + bg: 'rgb(228.39999999999998, 164.7, 128.3)', + text: '#000000', + border: 'rgb(228.39999999999998, 164.7, 128.3)', + }); + }); + + it('generates different colors for different tags', () => { + const colorA = getTagColorWithSpecial('tagA', false); + const colorB = getTagColorWithSpecial('tagB', false); + + expect(colorA.bg).not.toBe(colorB.bg); + }); + + it('prefers proxmox-supplied colors over generated fallback colors', () => { + expect(getTagColorWithSpecial('Production', false, { + production: '#112233', + })).toEqual({ + bg: 'rgb(17, 34, 51)', + text: '#ffffff', + border: 'rgb(17, 34, 51)', + }); + }); + + it('falls back to generated colors when proxmox color is invalid', () => { + expect(getTagColorWithSpecial('backup', false, { + backup: 'not-a-color', + })).toEqual({ + bg: 'rgb(108.00000000000001, 149.3, 143.7)', + text: '#ffffff', + border: 'rgb(108.00000000000001, 149.3, 143.7)', + }); + }); + }); }); diff --git a/frontend-modern/src/utils/tagColors.ts b/frontend-modern/src/utils/tagColors.ts index 8363ae883..9a7a48c85 100644 --- a/frontend-modern/src/utils/tagColors.ts +++ b/frontend-modern/src/utils/tagColors.ts @@ -1,120 +1,93 @@ -// Generate consistent colors for tags based on their text -// This replicates Proxmox's tag color generation logic - -/** - * Simple hash function to generate a number from a string - * This ensures the same tag always gets the same color - */ -function hashString(str: string): number { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash); -} - -/** - * Generate a color for a tag based on its text - * Uses HSL to ensure good visibility and consistent saturation/lightness - * (Internal helper - use getTagColorWithSpecial instead) - */ -function getTagColor(tag: string): { bg: string; text: string; border: string } { - // Get a hash of the tag - const hash = hashString(tag.toLowerCase()); - - // Generate hue from hash (0-360 degrees) - const hue = hash % 360; - - // Use moderate saturation for subtle but visible colors - // These values are tuned to be noticeable without being distracting - const saturation = 65; // Moderate saturation - const lightnessBg = 60; // Slightly muted background - const lightnessText = 25; // Dark text for contrast - const lightnessBorder = 50; // Medium border - - // For dark mode, we'll adjust these in the component - return { - bg: `hsl(${hue}, ${saturation}%, ${lightnessBg}%)`, - text: `hsl(${hue}, ${saturation}%, ${lightnessText}%)`, - border: `hsl(${hue}, ${saturation}%, ${lightnessBorder}%)`, - }; -} - -/** - * Get tag colors adjusted for dark mode - * (Internal helper - use getTagColorWithSpecial instead) - */ -function getTagColorDark(tag: string): { bg: string; text: string; border: string } { - const hash = hashString(tag.toLowerCase()); - const hue = hash % 360; - const saturation = 55; // Moderate saturation in dark mode - - return { - bg: `hsl(${hue}, ${saturation}%, 35%)`, // Subtler background - text: `hsl(${hue}, ${saturation}%, 85%)`, // Light text - border: `hsl(${hue}, ${saturation}%, 45%)`, // Subtle border - }; -} - interface TagColorStyle { bg: string; text: string; border: string; } -interface TagColorTheme { - light: TagColorStyle; - dark: TagColorStyle; +function stringToRGB(tag: string): [number, number, number] { + let hash = 0; + if (!tag) { + return [255, 255, 255]; + } + + const value = `${tag.toLowerCase()}prox`; + for (let i = 0; i < value.length; i++) { + hash = value.charCodeAt(i) + ((hash << 5) - hash); + hash &= hash; + } + + const alpha = 0.7; + const bg = 255; + + return [ + (hash & 255) * alpha + bg * (1 - alpha), + ((hash >> 8) & 255) * alpha + bg * (1 - alpha), + ((hash >> 16) & 255) * alpha + bg * (1 - alpha), + ]; } -/** - * Proxmox's default tag colors for special tags - * These override the hash-based colors for specific tags - */ -const specialTagColors: Record = { - production: { - light: { bg: 'rgb(254, 226, 226)', text: 'rgb(153, 27, 27)', border: 'rgb(239, 68, 68)' }, - dark: { bg: 'rgb(127, 29, 29)', text: 'rgb(254, 202, 202)', border: 'rgb(185, 28, 28)' }, - }, - staging: { - light: { bg: 'rgb(254, 243, 199)', text: 'rgb(146, 64, 14)', border: 'rgb(245, 158, 11)' }, - dark: { bg: 'rgb(120, 53, 15)', text: 'rgb(253, 230, 138)', border: 'rgb(217, 119, 6)' }, - }, - development: { - light: { bg: 'rgb(220, 252, 231)', text: 'rgb(22, 101, 52)', border: 'rgb(34, 197, 94)' }, - dark: { bg: 'rgb(20, 83, 45)', text: 'rgb(187, 247, 208)', border: 'rgb(34, 197, 94)' }, - }, - backup: { - light: { bg: 'rgb(219, 234, 254)', text: 'rgb(30, 58, 138)', border: 'rgb(59, 130, 246)' }, - dark: { bg: 'rgb(30, 58, 138)', text: 'rgb(191, 219, 254)', border: 'rgb(59, 130, 246)' }, - }, -}; +function rgbToCss(rgb: [number, number, number]): string { + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; +} + +function getTextContrastClass(rgb: [number, number, number]): 'light' | 'dark' { + const blkThrs = 0.022; + const blkClmp = 1.414; + + const r = (rgb[0] / 255) ** 2.4; + const g = (rgb[1] / 255) ** 2.4; + const b = (rgb[2] / 255) ** 2.4; + + let bg = r * 0.2126729 + g * 0.7151522 + b * 0.072175; + bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp; + + const contrastLight = bg ** 0.65 - 1; + const contrastDark = bg ** 0.56 - 0.046134502; + + return Math.abs(contrastLight) >= Math.abs(contrastDark) ? 'light' : 'dark'; +} + +function parseHexColor(hex: string): [number, number, number] | null { + const normalized = hex.trim().replace(/^#/, ''); + if (!/^[0-9a-fA-F]{6}$/.test(normalized)) { + return null; + } + + return [ + parseInt(normalized.slice(0, 2), 16), + parseInt(normalized.slice(2, 4), 16), + parseInt(normalized.slice(4, 6), 16), + ]; +} + +function buildStyleFromRGB(rgb: [number, number, number]): TagColorStyle { + const bg = rgbToCss(rgb); + const text = getTextContrastClass(rgb) === 'light' ? '#ffffff' : '#000000'; + + return { + bg, + text, + border: bg, + }; +} /** * Get color for a tag. - * Priority: Proxmox-supplied hex color → special hardcoded colors → hash-based fallback. - * @param colorMap Optional map of lowercase tag name → "#rrggbb" from Proxmox datacenter config. + * Priority: Proxmox-supplied hex color -> Proxmox fallback hash algorithm. */ export function getTagColorWithSpecial( tag: string, - isDarkMode: boolean, + _isDarkMode: boolean, colorMap?: Record, -): { bg: string; text: string; border: string } { +): TagColorStyle { const lowerTag = tag.toLowerCase(); - - // Use Proxmox-supplied colour when available - if (colorMap?.[lowerTag]) { - const hex = colorMap[lowerTag]; - return { bg: hex, text: '#ffffff', border: hex }; + const proxmoxHex = colorMap?.[lowerTag]; + if (proxmoxHex) { + const rgb = parseHexColor(proxmoxHex); + if (rgb) { + return buildStyleFromRGB(rgb); + } } - // Check if it's a special tag - if (specialTagColors[lowerTag]) { - return isDarkMode ? specialTagColors[lowerTag].dark : specialTagColors[lowerTag].light; - } - - // Otherwise use hash-based color - return isDarkMode ? getTagColorDark(tag) : getTagColor(tag); + return buildStyleFromRGB(stringToRGB(tag)); }