Session 57: Phase 0 — Kill the Lies (2309 tests)

Work-order Phase 0 (Jul 10 live audit): every fabricated UI element deleted
or rewired to real data. Deletion sprint — no new product features.

0.1 Fake NBA game: root cause was scheduleService fetching the ESPN
    scoreboard with no ?dates= param or date filter — off-season ESPN
    returns the NEAREST slate (Jun 13 NYK@SA Finals rendered as tonight).
    Now pinned to the requested ET date + defensive filter; undated events
    dropped. Honest month-aware per-sport empty states (lib/emptyState.js).
0.2 Fake header counters: liveTick stripped to a bare 1s pulse (the
    auto-incrementing "247 graded", sin-driven brain-%, aPlus/cascades are
    dead). New GET /api/snapshot/summary (cache-only, before /:sport) +
    Next proxy; HeartbeatBar shows the real graded count and SYNC =
    elapsed since the last pipeline run (amber past 5 min).
0.3 Ticker: hardcoded fallback items deleted (real snapshot exhaust only);
    MOVE kept — computeLineDeltas is real movement. <4 real items → no
    ticker; bar publishes --ticker-h so the fixed header collapses cleanly.
0.4 /terminal retired: route redirects to /dashboard; VVI/injury-wire/
    leaders layouts preserved unrouted as §12 content-engine templates.
    Nav PRIMARY = Slate/Scan/Ledger; BottomTabBar Terminal→Explore; PWA
    shortcut Terminal→Ledger; #terminal alias → /dashboard.
0.5 ›Query nav pill deleted (duplicate /scan link).

