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:
Kev
2026-06-16 00:20:45 -04:00
parent 907c7b17c1
commit 1d83682cdb
13 changed files with 1205 additions and 33 deletions
+88 -3
View File
@@ -1,11 +1,96 @@
# VYNDR — Build State # VYNDR — Build State
## Last Updated ## Last Updated
2026-06-15 2026-06-16
## Current Phase ## Current Phase
SHIP BUILD v34.0 — VYNDR 2.0 design system, Phase C: app shell — nav, routing, SHIP BUILD v35.0 — VYNDR 2.0 design system, Phase D: core screens — Grade Result
auth gate, footer, 404 (Session 34) Card, Slate GameCard, Scan, Terminal, Landing claim meter (Session 35)
## Session 35 (2026-06-16) — SHIPPED
Phase D of the VYNDR 2.0 conversion: the screens users touch. Frontend-only;
ZERO backend changes. Backend 1818 → **1839 tests** (+21), 143 suites, zero
regressions. Web build clean (exit 0, 36 routes).
### Strategy (5 screens, time-boxed → prioritized + honest scoping)
Built the high-value NEW components fully + wired the core moment, with
deliberate restraint where ripping out a working flow would regress
monetization or features (paywall polish is Phase G, Session 38).
### D.1 — Grade Result Card (the product's core moment) ✅ full + wired
- `components/vyndr/GradeResultCard.tsx` — faithful port: header, intel-surface
grade hero (92116px grade letter, grade-reveal + crt-sweep-local), confidence
strip, phosphor-confirmed pill, MODEL/LINE/EDGE row, signal breakdown, amber
kill-conditions, best-book strip (green tint + green left border), Desk alt
ladder, action row. **Every section self-hides when its data is empty.**
Callback props (onShare/onAddToParlay/onReadAnother) instead of window.__.
- `components/vyndr/ProcessingGrade.tsx` — the "weighing factors" reveal:
factor-ignite sequence + proc-scan bar + % rail + minimal inline neural SVG
(brain-node/brain-link), then reveals the card. Full NeuralBrain = Session 38.
- `lib/gradeAdapter.js` (CommonJS, unit-tested) — maps our engine output
(`/api/scan` ScanResponse + GradeCard props) → the §7 GradeResultCard contract:
direction→side, humanized stat, computed % edge (projection vs line),
phosphor-confirmed heuristic, factor→signal bullets. **Tier-gates content so
the new card doesn't give paid content away**: free = 3-signal teaser + no
kill conditions + no alt ladder; analyst = full signals + kill conditions;
desk = + alt ladder.
- **Scan page wired**: result render swapped from the legacy `GradeCard` to
`ProcessingGrade → GradeResultCard` via the adapter, keeping ALL existing scan
logic (fetch, scan-limit gating, parlay add). Preserved reads tracking
(`markReadComplete`, sessionStorage-deduped) and the sportsbook deep-links
(`target=_blank` + `rel=noopener noreferrer`). Added a free-tier upgrade nudge.
### D.2 — Slate GameCard (Bloomberg pattern) ✅ component, ⏳ live-swap deferred
- `components/vyndr/GameCard.tsx` — faithful: sport badge + team abbrs (18px/800
mono), live-dot + score/clock, grade-summary chip, 4-col book-lines grid with
**best = green tint + green left border, worst = subtle red**, graded PropRow
(GradeBadge + add-to-parlay), inline 🔥 streaks. Deterministic (no random
live-flash — that's the living layer, Session 38).
- HONEST SCOPE: did NOT rewire the live `dashboard`/`Slate.tsx` data flow onto
this card this session — that mapping (schedule+gamelines+streaks → GameCard
contract) is involved and the working slate shouldn't be destabilized inside a
5-screen session. The component + contract are ready for that swap next.
### D.3 — Scan ✅ result converted (input surface left intact)
Result/grade moment now uses the new design. The rich existing search (player
suggestions, tonight's-players chips, validation) was left as-is — swapping the
wired `<input>` for `TerminalInput` risked the suggestion flow for low visual
gain. Input-surface polish can follow.
### D.4 — Landing ✅ ClaimMeter added (additive)
- `components/vyndr/ClaimMeter.tsx` — amber "47 / 100 CLAIMED" founder-seat
scarcity bar; mounted under the Hero. Existing Hero/Pricing/Features kept (they
already use tokens). Full hero/grade-preview rebuild deferred — additive only
to avoid destabilizing the working conversion page.
### D.5 — Terminal ✅ full new page (replaced the stub)
- `app/terminal/page.tsx` — real league-intelligence screen (server component):
VVI most-impacted games on intel-surface cards, injury-wire cascade analysis,
factor pulse, gradeable leaders, matchup exploits. Uses §7 data shapes with
sample data (real wiring to scheduleService.getGameSummary/schedule/odds is a
later session, per the prompt). SectionHead/GradeBadge/SportBadge throughout.
### Files created
- `web/src/lib/gradeAdapter.js`
- `web/src/components/vyndr/{GradeResultCard,ProcessingGrade,GameCard,ClaimMeter}.tsx`
- `tests/unit/vyndrCoreScreens.test.js` (22 tests: adapter logic + tier gating,
card/processing/gamecard/claimmeter/terminal contracts, scan+landing wiring)
### Files modified
- `web/src/app/scan/page.tsx` (new card + adapter + reads + deep-links + nudge)
- `web/src/app/terminal/page.tsx` (stub → real), `web/src/app/page.tsx` (ClaimMeter)
- `web/src/components/vyndr/index.ts` (barrel exports)
- `tests/unit/vyndrAppShell.test.js` (terminal no longer a stub)
### Deferred (Sessions 36+)
- Live dashboard/Slate swap onto the new GameCard; scan input → TerminalInput;
full landing hero/grade-preview rebuild; Terminal real-data wiring; the
richer blurred-paywall treatment (Phase G). Remaining screens = Session 36.
---
## Session 34 (2026-06-15) — SHIPPED
## Session 34 (2026-06-15) — SHIPPED ## Session 34 (2026-06-15) — SHIPPED
+24
View File
@@ -197,6 +197,30 @@ The frame every page sits in. Frontend-only.
(terminal/compare/invite/help/about/notifications). Never stub over a route (terminal/compare/invite/help/about/notifications). Never stub over a route
that already has real content. that already has real content.
## VYNDR 2.0 Core Screens (Session 35 — Phase D)
- **Grade Result Card** = `@/components/vyndr/GradeResultCard` (the product's
core moment) + `ProcessingGrade` (the factor-ignite reveal that precedes it).
Feed them the §7 contract via `lib/gradeAdapter.js` (`mapScanToGradeResult`).
The adapter is CommonJS (unit-testable) and **tier-gates content**: free =
3-signal teaser, no kill conditions, no alt ladder; analyst = full signals +
kill conditions; desk = + alt ladder. Keep that gating — the card itself shows
whatever it's given, so the giveaway-prevention lives in the adapter.
GradeResultCard returns the inferred JS-object type loosely, so cast
`mapScanToGradeResult(...) as GradeResultData` at call sites (the JS adapter
has no TS types).
- **Scan grade flow** (`app/scan/page.tsx`) now renders ProcessingGrade→
GradeResultCard. The existing search/limit/parlay logic, `markReadComplete`
reads tracking, and `noopener noreferrer` sportsbook deep-links were preserved
— don't drop those when iterating.
- **GameCard** = `@/components/vyndr/GameCard` (Bloomberg best/worst line cells:
best = `rgba(0,212,160,.13)` + green left border, worst = subtle red). Built +
tested but NOT yet swapped into the live `dashboard`/`Slate.tsx` — that
data-mapping swap is a pending task; don't assume the dashboard uses it yet.
- **Terminal** (`app/terminal/page.tsx`) is a real page now (server component,
sample intel data) — no longer a RouteStub. Real-data wiring is later.
- **ClaimMeter** = `@/components/vyndr/ClaimMeter` (founder-seat scarcity), on the
landing under the Hero.
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+2 -1
View File
@@ -121,7 +121,8 @@ describe('Phase C — layout wiring', () => {
}); });
describe('Phase C — route stubs for not-yet-built screens', () => { describe('Phase C — route stubs for not-yet-built screens', () => {
const stubs = ['terminal', 'compare', 'invite', 'help', 'about', 'notifications']; // /terminal became a real page in Session 35 (Phase D) — no longer a stub.
const stubs = ['compare', 'invite', 'help', 'about', 'notifications'];
it.each(stubs)('/%s has a page that uses the design-system RouteStub', (name) => { it.each(stubs)('/%s has a page that uses the design-system RouteStub', (name) => {
expect(exists(`app/${name}/page.tsx`)).toBe(true); expect(exists(`app/${name}/page.tsx`)).toBe(true);
expect(read(`app/${name}/page.tsx`)).toContain('RouteStub'); expect(read(`app/${name}/page.tsx`)).toContain('RouteStub');
+153
View File
@@ -0,0 +1,153 @@
// VYNDR 2.0 — Phase D core screens (Session 35). Adapter LOGIC is exercised
// directly via the CommonJS gradeAdapter; the .tsx screens/components are
// asserted against source text (the plain-JS Jest config has no TS transform).
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 adapter = require('../../web/src/lib/gradeAdapter');
describe('Phase D — grade adapter (engine → §7 contract)', () => {
const base = {
player: 'Nikola Jokic', sport: 'NBA', stat: 'home_runs', line: 26.5, direction: 'over',
grade: 'A', projection: 29.4, confidence: 67, sample_size: 34,
factors: { matchup: 'soft interior', trend: 'hot', usage: 'up', rest: 'rested', pace: 'fast' },
kill_conditions: [{ code: 'BLOWOUT', reason: 'Blowout risk caps minutes' }],
alt_lines: [{ line: 24.5, grade: 'A+' }, { line: 28.5, grade: 'B' }],
};
it('maps direction → side and humanizes the stat', () => {
const out = adapter.mapScanToGradeResult({ ...base, tier: 'desk' });
expect(out.side).toBe('Over');
expect(out.stat).toBe('Home Runs');
expect(out.player).toBe('Nikola Jokic');
});
it('computes a signed percentage edge from projection vs line', () => {
expect(adapter.computeEdge(29.4, 26.5, 'over')).toBeCloseTo(10.9, 1);
expect(adapter.computeEdge(24, 26.5, 'over')).toBeLessThan(0);
expect(adapter.computeEdge(24, 26.5, 'under')).toBeGreaterThan(0);
});
it('flags phosphor confirmed only for A/A+ with strong support', () => {
expect(adapter.isPhosphorConfirmed('A+', 80, 40)).toBe(true);
expect(adapter.isPhosphorConfirmed('A', 50, 35)).toBe(true); // sample carries it
expect(adapter.isPhosphorConfirmed('B', 90, 90)).toBe(false);
expect(adapter.isPhosphorConfirmed('A', 40, 5)).toBe(false);
});
it('FREE tier teases 3 signals, hides kill conditions and the alt ladder', () => {
const out = adapter.mapScanToGradeResult({ ...base, tier: 'free' });
expect(out.signals.length).toBe(3);
expect(out.killConditions).toEqual([]);
expect(out.altLadder).toEqual([]);
});
it('ANALYST tier shows kill conditions + all signals, but no alt ladder', () => {
const out = adapter.mapScanToGradeResult({ ...base, tier: 'analyst' });
expect(out.signals.length).toBe(5);
expect(out.killConditions).toEqual(['Blowout risk caps minutes']);
expect(out.altLadder).toEqual([]);
});
it('DESK tier unlocks the alt line ladder', () => {
const out = adapter.mapScanToGradeResult({ ...base, tier: 'desk' });
expect(out.altLadder).toEqual([{ line: 24.5, grade: 'A+' }, { line: 28.5, grade: 'B' }]);
});
it('defaults missing fields safely', () => {
const out = adapter.mapScanToGradeResult({});
expect(out.grade).toBe('—');
expect(out.side).toBe('Over');
expect(out.signals).toEqual([]);
expect(out.books).toEqual([]);
});
});
describe('Phase D — GradeResultCard (the core moment)', () => {
const src = read('components/vyndr/GradeResultCard.tsx');
it('renders the grade letter at hero size (92116px)', () => {
expect(src).toContain('116');
expect(src).toContain('92');
});
it('uses the intel-surface for the grade hero zone + grade-reveal', () => {
expect(src).toContain('intel-surface');
expect(src).toContain('grade-reveal');
});
it('highlights the best book with green tint + green left border', () => {
expect(src).toContain('rgba(0,212,160,.13)');
expect(src).toContain("borderLeft: b.best ? '2px solid var(--g-a)'");
});
it('renders kill conditions with amber border, gated on non-empty', () => {
expect(src).toContain('hasKill');
expect(src).toContain('rgba(255,179,71,.4)');
expect(src).toContain('KILL CONDITIONS');
});
it('self-hides books / alt ladder when empty', () => {
expect(src).toContain('hasBooks');
expect(src).toContain('hasAlt');
});
});
describe('Phase D — ProcessingGrade', () => {
const src = read('components/vyndr/ProcessingGrade.tsx');
it('plays the factor-ignite + proc-scan sequence then reveals the card', () => {
expect(src).toContain('factor-ignite');
expect(src).toContain('proc-scan');
expect(src).toContain('<GradeResultCard');
});
});
describe('Phase D — GameCard (Bloomberg best/worst lines)', () => {
const src = read('components/vyndr/GameCard.tsx');
it('renders team abbreviations', () => {
expect(src).toContain('g.away.abbr');
expect(src).toContain('g.home.abbr');
});
it('best line = green tint + green border; worst = subtle red', () => {
expect(src).toContain('rgba(0,212,160,.13)');
expect(src).toContain('rgba(255,82,82,.07)');
});
it('shows a live-dot for live games and grades props with GradeBadge', () => {
expect(src).toContain('live-dot');
expect(src).toContain('<GradeBadge');
});
});
describe('Phase D — ClaimMeter', () => {
const src = read('components/vyndr/ClaimMeter.tsx');
it('renders the amber founder-seat scarcity bar', () => {
expect(src).toContain('CLAIMED');
expect(src).toContain('var(--amber)');
});
});
describe('Phase D — Terminal page (real intelligence, not a stub)', () => {
const src = read('app/terminal/page.tsx');
it('is no longer a RouteStub', () => {
expect(src).not.toContain('RouteStub');
});
it('uses the intel-surface and renders the VVI / cascade / leaders sections', () => {
expect(src).toContain('intel-surface');
expect(src).toContain('VOLATILITY INDEX');
expect(src).toContain('INJURY WIRE');
expect(src).toContain('GRADEABLE LEADERS');
});
});
describe('Phase D — scan + landing wiring', () => {
it('scan renders the new ProcessingGrade card via the adapter (legacy GradeCard removed)', () => {
const src = read('app/scan/page.tsx');
expect(src).toContain('ProcessingGrade');
expect(src).toContain('mapScanToGradeResult');
expect(src).not.toContain("from '@/components/GradeCard'");
});
it('scan keeps sportsbook deep-links safe (noopener noreferrer)', () => {
expect(read('app/scan/page.tsx')).toContain('noopener noreferrer');
});
it('landing mounts the ClaimMeter', () => {
expect(read('app/page.tsx')).toContain('ClaimMeter');
});
});
+5
View File
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import Hero from '@/components/Hero'; import Hero from '@/components/Hero';
import { ClaimMeter } from '@/components/vyndr';
// Session 17 — game-count strip mounted between the hero and the // Session 17 — game-count strip mounted between the hero and the
// existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games // existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games
// being graded right now" with a signup CTA. Hides itself when // being graded right now" with a signup CTA. Hides itself when
@@ -44,6 +45,10 @@ export default function Home() {
return ( return (
<> <>
<Hero /> <Hero />
{/* Founder-seat scarcity meter (§12) */}
<div style={{ padding: '0 16px 8px' }}>
<ClaimMeter />
</div>
<TonightsSlate /> <TonightsSlate />
<LivePropsStrip /> <LivePropsStrip />
<div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}> <div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}>
+75 -22
View File
@@ -2,7 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import GradeCard from '@/components/GradeCard'; import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
import { markReadComplete } from '@/lib/reads';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useParlay } from '@/contexts/ParlayContext'; import { useParlay } from '@/contexts/ParlayContext';
import { import {
@@ -83,6 +86,16 @@ const SPORT_ACCENT: Record<Sport, string> = {
WNBA: '#FFB347', WNBA: '#FFB347',
}; };
// Sportsbook deep-links — preserved from the legacy GradeCard so the new
// design keeps the book hand-off. target=_blank + noopener,noreferrer.
const SPORTSBOOKS = [
{ id: 'draftkings', label: 'DraftKings', host: 'sportsbook.draftkings.com' },
{ id: 'fanduel', label: 'FanDuel', host: 'sportsbook.fanduel.com' },
{ id: 'betmgm', label: 'BetMGM', host: 'sports.betmgm.com' },
{ id: 'caesars', label: 'Caesars', host: 'sportsbook.caesars.com' },
];
const deepLink = (host: string, player: string) => `https://${host}/?search=${encodeURIComponent(player)}`;
export default function ScanPage() { export default function ScanPage() {
const router = useRouter(); const router = useRouter();
const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth(); const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
@@ -249,6 +262,17 @@ export default function ScanPage() {
} }
}; };
// Count a completed read once per prop per session (drives the Install/Push
// prompt gates) — preserved from the legacy GradeCard's reveal effect.
useEffect(() => {
if (!result || typeof window === 'undefined') return;
const readKey = `vyndr_read_${sport}_${selectedPlayer}_${stat}_${line}_${direction}`;
if (!window.sessionStorage.getItem(readKey)) {
window.sessionStorage.setItem(readKey, '1');
markReadComplete();
}
}, [result, sport, selectedPlayer, stat, line, direction]);
const reset = () => { const reset = () => {
setResult(null); setResult(null);
setError(''); setError('');
@@ -645,29 +669,27 @@ export default function ScanPage() {
</div> </div>
)} )}
{/* Grade card output */} {/* Grade result — VYNDR 2.0 ProcessingGrade → GradeResultCard (Session 35).
Engine output is mapped to the §7 contract and tier-gated by the adapter. */}
{result && ( {result && (
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}> <div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
<GradeCard <ProcessingGrade
sport={sport} key={`${selectedPlayer}-${stat}-${line}-${direction}`}
player={selectedPlayer} data={mapScanToGradeResult({
stat={stat} player: selectedPlayer,
line={Number(line)} sport,
direction={direction} stat,
grade={result.grade} line: Number(line),
projection={result.projection} direction,
confidence={result.confidence} grade: result.grade,
sample_size={result.sample_size} projection: result.projection,
factors={result.factors} confidence: result.confidence,
alt_lines={result.alt_lines} sample_size: result.sample_size,
kill_conditions={result.kill_conditions} factors: result.factors,
reasoning={result.reasoning} alt_lines: result.alt_lines,
historical_hit_rate={result.historical_hit_rate} kill_conditions: result.kill_conditions,
tier={tier} tier,
onUpgradeClick={(target, from) => { }) as GradeResultData}
trackUpgradeClicked({ current_tier: tier, target_tier: target, trigger_location: from });
router.push(`/api/checkout?tier=${target}`);
}}
onAddToParlay={() => { onAddToParlay={() => {
addLeg({ addLeg({
sport, sport,
@@ -680,8 +702,39 @@ export default function ScanPage() {
}); });
open(); open();
}} }}
onReadAnother={reset}
/> />
{/* Sportsbook hand-off (preserved feature) */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
{SPORTSBOOKS.map((b) => (
<a
key={b.id}
href={deepLink(b.host, selectedPlayer)}
target="_blank"
rel="noopener noreferrer"
className="mono"
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
>
{b.label}
</a>
))}
</div>
{/* Free-tier nudge — full paywall treatment returns in Phase G */}
{tier === 'free' && (
<button
onClick={() => {
trackUpgradeClicked({ current_tier: tier, target_tier: 'analyst', trigger_location: 'grade_card_teaser' });
router.push('/api/checkout?tier=analyst');
}}
className="mono"
style={{ padding: '12px 16px', borderRadius: 8, border: '1px solid rgba(255,179,71,.4)', background: 'rgba(255,179,71,.06)', color: 'var(--amber)', fontWeight: 700, fontSize: 13, cursor: 'pointer' }}
>
Unlock every signal + kill conditions $14.99/mo
</button>
)}
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<button onClick={reset} className="btn-ghost" style={{ flex: 1 }}> <button onClick={reset} className="btn-ghost" style={{ flex: 1 }}>
Read another prop Read another prop
+200 -7
View File
@@ -1,13 +1,206 @@
import RouteStub from '@/components/vyndr/RouteStub'; import SectionHead from '@/components/vyndr/SectionHead';
import GradeBadge from '@/components/vyndr/GradeBadge';
import SportBadge from '@/components/vyndr/SportBadge';
export const metadata = { title: 'Terminal' }; export const metadata = { title: 'The Terminal' };
// Sample league-intelligence dataset (§7 shapes). Real wiring to
// scheduleService.getGameSummary (injury cascades) + schedule/odds (leaders)
// lands in a later session; the screen + contracts are real now.
const INJURY_WIRE = [
{
player: 'Jamal Murray', team: 'DEN', sport: 'nba', status: 'OUT', injury: 'Hamstring', posted: '2h ago',
cascade: [
{ player: 'Nikola Jokić', stat: 'Assists', delta: '+3.2% usage', grade: 'A+' },
{ player: 'Russell Westbrook', stat: 'Points', delta: '+4.1% usage', grade: 'B' },
],
},
{
player: 'Jeremy Sochan', team: 'SA', sport: 'nba', status: 'OUT', injury: 'Ankle sprain', posted: '3h ago',
cascade: [
{ player: 'Victor Wembanyama', stat: 'Points', delta: '+3.2% usage', grade: 'A' },
{ player: 'Devin Vassell', stat: '3PT Made', delta: '+2.0% usage', grade: 'B' },
],
},
{
player: 'Mookie Betts', team: 'LAD', sport: 'mlb', status: 'GTD', injury: 'Wrist', posted: '40m ago',
cascade: [{ player: 'Shohei Ohtani', stat: 'RBIs', delta: '+1.9% lineup', grade: 'C' }],
},
];
const IMPACTED_GAMES = [
{ sport: 'nba', match: 'LAL @ SA', time: '10:30 PM ET', vvi: 92, graded: 7, note: 'Sochan OUT spikes Wembanyama usage; LAL pace + 26th-vs-C matchup compound it.', drivers: ['INJURY CASCADE', 'PACE', 'MATCHUP', 'BLOWOUT RISK'] },
{ sport: 'mlb', match: 'LAD @ ATL', time: '7:20 PM ET', vvi: 78, graded: 5, note: 'Truist Park boosts LH power; Ohtani platoon edge vs RHP, ATL bullpen on fumes.', drivers: ['PARK FACTOR', 'PLATOON', 'BULLPEN FATIGUE'] },
{ sport: 'wnba', match: 'NY @ LV', time: '9:00 PM ET', vvi: 71, graded: 4, note: "Wilson 31% usage vs NY's soft interior; Liberty on a back-to-back.", drivers: ['USAGE', 'MATCHUP', 'REST EDGE'] },
];
const FACTOR_PULSE = [
{ count: 11, label: 'Injury cascades', hint: 'props recalibrated', color: 'var(--g-b)' },
{ count: 8, label: 'Pace mismatches', hint: 'tempo edges', color: 'var(--g-a)' },
{ count: 6, label: 'Park / platoon', hint: 'MLB power spots', color: 'var(--g-a)' },
{ count: 5, label: 'Blowout risk', hint: 'minutes-capped', color: 'var(--miss)' },
];
const LEADERS: { key: string; rows: { player: string; team: string; val: string; grade: string; gradeable: boolean }[] }[] = [
{
key: 'NBA · Points',
rows: [
{ player: 'Nikola Jokić', team: 'DEN', val: '31.2', grade: 'A+', gradeable: true },
{ player: 'Victor Wembanyama', team: 'SA', val: '29.4', grade: 'A', gradeable: true },
{ player: 'Anthony Edwards', team: 'MIN', val: '28.4', grade: 'A', gradeable: true },
],
},
{
key: 'MLB · Total Bases',
rows: [
{ player: 'Shohei Ohtani', team: 'LAD', val: '2.25', grade: 'A', gradeable: true },
{ player: 'Aaron Judge', team: 'NYY', val: '2.10', grade: 'B', gradeable: false },
],
},
];
const MATCHUP_EXPLOITS = [
{ sport: 'nba', team: 'LAL', rank: '26th', stat: 'Points allowed to C', exploit: 'Wembanyama Points O26.5 · A' },
{ sport: 'wnba', team: 'NY', rank: '8th', stat: 'Opp paint FG% allowed', exploit: "A'ja Wilson Points O23.5 · A" },
{ sport: 'mlb', team: 'ATL', rank: 'T-4th', stat: 'HR/9 to lefties', exploit: 'Ohtani Total Bases O1.5 · A' },
];
const statusColor = (s: string) =>
s === 'OUT' ? 'var(--miss)' : s === 'GTD' || s === 'QUESTIONABLE' ? 'var(--amber)' : 'var(--text-1)';
function vviColor(v: number) {
return v >= 85 ? 'var(--g-ap)' : v >= 75 ? 'var(--g-a)' : v >= 65 ? 'var(--g-b)' : 'var(--g-c)';
}
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
export default function TerminalPage() { export default function TerminalPage() {
return ( return (
<RouteStub <section style={{ maxWidth: 1100, margin: '0 auto', padding: '28px 16px 96px' }}>
title="The Terminal" {/* HEADER */}
arriving="SESSION 35" <header style={{ marginBottom: 22 }}>
blurb="League intelligence: injury cascades, game-impact scores, gradeable leaders, factor pulse, matchup exploits." <SectionHead accent="var(--g-a)"> LEAGUE INTELLIGENCE</SectionHead>
/> <h1 className="mono" style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 6px' }}>THE TERMINAL</h1>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
The context the books hide injury cascades, volatility, and the spots tonight's slate underprices.
</p>
</header>
{/* VVI — IMPACTED GAMES (intel surface) */}
<SectionHead style={{ marginBottom: 12 }}>VYNDR VOLATILITY INDEX · MOST-IMPACTED GAMES</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12, marginBottom: 28 }}>
{IMPACTED_GAMES.map((g) => (
<div key={g.match} className="intel-surface scanlines" style={{ borderRadius: 10, padding: 16, position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'relative', zIndex: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SportBadge sport={g.sport} size="sm" />
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: '#e8fff4' }}>{g.match}</span>
</div>
<div style={{ textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 26, fontWeight: 800, lineHeight: 1, color: vviColor(g.vvi), textShadow: '0 0 14px currentColor' }}>{g.vvi}</div>
<div className="label" style={{ fontSize: 8.5, color: 'rgba(232,255,244,.5)' }}>VVI</div>
</div>
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'rgba(232,255,244,.75)', margin: '10px 0', lineHeight: 1.55 }}>{g.note}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{g.drivers.map((d) => (
<span key={d} className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', padding: '2px 6px', borderRadius: 3, color: '#bdf5e2', border: '1px solid rgba(0,255,184,.3)', background: 'rgba(0,0,0,.25)' }}>{d}</span>
))}
</div>
<div className="mono" style={{ marginTop: 10, fontSize: 11, color: 'var(--g-a)' }}>{g.graded} graded props · {g.time}</div>
</div>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
{/* INJURY CASCADES */}
<div>
<SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{INJURY_WIRE.map((w) => (
<div key={w.player} style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SportBadge sport={w.sport} size="sm" />
<span style={{ fontSize: 14, fontWeight: 700 }}>{w.player}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{w.team}</span>
</div>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: statusColor(w.status) }}>{w.status}</span>
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 10 }}>{w.injury} · {w.posted}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{w.cascade.map((c, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)' }}></span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{c.player}</span>
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)' }}> · {c.stat} <span style={{ color: 'var(--g-a)', fontWeight: 700 }}> {c.delta}</span></span>
</span>
<GradeBadge grade={c.grade} size="sm" glow />
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* RIGHT COLUMN */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* FACTOR PULSE */}
<div>
<SectionHead style={{ marginBottom: 12 }}>FACTOR PULSE · FIRING NOW</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{FACTOR_PULSE.map((f) => (
<div key={f.label} style={card}>
<div className="mono" style={{ fontSize: 28, fontWeight: 800, color: f.color, lineHeight: 1 }}>{f.count}</div>
<div style={{ fontSize: 12.5, fontWeight: 700, marginTop: 6 }}>{f.label}</div>
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)', marginTop: 2 }}>{f.hint}</div>
</div>
))}
</div>
</div>
{/* GRADEABLE LEADERS */}
<div>
<SectionHead style={{ marginBottom: 12 }}>GRADEABLE LEADERS</SectionHead>
{LEADERS.map((grp) => (
<div key={grp.key} style={{ ...card, marginBottom: 10 }}>
<div className="label" style={{ marginBottom: 9 }}>{grp.key}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{grp.rows.map((r) => (
<div key={r.player} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600 }}>{r.player} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{r.team}</span></span>
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)' }}>{r.val}</span>
<GradeBadge grade={r.grade} size="sm" glow={r.gradeable} />
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
{/* MATCHUP EXPLOITS */}
<div style={{ marginTop: 28 }}>
<SectionHead style={{ marginBottom: 12 }}>MATCHUP EXPLOITS · UNDERPRICED SPOTS</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{MATCHUP_EXPLOITS.map((m, i) => (
<div key={i} style={card}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<SportBadge sport={m.sport} size="sm" />
<span className="mono" style={{ fontSize: 13, fontWeight: 700 }}>{m.team}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--miss)', fontWeight: 700 }}>{m.rank}</span>
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 8 }}>{m.stat}</div>
<div className="mono" style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--g-a)' }}> {m.exploit}</div>
</div>
))}
</div>
</div>
</section>
); );
} }
+40
View File
@@ -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>
);
}
+188
View File
@@ -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&apos;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 (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)';
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>
);
}
+6
View File
@@ -8,6 +8,12 @@ export { default as VBtn } from './VBtn';
export { default as Card } from './Card'; export { default as Card } from './Card';
export { default as Sparkline } from './Sparkline'; export { default as Sparkline } from './Sparkline';
export { default as Ticker } from './Ticker'; 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 { export {
GRADE_COLORS, GRADE_COLORS,
+92
View File
@@ -0,0 +1,92 @@
/* ============================================================
VYNDR 2.0 grade adapter (§7).
Maps our grading-engine output (the /api/scan ScanResponse shape +
the GradeCard props) onto the GradeResultCard data contract from the
design spec. Plain CommonJS so the .tsx card imports it (allowJs) AND
Jest exercises the mapping logic directly (no TS/Babel transform).
============================================================ */
const STAT_LABELS = {
points: 'Points', rebounds: 'Rebounds', assists: 'Assists', threes: '3-Pointers',
steals: 'Steals', blocks: 'Blocks', pra: 'P+R+A', turnovers: 'Turnovers',
strikeouts: 'Strikeouts', hits_allowed: 'Hits Allowed', earned_runs: 'Earned Runs',
innings_pitched: 'Innings Pitched', hits: 'Hits', total_bases: 'Total Bases',
rbi: 'RBI', runs: 'Runs', home_runs: 'Home Runs',
};
/** Humanize a stat_type id ("home_runs" → "Home Runs"). */
function statLabel(stat) {
if (!stat) return '';
if (STAT_LABELS[stat]) return STAT_LABELS[stat];
return String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Signed edge as a percentage of the line, from projection vs line. */
function computeEdge(projection, line, direction) {
if (projection == null || line == null) return 0;
const raw = direction === 'under' ? line - projection : projection - line;
const pct = line > 0 ? (raw / line) * 100 : raw;
return Math.round(pct * 10) / 10;
}
/** A/A+ with strong support → "phosphor confirmed". */
function isPhosphorConfirmed(grade, confidence, sampleSize) {
const g = String(grade || '').trim().toUpperCase();
const strong = g === 'A+' || g === 'A';
return strong && ((confidence != null && confidence >= 70) || (sampleSize != null && sampleSize >= 30));
}
/** factors object/array → plain-English signal bullets (46). */
function toSignals(factors) {
if (!factors) return [];
if (Array.isArray(factors)) return factors.filter(Boolean).slice(0, 6).map(String);
return Object.entries(factors)
.filter(([, v]) => Boolean(v))
.slice(0, 6)
.map(([k, v]) => `${k.replace(/_/g, ' ').replace(/\b\w/, (c) => c.toUpperCase())}: ${v}`);
}
/**
* Map an engine result to the GradeResultCard contract.
* input: { player, team, sport, stat, line, direction|side, grade, projection,
* confidence, sample_size, factors, alt_lines, kill_conditions, books, tier }
*/
function mapScanToGradeResult(input = {}) {
const direction = (input.side || input.direction || 'over').toString().toLowerCase();
const side = direction === 'under' ? 'Under' : 'Over';
const line = input.line != null ? Number(input.line) : 0;
const projection = input.projection != null ? Number(input.projection) : undefined;
const confidence = input.confidence != null ? Math.round(Number(input.confidence)) : 0;
const tier = input.tier || 'free';
const includeAlt = tier === 'desk';
// Tier gating so the new card doesn't give paid content away (free users get
// a 3-signal teaser; kill conditions are Analyst+; alt ladder is Desk). The
// richer blurred-paywall treatment returns in Phase G (Session 38).
const allSignals = toSignals(input.factors);
const signals = tier === 'free' ? allSignals.slice(0, 3) : allSignals;
const killConditions = tier === 'free'
? []
: (input.kill_conditions || []).map((k) => (typeof k === 'string' ? k : (k && k.reason) || '')).filter(Boolean);
return {
player: input.player || '',
team: input.team || '',
sport: (input.sport || 'nba').toString().toLowerCase(),
stat: statLabel(input.stat),
line,
side,
grade: input.grade || '—',
confidence,
edge: computeEdge(projection, line, direction),
projection: projection != null ? Math.round(projection * 10) / 10 : line,
phosphorConfirmed: isPhosphorConfirmed(input.grade, input.confidence, input.sample_size),
signals,
killConditions,
books: Array.isArray(input.books) ? input.books : [],
altLadder: includeAlt && Array.isArray(input.alt_lines)
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade }))
: [],
};
}
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals };