From 49a3323c201577b9839007a5f50fad9d2ee4c4a7 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 19:34:12 -0400 Subject: [PATCH] =?UTF-8?q?DS3=20(design):=20the=20color=20contract=20?= =?UTF-8?q?=E2=80=94=20one=20meaning,=20enforced=20by=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal-green #00D4A0 now means exactly ONE thing (edge/active/A-tier/CTA), locked by tests that fail on violation. Part 1 of DESIGN-SPEC v2. - web/src/lib/colorContract.js — pure CommonJS helpers: edgeColor(value) colors edge/CLV/delta by SIGN (neg=var(--miss), pos=var(--g-a), 0=neutral); gradeTierColor() (A/A+ green, B blue, C amber, D/F red, in lockstep with vyndrTokens.gradeColor); gradeGlows() (A/A+ only); deltaE()/isSignalGreen() CIE76 gate so no archetype hue dilutes the signal. - GradeResultCard: edge confidence-strip + EDGE row route through edgeColor (a -33.3% edge was rendering GREEN — audit #3); grade-hero glow gated to A/A+ via gradeGlows (a glowing C devalued the cue); VYNDR INTELLIGENCE panel de-flooded (neutral border, Form/Rest neutral not green — #15). - LiveHeroProp: negative edge now muted red, not neutral (sign completeness). - Archetype dedup off signal-green (both archetypes.js + archetypeService.js, kept matched): DUAL THREAT/MOTOR #00D4A0, MIRROR #34D399, ARTILLERY/RANGE/ GHOST/BLADE #2DD4BF, BRUSH #3DDC84 shifted to distinct non-green hues (all ΔE>=44 from #00D4A0). Within-sport uniqueness preserved. - tests/unit/colorContract.test.js — 21 tests: helper units + source-grep violation locks + archetype-green-dedup. QA.20-22 kept green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/archetypeService.js | 16 +- tests/unit/colorContract.test.js | 152 +++++++++++++++++++ web/src/components/LiveHeroProp.tsx | 7 +- web/src/components/vyndr/GradeResultCard.tsx | 19 ++- web/src/lib/archetypes.js | 16 +- web/src/lib/colorContract.js | 109 +++++++++++++ 6 files changed, 293 insertions(+), 26 deletions(-) create mode 100644 tests/unit/colorContract.test.js create mode 100644 web/src/lib/colorContract.js diff --git a/src/services/archetypeService.js b/src/services/archetypeService.js index 385beab..3c88931 100644 --- a/src/services/archetypeService.js +++ b/src/services/archetypeService.js @@ -37,7 +37,7 @@ const ARCHETYPES = { education: 'A Fortress generates value on the defensive glass and at the rim. Rebounds and blocks are matchup-resilient; their scoring depends on how much offense flows their way.', }, ARTILLERY: { - legacyName: 'STRETCH BIG', tag: 'ARTILLERY', sport: 'nba', color: '#2DD4BF', glyph: 'target', + legacyName: 'STRETCH BIG', tag: 'ARTILLERY', sport: 'nba', color: '#159AAE', glyph: 'target', description: 'Floor-spacing shooting big', propDNA: { reliable: ['threes', 'rebounds'], volatile: ['assists', 'blocks'] }, education: 'Artillery bigs space the floor and crash the glass. Threes and rebounds are their bread and butter; assists and blocks are situational.', @@ -49,7 +49,7 @@ const ARCHETYPES = { education: 'A Surge soaks up shots when a star sits or is injured. Their points props spike on cascade nights — read the injury report before trusting the baseline.', }, 'DUAL THREAT': { - legacyName: 'COMBO GUARD', tag: 'DUAL THREAT', sport: 'nba', color: '#00D4A0', glyph: 'twin', + legacyName: 'COMBO GUARD', tag: 'DUAL THREAT', sport: 'nba', color: '#1EA6D8', glyph: 'twin', description: 'Scoring + playmaking hybrid', propDNA: { reliable: ['points', 'assists'], volatile: ['rebounds'] }, education: 'Dual Threats score and create in equal measure, so points and assists both stay in play. Rebounds are the volatile leg for their size.', @@ -123,7 +123,7 @@ const ARCHETYPES = { education: 'Shields contribute on both ends. Points and defensive stats stay live; their playmaking is secondary.', }, RANGE: { - legacyName: 'STRETCH FORWARD', tag: 'RANGE', sport: 'wnba', color: '#2DD4BF', glyph: 'target', + legacyName: 'STRETCH FORWARD', tag: 'RANGE', sport: 'wnba', color: '#159AAE', glyph: 'target', description: 'Floor-spacing forward', propDNA: { reliable: ['threes', 'points'], volatile: ['assists', 'blocks'] }, education: 'Range forwards space the floor from the four. Threes and points are reliable; interior props are matchup-dependent.', @@ -149,7 +149,7 @@ const ARCHETYPES = { education: 'Bombers drive the ball over the fence. Total bases and home-run props carry their value; their batting-average-driven props (hits) are the volatile leg from strikeout risk.', }, BRUSH: { - legacyName: 'CONTACT', tag: 'BRUSH', sport: 'mlb', color: '#3DDC84', glyph: 'crosshair', + legacyName: 'CONTACT', tag: 'BRUSH', sport: 'mlb', color: '#C0C63C', glyph: 'crosshair', description: 'High average, low strikeout', propDNA: { reliable: ['hits'], volatile: ['home_runs', 'total_bases'] }, education: 'Brush hitters rarely strike out, so their hits props are among the most reliable in baseball. Power props (HR, TB) are the volatile leg.', @@ -173,7 +173,7 @@ const ARCHETYPES = { education: 'Whiffs rack up strikeouts but allow more baserunners than an Alpha. The strikeout prop is reliable; earned-run and innings props swing with their traffic.', }, GHOST: { - legacyName: 'SPEED THREAT', tag: 'GHOST', sport: 'mlb', color: '#2DD4BF', glyph: 'chevrons', + legacyName: 'SPEED THREAT', tag: 'GHOST', sport: 'mlb', color: '#159AAE', glyph: 'chevrons', description: 'Stolen bases, speed score', propDNA: { reliable: ['stolen_bases', 'runs'], volatile: ['home_runs'] }, education: 'Ghosts turn singles into runs. Stolen-base and runs props are their lane; power props rarely clear.', @@ -203,7 +203,7 @@ const ARCHETYPES = { education: 'Catalysts get on base and score. Hits and runs props are their lane; RBI and power props sit lower in their profile.', }, MIRROR: { - legacyName: 'GAP HITTER', tag: 'MIRROR', sport: 'mlb', color: '#34D399', glyph: 'uparrow', + legacyName: 'GAP HITTER', tag: 'MIRROR', sport: 'mlb', color: '#8FC04A', glyph: 'uparrow', description: 'Gap-to-gap line-drive bat', propDNA: { reliable: ['total_bases', 'hits'], volatile: ['home_runs'] }, education: 'Mirror hitters spray doubles to both gaps. Total bases and hits are reliable; home-run props are the volatile leg of their extra-base profile.', @@ -253,13 +253,13 @@ const ARCHETYPES = { education: 'Towers win aerial duels and hold the ball up. Shot props are reliable; conversion to goals is variable.', }, MOTOR: { - legacyName: 'BOX-TO-BOX', tag: 'MOTOR', sport: 'soccer', color: '#00D4A0', glyph: 'chevrons', + legacyName: 'BOX-TO-BOX', tag: 'MOTOR', sport: 'soccer', color: '#1EA6D8', glyph: 'chevrons', description: 'All-action central midfielder', propDNA: { reliable: ['tackles', 'passes'], volatile: ['goals', 'shots'] }, education: 'Motors cover every blade of grass. Tackles and passing props are reliable; their attacking output swings by role.', }, BLADE: { - legacyName: 'WING WIZARD', tag: 'BLADE', sport: 'soccer', color: '#2DD4BF', glyph: 'slash', + legacyName: 'WING WIZARD', tag: 'BLADE', sport: 'soccer', color: '#159AAE', glyph: 'slash', description: 'Dribbling wide threat', propDNA: { reliable: ['shots', 'assists'], volatile: ['goals'] }, education: 'Blades beat defenders wide. Shots and assists are their lane; goals come in streaks.', diff --git a/tests/unit/colorContract.test.js b/tests/unit/colorContract.test.js new file mode 100644 index 0000000..303ee41 --- /dev/null +++ b/tests/unit/colorContract.test.js @@ -0,0 +1,152 @@ +// DS3 — THE COLOR CONTRACT (DESIGN-SPEC Part 1). +// The contract is enforced as failing tests: green means ONE thing (edge), +// edge/CLV is colored by SIGN, grades by TIER, glow is A/A+ only, and NO +// archetype hue may dilute the signal green. Extends the QA.20-22 discipline. + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +const cc = require('../../web/src/lib/colorContract'); +const tokens = require('../../web/src/lib/vyndrTokens'); +const arch = require('../../web/src/lib/archetypes'); +const svc = require('../../src/services/archetypeService'); + +describe('colorContract.edgeColor — edge/CLV/delta colored by SIGN (#3)', () => { + it('positive edge is signal-green', () => { + expect(cc.edgeColor(6.2)).toBe('var(--g-a)'); + expect(cc.edgeColor('12')).toBe('var(--g-a)'); + }); + it('NEGATIVE edge is muted red — a -33.3% edge NEVER renders green', () => { + expect(cc.edgeColor(-33.3)).toBe('var(--miss)'); + expect(cc.edgeColor(-0.1)).toBe('var(--miss)'); + expect(cc.edgeColor('-5')).toBe('var(--miss)'); + expect(cc.edgeColor(-33.3)).not.toBe('var(--g-a)'); + }); + it('zero / null / NaN is NO edge → neutral (never a fake green)', () => { + expect(cc.edgeColor(0)).toBe('var(--text-2)'); + expect(cc.edgeColor(null)).toBe('var(--text-2)'); + expect(cc.edgeColor(undefined)).toBe('var(--text-2)'); + expect(cc.edgeColor('n/a')).toBe('var(--text-2)'); + }); +}); + +describe('colorContract.gradeTierColor — grades colored by TIER', () => { + it('A/A+ green, B blue, C amber, D/F red', () => { + expect(cc.gradeTierColor('A+')).toBe('var(--g-ap)'); + expect(cc.gradeTierColor('A')).toBe('var(--g-a)'); + expect(cc.gradeTierColor('A-')).toBe('var(--g-a)'); + expect(cc.gradeTierColor('B')).toBe('var(--g-b)'); + expect(cc.gradeTierColor('C')).toBe('var(--g-c)'); + expect(cc.gradeTierColor('D')).toBe('var(--g-d)'); + expect(cc.gradeTierColor('F')).toBe('var(--g-d)'); + }); + it('is case/whitespace tolerant, unknown → neutral', () => { + expect(cc.gradeTierColor(' a+ ')).toBe('var(--g-ap)'); + expect(cc.gradeTierColor('???')).toBe('var(--text-0)'); + }); + it('stays in lockstep with vyndrTokens.gradeColor for shared keys', () => { + for (const g of ['A+', 'A', 'A-', 'B+', 'B', 'B-', 'C', 'D']) { + expect(cc.gradeTierColor(g)).toBe(tokens.gradeColor(g)); + } + }); +}); + +describe('colorContract.gradeGlows — GLOW = A/A+ ONLY (#4)', () => { + it('true for A and A+ only', () => { + expect(cc.gradeGlows('A+')).toBe(true); + expect(cc.gradeGlows('A')).toBe(true); + }); + it('false for every non-A tier — a glowing C devalues the cue', () => { + for (const g of ['A-', 'B+', 'B', 'B-', 'C', 'D', 'F', '', null, undefined]) { + expect(cc.gradeGlows(g)).toBe(false); + } + }); +}); + +describe('colorContract.deltaE / isSignalGreen — perceptual gate', () => { + it('deltaE of a color with itself is 0', () => { + expect(cc.deltaE('#00D4A0', '#00D4A0')).toBeCloseTo(0, 5); + }); + it('flags the signal green itself and near-clones', () => { + expect(cc.isSignalGreen('#00D4A0')).toBe(true); + expect(cc.isSignalGreen('#34D399')).toBe(true); // the old MIRROR green + }); + it('clears clearly-different hues (amber, blue, red)', () => { + expect(cc.isSignalGreen('#FFB347')).toBe(false); + expect(cc.isSignalGreen('#4A9EFF')).toBe(false); + expect(cc.isSignalGreen('#FF5252')).toBe(false); + }); +}); + +describe('ARCHETYPE DEDUP — no archetype hue dilutes the signal green (#15,§5)', () => { + it('every FRONTEND archetype color clears the signal-green ΔE gate', () => { + const offenders = []; + for (const [name, info] of Object.entries(arch.ARCHETYPE_MAP)) { + if (cc.isSignalGreen(info.c)) offenders.push(`${name} ${info.c} (ΔE ${cc.deltaE(info.c, cc.SIGNAL_GREEN).toFixed(1)})`); + } + expect(offenders).toEqual([]); + }); + it('every BACKEND archetype color clears the gate too', () => { + const offenders = []; + for (const [name, a] of Object.entries(svc.ARCHETYPES)) { + if (cc.isSignalGreen(a.color)) offenders.push(`${name} ${a.color}`); + } + expect(offenders).toEqual([]); + }); + it('NO archetype is the literal signal green #00D4A0 (case-insensitive)', () => { + const hits = Object.values(arch.ARCHETYPE_MAP).map((i) => i.c.toUpperCase()).filter((c) => c === '#00D4A0'); + expect(hits).toEqual([]); + }); + it('frontend + backend colors still agree after the shift', () => { + for (const [name, a] of Object.entries(svc.ARCHETYPES)) { + expect(arch.archetypeColor(name)).toBe(a.color); + } + }); +}); + +// ---- SOURCE-GREP VIOLATION LOCKS ------------------------------------------- +// These fail if the ad-hoc green-on-negative / non-A-glow patterns creep back. + +describe('GradeResultCard.tsx — edge by sign, glow by tier', () => { + const src = read('components/vyndr/GradeResultCard.tsx'); + it('routes edge coloring through edgeColor (never a hardcoded green on edge)', () => { + expect(src).toContain('import { edgeColor, gradeGlows }'); + expect(src).toContain('color: edgeColor(d.edge)'); + expect(src).toContain('edgeColor(d.edge) : '); + }); + it('does NOT color an edge figure unconditionally green', () => { + // the old bug: a green span wrapping the edge %, agnostic to sign + expect(src).not.toMatch(/color: 'var\(--g-a\)' \}\}>\{d\.edge/); + expect(src).not.toMatch(/d\.edge != null \? 'var\(--g-a\)'/); + }); + it('gates the grade-hero glow (textShadow) to A/A+ via gradeGlows', () => { + expect(src).toContain('gradeGlows(d.grade) ?'); + // the ungated always-on glow must be gone + expect(src).not.toMatch(/textShadow: `0 0 28px \$\{hex\}aa, 0 0 60px \$\{hex\}55`,/); + }); +}); + +describe('GradeBadge.tsx — glow gated to A-tier', () => { + const src = read('components/vyndr/GradeBadge.tsx'); + it('boxShadow glow only when glow && isA (A/A+)', () => { + expect(src).toContain("const isA = grade === 'A+' || grade === 'A'"); + expect(src).toMatch(/boxShadow: glow && isA \?/); + }); +}); + +describe('LiveHeroProp.tsx — edge tone is sign-driven', () => { + const src = read('components/LiveHeroProp.tsx'); + it('negative edge maps to muted red, positive to green', () => { + expect(src).toMatch(/edge > 0 \? 'positive' : edge < 0 \? 'negative'/); + expect(src).toContain("tone === 'negative' ? 'var(--miss)'"); + }); +}); + +describe('VYNDR INTELLIGENCE panel — de-flooded (#15)', () => { + const src = read('components/vyndr/GradeResultCard.tsx'); + it('the panel border is a neutral token, not a green wash', () => { + expect(src).not.toContain("border: '1px solid rgba(0,212,160,0.24)'"); + }); +}); diff --git a/web/src/components/LiveHeroProp.tsx b/web/src/components/LiveHeroProp.tsx index 3a6fb73..44a305f 100644 --- a/web/src/components/LiveHeroProp.tsx +++ b/web/src/components/LiveHeroProp.tsx @@ -66,7 +66,7 @@ const row: React.CSSProperties = { borderBottom: '1px solid var(--border)', }; -function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' }) { +function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' | 'negative' }) { return (
{value} @@ -258,7 +259,7 @@ export default function LiveHeroProp() { {/* Projection + edge — visible, the proof. */}
- 0 ? 'positive' : undefined} /> + 0 ? 'positive' : edge < 0 ? 'negative' : undefined} />
{/* Reasoning — BLURRED, the paywall. */} diff --git a/web/src/components/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index 9ce58c3..b915f68 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -7,6 +7,7 @@ import VBtn from '@/components/vyndr/VBtn'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import GradeBadge from '@/components/vyndr/GradeBadge'; import { gradeColor, gradeHex } from '@/lib/vyndrTokens'; +import { edgeColor, gradeGlows } from '@/lib/colorContract'; import { playerHref } from '@/lib/playerHref'; export interface GradeResultData { @@ -137,7 +138,8 @@ export default function GradeResultCard({ lineHeight: 0.95, color: hex, letterSpacing: '-0.04em', - textShadow: `0 0 28px ${hex}aa, 0 0 60px ${hex}55`, + // GLOW = A/A+ ONLY (color contract #4): a glowing C/D devalues the cue. + textShadow: gradeGlows(d.grade) ? `0 0 28px ${hex}aa, 0 0 60px ${hex}55` : 'none', fontFamily: 'var(--sans)', }} > @@ -150,7 +152,7 @@ export default function GradeResultCard({ {d.edge != null && ( <> · - {d.edge >= 0 ? '+' : ''}{d.edge}% edge + {d.edge >= 0 ? '+' : ''}{d.edge}% edge )} · @@ -173,7 +175,7 @@ export default function GradeResultCard({ {[ { l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' }, { l: 'LINE', v: d.line, col: 'var(--text-0)' }, - { l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? 'var(--g-a)' : 'var(--text-2)' }, + { l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? edgeColor(d.edge) : 'var(--text-2)' }, ].map((x, i) => (
{x.l}
@@ -216,15 +218,18 @@ export default function GradeResultCard({
)} - {/* 5c. VYNDR INTELLIGENCE (Session 42) */} + {/* 5c. VYNDR INTELLIGENCE (Session 42). Color contract #6: the panel is + MUTED (neutral border) with a single green label as its identity + accent. Form/Rest are CONTEXT data, not "edge" — neutral-bright, + never green. */} {d.vyndrIntel && ( -
+
VYNDR INTELLIGENCE
- {d.vyndrIntel.form != null && (<>Form{d.vyndrIntel.form}·)} + {d.vyndrIntel.form != null && (<>Form{d.vyndrIntel.form}·)} {d.vyndrIntel.usage && (<>Usage{d.vyndrIntel.usage}·)} {d.vyndrIntel.matchup && (<>Matchup·)} - {d.vyndrIntel.rest && (<>Rest{d.vyndrIntel.rest})} + {d.vyndrIntel.rest && (<>Rest{d.vyndrIntel.rest})}
)} diff --git a/web/src/lib/archetypes.js b/web/src/lib/archetypes.js index ea74753..61bb7da 100644 --- a/web/src/lib/archetypes.js +++ b/web/src/lib/archetypes.js @@ -41,9 +41,9 @@ const ARCHETYPE_MAP = { TORCH: { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle', legacy: 'VOLUME SCORER' }, CONDUCTOR: { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node', legacy: 'FLOOR GENERAL' }, FORTRESS: { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield', legacy: 'TWO-WAY ANCHOR' }, - ARTILLERY: { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target', legacy: 'STRETCH BIG' }, + ARTILLERY: { c: '#159AAE', d: 'Floor-spacing shooting big', g: 'target', legacy: 'STRETCH BIG' }, SURGE: { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow', legacy: 'USAGE SPONGE' }, - 'DUAL THREAT': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin', legacy: 'COMBO GUARD' }, + 'DUAL THREAT': { c: '#1EA6D8', d: 'Scoring + playmaking hybrid', g: 'twin', legacy: 'COMBO GUARD' }, CONNECTOR: { c: '#9499A8', d: 'Low-usage specialist', g: 'chain', legacy: 'ROLE GLUE' }, FASTBREAK: { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons', legacy: 'TRANSITION ENGINE' }, 'PAINT BOSS': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup', legacy: 'POST SCORER' }, @@ -56,21 +56,21 @@ const ARCHETYPE_MAP = { // WNBA-unique DISTRIBUTOR: { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node', legacy: 'POST FACILITATOR' }, SHIELD: { c: '#A78BFA', d: 'Two-way perimeter forward', g: 'shieldCheck', legacy: 'TWO-WAY WING' }, - RANGE: { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target', legacy: 'STRETCH FORWARD' }, + RANGE: { c: '#159AAE', d: 'Floor-spacing forward', g: 'target', legacy: 'STRETCH FORWARD' }, SPARK: { c: '#FB923C', d: 'Downhill scoring guard', g: 'slash', legacy: 'SLASHING GUARD' }, ANCHOR: { c: '#6366F1', d: 'Dominant paint defender and rebounder', g: 'shield', legacy: 'INTERIOR ANCHOR' }, // MLB BOMBER: { c: '#FF6B4A', d: 'Middle-of-the-order power producer', g: 'triangle', legacy: 'POWER SLUGGER' }, - BRUSH: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair', legacy: 'CONTACT' }, + BRUSH: { c: '#C0C63C', d: 'High average, low strikeout', g: 'crosshair', legacy: 'CONTACT' }, DRIVER: { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond', legacy: 'RUN PRODUCER' }, ALPHA: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star', legacy: 'ACE' }, WHIFF: { c: '#FFB347', d: 'Bat-missing arm, high K with traffic', g: 'bolt', legacy: 'STRIKEOUT ARTIST' }, - GHOST: { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons', legacy: 'SPEED THREAT' }, + GHOST: { c: '#159AAE', d: 'Stolen bases, speed score', g: 'chevrons', legacy: 'SPEED THREAT' }, HYBRID: { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half', legacy: 'TWO-WAY PLAYER' }, FLEX: { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus', legacy: 'UTILITY PLAYER' }, WORKHORSE: { c: '#818CF8', d: 'Durable, deep-start arm', g: 'clock', legacy: 'INNINGS EATER' }, CATALYST: { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine', legacy: 'TABLE SETTER' }, - MIRROR: { c: '#34D399', d: 'Gap-to-gap line-drive bat', g: 'uparrow', legacy: 'GAP HITTER' }, + MIRROR: { c: '#8FC04A', d: 'Gap-to-gap line-drive bat', g: 'uparrow', legacy: 'GAP HITTER' }, HAMMER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock', legacy: 'CLOSER' }, SINKER: { c: '#FBBF24', d: 'Groundball spot-starter / long relief', g: 'swap', legacy: 'SWINGMAN' }, BRIDGE: { c: '#6366F1', d: 'Setup / high-leverage middle relief', g: 'shieldCheck', legacy: 'BULLPEN ARM' }, @@ -79,8 +79,8 @@ const ARCHETYPE_MAP = { FINISHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair', legacy: 'POACHER' }, MAESTRO: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node', legacy: 'CREATOR' }, TOWER: { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle', legacy: 'TARGET MAN' }, - MOTOR: { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons', legacy: 'BOX-TO-BOX' }, - BLADE: { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash', legacy: 'WING WIZARD' }, + MOTOR: { c: '#1EA6D8', d: 'All-action central midfielder', g: 'chevrons', legacy: 'BOX-TO-BOX' }, + BLADE: { c: '#159AAE', d: 'Dribbling wide threat', g: 'slash', legacy: 'WING WIZARD' }, WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' }, }; diff --git a/web/src/lib/colorContract.js b/web/src/lib/colorContract.js new file mode 100644 index 0000000..3c173de --- /dev/null +++ b/web/src/lib/colorContract.js @@ -0,0 +1,109 @@ +/* ============================================================ + 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, +};