/** * shared/named-colors.ts — Clone Architect * Palette de couleurs nommées pour nameColor/nameColorUnique. * Extrait de generate-design-md.ts (RFC D). */ import { parseRgb, colorDistance } from './css-helpers.js'; export interface NamedColor { name: string; hex: string; rgb: [number, number, number]; } export const NAMED_COLORS: NamedColor[] = [ { name: 'Pure White', hex: '#ffffff', rgb: [255, 255, 255] }, { name: 'Snow', hex: '#fffafa', rgb: [255, 250, 250] }, { name: 'Off-White', hex: '#fafafa', rgb: [250, 250, 250] }, { name: 'Ghost White', hex: '#f8f8ff', rgb: [248, 248, 255] }, { name: 'Warm Cream', hex: '#f7f4ed', rgb: [247, 244, 237] }, { name: 'Light Gray', hex: '#f0f0f0', rgb: [240, 240, 240] }, { name: 'Silver', hex: '#c0c0c0', rgb: [192, 192, 192] }, { name: 'Medium Gray', hex: '#808080', rgb: [128, 128, 128] }, { name: 'Dim Gray', hex: '#696969', rgb: [105, 105, 105] }, { name: 'Dark Gray', hex: '#404040', rgb: [64, 64, 64] }, { name: 'Charcoal', hex: '#333333', rgb: [51, 51, 51] }, { name: 'Near Black', hex: '#1c1c1c', rgb: [28, 28, 28] }, { name: 'Jet Black', hex: '#111111', rgb: [17, 17, 17] }, { name: 'Pure Black', hex: '#000000', rgb: [0, 0, 0] }, { name: 'Deep Navy', hex: '#0a1628', rgb: [10, 22, 40] }, { name: 'Navy Blue', hex: '#001f3f', rgb: [0, 31, 63] }, { name: 'Royal Blue', hex: '#4169e1', rgb: [65, 105, 225] }, { name: 'Cobalt Blue', hex: '#0047ab', rgb: [0, 71, 171] }, { name: 'Sky Blue', hex: '#87ceeb', rgb: [135, 206, 235] }, { name: 'Steel Blue', hex: '#4682b4', rgb: [70, 130, 180] }, { name: 'Cornflower Blue', hex: '#6495ed', rgb: [100, 149, 237] }, { name: 'Slate Blue', hex: '#6a5acd', rgb: [106, 90, 205] }, { name: 'Light Blue', hex: '#add8e6', rgb: [173, 216, 230] }, { name: 'Baby Blue', hex: '#89cff0', rgb: [137, 207, 240] }, { name: 'Indigo', hex: '#5e6ad2', rgb: [94, 106, 210] }, { name: 'Violet', hex: '#7c3aed', rgb: [124, 58, 237] }, { name: 'Deep Purple', hex: '#533afd', rgb: [83, 58, 253] }, { name: 'Lavender', hex: '#b9b9f9', rgb: [185, 185, 249] }, { name: 'Amethyst', hex: '#9966cc', rgb: [153, 102, 204] }, { name: 'Plum', hex: '#dda0dd', rgb: [221, 160, 221] }, { name: 'Red', hex: '#ff0000', rgb: [255, 0, 0] }, { name: 'Crimson', hex: '#dc143c', rgb: [220, 20, 60] }, { name: 'Ruby', hex: '#ea2261', rgb: [234, 34, 97] }, { name: 'Vermillion', hex: '#e34234', rgb: [227, 66, 52] }, { name: 'Cherry', hex: '#de3163', rgb: [222, 49, 99] }, { name: 'Coral', hex: '#ff7f50', rgb: [255, 127, 80] }, { name: 'Rose', hex: '#ff007f', rgb: [255, 0, 127] }, { name: 'Warm Rose', hex: '#ff385c', rgb: [255, 56, 92] }, { name: 'Bright Orange', hex: '#f54e00', rgb: [245, 78, 0] }, { name: 'Tangerine', hex: '#ff9966', rgb: [255, 153, 102] }, { name: 'Amber', hex: '#ffbf00', rgb: [255, 191, 0] }, { name: 'Gold', hex: '#ffd700', rgb: [255, 215, 0] }, { name: 'Honey', hex: '#c08532', rgb: [192, 133, 50] }, { name: 'Emerald', hex: '#10b981', rgb: [16, 185, 129] }, { name: 'Forest Green', hex: '#228b22', rgb: [34, 139, 34] }, { name: 'Sage', hex: '#9fbbe0', rgb: [159, 187, 224] }, { name: 'Mint', hex: '#98fb98', rgb: [152, 251, 152] }, { name: 'Teal', hex: '#1f8a65', rgb: [31, 138, 101] }, { name: 'Success Green', hex: '#27a644', rgb: [39, 166, 68] }, { name: 'Magenta', hex: '#f96bee', rgb: [249, 107, 238] }, { name: 'Hot Pink', hex: '#ff69b4', rgb: [255, 105, 180] }, { name: 'Pink', hex: '#ffc0cb', rgb: [255, 192, 203] }, { name: 'Soft Pink', hex: '#ffd7ef', rgb: [255, 215, 239] }, { name: 'Warm Brown', hex: '#26251e', rgb: [38, 37, 30] }, { name: 'Chocolate', hex: '#7b3f00', rgb: [123, 63, 0] }, { name: 'Beige', hex: '#f5f5dc', rgb: [245, 245, 220] }, { name: 'Parchment', hex: '#f7f4ed', rgb: [247, 244, 237] }, // Brand-specific extensions (2026-05-25, sprint v3) { name: 'Cream', hex: '#fcfbf8', rgb: [252, 251, 248] }, // Lovable / Anthropic warm canvas { name: 'Off-Cream', hex: '#fafaf7', rgb: [250, 250, 247] }, { name: 'Slate', hex: '#475569', rgb: [71, 85, 105] }, // Tailwind slate-600 { name: 'Slate Gray', hex: '#64748b', rgb: [100, 116, 139] }, { name: 'Cool Gray', hex: '#9ca3af', rgb: [156, 163, 175] }, { name: 'Stone', hex: '#78716c', rgb: [120, 113, 108] }, // Tailwind stone-500 { name: 'Terracotta', hex: '#c96442', rgb: [201, 100, 66] }, // Anthropic Claude brand { name: 'Warm Terracotta', hex: '#d97757', rgb: [217, 119, 87] }, // Claude alt { name: 'Ring Blue', hex: '#3b82f6', rgb: [59, 130, 246] }, // Tailwind focus ring (Lovable, many SaaS) { name: 'Sky Bright', hex: '#0ea5e9', rgb: [14, 165, 233] }, { name: 'Brand Indigo', hex: '#6366f1', rgb: [99, 102, 241] }, // Tailwind indigo-500 { name: 'M Tricolor Red', hex: '#e22718', rgb: [226, 39, 24] }, // BMW M { name: 'M Tricolor Blue', hex: '#1c69d4', rgb: [28, 105, 212] }, { name: 'Vibrant Yellow', hex: '#fff8c2', rgb: [255, 248, 194] }, // Mistral cards ]; /** * Color distance threshold above which we fallback to hex (no name). * RGB Euclidean distance — calibrated so that bright distinctive colors * far from any named entry get a fair `#hex` label instead of a misleading match. * Default: 60 (allows ~30% per-channel variation from nearest). */ const COLOR_NAME_DISTANCE_THRESHOLD = 60; function rgbToHex(r: number, g: number, b: number): string { return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); } export interface NamedColorResult { /** Semantic color name (e.g. "Indigo", "Transparent", "Translucent White (2%)"). */ name: string; /** Solid hex equivalent ("#000000" for transparent fallback, real hex otherwise). Use for palette indexing. */ hex: string; /** The value to render in user-facing output. 'transparent' if alpha=0, 'rgba(...)' if 0 | undefined ): string | null { if (!cssVars || Object.keys(cssVars).length === 0) return null; const targetHex = rgbToHex(rgb[0], rgb[1], rgb[2]).toLowerCase(); // Tokens that suggest a derivative/variant rather than canonical brand name const DERIVATIVE_TOKENS = /(?:^|-)(gradient|stop|color-stop|subtle|subdued|quiet|hover|active|focus|disabled|pressed|selected|onSubdued|alternate|alt|inverse|tint|shade|opacity|alpha|translucent|core-rtl|rtl)(?:-|$)/i; const SEMANTIC_PATTERNS: Array<{ regex: RegExp; transform: (m: RegExpMatchArray) => string; priority: number; }> = [ // --palette-WORD or --color-WORD (single brand word: Rausch, Luxe, Hof, Babu) { regex: /^--(?:palette|color)-([a-zA-Z]{3,})$/, transform: m => titleCase(m[1]), priority: 100 }, // Single semantic role: --error, --warning, --success, --info, --danger, --primary { regex: /^--(error|warning|success|info|danger|alert|primary|secondary|tertiary|brand|accent)$/i, transform: m => titleCase(m[1]), priority: 95 }, // --brand-WORD (single descriptor: --brand-primary, --brand-blue) { regex: /^--brand-([a-zA-Z]{3,})$/, transform: m => `${titleCase('Brand')} ${titleCase(m[1])}`, priority: 90 }, // Vendor-prefixed canonical role: --hds-color-accent[-X], --tw-color-primary, etc. // Returns just the role name (e.g. "Accent") — clean and reusable. { regex: /^--(?:hds|tw|ds|ui|next|app|nx|nui|ant|aria|mui)-color-(accent|brand|primary|surface|text|border|action|link|secondary|tertiary)(?:-[a-zA-Z0-9-]+)*$/i, transform: m => titleCase(m[1]), priority: 80 }, // --(brand|primary|accent|secondary)-WORD (single-segment descriptor only, no hyphens in group 2) { regex: /^--(brand|primary|accent|secondary|tertiary|action|link)-([a-zA-Z]{2,})$/i, transform: m => `${titleCase(m[1])} ${titleCase(m[2])}`, priority: 70 }, // --color-(role)-WORD (single-segment descriptor) { regex: /^--color-(text|bg|background|surface|border|fill|stroke|accent|brand|action|link)-([a-zA-Z]{2,})$/i, transform: m => `${titleCase(m[1])} ${titleCase(m[2])}`, priority: 60 }, // --palette-(role)-WORD-core { regex: /^--palette-(?:bg|surface|text|border|action|fg|fill)-([a-zA-Z]+)-?(?:core|main|primary|default)?$/i, transform: m => titleCase(m[1]), priority: 50 }, // --palette-WORD-core { regex: /^--palette-([a-zA-Z]+)-(?:core|main|primary|default)$/i, transform: m => titleCase(m[1]), priority: 45 }, ]; let bestMatch: { name: string; priority: number; keyLength: number; isDerivative: boolean } | null = null; for (const [key, value] of Object.entries(cssVars)) { if (!value || typeof value !== 'string') continue; // Try to parse the value as a color (hex or rgb/rgba) let varRgb: [number, number, number] | null = null; const hexParsed = parseHex(value.trim()); if (hexParsed && hexParsed.alpha === 1) { varRgb = hexParsed.rgb; } else { varRgb = parseRgb(value); } if (!varRgb) continue; // Exact hex match required (no fuzzy matching on semantic names) const varHex = rgbToHex(varRgb[0], varRgb[1], varRgb[2]).toLowerCase(); if (varHex !== targetHex) continue; const isDerivative = DERIVATIVE_TOKENS.test(key); // Try patterns in priority order, first match wins per key for (const pattern of SEMANTIC_PATTERNS) { const match = key.match(pattern.regex); if (match) { const name = pattern.transform(match); // Selection logic (in order of precedence): // 1. Non-derivative beats derivative (always prefer canonical "Accent" over "Accent Hover") // 2. Higher priority beats lower // 3. On tie: shorter key (more canonical) const beatsCurrentBest = !bestMatch || (!isDerivative && bestMatch.isDerivative) || (isDerivative === bestMatch.isDerivative && pattern.priority > bestMatch.priority) || (isDerivative === bestMatch.isDerivative && pattern.priority === bestMatch.priority && key.length < bestMatch.keyLength); if (beatsCurrentBest) { bestMatch = { name, priority: pattern.priority, keyLength: key.length, isDerivative }; } break; // first pattern wins per key } } } return bestMatch?.name || null; } function titleCase(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); } function titleCaseHyphenated(s: string): string { return s.split(/[-_]/).filter(Boolean).map(titleCase).join(' '); } /** * Name a color using brand-aware semantic lookup, falling back to nearest-neighbor. * * @param color - CSS color string (hex, rgb, rgba, "transparent") * @param cssVars - Optional CSS custom properties from the same site. If provided, * any var whose resolved color equals `color` will produce a semantic name * (e.g. "Rausch" for --palette-rausch) instead of nearest-neighbor ("Warm Rose"). */ export function nameColor(color: string, cssVars?: Record): NamedColorResult { if (!color) return { name: 'Unknown', hex: '#000000', displayValue: '#000000' }; const trimmed = color.trim().toLowerCase(); if (trimmed === 'transparent' || trimmed === 'rgba(0,0,0,0)' || trimmed === 'rgba(0, 0, 0, 0)') { return { name: 'Transparent', hex: 'transparent', displayValue: 'transparent', alpha: 0 }; } // Detect hex input first (parseRgb only handles rgb/rgba strings) let rgb: [number, number, number] | null = null; let alpha = 1; const hexParsed = parseHex(color); if (hexParsed) { rgb = hexParsed.rgb; alpha = hexParsed.alpha; } else { const rgbaMatch = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/); alpha = rgbaMatch && rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1; rgb = parseRgb(color); } if (alpha === 0) { return { name: 'Transparent', hex: 'transparent', displayValue: 'transparent', alpha: 0 }; } if (!rgb) return { name: 'Unknown', hex: '#000000', displayValue: '#000000' }; const hex = rgbToHex(rgb[0], rgb[1], rgb[2]); // Brand-aware semantic naming: if cssVars provided, try to match a brand/semantic CSS var // before falling back to nearest-neighbor. Only applies to fully opaque colors. if (cssVars && alpha === 1) { const semanticName = resolveSemanticBrandName(rgb, cssVars); if (semanticName) { return { name: semanticName, hex, displayValue: hex }; } } let bestName = 'Unknown'; let bestDist = Infinity; for (const nc of NAMED_COLORS) { const dist = colorDistance(rgb, nc.rgb); if (dist < bestDist) { bestDist = dist; bestName = nc.name; } } // If nearest named color is too far, fall back to the hex value as name. // Prevents misleading labels like "Indigo" for a totally different bright color. if (bestDist > COLOR_NAME_DISTANCE_THRESHOLD) { bestName = hex; } if (alpha < 1) { const opacityPct = Math.round(alpha * 100); // For translucent colors, attempt semantic match on the opaque equivalent if (cssVars) { const opaqueSemanticName = resolveSemanticBrandName(rgb, cssVars); if (opaqueSemanticName) { return { name: `Translucent ${opaqueSemanticName} (${opacityPct}%)`, hex, displayValue: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`, alpha, }; } } return { name: `Translucent ${bestName} (${opacityPct}%)`, hex, displayValue: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`, alpha, }; } return { name: bestName, hex, displayValue: hex }; } /** * Factory pour un nameColorUnique stateful (chaque générateur utilise sa propre map). * * @param cssVars - Optional default CSS custom properties for brand-aware semantic naming. * Each call to nameColorUnique can also pass its own cssVars to override. */ export function createColorNamer(defaultCssVars?: Record) { const usedColorNames = new Map(); return { nameColorUnique(color: string, cssVars?: Record): NamedColorResult { const result = nameColor(color, cssVars ?? defaultCssVars); const count = usedColorNames.get(result.name) || 0; usedColorNames.set(result.name, count + 1); if (count > 0) { result.name = `${result.name} (${result.hex})`; } return result; }, reset() { usedColorNames.clear(); }, }; }