From b452db727cc5b5bc25426bec01f186b203ef766c Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 16:52:13 -0400 Subject: [PATCH 01/12] =?UTF-8?q?DESIGN-SPEC.md=20v2=20=E2=80=94=20the=20r?= =?UTF-8?q?aised=20standard=20(governs=20the=20design=20train)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/DESIGN-SPEC.md | 84 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 specs/DESIGN-SPEC.md 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 — From 24af247b292def3fb9a4307bb9f6444ae906173b Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 19:02:18 -0400 Subject: [PATCH 02/12] =?UTF-8?q?DS0=20(Design=20v2):=20the=20Entity=20Lay?= =?UTF-8?q?er=20=E2=80=94=20teams/players/books=20render=20as=20themselves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder's #1 priority. A single cached asset+rendering system, swapped into every surface, so entities stop being flat gray strings (DESIGN-SPEC Part 2). - web/src/lib/teamMeta.js: static registry for ALL 4 sports — 30 MLB, 30 NBA, 13 WNBA teams + 48 World Cup national teams, each with real colors + the ESPN logo/flag CDN abbr. resolveTeam (abbr/full-name/nickname/alias), teamLogoUrl (statsapi->ESPN mapping: AZ->ari, CWS->chw; soccer via the countries/ flag CDN), accentColor (picks the VISIBLE color of the pair so a #000000 primary never renders an invisible accent on #06060B). Colors + abbrs sourced once from ESPN's team API — stable public facts, zero-latency static data, no paid dependency. - TeamLogo: real ESPN-CDN logo with a team-colored MONOGRAM fallback (never a gray box / bare abbr). PlayerAvatar: real headshot with a team-colored monogram fallback (kills the gray silhouette). BookWordmark: brand-color wordmark, proper casing (DraftKings, not 'draftkings'). - Swapped into the class-level shared components so it propagates to ALL surfaces: GameCard (team logos + team-colored accent edge), StatStrip (player identity avatar), StreaksPanel (P0 billboard avatars), TeamHub header (the team's real crest leads its hub). Barrel-exported. ZERO OUT-OF-POCKET: ESPN logo/flag CDN + league headshot CDNs, all verified 200 image/png across MLB/NBA/WNBA/soccer. ACCEPTANCE (SSR render proof): /entity-demo harness server-rendered the exact real asset URLs across all 4 sports — mlb/500/nyy.png, mlb/500/chw.png (White Sox, correct ESPN abbr), nba/500/lal.png, wnba/500/ny.png, countries/500/ usa.png + bra/eng/arg/jpn flags, real mlbstatic/nba headshots (Judge 592450, LeBron 2544), DraftKings/FanDuel wordmarks. Each URL curl-verified 200 image/png. Harness removed post-proof (not a product surface). Pixel screenshot blocked by WSL2<->Windows-Chrome localhost networking, not code. 2757 -> 2776 tests (+13 entityLayer, +6 boot resilience), web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/entityLayer.test.js | 74 +++++++ web/src/app/team/[abbr]/TeamHub.tsx | 4 +- web/src/components/StreaksPanel.tsx | 16 +- web/src/components/vyndr/BookWordmark.tsx | 35 ++++ web/src/components/vyndr/GameCard.tsx | 15 +- web/src/components/vyndr/PlayerAvatar.tsx | 76 +++++++ web/src/components/vyndr/StatStrip.tsx | 4 + web/src/components/vyndr/TeamLogo.tsx | 64 ++++++ web/src/components/vyndr/index.ts | 5 + web/src/lib/teamMeta.js | 244 ++++++++++++++++++++++ 10 files changed, 520 insertions(+), 17 deletions(-) create mode 100644 tests/unit/entityLayer.test.js create mode 100644 web/src/components/vyndr/BookWordmark.tsx create mode 100644 web/src/components/vyndr/PlayerAvatar.tsx create mode 100644 web/src/components/vyndr/TeamLogo.tsx create mode 100644 web/src/lib/teamMeta.js 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/web/src/app/team/[abbr]/TeamHub.tsx b/web/src/app/team/[abbr]/TeamHub.tsx index aad8585..9b247ac 100644 --- a/web/src/app/team/[abbr]/TeamHub.tsx +++ b/web/src/app/team/[abbr]/TeamHub.tsx @@ -5,6 +5,7 @@ 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 { playerHref } from '@/lib/playerHref'; import { useParlay, legKey } from '@/contexts/ParlayContext'; @@ -105,8 +106,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/components/StreaksPanel.tsx b/web/src/components/StreaksPanel.tsx index 90307d9..d00a2e2 100644 --- a/web/src/components/StreaksPanel.tsx +++ b/web/src/components/StreaksPanel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import { type Tier } from '@/lib/tierGate'; /** @@ -73,14 +73,9 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
{visible.map((s) => (
- {s.player} { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }} - /> + {/* DS0 — real headshot / team-colored monogram (kills the gray + silhouette). The STREAKS row is a P0 billboard. */} +
{s.player}{s.team ? ` · ${s.team}` : ''} @@ -118,9 +113,6 @@ const rowStyle: React.CSSProperties = { padding: '8px 10px', borderRadius: 10, background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)', }; -const avatarStyle: React.CSSProperties = { - borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto', -}; const playerName: React.CSSProperties = { fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', 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/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index 6f46e2e..c80c053 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -6,6 +6,8 @@ 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 { useParlay, legKey } from '@/contexts/ParlayContext'; @@ -100,19 +102,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} ); @@ -206,8 +210,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' }}> 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/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index 9bda785..b0bd6bd 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -1,6 +1,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import GradeBadge from '@/components/vyndr/GradeBadge'; import Sparkline from '@/components/vyndr/Sparkline'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import { nextRunLabelET } from '@/lib/pipelineSchedule'; // A1 S3 — BOOK IT is a real per-book deep link now (organic until the // affiliate config flips a book on). rel MUST stay BOOK_LINK_REL. @@ -375,6 +376,9 @@ export default function StatStrip({ return (
+ {/* DS0 — player identity block: real headshot / team-colored monogram + (never the gray silhouette). Team drives the accent color. */} + {player} {team} {archetype && } diff --git a/web/src/components/vyndr/TeamLogo.tsx b/web/src/components/vyndr/TeamLogo.tsx new file mode 100644 index 0000000..7fe370f --- /dev/null +++ b/web/src/components/vyndr/TeamLogo.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useState } from 'react'; +import { teamLogoUrl, accentColor, resolveTeam } from '@/lib/teamMeta'; + +/** + * TeamLogo (DS0) — a team renders as ITSELF: real ESPN-CDN logo, with a + * team-colored monogram fallback (NEVER a gray box or bare abbr). The whole + * entity layer's job is that "MIL @ PIT" stops being flat text. + * + * On a 404 / unknown team we draw the abbr on a disc in the team's accent + * color — still branded, still legible, never a fallback that reads broken. + */ +export default function TeamLogo({ + team, + sport = 'mlb', + size = 28, + title, +}: { + team: string; + sport?: string; + size?: number; + title?: string; +}) { + const [broken, setBroken] = useState(false); + const url = teamLogoUrl(team, sport); + const meta = resolveTeam(team, sport); + const accent = accentColor(team, sport) || 'var(--text-1)'; + const label = meta?.abbr || String(team || '').toUpperCase().slice(0, 3); + + if (!url || broken) { + // Team-colored monogram disc — branded fallback, never gray. + return ( + + {label.slice(0, 3)} + + ); + } + + return ( + // eslint-disable-next-line @next/next/no-img-element + {meta?.name setBroken(true)} + style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0, display: 'block' }} + /> + ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 7cab8dd..487276f 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -25,6 +25,11 @@ export { default as StatStrip } from './StatStrip'; export type { StatCell, StripProp, StripArchetype } from './StatStrip'; export { default as BookChip } from './BookChip'; +/* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */ +export { default as TeamLogo } from './TeamLogo'; +export { default as PlayerAvatar } from './PlayerAvatar'; +export { default as BookWordmark } from './BookWordmark'; + /* S6 (A1 board) — global search + row-grammar micro-marks */ export { default as SearchModal } from './SearchModal'; export { DotStrip, LineSparkline } from './StatStrip'; diff --git a/web/src/lib/teamMeta.js b/web/src/lib/teamMeta.js new file mode 100644 index 0000000..bc230ed --- /dev/null +++ b/web/src/lib/teamMeta.js @@ -0,0 +1,244 @@ +/* ============================================================ + DS0 (Design v2) — THE ENTITY LAYER: team logos + real colors. + + Teams render as THEMSELVES (DESIGN-SPEC Part 2 / Part 0 law #4): real + logo + team-colored accent, never a bare "MIL @ PIT" string. + + Colors + abbrs are stable public facts (like teams.js's names), so a + static registry is honest and zero-latency — no runtime fetch, no paid + dependency. Colors sourced once from ESPN's team API; logos from ESPN's + free CDN (verified 200 image/png). CommonJS so Jest requires it and the + .tsx components import it (allowJs). + + Each entry: { name, espn (logo-CDN abbr), primary, secondary }. Keyed by + the app/statsapi abbr; resolveTeam() also matches full names + nicknames. + The ESPN "primary" color is often near-black (dark-theme invisible), so + accentColor() picks the more VISIBLE of the two against #06060B. + ============================================================ */ + +// abbr → meta. MLB abbrs match statsapi; where ESPN's logo abbr differs +// (AZ→ari, CWS→chw) the `espn` field carries the CDN spelling. +const MLB = { + AZ: { name: 'Arizona Diamondbacks', espn: 'ari', primary: '#aa182c', secondary: '#e3d4ad' }, + ATH: { name: 'Athletics', espn: 'oak', primary: '#003831', secondary: '#efb21e' }, + ATL: { name: 'Atlanta Braves', espn: 'atl', primary: '#ba0c2f', secondary: '#13274f' }, + BAL: { name: 'Baltimore Orioles', espn: 'bal', primary: '#df4601', secondary: '#000000' }, + BOS: { name: 'Boston Red Sox', espn: 'bos', primary: '#bd3039', secondary: '#0d2b56' }, + CHC: { name: 'Chicago Cubs', espn: 'chc', primary: '#0e3386', secondary: '#cc3433' }, + CWS: { name: 'Chicago White Sox', espn: 'chw', primary: '#c4ced4', secondary: '#000000' }, + CIN: { name: 'Cincinnati Reds', espn: 'cin', primary: '#c6011f', secondary: '#000000' }, + CLE: { name: 'Cleveland Guardians', espn: 'cle', primary: '#e31937', secondary: '#002b5c' }, + COL: { name: 'Colorado Rockies', espn: 'col', primary: '#8e6cae', secondary: '#c4ced4' }, + DET: { name: 'Detroit Tigers', espn: 'det', primary: '#0a2240', secondary: '#ff4713' }, + HOU: { name: 'Houston Astros', espn: 'hou', primary: '#eb6e1f', secondary: '#002d62' }, + KC: { name: 'Kansas City Royals', espn: 'kc', primary: '#7ab2dd', secondary: '#004687' }, + LAA: { name: 'Los Angeles Angels', espn: 'laa', primary: '#ba0021', secondary: '#c4ced4' }, + LAD: { name: 'Los Angeles Dodgers', espn: 'lad', primary: '#005a9c', secondary: '#ef3e42' }, + MIA: { name: 'Miami Marlins', espn: 'mia', primary: '#00a3e0', secondary: '#ef3340' }, + MIL: { name: 'Milwaukee Brewers', espn: 'mil', primary: '#ffc72c', secondary: '#13294b' }, + MIN: { name: 'Minnesota Twins', espn: 'min', primary: '#e20e32', secondary: '#002b5c' }, + NYM: { name: 'New York Mets', espn: 'nym', primary: '#ff5910', secondary: '#002d72' }, + NYY: { name: 'New York Yankees', espn: 'nyy', primary: '#c4ced4', secondary: '#132448' }, + PHI: { name: 'Philadelphia Phillies',espn: 'phi', primary: '#e81828', secondary: '#284898' }, + PIT: { name: 'Pittsburgh Pirates', espn: 'pit', primary: '#fdb827', secondary: '#000000' }, + SD: { name: 'San Diego Padres', espn: 'sd', primary: '#ffc425', secondary: '#2f241d' }, + SF: { name: 'San Francisco Giants', espn: 'sf', primary: '#fd5a1e', secondary: '#000000' }, + SEA: { name: 'Seattle Mariners', espn: 'sea', primary: '#0c8f8f', secondary: '#0c2c56' }, + STL: { name: 'St. Louis Cardinals', espn: 'stl', primary: '#be0a14', secondary: '#0a2252' }, + TB: { name: 'Tampa Bay Rays', espn: 'tb', primary: '#8fbce6', secondary: '#092c5c' }, + TEX: { name: 'Texas Rangers', espn: 'tex', primary: '#c0111f', secondary: '#003278' }, + TOR: { name: 'Toronto Blue Jays', espn: 'tor', primary: '#1d78cf', secondary: '#134a8e' }, + WSH: { name: 'Washington Nationals', espn: 'wsh', primary: '#ab0003', secondary: '#11225b' }, +}; + +const NBA = { + ATL: { name: 'Atlanta Hawks', espn: 'atl', primary: '#e03a3e', secondary: '#fdb927' }, + BOS: { name: 'Boston Celtics', espn: 'bos', primary: '#008348', secondary: '#ffffff' }, + BKN: { name: 'Brooklyn Nets', espn: 'bkn', primary: '#c4ced4', secondary: '#000000' }, + CHA: { name: 'Charlotte Hornets', espn: 'cha', primary: '#00a3af', secondary: '#1d1160' }, + CHI: { name: 'Chicago Bulls', espn: 'chi', primary: '#ce1141', secondary: '#000000' }, + CLE: { name: 'Cleveland Cavaliers', espn: 'cle', primary: '#bc945c', secondary: '#860038' }, + DAL: { name: 'Dallas Mavericks', espn: 'dal', primary: '#0064b1', secondary: '#bbc4ca' }, + DEN: { name: 'Denver Nuggets', espn: 'den', primary: '#fec524', secondary: '#0e2240' }, + DET: { name: 'Detroit Pistons', espn: 'det', primary: '#1d428a', secondary: '#c8102e' }, + GSW: { name: 'Golden State Warriors', espn: 'gs', primary: '#fdb927', secondary: '#1d428a' }, + HOU: { name: 'Houston Rockets', espn: 'hou', primary: '#ce1141', secondary: '#c4ced4' }, + IND: { name: 'Indiana Pacers', espn: 'ind', primary: '#ffd520', secondary: '#0c2340' }, + LAC: { name: 'LA Clippers', espn: 'lac', primary: '#c8102e', secondary: '#1d428a' }, + LAL: { name: 'Los Angeles Lakers', espn: 'lal', primary: '#fdb927', secondary: '#552583' }, + MEM: { name: 'Memphis Grizzlies', espn: 'mem', primary: '#5d76a9', secondary: '#12173f' }, + MIA: { name: 'Miami Heat', espn: 'mia', primary: '#98002e', secondary: '#f9a01b' }, + MIL: { name: 'Milwaukee Bucks', espn: 'mil', primary: '#00471b', secondary: '#eee1c6' }, + MIN: { name: 'Minnesota Timberwolves', espn: 'min', primary: '#79bc43', secondary: '#266092' }, + NOP: { name: 'New Orleans Pelicans', espn: 'no', primary: '#b4975a', secondary: '#0a2240' }, + NYK: { name: 'New York Knicks', espn: 'ny', primary: '#f58426', secondary: '#1d428a' }, + OKC: { name: 'Oklahoma City Thunder', espn: 'okc', primary: '#007ac1', secondary: '#ef3b24' }, + ORL: { name: 'Orlando Magic', espn: 'orl', primary: '#0077c0', secondary: '#c4ced4' }, + PHI: { name: 'Philadelphia 76ers', espn: 'phi', primary: '#1d428a', secondary: '#e01234' }, + PHX: { name: 'Phoenix Suns', espn: 'phx', primary: '#e56020', secondary: '#1d1160' }, + POR: { name: 'Portland Trail Blazers', espn: 'por', primary: '#e03a3e', secondary: '#c4ced4' }, + SAC: { name: 'Sacramento Kings', espn: 'sac', primary: '#5a2d81', secondary: '#63727a' }, + SAS: { name: 'San Antonio Spurs', espn: 'sa', primary: '#c4ced4', secondary: '#000000' }, + TOR: { name: 'Toronto Raptors', espn: 'tor', primary: '#ce1141', secondary: '#000000' }, + UTA: { name: 'Utah Jazz', espn: 'utah', primary: '#79a3dc', secondary: '#4e008e' }, + WAS: { name: 'Washington Wizards', espn: 'wsh', primary: '#e31837', secondary: '#002b5c' }, +}; + +const WNBA = { + ATL: { name: 'Atlanta Dream', espn: 'atl', primary: '#e31837', secondary: '#5091cc' }, + CHI: { name: 'Chicago Sky', espn: 'chi', primary: '#5091cd', secondary: '#ffd520' }, + CON: { name: 'Connecticut Sun', espn: 'conn', primary: '#f05023', secondary: '#0a2240' }, + DAL: { name: 'Dallas Wings', espn: 'dal', primary: '#c4d600', secondary: '#002b5c' }, + GSV: { name: 'Golden State Valkyries', espn: 'gs', primary: '#b38fcf', secondary: '#000000' }, + IND: { name: 'Indiana Fever', espn: 'ind', primary: '#e03a3e', secondary: '#002d62' }, + LV: { name: 'Las Vegas Aces', espn: 'lv', primary: '#a7a8aa', secondary: '#000000' }, + LA: { name: 'Los Angeles Sparks', espn: 'la', primary: '#fdb927', secondary: '#552583' }, + MIN: { name: 'Minnesota Lynx', espn: 'min', primary: '#79bc43', secondary: '#266092' }, + NY: { name: 'New York Liberty', espn: 'ny', primary: '#86cebc', secondary: '#000000' }, + PHX: { name: 'Phoenix Mercury', espn: 'phx', primary: '#fa4b0a', secondary: '#3c286e' }, + SEA: { name: 'Seattle Storm', espn: 'sea', primary: '#2c5235', secondary: '#fee11a' }, + WAS: { name: 'Washington Mystics', espn: 'wsh', primary: '#e03a3e', secondary: '#002b5c' }, +}; + +// Soccer = World Cup national teams (fifa.world). Keyed by the ESPN country +// code (uppercased); the flag CDN is a different path (countries/, not +// teamlogos/soccer). Colors + codes from ESPN's fifa.world API. National +// teams ARE their flag — that's the crest. +const SOCCER = { + ALG: { name: 'Algeria', espn: 'alg', primary: '#4f9a44', secondary: '#ffffff' }, + ARG: { name: 'Argentina', espn: 'arg', primary: '#74acdf', secondary: '#173e69' }, + AUS: { name: 'Australia', espn: 'aus', primary: '#ffcd00', secondary: '#00843d' }, + AUT: { name: 'Austria', espn: 'aut', primary: '#d72b2c', secondary: '#000000' }, + BEL: { name: 'Belgium', espn: 'bel', primary: '#e30613', secondary: '#6ecff6' }, + BIH: { name: 'Bosnia-Herzegovina', espn: 'bih', primary: '#112855', secondary: '#ffce00' }, + BRA: { name: 'Brazil', espn: 'bra', primary: '#fee000', secondary: '#193375' }, + CAN: { name: 'Canada', espn: 'can', primary: '#ed2224', secondary: '#ffffff' }, + CPV: { name: 'Cape Verde', espn: 'cpv', primary: '#0537e4', secondary: '#ef3340' }, + COL: { name: 'Colombia', espn: 'col', primary: '#fbd632', secondary: '#21418c' }, + RDC: { name: 'Congo DR', espn: 'rdc', primary: '#418fde', secondary: '#c60000' }, + CRO: { name: 'Croatia', espn: 'cro', primary: '#ff0000', secondary: '#0c2fff' }, + CUW: { name: 'Curaçao', espn: '11678', primary: '#0537e4', secondary: '#ffce00' }, + CZE: { name: 'Czechia', espn: 'cze', primary: '#d7141a', secondary: '#11457e' }, + ECU: { name: 'Ecuador', espn: 'ecu', primary: '#ffdd00', secondary: '#034ea2' }, + EGY: { name: 'Egypt', espn: 'egy', primary: '#d20300', secondary: '#ffffff' }, + ENG: { name: 'England', espn: 'eng', primary: '#ea1f29', secondary: '#ffffff' }, + FRA: { name: 'France', espn: 'fra', primary: '#3a6dd8', secondary: '#ef4135' }, + GER: { name: 'Germany', espn: 'ger', primary: '#ffce00', secondary: '#dd0000' }, + GHA: { name: 'Ghana', espn: 'gha', primary: '#fbd632', secondary: '#006b3f' }, + HAI: { name: 'Haiti', espn: 'hai', primary: '#0033a0', secondary: '#d21034' }, + IRN: { name: 'Iran', espn: 'irn', primary: '#da0000', secondary: '#239f40' }, + IRQ: { name: 'Iraq', espn: 'irq', primary: '#0a4d2e', secondary: '#ce1126' }, + CIV: { name: 'Ivory Coast', espn: 'civ', primary: '#ff8200', secondary: '#009e60' }, + JPN: { name: 'Japan', espn: 'jpn', primary: '#3a6dd8', secondary: '#bc002d' }, + JOR: { name: 'Jordan', espn: 'jor', primary: '#e70000', secondary: '#007a3d' }, + MEX: { name: 'Mexico', espn: 'mex', primary: '#00843d', secondary: '#ce1126' }, + MAR: { name: 'Morocco', espn: 'mar', primary: '#df2027', secondary: '#006233' }, + NED: { name: 'Netherlands', espn: 'ned', primary: '#fb5d00', secondary: '#21468b' }, + NZL: { name: 'New Zealand', espn: 'nzl', primary: '#c8c8c8', secondary: '#000000' }, + NOR: { name: 'Norway', espn: 'nor', primary: '#c8102e', secondary: '#00205b' }, + PAN: { name: 'Panama', espn: 'pan', primary: '#d21034', secondary: '#005293' }, + PAR: { name: 'Paraguay', espn: 'par', primary: '#ea2300', secondary: '#21418c' }, + POR: { name: 'Portugal', espn: 'por', primary: '#da291c', secondary: '#0d6938' }, + QAT: { name: 'Qatar', espn: 'qat', primary: '#8a1538', secondary: '#ffffff' }, + KSA: { name: 'Saudi Arabia', espn: 'ksa', primary: '#0f7a3d', secondary: '#ffffff' }, + SCO: { name: 'Scotland', espn: 'sco', primary: '#4b6cb7', secondary: '#1a2d69' }, + SEN: { name: 'Senegal', espn: 'sen', primary: '#00853f', secondary: '#fdef42' }, + RSA: { name: 'South Africa', espn: 'rsa', primary: '#ffb81c', secondary: '#0c562e' }, + KOR: { name: 'South Korea', espn: 'kors', primary: '#ce2028', secondary: '#003478' }, + ESP: { name: 'Spain', espn: 'esp', primary: '#c60b1e', secondary: '#ffc400' }, + SWE: { name: 'Sweden', espn: 'swe', primary: '#fecb00', secondary: '#006aa7' }, + SUI: { name: 'Switzerland', espn: 'sui', primary: '#ff0000', secondary: '#ffffff' }, + TUN: { name: 'Tunisia', espn: 'tun', primary: '#d20300', secondary: '#ffffff' }, + TUR: { name: 'Türkiye', espn: 'tur', primary: '#ef3340', secondary: '#ffffff' }, + USA: { name: 'United States', espn: 'usa', primary: '#3a6dd8', secondary: '#d42339' }, + URU: { name: 'Uruguay', espn: 'uru', primary: '#55b5e5', secondary: '#00205b' }, + UZB: { name: 'Uzbekistan', espn: 'uzb', primary: '#0072ce', secondary: '#1eb53a' }, +}; + +const LEAGUES = { mlb: MLB, nba: NBA, wnba: WNBA, soccer: SOCCER }; + +// Common naming variants the odds feeds use → the canonical soccer key. +const SOCCER_ALIAS = { + usa: 'USA', unitedstates: 'USA', usmnt: 'USA', + southkorea: 'KOR', koreademocraticpeoplesrepublic: 'KOR', kore : 'KOR', korearepublic: 'KOR', + ivorycoast: 'CIV', cotedivoire: 'CIV', + turkey: 'TUR', turkiye: 'TUR', + czechrepublic: 'CZE', czechia: 'CZE', + bosnia: 'BIH', bosniaandherzegovina: 'BIH', + congodr: 'RDC', drcongo: 'RDC', + curacao: 'CUW', +}; + +// Full-name + nickname → abbr, built once per league for resolveTeam(). +const NAME_INDEX = {}; +for (const [sp, table] of Object.entries(LEAGUES)) { + NAME_INDEX[sp] = {}; + for (const [abbr, meta] of Object.entries(table)) { + const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, ''); + NAME_INDEX[sp][norm(meta.name)] = abbr; + // nickname = last word ("Yankees", "Red Sox" → "redsox") + const parts = meta.name.split(/\s+/); + NAME_INDEX[sp][norm(parts[parts.length - 1])] = abbr; + // two-word nickname ("Red Sox", "Blue Jays", "White Sox") + if (parts.length >= 2) NAME_INDEX[sp][norm(parts.slice(-2).join(''))] = abbr; + } +} + +const normKey = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + +/** Resolve any team key (abbr, full name, nickname) + sport → meta or null. */ +function resolveTeam(key, sport) { + if (!key) return null; + const sp = String(sport || 'mlb').toLowerCase(); + const table = LEAGUES[sp]; + if (!table) return null; + const up = String(key).toUpperCase().trim(); + if (table[up]) return { abbr: up, sport: sp, ...table[up] }; + // statsapi/ESPN alias fallbacks for the two MLB mismatches + const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' }; + if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] }; + if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) { + const a = SOCCER_ALIAS[normKey(key)]; + return { abbr: a, sport: sp, ...table[a] }; + } + const byName = NAME_INDEX[sp][normKey(key)]; + if (byName) return { abbr: byName, sport: sp, ...table[byName] }; + return null; +} + +/** ESPN free logo CDN url for a team, or null when unresolved. Soccer national + * teams use the flag CDN (countries/ for a 3-letter code, soccer/ for the rare + * numeric team id); the ball sports use teamlogos/{league}/. */ +function teamLogoUrl(key, sport) { + const m = resolveTeam(key, sport); + if (!m) return null; + if (m.sport === 'soccer') { + return /^\d+$/.test(m.espn) + ? `https://a.espncdn.com/i/teamlogos/soccer/500/${m.espn}.png` + : `https://a.espncdn.com/i/teamlogos/countries/500/${m.espn}.png`; + } + const path = m.sport === 'wnba' ? 'wnba' : 'nba'; + return `https://a.espncdn.com/i/teamlogos/${m.sport === 'mlb' ? 'mlb' : path}/500/${m.espn}.png`; +} + +// Perceived luminance of a #rrggbb (0–255). Used to keep team accents +// VISIBLE on the near-black terminal bg — a #000000 primary is useless. +function luminance(hex) { + const h = String(hex || '').replace('#', ''); + if (h.length !== 6) return 0; + const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** The team color to use for an accent/monogram against the dark theme: + * the more visible of primary/secondary. Falls back to a neutral when + * both are near-black (never returns an invisible accent). */ +function accentColor(key, sport) { + const m = resolveTeam(key, sport); + if (!m) return null; + const lp = luminance(m.primary), ls = luminance(m.secondary); + const best = lp >= ls ? m.primary : m.secondary; + return luminance(best) < 40 ? '#8A8A9A' : best; // both dark → neutral chrome +} + +module.exports = { resolveTeam, teamLogoUrl, accentColor, luminance, LEAGUES, __tables: { MLB, NBA, WNBA } }; From 2f6aaad3660107ae07a5a21192b04cd2dbaf7ffe Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 19:07:51 -0400 Subject: [PATCH 03/12] STATE.md: design train v2 in flight (DS0 done, Wave 1 building) Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/STATE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/specs/STATE.md b/specs/STATE.md index 2fe9614..f48f8ac 100644 --- a/specs/STATE.md +++ b/specs/STATE.md @@ -1,6 +1,21 @@ # VYNDR — STATE OF THE WORLD ### As of `ec5ee3d` (main, 2026-07-12). This file opens every future session. Update it when a train ships. +## IN FLIGHT — DESIGN TRAIN v2 (branch `design/v2-train`, 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). Sessions: +- **DS0 Entity Layer — DONE** (`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+bugs · DS3 color contract · DS4 billboards** — building (Wave 1). +- **DS2 dashboard slate rebuild · DS5 pricing/motion/states** — Wave 2 (queued). +NOTE: WSL2↔Windows-Chrome localhost is unreachable in this env, so visual proof +is SSR-render + curl + tests, not pixel screenshots. Nothing pushed to main +without the founder's "merge the train". + + + ## WHERE THINGS STAND **Tests: 2757 / 229 suites, all green. Web build exit 0.** Baseline at the A1 From 1c681df5d33b6618ac202e3f5270da3f787f67d2 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 19:25:32 -0400 Subject: [PATCH 04/12] DS1 (design): speed + trust bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the three DESIGN-SPEC Part 4 + #17 audit findings. 1. React #418 hydration mismatch (landing → dashboard entry). The `maybeSignedIn` value was computed in a useState INITIALIZER that reads localStorage during render: server (no window) → false → emits the marketing tree; a signed-in visitor's first CLIENT render → true → emits the loading placeholder. Whole-subtree server/client mismatch → React discarded and re-rendered the page. Deferred behind a mounted flag so the first client render matches the server; the stored-session check flips post-mount. SSR HTML is no longer discarded. 2. Loading walls → skeletons. New tokenized Skeleton primitive (.vyndr-skeleton, reduced-motion-safe via the global rule). Swapped into every text-wall loader: dashboard slate load ("Loading the slate…"), /desk ("Assembling the pack…"), /ledger ("Loading…"), scan ("Loading the model…"), and the landing redirect placeholder. No bare text loader remains. 3. scan→ledger persistence. Root cause: the scan page read its bearer token from localStorage['sb-token'] — a key written ONLY by the OAuth callback — so email/password users posted /api/scan anonymously and the ledger write (gated on an authed user) was silently skipped. Now uses the authoritative session.access_token (matching the ledger read path). Extracted the row builder to web/src/lib/ledgerRow.js (shared, testable). Tests: +17 (scanLedgerPersistence write→mine round-trip + scope + idempotency; ds1SpeedTrust hydration/skeleton/persistence source invariants). Full suite 233 suites / 2793 green; web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/ds1SpeedTrust.test.js | 96 ++++++++++++++ tests/unit/scanLedgerPersistence.test.js | 153 +++++++++++++++++++++++ web/src/app/api/scan/route.ts | 53 ++++---- web/src/app/dashboard/page.tsx | 12 +- web/src/app/desk/page.tsx | 14 ++- web/src/app/globals.css | 16 +++ web/src/app/ledger/page.tsx | 8 +- web/src/app/page.tsx | 24 +++- web/src/app/scan/page.tsx | 23 +++- web/src/components/vyndr/Skeleton.tsx | 63 ++++++++++ web/src/components/vyndr/index.ts | 3 + web/src/lib/ledgerRow.js | 78 ++++++++++++ 12 files changed, 498 insertions(+), 45 deletions(-) create mode 100644 tests/unit/ds1SpeedTrust.test.js create mode 100644 tests/unit/scanLedgerPersistence.test.js create mode 100644 web/src/components/vyndr/Skeleton.tsx create mode 100644 web/src/lib/ledgerRow.js 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/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/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..c278038 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -10,7 +10,7 @@ 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. @@ -181,9 +181,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…

+
+
+ + +
+
); } 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/globals.css b/web/src/app/globals.css index 4cd96c4..db5a884 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -401,6 +401,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 diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 1d4ded0..6772563 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; +import { Skeleton } from '@/components/vyndr'; /** * The Ledger (Session 58, Phase 1) — the truth surface, now backed by the @@ -153,7 +154,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 ? ( ) : ( 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/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/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 ( +
- {dist.map((b) => ( + {distArr.map((b) => (
0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}> {b.count} 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/StreaksPanel.tsx b/web/src/components/StreaksPanel.tsx index d00a2e2..46a41ad 100644 --- a/web/src/components/StreaksPanel.tsx +++ b/web/src/components/StreaksPanel.tsx @@ -2,15 +2,18 @@ import { useEffect, useState } from 'react'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import GradeBadge from '@/components/vyndr/GradeBadge'; 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 @@ -61,39 +64,60 @@ 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) => ( -
- {/* DS0 — real headshot / team-colored monogram (kills the gray - silhouette). The STREAKS row is a P0 billboard. */} - -
-
- {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}
- )} +
{s.description}
+
{builtVs}
+
+ + {/* The single severity accent + tier-gated grade badge. */} +
+ {sev && {sev.label}} + {s.grade && ( + canSeeGrade + ? + : GRADE ◆ + )} +
- {s.currentStreak} G -
- ))} + ); + })}
{hidden > 0 && ( @@ -109,20 +133,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)', }; +// 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/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index 9ce58c3..7a7576e 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -69,6 +69,9 @@ export default function GradeResultCard({ }, [replayKey]); const sideColor = d.side === 'Over' ? 'var(--g-a)' : 'var(--miss)'; + // COLOR CONTRACT (Part 1 #3): edge/CLV colored by SIGN. A negative edge must + // NEVER render green — negative = var(--miss), positive/zero = signal-green. + const edgeColor = d.edge != null && d.edge < 0 ? 'var(--miss)' : 'var(--g-a)'; const hasKill = !!d.killConditions && d.killConditions.length > 0; const hasBooks = Array.isArray(d.books) && d.books.length > 0; const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0; @@ -150,7 +153,7 @@ export default function GradeResultCard({ {d.edge != null && ( <> · - {d.edge >= 0 ? '+' : ''}{d.edge}% edge + {d.edge >= 0 ? '+' : ''}{d.edge}% edge )} · @@ -173,11 +176,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 : 'var(--text-2)' }, ].map((x, i) => (
{x.l}
-
{x.v}
+
{x.v}
))}
diff --git a/web/src/lib/clvDisplay.js b/web/src/lib/clvDisplay.js new file mode 100644 index 0000000..51bc32d --- /dev/null +++ b/web/src/lib/clvDisplay.js @@ -0,0 +1,45 @@ +/** + * clvDisplay (DS4 · Billboards — CLV reframe, DESIGN-SPEC Part 3 #16). + * + * "Charts that are flat should SAY so." When settled CLV is near-flat (e.g. + * 73 of 74 reads landed at the close), a 7-bar histogram is one giant bar and + * six empty stubs — it reads as BROKEN. The record (55-19, 74% hit) is the + * best data on the site; it must look the MOST premium, never errored. + * + * This is the single pure decision: given the server's `clv_distribution` + * (already n≥20 gated in ledgerService), decide FLAT vs SPREAD. Flat → a + * confident VOICE line. Spread → the real bars. Pure + CommonJS so the .tsx + * consumers (ledger, profile) and Jest share one tested rule. + */ + +// The flat bucket must hold this share of settled CLV for the histogram to be +// declared "flat." 73/74 → 0.986; a genuinely dispersed book → well under. +const FLAT_SHARE_THRESHOLD = 0.6; + +// VOICE-compliant one-liner. No jargon; states the model's actual claim. +const CLV_FLAT_LINE = 'CLV flat — we grade the outcome, not the close.'; + +/** + * @param {Array<{label:string,count:number,side:'beat'|'faded'|'flat'}>|null} dist + * @returns {{ mode:'flat'|'spread'|'none', total:number, flat:number, flatShare:number }} + */ +function clvMode(dist) { + if (!Array.isArray(dist) || dist.length === 0) { + return { mode: 'none', total: 0, flat: 0, flatShare: 0 }; + } + const total = dist.reduce((n, b) => n + (Number(b && b.count) || 0), 0); + if (total <= 0) return { mode: 'none', total: 0, flat: 0, flatShare: 0 }; + const flat = dist + .filter((b) => b && b.side === 'flat') + .reduce((n, b) => n + (Number(b.count) || 0), 0); + const flatShare = flat / total; + const mode = flatShare >= FLAT_SHARE_THRESHOLD ? 'flat' : 'spread'; + return { mode, total, flat, flatShare }; +} + +/** Convenience predicate for the flat branch. */ +function isFlatClv(dist) { + return clvMode(dist).mode === 'flat'; +} + +module.exports = { clvMode, isFlatClv, CLV_FLAT_LINE, FLAT_SHARE_THRESHOLD }; From 32929c0052cace60ab03403ad610f5bc03bea540 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 19:28:17 -0400 Subject: [PATCH 06/12] DS4 merge: update QA.20 color lock to the rebuilt STREAKS billboard structure Semantics preserved (step-up amber / step-down green); DS4 moved the coloring into a sev object, so the source-lock regex is updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/vyndrParityQA.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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/); }); }); From 49a3323c201577b9839007a5f50fad9d2ee4c4a7 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 19:34:12 -0400 Subject: [PATCH 07/12] =?UTF-8?q?DS3=20(design):=20the=20color=20contract?= =?UTF-8?q?=20=E2=80=94=20one=20meaning,=20enforced=20by=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal-green #00D4A0 now means exactly ONE thing (edge/active/A-tier/CTA), locked by tests that fail on violation. Part 1 of DESIGN-SPEC v2. - web/src/lib/colorContract.js — pure CommonJS helpers: edgeColor(value) colors edge/CLV/delta by SIGN (neg=var(--miss), pos=var(--g-a), 0=neutral); gradeTierColor() (A/A+ green, B blue, C amber, D/F red, in lockstep with vyndrTokens.gradeColor); gradeGlows() (A/A+ only); deltaE()/isSignalGreen() CIE76 gate so no archetype hue dilutes the signal. - GradeResultCard: edge confidence-strip + EDGE row route through edgeColor (a -33.3% edge was rendering GREEN — audit #3); grade-hero glow gated to A/A+ via gradeGlows (a glowing C devalued the cue); VYNDR INTELLIGENCE panel de-flooded (neutral border, Form/Rest neutral not green — #15). - LiveHeroProp: negative edge now muted red, not neutral (sign completeness). - Archetype dedup off signal-green (both archetypes.js + archetypeService.js, kept matched): DUAL THREAT/MOTOR #00D4A0, MIRROR #34D399, ARTILLERY/RANGE/ GHOST/BLADE #2DD4BF, BRUSH #3DDC84 shifted to distinct non-green hues (all ΔE>=44 from #00D4A0). Within-sport uniqueness preserved. - tests/unit/colorContract.test.js — 21 tests: helper units + source-grep violation locks + archetype-green-dedup. QA.20-22 kept green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/archetypeService.js | 16 +- tests/unit/colorContract.test.js | 152 +++++++++++++++++++ web/src/components/LiveHeroProp.tsx | 7 +- web/src/components/vyndr/GradeResultCard.tsx | 19 ++- web/src/lib/archetypes.js | 16 +- web/src/lib/colorContract.js | 109 +++++++++++++ 6 files changed, 293 insertions(+), 26 deletions(-) create mode 100644 tests/unit/colorContract.test.js create mode 100644 web/src/lib/colorContract.js 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/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/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/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index 9ce58c3..b915f68 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -7,6 +7,7 @@ import VBtn from '@/components/vyndr/VBtn'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import GradeBadge from '@/components/vyndr/GradeBadge'; import { gradeColor, gradeHex } from '@/lib/vyndrTokens'; +import { edgeColor, gradeGlows } from '@/lib/colorContract'; import { playerHref } from '@/lib/playerHref'; export interface GradeResultData { @@ -137,7 +138,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 +152,7 @@ export default function GradeResultCard({ {d.edge != null && ( <> · - {d.edge >= 0 ? '+' : ''}{d.edge}% edge + {d.edge >= 0 ? '+' : ''}{d.edge}% edge )} · @@ -173,7 +175,7 @@ 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}
@@ -216,15 +218,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/lib/archetypes.js b/web/src/lib/archetypes.js index ea74753..61bb7da 100644 --- a/web/src/lib/archetypes.js +++ b/web/src/lib/archetypes.js @@ -41,9 +41,9 @@ const ARCHETYPE_MAP = { TORCH: { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle', legacy: 'VOLUME SCORER' }, CONDUCTOR: { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node', legacy: 'FLOOR GENERAL' }, FORTRESS: { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield', legacy: 'TWO-WAY ANCHOR' }, - ARTILLERY: { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target', legacy: 'STRETCH BIG' }, + ARTILLERY: { c: '#159AAE', d: 'Floor-spacing shooting big', g: 'target', legacy: 'STRETCH BIG' }, SURGE: { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow', legacy: 'USAGE SPONGE' }, - 'DUAL THREAT': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin', legacy: 'COMBO GUARD' }, + 'DUAL THREAT': { c: '#1EA6D8', d: 'Scoring + playmaking hybrid', g: 'twin', legacy: 'COMBO GUARD' }, CONNECTOR: { c: '#9499A8', d: 'Low-usage specialist', g: 'chain', legacy: 'ROLE GLUE' }, FASTBREAK: { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons', legacy: 'TRANSITION ENGINE' }, 'PAINT BOSS': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup', legacy: 'POST SCORER' }, @@ -56,21 +56,21 @@ const ARCHETYPE_MAP = { // WNBA-unique DISTRIBUTOR: { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node', legacy: 'POST FACILITATOR' }, SHIELD: { c: '#A78BFA', d: 'Two-way perimeter forward', g: 'shieldCheck', legacy: 'TWO-WAY WING' }, - RANGE: { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target', legacy: 'STRETCH FORWARD' }, + RANGE: { c: '#159AAE', d: 'Floor-spacing forward', g: 'target', legacy: 'STRETCH FORWARD' }, SPARK: { c: '#FB923C', d: 'Downhill scoring guard', g: 'slash', legacy: 'SLASHING GUARD' }, ANCHOR: { c: '#6366F1', d: 'Dominant paint defender and rebounder', g: 'shield', legacy: 'INTERIOR ANCHOR' }, // MLB BOMBER: { c: '#FF6B4A', d: 'Middle-of-the-order power producer', g: 'triangle', legacy: 'POWER SLUGGER' }, - BRUSH: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair', legacy: 'CONTACT' }, + BRUSH: { c: '#C0C63C', d: 'High average, low strikeout', g: 'crosshair', legacy: 'CONTACT' }, DRIVER: { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond', legacy: 'RUN PRODUCER' }, ALPHA: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star', legacy: 'ACE' }, WHIFF: { c: '#FFB347', d: 'Bat-missing arm, high K with traffic', g: 'bolt', legacy: 'STRIKEOUT ARTIST' }, - GHOST: { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons', legacy: 'SPEED THREAT' }, + GHOST: { c: '#159AAE', d: 'Stolen bases, speed score', g: 'chevrons', legacy: 'SPEED THREAT' }, HYBRID: { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half', legacy: 'TWO-WAY PLAYER' }, FLEX: { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus', legacy: 'UTILITY PLAYER' }, WORKHORSE: { c: '#818CF8', d: 'Durable, deep-start arm', g: 'clock', legacy: 'INNINGS EATER' }, CATALYST: { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine', legacy: 'TABLE SETTER' }, - MIRROR: { c: '#34D399', d: 'Gap-to-gap line-drive bat', g: 'uparrow', legacy: 'GAP HITTER' }, + MIRROR: { c: '#8FC04A', d: 'Gap-to-gap line-drive bat', g: 'uparrow', legacy: 'GAP HITTER' }, HAMMER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock', legacy: 'CLOSER' }, SINKER: { c: '#FBBF24', d: 'Groundball spot-starter / long relief', g: 'swap', legacy: 'SWINGMAN' }, BRIDGE: { c: '#6366F1', d: 'Setup / high-leverage middle relief', g: 'shieldCheck', legacy: 'BULLPEN ARM' }, @@ -79,8 +79,8 @@ const ARCHETYPE_MAP = { FINISHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair', legacy: 'POACHER' }, MAESTRO: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node', legacy: 'CREATOR' }, TOWER: { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle', legacy: 'TARGET MAN' }, - MOTOR: { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons', legacy: 'BOX-TO-BOX' }, - BLADE: { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash', legacy: 'WING WIZARD' }, + MOTOR: { c: '#1EA6D8', d: 'All-action central midfielder', g: 'chevrons', legacy: 'BOX-TO-BOX' }, + BLADE: { c: '#159AAE', d: 'Dribbling wide threat', g: 'slash', legacy: 'WING WIZARD' }, WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' }, }; diff --git a/web/src/lib/colorContract.js b/web/src/lib/colorContract.js new file mode 100644 index 0000000..3c173de --- /dev/null +++ b/web/src/lib/colorContract.js @@ -0,0 +1,109 @@ +/* ============================================================ + VYNDR 2.0 — THE COLOR CONTRACT (DS3, DESIGN-SPEC Part 1). + Plain CommonJS so .tsx components import it AND the Jest suite + requires it directly (same pattern as vyndrTokens.js / archetypes.js). + + ONE law, enforced in one place, tested in colorContract.test.js: + - Signal-green (#00D4A0 / var(--g-a)) means exactly ONE thing: + edge / active / A-tier / primary CTA. Never decorative, never an + archetype hue, never a green rendered on a NEGATIVE number. + - Edge / CLV / delta is colored by SIGN: positive = signal-green, + negative = muted red (var(--miss)), zero = neutral (no edge). + - Grades are colored by TIER (A/A+ green, B blue, C amber, D/F red). + - GLOW is the scarcest cue in the system: A / A+ ONLY. + ============================================================ */ + +const SIGNAL_GREEN = '#00D4A0'; + +/* Archetype hues must clear this CIE76 ΔE from SIGNAL_GREEN so that + #00D4A0 stays uniquely "edge" and is never diluted by an archetype + chip. 20 sits above the ~10 "clearly a different color" boundary. */ +const MIN_ARCHETYPE_DELTA_E = 20; + +/* Sign → token. Positive edge is the ONE green meaning; negative is + muted red; a zero (or non-numeric) edge is no edge → neutral. */ +const EDGE_POSITIVE = 'var(--g-a)'; +const EDGE_NEGATIVE = 'var(--miss)'; +const EDGE_NEUTRAL = 'var(--text-2)'; + +/** + * edgeColor(value) — the single source of truth for coloring any + * edge / CLV / delta figure by SIGN. A -33.3% edge can NEVER render + * green (audit #3). Accepts number or numeric string; null/NaN/0 → neutral. + */ +function edgeColor(value) { + const n = typeof value === 'number' ? value : parseFloat(value); + if (!Number.isFinite(n) || n === 0) return EDGE_NEUTRAL; + return n > 0 ? EDGE_POSITIVE : EDGE_NEGATIVE; +} + +/* Grade → tier color. Mirrors vyndrTokens.gradeColor for the shared + keys (a test locks the two in sync) and extends F → red. */ +const TIER_COLORS = { + 'A+': 'var(--g-ap)', A: 'var(--g-a)', 'A-': 'var(--g-a)', + 'B+': 'var(--g-b)', B: 'var(--g-b)', 'B-': 'var(--g-b)', + C: 'var(--g-c)', D: 'var(--g-d)', F: 'var(--g-d)', +}; + +function normalizeGrade(grade) { + return (grade == null ? '' : String(grade)).trim().toUpperCase(); +} + +/** gradeTierColor(grade) — A/A+ green, B blue, C amber, D/F red. */ +function gradeTierColor(grade) { + return TIER_COLORS[normalizeGrade(grade)] || 'var(--text-0)'; +} + +/** + * gradeGlows(grade) — GLOW = A / A+ ONLY (audit #3: a glowing C + * devalues the cue). A- is NOT a glow tier. Every phosphor/textShadow/ + * boxShadow glow on a grade element must gate through this. + */ +function gradeGlows(grade) { + const g = normalizeGrade(grade); + return g === 'A+' || g === 'A'; +} + +/* ---- perceptual color distance (CIE76 in CIE-Lab) ---------------- */ +function hexToRgb(hex) { + const h = String(hex).replace('#', ''); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} +function srgbToLinear(c) { + c /= 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} +function hexToLab(hex) { + const [r, g, b] = hexToRgb(hex).map(srgbToLinear); + let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; + let y = r * 0.2126 + g * 0.7152 + b * 0.0722; + let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; + const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const fx = f(x), fy = f(y), fz = f(z); + return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; +} +/** deltaE(a, b) — CIE76 perceptual distance between two hex colors. */ +function deltaE(a, b) { + const A = hexToLab(a), B = hexToLab(b); + return Math.sqrt((A[0] - B[0]) ** 2 + (A[1] - B[1]) ** 2 + (A[2] - B[2]) ** 2); +} + +/** isSignalGreen(hex) — true when a hue is close enough to the signal + * green that it would dilute the "edge" meaning (archetype dedup gate). */ +function isSignalGreen(hex) { + return deltaE(hex, SIGNAL_GREEN) < MIN_ARCHETYPE_DELTA_E; +} + +module.exports = { + SIGNAL_GREEN, + MIN_ARCHETYPE_DELTA_E, + EDGE_POSITIVE, + EDGE_NEGATIVE, + EDGE_NEUTRAL, + TIER_COLORS, + edgeColor, + gradeTierColor, + gradeGlows, + deltaE, + isSignalGreen, +}; From bc5285d9e7e9e90e5fb643446fe4fcf63af2ff5f Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 19:45:07 -0400 Subject: [PATCH 08/12] DS3+DS4 reconcile: edge coloring via the centralized colorContract enforcer Both sessions fixed the negative-edge-green bug; kept DS3's centralized colorContract.edgeColor helper (removed DS4's local const), updated DS4's source-assertion test to match. Same semantics, one enforcer. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/ds4Billboards.test.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/unit/ds4Billboards.test.js b/tests/unit/ds4Billboards.test.js index ddae892..6cc20d3 100644 --- a/tests/unit/ds4Billboards.test.js +++ b/tests/unit/ds4Billboards.test.js @@ -107,11 +107,13 @@ describe('GradeResultCard — the letter is hero, edge is sign-colored', () => { const src = read('components/vyndr/GradeResultCard.tsx'); test('a negative edge NEVER renders green — negative = var(--miss)', () => { - expect(src).toMatch(/edgeColor\s*=\s*d\.edge != null && d\.edge < 0 \? 'var\(--miss\)' : 'var\(--g-a\)'/); - // both the confidence strip and the projection row use edgeColor, not a - // hardcoded green. - expect(src).toContain('color: edgeColor'); - expect(src).toMatch(/d\.edge != null \? edgeColor : 'var\(--text-2\)'/); + // 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)', () => { From cf91c04e909337b0dc0631b39d3b883bf357c859 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 23:41:13 -0400 Subject: [PATCH 09/12] DS1 follow-up: close the sb-token trust-bug class across all surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth-only 'sb-token' localStorage key was read by profile, slip, dashboard (recent-scans), settings, and tracker for their authenticated fetches. Email/password users never had that key, so those fetches sent no Authorization header and silently returned nothing. - web/src/lib/authToken.js — currentAccessToken() reads the REAL Supabase session (sb--auth-token, v2 top-level or v1 currentSession), legacy fallback. CommonJS so Jest can unit-test it (5 tests). - Swept all 5 pages to the helper (scan already session-first from DS1). - lib/api.ts (0 callers) + ParlayTray (unmounted) left as dead code. 236 suites / 2842 tests green, next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/authToken.test.js | 39 ++++++++++++++++++++++++++++++++ web/src/app/dashboard/page.tsx | 3 ++- web/src/app/profile/page.tsx | 5 +++-- web/src/app/settings/page.tsx | 3 ++- web/src/app/slip/page.tsx | 5 +++-- web/src/app/tracker/page.tsx | 3 ++- web/src/lib/authToken.js | 41 ++++++++++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/unit/authToken.test.js create mode 100644 web/src/lib/authToken.js 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/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index c278038..76c4460 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -15,6 +15,7 @@ import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; import { emptyStateCopy } from '@/lib/emptyState'; // Session 59 (work-order 2.3) — the real pipeline schedule for waiting states. import { nextRunLabelET } from '@/lib/pipelineSchedule'; +import { currentAccessToken } from '@/lib/authToken'; type Sport = 'NBA' | 'MLB' | 'WNBA'; @@ -170,7 +171,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}` } : {}, }) 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/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/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/lib/authToken.js b/web/src/lib/authToken.js new file mode 100644 index 0000000..19237c8 --- /dev/null +++ b/web/src/lib/authToken.js @@ -0,0 +1,41 @@ +/** + * currentAccessToken (DS1 follow-up) — the bearer token for authenticated + * browser fetches, robust across EVERY sign-in method. + * + * The bug this closes: surfaces read `localStorage.getItem('sb-token')`, but + * that key is written ONLY by the OAuth callback. Email/password users (the + * majority) had no `sb-token`, so their authenticated fetches (profile stats, + * tracker, slip, settings) sent no Authorization header and silently returned + * nothing. DS1 fixed the scan→ledger path; this helper fixes the rest. + * + * Supabase-js persists the session as JSON under `sb--auth-token` + * with `access_token` at the top level (v2) or under `currentSession` (v1). + * We read that directly (best-effort, synchronous), falling back to the legacy + * key. Prefer `useAuth().session?.access_token` in components that already have + * it; this helper is for the ones that don't. + * + * CommonJS (like colorContract.js / clvDisplay.js) so the plain-JS Jest suite + * can require it AND the .tsx app can import it (Next allowJs). + * + * @returns {string|null} + */ +function currentAccessToken() { + if (typeof window === 'undefined') return null; + try { + for (let i = 0; i < window.localStorage.length; i += 1) { + const k = window.localStorage.key(i) || ''; + if (/^sb-.*-auth-token$/.test(k)) { + const raw = window.localStorage.getItem(k); + if (!raw) continue; + const v = JSON.parse(raw); + const tok = (v && v.access_token) || (v && v.currentSession && v.currentSession.access_token); + if (tok) return tok; + } + } + } catch { + /* fall through to the legacy key */ + } + return window.localStorage.getItem('sb-token'); +} + +module.exports = { currentAccessToken }; From fe294a5de398a14dda683a15baec3b51af0649c4 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 00:07:06 -0400 Subject: [PATCH 10/12] =?UTF-8?q?DS2:=20Dashboard=20Slate=20Rebuild=20?= =?UTF-8?q?=E2=80=94=20one=20hero,=20pending=20collapse,=20never-empty=20h?= =?UTF-8?q?ero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN-SPEC Parts 3 + 6 (audit #1, #13, #14). The founder's named #1 rebuild. slateAdapter.js — the testable engine: - selectTopGrades: rank tonight's grades by tier → confidence → edge so the row varies on a real signal, not identical-weight noise (#13). - buildHeroReceipts: yesterday's PROVEN A-tier settled HITS (misses excluded), carrying the real result — the never-empty proof source (#1, Part 6). - heroFallbackState: tonight wins, else receipts, else empty. - pendingSummary: collapse an all-awaiting card's six "Grades post …" rows to ONE line count (#14). - topReadForCard: the single best live graded read to promote (#2). GameCard.tsx — ONE bold hero per card (large mono/tabular grade + player, rest demoted); all-awaiting cards render one "N props pending · grade ~X ET" line via nextRunLabelET instead of repeated filler. Real team logos + team-colored accent already lead the card (DS0) — preserved. dashboard/page.tsx — Top grades tonight ranked via selectTopGrades (+ % CONF the varying signal); when tonight is empty, fetch /api/ledger/model and fall back to yesterday's PROVEN A-tier receipts (✓ HIT + actual + CLV) so first paint always proves the model. Honest nextRunLabelET copy kept for the truly-empty case (QA.22). Tests: tests/unit/ds2Dashboard.test.js (21) — pure-fn + source assertions, fail-before / pass-after. Full suite 237 suites / 2863 tests green (+21). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/ds2Dashboard.test.js | 194 ++++++++++++++++++++++++ web/src/app/dashboard/page.tsx | 206 ++++++++++++++++++++------ web/src/components/vyndr/GameCard.tsx | 39 ++++- web/src/lib/slateAdapter.js | 140 +++++++++++++++++ 4 files changed, 531 insertions(+), 48 deletions(-) create mode 100644 tests/unit/ds2Dashboard.test.js 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/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 76c4460..9e2f603 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -15,6 +15,11 @@ import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; 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'; @@ -61,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; @@ -86,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. @@ -127,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: [] })), @@ -163,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') @@ -253,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/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index c80c053..ef68c2a 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -10,6 +10,8 @@ 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 { @@ -182,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( @@ -321,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) => ( { + const n = typeof v === 'number' ? v : parseFloat(v); + return Number.isFinite(n) ? n : fallback; +}; + +/** + * #13 — rank tonight's grades by TIER, then the VARYING signal so a leaderboard + * of near-identical rows stops being noise: confidence desc, then |edge| desc, + * stable by input order. Drops gradeless rows. Returns at most `limit`. + */ +function selectTopGrades(grades, limit = 10) { + const arr = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade); + const scored = arr.map((g, idx) => ({ + g, + idx, + rank: gradeRankOf(g.grade), + conf: numOr(g.confidence, -1), + edge: Math.abs(numOr(g.edge, -Infinity)), + })); + scored.sort((a, b) => a.rank - b.rank || b.conf - a.conf || b.edge - a.edge || a.idx - b.idx); + return scored.slice(0, Math.max(0, limit)).map((s) => s.g); +} + +/** + * #1 / Part 6 — build PROVEN receipts from settled ledger rows: only A-tier + * grades that HIT (a miss never becomes proof). Carries the real result so the + * receipt is verifiable. Best-grade first. `settledRows` = /api/ledger/model + * entries (player_name, stat, line, side, grade, outcome, actual_value, …). + */ +function buildHeroReceipts(settledRows, limit = 8) { + const rows = (Array.isArray(settledRows) ? settledRows : []).filter( + (r) => r && String(r.outcome || '').toLowerCase() === 'hit' && gradeRankOf(r.grade) <= 2, + ); + const mapped = rows.map((r) => ({ + player: displayName(r.player_name || r.player || ''), + stat: r.stat, + line: r.line, + side: String(r.side || 'over'), + grade: r.grade, + sport: String(r.sport || '').toUpperCase(), + outcome: 'hit', + actual: r.actual_value != null ? r.actual_value : null, + clvResult: r.clv_result || null, + })); + mapped.sort((a, b) => gradeRankOf(a.grade) - gradeRankOf(b.grade)); + return mapped.slice(0, Math.max(0, limit)); +} + +/** + * #1 / Part 6 — the never-empty hero engine. Tonight's ranked top grades win; + * when tonight is empty, fall back to yesterday's PROVEN A-tier receipts; only + * `empty` when both are absent. First paint ALWAYS proves the model when any + * settled proof exists. + */ +function heroFallbackState(tonightGrades, settledRows, limit = 10) { + const tonight = selectTopGrades(tonightGrades, limit); + if (tonight.length > 0) return { mode: 'tonight', items: tonight }; + const receipts = buildHeroReceipts(settledRows, Math.min(limit, 8)); + if (receipts.length > 0) return { mode: 'receipts', items: receipts }; + return { mode: 'empty', items: [] }; +} + +/** + * #14 — collapse the dead per-prop "Grades post …" repetition. When a card has + * NO graded reads yet (every prop awaiting), returns a single summary + * ({ count, players, statLabels }) so the UI renders ONE compact line instead + * of six identical rows. Any graded prop present → null (there are real reads + * to show). No awaiting props → null. + */ +function pendingSummary(strips) { + const list = Array.isArray(strips) ? strips : []; + let awaiting = 0; + let graded = 0; + const players = new Set(); + const statLabels = []; + for (const s of list) { + for (const p of s.props || []) { + if (p.grade) graded += 1; + else if (p.awaiting) { + awaiting += 1; + players.add(s.player); + if (statLabels.length < 6) statLabels.push(`${p.stat} ${p.line}`); + } + } + } + if (graded > 0 || awaiting === 0) return null; + return { count: awaiting, players: players.size, statLabels }; +} + +/** + * #2 — the ONE bold hero per card: the single highest-tier LIVE graded prop + * (dead/not-in reads never lead). Returns { player, team, archetype, stat, + * line, side, grade } or null when the card has no graded reads. + */ +function topReadForCard(strips) { + let best = null; + for (const s of Array.isArray(strips) ? strips : []) { + for (const p of s.props || []) { + if (!p.grade || p.dead) continue; + const rank = gradeRankOf(p.grade); + if (!best || rank < best.rank) { + best = { + rank, + player: s.player, + team: s.team || '', + archetype: s.archetype || null, + stat: p.stat, + line: p.line, + side: p.side || 'O', + grade: p.grade, + }; + } + } + } + if (!best) return null; + const { rank, ...rest } = best; // eslint-disable-line no-unused-vars + return rest; +} + // ── MLB probable pitchers (Session 46) ────────────────────────────── const teamToken = (name) => String(name == null ? '' : name).toLowerCase().replace(/[^a-z0-9 ]/g, '').trim(); const teamMascot = (name) => { const t = teamToken(name).split(' ').filter(Boolean); return t.length ? t[t.length - 1] : ''; }; @@ -468,4 +601,11 @@ module.exports = { buildPlayerStripsFromProps, buildPitcherMap, pitchersForGameTeams, + // DS2 — dashboard rebuild engine. + gradeRankOf, + selectTopGrades, + buildHeroReceipts, + heroFallbackState, + pendingSummary, + topReadForCard, }; From a18a3f33fc5dab6d49482d03471e8f04ee93aced Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 00:31:35 -0400 Subject: [PATCH 11/12] =?UTF-8?q?DS5=20=E2=80=94=20Pricing=20(Desk-as-hero?= =?UTF-8?q?)=20+=20Motion=20+=20Empty/Error=20+=20archetype=20glyphs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 6 #8 — Desk $44.99 is the hero tier. New DeskShowcase leads the pricing page with the "$1M terminal · $44.99" story + real feature visuals (alt-line ladder, quarter-Kelly, parlay φ, real-time feed) in a balanced two-column layout (kills the dead right-half). Desk is the sole highlighted tier / single primary CTA (color contract #9); Analyst is secondary. Real prices: Free 5 scans, Analyst $14.99/$19.99, Desk $34.99/$44.99. ClaimMeter + Stripe checkout wiring untouched. Part 4 #7 — Ticker → punctuated stillness. The continuous marquee is retired; the ticker now RESTS ≥4s on each ranked item and pulses only on change. The EKG heartbeat is a static readout — the header's ONE idle proof-of-life is the single SIGNAL-LIVE live-dot (the ticker dropped its competing pulse). All durations tokenized (--motion-*, --ticker-hold); prefers-reduced-motion kills the motion entirely. Part 8 #20 — new EmptyState component modeled on the north-star 404 (scanlines + glitch wordmark + amber system voice + CTA hierarchy), reused at the bare-red "Team not found", "Game not found", and the ledger empties — one unified voice. Part 5 — archetype glyph+chip propagated to STREAKS rows + ledger rows (optional + self-hiding; absent beats fabricated); grade reveal already carries ArchetypeBlend. Tests: new tests/unit/ds5PricingStates.test.js (22) locks all four workstreams; updated teamHubUI + vyndrDesignSystem for the new surfaces. 237 suites / 2864 tests green; next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/ds5PricingStates.test.js | 194 ++++++++++++++++++++++++ tests/unit/teamHubUI.test.js | 11 +- tests/unit/vyndrDesignSystem.test.js | 9 +- web/src/app/game/[id]/page.tsx | 15 +- web/src/app/globals.css | 47 +++++- web/src/app/ledger/page.tsx | 51 ++++--- web/src/app/pricing/DeskShowcase.tsx | 96 ++++++++++++ web/src/app/pricing/page.tsx | 4 + web/src/app/team/[abbr]/TeamHub.tsx | 12 +- web/src/components/Pricing.tsx | 20 ++- web/src/components/StreaksPanel.tsx | 11 ++ web/src/components/vyndr/EmptyState.tsx | 110 ++++++++++++++ web/src/components/vyndr/Ticker.tsx | 124 ++++++++++----- web/src/components/vyndr/index.ts | 2 + 14 files changed, 620 insertions(+), 86 deletions(-) create mode 100644 tests/unit/ds5PricingStates.test.js create mode 100644 web/src/app/pricing/DeskShowcase.tsx create mode 100644 web/src/components/vyndr/EmptyState.tsx 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/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/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 db5a884..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. */ @@ -1078,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%); } @@ -1088,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 { @@ -1104,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; } } @@ -1124,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 { @@ -1189,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 dae5137..579bc8d 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; -import { Skeleton } from '@/components/vyndr'; +import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr'; import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; /** @@ -44,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 } @@ -352,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, ' ')}

@@ -369,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/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/team/[abbr]/TeamHub.tsx b/web/src/app/team/[abbr]/TeamHub.tsx index 9b247ac..16e7a1f 100644 --- a/web/src/app/team/[abbr]/TeamHub.tsx +++ b/web/src/app/team/[abbr]/TeamHub.tsx @@ -7,6 +7,7 @@ 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'; @@ -69,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 -
+ ); } 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 46a41ad..6c34236 100644 --- a/web/src/components/StreaksPanel.tsx +++ b/web/src/components/StreaksPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import GradeBadge from '@/components/vyndr/GradeBadge'; +import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import { type Tier } from '@/lib/tierGate'; /** @@ -31,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 { @@ -102,6 +106,13 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
{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}
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. */} +
+ + {wordmark && ( +
+ +
+ )} + +
+ {code} +
+ +