Backend 2289 → 2309 tests (199 suites), web build exit 0.
Spec: specs/phase-0-kill-the-lies.md. Next: work-order Phase 1 (ledger
persistence + settlement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-10 20:17:49 -04:00
parent 2ae8a5697e
commit c8790fde55
27 changed files with 891 additions and 311 deletions
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Snapshot summary proxy (Session 57, Phase 0) — forwards
* GET /api/snapshot/summary (real graded count + last pipeline run) for the
* HeartbeatBar. Static segment wins over the [sport] dynamic route.
*/
export async function GET() {
try {
const upstream = await fetch(`${BACKEND_URL}/api/snapshot/summary`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({ graded: 0, updated_at: null, sports: {} }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ graded: 0, updated_at: null, sports: {} }, { status: 200 });
}
}
+6 -2
View File
@@ -11,6 +11,8 @@ import { GradePill } from '@/components/GradeCard';
import Slate from '@/components/Slate';
// Session 55 — the self-learning loop's track record, live in the header.
import { AccuracyBadge } from '@/components/vyndr';
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
import { emptyStateCopy } from '@/lib/emptyState';
type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -325,14 +327,16 @@ export default function DashboardPage() {
{games === null ? (
<SkeletonRow stacked />
) : games.length === 0 ? (
// Session 57 (Phase 0) — honest per-sport empty state (spec §6):
// an off-season sport says when it returns; in-season = off-day.
<div
className="surface diagonal-cut tex-scan"
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No games on the slate tonight.</h3>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>{emptyStateCopy(sport.toLowerCase()).title}</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
Check the Ledger to review past grades, or explore the Leaderboard.
{emptyStateCopy(sport.toLowerCase()).body}
</p>
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger </a>
+5 -2
View File
@@ -138,9 +138,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<ParlayProvider>
<HashRedirect />
<Nav />
{/* Header = 60px nav + 32px ticker + 30px heartbeat (§8); offset main. */}
{/* Header = 60px nav + 30px heartbeat (+2 slack) + a DYNAMIC
ticker: the Ticker publishes --ticker-h (32px with ≥4 real
items, else 0px — spec §3) so main's offset collapses with
it. Keep the 92px base in sync with Nav/HeartbeatBar. */}
<AuthGate>
<main style={{ paddingTop: 124, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
<main style={{ paddingTop: 'calc(92px + var(--ticker-h, 0px))', minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
</AuthGate>
<Footer />
<ParlayPanel />
+9 -203
View File
@@ -1,206 +1,12 @@
import SectionHead from '@/components/vyndr/SectionHead';
import GradeBadge from '@/components/vyndr/GradeBadge';
import SportBadge from '@/components/vyndr/SportBadge';
export const metadata = { title: 'The Terminal' };
// Sample league-intelligence dataset (§7 shapes). Real wiring to
// scheduleService.getGameSummary (injury cascades) + schedule/odds (leaders)
// lands in a later session; the screen + contracts are real now.
const INJURY_WIRE = [
{
player: 'Jamal Murray', team: 'DEN', sport: 'nba', status: 'OUT', injury: 'Hamstring', posted: '2h ago',
cascade: [
{ player: 'Nikola Jokić', stat: 'Assists', delta: '+3.2% usage', grade: 'A+' },
{ player: 'Russell Westbrook', stat: 'Points', delta: '+4.1% usage', grade: 'B' },
],
},
{
player: 'Jeremy Sochan', team: 'SA', sport: 'nba', status: 'OUT', injury: 'Ankle sprain', posted: '3h ago',
cascade: [
{ player: 'Victor Wembanyama', stat: 'Points', delta: '+3.2% usage', grade: 'A' },
{ player: 'Devin Vassell', stat: '3PT Made', delta: '+2.0% usage', grade: 'B' },
],
},
{
player: 'Mookie Betts', team: 'LAD', sport: 'mlb', status: 'GTD', injury: 'Wrist', posted: '40m ago',
cascade: [{ player: 'Shohei Ohtani', stat: 'RBIs', delta: '+1.9% lineup', grade: 'C' }],
},
];
const IMPACTED_GAMES = [
{ sport: 'nba', match: 'LAL @ SA', time: '10:30 PM ET', vvi: 92, graded: 7, note: 'Sochan OUT spikes Wembanyama usage; LAL pace + 26th-vs-C matchup compound it.', drivers: ['INJURY CASCADE', 'PACE', 'MATCHUP', 'BLOWOUT RISK'] },
{ sport: 'mlb', match: 'LAD @ ATL', time: '7:20 PM ET', vvi: 78, graded: 5, note: 'Truist Park boosts LH power; Ohtani platoon edge vs RHP, ATL bullpen on fumes.', drivers: ['PARK FACTOR', 'PLATOON', 'BULLPEN FATIGUE'] },
{ sport: 'wnba', match: 'NY @ LV', time: '9:00 PM ET', vvi: 71, graded: 4, note: "Wilson 31% usage vs NY's soft interior; Liberty on a back-to-back.", drivers: ['USAGE', 'MATCHUP', 'REST EDGE'] },
];
const FACTOR_PULSE = [
{ count: 11, label: 'Injury cascades', hint: 'props recalibrated', color: 'var(--g-b)' },
{ count: 8, label: 'Pace mismatches', hint: 'tempo edges', color: 'var(--g-a)' },
{ count: 6, label: 'Park / platoon', hint: 'MLB power spots', color: 'var(--g-a)' },
{ count: 5, label: 'Blowout risk', hint: 'minutes-capped', color: 'var(--miss)' },
];
const LEADERS: { key: string; rows: { player: string; team: string; val: string; grade: string; gradeable: boolean }[] }[] = [
{
key: 'NBA · Points',
rows: [
{ player: 'Nikola Jokić', team: 'DEN', val: '31.2', grade: 'A+', gradeable: true },
{ player: 'Victor Wembanyama', team: 'SA', val: '29.4', grade: 'A', gradeable: true },
{ player: 'Anthony Edwards', team: 'MIN', val: '28.4', grade: 'A', gradeable: true },
],
},
{
key: 'MLB · Total Bases',
rows: [
{ player: 'Shohei Ohtani', team: 'LAD', val: '2.25', grade: 'A', gradeable: true },
{ player: 'Aaron Judge', team: 'NYY', val: '2.10', grade: 'B', gradeable: false },
],
},
];
const MATCHUP_EXPLOITS = [
{ sport: 'nba', team: 'LAL', rank: '26th', stat: 'Points allowed to C', exploit: 'Wembanyama Points O26.5 · A' },
{ sport: 'wnba', team: 'NY', rank: '8th', stat: 'Opp paint FG% allowed', exploit: "A'ja Wilson Points O23.5 · A" },
{ sport: 'mlb', team: 'ATL', rank: 'T-4th', stat: 'HR/9 to lefties', exploit: 'Ohtani Total Bases O1.5 · A' },
];
const statusColor = (s: string) =>
s === 'OUT' ? 'var(--miss)' : s === 'GTD' || s === 'QUESTIONABLE' ? 'var(--amber)' : 'var(--text-1)';
function vviColor(v: number) {
return v >= 85 ? 'var(--g-ap)' : v >= 75 ? 'var(--g-a)' : v >= 65 ? 'var(--g-b)' : 'var(--g-c)';
}
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
import { redirect } from 'next/navigation';
/**
* Session 57 (Phase 0) — /terminal is retired. The surface was fabricated
* sample data (Finals-era NBA content rendered as live intelligence); audit
* verdict: REBUILD. Its layouts are preserved as §12 content-engine templates
* in components/intel/TerminalTemplates.tsx. "The Terminal" survives as brand
* language only (pricing copy). Old links land on the real slate.
*/
export default function TerminalPage() {
return (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '28px 16px 96px' }}>
{/* HEADER */}
<header style={{ marginBottom: 22 }}>
<SectionHead accent="var(--g-a)"> LEAGUE INTELLIGENCE</SectionHead>
<h1 className="mono" style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 6px' }}>THE TERMINAL</h1>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
The context the books hide injury cascades, volatility, and the spots tonight's slate underprices.
</p>
</header>
{/* VVI — IMPACTED GAMES (intel surface) */}
<SectionHead style={{ marginBottom: 12 }}>VYNDR VOLATILITY INDEX · MOST-IMPACTED GAMES</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12, marginBottom: 28 }}>
{IMPACTED_GAMES.map((g) => (
<div key={g.match} className="intel-surface scanlines" style={{ borderRadius: 10, padding: 16, position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'relative', zIndex: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SportBadge sport={g.sport} size="sm" />
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: '#e8fff4' }}>{g.match}</span>
</div>
<div style={{ textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 26, fontWeight: 800, lineHeight: 1, color: vviColor(g.vvi), textShadow: '0 0 14px currentColor' }}>{g.vvi}</div>
<div className="label" style={{ fontSize: 8.5, color: 'rgba(232,255,244,.5)' }}>VVI</div>
</div>
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'rgba(232,255,244,.75)', margin: '10px 0', lineHeight: 1.55 }}>{g.note}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{g.drivers.map((d) => (
<span key={d} className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', padding: '2px 6px', borderRadius: 3, color: '#bdf5e2', border: '1px solid rgba(0,255,184,.3)', background: 'rgba(0,0,0,.25)' }}>{d}</span>
))}
</div>
<div className="mono" style={{ marginTop: 10, fontSize: 11, color: 'var(--g-a)' }}>{g.graded} graded props · {g.time}</div>
</div>
</div>
))}
</div>
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
{/* INJURY CASCADES */}
<div>
<SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{INJURY_WIRE.map((w) => (
<div key={w.player} style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SportBadge sport={w.sport} size="sm" />
<span style={{ fontSize: 14, fontWeight: 700 }}>{w.player}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{w.team}</span>
</div>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: statusColor(w.status) }}>{w.status}</span>
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 10 }}>{w.injury} · {w.posted}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{w.cascade.map((c, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)' }}>↳</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{c.player}</span>
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)' }}> · {c.stat} <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>▲ {c.delta}</span></span>
</span>
<GradeBadge grade={c.grade} size="sm" glow />
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* RIGHT COLUMN */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* FACTOR PULSE */}
<div>
<SectionHead style={{ marginBottom: 12 }}>FACTOR PULSE · FIRING NOW</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{FACTOR_PULSE.map((f) => (
<div key={f.label} style={card}>
<div className="mono" style={{ fontSize: 28, fontWeight: 800, color: f.color, lineHeight: 1 }}>{f.count}</div>
<div style={{ fontSize: 12.5, fontWeight: 700, marginTop: 6 }}>{f.label}</div>
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)', marginTop: 2 }}>{f.hint}</div>
</div>
))}
</div>
</div>
{/* GRADEABLE LEADERS */}
<div>
<SectionHead style={{ marginBottom: 12 }}>GRADEABLE LEADERS</SectionHead>
{LEADERS.map((grp) => (
<div key={grp.key} style={{ ...card, marginBottom: 10 }}>
<div className="label" style={{ marginBottom: 9 }}>{grp.key}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{grp.rows.map((r) => (
<div key={r.player} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600 }}>{r.player} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{r.team}</span></span>
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)' }}>{r.val}</span>
<GradeBadge grade={r.grade} size="sm" glow={r.gradeable} />
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
{/* MATCHUP EXPLOITS */}
<div style={{ marginTop: 28 }}>
<SectionHead style={{ marginBottom: 12 }}>MATCHUP EXPLOITS · UNDERPRICED SPOTS</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{MATCHUP_EXPLOITS.map((m, i) => (
<div key={i} style={card}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<SportBadge sport={m.sport} size="sm" />
<span className="mono" style={{ fontSize: 13, fontWeight: 700 }}>{m.team}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--miss)', fontWeight: 700 }}>{m.rank}</span>
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 8 }}>{m.stat}</div>
<div className="mono" style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--g-a)' }}> {m.exploit}</div>
</div>
))}
</div>
</div>
</section>
);
redirect('/dashboard');
}
+9 -5
View File
@@ -5,11 +5,15 @@ import { usePathname } from 'next/navigation';
/**
* VYNDR 2.0 mobile tab bar (§6, Session 37). The PWA's primary navigation —
* 5 tabs: Slate · Terminal · Scan · Ledger · More. Scan is prominent (raised,
* 5 tabs: Slate · Explore · Scan · Ledger · More. Scan is prominent (raised,
* grade-green) because it's the core action. More opens a bottom sheet with
* every secondary route. Hidden ≥768px via globals.css (.mobile-tab-bar).
*
* Shown for everyone (anon included): Slate/Terminal/Scan are open routes and
* Session 57 (Phase 0): the Terminal tab is gone — that surface was fabricated
* and now redirects to /dashboard. Explore (real graded-props browser) takes
* its slot so the bar keeps 5 tabs.
*
* Shown for everyone (anon included): Slate/Explore/Scan are open routes and
* this is the only mobile nav. Gated routes (Ledger, Account…) bounce through
* the client AuthGate when an anon user taps them.
*/
@@ -24,7 +28,7 @@ type TabDef = {
const TABS: TabDef[] = [
{ id: 'slate', label: 'Slate', href: '/dashboard', icon: SlateIcon },
{ id: 'terminal', label: 'Terminal', href: '/terminal', icon: TerminalIcon },
{ id: 'explore', label: 'Explore', href: '/explore', icon: ExploreIcon },
{ id: 'scan', label: 'Scan', href: '/scan', icon: ScanIcon, primary: true },
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
{ id: 'more', label: 'More', icon: MoreIcon, isSheet: true },
@@ -200,10 +204,10 @@ function SlateIcon({ color }: { color: string }) {
</svg>
);
}
function TerminalIcon({ color }: { color: string }) {
function ExploreIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 9l3 3-3 3" /><path d="M13 15h4" />
<circle cx="12" cy="12" r="9" /><path d="M15.5 8.5l-2 5-5 2 2-5z" />
</svg>
);
}
+16 -39
View File
@@ -9,13 +9,16 @@ import NotificationBell from '@/components/NotificationBell';
// Nav labels are English literals for now; nav-string i18n lands in Phase G
// (Session 38) once the locale dictionaries carry slate/terminal/etc. keys.
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / TERMINAL / SCAN /
// LEDGER; everything else lives under a More dropdown. All nav chrome is
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / SCAN / LEDGER;
// everything else lives under a More dropdown. All nav chrome is
// JetBrains Mono, uppercase, 11px — system language, not SaaS sans. Active
// route is grade-green (--g-a); a Ticker runs under the bar.
// Session 57 (Phase 0) — TERMINAL removed: the surface was fabricated sample
// data (audit verdict REBUILD). Its layouts live on as §12 content-engine
// templates in components/intel/TerminalTemplates.tsx; "The Terminal" survives
// as brand language only. Don't re-add the link until it's fed real data.
const PRIMARY = [
{ id: 'slate', label: 'Slate', href: '/dashboard' },
{ id: 'terminal', label: 'Terminal', href: '/terminal' },
{ id: 'scan', label: 'Scan', href: '/scan' },
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
];
@@ -29,12 +32,10 @@ const MORE = [
{ label: 'Settings', href: '/settings' },
];
const TICKER_ITEMS = [
{ tag: 'A+', text: 'Signal detected · Wembanyama Points', glow: true },
{ tag: 'LIVE', text: 'NYK vs SA · Q3 4:22', color: 'var(--live)' },
{ tag: 'CASCADE', text: 'Murray OUT → Jokic usage', delta: '▲+12%', color: 'var(--g-b)' },
{ tag: 'MOVE', text: 'Tatum o27.5 → o26.5', delta: '▼-1.0', color: 'var(--amber)' },
];
// Session 57 (Phase 0) — the hardcoded fallback ticker items (fake Wembanyama
// signal, "NYK vs SA Q3", invented Tatum move) are DELETED. The ticker renders
// only real /api/ticker snapshot exhaust, and hides itself entirely below 4
// real items (spec §3). Never seed it with fabricated content again.
function isActive(pathname: string, href: string) {
return pathname === href || pathname.startsWith(href + '/');
@@ -175,32 +176,10 @@ export default function Nav() {
</div>
</div>
{/* Right — search, bell, read meter / plan, avatar */}
{/* Right — bell, read meter / plan, avatar. Session 57 (Phase 0):
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 }}>
<a
href="/scan"
title="Query the system"
className="mono"
style={{
display: 'none',
alignItems: 'center',
gap: 7,
height: 28,
padding: '0 10px',
borderRadius: 7,
border: '1px solid var(--border-hi)',
background: 'var(--bg-2)',
color: 'var(--text-1)',
textDecoration: 'none',
fontSize: 11,
letterSpacing: '0.06em',
}}
data-search-trigger
>
<span style={{ color: 'var(--g-a)' }}></span>
<span>Query</span>
</a>
{/* Preferences (language / odds format / accessibility) — §9/§10 */}
<button
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
@@ -408,8 +387,9 @@ export default function Nav() {
)}
</nav>
{/* Ticker + heartbeat under the bar (§8 living layer) */}
<Ticker items={TICKER_ITEMS} height={32} />
{/* Ticker + heartbeat under the bar (§8 living layer). No fallback
items — real snapshot exhaust or nothing. */}
<Ticker items={[]} height={32} />
<HeartbeatBar />
<style jsx>{`
@@ -423,9 +403,6 @@ export default function Nav() {
:global(.nav-mobile-panel) {
display: none !important;
}
:global([data-search-trigger]) {
display: inline-flex !important;
}
}
`}</style>
</div>
+15 -2
View File
@@ -8,6 +8,7 @@ import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
import { emptyStateCopy } from '@/lib/emptyState';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
// navigation system; streaks + hot lists layer ON TOP of the odds the
@@ -606,7 +607,9 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
<div
style={{
position: 'sticky',
top: 122, // clears nav (60) + ticker (32) + heartbeat (30) — Session 38
// Clears nav (60) + heartbeat (30) + the dynamic ticker (Session 57:
// --ticker-h is 32px only when the ticker has ≥4 real items).
top: 'calc(90px + var(--ticker-h, 0px))',
zIndex: 5,
background: 'var(--bg-0, #0A0A0F)',
paddingTop: 12,
@@ -805,7 +808,17 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
</a>
</>
) : (
<p>No games published yet today. Check back closer to first pitch / tip-off / kickoff.</p>
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
// off-season sports name their return window; in-season = off-day.
(() => {
const { title, body } = emptyStateCopy(tab);
return (
<>
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
<p>{body}</p>
</>
);
})()
)}
</div>
)}
@@ -0,0 +1,216 @@
import SectionHead from '@/components/vyndr/SectionHead';
import GradeBadge from '@/components/vyndr/GradeBadge';
import SportBadge from '@/components/vyndr/SportBadge';
/* ============================================================
RETIRED SURFACE — §12 CONTENT-ENGINE TEMPLATES ONLY. NOT ROUTED.
Session 57 (Phase 0): the /terminal page shipped with this fabricated
sample dataset (Finals-era NBA injuries, invented VVI scores, made-up
leaders) rendered as live intelligence — audit verdict: REBUILD. The
route now redirects to /dashboard.
The LAYOUTS below (VVI cards, injury wire, gradeable leaders, matchup
exploits) are visually strong and preserved here as templates for the
§12 content engine. Feed them REAL data (scheduleService.getGameSummary
injuries, snapshot grades, /api/stats/leaders) before routing anything
that renders them. Every constant below is SAMPLE data — never let it
reach a user-facing surface.
============================================================ */
const INJURY_WIRE = [
{
player: 'Jamal Murray', team: 'DEN', sport: 'nba', status: 'OUT', injury: 'Hamstring', posted: '2h ago',
cascade: [
{ player: 'Nikola Jokić', stat: 'Assists', delta: '+3.2% usage', grade: 'A+' },
{ player: 'Russell Westbrook', stat: 'Points', delta: '+4.1% usage', grade: 'B' },
],
},
{
player: 'Jeremy Sochan', team: 'SA', sport: 'nba', status: 'OUT', injury: 'Ankle sprain', posted: '3h ago',
cascade: [
{ player: 'Victor Wembanyama', stat: 'Points', delta: '+3.2% usage', grade: 'A' },
{ player: 'Devin Vassell', stat: '3PT Made', delta: '+2.0% usage', grade: 'B' },
],
},
{
player: 'Mookie Betts', team: 'LAD', sport: 'mlb', status: 'GTD', injury: 'Wrist', posted: '40m ago',
cascade: [{ player: 'Shohei Ohtani', stat: 'RBIs', delta: '+1.9% lineup', grade: 'C' }],
},
];
const IMPACTED_GAMES = [
{ sport: 'nba', match: 'LAL @ SA', time: '10:30 PM ET', vvi: 92, graded: 7, note: 'Sochan OUT spikes Wembanyama usage; LAL pace + 26th-vs-C matchup compound it.', drivers: ['INJURY CASCADE', 'PACE', 'MATCHUP', 'BLOWOUT RISK'] },
{ sport: 'mlb', match: 'LAD @ ATL', time: '7:20 PM ET', vvi: 78, graded: 5, note: 'Truist Park boosts LH power; Ohtani platoon edge vs RHP, ATL bullpen on fumes.', drivers: ['PARK FACTOR', 'PLATOON', 'BULLPEN FATIGUE'] },
{ sport: 'wnba', match: 'NY @ LV', time: '9:00 PM ET', vvi: 71, graded: 4, note: "Wilson 31% usage vs NY's soft interior; Liberty on a back-to-back.", drivers: ['USAGE', 'MATCHUP', 'REST EDGE'] },
];
const FACTOR_PULSE = [
{ count: 11, label: 'Injury cascades', hint: 'props recalibrated', color: 'var(--g-b)' },
{ count: 8, label: 'Pace mismatches', hint: 'tempo edges', color: 'var(--g-a)' },
{ count: 6, label: 'Park / platoon', hint: 'MLB power spots', color: 'var(--g-a)' },
{ count: 5, label: 'Blowout risk', hint: 'minutes-capped', color: 'var(--miss)' },
];
const LEADERS: { key: string; rows: { player: string; team: string; val: string; grade: string; gradeable: boolean }[] }[] = [
{
key: 'NBA · Points',
rows: [
{ player: 'Nikola Jokić', team: 'DEN', val: '31.2', grade: 'A+', gradeable: true },
{ player: 'Victor Wembanyama', team: 'SA', val: '29.4', grade: 'A', gradeable: true },
{ player: 'Anthony Edwards', team: 'MIN', val: '28.4', grade: 'A', gradeable: true },
],
},
{
key: 'MLB · Total Bases',
rows: [
{ player: 'Shohei Ohtani', team: 'LAD', val: '2.25', grade: 'A', gradeable: true },
{ player: 'Aaron Judge', team: 'NYY', val: '2.10', grade: 'B', gradeable: false },
],
},
];
const MATCHUP_EXPLOITS = [
{ sport: 'nba', team: 'LAL', rank: '26th', stat: 'Points allowed to C', exploit: 'Wembanyama Points O26.5 · A' },
{ sport: 'wnba', team: 'NY', rank: '8th', stat: 'Opp paint FG% allowed', exploit: "A'ja Wilson Points O23.5 · A" },
{ sport: 'mlb', team: 'ATL', rank: 'T-4th', stat: 'HR/9 to lefties', exploit: 'Ohtani Total Bases O1.5 · A' },
];
const statusColor = (s: string) =>
s === 'OUT' ? 'var(--miss)' : s === 'GTD' || s === 'QUESTIONABLE' ? 'var(--amber)' : 'var(--text-1)';
function vviColor(v: number) {
return v >= 85 ? 'var(--g-ap)' : v >= 75 ? 'var(--g-a)' : v >= 65 ? 'var(--g-b)' : 'var(--g-c)';
}
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
export default function TerminalIntelTemplates() {
return (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '28px 16px 96px' }}>
{/* HEADER */}
<header style={{ marginBottom: 22 }}>
<SectionHead accent="var(--g-a)"> LEAGUE INTELLIGENCE</SectionHead>
<h1 className="mono" style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 6px' }}>THE TERMINAL</h1>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
The context the books hide injury cascades, volatility, and the spots tonight's slate underprices.
</p>
</header>
{/* VVI — IMPACTED GAMES (intel surface) */}
<SectionHead style={{ marginBottom: 12 }}>VYNDR VOLATILITY INDEX · MOST-IMPACTED GAMES</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12, marginBottom: 28 }}>
{IMPACTED_GAMES.map((g) => (
<div key={g.match} className="intel-surface scanlines" style={{ borderRadius: 10, padding: 16, position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'relative', zIndex: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SportBadge sport={g.sport} size="sm" />
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: '#e8fff4' }}>{g.match}</span>
</div>
<div style={{ textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 26, fontWeight: 800, lineHeight: 1, color: vviColor(g.vvi), textShadow: '0 0 14px currentColor' }}>{g.vvi}</div>
<div className="label" style={{ fontSize: 8.5, color: 'rgba(232,255,244,.5)' }}>VVI</div>
</div>
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'rgba(232,255,244,.75)', margin: '10px 0', lineHeight: 1.55 }}>{g.note}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{g.drivers.map((d) => (
<span key={d} className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', padding: '2px 6px', borderRadius: 3, color: '#bdf5e2', border: '1px solid rgba(0,255,184,.3)', background: 'rgba(0,0,0,.25)' }}>{d}</span>
))}
</div>
<div className="mono" style={{ marginTop: 10, fontSize: 11, color: 'var(--g-a)' }}>{g.graded} graded props · {g.time}</div>
</div>
</div>
))}
</div>
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
{/* INJURY CASCADES */}
<div>
<SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{INJURY_WIRE.map((w) => (
<div key={w.player} style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SportBadge sport={w.sport} size="sm" />
<span style={{ fontSize: 14, fontWeight: 700 }}>{w.player}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{w.team}</span>
</div>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: statusColor(w.status) }}>{w.status}</span>
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 10 }}>{w.injury} · {w.posted}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{w.cascade.map((c, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)' }}>↳</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{c.player}</span>
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)' }}> · {c.stat} <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>▲ {c.delta}</span></span>
</span>
<GradeBadge grade={c.grade} size="sm" glow />
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* RIGHT COLUMN */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* FACTOR PULSE */}
<div>
<SectionHead style={{ marginBottom: 12 }}>FACTOR PULSE · FIRING NOW</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{FACTOR_PULSE.map((f) => (
<div key={f.label} style={card}>
<div className="mono" style={{ fontSize: 28, fontWeight: 800, color: f.color, lineHeight: 1 }}>{f.count}</div>
<div style={{ fontSize: 12.5, fontWeight: 700, marginTop: 6 }}>{f.label}</div>
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)', marginTop: 2 }}>{f.hint}</div>
</div>
))}
</div>
</div>
{/* GRADEABLE LEADERS */}
<div>
<SectionHead style={{ marginBottom: 12 }}>GRADEABLE LEADERS</SectionHead>
{LEADERS.map((grp) => (
<div key={grp.key} style={{ ...card, marginBottom: 10 }}>
<div className="label" style={{ marginBottom: 9 }}>{grp.key}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{grp.rows.map((r) => (
<div key={r.player} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600 }}>{r.player} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{r.team}</span></span>
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)' }}>{r.val}</span>
<GradeBadge grade={r.grade} size="sm" glow={r.gradeable} />
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
{/* MATCHUP EXPLOITS */}
<div style={{ marginTop: 28 }}>
<SectionHead style={{ marginBottom: 12 }}>MATCHUP EXPLOITS · UNDERPRICED SPOTS</SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{MATCHUP_EXPLOITS.map((m, i) => (
<div key={i} style={card}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<SportBadge sport={m.sport} size="sm" />
<span className="mono" style={{ fontSize: 13, fontWeight: 700 }}>{m.team}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--miss)', fontWeight: 700 }}>{m.rank}</span>
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 8 }}>{m.stat}</div>
<div className="mono" style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--g-a)' }}> {m.exploit}</div>
</div>
))}
</div>
</div>
</section>
);
}
+55 -9
View File
@@ -39,11 +39,56 @@ export function LiveNumber({ value, className = '', style = {} }: { value: numbe
const EKG = 'M0 12 H10 L13 4 L16 20 L19 12 H30 L33 9 L36 15 L39 12 H50';
interface SnapshotSummary {
graded: number;
updated_at: string | null;
}
const SUMMARY_POLL_MS = 60_000;
const SYNC_AMBER_MS = 5 * 60_000; // §6 SyncClock — amber past 5 min
/** Elapsed-since-snapshot label: MM:SS under an hour, then Xh Ym. */
function syncLabel(elapsedMs: number): string {
const s = Math.max(0, Math.floor(elapsedMs / 1000));
if (s < 3600) return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
}
/** The system heartbeat (§8): scrolling EKG + SIGNAL LIVE + graded count +
* breathing neural % + sync clock. Sits under the ticker. */
* sync clock. Sits under the ticker.
*
* Session 57 (Phase 0) — every number here is REAL now. The graded count is
* the snapshot's actual prop count from /api/snapshot/summary (small and true
* beats big and fake), the fabricated brain-% stat is gone entirely, and SYNC counts
* up from the last real pipeline run (amber once it's >5 min stale). No
* summary data → "SYNC —" and no invented count. */
export function HeartbeatBar() {
const live = useLive();
const clock = `${String(Math.floor(live.tick / 60) % 60).padStart(2, '0')}:${String(live.tick % 60).padStart(2, '0')}`;
const live = useLive(); // 1s pulse — re-renders the elapsed clock
const [summary, setSummary] = useState<SnapshotSummary | null>(null);
useEffect(() => {
let active = true;
const load = async () => {
try {
const r = await fetch('/api/snapshot/summary', { cache: 'no-store' });
if (!r.ok) return;
const data = await r.json();
if (active && typeof data?.graded === 'number') {
setSummary({ graded: data.graded, updated_at: data.updated_at ?? null });
}
} catch {
/* keep last known real values — never invent */
}
};
load();
const id = setInterval(load, SUMMARY_POLL_MS);
return () => { active = false; clearInterval(id); };
}, []);
void live.tick; // subscription drives the 1s clock re-render
const syncedAt = summary?.updated_at ? Date.parse(summary.updated_at) : NaN;
const elapsed = Number.isFinite(syncedAt) ? Date.now() - syncedAt : null;
const stale = elapsed !== null && elapsed > SYNC_AMBER_MS;
return (
<div
className="scanlines"
@@ -77,13 +122,14 @@ export function HeartbeatBar() {
</div>
</div>
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
<LiveNumber value={live.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
{summary !== null && (
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
<LiveNumber value={summary.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
</span>
)}
<span className="mono" style={{ color: stale ? 'var(--amber)' : 'var(--text-2)', flexShrink: 0 }}>
SYNC {elapsed !== null ? syncLabel(elapsed) : '—'}
</span>
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
neural <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{live.neural}%</span>
</span>
<span className="mono" style={{ color: 'var(--text-2)', flexShrink: 0 }}>SYNC {clock}</span>
</div>
);
}
+21 -2
View File
@@ -27,11 +27,19 @@ const TAG_COLORS: Record<string, string> = {
MOVE: 'var(--amber)', CASCADE: 'var(--amber)', ALERT: 'var(--text-0)',
};
// Spec §3 — fewer than 4 real items → no ticker. An almost-empty marquee
// looping the same item reads as fake; hide the bar instead.
const MIN_ITEMS = 4;
/**
* 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 passed `items` are the
* initial + graceful fallback so the bar is never empty.
* (top grades, line moves, slate-scanned events).
*
* 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.
*/
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
const [feed, setFeed] = useState<TickerItem[] | null>(null);
@@ -66,6 +74,17 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
}, [live, pollMs]);
const display = feed && feed.length > 0 ? feed : items;
const visible = display.length >= MIN_ITEMS;
// Publish the bar's height so the fixed-header consumers (layout main
// paddingTop, Slate sticky top) stay in sync whether or not the bar shows.
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.style.setProperty('--ticker-h', visible ? `${height}px` : '0px');
return () => { document.documentElement.style.setProperty('--ticker-h', '0px'); };
}, [visible, height]);
if (!visible) return null;
const content = display.map((it, i) => (
<span
+49
View File
@@ -0,0 +1,49 @@
/* ============================================================
Session 57 (Phase 0) — honest per-sport empty-slate copy (spec §6).
A sport with zero games must say WHY instead of showing a fixture game or
passive "waiting" language. Month-aware: an off-season sport names its
return window; an in-season sport calls it an off-day. CommonJS so both the
.tsx surfaces (allowJs) and the plain-JS Jest suite can load it.
============================================================ */
// Off-season months per sport (0-indexed). Deliberately conservative: only
// months where the league is CERTAINLY dark, so an early/late season edge day
// reads as an off-day rather than falsely claiming the league is done.
const OFF_SEASON = {
nba: { months: [6, 7, 8], returns: 'October' }, // JulSep
wnba: { months: [10, 11, 0, 1, 2, 3], returns: 'May' }, // NovApr
mlb: { months: [11, 0, 1], returns: 'spring' }, // DecFeb
};
const LABEL = { nba: 'NBA', wnba: 'WNBA', mlb: 'MLB', soccer: 'Soccer' };
/**
* Copy for an empty slate. `sport` is lowercase ('nba'|'wnba'|'mlb'|'soccer'
* |'all'); anything else falls back to the generic line.
* Returns { title, body }.
*/
function emptyStateCopy(sport, date = new Date()) {
const sp = String(sport || '').toLowerCase();
const month = date.getMonth();
const off = OFF_SEASON[sp];
if (off && off.months.includes(month)) {
return {
title: `${LABEL[sp]} returns in ${off.returns}.`,
body: 'The offseason board is dark. The model is running the live slates in the meantime.',
};
}
if (LABEL[sp]) {
return {
title: `No ${LABEL[sp]} games today.`,
body: 'Off-day. The board refills with the next slate.',
};
}
return {
title: 'No games on the board today.',
body: 'The board refills with the next slate.',
};
}
module.exports = { emptyStateCopy, OFF_SEASON };
+10 -14
View File
@@ -1,16 +1,22 @@
/* ============================================================
VYNDR 2.0 — living-layer tick engine (§8).
ONE interval, many subscribers (fan-out). Drives HeartbeatBar,
LiveNumber, NeuralBrain. Plain CommonJS so the React hook imports it
ONE interval, many subscribers (fan-out). Drives HeartbeatBar's
elapsed-since-snapshot clock. Plain CommonJS so the React hook imports it
AND Jest drives it manually via tick().
Does NOT auto-start on import (would run in SSR/tests). The React hook
calls start() on mount; tests call tick() directly. Animations are gated
behind reduced-motion in the CSS layer (Session 33), not here — the tick
only updates data.
Session 57 (Phase 0) — the fake counters are DEAD. This used to carry a
cosmetically auto-incrementing graded count, a sin-driven brain-%, and
invented aPlus/cascades counts. All deleted: real numbers now come from
/api/snapshot/summary (see LiveLayer). The tick survives ONLY as the 1s
re-render pulse. Do not add display data back here.
============================================================ */
const INITIAL = { tick: 0, graded: 247, neural: 96, aPlus: 6, cascades: 11 };
const INITIAL = { tick: 0 };
const liveTick = {
listeners: new Set(),
@@ -28,17 +34,7 @@ const liveTick = {
/** Advance one tick. New state object so React subscribers re-render. */
tick() {
const prev = this.state;
const t = prev.tick + 1;
this.state = {
tick: t,
// graded count creeps up roughly every ~7s
graded: prev.graded + (t % 7 === 0 ? 1 : 0),
// neural % "breathes" 9098 deterministically (sin-driven, test-safe)
neural: 94 + Math.round(Math.sin(t / 3) * 4),
aPlus: prev.aPlus + (t % 23 === 0 ? 1 : 0),
cascades: prev.cascades,
};
this.state = { tick: this.state.tick + 1 };
this._notify();
return this.state;
},
+3 -2
View File
@@ -26,12 +26,13 @@ const GATED_ROUTES = [
];
/* Open — reachable without auth (marketing + the free funnel + auth itself). */
/* Session 57 (Phase 0): '/terminal' removed — the route now server-redirects
to /dashboard (fabricated surface retired), so it needs no gate exemption. */
const OPEN_ROUTES = [
'/',
'/dashboard',
'/slate',
'/scan',
'/terminal',
'/compare',
'/game',
'/pricing',
@@ -59,7 +60,7 @@ const HASH_ALIASES = {
'#slate': '/dashboard',
'#dashboard': '/dashboard',
'#scan': '/scan',
'#terminal': '/terminal',
'#terminal': '/dashboard', // retired surface (Session 57) — old links land on the slate
'#compare': '/compare',
'#ledger': '/ledger',
'#tracker': '/tracker',