Merge the design train v2 (DS0-DS5) into main

The 6-session presentation overhaul: entity layer (real logos/headshots/
wordmarks), speed+trust bugs (React #418, skeletons, sb-token class),
color contract (green=one meaning, enforced by tests), billboards (STREAKS/
grade reveal/CLV/\u profile), dashboard slate rebuild (never-empty hero),
pricing Desk-as-hero + ticker stillness + unified empty/error + archetype glyphs.

238 suites / 2885 tests green, next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 00:44:54 -04:00
51 changed files with 3124 additions and 297 deletions
+84
View File
@@ -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 4060% 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 —
+34
View File
@@ -1,6 +1,40 @@
# VYNDR — STATE OF THE WORLD
### As of `ec5ee3d` (main, 2026-07-12). This file opens every future session. Update it when a train ships.
## READY TO MERGE — DESIGN TRAIN v2 (branch `design/v2-train` @ `3d08390`, NOT on main yet)
Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as
themselves, color contract, sub-200ms, screenshot-first billboards). **All six
sessions DS0DS5 are DONE, merged onto `design/v2-train`, green
(238 suites / 2885 tests) + `next build` exit 0. Awaiting the founder's
"merge the train" before touching main.**
- **DS0 Entity Layer** (`24af247`): `teamMeta.js` (real logos+colors for 30 MLB /
30 NBA / 13 WNBA / 48 WC nations via ESPN CDNs), `TeamLogo` / `PlayerAvatar`
(team-colored monogram fallback, no gray silhouette) / `BookWordmark`, swapped
into GameCard/StatStrip/StreaksPanel/TeamHub.
- **DS1 Speed + Trust Bugs** (`1c681df`): React #418 hydration fix (mounted-flag
guard), layout-matched `Skeleton`s replace text-wall loaders, scan→ledger
persistence via `session.access_token`. **DS1 follow-up** (`cf91c04`): closed
the sb-token trust-bug CLASS — `lib/authToken.js` `currentAccessToken()` reads
the REAL Supabase session (not the OAuth-only `sb-token` key) across
profile/slip/dashboard/settings/tracker.
- **DS3 Color Contract** (`49a3323`): `lib/colorContract.js` — green = one meaning
(edge/A-tier/CTA), edge/CLV by sign, glow A/A+ only, archetype greens deduped.
Enforced by `colorContract.test.js` (fails on violation).
- **DS4 Billboards** (`45bafbc`): STREAKS row (length as mono hero), grade reveal
(sign-colored edge), CLV reframe (flat says so), `/u` public profile + OG.
- **DS2 Dashboard Slate Rebuild** (`fe294a5`): never-empty hero (falls back to
yesterday's A-tier settled receipts), one bold hero per card, pending-filler
collapse, ranked tonight-grades. `slateAdapter.js` engine + 21 tests.
- **DS5 Pricing + Motion + States** (`a18a3f3`): Desk $44.99 as the hero tier
(single green CTA, `DeskShowcase`), ticker → punctuated stillness (≥4.2s hold,
one idle motion), unified `EmptyState` (404 grammar) at TeamHub/game/ledger,
archetype glyphs propagated to streaks/ledger.
NOTE: WSL2↔Windows-Chrome localhost is unreachable in this env, so visual proof
is SSR-render + source-assertion tests, not pixel screenshots. Post-merge TODO:
re-run the Chrome design audit against v2 to confirm the 22 findings closed.
## WHERE THINGS STAND
**Tests: 2757 / 229 suites, all green. Web build exit 0.** Baseline at the A1
+8 -8
View File
@@ -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.',
+39
View File
@@ -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();
});
});
+152
View File
@@ -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)'");
});
});
+96
View File
@@ -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<boolean>\(\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',
/<p[^>]*>\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();
});
});
+194
View File
@@ -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('<TeamLogo');
expect(src).toContain('accentColor(g.home.abbr');
expect(src).toContain('borderLeft');
});
test('promotes ONE bold hero read via topReadForCard (large mono/tabular grade)', () => {
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');
});
});
+182
View File
@@ -0,0 +1,182 @@
// DS4 (Design v2) — the BILLBOARDS. Screenshot-first surfaces (the timeline is
// customer #1): the STREAKS row, the grade reveal, the CLV reframe, and the /u
// public profile. The .tsx surfaces are asserted against source (plain-JS Jest,
// no TS transform) — same pattern as publicProfilePage / vyndrParityQA. The
// CLV flat-vs-spread decision is a pure fn and is exercised directly.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const WEB = path.join(ROOT, 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
// ── 1. CLV reframe — the pure flat/spread decision (#16) ──────────────────
describe('clvDisplay — flat CLV says so, spread shows bars', () => {
const { clvMode, isFlatClv, CLV_FLAT_LINE } = require('../../web/src/lib/clvDisplay');
test('near-flat (73/74 at the close) is FLAT, not a broken histogram', () => {
const dist = [
{ label: '[-.5,0)', side: 'faded', count: 0 },
{ label: '0', side: 'flat', count: 73 },
{ label: '(0,.5]', side: 'beat', count: 1 },
];
expect(clvMode(dist).mode).toBe('flat');
expect(isFlatClv(dist)).toBe(true);
});
test('real dispersion is SPREAD (the bars are meaningful)', () => {
const dist = [
{ label: '[-1,-.5)', side: 'faded', count: 8 },
{ label: '[-.5,0)', side: 'faded', count: 6 },
{ label: '0', side: 'flat', count: 5 },
{ label: '(0,.5]', side: 'beat', count: 9 },
{ label: '(.5,1]', side: 'beat', count: 12 },
];
expect(clvMode(dist).mode).toBe('spread');
expect(isFlatClv(dist)).toBe(false);
});
test('null / empty / all-zero → none (renders nothing, never errored)', () => {
expect(clvMode(null).mode).toBe('none');
expect(clvMode([]).mode).toBe('none');
expect(clvMode([{ side: 'flat', count: 0 }, { side: 'beat', count: 0 }]).mode).toBe('none');
});
test('the flat line is confident + VOICE-compliant (no jargon, no hype)', () => {
expect(CLV_FLAT_LINE).toMatch(/grade the outcome, not the close/);
expect(CLV_FLAT_LINE).not.toMatch(/!/);
expect(CLV_FLAT_LINE.toLowerCase()).not.toMatch(/bayesian|regression|guarantee/);
});
test('the ledger MODEL tab renders the flat line, not a histogram, when flat', () => {
const src = read('app/ledger/page.tsx');
expect(src).toContain("import { clvMode, CLV_FLAT_LINE }");
expect(src).toMatch(/mode === 'flat'/);
expect(src).toContain('CLV_FLAT_LINE');
// bars still exist for the spread branch
expect(src).toContain('CLV DISTRIBUTION');
});
});
// ── 2. STREAKS row — the Bloomberg alert (#2) ─────────────────────────────
describe('StreaksPanel — one hero figure, real identity, one accent', () => {
const src = read('components/StreaksPanel.tsx');
test('uses the real PlayerAvatar entity (not a bare <img>)', () => {
expect(src).toContain('import PlayerAvatar');
expect(src).toContain('<PlayerAvatar');
expect(src).not.toMatch(/<img\b/);
});
test('the streak LENGTH is the hero — the largest font in the row', () => {
// heroNum renders currentStreak and is the biggest fontSize; the player
// name is demoted well below it.
expect(src).toMatch(/heroNum[\s\S]*fontSize:\s*38/);
expect(src).toContain('{s.currentStreak}');
const heroSize = Number((src.match(/heroNum:[^}]*fontSize:\s*(\d+)/) || [])[1]);
const nameSize = Number((src.match(/playerName:[^}]*fontSize:\s*(\d+)/) || [])[1]);
expect(heroSize).toBeGreaterThan(nameSize);
expect(heroSize).toBeGreaterThanOrEqual(32);
});
test('the hero number is mono + tabular (data grammar)', () => {
expect(src).toMatch(/heroNum:[\s\S]*fontVariantNumeric:\s*'tabular-nums'/);
});
test('carries the muted "built vs" lens context', () => {
expect(src).toContain('built vs');
expect(src).toContain('builtVs');
});
test('ONE severity accent — step-up amber / step-down green', () => {
expect(src).toContain("diff === 'step up'");
expect(src).toContain('var(--amber');
expect(src).toContain("diff === 'step down'");
expect(src).toContain('var(--g-a');
});
test('the grade badge is tier-gated (the READ is the paid layer)', () => {
expect(src).toMatch(/tier === 'analyst' \|\| tier === 'desk'/);
expect(src).toContain('<GradeBadge');
});
});
// ── 3. Grade reveal — sign-colored edge (#3, the color contract) ──────────
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)', () => {
// DS3 superseded DS4's local const with the centralized colorContract
// enforcer — same semantics (negative → var(--miss)), now via the shared
// helper. Both the confidence strip and the projection row route edge
// through edgeColor(d.edge), never a hardcoded green.
expect(src).toMatch(/import \{ edgeColor.*\} from '@\/lib\/colorContract'/);
expect(src).toContain('color: edgeColor(d.edge)');
expect(src).toMatch(/d\.edge != null \? edgeColor\(d\.edge\) : 'var\(--text-2\)'/);
});
test('the grade LETTER is still the hero (largest element)', () => {
expect(src).toMatch(/grade-hero[\s\S]*fontSize: compact \? 92 : 116/);
});
test('the MODEL/LINE/EDGE row is mono + tabular (demoted data)', () => {
expect(src).toMatch(/fontSize: 19[\s\S]*fontVariantNumeric: 'tabular-nums'/);
});
test('reveal choreography is reduced-motion safe', () => {
const proc = read('components/vyndr/ProcessingGrade.tsx');
expect(proc).toContain("prefers-reduced-motion: reduce");
expect(proc).toMatch(/setPhase\('card'\)/);
});
});
// ── 4. /u public profile — the shareable record billboard (#4) ────────────
describe('PublicProfile — the record hero + CLV-verified badge', () => {
const src = read('app/u/[handle]/PublicProfile.tsx');
test('the hit rate is the bold hero figure (large mono tabular)', () => {
expect(src).toMatch(/fontSize: 56[\s\S]*fontVariantNumeric: 'tabular-nums'/);
expect(src).toContain('{agg.hit_pct}%');
expect(src).toContain('HIT RATE');
});
test('beat-close is the secondary bold figure', () => {
expect(src).toContain('{agg.beat_close_pct}%');
expect(src).toContain('BEAT CLOSE');
});
test('CLV-VERIFIED badge is HONEST — only when closing value is tracked', () => {
expect(src).toContain('✓ CLV-VERIFIED');
expect(src).toMatch(/clvVerified = Boolean\(ready && agg && agg\.beat_close_pct != null\)/);
});
test('still honors the n-gate: RECORD BUILDING under min_sample', () => {
expect(src).toContain('RECORD BUILDING');
expect(src).toMatch(/settled >= minSample/);
});
test('renders real player identity (PlayerAvatar entity)', () => {
expect(src).toContain('import PlayerAvatar');
expect(src).toContain('<PlayerAvatar');
});
});
// ── 5. /u OG image — the record at social crop ────────────────────────────
describe('/u opengraph-image — real record at 1200x630 social crop', () => {
const src = read('app/u/[handle]/opengraph-image.tsx');
test('social crop size is preserved (1200x630)', () => {
expect(src).toMatch(/size = \{ width: 1200, height: 630 \}/);
});
test('renders the real hit% / beat-close% when the record is published', () => {
expect(src).toContain('{rec.hit_pct}%');
expect(src).toContain('{rec.beat_close_pct}%');
expect(src).toContain('CLV-VERIFIED RECORD');
});
test('falls back gracefully to the tagline when there is no record', () => {
expect(src).toContain('every settled read · misses included');
});
});
+194
View File
@@ -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('<DeskShowcase');
expect(showcase).toContain('$1M terminal');
expect(showcase).toContain('$44.99'); // the conversion hook figure
expect(showcase).toContain('$34.99'); // the founder price
// real feature visuals fill the right half (kills the dead half, #8)
expect(showcase).toContain('ALT LINE LADDER');
expect(showcase).toContain('QUARTER-KELLY');
// the primary CTA scrolls to the grid where the real checkout lives
expect(showcase).toContain('href="#pricing"');
expect(showcase).toContain('btn-primary');
});
test('Stripe checkout wiring is untouched (checkoutUrl + POST /api/checkout)', () => {
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('<EmptyState');
expect(hub).toContain('code="TEAM NOT FOUND"');
// the bare-red string is gone
expect(hub).not.toContain('Team not found.');
});
test('reused at the "Game not found" offender', () => {
const game = read('app/game/[id]/page.tsx');
expect(game).toContain('<EmptyState');
expect(game).toContain('code="GAME NOT FOUND"');
});
test('reused for the ledger empty states (one voice, not a bespoke box)', () => {
const ledger = read('app/ledger/page.tsx');
expect(ledger).toContain('<EmptyState');
// the old bespoke "surface diagonal-cut tex-scan" empty box is replaced
expect(ledger).not.toContain('LEDGER EMPTY</p>');
});
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('<ArchetypeBlend');
});
test('STREAKS rows render the archetype badge (was plain text) — self-hiding', () => {
const panel = read('components/StreaksPanel.tsx');
expect(panel).toContain("import ArchetypeBadge");
expect(panel).toContain('<ArchetypeBadge');
expect(panel).toContain('s.archetype &&'); // optional + self-hiding
});
test('the ledger rows render the archetype badge (was plain text) — self-hiding', () => {
const ledger = read('app/ledger/page.tsx');
expect(ledger).toContain('ArchetypeBadge');
expect(ledger).toContain('<ArchetypeBadge');
expect(ledger).toContain('row.archetype &&');
});
test('the badge shows its one-line meaning where it leads (showDesc)', () => {
expect(read('components/StreaksPanel.tsx')).toContain('showDesc');
expect(read('app/ledger/page.tsx')).toContain('showDesc');
});
});
+74
View File
@@ -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);
});
});
+153
View File
@@ -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');
});
});
+7 -4
View File
@@ -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('<EmptyState');
expect(src).toContain('code="TEAM NOT FOUND"');
});
it('wires the parlay "+" on graded props', () => {
expect(src).toContain('useParlay');
+6 -3
View File
@@ -182,9 +182,12 @@ describe('Phase B — shared component contracts (§5)', () => {
expect(src).toContain('<path');
});
it('Ticker uses the ticker-track marquee and duplicates content for a seamless loop', () => {
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');
});
});
+5 -1
View File
@@ -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/);
});
});
+24 -29
View File
@@ -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);
}
+170 -51
View File
@@ -10,11 +10,17 @@ import { GradePill } from '@/components/GradeCard';
// below as intelligence layers on top of the raw odds.
import Slate from '@/components/Slate';
// Session 55 — the self-learning loop's track record, live in the header.
import { AccuracyBadge } from '@/components/vyndr';
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
import { emptyStateCopy } from '@/lib/emptyState';
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
import { nextRunLabelET } from '@/lib/pipelineSchedule';
// DS2 (Design v2) — the never-empty hero engine: rank tonight's grades on a
// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first
// paint ALWAYS proves the model (#1, #13, Part 6).
import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter';
import GradeBadge from '@/components/vyndr/GradeBadge';
import { currentAccessToken } from '@/lib/authToken';
type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -60,6 +66,32 @@ interface ParlayLegStat {
parlay_count: number;
}
// DS2 — a PROVEN receipt (yesterday's settled A-tier hit) for the never-empty hero.
interface HeroReceipt {
player: string;
stat: string;
line: number;
side: string;
grade: string;
sport: string;
outcome: string;
actual: number | null;
clvResult: string | null;
}
// The /api/ledger/model settled-row shape (subset we read for receipts).
interface ModelEntry {
player_name?: string;
sport?: string;
stat?: string;
line?: number;
side?: string;
grade?: string;
outcome?: string | null;
actual_value?: number | null;
clv_result?: string | null;
}
interface RecentScan {
id: string;
player_name: string;
@@ -85,6 +117,9 @@ export default function DashboardPage() {
const [sport, setSport] = useState<Sport>('NBA');
const [games, setGames] = useState<Game[] | null>(null);
const [topGrades, setTopGrades] = useState<TopGrade[] | null>(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<HeroReceipt[] | null>(null);
const [mostParlayed, setMostParlayed] = useState<ParlayLegStat[] | null>(null);
const [recentScans, setRecentScans] = useState<RecentScan[] | null>(null);
// Session 49 — the user's primary sport tab + preferred books from prefs.
@@ -126,6 +161,7 @@ export default function DashboardPage() {
let cancelled = false;
setGames(null);
setTopGrades(null);
setHeroReceipts(null);
Promise.all([
fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })),
@@ -162,6 +198,24 @@ export default function DashboardPage() {
};
}, [sport]);
// DS2 (#1, Part 6) — the NEVER-EMPTY hero fallback. When tonight's slate has
// no graded props yet, pull the model's most-recent settled reads and keep
// only the PROVEN A-tier hits (buildHeroReceipts). First paint always proves
// the model works (the real 55-19 / 74% record). Best-effort, self-hiding.
useEffect(() => {
if (topGrades === null || topGrades.length > 0) return; // tonight has grades → no fallback needed
let cancelled = false;
fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (cancelled) return;
const entries: ModelEntry[] = Array.isArray(data?.entries) ? data.entries : [];
setHeroReceipts(buildHeroReceipts(entries, 8) as HeroReceipt[]);
})
.catch(() => { if (!cancelled) setHeroReceipts([]); });
return () => { cancelled = true; };
}, [topGrades, sport]);
// Most parlayed + recent scans don't depend on sport
useEffect(() => {
fetch('/api/props/most-parlayed')
@@ -170,7 +224,7 @@ export default function DashboardPage() {
.catch(() => setMostParlayed([]));
if (user) {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const token = currentAccessToken();
fetch('/api/user/recent-scans', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
@@ -181,9 +235,15 @@ export default function DashboardPage() {
}, [user]);
if (authLoading || !user) {
// DS1 (§4) — layout-matched skeleton, never a text wall. Mirrors the real
// dashboard: header strip + a stack of slate cards.
return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the slate</p>
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '24px 16px 120px' }} aria-busy="true">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, gap: 16 }}>
<Skeleton height={30} width={220} radius={10} />
<Skeleton height={30} width={120} radius={999} />
</div>
<SkeletonList count={5} height={92} />
</section>
);
}
@@ -246,57 +306,116 @@ export default function DashboardPage() {
}}
/>
{/* Top grades horizontal scroll */}
<Section
title="Top grades tonight"
subtitle="The A-tier picks. Tap to read or add to parlay."
>
{topGrades === null ? (
<SkeletonRow />
) : topGrades.length === 0 ? (
// Session 59 (work-order 2.3) — the real schedule, not passive waiting.
<p style={emptyCopy}>No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.</p>
) : (
<div
style={{
display: 'flex',
gap: 12,
overflowX: 'auto',
padding: '4px 4px 12px',
scrollSnapType: 'x mandatory',
}}
{/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on
the varying signal (selectTopGrades: tier → confidence → edge) so the
row isn't identical-weight noise; when tonight is empty we fall back to
yesterday's PROVEN A-tier receipts so first paint ALWAYS proves the
model. Only the truly-empty case shows the honest schedule copy. */}
{(() => {
const ranked = topGrades ? selectTopGrades(topGrades, 10) as TopGrade[] : [];
const proofMode = topGrades !== null && ranked.length === 0 && (heroReceipts?.length ?? 0) > 0;
return (
<Section
title="Top grades tonight"
subtitle={proofMode ? 'No slate graded yet — the models most recent proven A-tier reads.' : 'The A-tier picks, ranked by confidence. Tap to read or add to parlay.'}
>
{topGrades.map((g, i) => (
<button
key={`${g.player}-${g.stat}-${i}`}
onClick={() => router.push(`/scan?sport=${g.sport}&player=${encodeURIComponent(g.player)}&stat=${g.stat}&line=${g.line}`)}
className="surface diagonal-cut surface-hover"
{topGrades === null ? (
<SkeletonRow />
) : ranked.length > 0 ? (
<div
style={{
minWidth: 200,
padding: 16,
textAlign: 'left',
scrollSnapAlign: 'start',
cursor: 'pointer',
border: '1px solid var(--border)',
borderRadius: 16,
background: 'var(--bg-surface)',
color: 'inherit',
fontFamily: 'inherit',
display: 'flex',
gap: 12,
overflowX: 'auto',
padding: '4px 4px 12px',
scrollSnapType: 'x mandatory',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<SportPill sport={g.sport} />
<GradePill grade={g.grade} />
</div>
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{g.player}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{g.direction} {g.line} {g.stat.replace(/_/g, ' ')}
</p>
</button>
))}
</div>
)}
</Section>
{ranked.map((g, i) => (
<button
key={`${g.player}-${g.stat}-${i}`}
onClick={() => router.push(`/scan?sport=${g.sport}&player=${encodeURIComponent(g.player)}&stat=${g.stat}&line=${g.line}`)}
className="surface diagonal-cut surface-hover"
style={{
minWidth: 200,
padding: 16,
textAlign: 'left',
scrollSnapAlign: 'start',
cursor: 'pointer',
border: '1px solid var(--border)',
borderRadius: 16,
background: 'var(--bg-surface)',
color: 'inherit',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<SportPill sport={g.sport} />
<GradePill grade={g.grade} />
</div>
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{g.player}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{g.direction} {g.line} {g.stat.replace(/_/g, ' ')}
</p>
{/* #13 — surface the VARYING signal so ranked rows read as a
real ranking, not identical noise. */}
{g.confidence != null && (
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.04em', marginTop: 6, fontVariantNumeric: 'tabular-nums' }}>
{g.confidence}% CONF
</p>
)}
</button>
))}
</div>
) : proofMode ? (
// The never-empty proof: yesterday's PROVEN A-tier receipts.
<div
style={{
display: 'flex',
gap: 12,
overflowX: 'auto',
padding: '4px 4px 12px',
scrollSnapType: 'x mandatory',
}}
>
{(heroReceipts || []).map((r, i) => (
<div
key={`${r.player}-${r.stat}-${i}`}
className="surface diagonal-cut"
style={{
minWidth: 210,
padding: 16,
scrollSnapAlign: 'start',
border: '1px solid var(--grade-a)',
borderRadius: 16,
background: 'var(--bg-surface)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--grade-a)' }}>YESTERDAY · PROVEN</span>
<GradeBadge grade={r.grade} size={30} glow />
</div>
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 4 }}>{r.player}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', fontVariantNumeric: 'tabular-nums' }}>
{String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')}
</p>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, color: 'var(--grade-a)', marginTop: 8, fontVariantNumeric: 'tabular-nums' }}>
&#10003; HIT{r.actual != null ? ` (${r.actual})` : ''}
{r.clvResult === 'beat' ? <span style={{ color: 'var(--text-tertiary)', fontWeight: 700 }}> · CLV BEAT</span> : null}
</p>
</div>
))}
</div>
) : heroReceipts === null ? (
// tonight empty, fallback still loading → skeleton, never a text wall.
<SkeletonRow />
) : (
// Truly nothing yet — the honest schedule-derived waiting copy (QA.22).
<p style={emptyCopy}>No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.</p>
)}
</Section>
);
})()}
{/* Tonight's games */}
<Section title={`Today's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} game${games?.length === 1 ? '' : 's'} today`}>
+13 -1
View File
@@ -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() {
</section>
);
}
if (status === 'loading') {
// DS1 (§4) — the pack is assembling; layout-matched skeleton, not a text
// wall. Mirrors the header + stack of FormatCards below.
return (
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 120px' }} aria-busy="true">
<Skeleton height={14} width={90} radius={4} style={{ marginBottom: 12 }} />
<Skeleton height={30} width={260} radius={8} style={{ marginBottom: 24 }} />
<SkeletonList count={4} height={120} />
</section>
);
}
if (status !== 'ready' || !pack) {
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}</p>
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>Pack unavailable. Check back after the first pipeline run.</p>
</section>
);
}
+8 -7
View File
@@ -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 (
<section style={{ maxWidth: 600, margin: '0 auto', padding: '64px 16px', textAlign: 'center' }}>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Game not found.</h1>
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>
That matchup isn&apos;t on tonight&apos;s slate. Maybe the line moved off the board.
</p>
<a href="/dashboard" className="btn-primary">Back to slate</a>
</section>
<EmptyState
code="GAME NOT FOUND"
title="That matchup isn't on the board."
message="It isn't on tonight's slate — maybe the line moved off the board. The Slate has what's live now."
actions={[{ label: '← Back to the Slate', href: '/dashboard', primary: true }]}
/>
);
}
+61 -2
View File
@@ -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 <html>).
Literal family names kept as fallbacks for any non-next/font context. */
@@ -401,6 +411,22 @@ h1, h2, h3, h4, h5, h6 {
animation: shimmer 1.5s linear infinite;
}
/* DS1 (§4 — kill loading walls) — the ONE skeleton primitive. Layout-matched
dark shimmer blocks replace every text-wall loader. Tokenized (no one-off
CSS); the global prefers-reduced-motion rule freezes the sweep to a static,
still-visible surface — arrives-then-rests, honored globally. */
.vyndr-skeleton {
background: linear-gradient(
90deg,
var(--bg-surface) 0%,
var(--bg-surface-hover) 50%,
var(--bg-surface) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
border-radius: 8px;
}
/* S6 (A1 board, LCP) — floored at a VISIBLE state (.6), matching the S33
entrance-keyframe rule (a paused frame is never invisible). Starting at
opacity 0 also delayed the hero heading's first contentful paint — an
@@ -1062,7 +1088,11 @@ body.tex-grain::before {
}
.grade-reveal { animation: grade-reveal 0.42s cubic-bezier(.2,1.3,.4,1) both; }
/* ===================== TICKER ===================== */
/* ===================== TICKER (DS5 — punctuated stillness) =====================
The ticker RESTS. It no longer marquee-scrolls (constant motion reads cheap,
Part 0 law #1). It holds each item for --ticker-hold (≥4s) so it can be READ,
and the ONLY motion is a change-only cross-fade + a brief data-update pulse
when the item advances. The legacy .ticker-track marquee is retired. */
@keyframes ticker-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
@@ -1072,6 +1102,23 @@ body.tex-grain::before {
white-space: nowrap;
animation: ticker-scroll 38s linear infinite;
}
/* The resting strip — a static ranked row, no idle animation. */
.ticker-rest {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
/* Change-only cross-fade as the held item advances (sub-200ms, weighted). */
@keyframes ticker-swap { from { opacity: 0.55; } to { opacity: 1; } }
.ticker-item-enter { animation: ticker-swap var(--motion-transition) ease-out both; }
/* Change-only green pulse — the terminal reacting to a fresh head event. */
@keyframes ticker-pulse {
0% { background: color-mix(in srgb, var(--g-a) 22%, transparent); }
100% { background: transparent; }
}
.ticker-pulse { animation: ticker-pulse var(--motion-data) ease-out 1; }
/* ===================== LIVE PULSE ===================== */
.live-dot {
@@ -1088,6 +1135,14 @@ body.tex-grain::before {
100% { box-shadow: 0 0 0 0 rgba(255,59,59,0); }
}
/* ===================== DESK SHOWCASE (DS5 pricing hero) ===================== */
/* Balanced two-column at desktop (pitch | real feature visuals), single column
on mobile — kills the dead right-half of the old pricing page (#8). */
.desk-showcase { grid-template-columns: 1fr; }
@media (min-width: 900px) {
.desk-showcase { grid-template-columns: 1.05fr 1fr; }
}
/* ===================== LINE FLASH (odds cell changes) ===================== */
@keyframes flash-up { 0% { background: rgba(0,212,160,0.45); } 100% { background: transparent; } }
@keyframes flash-down { 0% { background: rgba(255,82,82,0.45); } 100% { background: transparent; } }
@@ -1108,7 +1163,10 @@ body.tex-grain::before {
/* ===================== LIVING LAYER (the brain) ===================== */
/* EKG heartbeat strip */
@keyframes ekg-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
.ekg-track { display: inline-flex; animation: ekg-scroll 6s linear infinite; }
/* DS5 — the EKG is now a STATIC readout trace (no idle scroll). The header's
ONE idle proof-of-life is the single SIGNAL-LIVE live-dot; the heartbeat
waveform is a frozen instrument face, not constant motion (Part 4). */
.ekg-track { display: inline-flex; }
/* Neural node pulse */
@keyframes node-pulse {
@@ -1173,6 +1231,7 @@ body.tex-grain::before {
/* Reduced-motion: kill the living layer + glitch chrome outright */
@media (prefers-reduced-motion: reduce) {
.wm, .wm::before, .wm::after, .ticker-track, .live-dot,
.ticker-item-enter, .ticker-pulse,
.intel-surface::after, .phosphor-cursor,
.ekg-track, .brain-node, .brain-link, .proc-scan, .data-blink { animation: none !important; }
}
+63 -30
View File
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard';
import { useAuth } from '@/contexts/AuthContext';
import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr';
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
/**
* The Ledger (Session 58, Phase 1) — the truth surface, now backed by the
@@ -42,6 +44,9 @@ interface LedgerRow {
outcome?: 'hit' | 'miss' | 'push' | null;
actual_value?: number | null;
revised_from_grade?: string | null;
// DS5 (Part 5) — the player's locked archetype, when the pipeline supplies it.
// Optional + self-hiding: absent beats fabricated.
archetype?: string | null;
}
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
@@ -153,7 +158,12 @@ export default function LedgerPage() {
</div>
{rows === null ? (
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading</p>
// DS1 (§4) — grid of card skeletons matching the ledger's real layout.
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }} aria-busy="true">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={132} radius={14} />
))}
</div>
) : rows.length === 0 ? (
<EmptyLedger tab={tab} />
) : (
@@ -191,15 +201,36 @@ function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: n
);
}
/** S6 (A1 board) — compact settled-CLV distribution strip. Bars are DATA
* (mono, tabular): green = beat-the-close side, red = faded side, dim = flat.
* Renders only when the server passed the n≥20 gate (clv_distribution set). */
/** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives
* in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat
* distribution renders as one giant bar + six empty stubs — it reads as
* BROKEN. When CLV is flat we SAY so in one confident VOICE line (the record
* is the best data on the site; it must look the most premium, never errored)
* and show the bars only when there is real spread. */
function ClvDistribution({ agg }: { agg: ModelAggregate }) {
const dist = agg.clv_distribution;
if (!Array.isArray(dist) || dist.length === 0) return null;
const total = dist.reduce((n, b) => n + b.count, 0);
const { mode } = clvMode(dist as Parameters<typeof clvMode>[0]);
if (mode === 'none') return null;
if (mode === 'flat') {
// Flat CLV is a FEATURE, not an error: the model grades outcomes, not the
// close. State it plainly — premium, confident, no broken histogram.
return (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 6 }}>
CLOSING-LINE VALUE
</div>
<p className="mono" style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', margin: 0, letterSpacing: '0.01em' }}>
{CLV_FLAT_LINE}
</p>
</div>
);
}
const distArr = dist as ClvBucket[];
const total = distArr.reduce((n, b) => n + b.count, 0);
if (total === 0) return null;
const max = Math.max(...dist.map((b) => b.count));
const max = Math.max(...distArr.map((b) => b.count));
const color = (side: ClvBucket['side']) =>
side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
return (
@@ -208,7 +239,7 @@ function ClvDistribution({ agg }: { agg: ModelAggregate }) {
CLV DISTRIBUTION · {total} SETTLED
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', maxWidth: 420 }}>
{dist.map((b) => (
{distArr.map((b) => (
<div key={b.label} title={`${b.label}: ${b.count}`} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 0 }}>
<span className="mono" style={{ fontSize: 10, fontVariantNumeric: 'tabular-nums', color: b.count > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}>
{b.count}
@@ -324,6 +355,12 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
</span>
</div>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
{/* Part 5 — the archetype glyph+chip, propagated (self-hides when absent). */}
{row.archetype && (
<div style={{ marginBottom: 6 }}>
<ArchetypeBadge archetype={row.archetype} size="sm" showDesc />
</div>
)}
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
@@ -341,28 +378,24 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
}
function EmptyLedger({ tab }: { tab: Tab }) {
return (
<div className="surface diagonal-cut tex-scan" style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
{tab === 'mine' ? (
<>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No reads yet.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
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.
</p>
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
Read a Prop
</a>
</>
) : (
<>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>The model record starts with the next pipeline run.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
Every pre-graded prop lands here hits, misses, pushes, and closing-line value. Nothing is deleted.
</p>
</>
)}
</div>
// DS5 (#20) — the ONE unified empty surface, inline variant.
return tab === 'mine' ? (
<EmptyState
inline
wordmark={false}
code="LEDGER EMPTY"
title="No reads yet."
message="Read your first prop to start building your Ledger. Every grade lands here the moment it completes — and settles against the real result."
actions={[{ label: 'Read a Prop →', href: '/scan', primary: true }]}
/>
) : (
<EmptyState
inline
wordmark={false}
code="MODEL RECORD BUILDING"
title="The record starts with the next pipeline run."
message="Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted."
/>
);
}
+18 -6
View File
@@ -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<boolean>(() => 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 (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
Loading the slate
</p>
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px' }} aria-busy="true">
<Skeleton height={40} width={280} radius={10} style={{ marginBottom: 24 }} />
<SkeletonList count={4} height={96} />
</section>
);
}
+96
View File
@@ -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 (
<div style={{ flex: '1 1 0', minWidth: 58, textAlign: 'center', padding: '9px 6px', background: 'var(--bg-2)', border: `1px solid ${base ? 'var(--border-hi)' : 'var(--border)'}`, borderRadius: 6 }}>
<div className="mono" style={{ fontSize: 11, color: base ? 'var(--text-0)' : 'var(--text-1)', marginBottom: 5 }}>{line}{base ? ' •' : ''}</div>
<div className="mono" style={{ fontSize: 17, fontWeight: 800, color: gradeCol }}>{grade}</div>
<div className="mono" style={{ fontSize: 10, color: edgeCol, marginTop: 3 }}>{edge}</div>
</div>
);
}
export default function DeskShowcase() {
return (
<section style={{ padding: '72px 24px 8px', borderTop: '1px solid var(--border)' }}>
<div className="desk-showcase" style={{ maxWidth: 1100, margin: '0 auto', display: 'grid', gap: 40, alignItems: 'center' }}>
{/* LEFT — the pitch */}
<div>
<div className="mono" style={{ fontSize: 11, letterSpacing: '0.16em', color: 'var(--g-a)', marginBottom: 14 }}>
THE DESK · FLAGSHIP
</div>
<h2 className="text-balance" style={{ fontSize: 'clamp(30px, 4.4vw, 50px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.02, margin: 0 }}>
A $1M terminal.{' '}
<span className="mono" style={{ color: 'var(--g-a)' }}>$44.99</span>
<span style={{ color: 'var(--text-tertiary)' }}>/mo.</span>
</h2>
<p style={{ fontSize: 17, color: 'var(--text-secondary)', margin: '18px 0 0', maxWidth: 460, lineHeight: 1.55 }}>
Every alt line graded, quarter-Kelly sizing on your bankroll, parlay
correlation, and the model&rsquo;s real-time feed. The desk a trading
floor pays five figures for priced for one bettor.
</p>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 28, flexWrap: 'wrap' }}>
<a href="#pricing" className="btn-primary" style={{ padding: '13px 24px', fontSize: 15, textDecoration: 'none' }}>
Claim a Founder Desk
</a>
<span className="mono" style={{ fontSize: 12.5, color: 'var(--text-tertiary)' }}>
<span style={{ color: 'var(--g-a)', fontWeight: 700 }}>$34.99</span> locked for the first 100 · then $44.99
</span>
</div>
</div>
{/* RIGHT — real feature visuals (no dead half) */}
<div style={{ display: 'grid', gap: 14 }}>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border-hi)', borderRadius: 12, padding: 18 }}>
<SectionHead style={{ marginBottom: 12 }}>
ALT LINE LADDER <span style={{ color: 'var(--amber)', fontSize: 9, border: '1px solid rgba(255,179,71,.4)', borderRadius: 3, padding: '1px 5px', marginLeft: 4 }}>DESK</span>
</SectionHead>
<div style={{ display: 'flex', gap: 7 }}>
<Rung line="1.5" grade="A" edge="+7.1%" />
<Rung line="2.5" grade="A+" edge="+11.4%" base />
<Rung line="3.5" grade="B" edge="+2.0%" />
<Rung line="4.5" grade="C" edge="-3.8%" />
</div>
</div>
<div style={{ display: 'grid', gap: 14, gridTemplateColumns: '1fr 1fr' }}>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 16 }}>
<SectionHead style={{ marginBottom: 10 }}>QUARTER-KELLY</SectionHead>
<div className="mono" style={{ fontSize: 26, fontWeight: 800, color: 'var(--g-a)', fontVariantNumeric: 'tabular-nums' }}>2.4%</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>of bankroll · at -110</div>
</div>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 16 }}>
<SectionHead style={{ marginBottom: 10 }}>PARLAY φ</SectionHead>
<div className="mono" style={{ fontSize: 26, fontWeight: 800, color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums' }}>0.34</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>correlation · same-team legs</div>
</div>
</div>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
REAL-TIME FEED · <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>consensus vs model</span>, live line moves
</span>
</div>
</div>
</div>
</section>
);
}
+4
View File
@@ -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 (
<main style={{ minHeight: '100vh' }}>
{/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the
grid, so the premium tier stops being an afterthought. */}
<DeskShowcase />
<Pricing />
{/* Founder-seat scarcity under the grid (§12) */}
<div style={{ padding: '8px 16px 48px' }}>
+3 -2
View File
@@ -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: {
+18 -5
View File
@@ -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<Sport, string> = {
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<Sport>('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 (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the model</p>
<section style={{ maxWidth: 720, margin: '0 auto', padding: '32px 16px 96px' }} aria-busy="true">
<Skeleton height={30} width={200} radius={8} style={{ marginBottom: 8 }} />
<Skeleton height={16} width={320} radius={6} style={{ marginBottom: 28 }} />
<SkeletonList count={3} height={64} />
<Skeleton height={52} radius={12} style={{ marginTop: 20 }} />
</section>
);
}
+2 -1
View File
@@ -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}` } : {},
+3 -2
View File
@@ -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)) {
+11 -5
View File
@@ -5,7 +5,9 @@ import { useRouter } from 'next/navigation';
import SportBadge from '@/components/vyndr/SportBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import TeamLogo from '@/components/vyndr/TeamLogo';
import ModelRecord from '@/components/vyndr/ModelRecord';
import EmptyState from '@/components/vyndr/EmptyState';
import { playerHref } from '@/lib/playerHref';
import { useParlay, legKey } from '@/contexts/ParlayContext';
@@ -68,11 +70,14 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
return <section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><p className="mono" style={{ color: 'var(--text-2)' }}>Loading team intelligence</p></section>;
}
if (state === 'error' || !data) {
// DS5 (#20) — the unified empty/error surface, not a bare-red line.
return (
<section style={{ maxWidth: 600, margin: '0 auto', padding: '40px 16px' }}>
<p className="mono" style={{ color: 'var(--miss)' }}>Team not found.</p>
<a href="/dashboard" className="mono" style={{ color: 'var(--g-a)', fontSize: 13 }}> Back to Slate</a>
</section>
<EmptyState
code="TEAM NOT FOUND"
title="No hub for that team."
message="That abbreviation isn't on the board right now. Check tonight's slate for the teams in play."
actions={[{ label: '← Back to the Slate', href: '/dashboard', primary: true }]}
/>
);
}
@@ -105,8 +110,9 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
<section style={{ maxWidth: 920, margin: '0 auto', padding: '20px 16px 120px' }}>
<a href="/dashboard" className="mono" style={{ fontSize: 12, color: 'var(--text-1)', textDecoration: 'none' }}> Back to Slate</a>
{/* Header */}
{/* Header — DS0: the team's real crest leads the hub. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, margin: '14px 0 22px', flexWrap: 'wrap' }}>
<TeamLogo team={data.team.abbr} sport={data.team.sport} size={44} title={data.team.name} />
<SportBadge sport={data.team.sport} />
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800, letterSpacing: '-0.015em' }}>{data.team.name}</h1>
<span className="mono" style={{ fontSize: 13, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{data.team.abbr}</span>
+2 -1
View File
@@ -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<string, string> {
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' };
+57 -20
View File
@@ -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 (
<div
className="surface diagonal-cut"
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
style={{ padding: 24, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
>
{ready && agg ? (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'baseline' }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
RECORD · LAST 30D
</span>
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--g-a, #00D4A0)' }}>
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
</span>
{agg.beat_close_pct != null && (
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>
{agg.beat_close_pct}% BEAT CLOSE
<>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
RECORD · LAST 30D
</span>
)}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{agg.pending} pending
</span>
</div>
{clvVerified && (
<span className="mono" style={{ fontSize: 10.5, fontWeight: 800, letterSpacing: '0.08em', color: 'var(--g-a, #00D4A0)', border: '1px solid var(--g-a, #00D4A0)', borderRadius: 999, padding: '3px 10px', background: 'color-mix(in srgb, var(--g-a, #00D4A0) 12%, transparent)' }}>
CLV-VERIFIED
</span>
)}
</div>
{/* HERO figures — hit rate is the largest thing on the page. */}
<div style={{ display: 'flex', gap: 40, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<div className="mono" style={{ fontSize: 56, fontWeight: 800, lineHeight: 0.95, letterSpacing: '-0.03em', color: 'var(--g-a, #00D4A0)', fontVariantNumeric: 'tabular-nums' }}>
{agg.hit_pct}%
</div>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
HIT RATE · {agg.hits}-{agg.misses}
</div>
</div>
{agg.beat_close_pct != null && (
<div>
<div className="mono" style={{ fontSize: 40, fontWeight: 800, lineHeight: 0.95, letterSpacing: '-0.02em', color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{agg.beat_close_pct}%
</div>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
BEAT CLOSE
</div>
</div>
)}
<div style={{ marginLeft: 'auto' }}>
<div className="mono" style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-tertiary)', fontVariantNumeric: 'tabular-nums' }}>
{agg.pending} pending
</div>
</div>
</div>
</>
) : (
<div>
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
@@ -230,10 +261,16 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
<GradePill grade={row.grade} />
</span>
</div>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
{/* Real entity identity (DS0) — headshot / team-colored monogram. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<PlayerAvatar name={row.player_name} sport={row.sport} size={34} />
<div style={{ minWidth: 0 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.player_name}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', margin: 0 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
</div>
</div>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
+44 -8
View File
@@ -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(
(
<div
@@ -28,14 +49,29 @@ export default async function Image({ params }: { params: Promise<{ handle: stri
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
PUBLIC LEDGER
{rec ? 'CLV-VERIFIED RECORD · 30D' : 'PUBLIC LEDGER'}
</div>
<div style={{ display: 'flex', fontSize: 84, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 18, color: '#FFFFFF' }}>
<div style={{ display: 'flex', fontSize: 72, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 14, color: '#FFFFFF' }}>
@{h}
</div>
<div style={{ display: 'flex', fontSize: 28, color: '#7A7A8E', marginTop: 20 }}>
CLV-verified record · every settled read · misses included
</div>
{rec ? (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 56, marginTop: 26 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 128, fontWeight: 900, lineHeight: 1, color: '#00D4A0' }}>{rec.hit_pct}%</div>
<div style={{ display: 'flex', fontSize: 22, letterSpacing: '0.14em', color: '#7A7A8E', marginTop: 8 }}>HIT RATE · {rec.hits}-{rec.misses}</div>
</div>
{rec.beat_close_pct != null && (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 92, fontWeight: 900, lineHeight: 1, color: '#FFFFFF' }}>{rec.beat_close_pct}%</div>
<div style={{ display: 'flex', fontSize: 22, letterSpacing: '0.14em', color: '#7A7A8E', marginTop: 8 }}>BEAT CLOSE</div>
</div>
)}
</div>
) : (
<div style={{ display: 'flex', fontSize: 28, color: '#7A7A8E', marginTop: 20 }}>
CLV-verified record · every settled read · misses included
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', fontSize: 44, fontWeight: 900, letterSpacing: '0.08em' }}>
+4 -3
View File
@@ -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 (
<div
style={{
@@ -83,7 +83,8 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: 'po
style={{
fontSize: 14,
fontWeight: 700,
color: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
// Color contract #2: edge/CLV colored by SIGN — positive green, negative muted red.
color: tone === 'positive' ? 'var(--grade-a)' : tone === 'negative' ? 'var(--miss)' : 'var(--text-primary)',
}}
>
{value}
@@ -258,7 +259,7 @@ export default function LiveHeroProp() {
{/* Projection + edge — visible, the proof. */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value={`${projection} ${statTypeLabel}`} />
<Stat label="Edge" value={edgeDisplay} tone={edge > 0 ? 'positive' : undefined} />
<Stat label="Edge" value={edgeDisplay} tone={edge > 0 ? 'positive' : edge < 0 ? 'negative' : undefined} />
</div>
{/* Reasoning — BLURRED, the paywall. */}
+12 -8
View File
@@ -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,
},
];
+102 -44
View File
@@ -1,16 +1,20 @@
'use client';
import { useEffect, useState } from 'react';
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import { type Tier } from '@/lib/tierGate';
/**
* StreaksPanel (Session 23).
* StreaksPanel (Session 23 · rebuilt DS4 — the STREAKS BILLBOARD).
*
* Surfaces computed player streaks for a sport, narrowed by the active
* stat filter. Everything through VYNDR's lens — "4-game 28+ scoring
* streak", not "31.2 PPG". Free users see the top 3 with an upgrade
* nudge; paid users see the full list.
* DESIGN-SPEC Part 7 (#2): the STREAKS row is a P0 billboard — the timeline
* is customer #1. Not a log line: a Bloomberg alert. ONE bold figure (the
* streak LENGTH, mono + tabular, the largest thing in the row) among muted
* context (real player identity + "built vs" lens), with a SINGLE severity
* accent — tonight's matchup difficulty (step-up amber / step-down green).
* The grade badge is tier-gated (the READ is the paid layer).
*
* Self-hides when there are no streaks so the landing page never shows an
* empty box — the other layers (schedule, game lines, props) carry the
@@ -28,6 +32,9 @@ interface Streak {
// Session 60 (night2/C) — THE LENS + optional snapshot grade letter.
lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null };
grade?: string | null;
// DS5 (Part 5) — the player's locked archetype from the snapshot join, when
// present. Optional + self-hiding: absent beats fabricated.
archetype?: string | null;
}
export interface StreaksPanelProps {
@@ -61,44 +68,67 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
// Session 60 (product def) — the PICTURE is free: every streak shows for
// every tier. Only an explicit `limit` (landing teaser) caps the list.
// The paid layer is the GRADE, not the data. tierGate still governs the
// hot LISTS (top-3 free) — not streaks.
void tier;
const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks;
const hidden = streaks.length - visible.length;
// DS4 — the GRADE (the READ) is the paid layer; only analyst/desk see it.
const canSeeGrade = tier === 'analyst' || tier === 'desk';
return (
<section className="streaks-panel" style={{ margin: '16px 0' }}>
<h3 style={panelHeading}>🔥 STREAKS</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((s) => (
<div key={`${s.player}-${s.type}`} style={rowStyle}>
<img
src={getHeadshotUrl({ sport, playerId: s.playerId })}
alt={s.player}
width={36}
height={36}
style={avatarStyle}
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>
{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 && <span className="mono" style={{ marginLeft: 8, fontSize: 10.5, fontWeight: 800, color: 'var(--g-a, #00D4A0)', border: '1px solid rgba(0,212,160,.35)', borderRadius: 4, padding: '1px 5px' }}>{s.grade}</span>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{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 (
<div key={`${s.player}-${s.type}`} style={rowStyle}>
{/* Real identity — headshot / team-colored monogram (DS0). */}
<PlayerAvatar name={s.player} sport={sport} playerId={s.playerId} team={s.team} size={46} />
{/* THE HERO — streak length, mono + tabular, the largest figure
in the row. One bold number; everything else demoted. */}
<div style={heroBlock}>
<span className="mono" style={heroNum}>{s.currentStreak}</span>
<span className="mono" style={heroUnit}>GAME<br />STREAK</span>
</div>
{/* THE LENS — the interpreted read, never the raw streak alone. */}
<div style={streakDesc}>{s.lens?.read || s.description}</div>
{s.lens?.difficulty && (
<div className="mono" style={{ fontSize: 10.5, marginTop: 2, color: s.lens.difficulty === 'step up' ? 'var(--amber, #FFB347)' : s.lens.difficulty === 'step down' ? 'var(--g-a, #00D4A0)' : 'var(--text-tertiary, #6A6A78)' }}>
{s.lens.difficulty === 'step up' ? '▲ TOUGHER SPOT TONIGHT' : s.lens.difficulty === 'step down' ? '▼ SOFTER SPOT TONIGHT' : ''}
{/* Identity + the LENS — demoted, muted context. */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>
{s.player}{s.team ? <span style={teamTag}> · {s.team}</span> : null}
</div>
)}
{/* Part 5 — the archetype glyph+chip (the Rosetta stone),
propagated here; self-hides when the join has no archetype. */}
{s.archetype && (
<div style={{ margin: '3px 0' }}>
<ArchetypeBadge archetype={s.archetype} sport={sport} size="sm" showDesc />
</div>
)}
<div className="mono" style={categoryLine}>{s.description}</div>
<div style={lensLine}>{builtVs}</div>
</div>
{/* The single severity accent + tier-gated grade badge. */}
<div style={rightRail}>
{sev && <span className="mono" style={{ ...sevChip, color: sev.color, borderColor: sev.color }}>{sev.label}</span>}
{s.grade && (
canSeeGrade
? <GradeBadge grade={s.grade} size="md" glow />
: <a href="/pricing" className="mono" style={gradeLock} title="Unlock the grade">GRADE </a>
)}
</div>
</div>
<span style={badgeStyle}>{s.currentStreak} G</span>
</div>
))}
);
})}
</div>
{hidden > 0 && (
<a href="/explore" style={upsellStyle}>
@@ -114,23 +144,51 @@ const panelHeading: React.CSSProperties = {
color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px',
};
const rowStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 10,
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 14px', borderRadius: 12,
background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)',
};
const avatarStyle: React.CSSProperties = {
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
// THE HERO NUMBER — deliberately the largest font in the row (mono, tabular).
const heroBlock: React.CSSProperties = {
flex: '0 0 auto', display: 'flex', alignItems: 'baseline', gap: 6,
minWidth: 62,
};
const heroNum: React.CSSProperties = {
fontSize: 38, fontWeight: 800, lineHeight: 1,
color: 'var(--text-primary, #F0F0F4)', fontVariantNumeric: 'tabular-nums',
letterSpacing: '-0.03em',
};
const heroUnit: React.CSSProperties = {
fontSize: 8.5, fontWeight: 700, lineHeight: 1.05, letterSpacing: '0.1em',
color: 'var(--text-tertiary, #6A6A78)',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const streakDesc: React.CSSProperties = {
fontSize: 12, color: 'var(--text-secondary, #9A9AA8)',
const teamTag: React.CSSProperties = {
fontSize: 12, fontWeight: 500, color: 'var(--text-tertiary, #6A6A78)',
};
const badgeStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 12, fontWeight: 800, padding: '3px 8px',
borderRadius: 6, background: 'rgba(233,75,60,0.15)', color: 'var(--accent, #E94B3C)',
const categoryLine: React.CSSProperties = {
fontSize: 11, color: 'var(--text-secondary, #9A9AA8)', marginTop: 1,
};
const lensLine: React.CSSProperties = {
fontSize: 11, color: 'var(--text-tertiary, #6A6A78)', marginTop: 2,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const rightRail: React.CSSProperties = {
flex: '0 0 auto', display: 'flex', flexDirection: 'column',
alignItems: 'flex-end', gap: 6,
};
const sevChip: React.CSSProperties = {
fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em',
padding: '2px 7px', borderRadius: 5, border: '1px solid transparent',
background: 'transparent', whiteSpace: 'nowrap',
};
const gradeLock: React.CSSProperties = {
fontSize: 10.5, fontWeight: 800, letterSpacing: '0.06em',
color: 'var(--text-tertiary, #6A6A78)', textDecoration: 'none',
border: '1px solid var(--border, #2A2A36)', borderRadius: 4, padding: '2px 6px',
};
const upsellStyle: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
+35
View File
@@ -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 (
<span
className="mono"
title={best ? `Best price · ${b.name}` : b.name}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, whiteSpace: 'nowrap',
fontSize: size, fontWeight: 700, color: b.fg, letterSpacing: '0.01em',
...(best
? { padding: '1px 7px', borderRadius: 5, background: `color-mix(in srgb, ${b.fg} 12%, transparent)`, border: `1px solid color-mix(in srgb, ${b.fg} 45%, transparent)` }
: {}),
}}
>
{best && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 6px var(--g-a)' }} />}
{b.name}
</span>
);
}
+110
View File
@@ -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 (
<section
className="scanlines"
style={{
minHeight: inline ? undefined : 'calc(100vh - 240px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: inline ? '40px 24px' : '56px 24px',
textAlign: 'center',
position: 'relative',
border: inline ? '1px solid var(--border)' : undefined,
borderRadius: inline ? 12 : undefined,
background: inline ? 'var(--bg-1)' : undefined,
}}
>
{/* CRT sweep — fires once on mount; frozen under reduced motion. */}
<div className="crt-sweep" />
{wordmark && (
<div style={{ marginBottom: 18 }}>
<Wordmark size={inline ? 'md' : 'lg'} cursor />
</div>
)}
<div
className="mono amber-glow"
style={{ fontSize: inline ? 11 : 12.5, letterSpacing: '0.28em', color: 'var(--amber)', marginBottom: 16 }}
>
{code}
</div>
<h2 style={{ fontSize: inline ? 20 : 26, fontWeight: 800, letterSpacing: '-0.02em', margin: 0, color: 'var(--text-0)' }}>
{title}
</h2>
{message && (
<p className="mono" style={{ fontSize: 13.5, color: 'var(--text-1)', marginTop: 12, maxWidth: 460, lineHeight: 1.55 }}>
{message}
</p>
)}
{actions.length > 0 && (
<div style={{ display: 'flex', gap: 10, marginTop: 24, flexWrap: 'wrap', justifyContent: 'center' }}>
{actions.map((a) => (
<a
key={a.href + a.label}
href={a.href}
className={a.primary ? 'btn-primary' : 'btn-ghost'}
style={{ padding: '11px 20px', textDecoration: 'none', fontSize: 13.5 }}
>
{a.label}
</a>
))}
</div>
)}
{children}
</section>
);
}
+49 -5
View File
@@ -6,8 +6,12 @@ import SectionHead from '@/components/vyndr/SectionHead';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@/components/vyndr/StatStrip';
import TeamLogo from '@/components/vyndr/TeamLogo';
import { accentColor } from '@/lib/teamMeta';
import { playerHref } from '@/lib/playerHref';
import { isPreferredBook } from '@/lib/books';
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
import { nextRunLabelET } from '@/lib/pipelineSchedule';
import { useParlay, legKey } from '@/contexts/ParlayContext';
export interface GameLine {
@@ -100,19 +104,21 @@ function collapseStrips(strips: PlayerStrip[]) {
return { sorted, visible, totalReads, truncated: visible.length < sorted.length };
}
/** Clickable team abbreviation → /team/:abbr (Session 51). Stops propagation so
* it doesn't trigger the card's open-game handler; green underline on hover. */
/** Clickable team → /team/:abbr (Session 51). DS0: renders the real LOGO +
* abbr (the entity layer), not a bare string. Stops propagation so it
* doesn't trigger the card's open-game handler. */
function TeamLink({ abbr, sport }: { abbr: string; sport: string }) {
if (!abbr) return <span></span>;
return (
<a
href={`/team/${encodeURIComponent(abbr)}?sport=${encodeURIComponent(sport || 'mlb')}`}
onClick={(e) => 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`}
>
<TeamLogo team={abbr} sport={sport} size={22} />
{abbr}
</a>
);
@@ -178,6 +184,12 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
const [linesExpanded, setLinesExpanded] = useState(false);
const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]);
const stripsToRender = showAllReads ? collapsed.sorted : collapsed.visible;
// DS2 (#2) — the ONE bold hero per card: the single best live graded read,
// promoted large + mono + tabular. Everything else in the strips below stays
// demoted context. (#14) — when the card has NO graded reads yet, the awaiting
// props collapse to ONE pending line instead of six identical rows.
const topRead = useMemo(() => topReadForCard(g.playerStrips || []), [g.playerStrips]);
const pending = useMemo(() => pendingSummary(g.playerStrips || []), [g.playerStrips]);
// A1 S11 — LIVE SLATE MODE: any strip prop carrying live tracking shows the
// once-per-card label. Grades locked pre-game NEVER change in-game.
const isTracking = useMemo(
@@ -206,8 +218,11 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
},
isLegActive: (p: StripProp) => hasLeg(legKey(toLeg(ps, p))),
});
// DS0 — a team-colored accent edge (home team) so the card reads as THIS
// matchup, not a generic panel. Visible-color rule keeps it off near-black.
const homeAccent = accentColor(g.home.abbr, g.sport) || 'var(--border)';
return (
<div className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<div className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderLeft: `3px solid color-mix(in srgb, ${homeAccent} 65%, transparent)`, borderRadius: 10, overflow: 'hidden' }}>
{/* HEADER */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '13px 16px 11px' }}>
<div onClick={() => onOpen && onOpen(g.id)} title="Open game detail" style={{ display: 'flex', alignItems: 'center', gap: 11, minWidth: 0, cursor: onOpen ? 'pointer' : 'default' }}>
@@ -314,7 +329,36 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
</span>
)}
</SectionHead>
{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 && (
<div
style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 11, padding: '10px 12px', borderRadius: 9, background: 'var(--bg-2)', border: '1px solid var(--border-hi)' }}
>
<GradeBadge grade={topRead.grade} size={48} glow />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 800, letterSpacing: '-0.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{topRead.player}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3, fontVariantNumeric: 'tabular-nums' }}>
{topRead.stat} {String(topRead.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{topRead.line}
{topRead.team ? <span style={{ color: 'var(--text-2)' }}> · {topRead.team}</span> : null}
</div>
</div>
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)', flexShrink: 0 }}>TOP READ</span>
</div>
)}
{/* DS2 (#14) — collapse the dead "Grades post …" repetition. An
all-awaiting card renders ONE compact line, not six identical rows. */}
{pending ? (
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--text-1)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 800, color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums' }}>{pending.count}</span>
<span>props pending</span>
<span style={{ color: 'var(--text-2)' }}>·</span>
<span style={{ color: 'var(--text-2)' }}>{pending.players} player{pending.players === 1 ? '' : 's'}</span>
<span style={{ color: 'var(--text-2)' }}>·</span>
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{nextRunLabelET() ? `grade ${nextRunLabelET()}` : 'grade on the next pipeline run'}</span>
</div>
) : g.playerStrips && g.playerStrips.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{stripsToRender.map((ps, i) => (
<StatStrip
+16 -8
View File
@@ -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 {
@@ -69,6 +70,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 via the centralized
// colorContract.edgeColor helper (imported) — negative = var(--miss). DS4
// introduced a local const that DS3 superseded with the shared enforcer.
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;
@@ -137,7 +141,8 @@ export default function GradeResultCard({
lineHeight: 0.95,
color: hex,
letterSpacing: '-0.04em',
textShadow: `0 0 28px ${hex}aa, 0 0 60px ${hex}55`,
// GLOW = A/A+ ONLY (color contract #4): a glowing C/D devalues the cue.
textShadow: gradeGlows(d.grade) ? `0 0 28px ${hex}aa, 0 0 60px ${hex}55` : 'none',
fontFamily: 'var(--sans)',
}}
>
@@ -150,7 +155,7 @@ export default function GradeResultCard({
{d.edge != null && (
<>
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span style={{ color: 'var(--g-a)' }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
<span style={{ color: edgeColor(d.edge) }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
</>
)}
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
@@ -173,11 +178,11 @@ export default function GradeResultCard({
{[
{ l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' },
{ l: 'LINE', v: d.line, col: 'var(--text-0)' },
{ l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? 'var(--g-a)' : 'var(--text-2)' },
{ l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? edgeColor(d.edge) : 'var(--text-2)' },
].map((x, i) => (
<div key={i} style={{ padding: '14px 16px', textAlign: 'center', borderRight: i < 2 ? '1px solid var(--border)' : 'none' }}>
<div className="label" style={{ fontSize: 10, marginBottom: 5 }}>{x.l}</div>
<div className="mono" style={{ fontSize: 19, fontWeight: 700, color: x.col }}>{x.v}</div>
<div className="mono" style={{ fontSize: 19, fontWeight: 700, color: x.col, fontVariantNumeric: 'tabular-nums' }}>{x.v}</div>
</div>
))}
</div>
@@ -216,15 +221,18 @@ export default function GradeResultCard({
</div>
)}
{/* 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 && (
<div className="intel-surface" style={{ margin: '0 20px 16px', padding: '15px 16px', borderRadius: 12, border: '1px solid rgba(0,212,160,0.24)' }}>
<div className="intel-surface" style={{ margin: '0 20px 16px', padding: '15px 16px', borderRadius: 12, border: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 12 }}>VYNDR INTELLIGENCE</div>
<div className="mono" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', fontSize: 12 }}>
{d.vyndrIntel.form != null && (<><span style={{ color: 'var(--text-2)' }}>Form</span><span style={{ color: 'var(--g-ap)', fontWeight: 700 }}>{d.vyndrIntel.form}</span><span style={{ color: '#2A3531' }}>·</span></>)}
{d.vyndrIntel.form != null && (<><span style={{ color: 'var(--text-2)' }}>Form</span><span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{d.vyndrIntel.form}</span><span style={{ color: '#2A3531' }}>·</span></>)}
{d.vyndrIntel.usage && (<><span style={{ color: 'var(--text-2)' }}>Usage</span><span style={{ color: 'var(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.usage}</span><span style={{ color: '#2A3531' }}>·</span></>)}
{d.vyndrIntel.matchup && (<><span style={{ color: 'var(--text-2)' }}>Matchup</span><GradeBadge grade={d.vyndrIntel.matchup} size="sm" /><span style={{ color: '#2A3531' }}>·</span></>)}
{d.vyndrIntel.rest && (<><span style={{ color: 'var(--text-2)' }}>Rest</span><span style={{ color: 'var(--g-a)', fontWeight: 600 }}>{d.vyndrIntel.rest}</span></>)}
{d.vyndrIntel.rest && (<><span style={{ color: 'var(--text-2)' }}>Rest</span><span style={{ color: 'var(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.rest}</span></>)}
</div>
</div>
)}
+76
View File
@@ -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 = (
<span
title={name}
className="mono"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: '50%', flexShrink: 0,
background: `color-mix(in srgb, ${accent} 22%, #0E0E16)`,
border: `1px solid color-mix(in srgb, ${accent} 50%, transparent)`,
color: accent, fontWeight: 800, fontSize: Math.max(9, size * 0.34), letterSpacing: '0.01em',
}}
>
{initials(name)}
</span>
);
if (!showImg) return Monogram;
return (
<span
style={{
display: 'inline-flex', width: size, height: size, borderRadius: '50%',
overflow: 'hidden', flexShrink: 0, background: '#0E0E16',
border: `1px solid color-mix(in srgb, ${accent} 40%, transparent)`,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url as string}
alt={name}
width={size}
height={size}
loading="lazy"
onError={() => setBroken(true)}
style={{ width: size, height: size, objectFit: 'cover', objectPosition: 'top center' }}
/>
</span>
);
}
+63
View File
@@ -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 (
<div
aria-hidden="true"
className={`vyndr-skeleton ${className}`.trim()}
style={{ height, width, borderRadius: radius, ...style }}
/>
);
}
/**
* A row of card-shaped skeletons — matches horizontal-scroll grade/slate rows.
*/
export function SkeletonCards({ count = 4, height = 110, minWidth = 200 }: { count?: number; height?: number; minWidth?: number }) {
return (
<div style={{ display: 'flex', gap: 12 }} aria-hidden="true">
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} height={height} width={minWidth} radius={16} style={{ minWidth, flex: '0 0 auto' }} />
))}
</div>
);
}
/**
* A stack of full-width row skeletons — matches list/grid loading states
* (the slate, the ledger, the desk pack).
*/
export function SkeletonList({ count = 4, height = 80, gap = 12 }: { count?: number; height?: number; gap?: number }) {
return (
<div style={{ display: 'grid', gap }} aria-hidden="true">
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} height={height} radius={16} />
))}
</div>
);
}
export default Skeleton;
+4
View File
@@ -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 (
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 9 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{/* DS0 — player identity block: real headshot / team-colored monogram
(never the gray silhouette). Team drives the accent color. */}
<PlayerAvatar name={player} sport={sport} team={team} size={30} />
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
+64
View File
@@ -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 (
<span
title={title || meta?.name || label}
className="mono"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: '50%', flexShrink: 0,
background: `color-mix(in srgb, ${accent} 20%, #0E0E16)`,
border: `1px solid color-mix(in srgb, ${accent} 55%, transparent)`,
color: accent, fontWeight: 800, fontSize: Math.max(8, size * 0.36),
letterSpacing: '0.02em',
}}
>
{label.slice(0, 3)}
</span>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={meta?.name || label}
title={title || meta?.name || label}
width={size}
height={size}
loading="lazy"
onError={() => setBroken(true)}
style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0, display: 'block' }}
/>
);
}
+89 -35
View File
@@ -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<string, string> = {
// 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 <html> 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 []); <MIN_ITEMS → renders nothing. Visibility
* is published as `--ticker-h` on <html> 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<TickerItem[] | null>(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) => (
<span
key={i}
key={key}
className="mono"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 26px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 22px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
>
{rank != null && (
<span className="mono" style={{ color: 'var(--text-2)', fontWeight: 700, fontSize: 10, minWidth: 30 }}>
{rank}/{len}
</span>
)}
<span
style={{
color: it.color || 'var(--amber)',
@@ -105,37 +150,46 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
{it.delta && (
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>{it.delta}</span>
)}
<span style={{ color: 'var(--text-2)' }}>·</span>
</span>
));
);
return (
<div
className="scanlines"
style={{ height, overflow: 'hidden', background: 'var(--bg-1)', borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', position: 'relative' }}
>
<div className="ticker-track">
{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). */}
<div
className="mono"
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
alignSelf: 'stretch', background: 'var(--bg-1)', borderRight: '1px solid var(--border)',
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)',
}}
aria-hidden
>
TOP MOVES
</div>
{/* Session 55 — anchored LIVE badge (chrome, not data → may pulse). */}
{live && (
{reduced ? (
// Reduced motion — the full ranked list, static, no animation at all.
<div className="ticker-rest" style={{ flex: 1 }}>
{display.map((it, i) => renderItem(it, i))}
</div>
) : (
// Resting rotator — ONE item held for ≥4s, change-only cross-fade + pulse.
<div
className="mono"
style={{
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 3,
display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
background: 'var(--bg-1)', borderRight: '1px solid var(--border)',
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
color: flash ? 'var(--g-ap, #00ffb8)' : 'var(--g-a, #00D4A0)',
transition: 'color 0.3s ease',
}}
key={idx}
className={`ticker-rest ticker-item-enter${pulse ? ' ticker-pulse' : ''}`}
style={{ flex: 1 }}
aria-live="polite"
>
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'currentColor', display: 'inline-block', boxShadow: flash ? '0 0 8px currentColor' : 'none' }} />
LIVE
{renderItem(display[idx % len], idx, (idx % len) + 1)}
</div>
)}
<div style={{ position: 'absolute', left: live ? 66 : 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(90deg, var(--bg-1), transparent)', zIndex: 2 }} />
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(270deg, var(--bg-1), transparent)', zIndex: 2 }} />
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 48, background: 'linear-gradient(270deg, var(--bg-1), transparent)', zIndex: 2 }} />
</div>
);
}
+10
View File
@@ -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';
@@ -25,10 +27,18 @@ 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';
// DS1 (§4) — skeleton loaders replace text-wall "Loading…" states everywhere.
export { default as Skeleton, SkeletonCards, SkeletonList } from './Skeleton';
export {
GRADE_COLORS,
GRADE_HEX,
+8 -8
View File
@@ -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' },
};
+41
View File
@@ -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-<project-ref>-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 };
+45
View File
@@ -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 };
+109
View File
@@ -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,
};
+78
View File
@@ -0,0 +1,78 @@
/* DS1 (DESIGN-SPEC §17 — scan→ledger persistence).
*
* The single row-builder for a MANUAL scan's ledger_entries row. Shared
* (CommonJS) so the Next scan route builds the row through it AND the Jest
* suite can prove a written row is readable by the /api/ledger/mine query
* (same user_id scope, same player_key = nameKey, same dedupe key).
*
* DATA SEMANTICS: line/book are the REAL book values the user scanned;
* locked_odds/team attach only when the cache-only snapshot confirms them
* (absent beats wrong). Only grade/edge/confidence/model_value are MODEL. */
const { nameKey, normalizeName } = require('./playerName');
/** The columns the dedupe UNIQUE constraint (migration 019) is keyed on, and
* the onConflict target for the upsert. Kept here as the single source. */
const LEDGER_CONFLICT_COLS = 'user_id,player_key,stat,line,side,game_id';
/** Today's date in ET (YYYY-MM-DD) — the game_date the settle pass keys on. */
function gameDateET(now = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(now);
}
const numOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/**
* Build the ledger_entries row for a completed manual scan.
* @param {{
* userId: string,
* sport: string,
* player: string,
* stat: string,
* line: number,
* side: string,
* book?: string | null,
* team?: string | null,
* lockedOdds?: string | number | null,
* grade?: string | null,
* edge?: number | null,
* confidence?: number | null,
* projection?: number | null,
* now?: Date,
* }} opts
* @returns {Record<string, unknown>} a row ready for `sb.from('ledger_entries').upsert(row, ...)`.
*/
function buildManualLedgerRow({
userId, sport, player, stat, line, side, book,
team = null, lockedOdds = null,
grade = null, edge = null, confidence = null, projection = null,
now = new Date(),
}) {
const s = String(sport || '').toLowerCase();
const playerKey = nameKey(player);
const gameDate = gameDateET(now);
return {
user_id: userId,
player_key: playerKey,
player_name: normalizeName(player).display || player,
sport: s,
stat: String(stat || '').toLowerCase(),
line,
side,
locked_odds: lockedOdds != null ? String(lockedOdds) : null,
book: book || 'draftkings',
team,
opponent: null,
grade: grade ?? null,
edge: numOrNull(edge),
confidence: numOrNull(confidence),
model_value: numOrNull(projection),
graded_at: now.toISOString(),
game_id: `manual:${s}:${gameDate}:${playerKey}`,
game_date: gameDate,
};
}
module.exports = { buildManualLedgerRow, gameDateET, LEDGER_CONFLICT_COLS };
+140
View File
@@ -421,6 +421,139 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
});
}
// ── DS2 (Design v2) — Dashboard Slate Rebuild engine ────────────────
// Pure, testable functions behind the founder's #1 rebuild: rank the top
// grades on a VARYING signal (#13), promote ONE bold hero per card (#2),
// collapse the dead "Grades post …" repetition (#14), and the NEVER-EMPTY
// hero (#1, Part 6) — first paint ALWAYS proves the model.
const DS2_GRADE_RANK = {
'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10,
};
/** Grade → sortable tier rank (lower = better). Unknown → 99. */
function gradeRankOf(g) {
const k = String(g == null ? '' : g).trim().toUpperCase();
return DS2_GRADE_RANK[k] !== undefined ? DS2_GRADE_RANK[k] : 99;
}
const numOr = (v, fallback) => {
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,
};
+244
View File
@@ -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 (0255). 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 } };