From 1d83682cdb8d5dcf44ae82a572fe1ed9667fe774 Mon Sep 17 00:00:00 2001 From: Kev Date: Tue, 16 Jun 2026 00:20:45 -0400 Subject: [PATCH] =?UTF-8?q?Session=2035:=20Design=20system=20Phase=20D=20?= =?UTF-8?q?=E2=80=94=20core=20screens:=20Grade=20Result,=20Slate=20card,?= =?UTF-8?q?=20Scan,=20Terminal,=20Landing=20(1839=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BUILD-STATE.md | 91 +++++++- CLAUDE.md | 24 ++ tests/unit/vyndrAppShell.test.js | 3 +- tests/unit/vyndrCoreScreens.test.js | 153 +++++++++++++ web/src/app/page.tsx | 5 + web/src/app/scan/page.tsx | 97 ++++++-- web/src/app/terminal/page.tsx | 207 ++++++++++++++++- web/src/components/vyndr/ClaimMeter.tsx | 40 ++++ web/src/components/vyndr/GameCard.tsx | 188 ++++++++++++++++ web/src/components/vyndr/GradeResultCard.tsx | 221 +++++++++++++++++++ web/src/components/vyndr/ProcessingGrade.tsx | 111 ++++++++++ web/src/components/vyndr/index.ts | 6 + web/src/lib/gradeAdapter.js | 92 ++++++++ 13 files changed, 1205 insertions(+), 33 deletions(-) create mode 100644 tests/unit/vyndrCoreScreens.test.js create mode 100644 web/src/components/vyndr/ClaimMeter.tsx create mode 100644 web/src/components/vyndr/GameCard.tsx create mode 100644 web/src/components/vyndr/GradeResultCard.tsx create mode 100644 web/src/components/vyndr/ProcessingGrade.tsx create mode 100644 web/src/lib/gradeAdapter.js diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 9d3f301..328fee1 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -1,11 +1,96 @@ # VYNDR — Build State ## Last Updated -2026-06-15 +2026-06-16 ## Current Phase -SHIP BUILD v34.0 — VYNDR 2.0 design system, Phase C: app shell — nav, routing, -auth gate, footer, 404 (Session 34) +SHIP BUILD v35.0 — VYNDR 2.0 design system, Phase D: core screens — Grade Result +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 (92–116px 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 `` 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 diff --git a/CLAUDE.md b/CLAUDE.md index be6700d..d71be94 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,6 +197,30 @@ The frame every page sits in. Frontend-only. (terminal/compare/invite/help/about/notifications). Never stub over a route 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 - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/tests/unit/vyndrAppShell.test.js b/tests/unit/vyndrAppShell.test.js index 0adf559..bcc2dd6 100644 --- a/tests/unit/vyndrAppShell.test.js +++ b/tests/unit/vyndrAppShell.test.js @@ -121,7 +121,8 @@ describe('Phase C — layout wiring', () => { }); 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) => { expect(exists(`app/${name}/page.tsx`)).toBe(true); expect(read(`app/${name}/page.tsx`)).toContain('RouteStub'); diff --git a/tests/unit/vyndrCoreScreens.test.js b/tests/unit/vyndrCoreScreens.test.js new file mode 100644 index 0000000..191f4f6 --- /dev/null +++ b/tests/unit/vyndrCoreScreens.test.js @@ -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 (92–116px)', () => { + 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(' { + 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(' { + 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'); + }); +}); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index d8f5823..6edae41 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import Hero from '@/components/Hero'; +import { ClaimMeter } from '@/components/vyndr'; // Session 17 — game-count strip mounted between the hero and the // existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games // being graded right now" with a signup CTA. Hides itself when @@ -44,6 +45,10 @@ export default function Home() { return ( <> + {/* Founder-seat scarcity meter (§12) */} +
+ +
diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index d671c13..6ff7145 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { useParlay } from '@/contexts/ParlayContext'; import { @@ -83,6 +86,16 @@ const SPORT_ACCENT: Record = { 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() { const router = useRouter(); 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 = () => { setResult(null); setError(''); @@ -645,29 +669,27 @@ export default function ScanPage() {
)} - {/* 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 && (
- { - trackUpgradeClicked({ current_tier: tier, target_tier: target, trigger_location: from }); - router.push(`/api/checkout?tier=${target}`); - }} + { addLeg({ sport, @@ -680,8 +702,39 @@ export default function ScanPage() { }); open(); }} + onReadAnother={reset} /> + {/* Sportsbook hand-off (preserved feature) */} +
+ {SPORTSBOOKS.map((b) => ( + + {b.label} ↗ + + ))} +
+ + {/* Free-tier nudge — full paywall treatment returns in Phase G */} + {tier === 'free' && ( + + )} +
+ )} +
+ ); +} + +/** 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 ( +
+ {/* HEADER */} +
+
onOpen && onOpen(g.id)} title="Open game detail" style={{ display: 'flex', alignItems: 'center', gap: 11, minWidth: 0, cursor: onOpen ? 'pointer' : 'default' }}> + + + {g.away.abbr} @ {g.home.abbr} + + {g.live && ( + + + {g.score && {g.score.away} — {g.score.home}} + {g.clock && {g.clock}} + + )} +
+ {g.gradeSummary && (g.gradeSummary.a > 0 || g.gradeSummary.b > 0) && ( + + {g.gradeSummary.a > 0 && {g.gradeSummary.a}A} + {g.gradeSummary.b > 0 && {g.gradeSummary.b}B} + + )} +
+ + {/* SUB-HEADER */} +
+ {g.away.name} · {g.home.name} + · {g.time} + {g.venue && (<> · {g.venue})} +
+ +
+ + {/* GAME LINES */} + {g.lines && g.lines.length > 0 && ( +
+ GAME LINES · {g.lines.length} BOOKS +
+
BOOK
+
{g.away.abbr} ML
+
{g.home.abbr} ML
+
O/U
+ {g.lines.map((ln, i) => ( + +
{ln.book}
+ + + +
+ ))} +
+
+ )} + +
+ + {/* PROPS */} +
+ GRADED PROPS + {g.props && g.props.length > 0 ? ( +
+ {g.props.map((p, i) => ( + + ))} +
+ ) : ( +
Props for this game aren't published yet.
+ )} +
+ + {/* INLINE STREAKS */} + {g.streaks && g.streaks.length > 0 && ( +
+ 🔥 STREAKS +
+ {g.streaks.map((s, i) => ( +
+ {s.player} + {s.text} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/web/src/components/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx new file mode 100644 index 0000000..f431b05 --- /dev/null +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -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 ( +
+ {sweep &&
} + + {/* 1. HEADER */} +
+
+
{d.player}
+
+ {d.side.toUpperCase()} {d.line} + ·{d.stat} +
+
+
+ + {d.team && {d.team}} +
+
+ + {/* 2. GRADE HERO — intel surface */} +
+
VYNDR GRADE
+
+ {d.grade} +
+ + {/* 3. CONFIDENCE STRIP */} +
+ {d.grade} + · + {d.edge >= 0 ? '+' : ''}{d.edge}% edge + · + {d.confidence}% confidence +
+ + {d.phosphorConfirmed && ( +
+ + PHOSPHOR CONFIRMED +
+ )} +
+ + {/* 4. PROJECTION ROW */} +
+ {[ + { 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) => ( +
+
{x.l}
+
{x.v}
+
+ ))} +
+ + {/* 5. SIGNAL BREAKDOWN */} + {d.signals.length > 0 && ( +
+ SIGNAL BREAKDOWN · {d.signals.length} OF 40+ FACTORS +
+ {d.signals.map((s, i) => ( +
+ + {s} +
+ ))} +
+
+ )} + + {/* 6. KILL CONDITIONS */} + {hasKill && ( +
+
+ KILL CONDITIONS +
+ {d.killConditions!.map((k, i) => ( +
{k}
+ ))} +
+ )} + + {/* 7. BOOK COMPARISON */} + {hasBooks && ( +
+ BOOK COMPARISON +
+ {d.books.map((b, i) => ( +
+ {b.name} +
+ {d.side === 'Under' ? 'U' : 'O'}{b.line} + {b.odds} + {b.best && BEST} +
+
+ ))} +
+
+ )} + + {/* 8. ALT LINE LADDER (Desk) */} + {hasAlt && ( +
+ + ALT LINE LADDER DESK + +
+ {d.altLadder!.map((a, i) => ( +
+
{a.line}
+
{a.grade}
+
+ ))} +
+
+ )} + + {/* 9. ACTION ROW */} +
+ onShare && onShare(d)}>↗ Share This Grade + onAddToParlay && onAddToParlay(d)}>+ Add to Parlay + {onReadAnother && Read Another →} +
+
+ ); +} diff --git a/web/src/components/vyndr/ProcessingGrade.tsx b/web/src/components/vyndr/ProcessingGrade.tsx new file mode 100644 index 0000000..56473e3 --- /dev/null +++ b/web/src/components/vyndr/ProcessingGrade.tsx @@ -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 ( + + {links.map(([a, b], i) => ( + + ))} + {nodes.map(([x, y], i) => ( + + ))} + + ); +} + +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[] = []; + 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 ( + + ); + } + + const pct = Math.round((lit / total) * 100); + return ( +
+
+
+
+ +
+
+ PROCESSING +
+
+ {data.player} · {data.side} {data.line} {data.stat} +
+
+
+
{pct}%
+
WEIGHING 40+ FACTORS
+
+
+ +
+ {factors.map((f, i) => { + const on = i < lit; + return ( +
+ + {f} + {on && ✓ WEIGHED} +
+ ); + })} +
+ +
+
+
+
+
+ ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 423729a..b6b36c8 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -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, diff --git a/web/src/lib/gradeAdapter.js b/web/src/lib/gradeAdapter.js new file mode 100644 index 0000000..a9499c3 --- /dev/null +++ b/web/src/lib/gradeAdapter.js @@ -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 (4–6). */ +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 };