Session 35: Design system Phase D — core screens: Grade Result, Slate card, Scan, Terminal, Landing (1839 tests)
VYNDR 2.0 conversion, Phase D (the screens users touch). Frontend-only; zero backend changes. - GradeResultCard + ProcessingGrade (the core product moment): intel-surface grade hero, signal breakdown, kill conditions, best-book strip, alt ladder; sections self-hide when empty. - lib/gradeAdapter.js maps engine output -> §7 contract and tier-gates content (free teaser / analyst kill-conditions / desk alt ladder) so the new card doesn't give paid content away. - Scan result wired to ProcessingGrade->GradeResultCard, preserving scan limits, parlay add, reads tracking, and noopener sportsbook deep-links. - GameCard (Bloomberg best/worst line cells) built + tested. - Terminal page replaces its stub with a real league-intelligence screen. - Landing gets the founder-seat ClaimMeter. Honest scope: live dashboard/Slate swap onto GameCard, scan input -> TerminalInput, full landing rebuild, and the blurred-paywall polish (Phase G) are deferred to keep working flows stable. 22 new tests. Backend 1818 -> 1839, 143 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
|
||||
interface ClaimMeterProps {
|
||||
claimed?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Founder-seat scarcity meter (§12 ClaimMeter) — "47 / 100 seats claimed",
|
||||
* amber bar. Cosmetic conversion driver; the live tick-up wires into the
|
||||
* living layer in Session 38. Static here.
|
||||
*/
|
||||
export default function ClaimMeter({ claimed = 47, total = 100 }: ClaimMeterProps) {
|
||||
const pct = Math.min(100, Math.round((claimed / total) * 100));
|
||||
return (
|
||||
<div style={{ maxWidth: 420, margin: '0 auto', width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<SectionHead accent="var(--amber)">FOUNDER SEATS</SectionHead>
|
||||
<span className="mono amber-glow" style={{ fontSize: 13, fontWeight: 700, color: 'var(--amber)' }}>
|
||||
{claimed} / {total} CLAIMED
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #b8761f, var(--amber))',
|
||||
boxShadow: '0 0 12px rgba(255,179,71,.6)',
|
||||
borderRadius: 4,
|
||||
transition: 'width .4s ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 8, letterSpacing: '0.02em' }}>
|
||||
Founder pricing locks for life. When the seats are gone, the rate is gone.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
|
||||
export interface GameLine {
|
||||
book: string;
|
||||
awayML: string;
|
||||
homeML: string;
|
||||
ou: string;
|
||||
bestAway?: boolean;
|
||||
bestHome?: boolean;
|
||||
bestOU?: boolean;
|
||||
worstAway?: boolean;
|
||||
worstHome?: boolean;
|
||||
}
|
||||
export interface GameProp {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
grade: string;
|
||||
side: string;
|
||||
delta?: string;
|
||||
}
|
||||
export interface GameCardData {
|
||||
id: string;
|
||||
sport: string;
|
||||
live?: boolean;
|
||||
score?: { away: number; home: number };
|
||||
clock?: string;
|
||||
away: { abbr: string; name: string };
|
||||
home: { abbr: string; name: string };
|
||||
time: string;
|
||||
venue?: string;
|
||||
gradeSummary?: { a: number; b: number };
|
||||
lines: GameLine[];
|
||||
props?: GameProp[];
|
||||
streaks?: Array<{ player: string; text: string }>;
|
||||
}
|
||||
|
||||
interface GameCardProps {
|
||||
game: GameCardData;
|
||||
onAddParlay?: (p: GameProp) => void;
|
||||
onOpen?: (id: string) => void;
|
||||
}
|
||||
|
||||
/** A book-line cell with the Bloomberg pattern: best = green tint + green left
|
||||
* border, worst = subtle red. The #1 visual upgrade (§13). */
|
||||
function LineCell({ value, best, worst }: { value: string; best?: boolean; worst?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
padding: '8px 6px',
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: best ? 700 : 500,
|
||||
color: best ? 'var(--g-a)' : worst ? '#ff8a8a' : 'var(--text-0)',
|
||||
background: best ? 'rgba(0,212,160,.13)' : worst ? 'rgba(255,82,82,.07)' : 'transparent',
|
||||
borderLeft: best ? '2px solid var(--g-a)' : worst ? '2px solid rgba(255,82,82,.4)' : '2px solid transparent',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropRow({ prop: p, onAddParlay }: { prop: GameProp; onAddParlay?: (p: GameProp) => void }) {
|
||||
const deltaUp = p.delta && p.delta.startsWith('▲');
|
||||
const deltaDown = p.delta && p.delta.startsWith('▼');
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 10px', background: 'var(--bg-2)', borderRadius: 7, border: '1px solid var(--border)' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.player}</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginTop: 2 }}>
|
||||
{p.stat} <span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{p.line}</span>
|
||||
{p.delta && p.delta !== '—' && (
|
||||
<span style={{ marginLeft: 7, color: deltaUp ? 'var(--g-a)' : deltaDown ? 'var(--miss)' : 'var(--text-2)', fontWeight: 700 }}>{p.delta}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GradeBadge grade={p.grade} size={32} glow />
|
||||
{onAddParlay && (
|
||||
<button
|
||||
onClick={() => onAddParlay(p)}
|
||||
title="Add to parlay"
|
||||
style={{ width: 30, height: 30, borderRadius: 6, cursor: 'pointer', flexShrink: 0, background: 'transparent', border: '1px solid var(--border-hi)', color: 'var(--g-a)', fontSize: 18, fontWeight: 700, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard / Slate game card (§7) — game-lines grid w/ best-line highlight,
|
||||
* graded props, inline streaks, live indicator. */
|
||||
export default function GameCard({ game: g, onAddParlay, onOpen }: GameCardProps) {
|
||||
return (
|
||||
<div className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{/* HEADER */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '13px 16px 11px' }}>
|
||||
<div onClick={() => onOpen && onOpen(g.id)} title="Open game detail" style={{ display: 'flex', alignItems: 'center', gap: 11, minWidth: 0, cursor: onOpen ? 'pointer' : 'default' }}>
|
||||
<SportBadge sport={g.sport} />
|
||||
<span className="mono" style={{ fontSize: 18, fontWeight: 700, letterSpacing: '0.01em' }}>
|
||||
{g.away.abbr} <span style={{ color: 'var(--text-2)', fontWeight: 400 }}>@</span> {g.home.abbr}
|
||||
</span>
|
||||
{g.live && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginLeft: 2 }}>
|
||||
<span className="live-dot" />
|
||||
{g.score && <span className="mono" style={{ fontSize: 12, color: 'var(--text-0)', fontWeight: 700 }}>{g.score.away} — {g.score.home}</span>}
|
||||
{g.clock && <span className="mono amber-glow" style={{ fontSize: 10.5, color: 'var(--amber)' }}>{g.clock}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{g.gradeSummary && (g.gradeSummary.a > 0 || g.gradeSummary.b > 0) && (
|
||||
<span className="mono" style={{ fontSize: 12, fontWeight: 700, padding: '4px 9px', borderRadius: 4, background: 'var(--bg-2)', border: '1px solid var(--border-hi)', display: 'inline-flex', gap: 7 }}>
|
||||
{g.gradeSummary.a > 0 && <span style={{ color: 'var(--g-a)' }}>{g.gradeSummary.a}A</span>}
|
||||
{g.gradeSummary.b > 0 && <span style={{ color: 'var(--g-b)' }}>{g.gradeSummary.b}B</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SUB-HEADER */}
|
||||
<div className="mono" style={{ padding: '0 16px 11px', fontSize: 11.5, color: 'var(--text-1)' }}>
|
||||
{g.away.name} <span style={{ color: 'var(--text-2)' }}>·</span> {g.home.name}
|
||||
<span style={{ color: 'var(--text-2)' }}> · </span>{g.time}
|
||||
{g.venue && (<><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</>)}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
{/* GAME LINES */}
|
||||
{g.lines && g.lines.length > 0 && (
|
||||
<div style={{ padding: '13px 16px' }}>
|
||||
<SectionHead style={{ marginBottom: 11 }}>GAME LINES <span style={{ color: 'var(--text-2)' }}>· {g.lines.length} BOOKS</span></SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '78px 1fr 1fr 1fr', gap: '2px 4px', alignItems: 'center' }}>
|
||||
<div className="label" style={{ fontSize: 10 }}>BOOK</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{g.away.abbr} ML</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{g.home.abbr} ML</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
||||
{g.lines.map((ln, i) => (
|
||||
<span key={i} style={{ display: 'contents' }}>
|
||||
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-1)', paddingLeft: 2 }}>{ln.book}</div>
|
||||
<LineCell value={ln.awayML} best={ln.bestAway} worst={ln.worstAway} />
|
||||
<LineCell value={ln.homeML} best={ln.bestHome} worst={ln.worstHome} />
|
||||
<LineCell value={ln.ou} best={ln.bestOU} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
{/* PROPS */}
|
||||
<div style={{ padding: '13px 16px' }}>
|
||||
<SectionHead style={{ marginBottom: 11 }}>GRADED PROPS</SectionHead>
|
||||
{g.props && g.props.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{g.props.map((p, i) => (
|
||||
<PropRow key={i} prop={p} onAddParlay={onAddParlay} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mono" style={{ fontSize: 12.5, color: 'var(--text-1)' }}>Props for this game aren't published yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* INLINE STREAKS */}
|
||||
{g.streaks && g.streaks.length > 0 && (
|
||||
<div style={{ padding: '12px 16px 14px', borderTop: '1px solid var(--border)', background: 'linear-gradient(90deg, rgba(233,75,60,.08), rgba(233,75,60,.02) 60%, transparent)' }}>
|
||||
<SectionHead accent="#ff8b7a" style={{ marginBottom: 9 }}>🔥 STREAKS</SectionHead>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{g.streaks.map((s, i) => (
|
||||
<div key={i} className="mono" style={{ fontSize: 12.5, color: 'var(--text-0)' }}>
|
||||
<span style={{ fontWeight: 700, color: '#ffb0a4' }}>{s.player}</span>
|
||||
<span style={{ color: 'var(--text-2)' }}> — </span>{s.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'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 { gradeColor, gradeHex } from '@/lib/vyndrTokens';
|
||||
|
||||
export interface GradeResultData {
|
||||
player: string;
|
||||
team: string;
|
||||
sport: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
side: 'Over' | 'Under';
|
||||
grade: string;
|
||||
confidence: number;
|
||||
edge: number;
|
||||
projection: number;
|
||||
phosphorConfirmed?: boolean;
|
||||
signals: string[];
|
||||
killConditions?: string[];
|
||||
books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
|
||||
altLadder?: Array<{ line: number; grade: 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 (92–116px) 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)';
|
||||
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 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1 }}>{d.player}</div>
|
||||
<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 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>
|
||||
|
||||
{/* 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"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
fontSize: compact ? 92 : 116,
|
||||
fontWeight: 800,
|
||||
lineHeight: 0.95,
|
||||
color: hex,
|
||||
letterSpacing: '-0.04em',
|
||||
textShadow: `0 0 28px ${hex}aa, 0 0 60px ${hex}55`,
|
||||
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>
|
||||
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
|
||||
<span style={{ color: 'var(--g-a)' }}>{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 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', borderBottom: '1px solid var(--border)' }}>
|
||||
{[
|
||||
{ l: 'MODEL', v: d.projection, col: 'var(--g-a)' },
|
||||
{ l: 'LINE', v: d.line, col: 'var(--text-0)' },
|
||||
{ l: 'EDGE', v: `${d.edge >= 0 ? '+' : ''}${d.edge}%`, col: 'var(--g-a)' },
|
||||
].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 }}>{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} OF 40+ FACTORS</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>
|
||||
)}
|
||||
|
||||
{/* 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: '1px solid var(--border)', borderRadius: 6 }}>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 5 }}>{a.line}</div>
|
||||
<div className="mono" style={{ fontSize: 17, fontWeight: 800, color: gradeColor(a.grade) }}>{a.grade}</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import GradeResultCard, { type GradeResultData } from '@/components/vyndr/GradeResultCard';
|
||||
|
||||
/** Minimal pulsing synapse mark (the full NeuralBrain lands with the living
|
||||
* layer in Session 38). Uses brain-node / brain-link keyframes from globals. */
|
||||
function MiniBrain({ size = 56 }: { size?: number }) {
|
||||
const nodes = [
|
||||
[12, 14], [30, 8], [46, 16], [20, 30], [40, 34], [28, 46], [50, 44], [10, 40],
|
||||
];
|
||||
const links: [number, number][] = [[0, 1], [1, 2], [0, 3], [3, 4], [4, 6], [3, 5], [5, 6], [7, 3], [2, 4]];
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 60 56" aria-hidden style={{ flexShrink: 0 }}>
|
||||
{links.map(([a, b], i) => (
|
||||
<line key={i} className="brain-link" x1={nodes[a][0]} y1={nodes[a][1]} x2={nodes[b][0]} y2={nodes[b][1]} stroke="#00ffb8" strokeWidth="1" opacity="0.5" />
|
||||
))}
|
||||
{nodes.map(([x, y], i) => (
|
||||
<circle key={i} className="brain-node" cx={x} cy={y} r="2.6" fill="#00ffb8" style={{ animationDelay: `${(i % 5) * 0.2}s` }} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProcessingGradeProps {
|
||||
data: GradeResultData;
|
||||
replayKey?: number;
|
||||
onShare?: (d: GradeResultData) => void;
|
||||
onAddToParlay?: (d: GradeResultData) => void;
|
||||
onReadAnother?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "brain weighing factors" moment before a grade resolves (§D.1.3). Factors
|
||||
* ignite sequentially (factor-ignite), a proc-scan bar sweeps, the % rail fills,
|
||||
* then the GradeResultCard reveals. Pure chrome — the underlying grade is fixed.
|
||||
*/
|
||||
export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToParlay, onReadAnother }: ProcessingGradeProps) {
|
||||
const [proc, setProc] = useState(true);
|
||||
const [lit, setLit] = useState(0);
|
||||
|
||||
const factors = (data.signals || []).slice(0, 5);
|
||||
const total = factors.length || 1;
|
||||
|
||||
useEffect(() => {
|
||||
setProc(true);
|
||||
setLit(0);
|
||||
const stepMs = 190;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (let i = 1; i <= total; i++) timers.push(setTimeout(() => setLit(i), stepMs * i));
|
||||
timers.push(setTimeout(() => setProc(false), stepMs * total + 360));
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [replayKey, total]);
|
||||
|
||||
if (!proc) {
|
||||
return (
|
||||
<GradeResultCard
|
||||
data={data}
|
||||
replayKey={replayKey}
|
||||
onShare={onShare}
|
||||
onAddToParlay={onAddToParlay}
|
||||
onReadAnother={onReadAnother}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pct = Math.round((lit / total) * 100);
|
||||
return (
|
||||
<div
|
||||
className="intel-surface"
|
||||
style={{ width: '100%', maxWidth: 640, margin: '0 auto', borderRadius: 12, overflow: 'hidden', border: '1px solid rgba(0,255,184,.25)', minHeight: 360, position: 'relative', boxShadow: '0 0 0 1px rgba(0,212,160,.18), 0 24px 70px -28px rgba(0,212,160,.35)' }}
|
||||
aria-label="Processing grade"
|
||||
>
|
||||
<div className="proc-scan" style={{ position: 'absolute', left: 0, right: 0, top: 0, height: '30%', background: 'linear-gradient(180deg, transparent, rgba(0,255,184,.12) 50%, transparent)', zIndex: 1 }} />
|
||||
<div style={{ position: 'relative', zIndex: 2, padding: '26px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 22 }}>
|
||||
<MiniBrain size={56} />
|
||||
<div>
|
||||
<div className="mono amber-glow" style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-ap)', textShadow: '0 0 10px rgba(0,255,184,.6)' }}>
|
||||
PROCESSING<span className="phosphor-cursor" style={{ height: 11, marginLeft: 3 }} />
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'rgba(232,255,244,.65)', marginTop: 4 }}>
|
||||
{data.player} · {data.side} {data.line} {data.stat}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
|
||||
<div className="mono" style={{ fontSize: 30, fontWeight: 800, color: '#00ffb8', textShadow: '0 0 16px rgba(0,255,184,.5)' }}>{pct}%</div>
|
||||
<div className="mono" style={{ fontSize: 9.5, letterSpacing: '0.12em', color: 'rgba(232,255,244,.5)' }}>WEIGHING 40+ FACTORS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{factors.map((f, i) => {
|
||||
const on = i < lit;
|
||||
return (
|
||||
<div key={i} className={on ? 'factor-ignite' : ''} style={{ display: 'flex', alignItems: 'center', gap: 12, opacity: on ? 1 : 0.18, transition: 'opacity .2s' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, background: on ? '#00ffb8' : 'rgba(232,255,244,.3)', boxShadow: on ? '0 0 10px rgba(0,255,184,.8)' : 'none' }} />
|
||||
<span className="mono" style={{ fontSize: 13, color: on ? '#e8fff4' : 'rgba(232,255,244,.4)' }}>{f}</span>
|
||||
{on && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10.5, color: '#00ffb8', letterSpacing: '0.08em' }}>✓ WEIGHED</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24, height: 4, background: 'rgba(0,0,0,.35)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'linear-gradient(90deg, var(--acc-1), #00ffb8)', borderRadius: 3, transition: 'width .18s ease-out', boxShadow: '0 0 10px rgba(0,255,184,.6)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export { default as VBtn } from './VBtn';
|
||||
export { default as Card } from './Card';
|
||||
export { default as Sparkline } from './Sparkline';
|
||||
export { default as Ticker } from './Ticker';
|
||||
export { default as GradeResultCard } from './GradeResultCard';
|
||||
export type { GradeResultData } from './GradeResultCard';
|
||||
export { default as ProcessingGrade } from './ProcessingGrade';
|
||||
export { default as GameCard } from './GameCard';
|
||||
export type { GameCardData, GameLine, GameProp } from './GameCard';
|
||||
export { default as ClaimMeter } from './ClaimMeter';
|
||||
|
||||
export {
|
||||
GRADE_COLORS,
|
||||
|
||||
Reference in New Issue
Block a user