S6 (a1): display — the full picture under the grammar

- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
  law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
  StatStrip violations fixed: MovementChip before the grade (market
  context before model output); ViabilityChips after the archetype
  (identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
  real {t,line} points per grade (seeded with the lock, deduped when
  flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
  renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
  /api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
  StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
  buckets, outliers clamped) only past the centralized n>=20 gate;
  ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
  players via /api/players/search per sport + static lib/teams.js
  (soccer deliberately absent); Nav search icon + Search first in the
  mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
  first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
  (4 decorative font files off the slow-4G critical path).

2654 -> 2698 tests (226 suites) green; web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 20:08:24 -04:00
parent 1d46b446c9
commit d3637e7abd
26 changed files with 1408 additions and 25 deletions
+32
View File
@@ -97,6 +97,38 @@ export default function BottomTabBar() {
</button>
</div>
<div>
{/* S6 (A1 board) — global search, first item in the sheet.
Same modal as ⌘K / the Nav icon (window.__search). */}
<button
type="button"
onClick={() => {
setMoreOpen(false);
if (typeof window !== 'undefined' && window.__search) window.__search();
}}
className="mono"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
minHeight: 48,
padding: '0 18px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: 'var(--g-a)',
fontSize: 13,
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
borderBottom: '1px solid var(--border)',
}}
>
Search
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" />
</svg>
</button>
{MORE_ITEMS.map((it) => (
<a
key={it.href}
+14
View File
@@ -190,6 +190,20 @@ export default function Nav() {
the "Query" pill was a duplicate link to /scan — deleted. A real
⌘K palette is a later nicety (spec §4), not a nav link. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{/* S6 (A1 board) — global search (players + teams). ⌘K opens the
same modal; this icon is the mobile/mouse path (window.__search
registered by GlobalHosts). */}
<button
onClick={() => typeof window !== 'undefined' && window.__search && window.__search()}
aria-label="Search players and teams (⌘K)"
title="Search — ⌘K"
style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid var(--border-hi)', background: 'var(--bg-2)', color: 'var(--text-1)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" />
</svg>
</button>
{/* Preferences (language / odds format / accessibility) — §9/§10 */}
<button
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
+17
View File
@@ -6,6 +6,8 @@ import { REGIONS, ODDS_FORMATS, regionPreset } from '@/lib/oddsFormat';
import { checkoutUrl, PLAN_PRICES } from '@/lib/checkout';
import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
// S6 (A1 board) — global player/team search (⌘K + window.__search).
import SearchModal from '@/components/vyndr/SearchModal';
// Augment the window with the design's global hosts (§12).
declare global {
@@ -13,6 +15,7 @@ declare global {
__prefs?: () => void;
__goPaywall?: () => void;
__checkout?: (plan: string) => void;
__search?: () => void;
}
}
@@ -28,6 +31,7 @@ export default function GlobalHosts() {
const [prefs, setPrefs] = useState<Prefs>(DEFAULTS);
const [prefsOpen, setPrefsOpen] = useState(false);
const [paywallOpen, setPaywallOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
const loaded = loadPrefs();
@@ -38,10 +42,22 @@ export default function GlobalHosts() {
window.__checkout = (plan: string) => {
window.location.href = checkoutUrl(plan);
};
// S6 — global search: window.__search (Nav icon + mobile More sheet)
// and the ⌘K / Ctrl-K shortcut.
window.__search = () => setSearchOpen(true);
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === 'k') {
e.preventDefault();
setSearchOpen((o) => !o);
}
};
window.addEventListener('keydown', onKey);
return () => {
delete window.__prefs;
delete window.__goPaywall;
delete window.__checkout;
delete window.__search;
window.removeEventListener('keydown', onKey);
};
}, []);
@@ -63,6 +79,7 @@ export default function GlobalHosts() {
<>
{prefsOpen && <PrefsModal prefs={prefs} update={update} onClose={() => setPrefsOpen(false)} />}
{paywallOpen && <PaywallModal onClose={() => setPaywallOpen(false)} />}
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
</>
);
}
+272
View File
@@ -0,0 +1,272 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { playerHref } from '@/lib/playerHref';
import { searchTeams, teamHref } from '@/lib/teams';
/**
* SearchModal — S6 (A1 board). Global search over players + teams.
*
* Players: /api/players/search per sport (the canonical nameKey resolver,
* S60 — MLB statsapi list, others cache-only). Debounced 250ms.
* Teams: the static registry in lib/teams.js — no fetch.
*
* ⌘K / Ctrl-K opens it (registered in GlobalHosts, which also exposes
* window.__search for the Nav icon + the mobile More sheet). Grouped results
* (PLAYERS / TEAMS), arrow-key navigation, Enter → playerHref / team hub.
* Data is mono; chrome may glitch, results never do.
*/
const PLAYER_SPORTS = ['MLB', 'NBA', 'WNBA'] as const;
const SPORT_COLOR: Record<string, string> = {
mlb: 'var(--s-mlb, #1E90FF)',
nba: 'var(--s-nba, #E94B3C)',
wnba: 'var(--s-wnba, #FFB347)',
};
interface ResultItem {
kind: 'player' | 'team';
label: string;
sub: string; // team / abbr context
sport: string;
href: string;
}
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const router = useRouter();
const [q, setQ] = useState('');
const [players, setPlayers] = useState<ResultItem[]>([]);
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const seqRef = useRef(0);
// Teams are a static-list match — synchronous, no fetch.
const teams = useMemo<ResultItem[]>(
() =>
searchTeams(q, 6).map((t: { name: string; abbr: string; sport: string }) => ({
kind: 'team' as const,
label: t.name,
sub: t.abbr,
sport: t.sport,
href: teamHref(t),
})),
[q],
);
const flat = useMemo(() => [...players, ...teams], [players, teams]);
// Debounced player fan-out. A stale response never overwrites a newer one.
useEffect(() => {
if (!open) return;
const query = q.trim();
if (query.length < 2) {
setPlayers([]);
return;
}
const seq = ++seqRef.current;
const timer = setTimeout(async () => {
try {
const responses = await Promise.all(
PLAYER_SPORTS.map((sp) =>
fetch(`/api/players/search?sport=${sp}&q=${encodeURIComponent(query)}`)
.then((r) => (r.ok ? r.json() : { players: [] }))
.then((d) => ({ sport: sp.toLowerCase(), players: Array.isArray(d?.players) ? d.players : [] }))
.catch(() => ({ sport: sp.toLowerCase(), players: [] })),
),
);
if (seq !== seqRef.current) return;
const seen = new Set<string>();
const merged: ResultItem[] = [];
for (const res of responses) {
for (const p of res.players as Array<{ full_name?: string; team?: string }>) {
const name = String(p.full_name || '').trim();
if (!name) continue;
const key = `${name.toLowerCase()}|${res.sport}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push({
kind: 'player',
label: name,
sub: p.team ? String(p.team) : '',
sport: res.sport,
href: playerHref(name, res.sport),
});
if (merged.length >= 9) break;
}
if (merged.length >= 9) break;
}
setPlayers(merged);
} catch {
if (seq === seqRef.current) setPlayers([]);
}
}, 250);
return () => clearTimeout(timer);
}, [q, open]);
// Reset + focus on open.
useEffect(() => {
if (open) {
setQ('');
setPlayers([]);
setSel(0);
const t = setTimeout(() => inputRef.current?.focus(), 20);
return () => clearTimeout(t);
}
}, [open]);
useEffect(() => {
setSel(0);
}, [q]);
const go = useCallback(
(item: ResultItem | undefined) => {
if (!item) return;
onClose();
router.push(item.href);
},
[onClose, router],
);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSel((s) => Math.min(s + 1, Math.max(flat.length - 1, 0)));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
go(flat[sel]);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
if (!open) return null;
const Group = ({ title, items, offset }: { title: string; items: ResultItem[]; offset: number }) => {
if (items.length === 0) return null;
return (
<div>
<div className="mono" style={{ padding: '10px 16px 4px', fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--text-2)' }}>
{title}
</div>
{items.map((it, i) => {
const idx = offset + i;
const active = idx === sel;
return (
<button
key={`${it.kind}-${it.sport}-${it.label}`}
type="button"
onClick={() => go(it)}
onMouseEnter={() => setSel(idx)}
className="mono"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
width: '100%',
textAlign: 'left',
padding: '9px 16px',
background: active ? 'color-mix(in srgb, var(--g-a) 12%, transparent)' : 'transparent',
border: 'none',
borderLeft: `2px solid ${active ? 'var(--g-a)' : 'transparent'}`,
cursor: 'pointer',
color: 'var(--text-0, #E8E8F0)',
fontSize: 13,
}}
>
{/* ROW-GRAMMAR: never truncate a name — wrap instead. */}
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>}
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}>
{it.sport.toUpperCase()}
</span>
</span>
</button>
);
})}
</div>
);
};
return (
<div
className="fade-in"
role="dialog"
aria-modal="true"
aria-label="Search players and teams"
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
zIndex: 90,
background: 'rgba(6,6,11,.72)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
padding: '12vh 16px 16px',
}}
>
<div
onClick={(e) => e.stopPropagation()}
className="scanlines"
style={{
width: '100%',
maxWidth: 560,
background: 'var(--bg-1)',
border: '1px solid var(--border-hi)',
borderRadius: 12,
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<span className="mono" aria-hidden style={{ color: 'var(--g-a)', fontSize: 13 }}></span>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search players, teams"
aria-label="Search players and teams"
className="mono"
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--text-0, #fff)',
fontSize: 14,
letterSpacing: '0.02em',
}}
/>
<kbd className="mono" style={{ fontSize: 9.5, color: 'var(--text-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '2px 5px' }}>
ESC
</kbd>
</div>
<div style={{ maxHeight: '52vh', overflowY: 'auto', paddingBottom: 6 }}>
{q.trim().length < 2 ? (
<p className="mono" style={{ padding: '18px 16px', fontSize: 11.5, color: 'var(--text-2)' }}>
Type two characters. to move, Enter to open.
</p>
) : flat.length === 0 ? (
<p className="mono" style={{ padding: '18px 16px', fontSize: 11.5, color: 'var(--text-2)' }}>
No matches on the wire.
</p>
) : (
<>
<Group title="PLAYERS" items={players} offset={0} />
<Group title="TEAMS" items={teams} offset={players.length} />
</>
)}
</div>
</div>
</div>
);
}
+6 -2
View File
@@ -2,6 +2,10 @@ type SparklineProps = {
data: number[];
/** Direction tint: green when up, red when down. */
up?: boolean;
/** S6 (A1) — explicit stroke override (a CSS color/var). Takes precedence
* over `up`. ROW-GRAMMAR color law: line movement is green (toward) /
* amber (against) / dim (flat) — red is settled-negative only. */
color?: string;
w?: number;
h?: number;
};
@@ -10,7 +14,7 @@ type SparklineProps = {
* Tiny SVG line for line movement (§5). Green up / red down, dot on the last
* point. Dependency-free. Data viz — the line itself never glitches.
*/
export default function Sparkline({ data, up = true, w = 92, h = 26 }: SparklineProps) {
export default function Sparkline({ data, up = true, color, w = 92, h = 26 }: SparklineProps) {
const safe = data && data.length ? data : [0, 0];
const min = Math.min(...safe);
const max = Math.max(...safe);
@@ -21,7 +25,7 @@ export default function Sparkline({ data, up = true, w = 92, h = 26 }: Sparkline
return [x, y] as const;
});
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
const c = up ? 'var(--g-a)' : 'var(--miss)';
const c = color || (up ? 'var(--g-a)' : 'var(--miss)');
const last = pts[pts.length - 1];
return (
<svg width={w} height={h} style={{ display: 'block', overflow: 'visible' }} aria-hidden>
+69 -9
View File
@@ -1,5 +1,6 @@
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import Sparkline from '@/components/vyndr/Sparkline';
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.
@@ -31,6 +32,52 @@ export interface StripProp {
// Session 64 (A1-S5) — NOT-IN-LINEUP kills the grade display (struck
// through, actions suppressed). The locked ledger read is untouched.
dead?: boolean;
// S6 (A1 board) — ROW-GRAMMAR sub-line data. `history` = REAL captured
// {t, line} points from the intraday refresh (sparkline needs ≥3);
// `last10Dots` = last-10 games vs tonight's locked line, newest first
// (true = cleared). Both absent → nothing renders.
history?: Array<{ t: string; line: number }> | null;
last10Dots?: boolean[] | null;
}
/** ROW-GRAMMAR §4 — ●/○ last-10 dot strip. Filled green = that game's stat
* cleared TONIGHT'S locked line; hollow dim = it didn't. Newest first.
* Real game logs only — no log, no dots. */
export function DotStrip({ dots }: { dots?: boolean[] | null }) {
if (!Array.isArray(dots) || dots.length === 0) return null;
const hits = dots.filter(Boolean).length;
return (
<span
className="mono"
title={`Last ${dots.length} vs tonight's line — ${hits} of ${dots.length} cleared (newest first)`}
aria-label={`Last ${dots.length} games versus tonight's line: ${hits} cleared`}
style={{ fontSize: 10, letterSpacing: '0.12em', whiteSpace: 'nowrap' }}
>
{dots.map((hit, i) => (
<span key={i} style={{ color: hit ? 'var(--g-a, #00D4A0)' : 'var(--text-2, #4A4A5E)' }}>
{hit ? '●' : '○'}
</span>
))}
</span>
);
}
/** ROW-GRAMMAR §4 — line-movement sparkline from REAL captured {t, line}
* points. Renders only at ≥3 points. Color law: green = net move TOWARD the
* graded side, amber = net AGAINST, dim = flat. Never red — nothing settled. */
export function LineSparkline({ p }: { p: StripProp }) {
const h = p.history;
if (!Array.isArray(h) || h.length < 3) return null;
const lines = h.map((pt) => pt && pt.line).filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
if (lines.length < 3) return null;
const raw = lines[lines.length - 1] - lines[0];
const toward = String(p.side || 'O').toUpperCase().startsWith('U') ? -raw : raw;
const color = toward > 0 ? 'var(--g-a)' : toward < 0 ? 'var(--amber)' : 'var(--text-1)';
return (
<span title={`Line movement since lock — ${lines[0]}${lines[lines.length - 1]} (${lines.length} captures)`} style={{ display: 'inline-flex', alignItems: 'center' }}>
<Sparkline data={lines} color={color} w={64} h={16} />
</span>
);
}
/** Session 64 (A1-S5) — lineup + injury viability chips (real feeds only). */
@@ -270,8 +317,6 @@ export default function StatStrip({
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{/* Session 64 (A1-S5) — lineup confirmation + injury wire chips. */}
<ViabilityChips lineup={lineup} injury={injury} />
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
{archetype?.secondary && (
<>
@@ -279,6 +324,9 @@ export default function StatStrip({
<ArchetypeBadge archetype={archetype.secondary} size="sm" variant="ghost" />
</>
)}
{/* ROW-GRAMMAR §2 — identity is one contiguous run (name → team →
archetype); viability status chips FOLLOW it. Session 64 chips. */}
<ViabilityChips lineup={lineup} injury={injury} />
</div>
<div className="mono game-lines-grid" style={{ fontSize: 12, color: 'var(--text-0)', letterSpacing: '0.02em' }}>
{stats.map((s, i) => (
@@ -327,10 +375,14 @@ export default function StatStrip({
const toward = p.delta?.direction === 'toward';
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8', flexWrap: 'wrap' }}>
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
{/* ROW-GRAMMAR slot 4 — market context: best-price dot,
then movement chip, BEFORE any model output. */}
<BestPriceDot p={p} />
{/* Phase 2.5 — a revised grade is PUBLIC: original struck through. */}
{!p.dead && <MovementChip p={p} />}
{/* ROW-GRAMMAR slot 5 — model output. Phase 2.5: a revised
grade is PUBLIC — original struck through, then current. */}
{p.revisedFrom && (
<span className="mono" title="Grade revised after the line moved against the read — original preserved" style={{ fontSize: 10.5, color: 'var(--text-2)', textDecoration: 'line-through' }}>{p.revisedFrom}</span>
)}
@@ -346,7 +398,6 @@ export default function StatStrip({
<GradeBadge grade={p.grade} size="sm" />
)
)}
{!p.dead && <MovementChip p={p} />}
<OutcomeChip p={p} />
{!p.outcome && !p.dead && <ParlayBtn p={p} />}
{!p.outcome && !p.dead && <BookItTeaser p={p} />}
@@ -356,11 +407,20 @@ export default function StatStrip({
</span>
)}
</div>
{p.delta && (
<div className="mono" style={{ fontSize: 11, color: toward ? 'var(--g-a)' : 'var(--amber)' }}>
Current {p.delta.currentLine} · {p.delta.delta > 0 ? '▲' : '▼'} {toward ? 'TOWARD' : 'AWAY'} {p.delta.delta > 0 ? '+' : ''}{p.delta.delta}
{/* ROW-GRAMMAR sub-line — stat + market context, fixed order:
last-10 dots · line sparkline · current-line delta. Each
renders only on real data; all absent → no sub-line. */}
{(p.last10Dots?.length || (p.history && p.history.length >= 3) || p.delta) ? (
<div className="mono" style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<DotStrip dots={p.last10Dots} />
<LineSparkline p={p} />
{p.delta && (
<span style={{ color: toward ? 'var(--g-a)' : 'var(--amber)' }}>
Current {p.delta.currentLine} · {p.delta.delta > 0 ? '▲' : '▼'} {toward ? 'TOWARD' : 'AWAY'} {p.delta.delta > 0 ? '+' : ''}{p.delta.delta}
</span>
)}
</div>
)}
) : null}
</div>
);
})}
+4
View File
@@ -25,6 +25,10 @@ export { default as StatStrip } from './StatStrip';
export type { StatCell, StripProp, StripArchetype } from './StatStrip';
export { default as BookChip } from './BookChip';
/* S6 (A1 board) — global search + row-grammar micro-marks */
export { default as SearchModal } from './SearchModal';
export { DotStrip, LineSparkline } from './StatStrip';
export {
GRADE_COLORS,
GRADE_HEX,