+ {title} +

+ + {message && ( +

+ {message} +

+ )} + + {actions.length > 0 && ( +
+ {actions.map((a) => ( + + {a.label} + + ))} +
+ )} + + {children} +
+ ); +} diff --git a/web/src/components/vyndr/Ticker.tsx b/web/src/components/vyndr/Ticker.tsx index da12554..c9fbb6c 100644 --- a/web/src/components/vyndr/Ticker.tsx +++ b/web/src/components/vyndr/Ticker.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; type TickerItem = { tag: string; @@ -31,20 +31,41 @@ const TAG_COLORS: Record = { // looping the same item reads as fake; hide the bar instead. const MIN_ITEMS = 4; +// DS5 (DESIGN-SPEC Part 4) — the ticker RESTS this long on each item so it can +// be READ. Mirrors the --ticker-hold token in globals.css (kept in sync; a unit +// test cross-checks both are ≥4000ms). No one-off duration literals elsewhere. +const TICKER_HOLD_MS = 4200; + /** - * Scrolling marquee (§5). Continuous `ticker-scroll`; content duplicated so the - * loop is seamless. Session 45 — polls /api/ticker for live snapshot exhaust - * (top grades, line moves, slate-scanned events). + * The ranked "top moves" strip (§5) — DS5 rebuild to PUNCTUATED STILLNESS. * - * Session 57 (Phase 0) — real items only: the hardcoded fallback feed is gone - * (callers pass []), and with fewer than MIN_ITEMS real items the bar renders - * nothing. Visibility is published as `--ticker-h` on so the fixed - * header stack (layout main padding, Slate sticky top) collapses with it. + * The old continuous marquee (ticker-scroll, motion at all times) is retired: + * constant motion reads cheap (Part 0 law #1). The ticker now RESTS on one + * ranked item for --ticker-hold (≥4s), advancing with a sub-200ms cross-fade + * and a change-only green pulse — the terminal reacting, then still. It is the + * only motion in the ticker; the header's single idle proof-of-life is the + * heartbeat's one live-dot (this bar no longer renders a competing pulse). + * + * Session 45 — still polls /api/ticker for live snapshot exhaust. Session 57 — + * real items only (callers pass []); so the fixed header stack collapses + * with it. prefers-reduced-motion → a static ranked list, zero motion. */ export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) { const [feed, setFeed] = useState(null); - // Session 55 — flash the LIVE dot when a fresh event slides in (breaking-news feel). - const [flash, setFlash] = useState(false); + const [idx, setIdx] = useState(0); + const [pulse, setPulse] = useState(false); + const [reduced, setReduced] = useState(false); + + // Respect prefers-reduced-motion: no rotation, no pulse — a static strip. + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return; + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + const apply = () => setReduced(mq.matches); + apply(); + mq.addEventListener?.('change', apply); + return () => mq.removeEventListener?.('change', apply); + }, []); useEffect(() => { if (!live) return; @@ -58,8 +79,11 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00 if (active && Array.isArray(data.items) && data.items.length > 0) { const head = `${data.items[0]?.tag}|${data.items[0]?.text}`; if (lastHead && head !== lastHead) { - setFlash(true); - setTimeout(() => { if (active) setFlash(false); }, 2500); + // A fresh head event — reset to the top and pulse ONCE (change-only, + // the DATA-UPDATE motion category, never idle). + setIdx(0); + setPulse(true); + setTimeout(() => { if (active) setPulse(false); }, 600); } lastHead = head; setFeed(data.items.map((it) => ({ ...it, color: it.color || TAG_COLORS[it.tag] || 'var(--amber)' }))); @@ -84,14 +108,35 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00 return () => { document.documentElement.style.setProperty('--ticker-h', '0px'); }; }, [visible, height]); + // The rotation: HOLD each item for TICKER_HOLD_MS, then advance. Disabled + // entirely under reduced motion or with a single item — punctuated stillness, + // not a metronome. + const len = display.length; + useEffect(() => { + if (reduced || len <= 1) return; + const id = setInterval(() => { + setIdx((i) => (i + 1) % len); + setPulse(true); + setTimeout(() => setPulse(false), 600); + }, TICKER_HOLD_MS); + return () => clearInterval(id); + }, [reduced, len]); + + const heldRef = useRef(idx); + heldRef.current = idx; if (!visible) return null; - const content = display.map((it, i) => ( + const renderItem = (it: TickerItem, key: number, rank?: number) => ( + {rank != null && ( + + {rank}/{len} + + )} {it.delta} )} - · - )); + ); + return (
-
- {content} - {content} + {/* Static "TOP MOVES" anchor — a label, not a pulsing dot. The single + idle proof-of-life lives in the heartbeat below (one live-dot). */} +
+ TOP MOVES
- {/* Session 55 — anchored LIVE badge (chrome, not data → may pulse). */} - {live && ( + + {reduced ? ( + // Reduced motion — the full ranked list, static, no animation at all. +
+ {display.map((it, i) => renderItem(it, i))} +
+ ) : ( + // Resting rotator — ONE item held for ≥4s, change-only cross-fade + pulse.
- - LIVE + {renderItem(display[idx % len], idx, (idx % len) + 1)}
)} -
-
+ +
); } diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 57cb2bd..082db2b 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -8,6 +8,8 @@ export { default as VBtn } from './VBtn'; export { default as Card } from './Card'; export { default as Sparkline } from './Sparkline'; export { default as Ticker } from './Ticker'; +export { default as EmptyState } from './EmptyState'; +export type { EmptyStateProps, EmptyStateAction } from './EmptyState'; export { default as GradeResultCard } from './GradeResultCard'; export type { GradeResultData } from './GradeResultCard'; export { default as ProcessingGrade } from './ProcessingGrade'; From 2394ba1b849ac65d4574835ea8d1e5618c554790 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 00:33:55 -0400 Subject: [PATCH 12/12] STATE.md: design train v2 complete (DS0-DS5), ready to merge Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/STATE.md | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/specs/STATE.md b/specs/STATE.md index f48f8ac..10c5992 100644 --- a/specs/STATE.md +++ b/specs/STATE.md @@ -1,18 +1,37 @@ # VYNDR — STATE OF THE WORLD ### As of `ec5ee3d` (main, 2026-07-12). This file opens every future session. Update it when a train ships. -## IN FLIGHT — DESIGN TRAIN v2 (branch `design/v2-train`, NOT on main yet) +## 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). Sessions: -- **DS0 Entity Layer — DONE** (`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+bugs · DS3 color contract · DS4 billboards** — building (Wave 1). -- **DS2 dashboard slate rebuild · DS5 pricing/motion/states** — Wave 2 (queued). +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 + curl + tests, not pixel screenshots. Nothing pushed to main -without the founder's "merge the train". +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.