diff --git a/specs/DESIGN-SPEC.md b/specs/DESIGN-SPEC.md new file mode 100644 index 0000000..4b46c1f --- /dev/null +++ b/specs/DESIGN-SPEC.md @@ -0,0 +1,84 @@ +# VYNDR — DESIGN SPEC v2 +### The raised standard. Commit to /specs/DESIGN-SPEC.md. Supersedes v1. Governs the design train and every future surface. Built from: cross-vertical research (Bloomberg/Linear/Superhuman/Stripe), the live design audit (22 findings), and the founder's realism reference. +### Governed by MASTERMIND-BUILD-STANDARD (tokenized, tested, no one-off CSS). Every rule here is buildable — a token, a component, a motion primitive, or an asset. + +## THE ONE LINE +The substance is already here — the record (55-19, 74% hit), the archetype system, real live data. v2's entire job: **stop the presentation from failing the substance.** VYNDR must feel like a $1M/month terminal that costs $44.99 — the gut reaction "how is this only $44.99" IS the conversion event. + +--- + +## PART 0 — GOVERNING LAWS (from founder + research) +1. **ALIVE IS PUNCTUATED STILLNESS** — mostly still, meaningful pulses. Not constant motion. Fast motion reads cheap; fast RESPONSE reads expensive. +2. **PREMIUM = SPEED + RESTRAINT, NOT RICHNESS** — Bloomberg's moat is zero-latency; Linear feels expensive via sub-200ms + optimistic UI; Superhuman is "confident, not loud." Perceived instantaneity is the #1 premium lever. +3. **THE BLEND** — peak-ESPN swagger (weighted motion that ARRIVES then RESTS; one hero per screen) + Bloomberg density (dense but RANKED, effortless, instant) + VYNDR's existing glitch/signal-green signature (keepers — evolve, never replace). +4. **REALISM** — entities render as THEMSELVES: real team logos/colors, player identity, sportsbook wordmarks. Never a gray string. +5. **DENSITY IS UNLIMITED UNDER FIXED GRAMMAR** — the founder LIKES density; the failure is dense-and-flat. Rank it or it's noise. +6. **THE GAP IS THE PRODUCT** — design every surface so the price feels impossibly low for the experience. + +--- + +## PART 1 — THE COLOR CONTRACT (fixes audit #3,#6,#9,#15; enforce as QA tests) +- **Signal-green #00D4A0 = ONE meaning: edge / active / A-tier / primary CTA.** Own it like Linear owns purple. It is VYNDR's signature color — unmistakable, never diluted. +- **Never green for:** decorative backgrounds (kill the green-flooded Intelligence panel #15), "live" status while stale (#6), two competing CTAs (#9), or archetype colors that are green-family (dedupe BRUSH/WORKHORSE off the signal hue). +- **Edge / CLV colored by SIGN:** negative = muted red, positive = signal-green. A -33.3% edge must NEVER render green (#3). +- **Grades colored by TIER:** A/A+ signal-green, B neutral-bright, C muted, D/F red. +- **GLOW = A/A+ ONLY.** A glowing C devalues the cue (#3). Glow is the scarcest signal in the system. +- **Muted default, full-saturation only on what matters** (the Linear pattern, audit-validated): most text/chrome at 40–60% opacity; saturation reserved for the one thing that matters per surface. + +## PART 2 — THE ENTITY LAYER (fixes #10,#11,#12 — founder's #1 priority; one system, swapped everywhere) +A single asset/rendering system, cached, reused on every surface: +- **Teams:** real logo + primary/secondary colors. Game cards, grade cards, team hubs get the logo and a team-colored edge/accent. Never "MIL @ PIT" as bare text. +- **Players:** real headshot; fallback = team-colored monogram (not the gray silhouette). Always paired with position + status chip (CONFIRMED / OUT / GTD / NOT-IN-LINEUP) as a consistent identity block. Team hub already does position — propagate. +- **Sportsbooks:** real wordmarks in brand color (DraftKings, FanDuel, BetMGM, etc.), never lowercase text. Best-price gets a clear semantic highlight. +- **Reference standard:** the founder's admired game-strip — real logos, real O/U, park, context — executed to LEGIBLE premium (the reference's own cramped table is what VYNDR does BETTER: same density, real hierarchy). + +## PART 3 — THE DATA-DISPLAY STANDARD (fixes #13,#14,#16,#19,#21 — the "horrid" core) +Every stat/line/player/game is a designed component, not a cell. Rules: +- **One bold hero figure among muted context.** The number that matters is large + mono + tabular; everything else demoted. The failure (#14,#19): every value identical weight. +- **Stats carry context (the lens):** never a bare number — value + rank + trend + matchup. ".312" is trash; ".312 · 4th · ▲ up from .287 · .340 vs this SP" is intelligence. +- **Collapse dead repetition (#14):** six rows of "Grades post ~6:00 PM ET" → one compact "6 props pending · grade 6 PM" line. High real estate must carry high information. +- **Rankings must vary (#13):** if a leaderboard's rows are identical (all doubles-U0.5-55%-B), the ranking is meaningless — surface the varying signal (edge, confidence, trend) and close dead-space gaps with context. Tabular mono, aligned. +- **Charts that are flat should SAY so (#16):** CLV 73/74 flat → "CLV flat — we grade the outcome, not the close" in one confident line, not a histogram that reads as broken. The best data (55-19, 74%) must look the most premium, never errored. +- **No naked "—" fields (#15,#21):** populate or hide. A Desk billing page with blank STATUS, an Intelligence panel with four dashes — both read unfinished. +- **Legends where needed:** the last-10 dot strip (●○○●) needs a hit/miss legend. + +## PART 4 — MOTION + PERCEIVED SPEED (fixes #5,#7,#22; research's #1 lever) +- **Sub-200ms everything.** View transitions <200ms, weighted/eased (never linear, never slow). Optimistic UI: actions appear instant, reconcile in background (Linear model). +- **Kill loading walls (#5):** no "Loading the slate…" / "Assembling the pack…" text. Layout-matched skeletons only. FIX the React #418 hydration mismatch — server HTML must not be discarded and re-rendered. +- **Ticker = punctuated stillness (#7):** rests on each item long enough to READ (≥4s hold, or a static ranked "top moves" strip that pulses only on change). ONE animated element in the header zone — not ticker + heartbeat + counter stacked. +- **Motion categories:** IDLE (subtle proof-of-life — SYNC tick, one live-dot), TRANSITION (sub-200ms weighted), DATA-UPDATE (300ms green pulse on change — the terminal reacting), REVEAL (grade declassify, the Settle — staged, rare, deliberate), GLITCH (wordmark RGB-split on load, header scanline — chrome only, NEVER on data). All tokenized (--motion-*), prefers-reduced-motion honored globally. +- **The peak-ESPN rule:** motion ARRIVES with weight, then RESTS to be read. Swagger in the chrome, signal always pristine. + +## PART 5 — ARCHETYPE SYSTEM (the Rosetta stone — propagate, don't reinvent) +The team hub already renders archetypes as an ownable visual language: glyph + color chip (★ALPHA, ▲BOMBER, »GHOST, etc.). This is the strongest asset on the site. v2: +- **Propagate the glyph+chip to EVERY surface** — dashboard, grade reveal, /desk, streaks, ledger, Data Brief (currently plain text / JSON in most). One component, everywhere. +- **Dedupe archetype colors off signal-green** (BRUSH, WORKHORSE currently green-family — shift them so #00D4A0 stays uniquely "edge"). +- **Each archetype gets its one-line meaning on-page** where it leads (player page, grade reveal), expander for the long form. + +## PART 6 — CONVERSION ARCHITECTURE (design that produces the million) +- **Never an empty hero (#1):** "Top grades tonight" must fall back to yesterday's A-tier settled receipts (you have 55-19) — first paint ALWAYS proves the model. +- **Desk $44.99 = the hero tier (#8):** rebuild pricing so the premium tier carries the "$1M terminal" story — real feature visuals, balanced layout (kill the dead right-half), a CTA at least as strong as Analyst's. Stop selling down. +- **Landing 5-second test:** one sharp positioning line about how it FEELS + live proof above the fold (real grades, the record). The product working IS the hero. Not features, not animation. +- **Build the missing /u profile (#4):** the #1 shareable surface doesn't exist — a discoverable, designed, CLV-verified public profile (Kev's partner-pitch ammo). +- **Fix scan→ledger persistence (#17):** a completed read must land in the ledger instantly — the "Every grade, no hiding" promise breaking is a conversion-killer. +- **Growth prompts don't occlude the product (#9):** the notify/PWA card shows once, dismissible, one primary CTA. + +## PART 7 — SCREENSHOT-FIRST PRIORITY (the timeline is customer #1) +Design attention follows screenshot value: **P0 billboards** (Settle card, grade reveal, STREAKS row, /u profile, OG images) get pixel-level craft, tested at social crop ratios · **P1** landing/dashboard/pricing · **P2** slate rows/scan/explore/player/team · **P3** settings/onboarding. The STREAKS row (#2) is a billboard: real player image, streak-length as mono hero, muted "built vs" context, one severity accent — a Bloomberg alert, not a log line. + +## PART 8 — EMPTY/ERROR SYSTEM (fixes #20) +The 404 page is already on-brand (glitch, "TRANSMISSION INTERRUPTED", correct CTA hierarchy). Make it the quality bar: ONE designed empty/error system reused everywhere. Kill the bare-red "Team not found"; unify the three offseason-state voices into one. + +--- + +## THE DESIGN TRAIN (build sequence — sessions like the A1 board) +- **DS0 — Entity Layer** (Part 2): logos/colors/headshots/wordmarks system, swapped into every surface. Biggest single transformation; founder's #1. +- **DS1 — Speed + Trust Bugs** (Part 4 + #17): fix React #418, skeletons replace text-walls, scan→ledger persistence. +- **DS2 — Dashboard Slate Rebuild** (the named #1 rebuild): real entities + one hero per card + collapse pending-filler + never-empty hero. Inherit the team-hub bar. +- **DS3 — Color Contract** (Part 1): green = one meaning, edge/CLV by sign, glow A-tier only, re-skin green-flooded panels + double-green CTAs, dedupe archetype greens. +- **DS4 — Billboards** (Part 7): STREAKS row, grade reveal, CLV reframe, build /u profile. +- **DS5 — Pricing + Motion + States**: Desk-as-hero pricing, ticker to punctuated stillness, unify empty/error to the 404 bar, propagate archetype glyphs everywhere (Part 5). + +Each DS session: tokenized components, QA-locked (the color contract becomes failing tests), prefers-reduced-motion respected, acceptance evidence, ship on green. Same discipline as A1. + +— DESIGN SPEC v2 · the presentation catches up to the substance — diff --git a/specs/STATE.md b/specs/STATE.md index 2fe9614..10c5992 100644 --- a/specs/STATE.md +++ b/specs/STATE.md @@ -1,6 +1,40 @@ # VYNDR — STATE OF THE WORLD ### As of `ec5ee3d` (main, 2026-07-12). This file opens every future session. Update it when a train ships. +## READY TO MERGE — DESIGN TRAIN v2 (branch `design/v2-train` @ `3d08390`, NOT on main yet) +Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as +themselves, color contract, sub-200ms, screenshot-first billboards). **All six +sessions DS0–DS5 are DONE, merged onto `design/v2-train`, green +(238 suites / 2885 tests) + `next build` exit 0. Awaiting the founder's +"merge the train" before touching main.** +- **DS0 Entity Layer** (`24af247`): `teamMeta.js` (real logos+colors for 30 MLB / + 30 NBA / 13 WNBA / 48 WC nations via ESPN CDNs), `TeamLogo` / `PlayerAvatar` + (team-colored monogram fallback, no gray silhouette) / `BookWordmark`, swapped + into GameCard/StatStrip/StreaksPanel/TeamHub. +- **DS1 Speed + Trust Bugs** (`1c681df`): React #418 hydration fix (mounted-flag + guard), layout-matched `Skeleton`s replace text-wall loaders, scan→ledger + persistence via `session.access_token`. **DS1 follow-up** (`cf91c04`): closed + the sb-token trust-bug CLASS — `lib/authToken.js` `currentAccessToken()` reads + the REAL Supabase session (not the OAuth-only `sb-token` key) across + profile/slip/dashboard/settings/tracker. +- **DS3 Color Contract** (`49a3323`): `lib/colorContract.js` — green = one meaning + (edge/A-tier/CTA), edge/CLV by sign, glow A/A+ only, archetype greens deduped. + Enforced by `colorContract.test.js` (fails on violation). +- **DS4 Billboards** (`45bafbc`): STREAKS row (length as mono hero), grade reveal + (sign-colored edge), CLV reframe (flat says so), `/u` public profile + OG. +- **DS2 Dashboard Slate Rebuild** (`fe294a5`): never-empty hero (falls back to + yesterday's A-tier settled receipts), one bold hero per card, pending-filler + collapse, ranked tonight-grades. `slateAdapter.js` engine + 21 tests. +- **DS5 Pricing + Motion + States** (`a18a3f3`): Desk $44.99 as the hero tier + (single green CTA, `DeskShowcase`), ticker → punctuated stillness (≥4.2s hold, + one idle motion), unified `EmptyState` (404 grammar) at TeamHub/game/ledger, + archetype glyphs propagated to streaks/ledger. +NOTE: WSL2↔Windows-Chrome localhost is unreachable in this env, so visual proof +is SSR-render + source-assertion tests, not pixel screenshots. Post-merge TODO: +re-run the Chrome design audit against v2 to confirm the 22 findings closed. + + + ## WHERE THINGS STAND **Tests: 2757 / 229 suites, all green. Web build exit 0.** Baseline at the A1 diff --git a/src/services/archetypeService.js b/src/services/archetypeService.js index 385beab..3c88931 100644 --- a/src/services/archetypeService.js +++ b/src/services/archetypeService.js @@ -37,7 +37,7 @@ const ARCHETYPES = { education: 'A Fortress generates value on the defensive glass and at the rim. Rebounds and blocks are matchup-resilient; their scoring depends on how much offense flows their way.', }, ARTILLERY: { - legacyName: 'STRETCH BIG', tag: 'ARTILLERY', sport: 'nba', color: '#2DD4BF', glyph: 'target', + legacyName: 'STRETCH BIG', tag: 'ARTILLERY', sport: 'nba', color: '#159AAE', glyph: 'target', description: 'Floor-spacing shooting big', propDNA: { reliable: ['threes', 'rebounds'], volatile: ['assists', 'blocks'] }, education: 'Artillery bigs space the floor and crash the glass. Threes and rebounds are their bread and butter; assists and blocks are situational.', @@ -49,7 +49,7 @@ const ARCHETYPES = { education: 'A Surge soaks up shots when a star sits or is injured. Their points props spike on cascade nights — read the injury report before trusting the baseline.', }, 'DUAL THREAT': { - legacyName: 'COMBO GUARD', tag: 'DUAL THREAT', sport: 'nba', color: '#00D4A0', glyph: 'twin', + legacyName: 'COMBO GUARD', tag: 'DUAL THREAT', sport: 'nba', color: '#1EA6D8', glyph: 'twin', description: 'Scoring + playmaking hybrid', propDNA: { reliable: ['points', 'assists'], volatile: ['rebounds'] }, education: 'Dual Threats score and create in equal measure, so points and assists both stay in play. Rebounds are the volatile leg for their size.', @@ -123,7 +123,7 @@ const ARCHETYPES = { education: 'Shields contribute on both ends. Points and defensive stats stay live; their playmaking is secondary.', }, RANGE: { - legacyName: 'STRETCH FORWARD', tag: 'RANGE', sport: 'wnba', color: '#2DD4BF', glyph: 'target', + legacyName: 'STRETCH FORWARD', tag: 'RANGE', sport: 'wnba', color: '#159AAE', glyph: 'target', description: 'Floor-spacing forward', propDNA: { reliable: ['threes', 'points'], volatile: ['assists', 'blocks'] }, education: 'Range forwards space the floor from the four. Threes and points are reliable; interior props are matchup-dependent.', @@ -149,7 +149,7 @@ const ARCHETYPES = { education: 'Bombers drive the ball over the fence. Total bases and home-run props carry their value; their batting-average-driven props (hits) are the volatile leg from strikeout risk.', }, BRUSH: { - legacyName: 'CONTACT', tag: 'BRUSH', sport: 'mlb', color: '#3DDC84', glyph: 'crosshair', + legacyName: 'CONTACT', tag: 'BRUSH', sport: 'mlb', color: '#C0C63C', glyph: 'crosshair', description: 'High average, low strikeout', propDNA: { reliable: ['hits'], volatile: ['home_runs', 'total_bases'] }, education: 'Brush hitters rarely strike out, so their hits props are among the most reliable in baseball. Power props (HR, TB) are the volatile leg.', @@ -173,7 +173,7 @@ const ARCHETYPES = { education: 'Whiffs rack up strikeouts but allow more baserunners than an Alpha. The strikeout prop is reliable; earned-run and innings props swing with their traffic.', }, GHOST: { - legacyName: 'SPEED THREAT', tag: 'GHOST', sport: 'mlb', color: '#2DD4BF', glyph: 'chevrons', + legacyName: 'SPEED THREAT', tag: 'GHOST', sport: 'mlb', color: '#159AAE', glyph: 'chevrons', description: 'Stolen bases, speed score', propDNA: { reliable: ['stolen_bases', 'runs'], volatile: ['home_runs'] }, education: 'Ghosts turn singles into runs. Stolen-base and runs props are their lane; power props rarely clear.', @@ -203,7 +203,7 @@ const ARCHETYPES = { education: 'Catalysts get on base and score. Hits and runs props are their lane; RBI and power props sit lower in their profile.', }, MIRROR: { - legacyName: 'GAP HITTER', tag: 'MIRROR', sport: 'mlb', color: '#34D399', glyph: 'uparrow', + legacyName: 'GAP HITTER', tag: 'MIRROR', sport: 'mlb', color: '#8FC04A', glyph: 'uparrow', description: 'Gap-to-gap line-drive bat', propDNA: { reliable: ['total_bases', 'hits'], volatile: ['home_runs'] }, education: 'Mirror hitters spray doubles to both gaps. Total bases and hits are reliable; home-run props are the volatile leg of their extra-base profile.', @@ -253,13 +253,13 @@ const ARCHETYPES = { education: 'Towers win aerial duels and hold the ball up. Shot props are reliable; conversion to goals is variable.', }, MOTOR: { - legacyName: 'BOX-TO-BOX', tag: 'MOTOR', sport: 'soccer', color: '#00D4A0', glyph: 'chevrons', + legacyName: 'BOX-TO-BOX', tag: 'MOTOR', sport: 'soccer', color: '#1EA6D8', glyph: 'chevrons', description: 'All-action central midfielder', propDNA: { reliable: ['tackles', 'passes'], volatile: ['goals', 'shots'] }, education: 'Motors cover every blade of grass. Tackles and passing props are reliable; their attacking output swings by role.', }, BLADE: { - legacyName: 'WING WIZARD', tag: 'BLADE', sport: 'soccer', color: '#2DD4BF', glyph: 'slash', + legacyName: 'WING WIZARD', tag: 'BLADE', sport: 'soccer', color: '#159AAE', glyph: 'slash', description: 'Dribbling wide threat', propDNA: { reliable: ['shots', 'assists'], volatile: ['goals'] }, education: 'Blades beat defenders wide. Shots and assists are their lane; goals come in streaks.', diff --git a/tests/unit/authToken.test.js b/tests/unit/authToken.test.js new file mode 100644 index 0000000..906c647 --- /dev/null +++ b/tests/unit/authToken.test.js @@ -0,0 +1,39 @@ +// DS1 follow-up — currentAccessToken reads the REAL Supabase session, so +// email/password users' authenticated fetches carry a token (the OAuth-only +// 'sb-token' key left them silently unauthenticated everywhere). + +const { currentAccessToken } = require('../../web/src/lib/authToken'); + +function fakeWindow(store) { + const keys = Object.keys(store); + global.window = { + localStorage: { + length: keys.length, + key: (i) => keys[i] ?? null, + getItem: (k) => (k in store ? store[k] : null), + }, + }; +} +afterEach(() => { delete global.window; }); + +describe('currentAccessToken', () => { + test('reads the v2 supabase session (access_token at top level)', () => { + fakeWindow({ 'sb-zmdnczhtdxcddsxzttub-auth-token': JSON.stringify({ access_token: 'TOKEN_V2', refresh_token: 'r' }) }); + expect(currentAccessToken()).toBe('TOKEN_V2'); + }); + test('reads the v1 shape (currentSession.access_token)', () => { + fakeWindow({ 'sb-abc-auth-token': JSON.stringify({ currentSession: { access_token: 'TOKEN_V1' } }) }); + expect(currentAccessToken()).toBe('TOKEN_V1'); + }); + test('falls back to the legacy sb-token key', () => { + fakeWindow({ 'sb-token': 'LEGACY' }); + expect(currentAccessToken()).toBe('LEGACY'); + }); + test('no session → null (never throws on malformed JSON)', () => { + fakeWindow({ 'sb-x-auth-token': 'not json{' }); + expect(currentAccessToken()).toBeNull(); + }); + test('SSR (no window) → null', () => { + expect(currentAccessToken()).toBeNull(); + }); +}); diff --git a/tests/unit/colorContract.test.js b/tests/unit/colorContract.test.js new file mode 100644 index 0000000..303ee41 --- /dev/null +++ b/tests/unit/colorContract.test.js @@ -0,0 +1,152 @@ +// DS3 — THE COLOR CONTRACT (DESIGN-SPEC Part 1). +// The contract is enforced as failing tests: green means ONE thing (edge), +// edge/CLV is colored by SIGN, grades by TIER, glow is A/A+ only, and NO +// archetype hue may dilute the signal green. Extends the QA.20-22 discipline. + +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 cc = require('../../web/src/lib/colorContract'); +const tokens = require('../../web/src/lib/vyndrTokens'); +const arch = require('../../web/src/lib/archetypes'); +const svc = require('../../src/services/archetypeService'); + +describe('colorContract.edgeColor — edge/CLV/delta colored by SIGN (#3)', () => { + it('positive edge is signal-green', () => { + expect(cc.edgeColor(6.2)).toBe('var(--g-a)'); + expect(cc.edgeColor('12')).toBe('var(--g-a)'); + }); + it('NEGATIVE edge is muted red — a -33.3% edge NEVER renders green', () => { + expect(cc.edgeColor(-33.3)).toBe('var(--miss)'); + expect(cc.edgeColor(-0.1)).toBe('var(--miss)'); + expect(cc.edgeColor('-5')).toBe('var(--miss)'); + expect(cc.edgeColor(-33.3)).not.toBe('var(--g-a)'); + }); + it('zero / null / NaN is NO edge → neutral (never a fake green)', () => { + expect(cc.edgeColor(0)).toBe('var(--text-2)'); + expect(cc.edgeColor(null)).toBe('var(--text-2)'); + expect(cc.edgeColor(undefined)).toBe('var(--text-2)'); + expect(cc.edgeColor('n/a')).toBe('var(--text-2)'); + }); +}); + +describe('colorContract.gradeTierColor — grades colored by TIER', () => { + it('A/A+ green, B blue, C amber, D/F red', () => { + expect(cc.gradeTierColor('A+')).toBe('var(--g-ap)'); + expect(cc.gradeTierColor('A')).toBe('var(--g-a)'); + expect(cc.gradeTierColor('A-')).toBe('var(--g-a)'); + expect(cc.gradeTierColor('B')).toBe('var(--g-b)'); + expect(cc.gradeTierColor('C')).toBe('var(--g-c)'); + expect(cc.gradeTierColor('D')).toBe('var(--g-d)'); + expect(cc.gradeTierColor('F')).toBe('var(--g-d)'); + }); + it('is case/whitespace tolerant, unknown → neutral', () => { + expect(cc.gradeTierColor(' a+ ')).toBe('var(--g-ap)'); + expect(cc.gradeTierColor('???')).toBe('var(--text-0)'); + }); + it('stays in lockstep with vyndrTokens.gradeColor for shared keys', () => { + for (const g of ['A+', 'A', 'A-', 'B+', 'B', 'B-', 'C', 'D']) { + expect(cc.gradeTierColor(g)).toBe(tokens.gradeColor(g)); + } + }); +}); + +describe('colorContract.gradeGlows — GLOW = A/A+ ONLY (#4)', () => { + it('true for A and A+ only', () => { + expect(cc.gradeGlows('A+')).toBe(true); + expect(cc.gradeGlows('A')).toBe(true); + }); + it('false for every non-A tier — a glowing C devalues the cue', () => { + for (const g of ['A-', 'B+', 'B', 'B-', 'C', 'D', 'F', '', null, undefined]) { + expect(cc.gradeGlows(g)).toBe(false); + } + }); +}); + +describe('colorContract.deltaE / isSignalGreen — perceptual gate', () => { + it('deltaE of a color with itself is 0', () => { + expect(cc.deltaE('#00D4A0', '#00D4A0')).toBeCloseTo(0, 5); + }); + it('flags the signal green itself and near-clones', () => { + expect(cc.isSignalGreen('#00D4A0')).toBe(true); + expect(cc.isSignalGreen('#34D399')).toBe(true); // the old MIRROR green + }); + it('clears clearly-different hues (amber, blue, red)', () => { + expect(cc.isSignalGreen('#FFB347')).toBe(false); + expect(cc.isSignalGreen('#4A9EFF')).toBe(false); + expect(cc.isSignalGreen('#FF5252')).toBe(false); + }); +}); + +describe('ARCHETYPE DEDUP — no archetype hue dilutes the signal green (#15,§5)', () => { + it('every FRONTEND archetype color clears the signal-green ΔE gate', () => { + const offenders = []; + for (const [name, info] of Object.entries(arch.ARCHETYPE_MAP)) { + if (cc.isSignalGreen(info.c)) offenders.push(`${name} ${info.c} (ΔE ${cc.deltaE(info.c, cc.SIGNAL_GREEN).toFixed(1)})`); + } + expect(offenders).toEqual([]); + }); + it('every BACKEND archetype color clears the gate too', () => { + const offenders = []; + for (const [name, a] of Object.entries(svc.ARCHETYPES)) { + if (cc.isSignalGreen(a.color)) offenders.push(`${name} ${a.color}`); + } + expect(offenders).toEqual([]); + }); + it('NO archetype is the literal signal green #00D4A0 (case-insensitive)', () => { + const hits = Object.values(arch.ARCHETYPE_MAP).map((i) => i.c.toUpperCase()).filter((c) => c === '#00D4A0'); + expect(hits).toEqual([]); + }); + it('frontend + backend colors still agree after the shift', () => { + for (const [name, a] of Object.entries(svc.ARCHETYPES)) { + expect(arch.archetypeColor(name)).toBe(a.color); + } + }); +}); + +// ---- SOURCE-GREP VIOLATION LOCKS ------------------------------------------- +// These fail if the ad-hoc green-on-negative / non-A-glow patterns creep back. + +describe('GradeResultCard.tsx — edge by sign, glow by tier', () => { + const src = read('components/vyndr/GradeResultCard.tsx'); + it('routes edge coloring through edgeColor (never a hardcoded green on edge)', () => { + expect(src).toContain('import { edgeColor, gradeGlows }'); + expect(src).toContain('color: edgeColor(d.edge)'); + expect(src).toContain('edgeColor(d.edge) : '); + }); + it('does NOT color an edge figure unconditionally green', () => { + // the old bug: a green span wrapping the edge %, agnostic to sign + expect(src).not.toMatch(/color: 'var\(--g-a\)' \}\}>\{d\.edge/); + expect(src).not.toMatch(/d\.edge != null \? 'var\(--g-a\)'/); + }); + it('gates the grade-hero glow (textShadow) to A/A+ via gradeGlows', () => { + expect(src).toContain('gradeGlows(d.grade) ?'); + // the ungated always-on glow must be gone + expect(src).not.toMatch(/textShadow: `0 0 28px \$\{hex\}aa, 0 0 60px \$\{hex\}55`,/); + }); +}); + +describe('GradeBadge.tsx — glow gated to A-tier', () => { + const src = read('components/vyndr/GradeBadge.tsx'); + it('boxShadow glow only when glow && isA (A/A+)', () => { + expect(src).toContain("const isA = grade === 'A+' || grade === 'A'"); + expect(src).toMatch(/boxShadow: glow && isA \?/); + }); +}); + +describe('LiveHeroProp.tsx — edge tone is sign-driven', () => { + const src = read('components/LiveHeroProp.tsx'); + it('negative edge maps to muted red, positive to green', () => { + expect(src).toMatch(/edge > 0 \? 'positive' : edge < 0 \? 'negative'/); + expect(src).toContain("tone === 'negative' ? 'var(--miss)'"); + }); +}); + +describe('VYNDR INTELLIGENCE panel — de-flooded (#15)', () => { + const src = read('components/vyndr/GradeResultCard.tsx'); + it('the panel border is a neutral token, not a green wash', () => { + expect(src).not.toContain("border: '1px solid rgba(0,212,160,0.24)'"); + }); +}); diff --git a/tests/unit/ds1SpeedTrust.test.js b/tests/unit/ds1SpeedTrust.test.js new file mode 100644 index 0000000..64396a2 --- /dev/null +++ b/tests/unit/ds1SpeedTrust.test.js @@ -0,0 +1,96 @@ +/** + * DS1 (DESIGN-SPEC §4 + §17) — source invariants for the Speed + Trust bugs. + * - React #418 hydration: no client-only value rendered in the SSR path + * without a mounted guard. + * - Loading walls: every named loader is a skeleton, not a text wall. + * - Persistence: the scan write uses the authoritative session token. + */ + +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'); + +describe('DS1 — React #418 hydration safety (§4, audit #5)', () => { + const landing = read('app/page.tsx'); + + test('landing no longer reads localStorage inside a useState initializer', () => { + // The exact antipattern that caused #418: the client-only stored-session + // check ran during render, so server (false) and client (true) diverged. + expect(landing).not.toMatch(/useState\(\s*\(\)\s*=>\s*hasStoredSession\(\)\s*\)/); + expect(landing).not.toMatch(/useState\(\s*\(\)\s*=>\s*hasStoredSession/); + }); + + test('the client-only value is deferred behind a mounted flag', () => { + // Server + first client render both compute maybeSignedIn from mounted=false. + expect(landing).toMatch(/const\s+\[mounted,\s*setMounted\]\s*=\s*useState\(false\)/); + expect(landing).toMatch(/setMounted\(true\)/); + expect(landing).toMatch(/maybeSignedIn\s*=\s*mounted\s*&&\s*hasStoredSession\(\)/); + }); + + test('hasStoredSession still guards against server-side window access', () => { + expect(landing).toMatch(/typeof window === 'undefined'/); + }); +}); + +describe('DS1 — loading walls are skeletons, never text (§4, audit #5)', () => { + const files = { + 'dashboard slate load': 'app/dashboard/page.tsx', + '/desk assembling': 'app/desk/page.tsx', + '/ledger loading': 'app/ledger/page.tsx', + 'scan loading': 'app/scan/page.tsx', + 'landing redirect': 'app/page.tsx', + }; + const BANNED = [ + 'Loading the slate', + 'Assembling the pack', + 'Loading the model', + /]*>\s*Loading…\s*<\/p>/, // the bare ledger text loader + ]; + + for (const [name, rel] of Object.entries(files)) { + test(`${name} contains no text-wall loader`, () => { + const src = read(rel); + for (const b of BANNED) { + if (typeof b === 'string') expect(src).not.toContain(b); + else expect(src).not.toMatch(b); + } + }); + } + + test('each loader path renders the shared Skeleton', () => { + for (const rel of ['app/dashboard/page.tsx', 'app/desk/page.tsx', 'app/ledger/page.tsx', 'app/scan/page.tsx', 'app/page.tsx']) { + const src = read(rel); + expect(src).toMatch(/Skeleton/); + expect(src).toMatch(/from '@\/components\/vyndr'/); + } + }); + + test('the Skeleton primitive is tokenized (no raw hex) and reduced-motion-safe', () => { + const skel = read('components/vyndr/Skeleton.tsx'); + // Uses the CSS class, no inline colors. + expect(skel).toMatch(/vyndr-skeleton/); + expect(skel).not.toMatch(/#[0-9a-fA-F]{3,8}/); + const css = fs.readFileSync(path.join(WEB, 'app', 'globals.css'), 'utf8'); + expect(css).toMatch(/\.vyndr-skeleton\s*\{/); + expect(css).toMatch(/var\(--bg-surface\)/); + // The global prefers-reduced-motion rule freezes every animation. + expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/); + }); +}); + +describe('DS1 — scan→ledger persistence uses the authoritative token (§17)', () => { + const scan = read('app/scan/page.tsx'); + + test('scan pulls the bearer token from the live Supabase session', () => { + expect(scan).toMatch(/session,/); // destructured from useAuth + expect(scan).toMatch(/session\?\.access_token/); + }); + + test('scan no longer relies solely on the OAuth-only legacy localStorage key', () => { + // The stale key may remain only as a FALLBACK after the session token. + const m = scan.match(/session\?\.access_token\s*\|\|[\s\S]{0,120}sb-token/); + expect(m).toBeTruthy(); + }); +}); diff --git a/tests/unit/ds2Dashboard.test.js b/tests/unit/ds2Dashboard.test.js new file mode 100644 index 0000000..2b43e94 --- /dev/null +++ b/tests/unit/ds2Dashboard.test.js @@ -0,0 +1,194 @@ +// DS2 (Design v2) — Dashboard Slate Rebuild. The founder's named #1 rebuild: +// real entities per card, ONE bold hero, collapse pending-filler (#14), and the +// NEVER-EMPTY hero (#1, Part 6) — first paint ALWAYS proves the model. The pure +// engine lives in web/src/lib/slateAdapter.js (unit-tested directly); the .tsx +// surfaces are asserted against source (plain-JS Jest) — same pattern as +// ds4Billboards / vyndrParityQA. + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const WEB = path.join(ROOT, 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const adapter = require('../../web/src/lib/slateAdapter'); + +// ── 1. selectTopGrades — ranking VARIES on a real signal (#13) ───────────── +describe('selectTopGrades — tier first, then the varying signal (confidence/edge)', () => { + test('orders by grade tier, then confidence desc, then edge desc', () => { + const grades = [ + { player: 'C-low', grade: 'B', confidence: 40, edge: 1 }, + { player: 'A-hiconf', grade: 'A', confidence: 90, edge: 2 }, + { player: 'A-loconf', grade: 'A', confidence: 55, edge: 9 }, + { player: 'Aplus', grade: 'A+', confidence: 10, edge: 0 }, + ]; + const out = adapter.selectTopGrades(grades, 10).map((g) => g.player); + expect(out).toEqual(['Aplus', 'A-hiconf', 'A-loconf', 'C-low']); + }); + + test('ties on grade break on confidence — rows are NOT identical-order noise', () => { + const grades = [ + { player: 'X', grade: 'B', confidence: 30 }, + { player: 'Y', grade: 'B', confidence: 80 }, + { player: 'Z', grade: 'B', confidence: 55 }, + ]; + expect(adapter.selectTopGrades(grades, 10).map((g) => g.player)).toEqual(['Y', 'Z', 'X']); + }); + + test('honors the limit and drops gradeless rows; empty in → empty out', () => { + const grades = [{ grade: 'A', confidence: 1 }, { grade: 'A', confidence: 2 }, { confidence: 9 }]; + expect(adapter.selectTopGrades(grades, 1)).toHaveLength(1); + expect(adapter.selectTopGrades([], 5)).toEqual([]); + expect(adapter.selectTopGrades(null, 5)).toEqual([]); + }); +}); + +// ── 2. buildHeroReceipts — yesterday's A-tier settled HITS (#1) ──────────── +describe('buildHeroReceipts — proven A-tier receipts, misses excluded', () => { + const rows = [ + { player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A+', outcome: 'hit', actual_value: 3, clv_result: 'beat', sport: 'mlb' }, + { player_name: 'Missed Guy', stat: 'hits', line: 1.5, side: 'over', grade: 'A', outcome: 'miss', actual_value: 0 }, + { player_name: 'B Guy', stat: 'runs', line: 0.5, side: 'over', grade: 'B', outcome: 'hit', actual_value: 1 }, + { player_name: 'Aminus Hit', stat: 'rbi', line: 0.5, side: 'over', grade: 'A-', outcome: 'hit', actual_value: 2 }, + ]; + + test('keeps only A-family grades that HIT (a miss never becomes proof)', () => { + const out = adapter.buildHeroReceipts(rows, 8); + const names = out.map((r) => r.player); + expect(names).toContain('Aaron Judge'); + expect(names).toContain('Aminus Hit'); + expect(names).not.toContain('Missed Guy'); // A-tier but a MISS + expect(names).not.toContain('B Guy'); // a hit but not A-tier + }); + + test('carries the real result (actual + grade) so the receipt is verifiable', () => { + const judge = adapter.buildHeroReceipts(rows, 8).find((r) => r.player === 'Aaron Judge'); + expect(judge).toMatchObject({ grade: 'A+', outcome: 'hit', actual: 3 }); + }); + + test('best-grade first, honors the limit, empty/absent → []', () => { + expect(adapter.buildHeroReceipts(rows, 8)[0].grade).toBe('A+'); + expect(adapter.buildHeroReceipts(rows, 1)).toHaveLength(1); + expect(adapter.buildHeroReceipts([], 5)).toEqual([]); + expect(adapter.buildHeroReceipts(null, 5)).toEqual([]); + }); +}); + +// ── 3. heroFallbackState — the NEVER-EMPTY hero engine (#1, Part 6) ──────── +describe('heroFallbackState — tonight wins, else receipts, never empty when proof exists', () => { + const settled = [{ player_name: 'Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A', outcome: 'hit', actual_value: 3 }]; + + test('tonight has graded props → mode tonight', () => { + const s = adapter.heroFallbackState([{ player: 'P', grade: 'A', confidence: 70 }], settled, 10); + expect(s.mode).toBe('tonight'); + expect(s.items).toHaveLength(1); + }); + + test('tonight EMPTY but yesterday has A-tier hits → mode receipts (proof, not empty)', () => { + const s = adapter.heroFallbackState([], settled, 10); + expect(s.mode).toBe('receipts'); + expect(s.items[0].player).toBe('Judge'); + }); + + test('only truly empty when BOTH are absent', () => { + expect(adapter.heroFallbackState([], [], 10).mode).toBe('empty'); + expect(adapter.heroFallbackState(null, null, 10).mode).toBe('empty'); + }); +}); + +// ── 4. pendingSummary — collapse the dead "Grades post" repetition (#14) ── +describe('pendingSummary — six pending rows collapse to ONE line', () => { + test('all-awaiting card → one summary counting props + players', () => { + const strips = [ + { player: 'One', props: [{ stat: 'TB', line: 1.5, awaiting: true }, { stat: 'HR', line: 0.5, awaiting: true }] }, + { player: 'Two', props: [{ stat: 'Hits', line: 0.5, awaiting: true }] }, + ]; + const s = adapter.pendingSummary(strips); + expect(s).not.toBeNull(); + expect(s.count).toBe(3); + expect(s.players).toBe(2); + }); + + test('any graded prop present → no collapse (the card has real reads to show)', () => { + const strips = [ + { player: 'One', props: [{ stat: 'TB', line: 1.5, grade: 'A' }] }, + { player: 'Two', props: [{ stat: 'Hits', line: 0.5, awaiting: true }] }, + ]; + expect(adapter.pendingSummary(strips)).toBeNull(); + }); + + test('no awaiting props at all → null', () => { + expect(adapter.pendingSummary([{ player: 'One', props: [] }])).toBeNull(); + expect(adapter.pendingSummary([])).toBeNull(); + expect(adapter.pendingSummary(null)).toBeNull(); + }); +}); + +// ── 5. topReadForCard — the ONE bold hero per card (#2) ──────────────────── +describe('topReadForCard — the single best graded prop leads the card', () => { + const strips = [ + { player: 'Mid', team: 'NYY', props: [{ stat: 'Hits', line: 0.5, side: 'O', grade: 'B' }] }, + { player: 'Best', team: 'BOS', archetype: { primary: 'BOMBER' }, props: [{ stat: 'TB', line: 1.5, side: 'O', grade: 'A+' }] }, + { player: 'Dead', team: 'LAD', props: [{ stat: 'HR', line: 0.5, side: 'O', grade: 'A+', dead: true }] }, + ]; + + test('picks the highest-tier live grade (dead reads never lead)', () => { + const top = adapter.topReadForCard(strips); + expect(top).not.toBeNull(); + expect(top.player).toBe('Best'); + expect(top.grade).toBe('A+'); + expect(top.stat).toBe('TB'); + }); + + test('no graded props → null (nothing to promote)', () => { + expect(adapter.topReadForCard([{ player: 'X', props: [{ stat: 'TB', line: 1, awaiting: true }] }])).toBeNull(); + expect(adapter.topReadForCard([])).toBeNull(); + }); +}); + +// ── 6. GameCard.tsx — one hero + pending collapse + real entities ───────── +describe('GameCard.tsx wires the DS2 rebuild', () => { + const src = read('components/vyndr/GameCard.tsx'); + + test('leads with the real team entity (logo) + a team-colored accent edge', () => { + expect(src).toContain(' { + expect(src).toContain('topReadForCard'); + expect(src).toMatch(/fontVariantNumeric:\s*'tabular-nums'/); + // the hero grade badge is the largest on the card (strips use size 'sm'). + expect(src).toMatch(/size=\{48\}|size="lg"/); + }); + + test('collapses all-awaiting cards to ONE pending line (kills the #14 repetition)', () => { + expect(src).toContain('pendingSummary'); + expect(src).toContain('nextRunLabelET'); + expect(src).toContain('props pending'); + }); +}); + +// ── 7. dashboard/page.tsx — the never-empty hero + varying rankings ──────── +describe('dashboard Top grades tonight — proof-first, ranked, honest', () => { + const src = read('app/dashboard/page.tsx'); + + test('uses the never-empty engine (heroFallbackState / buildHeroReceipts)', () => { + expect(src).toMatch(/heroFallbackState|buildHeroReceipts/); + }); + + test('ranks tonight grades by the varying signal (selectTopGrades) and shows confidence', () => { + expect(src).toContain('selectTopGrades'); + expect(src).toContain('confidence'); + }); + + test('falls back to yesterday PROVEN A-tier receipts (with the settled result)', () => { + expect(src).toContain('/api/ledger/model'); + expect(src).toMatch(/HIT|PROVEN|YESTERDAY/); + }); + + test('still keeps the honest schedule-derived waiting copy for the truly-empty case (QA.22)', () => { + expect(src).toContain('nextRunLabelET'); + }); +}); diff --git a/tests/unit/ds4Billboards.test.js b/tests/unit/ds4Billboards.test.js new file mode 100644 index 0000000..6cc20d3 --- /dev/null +++ b/tests/unit/ds4Billboards.test.js @@ -0,0 +1,182 @@ +// DS4 (Design v2) — the BILLBOARDS. Screenshot-first surfaces (the timeline is +// customer #1): the STREAKS row, the grade reveal, the CLV reframe, and the /u +// public profile. The .tsx surfaces are asserted against source (plain-JS Jest, +// no TS transform) — same pattern as publicProfilePage / vyndrParityQA. The +// CLV flat-vs-spread decision is a pure fn and is exercised directly. + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const WEB = path.join(ROOT, 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +// ── 1. CLV reframe — the pure flat/spread decision (#16) ────────────────── +describe('clvDisplay — flat CLV says so, spread shows bars', () => { + const { clvMode, isFlatClv, CLV_FLAT_LINE } = require('../../web/src/lib/clvDisplay'); + + test('near-flat (73/74 at the close) is FLAT, not a broken histogram', () => { + const dist = [ + { label: '[-.5,0)', side: 'faded', count: 0 }, + { label: '0', side: 'flat', count: 73 }, + { label: '(0,.5]', side: 'beat', count: 1 }, + ]; + expect(clvMode(dist).mode).toBe('flat'); + expect(isFlatClv(dist)).toBe(true); + }); + + test('real dispersion is SPREAD (the bars are meaningful)', () => { + const dist = [ + { label: '[-1,-.5)', side: 'faded', count: 8 }, + { label: '[-.5,0)', side: 'faded', count: 6 }, + { label: '0', side: 'flat', count: 5 }, + { label: '(0,.5]', side: 'beat', count: 9 }, + { label: '(.5,1]', side: 'beat', count: 12 }, + ]; + expect(clvMode(dist).mode).toBe('spread'); + expect(isFlatClv(dist)).toBe(false); + }); + + test('null / empty / all-zero → none (renders nothing, never errored)', () => { + expect(clvMode(null).mode).toBe('none'); + expect(clvMode([]).mode).toBe('none'); + expect(clvMode([{ side: 'flat', count: 0 }, { side: 'beat', count: 0 }]).mode).toBe('none'); + }); + + test('the flat line is confident + VOICE-compliant (no jargon, no hype)', () => { + expect(CLV_FLAT_LINE).toMatch(/grade the outcome, not the close/); + expect(CLV_FLAT_LINE).not.toMatch(/!/); + expect(CLV_FLAT_LINE.toLowerCase()).not.toMatch(/bayesian|regression|guarantee/); + }); + + test('the ledger MODEL tab renders the flat line, not a histogram, when flat', () => { + const src = read('app/ledger/page.tsx'); + expect(src).toContain("import { clvMode, CLV_FLAT_LINE }"); + expect(src).toMatch(/mode === 'flat'/); + expect(src).toContain('CLV_FLAT_LINE'); + // bars still exist for the spread branch + expect(src).toContain('CLV DISTRIBUTION'); + }); +}); + +// ── 2. STREAKS row — the Bloomberg alert (#2) ───────────────────────────── +describe('StreaksPanel — one hero figure, real identity, one accent', () => { + const src = read('components/StreaksPanel.tsx'); + + test('uses the real PlayerAvatar entity (not a bare )', () => { + expect(src).toContain('import PlayerAvatar'); + expect(src).toContain(' { + // heroNum renders currentStreak and is the biggest fontSize; the player + // name is demoted well below it. + expect(src).toMatch(/heroNum[\s\S]*fontSize:\s*38/); + expect(src).toContain('{s.currentStreak}'); + const heroSize = Number((src.match(/heroNum:[^}]*fontSize:\s*(\d+)/) || [])[1]); + const nameSize = Number((src.match(/playerName:[^}]*fontSize:\s*(\d+)/) || [])[1]); + expect(heroSize).toBeGreaterThan(nameSize); + expect(heroSize).toBeGreaterThanOrEqual(32); + }); + + test('the hero number is mono + tabular (data grammar)', () => { + expect(src).toMatch(/heroNum:[\s\S]*fontVariantNumeric:\s*'tabular-nums'/); + }); + + test('carries the muted "built vs" lens context', () => { + expect(src).toContain('built vs'); + expect(src).toContain('builtVs'); + }); + + test('ONE severity accent — step-up amber / step-down green', () => { + expect(src).toContain("diff === 'step up'"); + expect(src).toContain('var(--amber'); + expect(src).toContain("diff === 'step down'"); + expect(src).toContain('var(--g-a'); + }); + + test('the grade badge is tier-gated (the READ is the paid layer)', () => { + expect(src).toMatch(/tier === 'analyst' \|\| tier === 'desk'/); + expect(src).toContain(' { + const src = read('components/vyndr/GradeResultCard.tsx'); + + test('a negative edge NEVER renders green — negative = var(--miss)', () => { + // DS3 superseded DS4's local const with the centralized colorContract + // enforcer — same semantics (negative → var(--miss)), now via the shared + // helper. Both the confidence strip and the projection row route edge + // through edgeColor(d.edge), never a hardcoded green. + expect(src).toMatch(/import \{ edgeColor.*\} from '@\/lib\/colorContract'/); + expect(src).toContain('color: edgeColor(d.edge)'); + expect(src).toMatch(/d\.edge != null \? edgeColor\(d\.edge\) : 'var\(--text-2\)'/); + }); + + test('the grade LETTER is still the hero (largest element)', () => { + expect(src).toMatch(/grade-hero[\s\S]*fontSize: compact \? 92 : 116/); + }); + + test('the MODEL/LINE/EDGE row is mono + tabular (demoted data)', () => { + expect(src).toMatch(/fontSize: 19[\s\S]*fontVariantNumeric: 'tabular-nums'/); + }); + + test('reveal choreography is reduced-motion safe', () => { + const proc = read('components/vyndr/ProcessingGrade.tsx'); + expect(proc).toContain("prefers-reduced-motion: reduce"); + expect(proc).toMatch(/setPhase\('card'\)/); + }); +}); + +// ── 4. /u public profile — the shareable record billboard (#4) ──────────── +describe('PublicProfile — the record hero + CLV-verified badge', () => { + const src = read('app/u/[handle]/PublicProfile.tsx'); + + test('the hit rate is the bold hero figure (large mono tabular)', () => { + expect(src).toMatch(/fontSize: 56[\s\S]*fontVariantNumeric: 'tabular-nums'/); + expect(src).toContain('{agg.hit_pct}%'); + expect(src).toContain('HIT RATE'); + }); + + test('beat-close is the secondary bold figure', () => { + expect(src).toContain('{agg.beat_close_pct}%'); + expect(src).toContain('BEAT CLOSE'); + }); + + test('CLV-VERIFIED badge is HONEST — only when closing value is tracked', () => { + expect(src).toContain('✓ CLV-VERIFIED'); + expect(src).toMatch(/clvVerified = Boolean\(ready && agg && agg\.beat_close_pct != null\)/); + }); + + test('still honors the n-gate: RECORD BUILDING under min_sample', () => { + expect(src).toContain('RECORD BUILDING'); + expect(src).toMatch(/settled >= minSample/); + }); + + test('renders real player identity (PlayerAvatar entity)', () => { + expect(src).toContain('import PlayerAvatar'); + expect(src).toContain(' { + const src = read('app/u/[handle]/opengraph-image.tsx'); + + test('social crop size is preserved (1200x630)', () => { + expect(src).toMatch(/size = \{ width: 1200, height: 630 \}/); + }); + + test('renders the real hit% / beat-close% when the record is published', () => { + expect(src).toContain('{rec.hit_pct}%'); + expect(src).toContain('{rec.beat_close_pct}%'); + expect(src).toContain('CLV-VERIFIED RECORD'); + }); + + test('falls back gracefully to the tagline when there is no record', () => { + expect(src).toContain('every settled read · misses included'); + }); +}); diff --git a/tests/unit/ds5PricingStates.test.js b/tests/unit/ds5PricingStates.test.js new file mode 100644 index 0000000..51ace72 --- /dev/null +++ b/tests/unit/ds5PricingStates.test.js @@ -0,0 +1,194 @@ +// DS5 (Design v2) — Pricing (Desk-as-hero) + Motion (ticker → punctuated +// stillness) + Empty/Error unification (the 404 bar) + archetype propagation. +// Source-grep locks (plain-JS Jest, no TS transform) — same pattern as +// vyndrParityQA / ds4Billboards. Every assertion targets a DS5 artifact that +// did NOT exist on the base commit, so the suite fails before and passes after. + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const WEB = path.join(ROOT, 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const readCss = () => fs.readFileSync(path.join(WEB, 'app', 'globals.css'), 'utf8'); + +// Extract a single TIERS object block by its id. +function tierBlock(src, id) { + const m = src.match(new RegExp(`id: '${id}'[\\s\\S]*?highlight: (true|false)`)); + return m ? m[1] : null; +} + +// ── 1. Desk $44.99 is the HERO tier (#8, Part 6) ────────────────────────── +describe('Pricing — Desk is the hero, real prices, single primary CTA', () => { + const pricing = read('components/Pricing.tsx'); + const showcase = read('app/pricing/DeskShowcase.tsx'); + const page = read('app/pricing/page.tsx'); + + test('Desk is highlighted (the hero); Analyst is NOT (no two competing green CTAs, #9)', () => { + expect(tierBlock(pricing, 'desk')).toBe('true'); + expect(tierBlock(pricing, 'analyst')).toBe('false'); + }); + + test('Desk CTA is at least as strong as Analyst — Desk drives the primary button', () => { + // highlight:true → btn-primary (the strong CTA); highlight:false → btn-ghost. + expect(pricing).toContain("className={tier.highlight ? 'btn-primary' : 'btn-ghost'}"); + // Desk is the highlighted tier, so it owns the primary; Analyst is secondary. + expect(tierBlock(pricing, 'desk')).toBe('true'); + }); + + test('real prices only — Desk $34.99 founder / $44.99 regular, Analyst $14.99/$19.99, Free 5 scans', () => { + expect(pricing).toMatch(/id: 'desk'[\s\S]*?price: '\$34\.99'/); + expect(pricing).toMatch(/id: 'desk'[\s\S]*?originalPrice: '\$44\.99'/); + expect(pricing).toMatch(/id: 'analyst'[\s\S]*?price: '\$14\.99'/); + expect(pricing).toMatch(/id: 'analyst'[\s\S]*?originalPrice: '\$19\.99'/); + expect(pricing).toContain('5 scans to try the model'); + // no fabricated legacy price + expect(pricing).not.toContain("originalPrice: '$24.99'"); + expect(pricing).not.toContain("originalPrice: '$49.99'"); + }); + + test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => { + expect(page).toContain('import DeskShowcase'); + expect(page).toContain(' { + expect(require('../../web/src/lib/checkout').checkoutUrl('desk')).toBe('/api/checkout?tier=desk'); + expect(pricing).toContain("fetch('/api/checkout'"); + }); +}); + +// ── 2. Ticker → punctuated stillness (#7, Part 4) ───────────────────────── +describe('Ticker + header motion — mostly still, meaningful pulses', () => { + const ticker = read('components/vyndr/Ticker.tsx'); + const css = readCss(); + + test('motion is tokenized — no one-off durations (--ticker-hold + --motion-*)', () => { + expect(css).toMatch(/--ticker-hold:\s*\d+ms/); + expect(css).toContain('--motion-transition:'); + expect(css).toContain('--motion-data:'); + expect(css).toContain('--motion-idle:'); + }); + + test('the ticker RESTS ≥4s on each item (hold token + JS constant both ≥4000ms, in sync)', () => { + const holdMs = Number((css.match(/--ticker-hold:\s*(\d+)ms/) || [])[1]); + const jsMs = Number((ticker.match(/TICKER_HOLD_MS\s*=\s*(\d+)/) || [])[1]); + expect(holdMs).toBeGreaterThanOrEqual(4000); + expect(jsMs).toBeGreaterThanOrEqual(4000); + expect(jsMs).toBe(holdMs); + }); + + test('the continuous marquee is retired — the ticker no longer scrolls forever', () => { + // The resting strip is static; the old infinite .ticker-track marquee is gone + // from the component (constant motion reads cheap — Part 0 law #1). + expect(ticker).not.toContain('ticker-track'); + expect(ticker).toContain('ticker-rest'); + }); + + test('ONE animated element in the header zone — the ticker no longer renders a competing live-dot', () => { + // The single idle proof-of-life is the heartbeat live-dot; the ticker drops + // its own pulsing dot so the header is not ticker + heartbeat + counter. + expect(ticker).not.toMatch(/className="[^"]*\blive-dot\b/); + const live = read('components/vyndr/LiveLayer.tsx'); + expect((live.match(/live-dot/g) || []).length).toBe(1); + }); + + test('the EKG heartbeat is a STATIC readout (no idle scroll animation)', () => { + // .ekg-track keeps its class (vyndrSystems locks the string) but no longer + // carries an infinite scroll — proof-of-life is the one live-dot. + expect(/\.ekg-track\s*\{[^}]*animation/.test(css)).toBe(false); + expect(read('components/vyndr/LiveLayer.tsx')).toContain('ekg-track'); + }); + + test('prefers-reduced-motion kills the motion entirely', () => { + expect(ticker).toContain('prefers-reduced-motion'); + // new ticker motion classes are in the global reduced-motion kill list + expect(css).toMatch(/prefers-reduced-motion[\s\S]*?\.ticker-item-enter/); + expect(css).toMatch(/prefers-reduced-motion[\s\S]*?\.ticker-pulse/); + }); + + test('the polling contract is preserved (tickerLive lock stays green)', () => { + expect(ticker).toContain("fetch('/api/ticker'"); + expect(ticker).toContain('pollMs = 30_000'); + expect(ticker).toContain('feed && feed.length > 0 ? feed : items'); + }); +}); + +// ── 3. Unify empty/error to the 404 bar (#20, Part 8) ───────────────────── +describe('EmptyState — one designed empty/error system, modeled on the 404', () => { + const es = read('components/vyndr/EmptyState.tsx'); + + test('carries the 404 north-star grammar (scanlines + glitch wordmark + amber system voice)', () => { + expect(es).toContain('scanlines'); + expect(es).toContain('crt-sweep'); + expect(es).toContain('amber-glow'); + expect(es).toContain('Wordmark'); + }); + + test('CTA hierarchy — at most one primary (color contract #9)', () => { + expect(es).toContain('primary'); + expect(es).toContain("a.primary ? 'btn-primary' : 'btn-ghost'"); + }); + + test('reused at the "Team not found" offender — no bare-red line', () => { + const hub = read('app/team/[abbr]/TeamHub.tsx'); + expect(hub).toContain(' { + const game = read('app/game/[id]/page.tsx'); + expect(game).toContain(' { + const ledger = read('app/ledger/page.tsx'); + expect(ledger).toContain(''); + }); + + test('EmptyState is exported from the vyndr barrel (one component, everywhere)', () => { + expect(read('components/vyndr/index.ts')).toContain("export { default as EmptyState }"); + }); +}); + +// ── 4. Propagate archetype glyphs everywhere (Part 5) ───────────────────── +describe('Archetype glyph+chip — the ONE component, propagated', () => { + test('the grade reveal renders the archetype (glyph+chip via ArchetypeBlend)', () => { + const card = read('components/vyndr/GradeResultCard.tsx'); + expect(card).toContain('ArchetypeBlend'); + expect(card).toContain(' { + const panel = read('components/StreaksPanel.tsx'); + expect(panel).toContain("import ArchetypeBadge"); + expect(panel).toContain(' { + const ledger = read('app/ledger/page.tsx'); + expect(ledger).toContain('ArchetypeBadge'); + expect(ledger).toContain(' { + expect(read('components/StreaksPanel.tsx')).toContain('showDesc'); + expect(read('app/ledger/page.tsx')).toContain('showDesc'); + }); +}); diff --git a/tests/unit/entityLayer.test.js b/tests/unit/entityLayer.test.js new file mode 100644 index 0000000..3e22dc4 --- /dev/null +++ b/tests/unit/entityLayer.test.js @@ -0,0 +1,74 @@ +// DS0 (Design v2) — THE ENTITY LAYER. Teams resolve to real logos + real +// colors; the accent rule keeps team color VISIBLE on the near-black terminal. + +const { resolveTeam, teamLogoUrl, accentColor, luminance } = require('../../web/src/lib/teamMeta'); + +describe('resolveTeam — abbr / full name / nickname / alias', () => { + test('canonical statsapi abbr', () => { + expect(resolveTeam('NYY', 'mlb').name).toBe('New York Yankees'); + expect(resolveTeam('MIL', 'mlb').name).toBe('Milwaukee Brewers'); + }); + test('full name and nickname', () => { + expect(resolveTeam('Milwaukee Brewers', 'mlb').abbr).toBe('MIL'); + expect(resolveTeam('Pirates', 'mlb').abbr).toBe('PIT'); + expect(resolveTeam('Red Sox', 'mlb').abbr).toBe('BOS'); + expect(resolveTeam('White Sox', 'mlb').abbr).toBe('CWS'); + }); + test('statsapi↔ESPN abbr aliases (AZ↔ARI, CWS↔CHW)', () => { + expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ'); + expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS'); + }); + test('unknown team → null (never a fake)', () => { + expect(resolveTeam('ZZZ', 'mlb')).toBeNull(); + expect(resolveTeam('', 'mlb')).toBeNull(); + }); + test('resolves across leagues', () => { + expect(resolveTeam('LAL', 'nba').name).toBe('Los Angeles Lakers'); + expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty'); + }); + test('soccer = World Cup national teams, by name + alias', () => { + expect(resolveTeam('USA', 'soccer').name).toBe('United States'); + expect(resolveTeam('United States', 'soccer').abbr).toBe('USA'); + expect(resolveTeam('Brazil', 'soccer').abbr).toBe('BRA'); + expect(resolveTeam('South Korea', 'soccer').abbr).toBe('KOR'); + expect(resolveTeam('Ivory Coast', 'soccer').abbr).toBe('CIV'); // alias + }); +}); + +describe('teamLogoUrl — real ESPN CDN paths', () => { + test('MLB logo url uses the ESPN logo abbr, not the app abbr', () => { + expect(teamLogoUrl('AZ', 'mlb')).toBe('https://a.espncdn.com/i/teamlogos/mlb/500/ari.png'); + expect(teamLogoUrl('CWS', 'mlb')).toBe('https://a.espncdn.com/i/teamlogos/mlb/500/chw.png'); + expect(teamLogoUrl('NYY', 'mlb')).toBe('https://a.espncdn.com/i/teamlogos/mlb/500/nyy.png'); + }); + test('per-league path', () => { + expect(teamLogoUrl('LAL', 'nba')).toContain('/teamlogos/nba/500/lal.png'); + expect(teamLogoUrl('NY', 'wnba')).toContain('/teamlogos/wnba/500/ny.png'); + }); + test('soccer national teams use the flag CDN (countries/ + numeric edge)', () => { + expect(teamLogoUrl('USA', 'soccer')).toBe('https://a.espncdn.com/i/teamlogos/countries/500/usa.png'); + expect(teamLogoUrl('South Korea', 'soccer')).toContain('/countries/500/kors.png'); + expect(teamLogoUrl('Curaçao', 'soccer')).toBe('https://a.espncdn.com/i/teamlogos/soccer/500/11678.png'); + }); + test('unknown → null', () => { + expect(teamLogoUrl('ZZZ', 'mlb')).toBeNull(); + expect(teamLogoUrl('Narnia', 'soccer')).toBeNull(); + }); +}); + +describe('accentColor — VISIBLE on #06060B (the near-black bug guard)', () => { + test('picks the more luminous of primary/secondary', () => { + // White Sox: primary silver (#c4ced4) vs black — must pick silver. + expect(accentColor('CWS', 'mlb').toLowerCase()).toBe('#c4ced4'); + // Pirates: black primary + gold secondary — must pick gold. + expect(accentColor('PIT', 'mlb').toLowerCase()).toBe('#fdb827'); + }); + test('both-dark team never returns an invisible accent', () => { + const acc = accentColor('SF', 'mlb'); // black + orange → orange, visible + expect(luminance(acc)).toBeGreaterThan(40); + }); + test('luminance ranks black low, white high', () => { + expect(luminance('#000000')).toBeLessThan(10); + expect(luminance('#ffffff')).toBeGreaterThan(240); + }); +}); diff --git a/tests/unit/scanLedgerPersistence.test.js b/tests/unit/scanLedgerPersistence.test.js new file mode 100644 index 0000000..d29df48 --- /dev/null +++ b/tests/unit/scanLedgerPersistence.test.js @@ -0,0 +1,153 @@ +/** + * DS1 (DESIGN-SPEC §17) — scan→ledger persistence. + * + * Proves a completed scan's ledger row (built by the SAME builder the + * /api/scan route uses) is readable by the /api/ledger/mine query: scoped by + * user_id, keyed by player_key = nameKey, deduped idempotently. This is the + * "every grade, no hiding" guarantee the audit found broken. + */ + +const { buildManualLedgerRow, LEDGER_CONFLICT_COLS, gameDateET } = require('../../web/src/lib/ledgerRow'); +const { nameKey, normalizeName } = require('../../web/src/lib/playerName'); + +// A minimal in-memory stand-in for the service-role Supabase client, modelling +// exactly the two operations that matter: the route's upsert(row, {onConflict, +// ignoreDuplicates}) and the /api/ledger/mine read +// (.select().eq('user_id',id).order('graded_at',desc).limit(n)). +function makeFakeSupabase() { + const rows = []; + const conflictCols = LEDGER_CONFLICT_COLS.split(','); + const keyOf = (r) => conflictCols.map((c) => String(r[c])).join('|'); + + return { + _rows: rows, + from() { + return { + async upsert(row, opts = {}) { + const k = keyOf(row); + const existing = rows.find((r) => keyOf(r) === k); + if (existing) { + if (!opts.ignoreDuplicates) Object.assign(existing, row); + return { data: null, error: null }; + } + rows.push({ ...row, id: `row-${rows.length + 1}` }); + return { data: null, error: null }; + }, + // Chainable read that mirrors the mine route. + select() { + const filters = {}; + let orderCol = null; let asc = true; let lim = Infinity; + const q = { + eq(col, val) { filters[col] = val; return q; }, + order(col, o = {}) { orderCol = col; asc = o.ascending !== false; return q; }, + limit(n) { lim = n; return q; }, + then(resolve) { + let out = rows.filter((r) => Object.entries(filters).every(([c, v]) => r[c] === v)); + if (orderCol) { + out = [...out].sort((a, b) => (a[orderCol] < b[orderCol] ? -1 : a[orderCol] > b[orderCol] ? 1 : 0)); + if (!asc) out.reverse(); + } + out = out.slice(0, lim); + resolve({ data: out, error: null }); + }, + }; + return q; + }, + }; + }, + }; +} + +// The mine query, byte-for-byte in spirit with routes/ledger.js GET /mine. +async function mineQuery(sb, userId, limit = 100) { + const { data } = await sb.from('ledger_entries') + .select('*') + .eq('user_id', userId) + .order('graded_at', { ascending: false }) + .limit(limit); + return data; +} + +describe('scan→ledger persistence (DS1 §17)', () => { + const USER_A = 'user-aaaa'; + const USER_B = 'user-bbbb'; + + test('a completed scan write is immediately readable by the mine query', async () => { + const sb = makeFakeSupabase(); + const row = buildManualLedgerRow({ + userId: USER_A, + sport: 'MLB', + player: 'Aaron Judge', + stat: 'home_runs', + line: 1.5, + side: 'over', + book: 'draftkings', + grade: 'A', + confidence: 71, + projection: 1.9, + edge: 12.4, + }); + + await sb.from('ledger_entries').upsert(row, { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); + + const mine = await mineQuery(sb, USER_A); + expect(mine).toHaveLength(1); + const got = mine[0]; + expect(got.user_id).toBe(USER_A); + // The join key the ledger/model reads on is player_key = nameKey. + expect(got.player_key).toBe(nameKey('Aaron Judge')); + expect(got.player_name).toBe(normalizeName('Aaron Judge').display); + expect(got.sport).toBe('mlb'); + expect(got.stat).toBe('home_runs'); + expect(got.side).toBe('over'); + expect(got.line).toBe(1.5); + expect(got.grade).toBe('A'); + // game_id embeds the ET game_date so the settle pass can find it. + expect(got.game_id).toBe(`manual:mlb:${gameDateET()}:${nameKey('Aaron Judge')}`); + expect(got.game_date).toBe(gameDateET()); + }); + + test('the mine query is scoped: another user cannot read the row', async () => { + const sb = makeFakeSupabase(); + const row = buildManualLedgerRow({ + userId: USER_A, sport: 'MLB', player: 'Shohei Ohtani', stat: 'total_bases', + line: 1.5, side: 'over', book: 'fanduel', grade: 'B', + }); + await sb.from('ledger_entries').upsert(row, { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); + + expect(await mineQuery(sb, USER_A)).toHaveLength(1); + expect(await mineQuery(sb, USER_B)).toHaveLength(0); + }); + + test('re-scanning the same prop is idempotent (never duplicates the row)', async () => { + const sb = makeFakeSupabase(); + const mk = () => buildManualLedgerRow({ + userId: USER_A, sport: 'MLB', player: 'Mookie Betts', stat: 'hits', + line: 0.5, side: 'over', book: 'draftkings', grade: 'A', + }); + await sb.from('ledger_entries').upsert(mk(), { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); + await sb.from('ledger_entries').upsert(mk(), { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); + + expect(await mineQuery(sb, USER_A)).toHaveLength(1); + }); + + test('DATA SEMANTICS: absent beats wrong — no snapshot ⇒ locked_odds/team null, model fields carried', () => { + const row = buildManualLedgerRow({ + userId: USER_A, sport: 'WNBA', player: "A'ja Wilson", stat: 'points', + line: 22.5, side: 'over', grade: 'B', confidence: 60, projection: 24, + // no team / lockedOdds supplied (no snapshot match) + }); + expect(row.locked_odds).toBeNull(); + expect(row.team).toBeNull(); + expect(row.opponent).toBeNull(); + expect(row.model_value).toBe(24); + expect(row.confidence).toBe(60); + // Non-numeric model fields degrade to null, never 0 (Number(null)===0 bug). + expect(buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'X', stat: 'hits', line: 1, side: 'over' }).confidence).toBeNull(); + expect(buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'X', stat: 'hits', line: 1, side: 'over' }).model_value).toBeNull(); + }); + + test('the dedupe key matches the migration-019 constraint the route upserts on', () => { + expect(LEDGER_CONFLICT_COLS).toBe('user_id,player_key,stat,line,side,game_id'); + }); +}); diff --git a/tests/unit/teamHubUI.test.js b/tests/unit/teamHubUI.test.js index 2a4c169..ae333bb 100644 --- a/tests/unit/teamHubUI.test.js +++ b/tests/unit/teamHubUI.test.js @@ -29,13 +29,16 @@ describe('Team Hub page', () => { expect(src).toContain('opacity: noProps ? 0.6 : 1'); }); it('has back-to-slate navigation', () => { - expect(src).toContain('← Back to Slate'); - expect(src).toContain('href="/dashboard"'); + // DS5 (#20) — the error surface is the unified EmptyState; its action links + // back to the Slate. + expect(src).toContain('← Back to the Slate'); + expect(src).toContain("href: '/dashboard'"); }); - it('handles loading + error states', () => { + it('handles loading + error states via the unified EmptyState (no bare-red line)', () => { expect(src).toContain("'loading'"); expect(src).toContain("'error'"); - expect(src).toContain('Team not found'); + expect(src).toContain(' { expect(src).toContain('useParlay'); diff --git a/tests/unit/vyndrDesignSystem.test.js b/tests/unit/vyndrDesignSystem.test.js index ed1c47d..7a6f24c 100644 --- a/tests/unit/vyndrDesignSystem.test.js +++ b/tests/unit/vyndrDesignSystem.test.js @@ -182,9 +182,12 @@ describe('Phase B — shared component contracts (§5)', () => { expect(src).toContain(' { + it('Ticker RESTS on each item (DS5 punctuated stillness) — no infinite marquee', () => { + // DS5 (Part 4, #7) retired the continuous .ticker-track marquee: the ticker + // now holds each ranked item ≥4s and pulses only on change. See + // ds5PricingStates.test.js for the full motion lock. const src = comp('Ticker.tsx'); - expect(src).toContain('ticker-track'); - expect(src).toMatch(/\{content\}\s*\{content\}/); + expect(src).not.toContain('ticker-track'); + expect(src).toContain('ticker-rest'); }); }); diff --git a/tests/unit/vyndrParityQA.test.js b/tests/unit/vyndrParityQA.test.js index 33a85a6..dad1db0 100644 --- a/tests/unit/vyndrParityQA.test.js +++ b/tests/unit/vyndrParityQA.test.js @@ -112,8 +112,12 @@ describe('QA.20 — movement + status color semantics (Phase 2.5 surfaces)', () expect(read('app/ledger/page.tsx')).toContain("textDecoration: 'line-through'"); }); it('lens difficulty: tougher = amber, softer = green — never inverted', () => { + // DS4 rebuilt the STREAKS billboard: difficulty coloring moved into a `sev` + // object, but the semantics are locked here — step-up must be amber, step- + // down must be green, and the two must not be swapped. const panel = read('components/StreaksPanel.tsx'); - expect(panel).toMatch(/step up' \? 'var\(--amber[^']*' : [^:]*step down' \? 'var\(--g-a/); + expect(panel).toMatch(/step up'\s*\?\s*\{ color: 'var\(--amber/); + expect(panel).toMatch(/step down'\s*\?\s*\{ color: 'var\(--g-a/); }); }); diff --git a/web/src/app/api/scan/route.ts b/web/src/app/api/scan/route.ts index c506f47..19757a0 100644 --- a/web/src/app/api/scan/route.ts +++ b/web/src/app/api/scan/route.ts @@ -193,12 +193,10 @@ async function writeLedgerEntry( data: { grade?: string; projection?: number; confidence?: number; edge_pct?: number }, ) { try { - const { nameKey, normalizeName } = await import('@/lib/playerName'); + const { nameKey } = await import('@/lib/playerName'); + const { buildManualLedgerRow, LEDGER_CONFLICT_COLS } = await import('@/lib/ledgerRow'); const sport = body.sport.toLowerCase(); const playerKey = nameKey(body.player); - const gameDate = new Intl.DateTimeFormat('en-CA', { - timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', - }).format(new Date()); // Cache-only snapshot read (never triggers an odds fetch → no quota). let lockedOdds: string | null = null; @@ -220,31 +218,28 @@ async function writeLedgerEntry( if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds); } catch { /* absent beats wrong */ } - await sb.from('ledger_entries').upsert( - { - user_id: userId, - player_key: playerKey, - player_name: normalizeName(body.player).display || body.player, - sport, - stat: body.stat.toLowerCase(), - line: body.line, - side: body.direction, - locked_odds: lockedOdds, - book: body.book ?? 'draftkings', - // Session 59 — team from the snapshot's stats resolve (real feed); - // opponent stays null on manual scans (no game context — never guessed). - team, - opponent: null, - grade: data.grade, - edge: typeof data.edge_pct === 'number' ? data.edge_pct : null, - confidence: typeof data.confidence === 'number' ? data.confidence : null, - model_value: typeof data.projection === 'number' ? data.projection : null, - graded_at: new Date().toISOString(), - game_id: `manual:${sport}:${gameDate}:${playerKey}`, - game_date: gameDate, - }, - { onConflict: 'user_id,player_key,stat,line,side,game_id', ignoreDuplicates: true }, - ); + // Session 59 — team from the snapshot's stats resolve (real feed); opponent + // stays null on manual scans (no game context — never guessed). + const row = buildManualLedgerRow({ + userId, + sport: body.sport, + player: body.player, + stat: body.stat, + line: body.line, + side: body.direction, + book: body.book ?? 'draftkings', + team, + lockedOdds, + grade: data.grade, + edge: data.edge_pct, + confidence: data.confidence, + projection: data.projection, + }); + + await sb.from('ledger_entries').upsert(row, { + onConflict: LEDGER_CONFLICT_COLS, + ignoreDuplicates: true, + }); } catch (err) { console.warn('[scan] ledger write failed', err); } diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index df05037..9e2f603 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -10,11 +10,17 @@ import { GradePill } from '@/components/GradeCard'; // below as intelligence layers on top of the raw odds. import Slate from '@/components/Slate'; // Session 55 — the self-learning loop's track record, live in the header. -import { AccuracyBadge } from '@/components/vyndr'; +import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; // Session 57 (Phase 0) — honest per-sport empty-slate copy. import { emptyStateCopy } from '@/lib/emptyState'; // Session 59 (work-order 2.3) — the real pipeline schedule for waiting states. import { nextRunLabelET } from '@/lib/pipelineSchedule'; +// DS2 (Design v2) — the never-empty hero engine: rank tonight's grades on a +// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first +// paint ALWAYS proves the model (#1, #13, Part 6). +import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter'; +import GradeBadge from '@/components/vyndr/GradeBadge'; +import { currentAccessToken } from '@/lib/authToken'; type Sport = 'NBA' | 'MLB' | 'WNBA'; @@ -60,6 +66,32 @@ interface ParlayLegStat { parlay_count: number; } +// DS2 — a PROVEN receipt (yesterday's settled A-tier hit) for the never-empty hero. +interface HeroReceipt { + player: string; + stat: string; + line: number; + side: string; + grade: string; + sport: string; + outcome: string; + actual: number | null; + clvResult: string | null; +} + +// The /api/ledger/model settled-row shape (subset we read for receipts). +interface ModelEntry { + player_name?: string; + sport?: string; + stat?: string; + line?: number; + side?: string; + grade?: string; + outcome?: string | null; + actual_value?: number | null; + clv_result?: string | null; +} + interface RecentScan { id: string; player_name: string; @@ -85,6 +117,9 @@ export default function DashboardPage() { const [sport, setSport] = useState('NBA'); const [games, setGames] = useState(null); const [topGrades, setTopGrades] = useState(null); + // DS2 — yesterday's PROVEN receipts, fetched only when tonight has no grades + // yet (the never-empty hero fallback). null = not yet loaded. + const [heroReceipts, setHeroReceipts] = useState(null); const [mostParlayed, setMostParlayed] = useState(null); const [recentScans, setRecentScans] = useState(null); // Session 49 — the user's primary sport tab + preferred books from prefs. @@ -126,6 +161,7 @@ export default function DashboardPage() { let cancelled = false; setGames(null); setTopGrades(null); + setHeroReceipts(null); Promise.all([ fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })), @@ -162,6 +198,24 @@ export default function DashboardPage() { }; }, [sport]); + // DS2 (#1, Part 6) — the NEVER-EMPTY hero fallback. When tonight's slate has + // no graded props yet, pull the model's most-recent settled reads and keep + // only the PROVEN A-tier hits (buildHeroReceipts). First paint always proves + // the model works (the real 55-19 / 74% record). Best-effort, self-hiding. + useEffect(() => { + if (topGrades === null || topGrades.length > 0) return; // tonight has grades → no fallback needed + let cancelled = false; + fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (cancelled) return; + const entries: ModelEntry[] = Array.isArray(data?.entries) ? data.entries : []; + setHeroReceipts(buildHeroReceipts(entries, 8) as HeroReceipt[]); + }) + .catch(() => { if (!cancelled) setHeroReceipts([]); }); + return () => { cancelled = true; }; + }, [topGrades, sport]); + // Most parlayed + recent scans don't depend on sport useEffect(() => { fetch('/api/props/most-parlayed') @@ -170,7 +224,7 @@ export default function DashboardPage() { .catch(() => setMostParlayed([])); if (user) { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); fetch('/api/user/recent-scans', { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) @@ -181,9 +235,15 @@ export default function DashboardPage() { }, [user]); if (authLoading || !user) { + // DS1 (§4) — layout-matched skeleton, never a text wall. Mirrors the real + // dashboard: header strip + a stack of slate cards. return ( -
-

