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 (