Files
vyndr/web/src/components/vyndr/GradeResultCard.tsx
T
builtbykev 47ada9013c Wave 2A: real player headshots — sport-agnostic id threaded from ingestion
Threads a REAL athlete id from the snapshot's per-player stats resolve (zero
new I/O) → enriched grade → grades:{sport} → slate strip → PlayerAvatar. Real
photo where an id resolves; team-colored monogram (never a gray silhouette,
never a broken image) where it can't. Ids are never fabricated.

Ingestion (Addition 1):
- espnStatsAdapter.getSeasonAverages now RETURNS the resolved ESPN athlete id
  (was discarded) as espnId; non-numeric uid degrades to null.
- playerIntelService surfaces MLBAM playerId (MLB) / ESPN espnId (NBA/WNBA).
- snapshotService captures both per player and stores them on the enriched
  grade beside archetype/team (null when unresolved → monogram path).

Thread → component:
- slateAdapter.buildPlayerStripsFromProps carries playerId/espnId onto each
  strip; StatStrip → PlayerAvatar (accepts both ids; getHeadshotUrl routes by
  sport: MLB→mlbstatic, NBA/WNBA→a.espncdn).
- Silhouette surfaces rewired to PlayerAvatar (branded monogram on null):
  scan search dropdown (guarded MLBAM p.id) + tonight chips, SearchModal,
  HotListPanel, GradeResultCard header. Scan grade card feeds the picked
  MLBAM id through gradeAdapter.
- playerHeadshot pure URL logic extracted to CommonJS playerHeadshotUrl.js
  (unit-testable; the .ts re-exports it). nfl/nhl added to ESPN_SPORT_PATH.

Tests: tests/unit/headshotThread.test.js (per-league URL + id thread + monogram
null path) + extended snapshotService/espnStatsAdapter suites. Full suite
241 suites / 2915 green; next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:18:59 -04:00

