- Add sidebar text color picker to Branding Settings page - Implement auto-calculated complementary text colors based on brand color luminance - Dark themes get light tinted text, light themes get dark tinted text - Add navigation preview showing text on gradient background - Support 10 new lighter color palettes (Soft Mint, Lavender, Peach, etc.) - Add CSS utility classes for brand-text with opacity support - Update sidebar and navigation components to use dynamic text colors - Add sidebar_text_color field to Tenant model with migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
216 lines
6.9 KiB
TypeScript
216 lines
6.9 KiB
TypeScript
/**
|
|
* 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<string, string> {
|
|
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<string, string>): 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<string, string> = {
|
|
50: '#eff6ff',
|
|
100: '#dbeafe',
|
|
200: '#bfdbfe',
|
|
300: '#93c5fd',
|
|
400: '#60a5fa',
|
|
500: '#3b82f6',
|
|
600: '#2563eb',
|
|
700: '#1d4ed8',
|
|
800: '#1e40af',
|
|
900: '#1e3a8a',
|
|
};
|