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:
@@ -401,8 +401,13 @@ h1, h2, h3, h4, h5, h6 {
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
}
|
||||
|
||||
/* 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
|
||||
invisible element isn't an LCP candidate until it becomes visible, so the
|
||||
old 0-start pushed the landing LCP by the animation delay + duration. */
|
||||
@keyframes fade-up {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
0% { opacity: 0.6; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ const ibmPlexMono = IBM_Plex_Mono({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-ibm',
|
||||
display: 'swap',
|
||||
// S6 (A1 board, LCP) — IBM Plex is the legacy/decorative mono (wordmark,
|
||||
// hero sport chips); its FOUR weight files were <link rel=preload>'d ahead
|
||||
// of the render-critical CSS + Inter (the hero h1's face) on slow 4G.
|
||||
// Not preloading them frees that bandwidth; display:swap still swaps them
|
||||
// in when ready. Inter + JetBrains (the data face) stay preloaded.
|
||||
preload: false,
|
||||
});
|
||||
const fontVars = `${inter.variable} ${jetbrainsMono.variable} ${ibmPlexMono.variable}`;
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ interface LedgerRow {
|
||||
}
|
||||
|
||||
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
|
||||
// S6 (A1 board) — settled-CLV distribution bucket. Server-side only when the
|
||||
// n≥20 gate passes (null below — the gate lives in getModelAggregate).
|
||||
interface ClvBucket { label: string; count: number; side: 'beat' | 'faded' | 'flat' }
|
||||
interface ModelAggregate {
|
||||
settled: number;
|
||||
hits: number;
|
||||
@@ -58,6 +61,7 @@ interface ModelAggregate {
|
||||
min_sample?: number;
|
||||
// Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier).
|
||||
by_tier?: Record<string, TierRecord>;
|
||||
clv_distribution?: ClvBucket[] | null;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<string, string> = {
|
||||
@@ -187,6 +191,48 @@ 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). */
|
||||
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);
|
||||
if (total === 0) return null;
|
||||
const max = Math.max(...dist.map((b) => b.count));
|
||||
const color = (side: ClvBucket['side']) =>
|
||||
side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
|
||||
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: 8 }}>
|
||||
CLV DISTRIBUTION · {total} SETTLED
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', maxWidth: 420 }}>
|
||||
{dist.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}
|
||||
</span>
|
||||
<div
|
||||
aria-label={`${b.label}: ${b.count} settled`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: Math.max(3, Math.round((b.count / max) * 40)),
|
||||
background: b.count > 0 ? color(b.side) : 'var(--border)',
|
||||
borderRadius: 2,
|
||||
opacity: b.count > 0 ? 0.9 : 0.6,
|
||||
}}
|
||||
/>
|
||||
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '0.02em', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||
{b.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
||||
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
||||
return (
|
||||
@@ -227,6 +273,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ClvDistribution agg={agg} />
|
||||
<TierCalibration agg={agg} minSample={minSample} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -384,6 +384,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
// + the ORIGINAL grade when a public revision happened.
|
||||
movement: rec.movement || null,
|
||||
revisedFrom: rec.revised_from_grade || null,
|
||||
// S6 (A1 board, ROW-GRAMMAR sub-line) — real captured line history
|
||||
// (sparkline, ≥3 points) + last-10 ●/○ vs tonight's locked line.
|
||||
history: Array.isArray(rec.history) && rec.history.length > 0 ? rec.history : null,
|
||||
last10Dots: Array.isArray(rec.last10_dots) && rec.last10_dots.length > 0 ? rec.last10_dots : null,
|
||||
});
|
||||
} else {
|
||||
byPlayer[pk].props.push({
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* ============================================================
|
||||
S6 (A1 board) — static team registry for global search.
|
||||
League membership is a stable public fact (not market data), so a
|
||||
static list is honest. CommonJS so the Jest suite requires it and
|
||||
the .tsx SearchModal imports it (allowJs).
|
||||
|
||||
Soccer is deliberately ABSENT: the product has no canonical soccer
|
||||
team registry (no soccer team-hub branch, no fixed competition set)
|
||||
— absent beats wrong. Add it when the soccer hub lands.
|
||||
|
||||
Team links go to /team/:abbr?sport= (the S51 Team Hub). MLB abbrs
|
||||
match statsapi (AZ, CWS, ATH); NBA/WNBA hubs render the partial
|
||||
graded-roster view until a free roster feed exists.
|
||||
============================================================ */
|
||||
|
||||
const TEAMS = [
|
||||
// ── MLB (30) ──────────────────────────────────────────────
|
||||
{ sport: 'mlb', abbr: 'AZ', name: 'Arizona Diamondbacks' },
|
||||
{ sport: 'mlb', abbr: 'ATL', name: 'Atlanta Braves' },
|
||||
{ sport: 'mlb', abbr: 'BAL', name: 'Baltimore Orioles' },
|
||||
{ sport: 'mlb', abbr: 'BOS', name: 'Boston Red Sox' },
|
||||
{ sport: 'mlb', abbr: 'CHC', name: 'Chicago Cubs' },
|
||||
{ sport: 'mlb', abbr: 'CWS', name: 'Chicago White Sox' },
|
||||
{ sport: 'mlb', abbr: 'CIN', name: 'Cincinnati Reds' },
|
||||
{ sport: 'mlb', abbr: 'CLE', name: 'Cleveland Guardians' },
|
||||
{ sport: 'mlb', abbr: 'COL', name: 'Colorado Rockies' },
|
||||
{ sport: 'mlb', abbr: 'DET', name: 'Detroit Tigers' },
|
||||
{ sport: 'mlb', abbr: 'HOU', name: 'Houston Astros' },
|
||||
{ sport: 'mlb', abbr: 'KC', name: 'Kansas City Royals' },
|
||||
{ sport: 'mlb', abbr: 'LAA', name: 'Los Angeles Angels' },
|
||||
{ sport: 'mlb', abbr: 'LAD', name: 'Los Angeles Dodgers' },
|
||||
{ sport: 'mlb', abbr: 'MIA', name: 'Miami Marlins' },
|
||||
{ sport: 'mlb', abbr: 'MIL', name: 'Milwaukee Brewers' },
|
||||
{ sport: 'mlb', abbr: 'MIN', name: 'Minnesota Twins' },
|
||||
{ sport: 'mlb', abbr: 'NYM', name: 'New York Mets' },
|
||||
{ sport: 'mlb', abbr: 'NYY', name: 'New York Yankees' },
|
||||
{ sport: 'mlb', abbr: 'ATH', name: 'Athletics' },
|
||||
{ sport: 'mlb', abbr: 'PHI', name: 'Philadelphia Phillies' },
|
||||
{ sport: 'mlb', abbr: 'PIT', name: 'Pittsburgh Pirates' },
|
||||
{ sport: 'mlb', abbr: 'SD', name: 'San Diego Padres' },
|
||||
{ sport: 'mlb', abbr: 'SF', name: 'San Francisco Giants' },
|
||||
{ sport: 'mlb', abbr: 'SEA', name: 'Seattle Mariners' },
|
||||
{ sport: 'mlb', abbr: 'STL', name: 'St. Louis Cardinals' },
|
||||
{ sport: 'mlb', abbr: 'TB', name: 'Tampa Bay Rays' },
|
||||
{ sport: 'mlb', abbr: 'TEX', name: 'Texas Rangers' },
|
||||
{ sport: 'mlb', abbr: 'TOR', name: 'Toronto Blue Jays' },
|
||||
{ sport: 'mlb', abbr: 'WSH', name: 'Washington Nationals' },
|
||||
// ── NBA (30) ──────────────────────────────────────────────
|
||||
{ sport: 'nba', abbr: 'ATL', name: 'Atlanta Hawks' },
|
||||
{ sport: 'nba', abbr: 'BOS', name: 'Boston Celtics' },
|
||||
{ sport: 'nba', abbr: 'BKN', name: 'Brooklyn Nets' },
|
||||
{ sport: 'nba', abbr: 'CHA', name: 'Charlotte Hornets' },
|
||||
{ sport: 'nba', abbr: 'CHI', name: 'Chicago Bulls' },
|
||||
{ sport: 'nba', abbr: 'CLE', name: 'Cleveland Cavaliers' },
|
||||
{ sport: 'nba', abbr: 'DAL', name: 'Dallas Mavericks' },
|
||||
{ sport: 'nba', abbr: 'DEN', name: 'Denver Nuggets' },
|
||||
{ sport: 'nba', abbr: 'DET', name: 'Detroit Pistons' },
|
||||
{ sport: 'nba', abbr: 'GSW', name: 'Golden State Warriors' },
|
||||
{ sport: 'nba', abbr: 'HOU', name: 'Houston Rockets' },
|
||||
{ sport: 'nba', abbr: 'IND', name: 'Indiana Pacers' },
|
||||
{ sport: 'nba', abbr: 'LAC', name: 'LA Clippers' },
|
||||
{ sport: 'nba', abbr: 'LAL', name: 'Los Angeles Lakers' },
|
||||
{ sport: 'nba', abbr: 'MEM', name: 'Memphis Grizzlies' },
|
||||
{ sport: 'nba', abbr: 'MIA', name: 'Miami Heat' },
|
||||
{ sport: 'nba', abbr: 'MIL', name: 'Milwaukee Bucks' },
|
||||
{ sport: 'nba', abbr: 'MIN', name: 'Minnesota Timberwolves' },
|
||||
{ sport: 'nba', abbr: 'NOP', name: 'New Orleans Pelicans' },
|
||||
{ sport: 'nba', abbr: 'NYK', name: 'New York Knicks' },
|
||||
{ sport: 'nba', abbr: 'OKC', name: 'Oklahoma City Thunder' },
|
||||
{ sport: 'nba', abbr: 'ORL', name: 'Orlando Magic' },
|
||||
{ sport: 'nba', abbr: 'PHI', name: 'Philadelphia 76ers' },
|
||||
{ sport: 'nba', abbr: 'PHX', name: 'Phoenix Suns' },
|
||||
{ sport: 'nba', abbr: 'POR', name: 'Portland Trail Blazers' },
|
||||
{ sport: 'nba', abbr: 'SAC', name: 'Sacramento Kings' },
|
||||
{ sport: 'nba', abbr: 'SAS', name: 'San Antonio Spurs' },
|
||||
{ sport: 'nba', abbr: 'TOR', name: 'Toronto Raptors' },
|
||||
{ sport: 'nba', abbr: 'UTA', name: 'Utah Jazz' },
|
||||
{ sport: 'nba', abbr: 'WAS', name: 'Washington Wizards' },
|
||||
// ── WNBA (13) ─────────────────────────────────────────────
|
||||
{ sport: 'wnba', abbr: 'ATL', name: 'Atlanta Dream' },
|
||||
{ sport: 'wnba', abbr: 'CHI', name: 'Chicago Sky' },
|
||||
{ sport: 'wnba', abbr: 'CON', name: 'Connecticut Sun' },
|
||||
{ sport: 'wnba', abbr: 'DAL', name: 'Dallas Wings' },
|
||||
{ sport: 'wnba', abbr: 'GSV', name: 'Golden State Valkyries' },
|
||||
{ sport: 'wnba', abbr: 'IND', name: 'Indiana Fever' },
|
||||
{ sport: 'wnba', abbr: 'LVA', name: 'Las Vegas Aces' },
|
||||
{ sport: 'wnba', abbr: 'LAS', name: 'Los Angeles Sparks' },
|
||||
{ sport: 'wnba', abbr: 'MIN', name: 'Minnesota Lynx' },
|
||||
{ sport: 'wnba', abbr: 'NYL', name: 'New York Liberty' },
|
||||
{ sport: 'wnba', abbr: 'PHX', name: 'Phoenix Mercury' },
|
||||
{ sport: 'wnba', abbr: 'SEA', name: 'Seattle Storm' },
|
||||
{ sport: 'wnba', abbr: 'WAS', name: 'Washington Mystics' },
|
||||
];
|
||||
|
||||
/** /team/:abbr?sport= — the canonical Team Hub link (S51). */
|
||||
function teamHref(team) {
|
||||
return `/team/${encodeURIComponent(team.abbr)}?sport=${encodeURIComponent(team.sport)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive team match: full name substring, mascot (last word)
|
||||
* prefix, or abbreviation prefix. Returns [] under 2 chars.
|
||||
*/
|
||||
function searchTeams(q, limit = 6) {
|
||||
const s = String(q || '').trim().toLowerCase();
|
||||
if (s.length < 2) return [];
|
||||
const out = [];
|
||||
for (const t of TEAMS) {
|
||||
const name = t.name.toLowerCase();
|
||||
const mascot = name.split(' ').pop() || '';
|
||||
if (name.includes(s) || mascot.startsWith(s) || t.abbr.toLowerCase().startsWith(s)) {
|
||||
out.push(t);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { TEAMS, searchTeams, teamHref };
|
||||
Reference in New Issue
Block a user