329 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import SportBadge from '@/components/vyndr/SportBadge';
import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import GradeBadge from '@/components/vyndr/GradeBadge';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
import { edgeColor, gradeGlows } from '@/lib/colorContract';
import { playerHref } from '@/lib/playerHref';
export interface GradeResultData {
player: string;
team: string;
sport: string;
// Wave 2A — real headshot ids (optional; self-hide to a team-colored
// monogram). MLB → MLBAM playerId; NBA/WNBA → ESPN espnId.
playerId?: string | number | null;
espnId?: string | number | null;
stat: string;
line: number;
side: 'Over' | 'Under';
grade: string;
confidence: number;
edge: number | null;
projection: number | null;
phosphorConfirmed?: boolean;
signals: string[];
killConditions?: string[];
books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
altLadder?: Array<{ line: number; grade: string; edge?: number | null; base?: boolean }>;
// Session 62 (A1-S1) — quarter-Kelly sizing (Desk only; real p × real odds).
kelly?: { pct: number; quarter: number; odds: string };
// Session 42 — Player Intelligence additions (all optional; self-hide).
archetypeBlend?: Array<{ archetype: string; weight: number }>;
propDNA?: { reliable: string[]; volatile: string[] };
statContext?: { season?: string; last10?: string; vsOpp?: string };
vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string };
}
interface GradeResultCardProps {
data: GradeResultData;
replayKey?: number;
compact?: boolean;
onShare?: (d: GradeResultData) => void;
onAddToParlay?: (d: GradeResultData) => void;
onReadAnother?: () => void;
}
/**
* The product's core moment (§7). The grade letter is the largest, first-read
* element (92116px) on the intel-surface "VYNDR is speaking" zone, revealing
* with grade-reveal + a CRT sweep. Data is sacred — nothing here glitches.
* Sections (kill conditions, books, alt ladder) self-hide when empty.
*/
export default function GradeResultCard({
data,
replayKey = 0,
compact = false,
onShare,
onAddToParlay,
onReadAnother,
}: GradeResultCardProps) {
const d = data;
const c = gradeColor(d.grade);
const hex = gradeHex(d.grade);
const [sweep, setSweep] = useState(true);
useEffect(() => {
setSweep(true);
const t = setTimeout(() => setSweep(false), 700);
return () => clearTimeout(t);
}, [replayKey]);
const sideColor = d.side === 'Over' ? 'var(--g-a)' : 'var(--miss)';
// 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;
return (
<div
style={{
width: '100%',
maxWidth: 640,
margin: '0 auto',
position: 'relative',
background: 'var(--bg-1)',
border: '1px solid var(--border-hi)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: `0 0 0 1px color-mix(in srgb, ${c} 22%, transparent), 0 24px 70px -28px ${hex}55, 0 18px 50px -20px rgba(0,0,0,.7)`,
}}
aria-label={`VYNDR grade for ${d.player}`}
>
{sweep && <div className="crt-sweep-local" />}
{/* 1. HEADER — player name links to the full profile (Session 42) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
{/* Wave 2A — player identity block: real headshot / team-colored
monogram (never a gray silhouette). */}
<PlayerAvatar name={d.player} sport={d.sport as HeadshotSport} playerId={d.playerId} espnId={d.espnId} team={d.team} size={40} />
<div style={{ minWidth: 0 }}>
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<SportBadge sport={d.sport} />
{d.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{d.team}</span>}
</div>
</div>
{/* 1b. ARCHETYPE STRIP (Session 42) — between header and grade hero */}
{Array.isArray(d.archetypeBlend) && d.archetypeBlend.length > 0 && (
<div style={{ padding: '13px 20px', background: '#0C0E0D', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<ArchetypeBlend blend={d.archetypeBlend} size="sm" showLegend caption="Why this grade: the prop sits in this player's PRIMARY lane, so the model weights it heavily." />
{d.propDNA && (
<div className="mono" style={{ fontSize: 11, color: '#7E8A86' }}>
<span style={{ color: 'var(--text-2)' }}>PROP DNA</span>
{(d.propDNA.reliable || []).map((s) => (
<span key={s}> · {s.replace(/_/g, ' ')} <span style={{ color: 'var(--g-a)' }}> reliable</span></span>
))}
{(d.propDNA.volatile || []).map((s) => (
<span key={s}> · {s.replace(/_/g, ' ')} <span style={{ color: 'var(--amber)' }}> volatile</span></span>
))}
</div>
)}
</div>
)}
{/* 2. GRADE HERO — intel surface */}
<div className="intel-surface" style={{ padding: '26px 20px 22px', textAlign: 'center' }}>
<div className="label" style={{ position: 'relative', zIndex: 2, color: 'rgba(232,255,244,.5)', marginBottom: 2 }}>VYNDR GRADE</div>
<div
key={replayKey}
className="grade-reveal grade-hero"
style={{
position: 'relative',
zIndex: 2,
fontSize: compact ? 92 : 116,
fontWeight: 800,
lineHeight: 0.95,
color: hex,
letterSpacing: '-0.04em',
// 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)',
}}
>
{d.grade}
</div>
{/* 3. CONFIDENCE STRIP */}
<div className="mono" style={{ position: 'relative', zIndex: 2, marginTop: 8, fontSize: 15, fontWeight: 600, color: 'var(--text-0)', display: 'flex', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ color: hex, fontWeight: 800 }}>{d.grade}</span>
{d.edge != null && (
<>
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</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>
<span>{d.confidence}% confidence</span>
</div>
{d.phosphorConfirmed && (
<div style={{ position: 'relative', zIndex: 2, marginTop: 13, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '5px 12px', border: '1px solid rgba(0,255,184,.45)', borderRadius: 100, background: 'rgba(0,255,184,.08)' }}>
<span className="phosphor-cursor" style={{ width: 7, height: 13, margin: 0 }} />
<span className="mono amber-glow" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-ap)', textShadow: '0 0 10px rgba(0,255,184,.7)' }}>PHOSPHOR CONFIRMED</span>
</div>
)}
</div>
{/* 4. PROJECTION ROW — DATA SEMANTICS (Session 58): LINE is a real
market number (fact); MODEL/EDGE are model output. When the model
has no projection they render an absent state ("—"), never the
line or a fake +0%. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', borderBottom: '1px solid var(--border)' }}>
{[
{ 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(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>
<div className="mono" style={{ fontSize: 19, fontWeight: 700, color: x.col, fontVariantNumeric: 'tabular-nums' }}>{x.v}</div>
</div>
))}
</div>
{/* 5. SIGNAL BREAKDOWN */}
{d.signals.length > 0 && (
<div style={{ padding: '16px 20px' }}>
<SectionHead style={{ marginBottom: 12 }}>SIGNAL BREAKDOWN · {d.signals.length} FACTORS SHOWN</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{d.signals.map((s, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 7px rgba(0,212,160,.7)', flexShrink: 0 }} />
<span className="mono" style={{ fontSize: 13, color: 'var(--text-0)', letterSpacing: '0.01em' }}>{s}</span>
</div>
))}
</div>
</div>
)}
{/* 5b. STAT CONTEXT (Session 42) */}
{d.statContext && (d.statContext.season || d.statContext.last10 || d.statContext.vsOpp) && (
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--border)' }}>
<SectionHead style={{ marginBottom: 12 }}>STAT CONTEXT</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: 'var(--border)', border: '1px solid var(--border)', borderRadius: 9, overflow: 'hidden' }}>
{[
{ l: 'SEASON', v: d.statContext.season, col: 'var(--text-0)' },
{ l: 'LAST 10', v: d.statContext.last10, col: 'var(--g-a)' },
{ l: 'vs OPP', v: d.statContext.vsOpp, col: 'var(--g-a)' },
].map((x, i) => (
<div key={i} style={{ background: 'var(--bg-1)', padding: '11px 12px' }}>
<div className="mono" style={{ fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.06em', marginBottom: 5 }}>{x.l}</div>
<div className="mono" style={{ fontSize: 16, fontWeight: 600, color: x.v ? x.col : 'var(--text-2)' }}>{x.v || '—'}</div>
</div>
))}
</div>
</div>
)}
{/* 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 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(--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(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.rest}</span></>)}
</div>
</div>
)}
{/* 6. KILL CONDITIONS */}
{hasKill && (
<div style={{ margin: '0 20px 16px', border: '1px solid rgba(255,179,71,.4)', borderRadius: 8, background: 'rgba(255,179,71,.06)', padding: '13px 15px' }}>
<div className="label amber-glow" style={{ color: 'var(--amber)', marginBottom: 9, display: 'flex', alignItems: 'center', gap: 7 }}>
<span style={{ fontSize: 13 }}></span> KILL CONDITIONS
</div>
{d.killConditions!.map((k, i) => (
<div key={i} className="mono cb-neg" style={{ fontSize: 12.5, color: '#ffd9a8', letterSpacing: '0.01em' }}>{k}</div>
))}
</div>
)}
{/* 7. BOOK COMPARISON */}
{hasBooks && (
<div style={{ padding: '0 20px 16px' }}>
<SectionHead style={{ marginBottom: 10 }}>BOOK COMPARISON</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{d.books.map((b, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 13px', borderRadius: 6, background: b.best ? 'rgba(0,212,160,.13)' : 'var(--bg-2)', borderLeft: b.best ? '2px solid var(--g-a)' : '2px solid transparent' }}>
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: b.best ? 'var(--g-a)' : 'var(--text-0)' }}>{b.name}</span>
<div className="mono" style={{ fontSize: 13, display: 'flex', gap: 14, alignItems: 'center' }}>
<span style={{ color: 'var(--text-1)' }}>{d.side === 'Under' ? 'U' : 'O'}{b.line}</span>
<span style={{ color: b.best ? 'var(--g-a)' : 'var(--text-0)', fontWeight: 700, minWidth: 44, textAlign: 'right' }}>{b.odds}</span>
{b.best && <span className="label" style={{ color: 'var(--g-a)', fontSize: 9 }}>BEST</span>}
</div>
</div>
))}
</div>
</div>
)}
{/* 8. ALT LINE LADDER (Desk) */}
{hasAlt && (
<div style={{ padding: '0 20px 16px' }}>
<SectionHead style={{ marginBottom: 10 }}>
ALT LINE LADDER <span style={{ color: 'var(--amber)', fontSize: 9, border: '1px solid rgba(255,179,71,.4)', borderRadius: 3, padding: '1px 5px', marginLeft: 4 }}>DESK</span>
</SectionHead>
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
{d.altLadder!.map((a, i) => (
<div key={i} style={{ flex: '1 1 0', minWidth: 64, textAlign: 'center', padding: '9px 6px', background: 'var(--bg-2)', border: a.base ? '1px solid var(--border-hi)' : '1px solid var(--border)', borderRadius: 6 }}>
<div className="mono" style={{ fontSize: 12, color: a.base ? 'var(--text-0)' : 'var(--text-1)', marginBottom: 5 }}>{a.line}{a.base ? ' •' : ''}</div>
<div className="mono" style={{ fontSize: 17, fontWeight: 800, color: gradeColor(a.grade) }}>{a.grade}</div>
{a.edge != null && (
<div className="mono" style={{ fontSize: 10, color: Number(a.edge) >= 0 ? 'var(--g-a)' : 'var(--miss)', marginTop: 3 }}>{Number(a.edge) >= 0 ? '+' : ''}{a.edge}%</div>
)}
</div>
))}
</div>
{/* Quarter-Kelly (Desk) — real probability × the real captured odds. */}
{d.kelly && (
<div className="mono" style={{ marginTop: 10, fontSize: 12, color: 'var(--text-1)' }}>
<span className="label" style={{ fontSize: 9.5, marginRight: 8 }}>QTR-KELLY</span>
<span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{d.kelly.pct}% of bankroll</span>
<span style={{ color: 'var(--text-2)' }}> · at {d.kelly.odds}</span>
</div>
)}
</div>
)}
{/* 9. ACTION ROW */}
<div style={{ display: 'flex', gap: 10, padding: '16px 20px', borderTop: '1px solid var(--border)', background: 'var(--bg-2)', alignItems: 'center' }}>
<VBtn variant="primary" style={{ flex: 1 }} onClick={() => onShare && onShare(d)}> Share This Grade</VBtn>
<VBtn variant="outline" style={{ flex: 1 }} onClick={() => onAddToParlay && onAddToParlay(d)}>+ Add to Parlay</VBtn>
{onReadAnother && <VBtn variant="ghost" small onClick={onReadAnother}>Read Another </VBtn>}
</div>
{/* 10. PUSH-TO-BOOK teaser (Session 52) — feature not live yet. */}
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 20px 16px', textAlign: 'center', background: 'var(--bg-2)' }}>
<span className="mono" style={{ color: 'var(--text-2)', fontSize: 11, letterSpacing: '0.06em' }}>
PUSH-TO-BOOK · COMING SOON
</span>
<div style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 4 }}>
One tap from grade to bet slip. Connect DraftKings, FanDuel, BetMGM.
</div>
</div>
</div>
);
}