/* ============================================================ VYNDR 2.0 — THE COLOR CONTRACT (DS3, DESIGN-SPEC Part 1). Plain CommonJS so .tsx components import it AND the Jest suite requires it directly (same pattern as vyndrTokens.js / archetypes.js). ONE law, enforced in one place, tested in colorContract.test.js: - Signal-green (#00D4A0 / var(--g-a)) means exactly ONE thing: edge / active / A-tier / primary CTA. Never decorative, never an archetype hue, never a green rendered on a NEGATIVE number. - Edge / CLV / delta is colored by SIGN: positive = signal-green, negative = muted red (var(--miss)), zero = neutral (no edge). - Grades are colored by TIER (A/A+ green, B blue, C amber, D/F red). - GLOW is the scarcest cue in the system: A / A+ ONLY. ============================================================ */ const SIGNAL_GREEN = '#00D4A0'; /* Archetype hues must clear this CIE76 ΔE from SIGNAL_GREEN so that #00D4A0 stays uniquely "edge" and is never diluted by an archetype chip. 20 sits above the ~10 "clearly a different color" boundary. */ const MIN_ARCHETYPE_DELTA_E = 20; /* Sign → token. Positive edge is the ONE green meaning; negative is muted red; a zero (or non-numeric) edge is no edge → neutral. */ const EDGE_POSITIVE = 'var(--g-a)'; const EDGE_NEGATIVE = 'var(--miss)'; const EDGE_NEUTRAL = 'var(--text-2)'; /** * edgeColor(value) — the single source of truth for coloring any * edge / CLV / delta figure by SIGN. A -33.3% edge can NEVER render * green (audit #3). Accepts number or numeric string; null/NaN/0 → neutral. */ function edgeColor(value) { const n = typeof value === 'number' ? value : parseFloat(value); if (!Number.isFinite(n) || n === 0) return EDGE_NEUTRAL; return n > 0 ? EDGE_POSITIVE : EDGE_NEGATIVE; } /* Grade → tier color. Mirrors vyndrTokens.gradeColor for the shared keys (a test locks the two in sync) and extends F → red. */ const TIER_COLORS = { 'A+': 'var(--g-ap)', A: 'var(--g-a)', 'A-': 'var(--g-a)', 'B+': 'var(--g-b)', B: 'var(--g-b)', 'B-': 'var(--g-b)', C: 'var(--g-c)', D: 'var(--g-d)', F: 'var(--g-d)', }; function normalizeGrade(grade) { return (grade == null ? '' : String(grade)).trim().toUpperCase(); } /** gradeTierColor(grade) — A/A+ green, B blue, C amber, D/F red. */ function gradeTierColor(grade) { return TIER_COLORS[normalizeGrade(grade)] || 'var(--text-0)'; } /** * gradeGlows(grade) — GLOW = A / A+ ONLY (audit #3: a glowing C * devalues the cue). A- is NOT a glow tier. Every phosphor/textShadow/ * boxShadow glow on a grade element must gate through this. */ function gradeGlows(grade) { const g = normalizeGrade(grade); return g === 'A+' || g === 'A'; } /* ---- perceptual color distance (CIE76 in CIE-Lab) ---------------- */ function hexToRgb(hex) { const h = String(hex).replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; } function srgbToLinear(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function hexToLab(hex) { const [r, g, b] = hexToRgb(hex).map(srgbToLinear); let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; let y = r * 0.2126 + g * 0.7152 + b * 0.0722; let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); const fx = f(x), fy = f(y), fz = f(z); return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; } /** deltaE(a, b) — CIE76 perceptual distance between two hex colors. */ function deltaE(a, b) { const A = hexToLab(a), B = hexToLab(b); return Math.sqrt((A[0] - B[0]) ** 2 + (A[1] - B[1]) ** 2 + (A[2] - B[2]) ** 2); } /** isSignalGreen(hex) — true when a hue is close enough to the signal * green that it would dilute the "edge" meaning (archetype dedup gate). */ function isSignalGreen(hex) { return deltaE(hex, SIGNAL_GREEN) < MIN_ARCHETYPE_DELTA_E; } module.exports = { SIGNAL_GREEN, MIN_ARCHETYPE_DELTA_E, EDGE_POSITIVE, EDGE_NEGATIVE, EDGE_NEUTRAL, TIER_COLORS, edgeColor, gradeTierColor, gradeGlows, deltaE, isSignalGreen, };