Add dynamic sidebar text color with brand color contrast

- 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>
This commit is contained in:
poduck
2025-12-16 22:16:25 -05:00
parent 6a6ad63e7b
commit 725a3c5d84
15 changed files with 365 additions and 54 deletions

View File

@@ -2,6 +2,80 @@
* 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
*/
@@ -94,15 +168,34 @@ export function applyColorPalette(palette: Record<string, string>): void {
}
/**
* Apply primary and secondary colors including the secondary color variable
* 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): void {
export function applyBrandColors(primaryColor: string, secondaryColor?: string, sidebarTextColor?: string): void {
const palette = generateColorPalette(primaryColor);
applyColorPalette(palette);
// Set the secondary color variable (used for gradients)
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));
}
/**