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:
@@ -0,0 +1,84 @@
|
||||
# VYNDR — DESIGN SPEC v2
|
||||
### The raised standard. Commit to /specs/DESIGN-SPEC.md. Supersedes v1. Governs the design train and every future surface. Built from: cross-vertical research (Bloomberg/Linear/Superhuman/Stripe), the live design audit (22 findings), and the founder's realism reference.
|
||||
### Governed by MASTERMIND-BUILD-STANDARD (tokenized, tested, no one-off CSS). Every rule here is buildable — a token, a component, a motion primitive, or an asset.
|
||||
|
||||
## THE ONE LINE
|
||||
The substance is already here — the record (55-19, 74% hit), the archetype system, real live data. v2's entire job: **stop the presentation from failing the substance.** VYNDR must feel like a $1M/month terminal that costs $44.99 — the gut reaction "how is this only $44.99" IS the conversion event.
|
||||
|
||||
---
|
||||
|
||||
## PART 0 — GOVERNING LAWS (from founder + research)
|
||||
1. **ALIVE IS PUNCTUATED STILLNESS** — mostly still, meaningful pulses. Not constant motion. Fast motion reads cheap; fast RESPONSE reads expensive.
|
||||
2. **PREMIUM = SPEED + RESTRAINT, NOT RICHNESS** — Bloomberg's moat is zero-latency; Linear feels expensive via sub-200ms + optimistic UI; Superhuman is "confident, not loud." Perceived instantaneity is the #1 premium lever.
|
||||
3. **THE BLEND** — peak-ESPN swagger (weighted motion that ARRIVES then RESTS; one hero per screen) + Bloomberg density (dense but RANKED, effortless, instant) + VYNDR's existing glitch/signal-green signature (keepers — evolve, never replace).
|
||||
4. **REALISM** — entities render as THEMSELVES: real team logos/colors, player identity, sportsbook wordmarks. Never a gray string.
|
||||
5. **DENSITY IS UNLIMITED UNDER FIXED GRAMMAR** — the founder LIKES density; the failure is dense-and-flat. Rank it or it's noise.
|
||||
6. **THE GAP IS THE PRODUCT** — design every surface so the price feels impossibly low for the experience.
|
||||
|
||||
---
|
||||
|
||||
## PART 1 — THE COLOR CONTRACT (fixes audit #3,#6,#9,#15; enforce as QA tests)
|
||||
- **Signal-green #00D4A0 = ONE meaning: edge / active / A-tier / primary CTA.** Own it like Linear owns purple. It is VYNDR's signature color — unmistakable, never diluted.
|
||||
- **Never green for:** decorative backgrounds (kill the green-flooded Intelligence panel #15), "live" status while stale (#6), two competing CTAs (#9), or archetype colors that are green-family (dedupe BRUSH/WORKHORSE off the signal hue).
|
||||
- **Edge / CLV colored by SIGN:** negative = muted red, positive = signal-green. A -33.3% edge must NEVER render green (#3).
|
||||
- **Grades colored by TIER:** A/A+ signal-green, B neutral-bright, C muted, D/F red.
|
||||
- **GLOW = A/A+ ONLY.** A glowing C devalues the cue (#3). Glow is the scarcest signal in the system.
|
||||
- **Muted default, full-saturation only on what matters** (the Linear pattern, audit-validated): most text/chrome at 40–60% opacity; saturation reserved for the one thing that matters per surface.
|
||||
|
||||
## PART 2 — THE ENTITY LAYER (fixes #10,#11,#12 — founder's #1 priority; one system, swapped everywhere)
|
||||
A single asset/rendering system, cached, reused on every surface:
|
||||
- **Teams:** real logo + primary/secondary colors. Game cards, grade cards, team hubs get the logo and a team-colored edge/accent. Never "MIL @ PIT" as bare text.
|
||||
- **Players:** real headshot; fallback = team-colored monogram (not the gray silhouette). Always paired with position + status chip (CONFIRMED / OUT / GTD / NOT-IN-LINEUP) as a consistent identity block. Team hub already does position — propagate.
|
||||
- **Sportsbooks:** real wordmarks in brand color (DraftKings, FanDuel, BetMGM, etc.), never lowercase text. Best-price gets a clear semantic highlight.
|
||||
- **Reference standard:** the founder's admired game-strip — real logos, real O/U, park, context — executed to LEGIBLE premium (the reference's own cramped table is what VYNDR does BETTER: same density, real hierarchy).
|
||||
|
||||
## PART 3 — THE DATA-DISPLAY STANDARD (fixes #13,#14,#16,#19,#21 — the "horrid" core)
|
||||
Every stat/line/player/game is a designed component, not a cell. Rules:
|
||||
- **One bold hero figure among muted context.** The number that matters is large + mono + tabular; everything else demoted. The failure (#14,#19): every value identical weight.
|
||||
- **Stats carry context (the lens):** never a bare number — value + rank + trend + matchup. ".312" is trash; ".312 · 4th · ▲ up from .287 · .340 vs this SP" is intelligence.
|
||||
- **Collapse dead repetition (#14):** six rows of "Grades post ~6:00 PM ET" → one compact "6 props pending · grade 6 PM" line. High real estate must carry high information.
|
||||
- **Rankings must vary (#13):** if a leaderboard's rows are identical (all doubles-U0.5-55%-B), the ranking is meaningless — surface the varying signal (edge, confidence, trend) and close dead-space gaps with context. Tabular mono, aligned.
|
||||
- **Charts that are flat should SAY so (#16):** CLV 73/74 flat → "CLV flat — we grade the outcome, not the close" in one confident line, not a histogram that reads as broken. The best data (55-19, 74%) must look the most premium, never errored.
|
||||
- **No naked "—" fields (#15,#21):** populate or hide. A Desk billing page with blank STATUS, an Intelligence panel with four dashes — both read unfinished.
|
||||
- **Legends where needed:** the last-10 dot strip (●○○●) needs a hit/miss legend.
|
||||
|
||||
## PART 4 — MOTION + PERCEIVED SPEED (fixes #5,#7,#22; research's #1 lever)
|
||||
- **Sub-200ms everything.** View transitions <200ms, weighted/eased (never linear, never slow). Optimistic UI: actions appear instant, reconcile in background (Linear model).
|
||||
- **Kill loading walls (#5):** no "Loading the slate…" / "Assembling the pack…" text. Layout-matched skeletons only. FIX the React #418 hydration mismatch — server HTML must not be discarded and re-rendered.
|
||||
- **Ticker = punctuated stillness (#7):** rests on each item long enough to READ (≥4s hold, or a static ranked "top moves" strip that pulses only on change). ONE animated element in the header zone — not ticker + heartbeat + counter stacked.
|
||||
- **Motion categories:** IDLE (subtle proof-of-life — SYNC tick, one live-dot), TRANSITION (sub-200ms weighted), DATA-UPDATE (300ms green pulse on change — the terminal reacting), REVEAL (grade declassify, the Settle — staged, rare, deliberate), GLITCH (wordmark RGB-split on load, header scanline — chrome only, NEVER on data). All tokenized (--motion-*), prefers-reduced-motion honored globally.
|
||||
- **The peak-ESPN rule:** motion ARRIVES with weight, then RESTS to be read. Swagger in the chrome, signal always pristine.
|
||||
|
||||
## PART 5 — ARCHETYPE SYSTEM (the Rosetta stone — propagate, don't reinvent)
|
||||
The team hub already renders archetypes as an ownable visual language: glyph + color chip (★ALPHA, ▲BOMBER, »GHOST, etc.). This is the strongest asset on the site. v2:
|
||||
- **Propagate the glyph+chip to EVERY surface** — dashboard, grade reveal, /desk, streaks, ledger, Data Brief (currently plain text / JSON in most). One component, everywhere.
|
||||
- **Dedupe archetype colors off signal-green** (BRUSH, WORKHORSE currently green-family — shift them so #00D4A0 stays uniquely "edge").
|
||||
- **Each archetype gets its one-line meaning on-page** where it leads (player page, grade reveal), expander for the long form.
|
||||
|
||||
## PART 6 — CONVERSION ARCHITECTURE (design that produces the million)
|
||||
- **Never an empty hero (#1):** "Top grades tonight" must fall back to yesterday's A-tier settled receipts (you have 55-19) — first paint ALWAYS proves the model.
|
||||
- **Desk $44.99 = the hero tier (#8):** rebuild pricing so the premium tier carries the "$1M terminal" story — real feature visuals, balanced layout (kill the dead right-half), a CTA at least as strong as Analyst's. Stop selling down.
|
||||
- **Landing 5-second test:** one sharp positioning line about how it FEELS + live proof above the fold (real grades, the record). The product working IS the hero. Not features, not animation.
|
||||
- **Build the missing /u profile (#4):** the #1 shareable surface doesn't exist — a discoverable, designed, CLV-verified public profile (Kev's partner-pitch ammo).
|
||||
- **Fix scan→ledger persistence (#17):** a completed read must land in the ledger instantly — the "Every grade, no hiding" promise breaking is a conversion-killer.
|
||||
- **Growth prompts don't occlude the product (#9):** the notify/PWA card shows once, dismissible, one primary CTA.
|
||||
|
||||
## PART 7 — SCREENSHOT-FIRST PRIORITY (the timeline is customer #1)
|
||||
Design attention follows screenshot value: **P0 billboards** (Settle card, grade reveal, STREAKS row, /u profile, OG images) get pixel-level craft, tested at social crop ratios · **P1** landing/dashboard/pricing · **P2** slate rows/scan/explore/player/team · **P3** settings/onboarding. The STREAKS row (#2) is a billboard: real player image, streak-length as mono hero, muted "built vs" context, one severity accent — a Bloomberg alert, not a log line.
|
||||
|
||||
## PART 8 — EMPTY/ERROR SYSTEM (fixes #20)
|
||||
The 404 page is already on-brand (glitch, "TRANSMISSION INTERRUPTED", correct CTA hierarchy). Make it the quality bar: ONE designed empty/error system reused everywhere. Kill the bare-red "Team not found"; unify the three offseason-state voices into one.
|
||||
|
||||
---
|
||||
|
||||
## THE DESIGN TRAIN (build sequence — sessions like the A1 board)
|
||||
- **DS0 — Entity Layer** (Part 2): logos/colors/headshots/wordmarks system, swapped into every surface. Biggest single transformation; founder's #1.
|
||||
- **DS1 — Speed + Trust Bugs** (Part 4 + #17): fix React #418, skeletons replace text-walls, scan→ledger persistence.
|
||||
- **DS2 — Dashboard Slate Rebuild** (the named #1 rebuild): real entities + one hero per card + collapse pending-filler + never-empty hero. Inherit the team-hub bar.
|
||||
- **DS3 — Color Contract** (Part 1): green = one meaning, edge/CLV by sign, glow A-tier only, re-skin green-flooded panels + double-green CTAs, dedupe archetype greens.
|
||||
- **DS4 — Billboards** (Part 7): STREAKS row, grade reveal, CLV reframe, build /u profile.
|
||||
- **DS5 — Pricing + Motion + States**: Desk-as-hero pricing, ticker to punctuated stillness, unify empty/error to the 404 bar, propagate archetype glyphs everywhere (Part 5).
|
||||
|
||||
Each DS session: tokenized components, QA-locked (the color contract becomes failing tests), prefers-reduced-motion respected, acceptance evidence, ship on green. Same discipline as A1.
|
||||
|
||||
— DESIGN SPEC v2 · the presentation catches up to the substance —
|
||||
@@ -1,6 +1,40 @@
|
||||
# VYNDR — STATE OF THE WORLD
|
||||
### As of `ec5ee3d` (main, 2026-07-12). This file opens every future session. Update it when a train ships.
|
||||
|
||||
## READY TO MERGE — DESIGN TRAIN v2 (branch `design/v2-train` @ `3d08390`, NOT on main yet)
|
||||
Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as
|
||||
themselves, color contract, sub-200ms, screenshot-first billboards). **All six
|
||||
sessions DS0–DS5 are DONE, merged onto `design/v2-train`, green
|
||||
(238 suites / 2885 tests) + `next build` exit 0. Awaiting the founder's
|
||||
"merge the train" before touching main.**
|
||||
- **DS0 Entity Layer** (`24af247`): `teamMeta.js` (real logos+colors for 30 MLB /
|
||||
30 NBA / 13 WNBA / 48 WC nations via ESPN CDNs), `TeamLogo` / `PlayerAvatar`
|
||||
(team-colored monogram fallback, no gray silhouette) / `BookWordmark`, swapped
|
||||
into GameCard/StatStrip/StreaksPanel/TeamHub.
|
||||
- **DS1 Speed + Trust Bugs** (`1c681df`): React #418 hydration fix (mounted-flag
|
||||
guard), layout-matched `Skeleton`s replace text-wall loaders, scan→ledger
|
||||
persistence via `session.access_token`. **DS1 follow-up** (`cf91c04`): closed
|
||||
the sb-token trust-bug CLASS — `lib/authToken.js` `currentAccessToken()` reads
|
||||
the REAL Supabase session (not the OAuth-only `sb-token` key) across
|
||||
profile/slip/dashboard/settings/tracker.
|
||||
- **DS3 Color Contract** (`49a3323`): `lib/colorContract.js` — green = one meaning
|
||||
(edge/A-tier/CTA), edge/CLV by sign, glow A/A+ only, archetype greens deduped.
|
||||
Enforced by `colorContract.test.js` (fails on violation).
|
||||
- **DS4 Billboards** (`45bafbc`): STREAKS row (length as mono hero), grade reveal
|
||||
(sign-colored edge), CLV reframe (flat says so), `/u` public profile + OG.
|
||||
- **DS2 Dashboard Slate Rebuild** (`fe294a5`): never-empty hero (falls back to
|
||||
yesterday's A-tier settled receipts), one bold hero per card, pending-filler
|
||||
collapse, ranked tonight-grades. `slateAdapter.js` engine + 21 tests.
|
||||
- **DS5 Pricing + Motion + States** (`a18a3f3`): Desk $44.99 as the hero tier
|
||||
(single green CTA, `DeskShowcase`), ticker → punctuated stillness (≥4.2s hold,
|
||||
one idle motion), unified `EmptyState` (404 grammar) at TeamHub/game/ledger,
|
||||
archetype glyphs propagated to streaks/ledger.
|
||||
NOTE: WSL2↔Windows-Chrome localhost is unreachable in this env, so visual proof
|
||||
is SSR-render + source-assertion tests, not pixel screenshots. Post-merge TODO:
|
||||
re-run the Chrome design audit against v2 to confirm the 22 findings closed.
|
||||
|
||||
|
||||
|
||||
## WHERE THINGS STAND
|
||||
|
||||
**Tests: 2757 / 229 suites, all green. Web build exit 0.** Baseline at the A1
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)'");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 model’s 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' }}>
|
||||
✓ 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`}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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't on tonight'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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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’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>
|
||||
);
|
||||
}
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}` } : {},
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/* ============================================================
|
||||
DS0 (Design v2) — THE ENTITY LAYER: team logos + real colors.
|
||||
|
||||
Teams render as THEMSELVES (DESIGN-SPEC Part 2 / Part 0 law #4): real
|
||||
logo + team-colored accent, never a bare "MIL @ PIT" string.
|
||||
|
||||
Colors + abbrs are stable public facts (like teams.js's names), so a
|
||||
static registry is honest and zero-latency — no runtime fetch, no paid
|
||||
dependency. Colors sourced once from ESPN's team API; logos from ESPN's
|
||||
free CDN (verified 200 image/png). CommonJS so Jest requires it and the
|
||||
.tsx components import it (allowJs).
|
||||
|
||||
Each entry: { name, espn (logo-CDN abbr), primary, secondary }. Keyed by
|
||||
the app/statsapi abbr; resolveTeam() also matches full names + nicknames.
|
||||
The ESPN "primary" color is often near-black (dark-theme invisible), so
|
||||
accentColor() picks the more VISIBLE of the two against #06060B.
|
||||
============================================================ */
|
||||
|
||||
// abbr → meta. MLB abbrs match statsapi; where ESPN's logo abbr differs
|
||||
// (AZ→ari, CWS→chw) the `espn` field carries the CDN spelling.
|
||||
const MLB = {
|
||||
AZ: { name: 'Arizona Diamondbacks', espn: 'ari', primary: '#aa182c', secondary: '#e3d4ad' },
|
||||
ATH: { name: 'Athletics', espn: 'oak', primary: '#003831', secondary: '#efb21e' },
|
||||
ATL: { name: 'Atlanta Braves', espn: 'atl', primary: '#ba0c2f', secondary: '#13274f' },
|
||||
BAL: { name: 'Baltimore Orioles', espn: 'bal', primary: '#df4601', secondary: '#000000' },
|
||||
BOS: { name: 'Boston Red Sox', espn: 'bos', primary: '#bd3039', secondary: '#0d2b56' },
|
||||
CHC: { name: 'Chicago Cubs', espn: 'chc', primary: '#0e3386', secondary: '#cc3433' },
|
||||
CWS: { name: 'Chicago White Sox', espn: 'chw', primary: '#c4ced4', secondary: '#000000' },
|
||||
CIN: { name: 'Cincinnati Reds', espn: 'cin', primary: '#c6011f', secondary: '#000000' },
|
||||
CLE: { name: 'Cleveland Guardians', espn: 'cle', primary: '#e31937', secondary: '#002b5c' },
|
||||
COL: { name: 'Colorado Rockies', espn: 'col', primary: '#8e6cae', secondary: '#c4ced4' },
|
||||
DET: { name: 'Detroit Tigers', espn: 'det', primary: '#0a2240', secondary: '#ff4713' },
|
||||
HOU: { name: 'Houston Astros', espn: 'hou', primary: '#eb6e1f', secondary: '#002d62' },
|
||||
KC: { name: 'Kansas City Royals', espn: 'kc', primary: '#7ab2dd', secondary: '#004687' },
|
||||
LAA: { name: 'Los Angeles Angels', espn: 'laa', primary: '#ba0021', secondary: '#c4ced4' },
|
||||
LAD: { name: 'Los Angeles Dodgers', espn: 'lad', primary: '#005a9c', secondary: '#ef3e42' },
|
||||
MIA: { name: 'Miami Marlins', espn: 'mia', primary: '#00a3e0', secondary: '#ef3340' },
|
||||
MIL: { name: 'Milwaukee Brewers', espn: 'mil', primary: '#ffc72c', secondary: '#13294b' },
|
||||
MIN: { name: 'Minnesota Twins', espn: 'min', primary: '#e20e32', secondary: '#002b5c' },
|
||||
NYM: { name: 'New York Mets', espn: 'nym', primary: '#ff5910', secondary: '#002d72' },
|
||||
NYY: { name: 'New York Yankees', espn: 'nyy', primary: '#c4ced4', secondary: '#132448' },
|
||||
PHI: { name: 'Philadelphia Phillies',espn: 'phi', primary: '#e81828', secondary: '#284898' },
|
||||
PIT: { name: 'Pittsburgh Pirates', espn: 'pit', primary: '#fdb827', secondary: '#000000' },
|
||||
SD: { name: 'San Diego Padres', espn: 'sd', primary: '#ffc425', secondary: '#2f241d' },
|
||||
SF: { name: 'San Francisco Giants', espn: 'sf', primary: '#fd5a1e', secondary: '#000000' },
|
||||
SEA: { name: 'Seattle Mariners', espn: 'sea', primary: '#0c8f8f', secondary: '#0c2c56' },
|
||||
STL: { name: 'St. Louis Cardinals', espn: 'stl', primary: '#be0a14', secondary: '#0a2252' },
|
||||
TB: { name: 'Tampa Bay Rays', espn: 'tb', primary: '#8fbce6', secondary: '#092c5c' },
|
||||
TEX: { name: 'Texas Rangers', espn: 'tex', primary: '#c0111f', secondary: '#003278' },
|
||||
TOR: { name: 'Toronto Blue Jays', espn: 'tor', primary: '#1d78cf', secondary: '#134a8e' },
|
||||
WSH: { name: 'Washington Nationals', espn: 'wsh', primary: '#ab0003', secondary: '#11225b' },
|
||||
};
|
||||
|
||||
const NBA = {
|
||||
ATL: { name: 'Atlanta Hawks', espn: 'atl', primary: '#e03a3e', secondary: '#fdb927' },
|
||||
BOS: { name: 'Boston Celtics', espn: 'bos', primary: '#008348', secondary: '#ffffff' },
|
||||
BKN: { name: 'Brooklyn Nets', espn: 'bkn', primary: '#c4ced4', secondary: '#000000' },
|
||||
CHA: { name: 'Charlotte Hornets', espn: 'cha', primary: '#00a3af', secondary: '#1d1160' },
|
||||
CHI: { name: 'Chicago Bulls', espn: 'chi', primary: '#ce1141', secondary: '#000000' },
|
||||
CLE: { name: 'Cleveland Cavaliers', espn: 'cle', primary: '#bc945c', secondary: '#860038' },
|
||||
DAL: { name: 'Dallas Mavericks', espn: 'dal', primary: '#0064b1', secondary: '#bbc4ca' },
|
||||
DEN: { name: 'Denver Nuggets', espn: 'den', primary: '#fec524', secondary: '#0e2240' },
|
||||
DET: { name: 'Detroit Pistons', espn: 'det', primary: '#1d428a', secondary: '#c8102e' },
|
||||
GSW: { name: 'Golden State Warriors', espn: 'gs', primary: '#fdb927', secondary: '#1d428a' },
|
||||
HOU: { name: 'Houston Rockets', espn: 'hou', primary: '#ce1141', secondary: '#c4ced4' },
|
||||
IND: { name: 'Indiana Pacers', espn: 'ind', primary: '#ffd520', secondary: '#0c2340' },
|
||||
LAC: { name: 'LA Clippers', espn: 'lac', primary: '#c8102e', secondary: '#1d428a' },
|
||||
LAL: { name: 'Los Angeles Lakers', espn: 'lal', primary: '#fdb927', secondary: '#552583' },
|
||||
MEM: { name: 'Memphis Grizzlies', espn: 'mem', primary: '#5d76a9', secondary: '#12173f' },
|
||||
MIA: { name: 'Miami Heat', espn: 'mia', primary: '#98002e', secondary: '#f9a01b' },
|
||||
MIL: { name: 'Milwaukee Bucks', espn: 'mil', primary: '#00471b', secondary: '#eee1c6' },
|
||||
MIN: { name: 'Minnesota Timberwolves', espn: 'min', primary: '#79bc43', secondary: '#266092' },
|
||||
NOP: { name: 'New Orleans Pelicans', espn: 'no', primary: '#b4975a', secondary: '#0a2240' },
|
||||
NYK: { name: 'New York Knicks', espn: 'ny', primary: '#f58426', secondary: '#1d428a' },
|
||||
OKC: { name: 'Oklahoma City Thunder', espn: 'okc', primary: '#007ac1', secondary: '#ef3b24' },
|
||||
ORL: { name: 'Orlando Magic', espn: 'orl', primary: '#0077c0', secondary: '#c4ced4' },
|
||||
PHI: { name: 'Philadelphia 76ers', espn: 'phi', primary: '#1d428a', secondary: '#e01234' },
|
||||
PHX: { name: 'Phoenix Suns', espn: 'phx', primary: '#e56020', secondary: '#1d1160' },
|
||||
POR: { name: 'Portland Trail Blazers', espn: 'por', primary: '#e03a3e', secondary: '#c4ced4' },
|
||||
SAC: { name: 'Sacramento Kings', espn: 'sac', primary: '#5a2d81', secondary: '#63727a' },
|
||||
SAS: { name: 'San Antonio Spurs', espn: 'sa', primary: '#c4ced4', secondary: '#000000' },
|
||||
TOR: { name: 'Toronto Raptors', espn: 'tor', primary: '#ce1141', secondary: '#000000' },
|
||||
UTA: { name: 'Utah Jazz', espn: 'utah', primary: '#79a3dc', secondary: '#4e008e' },
|
||||
WAS: { name: 'Washington Wizards', espn: 'wsh', primary: '#e31837', secondary: '#002b5c' },
|
||||
};
|
||||
|
||||
const WNBA = {
|
||||
ATL: { name: 'Atlanta Dream', espn: 'atl', primary: '#e31837', secondary: '#5091cc' },
|
||||
CHI: { name: 'Chicago Sky', espn: 'chi', primary: '#5091cd', secondary: '#ffd520' },
|
||||
CON: { name: 'Connecticut Sun', espn: 'conn', primary: '#f05023', secondary: '#0a2240' },
|
||||
DAL: { name: 'Dallas Wings', espn: 'dal', primary: '#c4d600', secondary: '#002b5c' },
|
||||
GSV: { name: 'Golden State Valkyries', espn: 'gs', primary: '#b38fcf', secondary: '#000000' },
|
||||
IND: { name: 'Indiana Fever', espn: 'ind', primary: '#e03a3e', secondary: '#002d62' },
|
||||
LV: { name: 'Las Vegas Aces', espn: 'lv', primary: '#a7a8aa', secondary: '#000000' },
|
||||
LA: { name: 'Los Angeles Sparks', espn: 'la', primary: '#fdb927', secondary: '#552583' },
|
||||
MIN: { name: 'Minnesota Lynx', espn: 'min', primary: '#79bc43', secondary: '#266092' },
|
||||
NY: { name: 'New York Liberty', espn: 'ny', primary: '#86cebc', secondary: '#000000' },
|
||||
PHX: { name: 'Phoenix Mercury', espn: 'phx', primary: '#fa4b0a', secondary: '#3c286e' },
|
||||
SEA: { name: 'Seattle Storm', espn: 'sea', primary: '#2c5235', secondary: '#fee11a' },
|
||||
WAS: { name: 'Washington Mystics', espn: 'wsh', primary: '#e03a3e', secondary: '#002b5c' },
|
||||
};
|
||||
|
||||
// Soccer = World Cup national teams (fifa.world). Keyed by the ESPN country
|
||||
// code (uppercased); the flag CDN is a different path (countries/, not
|
||||
// teamlogos/soccer). Colors + codes from ESPN's fifa.world API. National
|
||||
// teams ARE their flag — that's the crest.
|
||||
const SOCCER = {
|
||||
ALG: { name: 'Algeria', espn: 'alg', primary: '#4f9a44', secondary: '#ffffff' },
|
||||
ARG: { name: 'Argentina', espn: 'arg', primary: '#74acdf', secondary: '#173e69' },
|
||||
AUS: { name: 'Australia', espn: 'aus', primary: '#ffcd00', secondary: '#00843d' },
|
||||
AUT: { name: 'Austria', espn: 'aut', primary: '#d72b2c', secondary: '#000000' },
|
||||
BEL: { name: 'Belgium', espn: 'bel', primary: '#e30613', secondary: '#6ecff6' },
|
||||
BIH: { name: 'Bosnia-Herzegovina', espn: 'bih', primary: '#112855', secondary: '#ffce00' },
|
||||
BRA: { name: 'Brazil', espn: 'bra', primary: '#fee000', secondary: '#193375' },
|
||||
CAN: { name: 'Canada', espn: 'can', primary: '#ed2224', secondary: '#ffffff' },
|
||||
CPV: { name: 'Cape Verde', espn: 'cpv', primary: '#0537e4', secondary: '#ef3340' },
|
||||
COL: { name: 'Colombia', espn: 'col', primary: '#fbd632', secondary: '#21418c' },
|
||||
RDC: { name: 'Congo DR', espn: 'rdc', primary: '#418fde', secondary: '#c60000' },
|
||||
CRO: { name: 'Croatia', espn: 'cro', primary: '#ff0000', secondary: '#0c2fff' },
|
||||
CUW: { name: 'Curaçao', espn: '11678', primary: '#0537e4', secondary: '#ffce00' },
|
||||
CZE: { name: 'Czechia', espn: 'cze', primary: '#d7141a', secondary: '#11457e' },
|
||||
ECU: { name: 'Ecuador', espn: 'ecu', primary: '#ffdd00', secondary: '#034ea2' },
|
||||
EGY: { name: 'Egypt', espn: 'egy', primary: '#d20300', secondary: '#ffffff' },
|
||||
ENG: { name: 'England', espn: 'eng', primary: '#ea1f29', secondary: '#ffffff' },
|
||||
FRA: { name: 'France', espn: 'fra', primary: '#3a6dd8', secondary: '#ef4135' },
|
||||
GER: { name: 'Germany', espn: 'ger', primary: '#ffce00', secondary: '#dd0000' },
|
||||
GHA: { name: 'Ghana', espn: 'gha', primary: '#fbd632', secondary: '#006b3f' },
|
||||
HAI: { name: 'Haiti', espn: 'hai', primary: '#0033a0', secondary: '#d21034' },
|
||||
IRN: { name: 'Iran', espn: 'irn', primary: '#da0000', secondary: '#239f40' },
|
||||
IRQ: { name: 'Iraq', espn: 'irq', primary: '#0a4d2e', secondary: '#ce1126' },
|
||||
CIV: { name: 'Ivory Coast', espn: 'civ', primary: '#ff8200', secondary: '#009e60' },
|
||||
JPN: { name: 'Japan', espn: 'jpn', primary: '#3a6dd8', secondary: '#bc002d' },
|
||||
JOR: { name: 'Jordan', espn: 'jor', primary: '#e70000', secondary: '#007a3d' },
|
||||
MEX: { name: 'Mexico', espn: 'mex', primary: '#00843d', secondary: '#ce1126' },
|
||||
MAR: { name: 'Morocco', espn: 'mar', primary: '#df2027', secondary: '#006233' },
|
||||
NED: { name: 'Netherlands', espn: 'ned', primary: '#fb5d00', secondary: '#21468b' },
|
||||
NZL: { name: 'New Zealand', espn: 'nzl', primary: '#c8c8c8', secondary: '#000000' },
|
||||
NOR: { name: 'Norway', espn: 'nor', primary: '#c8102e', secondary: '#00205b' },
|
||||
PAN: { name: 'Panama', espn: 'pan', primary: '#d21034', secondary: '#005293' },
|
||||
PAR: { name: 'Paraguay', espn: 'par', primary: '#ea2300', secondary: '#21418c' },
|
||||
POR: { name: 'Portugal', espn: 'por', primary: '#da291c', secondary: '#0d6938' },
|
||||
QAT: { name: 'Qatar', espn: 'qat', primary: '#8a1538', secondary: '#ffffff' },
|
||||
KSA: { name: 'Saudi Arabia', espn: 'ksa', primary: '#0f7a3d', secondary: '#ffffff' },
|
||||
SCO: { name: 'Scotland', espn: 'sco', primary: '#4b6cb7', secondary: '#1a2d69' },
|
||||
SEN: { name: 'Senegal', espn: 'sen', primary: '#00853f', secondary: '#fdef42' },
|
||||
RSA: { name: 'South Africa', espn: 'rsa', primary: '#ffb81c', secondary: '#0c562e' },
|
||||
KOR: { name: 'South Korea', espn: 'kors', primary: '#ce2028', secondary: '#003478' },
|
||||
ESP: { name: 'Spain', espn: 'esp', primary: '#c60b1e', secondary: '#ffc400' },
|
||||
SWE: { name: 'Sweden', espn: 'swe', primary: '#fecb00', secondary: '#006aa7' },
|
||||
SUI: { name: 'Switzerland', espn: 'sui', primary: '#ff0000', secondary: '#ffffff' },
|
||||
TUN: { name: 'Tunisia', espn: 'tun', primary: '#d20300', secondary: '#ffffff' },
|
||||
TUR: { name: 'Türkiye', espn: 'tur', primary: '#ef3340', secondary: '#ffffff' },
|
||||
USA: { name: 'United States', espn: 'usa', primary: '#3a6dd8', secondary: '#d42339' },
|
||||
URU: { name: 'Uruguay', espn: 'uru', primary: '#55b5e5', secondary: '#00205b' },
|
||||
UZB: { name: 'Uzbekistan', espn: 'uzb', primary: '#0072ce', secondary: '#1eb53a' },
|
||||
};
|
||||
|
||||
const LEAGUES = { mlb: MLB, nba: NBA, wnba: WNBA, soccer: SOCCER };
|
||||
|
||||
// Common naming variants the odds feeds use → the canonical soccer key.
|
||||
const SOCCER_ALIAS = {
|
||||
usa: 'USA', unitedstates: 'USA', usmnt: 'USA',
|
||||
southkorea: 'KOR', koreademocraticpeoplesrepublic: 'KOR', kore : 'KOR', korearepublic: 'KOR',
|
||||
ivorycoast: 'CIV', cotedivoire: 'CIV',
|
||||
turkey: 'TUR', turkiye: 'TUR',
|
||||
czechrepublic: 'CZE', czechia: 'CZE',
|
||||
bosnia: 'BIH', bosniaandherzegovina: 'BIH',
|
||||
congodr: 'RDC', drcongo: 'RDC',
|
||||
curacao: 'CUW',
|
||||
};
|
||||
|
||||
// Full-name + nickname → abbr, built once per league for resolveTeam().
|
||||
const NAME_INDEX = {};
|
||||
for (const [sp, table] of Object.entries(LEAGUES)) {
|
||||
NAME_INDEX[sp] = {};
|
||||
for (const [abbr, meta] of Object.entries(table)) {
|
||||
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
NAME_INDEX[sp][norm(meta.name)] = abbr;
|
||||
// nickname = last word ("Yankees", "Red Sox" → "redsox")
|
||||
const parts = meta.name.split(/\s+/);
|
||||
NAME_INDEX[sp][norm(parts[parts.length - 1])] = abbr;
|
||||
// two-word nickname ("Red Sox", "Blue Jays", "White Sox")
|
||||
if (parts.length >= 2) NAME_INDEX[sp][norm(parts.slice(-2).join(''))] = abbr;
|
||||
}
|
||||
}
|
||||
|
||||
const normKey = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
/** Resolve any team key (abbr, full name, nickname) + sport → meta or null. */
|
||||
function resolveTeam(key, sport) {
|
||||
if (!key) return null;
|
||||
const sp = String(sport || 'mlb').toLowerCase();
|
||||
const table = LEAGUES[sp];
|
||||
if (!table) return null;
|
||||
const up = String(key).toUpperCase().trim();
|
||||
if (table[up]) return { abbr: up, sport: sp, ...table[up] };
|
||||
// statsapi/ESPN alias fallbacks for the two MLB mismatches
|
||||
const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' };
|
||||
if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] };
|
||||
if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) {
|
||||
const a = SOCCER_ALIAS[normKey(key)];
|
||||
return { abbr: a, sport: sp, ...table[a] };
|
||||
}
|
||||
const byName = NAME_INDEX[sp][normKey(key)];
|
||||
if (byName) return { abbr: byName, sport: sp, ...table[byName] };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ESPN free logo CDN url for a team, or null when unresolved. Soccer national
|
||||
* teams use the flag CDN (countries/ for a 3-letter code, soccer/ for the rare
|
||||
* numeric team id); the ball sports use teamlogos/{league}/. */
|
||||
function teamLogoUrl(key, sport) {
|
||||
const m = resolveTeam(key, sport);
|
||||
if (!m) return null;
|
||||
if (m.sport === 'soccer') {
|
||||
return /^\d+$/.test(m.espn)
|
||||
? `https://a.espncdn.com/i/teamlogos/soccer/500/${m.espn}.png`
|
||||
: `https://a.espncdn.com/i/teamlogos/countries/500/${m.espn}.png`;
|
||||
}
|
||||
const path = m.sport === 'wnba' ? 'wnba' : 'nba';
|
||||
return `https://a.espncdn.com/i/teamlogos/${m.sport === 'mlb' ? 'mlb' : path}/500/${m.espn}.png`;
|
||||
}
|
||||
|
||||
// Perceived luminance of a #rrggbb (0–255). Used to keep team accents
|
||||
// VISIBLE on the near-black terminal bg — a #000000 primary is useless.
|
||||
function luminance(hex) {
|
||||
const h = String(hex || '').replace('#', '');
|
||||
if (h.length !== 6) return 0;
|
||||
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/** The team color to use for an accent/monogram against the dark theme:
|
||||
* the more visible of primary/secondary. Falls back to a neutral when
|
||||
* both are near-black (never returns an invisible accent). */
|
||||
function accentColor(key, sport) {
|
||||
const m = resolveTeam(key, sport);
|
||||
if (!m) return null;
|
||||
const lp = luminance(m.primary), ls = luminance(m.secondary);
|
||||
const best = lp >= ls ? m.primary : m.secondary;
|
||||
return luminance(best) < 40 ? '#8A8A9A' : best; // both dark → neutral chrome
|
||||
}
|
||||
|
||||
module.exports = { resolveTeam, teamLogoUrl, accentColor, luminance, LEAGUES, __tables: { MLB, NBA, WNBA } };
|
||||
Reference in New Issue
Block a user