/** * Color utility functions for generating brand color palettes */ /** * Convert hex color to RGB values */ export function hexToRGB(hex: string): { r: number; g: number; b: number } { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) return { r: 0, g: 0, b: 0 }; return { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16), }; } /** * Convert hex color to RGB string for Tailwind CSS variable opacity support * Returns "R G B" format (space-separated) for use with rgb() and opacity */ export function hexToRGBString(hex: string): string { const { r, g, b } = hexToRGB(hex); return `${r} ${g} ${b}`; } /** * Calculate relative luminance of a color (WCAG formula) * Returns value between 0 (black) and 1 (white) */ export function getLuminance(hex: string): number { const { r, g, b } = hexToRGB(hex); const sR = r / 255; const sG = g / 255; const sB = b / 255; const R = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4); const G = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4); const B = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4); return 0.2126 * R + 0.7152 * G + 0.0722 * B; } /** * Generate a dark complementary text color from a brand color * Creates a very dark shade of the brand's hue for use on light backgrounds */ export function getDarkBrandTextColor(brandColor: string): string { const { h, s } = hexToHSL(brandColor); // Create a very dark, moderately saturated version of the brand color // Lightness ~12% for strong contrast, saturation capped for readability return hslToHex(h, Math.min(s, 50), 12); } /** * Generate a light complementary text color from a brand color * Creates a very light shade of the brand's hue for use on dark backgrounds */ export function getLightBrandTextColor(brandColor: string): string { const { h, s } = hexToHSL(brandColor); // Create a very light, soft version of the brand color // Lightness ~92% for good contrast, lower saturation for softness return hslToHex(h, Math.min(s, 40), 92); } /** * Get the appropriate text color for optimal contrast on a background * Uses WCAG 2.1 contrast ratio guidelines * Returns complementary brand shades instead of pure black/white */ export function getContrastTextColor(backgroundColor: string, brandColor?: string): string { const luminance = getLuminance(backgroundColor); if (luminance > 0.179) { // Light background - use dark brand shade return brandColor ? getDarkBrandTextColor(brandColor) : '#1f2937'; } // Dark background - use light brand shade return brandColor ? getLightBrandTextColor(brandColor) : '#ffffff'; } /** * Convert hex color to HSL values */ export function hexToHSL(hex: string): { h: number; s: number; l: number } { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) return { h: 0, s: 0, l: 0 }; const r = parseInt(result[1], 16) / 255; const g = parseInt(result[2], 16) / 255; const b = parseInt(result[3], 16) / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h = 0; let s = 0; const l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / d + 2) / 6; break; case b: h = ((r - g) / d + 4) / 6; break; } } return { h: h * 360, s: s * 100, l: l * 100 }; } /** * Convert HSL values to hex color */ export function hslToHex(h: number, s: number, l: number): string { s /= 100; l /= 100; const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs((h / 60) % 2 - 1)); const m = l - c / 2; let r = 0, g = 0, b = 0; if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; } else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } /** * Generate a color palette from a base color */ export function generateColorPalette(baseColor: string): Record { const { h, s } = hexToHSL(baseColor); return { 50: hslToHex(h, Math.min(s, 30), 97), 100: hslToHex(h, Math.min(s, 40), 94), 200: hslToHex(h, Math.min(s, 50), 86), 300: hslToHex(h, Math.min(s, 60), 74), 400: hslToHex(h, Math.min(s, 70), 60), 500: hslToHex(h, s, 50), 600: baseColor, // Use the exact primary color for 600 700: hslToHex(h, s, 40), 800: hslToHex(h, s, 32), 900: hslToHex(h, s, 24), }; } /** * Apply a color palette to CSS custom properties */ export function applyColorPalette(palette: Record): void { const root = document.documentElement; Object.entries(palette).forEach(([shade, color]) => { root.style.setProperty(`--color-brand-${shade}`, color); }); } /** * Apply primary and secondary colors including contrast text colors * @param primaryColor - The main brand color * @param secondaryColor - Optional secondary color for gradients * @param sidebarTextColor - Optional custom sidebar text color (auto-calculated if not provided) */ export function applyBrandColors(primaryColor: string, secondaryColor?: string, sidebarTextColor?: string): void { const palette = generateColorPalette(primaryColor); applyColorPalette(palette); const root = document.documentElement; // Set the secondary color variable (used for gradients) root.style.setProperty('--color-brand-secondary', secondaryColor || primaryColor); // Set contrast text colors for each shade level // These ensure readable text on brand-colored backgrounds // Pass primaryColor so dark text is a complementary shade, not generic black Object.entries(palette).forEach(([shade, color]) => { const textColor = getContrastTextColor(color, primaryColor); root.style.setProperty(`--color-brand-${shade}-text`, textColor); }); // Set main brand text color (for use with brand-600 backgrounds like sidebar) // Use custom sidebarTextColor if provided, otherwise auto-calculate based on contrast const brandTextColor = sidebarTextColor || getContrastTextColor(palette['600'], primaryColor); root.style.setProperty('--color-brand-text', brandTextColor); // Also set RGB version for Tailwind opacity support (e.g., text-brand-text/70) root.style.setProperty('--color-brand-text-rgb', hexToRGBString(brandTextColor)); } /** * Default brand color palette (blue) */ export const defaultColorPalette: Record = { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', };