/** * Color utilities for WealthX white-label theming. * Ported from \@wealthx/ui theme/utils.ts — zero MUI dependencies. */ // WCAG contrast text colors const CONTRAST_DARK = "#040D13"; const CONTRAST_LIGHT = "#FFFFFF"; export function hexToRgb(hex: string): [number, number, number] { const normalized = hex.replace(/^#/, ""); if (normalized.length === 3) { const r = parseInt(normalized[0] + normalized[0], 16); const g = parseInt(normalized[1] + normalized[1], 16); const b = parseInt(normalized[2] + normalized[2], 16); return [r, g, b]; } if (normalized.length === 6 || normalized.length === 8) { const r = parseInt(normalized.slice(0, 2), 16); const g = parseInt(normalized.slice(2, 4), 16); const b = parseInt(normalized.slice(4, 6), 16); return [r, g, b]; } throw new Error(`Invalid hex color: ${hex}`); } function linearizeSrgb(c: number): number { const n = c / 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); } /** WCAG relative luminance (0 = black, 1 = white) */ export function getLuminance(hex: string): number { const [r, g, b] = hexToRgb(hex); return ( 0.2126 * linearizeSrgb(r) + 0.7152 * linearizeSrgb(g) + 0.0722 * linearizeSrgb(b) ); } /** Returns dark or light text color based on WCAG contrast ratio */ export function getContrastText(backgroundColor: string): string { const luminance = getLuminance(backgroundColor); return luminance > 0.179 ? CONTRAST_DARK : CONTRAST_LIGHT; } /** * Returns a CSS color-mix() string blending `color` with transparent at the given fill opacity. * Used for swatch/chip backgrounds in income/expense/debt indicators. * * @example colorMixSwatch("#3B82F6") // "color-mix(in srgb, #3B82F6 15%, transparent)" * @example colorMixSwatch("#3B82F6", 0.3) // "color-mix(in srgb, #3B82F6 30%, transparent)" */ export function colorMixSwatch(color: string, fillOpacity = 0.15): string { const percent = Math.round(fillOpacity * 100); return `color-mix(in srgb, ${color} ${percent}%, transparent)`; } /** * CSS variables that re-point the primary token at an arbitrary brand color. * * Returns an empty object when `brandColor` is unset, so the caller keeps * inheriting `--primary` from the nearest ThemeProvider — that is the * "follow tenant theme color" mode, expressed as the absence of an override. * * `--primary-foreground` is derived from the brand color via `getContrastText`, * which is what keeps header and bubble text readable on light brand colors. * An unparseable hex is treated like no color at all rather than throwing, so a * half-typed value in a color input can never blank out the surface. */ export function resolveBrandVars(brandColor?: string): Record { if (!brandColor) return {}; try { const primary = hexToOklch(brandColor); return { "--primary": primary, "--primary-foreground": hexToOklch(getContrastText(brandColor)), "--ring": primary, }; } catch { return {}; } } /** Parsed OKLCH components as numbers (not a CSS string). */ export interface OklchComponents { /** Lightness: 0 (black) – 1 (white) */ L: number; /** Chroma: 0 (grey) – ~0.4 (max saturation) */ C: number; /** Hue angle in degrees: 0–360 */ H: number; } /** Convert hex to individual OKLCH components. */ export function hexToOklchComponents(hex: string): OklchComponents { const [r, g, b] = hexToRgb(hex); const rl = linearizeSrgb(r); const gl = linearizeSrgb(g); const bl = linearizeSrgb(b); // sRGB linear → OKLab const l = 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl; const m = 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl; const s = 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl; const l_ = l > 0 ? Math.cbrt(l) : 0; const m_ = m > 0 ? Math.cbrt(m) : 0; const s_ = s > 0 ? Math.cbrt(s) : 0; const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; const bv = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; const C = Math.sqrt(a * a + bv * bv); const H = ((Math.atan2(bv, a) * 180) / Math.PI + 360) % 360; return { L, C, H }; } /** Convert hex to oklch() CSS string */ export function hexToOklch(hex: string): string { const { L, C, H } = hexToOklchComponents(hex); if (C < 0.001) return `oklch(${L.toFixed(3)} 0 0)`; return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${H.toFixed(1)})`; } /** * Tailwind-style lightness stops for an 11-step shade scale (50–950). * The hue and a scaled chroma from the base color fill in the rest. * * Lightness values mirror Tailwind v4's perceptual curve so shades look * consistent with standard Tailwind palettes across all tenant hues. */ const SHADE_LIGHTNESS: ReadonlyArray<[number, number]> = [ [50, 0.971], [100, 0.944], [200, 0.887], [300, 0.808], [400, 0.72], [500, 0.646], [600, 0.57], [700, 0.48], [800, 0.39], [900, 0.31], [950, 0.24], ]; /** * Generate an 11-step Tailwind-style primary shade palette from a hex color. * * Returns CSS-variable key/value pairs ready to be spread into an inline * style object or injected via ThemeProvider: * * ```ts * { "--primary-50": "oklch(0.971 0.104 163.0)", "--primary-100": "...", … } * ``` * * Each shade keeps the hue of the base color and linearly scales chroma down * for very light (≥ 0.87) and very dark (≤ 0.35) stops to stay in-gamut. */ export function generatePrimaryShades(hex: string): Record { const { L: baseL, C: baseC, H } = hexToOklchComponents(hex); return Object.fromEntries( SHADE_LIGHTNESS.map(([shade, targetL]) => { // Reduce chroma for stops far from the base lightness to stay in-gamut. // Stops very close to white (>0.9) or black (<0.3) get ~10% of base chroma. const distance = Math.abs(targetL - baseL); const chromaScale = Math.max(0.1, 1 - distance * 1.5); const C = +(baseC * chromaScale).toFixed(3); const value = C < 0.001 ? `oklch(${targetL.toFixed(3)} 0 0)` : `oklch(${targetL.toFixed(3)} ${C} ${H.toFixed(1)})`; return [`--primary-${shade}`, value]; }), ); }