/** * shared/css-helpers.ts — Clone Architect Phase 5 Sprint 80/20 * Primitives pures unifiées (parseRgb×3 → 1, luminance×3 → 1, NOISE_VALUES, etc.) */ // ─── Color parsing & normalization ──────────────────────────────────────────── /** Parse rgb/rgba string → [r,g,b]. Returns null if invalid. */ export function parseRgb(color: string): [number, number, number] | null { if (!color) return null; const m = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/); if (!m) return null; return [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])]; } /** Normalize rgb/rgba string: uniform spacing, preserve alpha when <1. */ export function normalizeRgb(color: string): string { const m = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/); if (!m) return color; const a = m[4]; if (a !== undefined && parseFloat(a) < 1) { return `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${a})`; } return `rgb(${m[1]}, ${m[2]}, ${m[3]})`; } // ─── Luminance (WCAG perceptual) — accepts (r,g,b) OR ([r,g,b]) ────────────── /** * WCAG relative luminance (perceptual, gamma-corrected). * Used by: retheme.ts, bank-register.ts (isDark detection + fuzzy matching). * Accepts both signatures for backward compat. */ export function luminance(r: number | [number, number, number], g?: number, b?: number): number { let rr: number, gg: number, bb: number; if (Array.isArray(r)) { [rr, gg, bb] = r; } else { rr = r; gg = g as number; bb = b as number; } const s = [rr, gg, bb].map(v => { const n = v / 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); }); return 0.2126 * s[0] + 0.7152 * s[1] + 0.0722 * s[2]; } /** * Simple luminance (ITU-R BT.601, not gamma-corrected). * Used by: tokenize.ts classifyColors (keeping legacy behavior for compat). * DO NOT change formula — changes isDark detection on 411 existing snapshots. */ export function luminanceSimple(rgb: [number, number, number]): number { return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255; } // ─── Memoized luminance (Sprint 80/20 J3 perf) ─────────────────────────────── const _lumCache = new Map(); export function luminanceMemo(r: number, g: number, b: number): number { const key = `${r},${g},${b}`; const cached = _lumCache.get(key); if (cached !== undefined) return cached; // LRU cap 10K entries if (_lumCache.size >= 10000) { const firstKey = _lumCache.keys().next().value; if (firstKey !== undefined) _lumCache.delete(firstKey); } const result = luminance(r, g, b); _lumCache.set(key, result); return result; } // ─── Perceptual color distance (weighted euclidean) ─────────────────────────── export function colorDistance(a: [number, number, number], b: [number, number, number]): number { const rMean = (a[0] + b[0]) / 2; const dr = a[0] - b[0]; const dg = a[1] - b[1]; const db = a[2] - b[2]; return Math.sqrt( (2 + rMean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rMean) / 256) * db * db ); } // ─── Pixel parsing (from tokenize.ts — most permissive) ─────────────────────── export function parsePixels(val: string): number { const m = (val || '').match(/(-?\d+(?:\.\d+)?)\s*px/); return m ? parseFloat(m[1]) : 0; } /** * Normalize a CSS border-radius value for display. * Chrome MAX_INT border-radius (3.35544e+07px) → '9999px'. * Preserves valid values under 9999. */ export function normalizeRadius(val: string): string { if (!val || val === 'none' || val === '0px') return val; // Handle multi-value radius (e.g. "4px 4px 0px 0px") — normalize each part const parts = val.trim().split(/\s+/); const normalized = parts.map(part => { const n = parseFloat(part); if (!isNaN(n) && n > 9999) return '9999px'; return part; }); return normalized.join(' '); } // ─── HEX conversion ─────────────────────────────────────────────────────────── export function rgbToHex(rgb: [number, number, number]): string { const [r, g, b] = rgb; const toHex = (n: number) => n.toString(16).padStart(2, '0'); return '#' + toHex(r) + toHex(g) + toHex(b); } // ─── Clean color value (from tokenize.ts) ───────────────────────────────────── export function cleanColorValue(v: string): string { return (v || '').trim().replace(/\s+/g, ' '); } // ─── Dark site detection (unified) ──────────────────────────────────────────── export function isDarkBackground(color: string, threshold = 0.3): boolean { if (!color || color.includes('rgba(0, 0, 0, 0)') || color === 'transparent') return false; const rgb = parseRgb(color); if (!rgb) return false; return luminance(rgb) < threshold; } // ─── CSS NOISE filter (formerly in bank-inject.ts + renderers.ts duplicated) ── export const NOISE_VALUES: Record> = { backgroundColor: new Set(['rgba(0, 0, 0, 0)', 'transparent', 'initial', 'inherit']), border: new Set(['0px none rgb(0, 0, 0)', '0px none', 'none', '0px', 'medium none']), borderColor: new Set([]), boxShadow: new Set(['none']), opacity: new Set(['1']), overflow: new Set(['visible']), position: new Set(['static']), flexDirection: new Set(['row']), alignItems: new Set(['normal', 'stretch']), justifyContent: new Set(['normal', 'flex-start']), transition: new Set(['all', 'all 0s ease 0s', 'none 0s ease 0s', 'none']), textTransform: new Set(['none']), textDecoration: new Set(['none']), letterSpacing: new Set(['normal', '0px']), fontFeatureSettings: new Set(['normal']), fontVariationSettings: new Set(['normal']), gap: new Set(['normal', '0px']), gridTemplateColumns: new Set(['none']), gridTemplateRows: new Set(['none']), maxWidth: new Set(['none']), minHeight: new Set(['auto', '0px']), }; export const ALWAYS_DROP_PROPS = new Set([ 'borderColor', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', ]); export const INHERITED_PROPS = new Set([ 'color', 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'textTransform', ]); export function isNoise(prop: string, value: string): boolean { if (ALWAYS_DROP_PROPS.has(prop)) return true; const noiseSet = NOISE_VALUES[prop]; if (!noiseSet) return false; if (noiseSet.size === 0) return true; // empty = always drop return noiseSet.has(value); } export function isTransparent(color: string): boolean { return !color || color.includes('rgba(0, 0, 0, 0)') || color === 'transparent'; } // ─── Filter styles: apply NOISE + inheritance ───────────────────────────────── export function filterStyles( styles: Record, parentStyles: Record, ): Record { const result: Record = {}; for (const [prop, val] of Object.entries(styles)) { if (typeof val !== 'string') continue; if (isNoise(prop, val)) continue; if (INHERITED_PROPS.has(prop) && parentStyles[prop] === val) continue; result[prop] = val; } return result; } export function buildChildParentStyles( ownStyles: Record, parentStyles: Record, ): Record { const next = { ...parentStyles }; for (const [prop, val] of Object.entries(ownStyles)) { if (INHERITED_PROPS.has(prop)) next[prop] = val; } return next; } // ─── HTML utilities ─────────────────────────────────────────────────────────── export function escapeHtml(str: string): string { if (!str) return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function sanitizeUrl(url: string): string { if (!url) return ''; const lower = url.trim().toLowerCase(); if (lower.startsWith('javascript:') || lower.startsWith('data:')) return '#'; if (url.startsWith('//')) return 'https:' + url; return url; } export function cssKebab(prop: string): string { return prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); } // ─── Color hue family (for tagging) ─────────────────────────────────────────── export function colorHueTag(color: string): string | null { const rgb = parseRgb(color); if (!rgb) return null; const [r, g, b] = rgb; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const delta = max - min; if (delta < 30) return null; let hue = 0; if (max === r) hue = ((g - b) / delta) % 6; else if (max === g) hue = (b - r) / delta + 2; else hue = (r - g) / delta + 4; hue = Math.round(hue * 60); if (hue < 0) hue += 360; if (hue < 30) return 'accent-red'; if (hue < 60) return 'accent-orange'; if (hue < 150) return 'accent-green'; if (hue < 210) return 'accent-teal'; if (hue < 270) return 'accent-blue'; if (hue < 330) return 'accent-purple'; return 'accent-pink'; }