From 125919f86ae66f705db62aaa5ce0c2e298b4e821 Mon Sep 17 00:00:00 2001 From: Kev Date: Wed, 22 Jul 2026 21:52:41 -0400 Subject: [PATCH] =?UTF-8?q?Scanner=20reskin:=20amber=20=E2=86=92=20blue=20?= =?UTF-8?q?boundary=20channel=20+=20build=20never-built=20S6/S7=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantic COLOR fix, not cosmetic. The S6/S7 build predated the current Scanner States spec: it used AMBER (the quarantine / model-suppressed channel) for the "no market / line not priced" case, telling users "model suppressed" when the truth is "the board never priced this." Corrected to the HANDOFF Session-3 blue-boundary law: BLUE (--priced-out #8FB2DE) = no-market boundary; amber stays QUARANTINE; red stays REFUSAL. Phase 1 (reskin): NoMarketState → dashed BLUE void box + blue header/copy; the S7 rows → spec format (o 27.5 · BK −114 · ◆ −105 · OPEN READ ▸) at 44px, 390-legible. Input-area surfacer pills reskinned to the blue channel too. Phase 2 (never-built states, only those Phase 0 confirmed against live data): - GREEN CTA with LIVE player count ("PLAYER · N PRICED PROPS ▸"), degrading honestly to the board path ("N PROPS LIVE · TONIGHT'S BOARD ▸") at 0 — count from the SAME fresh index as the rows (pricedCountForPlayer), can't disagree. - CASE A none-priced DEFAULT: "WE PRICE THESE FOR [player]" — the player's other priced stats (pricedStatsForPlayer, filter by nameKey). - Typed-line-mismatch blue fact line ("o X ISN'T PRICED · NEAREST ↓"). FLAGGED / not built (no shells): Case C off-slate quiet-stop needs schedule/ roster membership the pricedLines index doesn't carry (out of the presentation fence). Spec CONTRADICTION: Case B says "fair previews amber," but the law reserves amber for quarantine — fair renders NEUTRAL ◆ (blue-dim), not amber, to avoid blurring the channel. Free-tier gate VERIFIED before rendering FAIR: fair_odds is the de-vigged MARKET price (valueState: "never hide the honest fair number"), NOT the gated model_odds — no paid leak. Carried through indexPricedLines (additive; keying/ refresh/onPick/stale-tap all unchanged — the proven S7 data path is untouched). Scan A byte-identical; PRICED_NUDGE_ENABLED still the kill switch; reversible. Build exit 0; priced + parity suites green (67). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj --- tests/unit/pricedLines.test.js | 65 +++++- web/src/app/scan/page.tsx | 47 +++- web/src/components/vyndr/NoMarketState.tsx | 239 +++++++++++++++------ web/src/lib/pricedLines.js | 76 ++++++- 4 files changed, 350 insertions(+), 77 deletions(-) diff --git a/tests/unit/pricedLines.test.js b/tests/unit/pricedLines.test.js index d32e750..617cb0c 100644 --- a/tests/unit/pricedLines.test.js +++ b/tests/unit/pricedLines.test.js @@ -3,7 +3,10 @@ NON-FABRICATING: only real snapshot lines with a real book price surface. ============================================================ */ -const { indexPricedLines, pricedLinesFor } = require('../../web/src/lib/pricedLines'); +const { + indexPricedLines, pricedLinesFor, pricedStatsForPlayer, pricedCountForPlayer, + totalPricedCount, nearestPricedLine, +} = require('../../web/src/lib/pricedLines'); const GRADES = [ { player_name: 'Gabriel Arias', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -165, fair_odds: -140 }, @@ -17,7 +20,16 @@ describe('indexPricedLines — only real, priced rows', () => { it('indexes a priced row by player+stat', () => { const idx = indexPricedLines(GRADES); const rows = pricedLinesFor(idx, 'Gabriel Arias', 'hits'); - expect(rows).toEqual([{ line: 0.5, direction: 'over', book_odds: -165 }]); + // fair_odds is the de-vigged MARKET price, carried through for the S7 ◆ FAIR + // cell (free-tier visible — NOT the gated model_odds). + expect(rows).toEqual([{ line: 0.5, direction: 'over', book_odds: -165, fair_odds: -140 }]); + }); + + it('carries fair_odds strictly (null when absent — never Number(null)===0)', () => { + const idx = indexPricedLines([ + { player_name: 'Y', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -110 }, // no fair + ]); + expect(pricedLinesFor(idx, 'Y', 'hits')[0].fair_odds).toBeNull(); }); it('EXCLUDES a graded row with no book price — not a surfaceable line', () => { @@ -65,6 +77,55 @@ describe('indexPricedLines — only real, priced rows', () => { }); }); +// ── S7 reskin — count + Case-A helpers, all from the SAME fresh index ──────── +describe('pricedStatsForPlayer / count / board — one source, honest degrade', () => { + const MULTI = [ + { player_name: 'Justin Herbert', stat_type: 'pass_yds', line: 275.5, direction: 'over', book_odds: -115, fair_odds: -108 }, + { player_name: 'Justin Herbert', stat_type: 'pass_td', line: 1.5, direction: 'over', book_odds: 120, fair_odds: 135 }, + { player_name: 'Justin Herbert', stat_type: 'interceptions', line: 0.5, direction: 'over', book_odds: 150, fair_odds: 170 }, + { player_name: 'Other Guy', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -110, fair_odds: -100 }, + ]; + + it('pricedStatsForPlayer returns the player\'s priced stats across the board', () => { + const idx = indexPricedLines(MULTI); + const stats = pricedStatsForPlayer(idx, 'Justin Herbert').map((s) => s.stat).sort(); + expect(stats).toEqual(['interceptions', 'pass_td', 'pass_yds']); + }); + + it('excludeStat drops the already-selected stat (Case A "these OTHERS")', () => { + const idx = indexPricedLines(MULTI); + const stats = pricedStatsForPlayer(idx, 'Justin Herbert', 'pass_yds').map((s) => s.stat).sort(); + expect(stats).toEqual(['interceptions', 'pass_td']); + }); + + it('count comes from the SAME index as the rows (can\'t disagree)', () => { + const idx = indexPricedLines(MULTI); + expect(pricedCountForPlayer(idx, 'Justin Herbert')).toBe(3); + }); + + it('count is 0 for an off-slate player → caller degrades, never "0 PROPS"', () => { + const idx = indexPricedLines(MULTI); + expect(pricedCountForPlayer(idx, 'Nobody Here')).toBe(0); + }); + + it('totalPricedCount is the board-wide prop count (real exhaust or 0)', () => { + expect(totalPricedCount(indexPricedLines(MULTI))).toBe(4); + expect(totalPricedCount(indexPricedLines([]))).toBe(0); + expect(totalPricedCount(null)).toBe(0); + }); + + it('nearestPricedLine picks the closest priced line to a typed line', () => { + const idx = indexPricedLines([ + { player_name: 'Z', stat_type: 'points', line: 27.5, direction: 'over', book_odds: -114, fair_odds: -105 }, + { player_name: 'Z', stat_type: 'points', line: 24.5, direction: 'over', book_odds: -120, fair_odds: -110 }, + ]); + expect(nearestPricedLine(idx, 'Z', 'points', 30.5).line).toBe(27.5); + expect(nearestPricedLine(idx, 'Z', 'points', 24).line).toBe(24.5); + expect(nearestPricedLine(idx, 'Z', 'points', null).line).toBe(24.5); // sorted first + expect(nearestPricedLine(idx, 'Nobody', 'points', 30)).toBeNull(); + }); +}); + describe('the empty state + surfacer are independently reversible', () => { const fs = require('fs'); it('the working card/join/triplet are untouched (Scan A byte-identical)', () => { diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index 078c5c3..808d324 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -17,8 +17,10 @@ import { trackUpgradeClicked, } from '@/lib/analytics'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; -import NoMarketState, { type PricedLine } from '@/components/vyndr/NoMarketState'; -import { indexPricedLines, pricedLinesFor } from '@/lib/pricedLines'; +import NoMarketState, { type PricedLine, type PricedStat } from '@/components/vyndr/NoMarketState'; +import { + indexPricedLines, pricedLinesFor, pricedStatsForPlayer, pricedCountForPlayer, totalPricedCount, +} from '@/lib/pricedLines'; import { type HeadshotSport } from '@/lib/playerHeadshot'; import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks'; @@ -173,6 +175,23 @@ export default function ScanPage() { ? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []), [pricedIndex, selectedPlayer, stat], ); + // Session (scanner reskin) — S7 Case A + live count, ALL derived from the SAME + // fresh pricedIndex as pricedForSelection, so the count can never disagree + // with the rows. Presentation-only; the index/refresh/onPick path is unchanged. + const pricedStatsForSelection = useMemo( + () => (PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer + ? pricedStatsForPlayer(pricedIndex, selectedPlayer, stat) : []), + [pricedIndex, selectedPlayer, stat], + ); + const playerPricedCount = useMemo( + () => (PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer + ? pricedCountForPlayer(pricedIndex, selectedPlayer) : 0), + [pricedIndex, selectedPlayer], + ); + const boardPricedCount = useMemo( + () => (PRICED_NUDGE_ENABLED && pricedIndex ? totalPricedCount(pricedIndex) : 0), + [pricedIndex], + ); // One-tap re-scan after a surfaced priced line commits its line/direction. useEffect(() => { @@ -681,9 +700,13 @@ export default function ScanPage() { {/* Session 79 — HELP, not restriction: the board's REAL priced lines for this exact player+stat. Tap to pre-fill a line that will yield a real triplet. The scanner still accepts any free-typed line. */} + {/* S7 Case B — the board's REAL priced lines for this player+stat, in + the blue boundary channel (--priced-out): line · BK odds · ◆ fair. + Tap pre-fills the line so the next scan yields a real triplet. Fair + renders NEUTRAL, not amber (blue-boundary law). */} {pricedForSelection.length > 0 && (
- + PRICED TONIGHT: {pricedForSelection.map((l, i) => ( @@ -692,9 +715,20 @@ export default function ScanPage() { type="button" onClick={() => { setLine(String(l.line)); setDirection(l.direction); }} className="mono" - style={{ cursor: 'pointer', fontSize: 11, fontWeight: 700, padding: '4px 9px', borderRadius: 999, border: '1px solid var(--border-hi)', background: 'transparent', color: 'var(--text-1)', fontVariantNumeric: 'tabular-nums' }} + style={{ + display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', + minHeight: 40, fontSize: 11, fontWeight: 700, padding: '0 11px', borderRadius: 999, + border: '1px solid var(--priced-out-border-hi, rgba(106,147,200,0.34))', + background: 'var(--priced-out-tint-2, rgba(106,147,200,0.12))', + color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums', + }} > - {l.direction === 'under' ? 'U' : 'O'} {l.line} + {l.direction === 'under' ? 'u' : 'o'} {l.line} + · + BK {l.book_odds > 0 ? `+${Math.round(l.book_odds)}` : Math.round(l.book_odds)} + {l.fair_odds != null && ( + ◆ {l.fair_odds > 0 ? `+${Math.round(l.fair_odds)}` : Math.round(l.fair_odds)} + )} ))}
@@ -882,6 +916,9 @@ export default function ScanPage() { stat={stat} line={line} pricedLines={pricedForSelection} + pricedStats={pricedStatsForSelection} + playerCount={playerPricedCount} + boardCount={boardPricedCount} onPick={(l) => { setLine(String(l.line)); setDirection(l.direction); diff --git a/web/src/components/vyndr/NoMarketState.tsx b/web/src/components/vyndr/NoMarketState.tsx index d3c1422..9c68368 100644 --- a/web/src/components/vyndr/NoMarketState.tsx +++ b/web/src/components/vyndr/NoMarketState.tsx @@ -1,121 +1,224 @@ 'use client'; /** - * NoMarketState (Session 79) — the honest empty state for a scanned prop the - * board never priced. + * NoMarketState (S6 · SCAN EMPTY STATE) — the honest marketless read card. * * A manual scan is an arbitrary player + stat + free-typed line, so most scans - * land on a prop no book line was captured for. The join correctly returns no - * market and the read card's price triplet correctly self-hides — but a blank - * space reads as "the product is empty." This says, truthfully, what happened - * and points the user at what IS priced. + * land on a prop the board never priced. The join correctly returns no market + * and the read card's price triplet correctly self-hides — but a blank space + * reads as "the product is empty." This states, truthfully, what happened and + * offers the player's REAL priced stats as the way through. * - * TRUTH LAW: NO market numbers, no placeholder odds, no "coming soon." The only - * numbers shown are REAL priced lines from the current snapshot (passed in), - * each a one-tap route to a genuine triplet. If there are none, it degrades to - * copy + a path to the live board. Everything renders from tokens. + * COLOR LAW (HANDOFF Session 3 — blue boundary channel): "no market / line not + * priced / priced out" is the BLUE channel (--priced-out #8FB2DE), NOT amber. + * Amber is reserved for QUARANTINE (model leg suppressed); red for REFUSAL + * (a model decision). The pre-spec build used amber here — telling users "model + * suppressed" when the truth is "the board never priced this." This is the + * semantic correction, not a cosmetic one. * - * FRAMING: this is HELP ("here's what we price tonight"), not RESTRICTION — the - * scanner still accepts any prop; this only appears once a scan came back - * marketless. + * TRUTH LAW: NO market numbers are invented. The only numbers shown are REAL + * priced lines from the current snapshot (book + de-vigged fair, both free-tier + * visible; MODEL is never rendered here), each a one-tap route to a genuine + * triplet. Counts come from the SAME fresh index as the rows, so they can never + * disagree. Nothing is suggested, interpolated, or rounded to a nearest price. + * + * FLAG (spec vs law): the S7 spec calls fair previews AMBER; the blue-boundary + * law reserves amber for quarantine. To avoid blurring the channel the fair + * number renders in a restrained neutral `◆` treatment, not amber. */ export interface PricedLine { line: number; direction: 'over' | 'under'; book_odds: number; + fair_odds?: number | null; +} + +export interface PricedStat { + stat: string; + lines: PricedLine[]; } const fmtOdds = (o: number) => (o > 0 ? `+${Math.round(o)}` : String(Math.round(o))); +const statLabel = (s: string) => String(s || '').replace(/_/g, ' ').toUpperCase(); + +// Blue boundary-channel tokens (globals.css --priced-out family), with the +// documented-intentional hex fallbacks. +const BLUE = 'var(--priced-out, #8fb2de)'; +const BLUE_DIM = 'var(--priced-out-dim, #7a93b0)'; +const BLUE_TINT = 'var(--priced-out-tint-2, rgba(106,147,200,0.12))'; +const BLUE_BORDER = 'var(--priced-out-border-hi, rgba(106,147,200,0.34))'; + +/** The S7 spec row: `{o|u} {line} · BK {odds} · ◆ {fair} · OPEN READ ▸`, a 44px + * tap straight into the real triplet. Legible at 390px — the label is compact + * (BK/◆) so it never overflows. */ +function PricedRow({ l, onPick }: { l: PricedLine; onPick?: (l: PricedLine) => void }) { + return ( + + ); +} export default function NoMarketState({ player, stat, line, pricedLines = [], + pricedStats = [], + playerCount = 0, + boardCount = 0, onPick, }: { player: string; stat: string; line: number | string; - /** REAL priced lines for this exact player+stat from the current snapshot. */ + /** REAL priced lines for this EXACT player+stat (typed-line mismatch path). */ pricedLines?: PricedLine[]; - /** Tap a real priced line → re-scan it (the market is re-validated server-side). */ + /** The player's OTHER priced stats (Case A "we price these for [player]"). */ + pricedStats?: PricedStat[]; + /** Priced-prop count for THIS player (green CTA). 0 → degrade to board path. */ + playerCount?: number; + /** Board-wide priced-prop count ("N PROPS LIVE"). */ + boardCount?: number; + /** Tap a real priced line → re-scan it (re-validated server-side). */ onPick?: (l: PricedLine) => void; }) { - const statLabel = String(stat || '').replace(/_/g, ' '); + const sorted = [...pricedLines].sort((a, b) => a.line - b.line); + const hasExact = sorted.length > 0; // this stat IS priced (typed mismatch) + const hasOtherStats = pricedStats.length > 0; // Case A + return (
NO LIVE MARKET FOR THIS LINE
-

- The board hasn’t priced {player}{' '} - {statLabel} at {String(line)}. The grade - above stands; there’s just no book number to price it against. +

+ No book prices {player}{' '} + {String(stat || '').replace(/_/g, ' ')} at{' '} + {String(line)} right now — so + there’s no vig to strip and no fair number to show. The grade above stands; we won’t + invent a market that isn’t there.

+
+ NO BOOK · NO FAIR · NO MODEL · NOTHING FABRICATED +
- {pricedLines.length > 0 ? ( + {/* ── PATH FORWARD ──────────────────────────────────────────────────── */} + {hasExact ? ( + // Typed-line mismatch: this exact stat IS priced, just not at the typed + // line. Show the real priced lines — never rewrite the user's intent. <> -
- PRICED TONIGHT · TAP TO READ THE TRIPLET +
+ o {String(line)} ISN’T PRICED · NEAREST PRICED LINE ↓
-
- {pricedLines.map((l, i) => ( - - ))} +
+ {sorted.map((l, i) => )}
- ) : ( - - SEE TONIGHT’S PRICED BOARD ▸ - - )} + ) : hasOtherStats ? ( + // CASE A — none priced for THIS stat, but the player has other priced + // stats. The DEFAULT, common case: quiet, structured, the way through. + <> +
+ WE PRICE THESE FOR {String(player).toUpperCase()} +
+
+ {pricedStats.map((ps) => { + const l = ps.lines[0]; + return ( + + ); + })} +
+ + ) : null} + + {/* ── LIVE COUNT CTA — player-specific when >0, else honest board path ── */} +
); } + +function ctaStyle(color: string): React.CSSProperties { + return { + display: 'inline-block', + fontSize: 11, + fontWeight: 800, + letterSpacing: '0.08em', + color, + textDecoration: 'none', + }; +} diff --git a/web/src/lib/pricedLines.js b/web/src/lib/pricedLines.js index 7dbe2c6..0fd6227 100644 --- a/web/src/lib/pricedLines.js +++ b/web/src/lib/pricedLines.js @@ -32,7 +32,16 @@ function indexPricedLines(grades) { // Dedupe on line+side — one snapshot shouldn't carry the same line twice, // but be defensive so the chip list never repeats. if (!rows.some((r) => r.line === line && r.direction === side)) { - rows.push({ line, direction: side, book_odds: Number(book) }); + // fair_odds is the de-vigged MARKET price (free-tier visible — NOT the + // gated model_odds). Carried through so the S7 spec row can render + // `◆ FAIR`; strict null (Number(null) === 0 would fabricate a price). + const fair = g.fair_odds; + rows.push({ + line, + direction: side, + book_odds: Number(book), + fair_odds: fair == null || fair === '' ? null : Number(fair), + }); } idx.set(key, rows); } @@ -51,4 +60,67 @@ function pricedLinesFor(index, player, stat) { return [...rows].sort((a, b) => a.line - b.line); } -module.exports = { indexPricedLines, pricedLinesFor }; +/** + * pricedStatsForPlayer(index, player) — the player's priced stats ACROSS the + * board (S7 Case A "WE PRICE THESE FOR [player]"). Reads the SAME fresh index + * the per-selection chips use — filters keys by the player's nameKey prefix so + * the count and the chips can never disagree with the rows above them. Returns + * [{ stat, lines }] sorted by stat; `excludeStat` drops the already-selected + * stat (Case A is "this stat has none, but these others do"). + */ +function pricedStatsForPlayer(index, player, excludeStat) { + if (!index || !player) return []; + const prefix = `${nameKey(player)}|`; + const skip = excludeStat ? String(excludeStat).toLowerCase() : null; + const out = []; + for (const [key, rows] of index) { + if (!key.startsWith(prefix)) continue; + const stat = key.slice(prefix.length); + if (skip && stat === skip) continue; + if (Array.isArray(rows) && rows.length) { + out.push({ stat, lines: [...rows].sort((a, b) => a.line - b.line) }); + } + } + return out.sort((a, b) => a.stat.localeCompare(b.stat)); +} + +/** + * pricedCountForPlayer(index, player) — how many priced props (distinct stats) + * the board carries for this player tonight. The number behind the green CTA + * "PLAYER · N PRICED PROPS ▸"; 0 → the caller must degrade to the board path, + * never render "0 PRICED PROPS". + */ +function pricedCountForPlayer(index, player) { + return pricedStatsForPlayer(index, player).length; +} + +/** + * totalPricedCount(index) — board-wide priced-prop count ("N PROPS LIVE"). Each + * index key is one player+stat prop. Real exhaust or 0 — never a placeholder. + */ +function totalPricedCount(index) { + return index && typeof index.size === 'number' ? index.size : 0; +} + +/** + * nearestPricedLine(index, player, stat, typedLine) — for the typed-line + * mismatch fact line: the priced line closest to what the user typed, so the + * strip can say "o 30.5 ISN'T PRICED · NEAREST ↓ o 27.5" WITHOUT rewriting the + * user's intent. Null when nothing is priced for that exact player+stat. + */ +function nearestPricedLine(index, player, stat, typedLine) { + const rows = pricedLinesFor(index, player, stat); + if (!rows.length) return null; + const t = Number(typedLine); + if (!Number.isFinite(t)) return rows[0]; + return rows.reduce((best, r) => (Math.abs(r.line - t) < Math.abs(best.line - t) ? r : best), rows[0]); +} + +module.exports = { + indexPricedLines, + pricedLinesFor, + pricedStatsForPlayer, + pricedCountForPlayer, + totalPricedCount, + nearestPricedLine, +};