Loading the slate…

+
+
+ + +
+
); } @@ -246,57 +306,116 @@ export default function DashboardPage() { }} /> - {/* Top grades horizontal scroll */} -
- {topGrades === null ? ( - - ) : topGrades.length === 0 ? ( - // Session 59 (work-order 2.3) — the real schedule, not passive waiting. -

No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.

- ) : ( -
{ + const ranked = topGrades ? selectTopGrades(topGrades, 10) as TopGrade[] : []; + const proofMode = topGrades !== null && ranked.length === 0 && (heroReceipts?.length ?? 0) > 0; + return ( +
- {topGrades.map((g, i) => ( - - ))} -
- )} -
+ {ranked.map((g, i) => ( + + ))} + + ) : proofMode ? ( + // The never-empty proof: yesterday's PROVEN A-tier receipts. +
+ {(heroReceipts || []).map((r, i) => ( +
+
+ YESTERDAY · PROVEN + +
+

{r.player}

+

+ {String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')} +

+

+ ✓ HIT{r.actual != null ? ` (${r.actual})` : ''} + {r.clvResult === 'beat' ? · CLV BEAT : null} +

+
+ ))} +
+ ) : heroReceipts === null ? ( + // tonight empty, fallback still loading → skeleton, never a text wall. + + ) : ( + // Truly nothing yet — the honest schedule-derived waiting copy (QA.22). +

No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.

+ )} +
+ ); + })()} {/* Tonight's games */}
diff --git a/web/src/app/desk/page.tsx b/web/src/app/desk/page.tsx index ec397b3..0f45c28 100644 --- a/web/src/app/desk/page.tsx +++ b/web/src/app/desk/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useAuth } from '@/contexts/AuthContext'; +import { Skeleton, SkeletonList } from '@/components/vyndr'; /** * /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal. @@ -103,10 +104,21 @@ export default function DeskPage() {
); } + if (status === 'loading') { + // DS1 (§4) — the pack is assembling; layout-matched skeleton, not a text + // wall. Mirrors the header + stack of FormatCards below. + return ( +
+ + + +
+ ); + } if (status !== 'ready' || !pack) { return (
-

{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}

+

Pack unavailable. Check back after the first pipeline run.

); } diff --git a/web/src/app/game/[id]/page.tsx b/web/src/app/game/[id]/page.tsx index 9f7cbd9..28e66e9 100644 --- a/web/src/app/game/[id]/page.tsx +++ b/web/src/app/game/[id]/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { useParlay } from '@/contexts/ParlayContext'; import GradeCard, { GradePill } from '@/components/GradeCard'; +import EmptyState from '@/components/vyndr/EmptyState'; type Sport = 'NBA' | 'MLB' | 'WNBA'; @@ -100,14 +101,14 @@ export default function GamePage({ params }: { params: Promise<{ id: string }> } } if (error) { + // DS5 (#20) — unified empty/error surface (was a bare heading + link). return ( -
-

Game not found.

-

- That matchup isn't on tonight's slate. Maybe the line moved off the board. -

- Back to slate -
+ ); } diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 4cd96c4..aa810e9 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -94,6 +94,16 @@ --scan-op: 0.04; --grade-hero: var(--g-a); + /* Motion tokens (DS5 · DESIGN-SPEC Part 4 — ALIVE IS PUNCTUATED STILLNESS). + Every duration in the living layer resolves from one of these — no one-off + ms literals. IDLE = the single proof-of-life pulse; TRANSITION is sub-200ms + (perceived instantaneity); DATA is the change-only green pulse; TICKER-HOLD + is how long the ticker RESTS on each item so it can be read (≥4s). */ + --motion-idle: 1.4s; + --motion-transition: 180ms; + --motion-data: 300ms; + --ticker-hold: 4200ms; + /* Type — Inter for chrome/UI, JetBrains Mono for ALL data. Session 41: --font-* come from next/font (self-hosted, set on ). Literal family names kept as fallbacks for any non-next/font context. */ @@ -401,6 +411,22 @@ h1, h2, h3, h4, h5, h6 { animation: shimmer 1.5s linear infinite; } +/* DS1 (§4 — kill loading walls) — the ONE skeleton primitive. Layout-matched + dark shimmer blocks replace every text-wall loader. Tokenized (no one-off + CSS); the global prefers-reduced-motion rule freezes the sweep to a static, + still-visible surface — arrives-then-rests, honored globally. */ +.vyndr-skeleton { + background: linear-gradient( + 90deg, + var(--bg-surface) 0%, + var(--bg-surface-hover) 50%, + var(--bg-surface) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s linear infinite; + border-radius: 8px; +} + /* S6 (A1 board, LCP) — floored at a VISIBLE state (.6), matching the S33 entrance-keyframe rule (a paused frame is never invisible). Starting at opacity 0 also delayed the hero heading's first contentful paint — an @@ -1062,7 +1088,11 @@ body.tex-grain::before { } .grade-reveal { animation: grade-reveal 0.42s cubic-bezier(.2,1.3,.4,1) both; } -/* ===================== TICKER ===================== */ +/* ===================== TICKER (DS5 — punctuated stillness) ===================== + The ticker RESTS. It no longer marquee-scrolls (constant motion reads cheap, + Part 0 law #1). It holds each item for --ticker-hold (≥4s) so it can be READ, + and the ONLY motion is a change-only cross-fade + a brief data-update pulse + when the item advances. The legacy .ticker-track marquee is retired. */ @keyframes ticker-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } @@ -1072,6 +1102,23 @@ body.tex-grain::before { white-space: nowrap; animation: ticker-scroll 38s linear infinite; } +/* The resting strip — a static ranked row, no idle animation. */ +.ticker-rest { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} +/* Change-only cross-fade as the held item advances (sub-200ms, weighted). */ +@keyframes ticker-swap { from { opacity: 0.55; } to { opacity: 1; } } +.ticker-item-enter { animation: ticker-swap var(--motion-transition) ease-out both; } +/* Change-only green pulse — the terminal reacting to a fresh head event. */ +@keyframes ticker-pulse { + 0% { background: color-mix(in srgb, var(--g-a) 22%, transparent); } + 100% { background: transparent; } +} +.ticker-pulse { animation: ticker-pulse var(--motion-data) ease-out 1; } /* ===================== LIVE PULSE ===================== */ .live-dot { @@ -1088,6 +1135,14 @@ body.tex-grain::before { 100% { box-shadow: 0 0 0 0 rgba(255,59,59,0); } } +/* ===================== DESK SHOWCASE (DS5 pricing hero) ===================== */ +/* Balanced two-column at desktop (pitch | real feature visuals), single column + on mobile — kills the dead right-half of the old pricing page (#8). */ +.desk-showcase { grid-template-columns: 1fr; } +@media (min-width: 900px) { + .desk-showcase { grid-template-columns: 1.05fr 1fr; } +} + /* ===================== LINE FLASH (odds cell changes) ===================== */ @keyframes flash-up { 0% { background: rgba(0,212,160,0.45); } 100% { background: transparent; } } @keyframes flash-down { 0% { background: rgba(255,82,82,0.45); } 100% { background: transparent; } } @@ -1108,7 +1163,10 @@ body.tex-grain::before { /* ===================== LIVING LAYER (the brain) ===================== */ /* EKG heartbeat strip */ @keyframes ekg-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } -.ekg-track { display: inline-flex; animation: ekg-scroll 6s linear infinite; } +/* DS5 — the EKG is now a STATIC readout trace (no idle scroll). The header's + ONE idle proof-of-life is the single SIGNAL-LIVE live-dot; the heartbeat + waveform is a frozen instrument face, not constant motion (Part 4). */ +.ekg-track { display: inline-flex; } /* Neural node pulse */ @keyframes node-pulse { @@ -1173,6 +1231,7 @@ body.tex-grain::before { /* Reduced-motion: kill the living layer + glitch chrome outright */ @media (prefers-reduced-motion: reduce) { .wm, .wm::before, .wm::after, .ticker-track, .live-dot, + .ticker-item-enter, .ticker-pulse, .intel-surface::after, .phosphor-cursor, .ekg-track, .brain-node, .brain-link, .proc-scan, .data-blink { animation: none !important; } } diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 1d4ded0..579bc8d 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -3,6 +3,8 @@ import { useCallback, useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; +import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr'; +import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; /** * The Ledger (Session 58, Phase 1) — the truth surface, now backed by the @@ -42,6 +44,9 @@ interface LedgerRow { outcome?: 'hit' | 'miss' | 'push' | null; actual_value?: number | null; revised_from_grade?: string | null; + // DS5 (Part 5) — the player's locked archetype, when the pipeline supplies it. + // Optional + self-hiding: absent beats fabricated. + archetype?: string | null; } interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null } @@ -153,7 +158,12 @@ export default function LedgerPage() { {rows === null ? ( -

Loading…

+ // DS1 (§4) — grid of card skeletons matching the ledger's real layout. +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
) : rows.length === 0 ? ( ) : ( @@ -191,15 +201,36 @@ function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: n ); } -/** S6 (A1 board) — compact settled-CLV distribution strip. Bars are DATA - * (mono, tabular): green = beat-the-close side, red = faded side, dim = flat. - * Renders only when the server passed the n≥20 gate (clv_distribution set). */ +/** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives + * in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat + * distribution renders as one giant bar + six empty stubs — it reads as + * BROKEN. When CLV is flat we SAY so in one confident VOICE line (the record + * is the best data on the site; it must look the most premium, never errored) + * and show the bars only when there is real spread. */ function ClvDistribution({ agg }: { agg: ModelAggregate }) { const dist = agg.clv_distribution; - if (!Array.isArray(dist) || dist.length === 0) return null; - const total = dist.reduce((n, b) => n + b.count, 0); + const { mode } = clvMode(dist as Parameters[0]); + if (mode === 'none') return null; + + if (mode === 'flat') { + // Flat CLV is a FEATURE, not an error: the model grades outcomes, not the + // close. State it plainly — premium, confident, no broken histogram. + return ( +
+
+ CLOSING-LINE VALUE +
+

+ {CLV_FLAT_LINE} +

+
+ ); + } + + const distArr = dist as ClvBucket[]; + const total = distArr.reduce((n, b) => n + b.count, 0); if (total === 0) return null; - const max = Math.max(...dist.map((b) => b.count)); + const max = Math.max(...distArr.map((b) => b.count)); const color = (side: ClvBucket['side']) => side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)'; return ( @@ -208,7 +239,7 @@ function ClvDistribution({ agg }: { agg: ModelAggregate }) { CLV DISTRIBUTION · {total} SETTLED
- {dist.map((b) => ( + {distArr.map((b) => (
0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}> {b.count} @@ -324,6 +355,12 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {

{row.player_name}

+ {/* Part 5 — the archetype glyph+chip, propagated (self-hides when absent). */} + {row.archetype && ( +
+ +
+ )}

{row.side} {row.line} {row.stat.replace(/_/g, ' ')}

@@ -341,28 +378,24 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) { } function EmptyLedger({ tab }: { tab: Tab }) { - return ( -
-

LEDGER EMPTY

- {tab === 'mine' ? ( - <> -

No reads yet.

-

- Read your first prop to start building your Ledger. Every grade you run lands here the moment it completes — and settles against the real result. -

- - Read a Prop → - - - ) : ( - <> -

The model record starts with the next pipeline run.

-

- Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted. -

- - )} -
+ // DS5 (#20) — the ONE unified empty surface, inline variant. + return tab === 'mine' ? ( + + ) : ( + ); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index c7fdb97..cbe7130 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import Hero from '@/components/Hero'; -import { ClaimMeter, ModelRecord } from '@/components/vyndr'; +import { ClaimMeter, ModelRecord, Skeleton, SkeletonList } from '@/components/vyndr'; // Session 55 — live top A-rated grades pulled from tonight's real snapshot, // with the self-learning loop's accuracy line. The product shown, not described. import TopSignals from '@/components/TopSignals'; @@ -47,7 +47,18 @@ function hasStoredSession(): boolean { export default function Home() { const { user, loading } = useAuth(); const router = useRouter(); - const [maybeSignedIn] = useState(() => hasStoredSession()); + // DS1 (DESIGN-SPEC §4, audit #5) — React #418 hydration fix. Reading + // localStorage inside a useState INITIALIZER runs during render: the server + // (no `window`) computes `false` and emits the marketing tree, but the first + // CLIENT render of a signed-in visitor reads the stored session, computes + // `true`, and emits the "Loading" placeholder instead — a server/client HTML + // mismatch that made React discard and re-render the whole page (#418). The + // client-only value is now deferred behind a mounted flag: the first client + // render matches the server (both treat it as `false`), then the stored- + // session check flips it post-mount. SSR HTML is never discarded. + const [mounted, setMounted] = useState(false); + useEffect(() => { setMounted(true); }, []); + const maybeSignedIn = mounted && hasStoredSession(); useEffect(() => { if (!loading && user) router.replace('/dashboard'); @@ -56,11 +67,12 @@ export default function Home() { // Suppress the marketing render ONLY for visitors with a stored session // (they're about to redirect) — anonymous first paint is instant. if (user || (loading && maybeSignedIn)) { + // Signed-in visitor mid-redirect to /dashboard — a layout-matched skeleton, + // never a text wall (§4). return ( -
-

- Loading the slate -

+
+ +
); } diff --git a/web/src/app/pricing/DeskShowcase.tsx b/web/src/app/pricing/DeskShowcase.tsx new file mode 100644 index 0000000..8f8cb0b --- /dev/null +++ b/web/src/app/pricing/DeskShowcase.tsx @@ -0,0 +1,96 @@ +import SectionHead from '@/components/vyndr/SectionHead'; + +/** + * DeskShowcase (DS5 · DESIGN-SPEC Part 6, #8) — the Desk-as-hero story that + * sits ABOVE the pricing grid. Its job is the founder's one line: make $44.99 + * feel impossibly low for a professional terminal ("how is this only $44.99" + * IS the conversion event). Balanced two-column layout — the pitch on the left, + * REAL feature visuals on the right (kills the dead right-half, audit #8). The + * single primary CTA scrolls to the grid where the real Stripe checkout lives + * (checkout wiring untouched). + * + * Server component — no interactivity here; the CTA is an in-page anchor and the + * feature visuals are static, tokenized, mono-for-data mock readouts. + */ + +// A real alt-line-ladder rung (the Desk exclusive) — line + locked grade + edge. +function Rung({ line, grade, edge, base }: { line: string; grade: string; edge: string; base?: boolean }) { + const gradeCol = grade.startsWith('A') ? 'var(--g-a)' : grade.startsWith('B') ? 'var(--text-0)' : 'var(--amber)'; + const edgeCol = edge.startsWith('+') ? 'var(--g-a)' : edge.startsWith('-') ? 'var(--miss)' : 'var(--text-2)'; + return ( +
+
{line}{base ? ' •' : ''}
+
{grade}
+
{edge}
+
+ ); +} + +export default function DeskShowcase() { + return ( +
+
+ {/* LEFT — the pitch */} +
+
+ THE DESK · FLAGSHIP +
+

+ A $1M terminal.{' '} + $44.99 + /mo. +

+

+ Every alt line graded, quarter-Kelly sizing on your bankroll, parlay + correlation, and the model’s real-time feed. The desk a trading + floor pays five figures for — priced for one bettor. +

+ +
+ + Claim a Founder Desk → + + + $34.99 locked for the first 100 · then $44.99 + +
+
+ + {/* RIGHT — real feature visuals (no dead half) */} +
+
+ + ALT LINE LADDER DESK + +
+ + + + +
+
+ +
+
+ QUARTER-KELLY +
2.4%
+
of bankroll · at -110
+
+
+ PARLAY φ +
0.34
+
correlation · same-team legs
+
+
+ +
+ + + REAL-TIME FEED · consensus vs model, live line moves + +
+
+
+
+ ); +} diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index 8857976..2ee04f5 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import Pricing from '@/components/Pricing'; import { ClaimMeter } from '@/components/vyndr'; +import DeskShowcase from './DeskShowcase'; export const metadata: Metadata = { title: 'Pricing — VYNDR', @@ -35,6 +36,9 @@ export const metadata: Metadata = { export default function PricingPage() { return (
+ {/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the + grid, so the premium tier stops being an afterthought. */} + {/* Founder-seat scarcity under the grid (§12) */}
diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index ec15efa..2ec8da8 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; +import { currentAccessToken } from '@/lib/authToken'; interface FullProfile { id: string; @@ -29,7 +30,7 @@ export default function ProfilePage() { useEffect(() => { if (!user) return; - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); fetch('/api/user/profile', { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) @@ -42,7 +43,7 @@ export default function ProfilePage() { if (!confirm('Cancel your subscription at the end of the current period?')) return; setWorking(true); setError(''); - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); const res = await fetch('/api/user/profile', { method: 'PUT', headers: { diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index c4799c6..56e4244 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import ProcessingGrade from '@/components/vyndr/ProcessingGrade'; import PriorReads from '@/components/vyndr/PriorReads'; -import { AccuracyBadge } from '@/components/vyndr'; +import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; import type { GradeResultData } from '@/components/vyndr/GradeResultCard'; import { mapScanToGradeResult } from '@/lib/gradeAdapter'; import { normalizeName, nameKey } from '@/lib/playerName'; @@ -110,7 +110,7 @@ const SPORT_ACCENT: Record = { export default function ScanPage() { const router = useRouter(); - const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth(); + const { user, session, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth(); const { addLeg, legCount, open } = useParlay(); const [sport, setSport] = useState('NBA'); @@ -241,7 +241,16 @@ export default function ScanPage() { setError(''); setResult(null); try { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + // DS1 (§17 — scan→ledger persistence). The authoritative bearer token is + // the live Supabase session's access_token (set for EVERY sign-in method). + // The legacy `localStorage['sb-token']` key is written ONLY by the OAuth + // callback — so email/password users sent NO Authorization header, the + // /api/scan route saw an anonymous request, and the completed read was + // silently dropped from the ledger (the write is gated on an authed user). + // Prefer the session token; keep the legacy key as a fallback. + const token = + session?.access_token || + (typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null); const res = await fetch('/api/scan', { method: 'POST', headers: { @@ -301,9 +310,13 @@ export default function ScanPage() { }; if (authLoading || !user) { + // DS1 (§4) — layout-matched skeleton of the scan form, not a text wall. return ( -
-

Loading the model…

+
+ + + +
); } diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index ec17d39..d192982 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; +import { currentAccessToken } from '@/lib/authToken'; /** * /settings (Session 42 — Player Intelligence design). @@ -173,7 +174,7 @@ export default function SettingsPage() { setDeleting(true); setDelError(''); try { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); const res = await fetch('/api/user/profile', { method: 'DELETE', headers: token ? { Authorization: `Bearer ${token}` } : {}, diff --git a/web/src/app/slip/page.tsx b/web/src/app/slip/page.tsx index 62ef22f..846a49a 100644 --- a/web/src/app/slip/page.tsx +++ b/web/src/app/slip/page.tsx @@ -17,6 +17,7 @@ import SectionHead from '@/components/vyndr/SectionHead'; import VBtn from '@/components/vyndr/VBtn'; import GradeBadge from '@/components/vyndr/GradeBadge'; import { useParlay } from '@/contexts/ParlayContext'; +import { currentAccessToken } from '@/lib/authToken'; type Sport = 'MLB' | 'NBA' | 'WNBA'; @@ -109,7 +110,7 @@ export default function SlipPage() { setGraded(false); setAdded(false); try { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); const res = await fetch('/api/slips/parse', { method: 'POST', headers: { @@ -185,7 +186,7 @@ export default function SlipPage() { if (!legs || grading) return; setGrading(true); setGrades(legs.map(() => ({ status: 'pending' }))); - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); const results: LegGrade[] = []; for (const leg of legs) { if (!legReady(leg)) { diff --git a/web/src/app/team/[abbr]/TeamHub.tsx b/web/src/app/team/[abbr]/TeamHub.tsx index aad8585..16e7a1f 100644 --- a/web/src/app/team/[abbr]/TeamHub.tsx +++ b/web/src/app/team/[abbr]/TeamHub.tsx @@ -5,7 +5,9 @@ import { useRouter } from 'next/navigation'; import SportBadge from '@/components/vyndr/SportBadge'; import GradeBadge from '@/components/vyndr/GradeBadge'; import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; +import TeamLogo from '@/components/vyndr/TeamLogo'; import ModelRecord from '@/components/vyndr/ModelRecord'; +import EmptyState from '@/components/vyndr/EmptyState'; import { playerHref } from '@/lib/playerHref'; import { useParlay, legKey } from '@/contexts/ParlayContext'; @@ -68,11 +70,14 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string } return

Loading team intelligence…

; } if (state === 'error' || !data) { + // DS5 (#20) — the unified empty/error surface, not a bare-red line. return ( -
-

Team not found.

- ← Back to Slate -
+ ); } @@ -105,8 +110,9 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
← Back to Slate - {/* Header */} + {/* Header — DS0: the team's real crest leads the hub. */}
+

{data.team.name}

{data.team.abbr} diff --git a/web/src/app/tracker/page.tsx b/web/src/app/tracker/page.tsx index cb8ab86..0bca2f9 100644 --- a/web/src/app/tracker/page.tsx +++ b/web/src/app/tracker/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; +import { currentAccessToken } from '@/lib/authToken'; type Period = 'weekly' | 'monthly' | 'all_time'; @@ -28,7 +29,7 @@ interface Bet { const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; function getAuthHeaders(): Record { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }; diff --git a/web/src/app/u/[handle]/PublicProfile.tsx b/web/src/app/u/[handle]/PublicProfile.tsx index a0c38b5..03811dd 100644 --- a/web/src/app/u/[handle]/PublicProfile.tsx +++ b/web/src/app/u/[handle]/PublicProfile.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; /** * PublicProfile (A1 Session 10) — the public ledger record for one handle. @@ -144,30 +145,60 @@ export default function PublicProfile({ handle }: { handle: string }) { ); } +/** + * The record HERO (DS4 · billboard — DESIGN-SPEC Part 3 + Part 6 #4). This is + * the shareable surface Kev pitches partners with. One bold figure among muted + * context: the HIT RATE as a large mono tabular number, beat-close as the + * secondary bold figure, the raw record demoted. CLV-VERIFIED badge only when + * closing-line value is actually tracked (honest — never a decorative badge). + */ function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) { const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null); + const clvVerified = Boolean(ready && agg && agg.beat_close_pct != null); return (
{ready && agg ? ( -
- - RECORD · LAST 30D - - - {agg.hits}-{agg.misses} · {agg.hit_pct}% HIT - - {agg.beat_close_pct != null && ( - - {agg.beat_close_pct}% BEAT CLOSE + <> +
+ + RECORD · LAST 30D - )} - - {agg.pending} pending - -
+ {clvVerified && ( + + ✓ CLV-VERIFIED + + )} +
+ {/* HERO figures — hit rate is the largest thing on the page. */} +
+
+
+ {agg.hit_pct}% +
+
+ HIT RATE · {agg.hits}-{agg.misses} +
+
+ {agg.beat_close_pct != null && ( +
+
+ {agg.beat_close_pct}% +
+
+ BEAT CLOSE +
+
+ )} +
+
+ {agg.pending} pending +
+
+
+ ) : (

@@ -230,10 +261,16 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {

-

{row.player_name}

-

- {row.side} {row.line} {row.stat.replace(/_/g, ' ')} -

+ {/* Real entity identity (DS0) — headshot / team-colored monogram. */} +
+ +
+

{row.player_name}

+

+ {row.side} {row.line} {row.stat.replace(/_/g, ' ')} +

+
+

{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {row.model_value != null && · MODEL {row.model_value}} diff --git a/web/src/app/u/[handle]/opengraph-image.tsx b/web/src/app/u/[handle]/opengraph-image.tsx index 1473020..8b63f46 100644 --- a/web/src/app/u/[handle]/opengraph-image.tsx +++ b/web/src/app/u/[handle]/opengraph-image.tsx @@ -1,15 +1,36 @@ import { ImageResponse } from 'next/og'; -// A1 Session 10 — every shared /u/:handle link unfurls as a record card. -// Self-hosted standalone build → Node runtime (NOT edge; Session-53 rule — -// next/og breaks under edge off Vercel). +// A1 Session 10 · elevated DS4 — every shared /u/:handle link unfurls as a +// record card at social crop (1200×630). The billboard now carries the REAL +// record (hit% + beat-close%) when the handle is published + past the n-gate, +// so the timeline (customer #1) sees the proof, not just a tagline. Self-hosted +// standalone build → Node runtime (NOT edge; Session-53 rule). export const alt = 'VYNDR Public Ledger'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number } | null> { + try { + const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!r.ok) return null; + const d = await r.json(); + const agg = d && d.aggregate; + if (!agg || agg.hit_pct == null) return null; + return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses }; + } catch { + return null; + } +} + export default async function Image({ params }: { params: Promise<{ handle: string }> }) { const { handle } = await params; const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle'; + const rec = await fetchRecord(h); return new ImageResponse( (

- PUBLIC LEDGER + {rec ? 'CLV-VERIFIED RECORD · 30D' : 'PUBLIC LEDGER'}
-
+
@{h}
-
- CLV-verified record · every settled read · misses included -
+ {rec ? ( +
+
+
{rec.hit_pct}%
+
HIT RATE · {rec.hits}-{rec.misses}
+
+ {rec.beat_close_pct != null && ( +
+
{rec.beat_close_pct}%
+
BEAT CLOSE
+
+ )} +
+ ) : ( +
+ CLV-verified record · every settled read · misses included +
+ )}
diff --git a/web/src/components/LiveHeroProp.tsx b/web/src/components/LiveHeroProp.tsx index 3a6fb73..44a305f 100644 --- a/web/src/components/LiveHeroProp.tsx +++ b/web/src/components/LiveHeroProp.tsx @@ -66,7 +66,7 @@ const row: React.CSSProperties = { borderBottom: '1px solid var(--border)', }; -function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' }) { +function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' | 'negative' }) { return (
{value} @@ -258,7 +259,7 @@ export default function LiveHeroProp() { {/* Projection + edge — visible, the proof. */}
- 0 ? 'positive' : undefined} /> + 0 ? 'positive' : edge < 0 ? 'negative' : undefined} />
{/* Reasoning — BLURRED, the paywall. */} diff --git a/web/src/components/Pricing.tsx b/web/src/components/Pricing.tsx index 1b807f2..6287e00 100644 --- a/web/src/components/Pricing.tsx +++ b/web/src/components/Pricing.tsx @@ -30,7 +30,7 @@ const TIERS: TierConfig[] = [ headline: 'Try the model. No card required.', cta: 'Start Free', features: [ - '3 reads per day', + '5 scans to try the model', 'Grade letter + projection', 'Cross-book line comparison', 'Confidence indicator', @@ -69,7 +69,7 @@ const TIERS: TierConfig[] = [ id: 'analyst', name: 'Analyst', price: '$14.99', - originalPrice: '$24.99', + originalPrice: '$19.99', cadence: '/mo', badge: 'Founder Access', headline: 'The full intelligence layer.', @@ -86,16 +86,20 @@ const TIERS: TierConfig[] = [ 'Alt line ladder (Desk only)', 'Kelly sizing (Desk only)', ], - highlight: true, + highlight: false, }, { + // DS5 (Part 6, #8) — Desk is THE hero tier. It carries the "$1M terminal" + // story and the single primary CTA on the grid (color contract #9: never + // two competing green CTAs). $44.99 regular, $34.99 for founders. id: 'desk', name: 'Desk', - price: '$44.99', - originalPrice: '$49.99', + price: '$34.99', + originalPrice: '$44.99', cadence: '/mo', - headline: 'Everything. The professional setup.', - cta: 'Go Desk', + badge: 'Founder Desk', + headline: 'The professional terminal. Everything the model knows.', + cta: 'Claim a Founder Desk', features: [ 'Everything in Analyst', 'Alt line ladder + edge ranking', @@ -105,7 +109,7 @@ const TIERS: TierConfig[] = [ 'Consensus vs model comparison', ], locked: [], - highlight: false, + highlight: true, }, ]; diff --git a/web/src/components/StreaksPanel.tsx b/web/src/components/StreaksPanel.tsx index 90307d9..6c34236 100644 --- a/web/src/components/StreaksPanel.tsx +++ b/web/src/components/StreaksPanel.tsx @@ -1,16 +1,20 @@ 'use client'; import { useEffect, useState } from 'react'; -import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import GradeBadge from '@/components/vyndr/GradeBadge'; +import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import { type Tier } from '@/lib/tierGate'; /** - * StreaksPanel (Session 23). + * StreaksPanel (Session 23 · rebuilt DS4 — the STREAKS BILLBOARD). * - * Surfaces computed player streaks for a sport, narrowed by the active - * stat filter. Everything through VYNDR's lens — "4-game 28+ scoring - * streak", not "31.2 PPG". Free users see the top 3 with an upgrade - * nudge; paid users see the full list. + * DESIGN-SPEC Part 7 (#2): the STREAKS row is a P0 billboard — the timeline + * is customer #1. Not a log line: a Bloomberg alert. ONE bold figure (the + * streak LENGTH, mono + tabular, the largest thing in the row) among muted + * context (real player identity + "built vs" lens), with a SINGLE severity + * accent — tonight's matchup difficulty (step-up amber / step-down green). + * The grade badge is tier-gated (the READ is the paid layer). * * Self-hides when there are no streaks so the landing page never shows an * empty box — the other layers (schedule, game lines, props) carry the @@ -28,6 +32,9 @@ interface Streak { // Session 60 (night2/C) — THE LENS + optional snapshot grade letter. lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null }; grade?: string | null; + // DS5 (Part 5) — the player's locked archetype from the snapshot join, when + // present. Optional + self-hiding: absent beats fabricated. + archetype?: string | null; } export interface StreaksPanelProps { @@ -61,44 +68,67 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit // Session 60 (product def) — the PICTURE is free: every streak shows for // every tier. Only an explicit `limit` (landing teaser) caps the list. - // The paid layer is the GRADE, not the data. tierGate still governs the - // hot LISTS (top-3 free) — not streaks. - void tier; const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks; const hidden = streaks.length - visible.length; + // DS4 — the GRADE (the READ) is the paid layer; only analyst/desk see it. + const canSeeGrade = tier === 'analyst' || tier === 'desk'; return (

🔥 STREAKS

-
- {visible.map((s) => ( -
- {s.player} { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }} - /> -
-
- {s.player}{s.team ? ` · ${s.team}` : ''} - {/* Grade letter when the snapshot graded this player (the paid - READ layer; the letter itself is public on the slate). */} - {s.grade && {s.grade}} +
+ {visible.map((s) => { + const diff = s.lens?.difficulty; + // ONE severity accent — tonight's matchup difficulty. + const sev = diff === 'step up' + ? { color: 'var(--amber, #FFB347)', label: '▲ STEP-UP SPOT TONIGHT' } + : diff === 'step down' + ? { color: 'var(--g-a, #00D4A0)', label: '▼ SOFTER SPOT TONIGHT' } + : null; + // The LENS — muted "built vs" context; never the raw streak alone. + const builtVs = Array.isArray(s.lens?.builtVs) && s.lens!.builtVs!.length > 0 + ? `built vs ${s.lens!.builtVs!.slice(0, 3).join(', ')}` + : (s.lens?.read || s.description); + return ( +
+ {/* Real identity — headshot / team-colored monogram (DS0). */} + + + {/* THE HERO — streak length, mono + tabular, the largest figure + in the row. One bold number; everything else demoted. */} +
+ {s.currentStreak} + GAME
STREAK
- {/* THE LENS — the interpreted read, never the raw streak alone. */} -
{s.lens?.read || s.description}
- {s.lens?.difficulty && ( -
- {s.lens.difficulty === 'step up' ? '▲ TOUGHER SPOT TONIGHT' : s.lens.difficulty === 'step down' ? '▼ SOFTER SPOT TONIGHT' : ''} + + {/* Identity + the LENS — demoted, muted context. */} +
+
+ {s.player}{s.team ? · {s.team} : null}
- )} + {/* Part 5 — the archetype glyph+chip (the Rosetta stone), + propagated here; self-hides when the join has no archetype. */} + {s.archetype && ( +
+ +
+ )} +
{s.description}
+
{builtVs}
+
+ + {/* The single severity accent + tier-gated grade badge. */} +
+ {sev && {sev.label}} + {s.grade && ( + canSeeGrade + ? + : GRADE ◆ + )} +
- {s.currentStreak} G -
- ))} + ); + })}
{hidden > 0 && ( @@ -114,23 +144,51 @@ const panelHeading: React.CSSProperties = { color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px', }; const rowStyle: React.CSSProperties = { - display: 'flex', alignItems: 'center', gap: 10, - padding: '8px 10px', borderRadius: 10, + display: 'flex', alignItems: 'center', gap: 14, + padding: '12px 14px', borderRadius: 12, background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)', }; -const avatarStyle: React.CSSProperties = { - borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto', +// THE HERO NUMBER — deliberately the largest font in the row (mono, tabular). +const heroBlock: React.CSSProperties = { + flex: '0 0 auto', display: 'flex', alignItems: 'baseline', gap: 6, + minWidth: 62, +}; +const heroNum: React.CSSProperties = { + fontSize: 38, fontWeight: 800, lineHeight: 1, + color: 'var(--text-primary, #F0F0F4)', fontVariantNumeric: 'tabular-nums', + letterSpacing: '-0.03em', +}; +const heroUnit: React.CSSProperties = { + fontSize: 8.5, fontWeight: 700, lineHeight: 1.05, letterSpacing: '0.1em', + color: 'var(--text-tertiary, #6A6A78)', }; const playerName: React.CSSProperties = { fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }; -const streakDesc: React.CSSProperties = { - fontSize: 12, color: 'var(--text-secondary, #9A9AA8)', +const teamTag: React.CSSProperties = { + fontSize: 12, fontWeight: 500, color: 'var(--text-tertiary, #6A6A78)', }; -const badgeStyle: React.CSSProperties = { - flex: '0 0 auto', fontSize: 12, fontWeight: 800, padding: '3px 8px', - borderRadius: 6, background: 'rgba(233,75,60,0.15)', color: 'var(--accent, #E94B3C)', +const categoryLine: React.CSSProperties = { + fontSize: 11, color: 'var(--text-secondary, #9A9AA8)', marginTop: 1, +}; +const lensLine: React.CSSProperties = { + fontSize: 11, color: 'var(--text-tertiary, #6A6A78)', marginTop: 2, + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', +}; +const rightRail: React.CSSProperties = { + flex: '0 0 auto', display: 'flex', flexDirection: 'column', + alignItems: 'flex-end', gap: 6, +}; +const sevChip: React.CSSProperties = { + fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em', + padding: '2px 7px', borderRadius: 5, border: '1px solid transparent', + background: 'transparent', whiteSpace: 'nowrap', +}; +const gradeLock: React.CSSProperties = { + fontSize: 10.5, fontWeight: 800, letterSpacing: '0.06em', + color: 'var(--text-tertiary, #6A6A78)', textDecoration: 'none', + border: '1px solid var(--border, #2A2A36)', borderRadius: 4, padding: '2px 6px', }; const upsellStyle: React.CSSProperties = { display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600, diff --git a/web/src/components/vyndr/BookWordmark.tsx b/web/src/components/vyndr/BookWordmark.tsx new file mode 100644 index 0000000..04628b2 --- /dev/null +++ b/web/src/components/vyndr/BookWordmark.tsx @@ -0,0 +1,35 @@ +import { bookInfo } from '@/lib/books'; + +/** + * BookWordmark (DS0) — a sportsbook renders as its brand: the real name in + * the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare + * lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts + * where the BookChip tile is too heavy. `best` gives it the signal ring. + */ +export default function BookWordmark({ + book, + best = false, + size = 12, +}: { + book: string; + best?: boolean; + size?: number; +}) { + const b = bookInfo(book); + return ( + + {best && } + {b.name} + + ); +} diff --git a/web/src/components/vyndr/EmptyState.tsx b/web/src/components/vyndr/EmptyState.tsx new file mode 100644 index 0000000..a5a48bb --- /dev/null +++ b/web/src/components/vyndr/EmptyState.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from 'react'; +import Wordmark from './Wordmark'; + +export interface EmptyStateAction { + label: string; + href: string; + /** Primary = the one green CTA (color contract #9: never two competing). */ + primary?: boolean; +} + +export interface EmptyStateProps { + /** The system-voice code line, e.g. "TEAM NOT FOUND" (amber, mono, glitch-free data-adjacent chrome). */ + code: string; + /** Human headline. */ + title: string; + /** One muted mono line — say less, never invent (VOICE). */ + message?: string; + /** CTA hierarchy — at most one `primary`. */ + actions?: EmptyStateAction[]; + /** Show the glitch wordmark above the code (default true — the 404 north-star look). */ + wordmark?: boolean; + /** Compact inline variant (no full-viewport min-height) — for in-page empties like the ledger. */ + inline?: boolean; + /** Extra content below the actions (legends, secondary copy). */ + children?: ReactNode; +} + +/** + * EmptyState (DS5 · DESIGN-SPEC Part 8, #20) — ONE designed empty/error system, + * modeled on the north-star 404 (app/not-found.tsx): scanlines + optional glitch + * wordmark + a mono amber system-voice code line + correct CTA hierarchy. It + * REPLACES the scattered bare-red "Team not found", the naked "Game not found", + * and the flat offseason-empty voices — one on-brand surface everywhere. + * + * Amber (not red) is the system-voice accent (matches the 404): an empty board + * is a state, not an error to alarm about. Data never glitches — only the + * wordmark chrome carries the glitch (Part 4). prefers-reduced-motion freezes + * the crt-sweep via the global CSS rule. + */ +export default function EmptyState({ + code, + title, + message, + actions = [], + wordmark = true, + inline = false, + children, +}: EmptyStateProps) { + return ( +
+ {/* CRT sweep — fires once on mount; frozen under reduced motion. */} +
+ ); +} diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index 6f46e2e..ef68c2a 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -6,8 +6,12 @@ import SectionHead from '@/components/vyndr/SectionHead'; import GradeBadge from '@/components/vyndr/GradeBadge'; import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@/components/vyndr/StatStrip'; +import TeamLogo from '@/components/vyndr/TeamLogo'; +import { accentColor } from '@/lib/teamMeta'; import { playerHref } from '@/lib/playerHref'; import { isPreferredBook } from '@/lib/books'; +import { pendingSummary, topReadForCard } from '@/lib/slateAdapter'; +import { nextRunLabelET } from '@/lib/pipelineSchedule'; import { useParlay, legKey } from '@/contexts/ParlayContext'; export interface GameLine { @@ -100,19 +104,21 @@ function collapseStrips(strips: PlayerStrip[]) { return { sorted, visible, totalReads, truncated: visible.length < sorted.length }; } -/** Clickable team abbreviation → /team/:abbr (Session 51). Stops propagation so - * it doesn't trigger the card's open-game handler; green underline on hover. */ +/** Clickable team → /team/:abbr (Session 51). DS0: renders the real LOGO + + * abbr (the entity layer), not a bare string. Stops propagation so it + * doesn't trigger the card's open-game handler. */ function TeamLink({ abbr, sport }: { abbr: string; sport: string }) { if (!abbr) return ; return ( e.stopPropagation()} - style={{ color: '#fff', textDecoration: 'none', borderBottom: '1px solid transparent' }} + style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: '#fff', textDecoration: 'none', borderBottom: '1px solid transparent' }} onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--g-a)'; e.currentTarget.style.borderBottomColor = 'var(--g-a)'; }} onMouseLeave={(e) => { e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderBottomColor = 'transparent'; }} title={`${abbr} team hub`} > + {abbr} ); @@ -178,6 +184,12 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks const [linesExpanded, setLinesExpanded] = useState(false); const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]); const stripsToRender = showAllReads ? collapsed.sorted : collapsed.visible; + // DS2 (#2) — the ONE bold hero per card: the single best live graded read, + // promoted large + mono + tabular. Everything else in the strips below stays + // demoted context. (#14) — when the card has NO graded reads yet, the awaiting + // props collapse to ONE pending line instead of six identical rows. + const topRead = useMemo(() => topReadForCard(g.playerStrips || []), [g.playerStrips]); + const pending = useMemo(() => pendingSummary(g.playerStrips || []), [g.playerStrips]); // A1 S11 — LIVE SLATE MODE: any strip prop carrying live tracking shows the // once-per-card label. Grades locked pre-game NEVER change in-game. const isTracking = useMemo( @@ -206,8 +218,11 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks }, isLegActive: (p: StripProp) => hasLeg(legKey(toLeg(ps, p))), }); + // DS0 — a team-colored accent edge (home team) so the card reads as THIS + // matchup, not a generic panel. Visible-color rule keeps it off near-black. + const homeAccent = accentColor(g.home.abbr, g.sport) || 'var(--border)'; return ( -
+
{/* HEADER */}
onOpen && onOpen(g.id)} title="Open game detail" style={{ display: 'flex', alignItems: 'center', gap: 11, minWidth: 0, cursor: onOpen ? 'pointer' : 'default' }}> @@ -314,7 +329,36 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks )} - {g.playerStrips && g.playerStrips.length > 0 ? ( + {/* DS2 (#2) — ONE bold hero: the card's best read, large + mono + + tabular. The failure mode was every value at identical weight; this + promotes the single number that matters and demotes the rest. */} + {topRead && !pending && ( +
+ +
+
{topRead.player}
+
+ {topRead.stat} {String(topRead.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{topRead.line} + {topRead.team ? · {topRead.team} : null} +
+
+ TOP READ +
+ )} + {/* DS2 (#14) — collapse the dead "Grades post …" repetition. An + all-awaiting card renders ONE compact line, not six identical rows. */} + {pending ? ( +
+ {pending.count} + props pending + · + {pending.players} player{pending.players === 1 ? '' : 's'} + · + {nextRunLabelET() ? `grade ${nextRunLabelET()}` : 'grade on the next pipeline run'} +
+ ) : g.playerStrips && g.playerStrips.length > 0 ? (
{stripsToRender.map((ps, i) => ( 0; const hasBooks = Array.isArray(d.books) && d.books.length > 0; const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0; @@ -137,7 +141,8 @@ export default function GradeResultCard({ lineHeight: 0.95, color: hex, letterSpacing: '-0.04em', - textShadow: `0 0 28px ${hex}aa, 0 0 60px ${hex}55`, + // GLOW = A/A+ ONLY (color contract #4): a glowing C/D devalues the cue. + textShadow: gradeGlows(d.grade) ? `0 0 28px ${hex}aa, 0 0 60px ${hex}55` : 'none', fontFamily: 'var(--sans)', }} > @@ -150,7 +155,7 @@ export default function GradeResultCard({ {d.edge != null && ( <> · - {d.edge >= 0 ? '+' : ''}{d.edge}% edge + {d.edge >= 0 ? '+' : ''}{d.edge}% edge )} · @@ -173,11 +178,11 @@ export default function GradeResultCard({ {[ { l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' }, { l: 'LINE', v: d.line, col: 'var(--text-0)' }, - { l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? 'var(--g-a)' : 'var(--text-2)' }, + { l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? edgeColor(d.edge) : 'var(--text-2)' }, ].map((x, i) => (
{x.l}
-
{x.v}
+
{x.v}
))}
@@ -216,15 +221,18 @@ export default function GradeResultCard({
)} - {/* 5c. VYNDR INTELLIGENCE (Session 42) */} + {/* 5c. VYNDR INTELLIGENCE (Session 42). Color contract #6: the panel is + MUTED (neutral border) with a single green label as its identity + accent. Form/Rest are CONTEXT data, not "edge" — neutral-bright, + never green. */} {d.vyndrIntel && ( -
+
VYNDR INTELLIGENCE
- {d.vyndrIntel.form != null && (<>Form{d.vyndrIntel.form}·)} + {d.vyndrIntel.form != null && (<>Form{d.vyndrIntel.form}·)} {d.vyndrIntel.usage && (<>Usage{d.vyndrIntel.usage}·)} {d.vyndrIntel.matchup && (<>Matchup·)} - {d.vyndrIntel.rest && (<>Rest{d.vyndrIntel.rest})} + {d.vyndrIntel.rest && (<>Rest{d.vyndrIntel.rest})}
)} diff --git a/web/src/components/vyndr/PlayerAvatar.tsx b/web/src/components/vyndr/PlayerAvatar.tsx new file mode 100644 index 0000000..e074cc0 --- /dev/null +++ b/web/src/components/vyndr/PlayerAvatar.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useState } from 'react'; +import { getHeadshotUrl, type HeadshotSport } from '@/lib/playerHeadshot'; +import { accentColor } from '@/lib/teamMeta'; + +/** + * PlayerAvatar (DS0) — real headshot; fallback = TEAM-COLORED MONOGRAM, never + * the gray silhouette (DESIGN-SPEC Part 2). A player always renders with + * identity. When the CDN 404s (roster gaps) we draw the player's initials on + * a disc tinted with their team color — branded, legible, never a gray blob. + */ +function initials(name: string): string { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +export default function PlayerAvatar({ + name, + sport = 'mlb', + playerId, + team, + size = 36, +}: { + name: string; + sport?: HeadshotSport; + playerId?: string | number | null; + team?: string | null; + size?: number; +}) { + const [broken, setBroken] = useState(false); + const url = playerId != null ? getHeadshotUrl({ sport, playerId }) : null; + const accent = (team && accentColor(team, String(sport))) || '#4A9EFF'; + const showImg = url && url !== '/images/player-silhouette.svg' && !broken; + + const Monogram = ( + + {initials(name)} + + ); + + if (!showImg) return Monogram; + + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {name} setBroken(true)} + style={{ width: size, height: size, objectFit: 'cover', objectPosition: 'top center' }} + /> + + ); +} diff --git a/web/src/components/vyndr/Skeleton.tsx b/web/src/components/vyndr/Skeleton.tsx new file mode 100644 index 0000000..9321195 --- /dev/null +++ b/web/src/components/vyndr/Skeleton.tsx @@ -0,0 +1,63 @@ +'use client'; + +import React from 'react'; + +/** + * DS1 (DESIGN-SPEC §4 — "kill loading walls") — the single skeleton primitive. + * + * Governing law: premium = SPEED + RESTRAINT. A text-wall loader ("Loading…") + * reads cheap; a layout-matched skeleton reads instant. This is a tokenized + * dark shimmer block (`.vyndr-skeleton` in globals.css) — the sweep is chrome, + * never data, and the global prefers-reduced-motion rule freezes it to a + * still, visible surface. + */ +export function Skeleton({ + height = 16, + width = '100%', + radius = 8, + style, + className = '', +}: { + height?: number | string; + width?: number | string; + radius?: number | string; + style?: React.CSSProperties; + className?: string; +}) { + return ( +