Files
vyndr/web/src/components/SoccerGradeResult.tsx
T
builtbykev 489d849f1e P0-1 fix: ledger/u grade badges are token-derived — kill blue-B / amber-C / non-A glow
Phone audit found LEDGER READ CARDS rendering B badges with BLUE borders + C
with AMBER right now in prod — GradePill (components/GradeCard.tsx) hardcoded the
OLD palette (rgba(74,158,255) blue-B, rgba(255,179,71) amber-C) for bg/border
while the text used the migrated token. Migrated bg/border to color-mix on the
grade token, so B renders neutral-white and C grey (matching the board).
- globals.css .grade-*-bg → token-derived color-mix (was raw blue/amber rgba).
- DELETED glow from .grade-glow-b/c/d (glow is A-tier ONLY, by law) — B/C/D keep
  their token color, no text-shadow.
- Purged the last dead grade-blue #4A9EFF fallbacks (SoccerGradeResult, the
  intelligence INFO dot).
- REGRESSION LOCK: vyndrParityQA fails if #4a9eff / rgba(74,158,255) reappears
  anywhere in web/src, if GradePill hardcodes blue/amber rgba, or if grade-glow
  B/C/D grow a text-shadow again.

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

351 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useMemo } from 'react';
/**
* Soccer result card — renders an analyze/prop response with
* soccer-specific visual treatment. We can't surface raw feature
* values (the backend response carries only `reasoning.summary` +
* `kill_conditions_triggered` per the engine1 → legacy adapter), so
* we parse the summary for known soccer-signal phrases and surface
* each as a colored chip above the prose.
*
* Free-tier responses already arrive gated (the Session 7h
* `applyTierGating` redacts `reasoning` and `kill_conditions`); we
* just need to detect the `tier_gated` / `locked` markers and show
* an upgrade CTA over the blurred content.
*/
interface KillCondition {
code: string;
reason: string;
locked?: boolean;
}
interface Reasoning {
summary?: string;
steps?: unknown;
locked?: boolean;
}
export interface SoccerGradeResultProps {
player: string;
stat_type: string;
line: number;
direction: 'over' | 'under';
league: string;
grade: string;
confidence?: number;
edge_pct?: number;
reasoning?: Reasoning;
kill_conditions_triggered?: KillCondition[];
tier_gated?: boolean;
upgrade_hint?: string;
onUpgradeClick?: () => void;
onClose?: () => void;
}
type SignalTone = 'positive' | 'caution' | 'warning' | 'neutral';
interface ParsedSignal {
icon: string;
label: string;
detail: string;
tone: SignalTone;
}
const SIGNAL_TONE_STYLE: Record<SignalTone, { color: string; bg: string; border: string }> = {
positive: { color: 'var(--grade-a)', bg: 'rgba(0,200,150,0.08)', border: 'rgba(0,200,150,0.40)' },
caution: { color: 'var(--grade-c, #B8BCC8)', bg: 'rgba(255,179,71,0.08)', border: 'rgba(255,179,71,0.40)' },
warning: { color: 'var(--grade-d, #ff5a5a)', bg: 'rgba(255,90,90,0.08)', border: 'rgba(255,90,90,0.40)' },
neutral: { color: 'var(--text-secondary)', bg: 'transparent', border: 'var(--border)' },
};
// Pattern-match the concrete sentences `buildSoccerReasoningLines`
// emits in src/services/intelligence/analyzeViaEngine1.js. Order
// matters — earlier patterns win when multiple match the same line.
const SIGNAL_PATTERNS: Array<(line: string) => ParsedSignal | null> = [
(line) => {
const m = line.match(/scores ([\d.]+) goals per 90 minutes/i);
if (m) return { icon: '⚽', label: 'Goals / 90', detail: `${m[1]}`, tone: 'positive' };
return null;
},
(line) => {
const m = line.match(/Expected goals \(xG\): ([\d.]+) per 90 — (.+)/i);
if (m) {
const trend = m[2].toLowerCase();
const tone: SignalTone = trend.includes('regression') ? 'caution'
: trend.includes('breakout') ? 'positive' : 'neutral';
return { icon: '📊', label: 'xG / 90', detail: `${m[1]}${m[2]}`, tone };
}
return null;
},
(line) => {
if (/Designated penalty taker/i.test(line)) {
return { icon: '🎯', label: 'Penalty Taker', detail: '+0.15 goals/90 boost', tone: 'positive' };
}
return null;
},
(line) => {
if (/Direct free-kick specialist/i.test(line)) {
return { icon: '🏹', label: 'Free-Kick Taker', detail: 'shot/goal probability boost', tone: 'positive' };
}
return null;
},
(line) => {
if (/corner taker/i.test(line)) {
return { icon: '⛳', label: 'Corner Taker', detail: 'assist probability boost', tone: 'positive' };
}
return null;
},
(line) => {
const m = line.match(/Match at ([\d,]+)ft altitude\.\s*(.+)/i);
if (m) {
const isAcclimated = /acclimated host/i.test(m[2]);
return {
icon: '🏔️',
label: 'Altitude',
detail: `${m[1]}ft — ${isAcclimated ? 'host acclimated' : 'visitor risk'}`,
tone: isAcclimated ? 'neutral' : 'warning',
};
}
return null;
},
(line) => {
const m = line.match(/(.+?) averages ([\d.]+) cards per match/i);
if (m) {
const cardsPerGame = parseFloat(m[2]);
const tone: SignalTone = cardsPerGame >= 5 ? 'caution' : 'neutral';
return { icon: '🟨', label: `Referee: ${m[1].trim()}`, detail: `${m[2]} cards/match`, tone };
}
return null;
},
(line) => {
const m = line.match(/Averaging only ([\d.]+) minutes per match/i);
if (m) return { icon: '⏱️', label: 'Minutes', detail: `${m[1]}/90 — under-line discount`, tone: 'caution' };
return null;
},
(line) => {
const m = line.match(/(.+?) concedes ([\d.]+) goals per game/i);
if (m) {
const conceded = parseFloat(m[2]);
const tone: SignalTone = conceded <= 0.8 ? 'warning' : conceded >= 1.6 ? 'positive' : 'neutral';
return { icon: '🛡️', label: `Defense: ${m[1].trim()}`, detail: `${m[2]} GA/match`, tone };
}
return null;
},
(line) => {
const m = line.match(/Tournament pedigree: (\d+) career World Cup goals/i);
if (m) return { icon: '🏆', label: 'WC Pedigree', detail: `${m[1]} career goals`, tone: 'positive' };
return null;
},
];
function parseSignals(summary: string | undefined): ParsedSignal[] {
if (!summary) return [];
const out: ParsedSignal[] = [];
// The buildSoccerReasoningLines output is a single `lines.join(' ')`,
// so split on period+space and trim. Some sentences contain periods
// (e.g. "0.67 goals per 90"), so re-match conservatively.
const fragments = summary.split(/(?<=\.)\s+(?=[A-Z⚽📊🎯🏹⛳🏔️🟨⏱️🛡️🏆])/);
for (const frag of fragments) {
for (const fn of SIGNAL_PATTERNS) {
const sig = fn(frag);
if (sig) {
out.push(sig);
break;
}
}
}
return out;
}
function gradeColor(grade: string): string {
const g = (grade || '').trim().toUpperCase().charAt(0);
if (g === 'A') return 'var(--grade-a)';
if (g === 'B') return 'var(--grade-b, #F0F0F0)';
if (g === 'C') return 'var(--grade-c, #B8BCC8)';
return 'var(--grade-d, #ff5a5a)';
}
export default function SoccerGradeResult(props: SoccerGradeResultProps) {
const {
player, stat_type, line, direction, league, grade, confidence, edge_pct,
reasoning, kill_conditions_triggered, tier_gated, upgrade_hint,
onUpgradeClick, onClose,
} = props;
const signals = useMemo(() => parseSignals(reasoning?.summary), [reasoning?.summary]);
const color = gradeColor(grade);
const locked = !!tier_gated || !!reasoning?.locked;
const kills = Array.isArray(kill_conditions_triggered) ? kill_conditions_triggered : [];
return (
<article
className="surface diagonal-cut"
style={{
padding: 24,
border: `1px solid ${color}`,
background: 'var(--bg-elevated)',
borderRadius: 8,
marginTop: 16,
position: 'relative',
}}
data-testid="soccer-grade-result"
>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
position: 'absolute', top: 8, right: 12,
background: 'transparent', border: 0, cursor: 'pointer',
fontSize: 18, color: 'var(--text-tertiary)',
}}
>
×
</button>
)}
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{player}</div>
<div
className="mono"
style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4, textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{direction.toUpperCase()} {line.toFixed(1)} {stat_type.replace(/_/g, ' ')} · {league.toUpperCase()}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 44, fontWeight: 800, color, lineHeight: 1, letterSpacing: '-0.04em' }}>
{grade}
</div>
{typeof confidence === 'number' && (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>
{confidence.toFixed(0)}% conf
{typeof edge_pct === 'number' && (
<> · {edge_pct >= 0 ? '+' : ''}{edge_pct.toFixed(1)}% edge</>
)}
</div>
)}
</div>
</header>
{signals.length > 0 && !locked && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
{signals.map((sig, idx) => {
const style = SIGNAL_TONE_STYLE[sig.tone];
return (
<div
key={`${sig.label}-${idx}`}
style={{
padding: '8px 12px',
border: `1px solid ${style.border}`,
background: style.bg,
borderRadius: 6,
fontSize: 12,
display: 'flex',
alignItems: 'baseline',
gap: 6,
maxWidth: '100%',
}}
>
<span aria-hidden style={{ fontSize: 14 }}>{sig.icon}</span>
<span
className="mono"
style={{
color: style.color,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
fontSize: 10,
}}
>
{sig.label}
</span>
<span style={{ color: 'var(--text-primary)' }}>{sig.detail}</span>
</div>
);
})}
</div>
)}
{!locked && reasoning?.summary && (
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: 16 }}>
{reasoning.summary}
</p>
)}
{locked && (
<div
style={{
padding: 20,
border: '1px dashed var(--border)',
borderRadius: 6,
background: 'rgba(0,0,0,0.20)',
textAlign: 'center',
marginBottom: 16,
}}
>
<div
className="mono"
style={{
filter: 'blur(4px)',
userSelect: 'none',
color: 'var(--text-tertiary)',
fontSize: 13,
marginBottom: 16,
}}
aria-hidden
>
Goals/90: 0.67 · 📊 xG: 0.52 overperforming · 🏔 altitude 7,349ft · 🟨 ref 4.7 cards/match · 🎯 penalty taker
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
{upgrade_hint || 'Unlock full intelligence — xG regression, altitude, referee, set-piece role.'}
</p>
<button
type="button"
onClick={onUpgradeClick}
className="btn-primary"
style={{ padding: '8px 18px', fontSize: 13 }}
>
Unlock full analysis
</button>
</div>
)}
{kills.length > 0 && (
<section style={{ marginTop: 8 }}>
<h3
className="mono"
style={{ fontSize: 10, color: 'var(--grade-d, #ff5a5a)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}
>
Kill conditions ({kills.length})
</h3>
<ul style={{ display: 'grid', gap: 6 }}>
{kills.map((k, idx) => (
<li
key={`${k.code}-${idx}`}
style={{
padding: '6px 10px',
border: '1px solid rgba(255,90,90,0.30)',
background: 'rgba(255,90,90,0.04)',
borderRadius: 4,
fontSize: 12,
}}
>
<span className="mono" style={{ color: 'var(--grade-d, #ff5a5a)', fontWeight: 700, marginRight: 6 }}>
{k.code}
</span>
<span style={{ color: 'var(--text-secondary)' }}>{k.reason}</span>
</li>
))}
</ul>
</section>
)}
</article>
);
}