Merge DS3 (design): the color contract — one meaning, enforced by tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	web/src/components/vyndr/GradeResultCard.tsx
This commit is contained in:
Kev
2026-07-12 19:36:07 -04:00
6 changed files with 296 additions and 29 deletions
+4 -3
View File
@@ -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 (
<div
style={{
@@ -83,7 +83,8 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: 'po
style={{
fontSize: 14,
fontWeight: 700,
color: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
// Color contract #2: edge/CLV colored by SIGN — positive green, negative muted red.
color: tone === 'positive' ? 'var(--grade-a)' : tone === 'negative' ? 'var(--miss)' : 'var(--text-primary)',
}}
>
{value}
@@ -258,7 +259,7 @@ export default function LiveHeroProp() {
{/* Projection + edge — visible, the proof. */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value={`${projection} ${statTypeLabel}`} />
<Stat label="Edge" value={edgeDisplay} tone={edge > 0 ? 'positive' : undefined} />
<Stat label="Edge" value={edgeDisplay} tone={edge > 0 ? 'positive' : edge < 0 ? 'negative' : undefined} />
</div>
{/* Reasoning — BLURRED, the paywall. */}
+15 -10
View File
@@ -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 {
@@ -69,9 +70,9 @@ export default function GradeResultCard({
}, [replayKey]);
const sideColor = d.side === 'Over' ? 'var(--g-a)' : 'var(--miss)';
// COLOR CONTRACT (Part 1 #3): edge/CLV colored by SIGN. A negative edge must
// NEVER render green — negative = var(--miss), positive/zero = signal-green.
const edgeColor = d.edge != null && d.edge < 0 ? 'var(--miss)' : 'var(--g-a)';
// COLOR CONTRACT (Part 1 #3): edge/CLV colored by SIGN via the centralized
// colorContract.edgeColor helper (imported) — negative = var(--miss). DS4
// introduced a local const that DS3 superseded with the shared enforcer.
const hasKill = !!d.killConditions && d.killConditions.length > 0;
const hasBooks = Array.isArray(d.books) && d.books.length > 0;
const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0;
@@ -140,7 +141,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)',
}}
>
@@ -153,7 +155,7 @@ export default function GradeResultCard({
{d.edge != null && (
<>
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span style={{ color: edgeColor }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
<span style={{ color: edgeColor(d.edge) }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
</>
)}
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
@@ -176,7 +178,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 ? edgeColor : '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) => (
<div key={i} style={{ padding: '14px 16px', textAlign: 'center', borderRight: i < 2 ? '1px solid var(--border)' : 'none' }}>
<div className="label" style={{ fontSize: 10, marginBottom: 5 }}>{x.l}</div>
@@ -219,15 +221,18 @@ export default function GradeResultCard({
</div>
)}
{/* 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 && (
<div className="intel-surface" style={{ margin: '0 20px 16px', padding: '15px 16px', borderRadius: 12, border: '1px solid rgba(0,212,160,0.24)' }}>
<div className="intel-surface" style={{ margin: '0 20px 16px', padding: '15px 16px', borderRadius: 12, border: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 12 }}>VYNDR INTELLIGENCE</div>
<div className="mono" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', fontSize: 12 }}>
{d.vyndrIntel.form != null && (<><span style={{ color: 'var(--text-2)' }}>Form</span><span style={{ color: 'var(--g-ap)', fontWeight: 700 }}>{d.vyndrIntel.form}</span><span style={{ color: '#2A3531' }}>·</span></>)}
{d.vyndrIntel.form != null && (<><span style={{ color: 'var(--text-2)' }}>Form</span><span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{d.vyndrIntel.form}</span><span style={{ color: '#2A3531' }}>·</span></>)}
{d.vyndrIntel.usage && (<><span style={{ color: 'var(--text-2)' }}>Usage</span><span style={{ color: 'var(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.usage}</span><span style={{ color: '#2A3531' }}>·</span></>)}
{d.vyndrIntel.matchup && (<><span style={{ color: 'var(--text-2)' }}>Matchup</span><GradeBadge grade={d.vyndrIntel.matchup} size="sm" /><span style={{ color: '#2A3531' }}>·</span></>)}
{d.vyndrIntel.rest && (<><span style={{ color: 'var(--text-2)' }}>Rest</span><span style={{ color: 'var(--g-a)', fontWeight: 600 }}>{d.vyndrIntel.rest}</span></>)}
{d.vyndrIntel.rest && (<><span style={{ color: 'var(--text-2)' }}>Rest</span><span style={{ color: 'var(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.rest}</span></>)}
</div>
</div>
)}
+8 -8
View File
@@ -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' },
};
+109
View File
@@ -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,
};