diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 5768ffe..a0da096 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,9 +4,55 @@ 2026-07-10 ## Current Phase -SHIP BUILD v56.0 — The full data audit: PropLine/boxscore/pipeline inventory, -rbis→rbi fix, +6 MLB markets, doubles/outs wired end-to-end, ntfy alerting + -retry + missed-cron detection, master roadmap. +SHIP BUILD v57.0 — Phase 0 "Kill the Lies" (work order, Jul 10 live audit): +every fabricated UI element deleted or rewired to real data. Next: Phase 1 +(ledger persistence + settlement — the truth infrastructure). + +## Session 57 (2026-07-10) — SHIPPED ✅ PHASE 0: KILL THE LIES + +Backend 2289 → **2309 tests** (+20), 199 suites. Web build clean (exit 0). +Spec: `specs/phase-0-kill-the-lies.md`. Work order: Phase 0 of the +overhaul (audit-driven; Phases 1–6 queued). + +### 0.1 — Fake NBA game (root cause found + fixed at the source) +- `scheduleService.fetchScheduleFromEspn` hit ESPN with NO `?dates=` param and + NO date filter → off-season ESPN returns the NEAREST slate (the Jun 13 NYK@SA + Finals game rendered as "tonight" all July). Now pins `?dates=YYYYMMDD` AND + filters events to the requested ET date; undated events dropped. +- Honest per-sport empty states: `web/src/lib/emptyState.js` (month-aware — + "NBA returns in October." vs in-season "No NBA games today."), used by the + dashboard NO SLATE block + the Slate empty state. + +### 0.2 — Fake header counters deleted +- `liveTick.js` stripped to `{ tick }` (1s re-render pulse only). The fake + auto-incrementing "247 graded", sin-driven brain-%, aPlus/cascades: DEAD. +- New `GET /api/snapshot/summary` (public, cache-only, registered before + `/:sport`) → `{ graded, updated_at, sports }`. Next proxy added. +- `HeartbeatBar` shows the REAL graded count + SYNC = elapsed since the last + pipeline run (amber past 5 min). No data → "SYNC —", nothing invented. + +### 0.3 — Ticker: real items only +- Nav's hardcoded fallback items (fake Wembanyama A+/"NYK vs SA Q3"/invented + Tatum MOVE) deleted. MOVE stays — `computeLineDeltas` is real movement. +- Spec §3 rule: <4 real items → ticker renders nothing. It publishes + `--ticker-h` (32px/0px) on ``; layout main paddingTop and Slate sticky + top are now `calc(base + var(--ticker-h))` so the header collapses cleanly. + +### 0.4 — /terminal retired (fabricated surface) +- Route now `redirect('/dashboard')`. Layouts preserved UNROUTED at + `web/src/components/intel/TerminalTemplates.tsx` (§12 content-engine + templates — feed real data before ever routing them). +- Nav PRIMARY = Slate/Scan/Ledger. BottomTabBar: Terminal→Explore. PWA + manifest shortcut Terminal→Ledger. routes.js: `/terminal` out of + OPEN_ROUTES; `#terminal` → `/dashboard`. + +### 0.5 — ›Query nav pill deleted (duplicate /scan link) + +### Next (work order Phase 1 — starts the settle clock) +- `ledger_entries` table + write-on-grade (user scans AND pipeline pre-grades) +- Settlement worker; populate /ledger (personal + MODEL tabs) +- Degenerate scan output (model==line, +0% edge) → honest "INSUFFICIENT DATA" +- Canonical player_key normalization (blocks 2.4, 4.1) ## Session 56 (2026-07-10) — SHIPPED ✅ THE FULL AUDIT diff --git a/CLAUDE.md b/CLAUDE.md index 61e51f1..ee13ef2 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -684,6 +684,32 @@ snapshot, locked to the line, and read from cache. transient provider blip). A successful-but-empty slate is NOT retried — that's a legit off-hours empty slate, and retrying would waste quota + add 60s latency. +## Phase 0 — Kill the Lies (Session 57 — non-obvious) +Work-order Phase 0 (Jul 10 live audit): fabricated UI deleted/rewired. The +overhaul work order lives in `specs/phase-0-kill-the-lies.md` (+ the full +phased plan in the Session-57 conversation / BUILD-STATE Next section). +- **ESPN schedule is date-pinned now.** `fetchScheduleFromEspn(sport, date)` + sends `?dates=YYYYMMDD` AND filters events to the requested ET date (undated + events dropped). Without this, off-season ESPN returns the NEAREST slate — + the Jun 13 NYK@SA Finals game rendered as "tonight" all July. Don't remove + the defensive filter even though the param "should" be enough. +- **`liveTick` carries NO display data** — state is `{ tick }` only (1s + re-render pulse). Real header numbers come from `GET /api/snapshot/summary` + (public, cache-only; registered BEFORE `/:sport` in `routes/snapshot.js`). + `vyndrSystems.test.js` fails if fabricated counters reappear. +- **Ticker hides below 4 real items** (spec §3) and publishes `--ticker-h` + (32px/0px) on ``. Layout main paddingTop = `calc(92px + + var(--ticker-h, 0px))`, Slate sticky top = `calc(90px + ...)`. If you change + header heights, update BOTH calc bases (they replace the old 124/122 + constants). No hardcoded fallback ticker items — real exhaust or nothing. +- **/terminal redirects to /dashboard.** The old page's layouts (VVI cards, + injury wire, leaders) are preserved UNROUTED in + `web/src/components/intel/TerminalTemplates.tsx` as §12 content-engine + templates — every constant in there is SAMPLE data; never route it as-is. + BottomTabBar's 5 tabs are now Slate/Explore/Scan/Ledger/More. +- **Empty slates use `web/src/lib/emptyState.js`** (month-aware per-sport + copy). It's CommonJS + unit-tested; add sports there, not inline. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/specs/phase-0-kill-the-lies.md b/specs/phase-0-kill-the-lies.md new file mode 100644 index 0000000..f888ef0 --- /dev/null +++ b/specs/phase-0-kill-the-lies.md @@ -0,0 +1,80 @@ +# Phase 0 — Kill the Lies (Session 57) + +Source: VYNDR work order (Jul 10 2026 live audit × VYNDR-OVERHAUL-SPEC). Every +task is a P0 credibility fix — deletion or rewiring of fake elements. No new +product features. + +## 0.1 — Delete the fake NBA game + +**Root cause (found):** `scheduleService.fetchScheduleFromEspn` hits the ESPN +scoreboard with NO `?dates=` param and NO date filter. In the off-season ESPN +returns the *nearest* slate (the Jun 13 NYK@SA Finals game), which every +consumer (dashboard legacy section, Slate merge, hasOdds flags) then renders as +tonight's game. + +**Fix (backend, at the source):** +- `fetchScheduleFromEspn(sport, date)` appends `?dates=YYYYMMDD` to the ESPN + scoreboard URL. +- Normalized events are filtered to the requested ET date (`gameTime` converted + to America/New_York must equal `date`). Events with no parseable date are + dropped — live-feed-only, no exceptions. +- `getSchedule` passes its `date` through. + +**Fix (frontend):** sport-specific empty-state copy on the dashboard when a +sport has zero games (NBA off-season: "NBA returns in October. The model is +watching the MLB + WNBA boards tonight."). + +**Acceptance:** `/api/schedule/nba?date=D` never returns an event whose ET date +≠ D. Regression test uses the old NYK@SA fixture (event dated D+1) and asserts +it is filtered out. + +## 0.2 — Delete the fake header counters + +- `lib/liveTick.js` state trimmed to `{ tick }` — the fake `graded: 247` + auto-increment, sin-driven `neural %`, and `aPlus/cascades` counters are + deleted. The tick remains solely as the 1s re-render pulse for the clock. +- `HeartbeatBar` now fetches **real** data from a new endpoint + `GET /api/snapshot/summary` (public, cache-only Redis reads, registered + BEFORE `/:sport`): `{ graded, updated_at, sports: {nba,wnba,mlb,soccer} }`. + - graded = Σ grades in each `snapshot:{sport}:latest` (fallback + `grades:{sport}`). Real and small beats fake and big. + - "neural XX%" — deleted entirely, no replacement. + - SYNC = elapsed since `updated_at` (counts up; amber past 5 min). No + snapshot data → "SYNC —", no graded count invented. +- Next proxy `web/src/app/api/snapshot/summary/route.ts` (S25 rule). + +**Acceptance:** header numbers change only when the snapshot changes; refresh +does not reset them to 247/96%. + +## 0.3 — Ticker: real items only + +- The MOVE pipeline is already real (`computeLineDeltas`: locked line vs + current line keyed `nameKey|stat|side`) — kept. +- Nav's hardcoded `TICKER_ITEMS` fallback (fake Wembanyama A+ / "NYK vs SA + Q3" / fake Tatum MOVE) — deleted. Ticker fallback is now an empty list. +- Spec §3 rule: fewer than 4 real items → the ticker bar does not render. + The bar sets `--ticker-h` (32px/0px) on ``; layout `main` paddingTop + and Slate sticky top become `calc(Xpx + var(--ticker-h))` so the header + collapses cleanly. + +## 0.4 — Pull /terminal from nav + +- Entire surface is fabricated (Finals-era sample data) → route now + `redirect('/dashboard')`. +- Sample layouts preserved (unrouted) at + `web/src/components/intel/TerminalTemplates.tsx` for the §12 content engine. +- Nav PRIMARY = Slate / Scan / Ledger. BottomTabBar: Terminal tab → Explore. + PWA manifest Terminal shortcut → Ledger. `routes.js`: `/terminal` out of + OPEN_ROUTES; `#terminal` alias → `/dashboard`. + +## 0.5 — Kill ›Query + +Duplicate link to /scan (`data-search-trigger`) deleted from Nav. + +## Test plan + +- Updated: scheduleRoute (date filter + regression), vyndrSystems (liveTick), + vyndrMobile (tabs/manifest), vyndrAppShell (routes/nav), vyndrCoreScreens + (terminal redirect), vyndrParityQA (tab list). +- New: snapshot summary route integration test; schedule off-date filter test. +- Full suite green before ship. diff --git a/src/routes/snapshot.js b/src/routes/snapshot.js index ae3f8b0..629ebc3 100644 --- a/src/routes/snapshot.js +++ b/src/routes/snapshot.js @@ -36,6 +36,39 @@ function attachOutcomes(grades, index) { }); } +// Session 57 (Phase 0) — GET /api/snapshot/summary: the HeartbeatBar's honest +// data source. Cheap cache-only Redis reads; total graded props in the current +// snapshots + the latest pipeline run time. Registered BEFORE /:sport or +// Express captures "summary" as a sport. +const SUMMARY_SPORTS = ['nba', 'wnba', 'mlb', 'soccer']; + +router.get('/summary', async (req, res) => { + try { + const reads = await Promise.all(SUMMARY_SPORTS.map(async (sp) => { + const snap = await cacheGet(`snapshot:${sp}:latest`); + if (snap && Array.isArray(snap.grades)) { + return { sport: sp, graded: snap.grades.length, updated_at: snap.updated_at || null }; + } + const env = await cacheGet(`grades:${sp}`); + return { + sport: sp, + graded: env && Array.isArray(env.grades) ? env.grades.length : 0, + updated_at: (env && env.updated_at) || null, + }; + })); + const graded = reads.reduce((n, r) => n + r.graded, 0); + // ISO timestamps sort lexicographically — the max is the latest run. + const updated_at = reads.map((r) => r.updated_at).filter(Boolean).sort().pop() || null; + const sports = {}; + for (const r of reads) sports[r.sport] = r.graded; + res.set('Cache-Control', 'public, max-age=30'); + return res.json({ graded, updated_at, sports }); + } catch (err) { + console.error('[snapshot/summary]', err.message); + return res.status(200).json({ graded: 0, updated_at: null, sports: {} }); + } +}); + router.get('/:sport', async (req, res) => { const sport = String(req.params.sport || '').toLowerCase(); try { diff --git a/src/services/scheduleService.js b/src/services/scheduleService.js index 34431e0..00cdaa2 100644 --- a/src/services/scheduleService.js +++ b/src/services/scheduleService.js @@ -87,14 +87,40 @@ function normalizeEvent(ev) { } /** - * Fetch + normalize the ESPN scoreboard for a sport. Free endpoint. + * A game's ET date (YYYY-MM-DD) from its ISO start time, or null when the + * timestamp is missing/unparseable. */ -async function fetchScheduleFromEspn(sport) { +function gameDateET(iso) { + if (!iso) return null; + const t = new Date(iso); + if (Number.isNaN(t.getTime())) return null; + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', + year: 'numeric', month: '2-digit', day: '2-digit', + }).format(t); +} + +/** + * Fetch + normalize the ESPN scoreboard for a sport. Free endpoint. + * + * Session 57 (Phase 0) — MUST be pinned to the requested date. Without a + * `?dates=` param ESPN returns the NEAREST slate in the off-season (the Jun 13 + * NYK@SA Finals game kept rendering as "tonight" all July). We pass the param + * AND filter defensively: only events whose ET date matches `date` survive; + * events with no parseable date are dropped — the slate derives from the live + * feed only, never from whatever ESPN felt like returning. + */ +async function fetchScheduleFromEspn(sport, date) { const cfg = getSportConfig(sport); if (!cfg || !cfg.espnScoreboard) return null; - const res = await axios.get(cfg.espnScoreboard, { timeout: HTTP_TIMEOUT_MS }); + const sep = cfg.espnScoreboard.includes('?') ? '&' : '?'; + const url = date + ? `${cfg.espnScoreboard}${sep}dates=${String(date).replace(/-/g, '')}` + : cfg.espnScoreboard; + const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS }); const events = res.data?.events || []; - return events.map(normalizeEvent).filter(Boolean); + const games = events.map(normalizeEvent).filter(Boolean); + return date ? games.filter((g) => gameDateET(g.gameTime) === date) : games; } /** @@ -111,7 +137,7 @@ async function getSchedule(sport, date) { if (cached !== null) return cached; try { - const games = await fetchScheduleFromEspn(sport); + const games = await fetchScheduleFromEspn(sport, date); if (Array.isArray(games)) { await cacheSet(key, games, SCHEDULE_TTL); await cacheSet(`${key}:stale`, games, STALE_TTL); diff --git a/tests/integration/scheduleRoute.test.js b/tests/integration/scheduleRoute.test.js index e14f5ce..66121f1 100644 --- a/tests/integration/scheduleRoute.test.js +++ b/tests/integration/scheduleRoute.test.js @@ -28,12 +28,16 @@ function mountApp() { return app; } +// NOTE (Session 57, Phase 0): the event date must fall on the REQUESTED ET +// date (2026-06-12) — 00:40Z Jun 13 = 20:40 ET Jun 12. The old fixture was +// dated a day later and the route served it anyway; that exact hole is how the +// Jun 13 NYK@SA Finals game kept rendering as "tonight" all July. const ESPN_NBA = { data: { events: [ { id: '401234567', - date: '2026-06-14T00:40:00Z', + date: '2026-06-13T00:40:00Z', status: { type: { state: 'pre' } }, competitions: [{ venue: { fullName: 'Frost Bank Center' }, @@ -81,6 +85,57 @@ describe('GET /api/schedule/:sport', () => { expect(axios.get.mock.calls.length).toBe(callsAfterFirst); // served from cache }); + test('pins the ESPN fetch to the requested date (?dates=YYYYMMDD)', async () => { + axios.get.mockResolvedValue(ESPN_NBA); + await request(mountApp()).get('/api/schedule/nba?date=2026-06-12'); + expect(axios.get.mock.calls[0][0]).toContain('dates=20260612'); + }); + + // Session 57 (Phase 0) regression — in the off-season ESPN returns the + // NEAREST slate, not today's. Any event whose ET date ≠ the requested date + // must be dropped, or a June Finals game renders as tonight's slate in July. + test('filters out events from a different ET date (fake off-season game)', async () => { + axios.get.mockResolvedValue({ + data: { + events: [ + { + id: '401999999', + date: '2026-06-14T00:30:00Z', // Jun 13 ET — NOT the requested Jun 12 + status: { type: { state: 'pre' } }, + competitions: [{ + competitors: [ + { homeAway: 'home', score: '0', team: { displayName: 'San Antonio Spurs', abbreviation: 'SA' } }, + { homeAway: 'away', score: '0', team: { displayName: 'New York Knicks', abbreviation: 'NYK' } }, + ], + }], + }, + ], + }, + }); + const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12'); + expect(res.status).toBe(200); + expect(res.body.games).toEqual([]); + }); + + test('drops events with no parseable date (live feed only)', async () => { + axios.get.mockResolvedValue({ + data: { + events: [{ + id: '77', // no `date` field at all + status: { type: { state: 'pre' } }, + competitions: [{ + competitors: [ + { homeAway: 'home', score: '0', team: { displayName: 'A', abbreviation: 'A' } }, + { homeAway: 'away', score: '0', team: { displayName: 'B', abbreviation: 'B' } }, + ], + }], + }], + }, + }); + const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12'); + expect(res.body.games).toEqual([]); + }); + test('returns empty array (not error) when no games today', async () => { axios.get.mockResolvedValue({ data: { events: [] } }); const res = await request(mountApp()).get('/api/schedule/mlb?date=2026-06-12'); diff --git a/tests/integration/snapshotSummary.test.js b/tests/integration/snapshotSummary.test.js new file mode 100644 index 0000000..20f1fb2 --- /dev/null +++ b/tests/integration/snapshotSummary.test.js @@ -0,0 +1,68 @@ +// Session 57 (Phase 0) — GET /api/snapshot/summary: the HeartbeatBar's honest +// data source. Real graded counts + latest pipeline run time from cache-only +// Redis reads; must never invent numbers on a cold cache. + +const express = require('express'); +const request = require('supertest'); + +const store = {}; +jest.mock('../../src/utils/redis', () => ({ + cacheGet: jest.fn(async (k) => (k in store ? store[k] : null)), + cacheSet: jest.fn(async (k, v) => { store[k] = v; return true; }), +})); + +function mountApp() { + delete require.cache[require.resolve('../../src/routes/snapshot')]; + const snapshotRoutes = require('../../src/routes/snapshot'); + const app = express(); + app.use('/api/snapshot', snapshotRoutes); + return app; +} + +beforeEach(() => { + for (const k of Object.keys(store)) delete store[k]; + jest.clearAllMocks(); +}); + +describe('GET /api/snapshot/summary', () => { + test('sums real graded counts across sports and reports the latest run', async () => { + store['snapshot:mlb:latest'] = { + updated_at: '2026-07-10T18:00:00.000Z', + grades: [{ player: 'A' }, { player: 'B' }, { player: 'C' }], + }; + store['snapshot:wnba:latest'] = { + updated_at: '2026-07-10T19:00:00.000Z', + grades: [{ player: 'D' }], + }; + const res = await request(mountApp()).get('/api/snapshot/summary'); + expect(res.status).toBe(200); + expect(res.body.graded).toBe(4); + expect(res.body.updated_at).toBe('2026-07-10T19:00:00.000Z'); // latest run + expect(res.body.sports.mlb).toBe(3); + expect(res.body.sports.wnba).toBe(1); + expect(res.body.sports.nba).toBe(0); // off-season → honest zero + }); + + test('falls back to the grades:{sport} envelope when no snapshot exists', async () => { + store['grades:mlb'] = { updated_at: '2026-07-10T16:00:00.000Z', grades: [{ player: 'A' }, { player: 'B' }] }; + const res = await request(mountApp()).get('/api/snapshot/summary'); + expect(res.body.graded).toBe(2); + expect(res.body.updated_at).toBe('2026-07-10T16:00:00.000Z'); + }); + + test('cold cache → zeros and null timestamp, never invented numbers', async () => { + const res = await request(mountApp()).get('/api/snapshot/summary'); + expect(res.status).toBe(200); + expect(res.body.graded).toBe(0); + expect(res.body.updated_at).toBeNull(); + }); + + test('is not captured by the /:sport route (summary ≠ a sport)', async () => { + store['snapshot:mlb:latest'] = { updated_at: 'x', grades: [{ player: 'A' }] }; + const res = await request(mountApp()).get('/api/snapshot/summary'); + // The /:sport handler would return { sport, grades, deltas }. + expect(res.body.sport).toBeUndefined(); + expect(res.body.grades).toBeUndefined(); + expect(typeof res.body.graded).toBe('number'); + }); +}); diff --git a/tests/unit/emptyState.test.js b/tests/unit/emptyState.test.js new file mode 100644 index 0000000..dd483c3 --- /dev/null +++ b/tests/unit/emptyState.test.js @@ -0,0 +1,46 @@ +// Session 57 (Phase 0) — honest per-sport empty-slate copy (spec §6). +// An off-season sport names its return window; an in-season sport calls it an +// off-day. Never fixture games, never passive "waiting" language. + +const { emptyStateCopy } = require('../../web/src/lib/emptyState'); + +const july = new Date('2026-07-10T12:00:00'); +const december = new Date('2026-12-10T12:00:00'); +const march = new Date('2026-03-10T12:00:00'); + +describe('emptyStateCopy', () => { + test('NBA in July → off-season, returns in October', () => { + const { title } = emptyStateCopy('nba', july); + expect(title).toBe('NBA returns in October.'); + }); + + test('NBA in December → in-season off-day', () => { + const { title } = emptyStateCopy('nba', december); + expect(title).toBe('No NBA games today.'); + }); + + test('WNBA in March → off-season, returns in May', () => { + expect(emptyStateCopy('wnba', march).title).toBe('WNBA returns in May.'); + }); + + test('WNBA in July → in-season off-day', () => { + expect(emptyStateCopy('wnba', july).title).toBe('No WNBA games today.'); + }); + + test('MLB in December → off-season', () => { + expect(emptyStateCopy('mlb', december).title).toBe('MLB returns in spring.'); + }); + + test('soccer has no off-season branch → off-day copy', () => { + expect(emptyStateCopy('soccer', july).title).toBe('No Soccer games today.'); + }); + + test('unknown sport / "all" tab falls back to the generic line', () => { + expect(emptyStateCopy('all', july).title).toBe('No games on the board today.'); + expect(emptyStateCopy(undefined, july).title).toBe('No games on the board today.'); + }); + + test('handles uppercase sport ids (dashboard passes NBA.toLowerCase(), but be safe)', () => { + expect(emptyStateCopy('NBA', july).title).toBe('NBA returns in October.'); + }); +}); diff --git a/tests/unit/vyndrAppShell.test.js b/tests/unit/vyndrAppShell.test.js index c5387b3..e72096e 100644 --- a/tests/unit/vyndrAppShell.test.js +++ b/tests/unit/vyndrAppShell.test.js @@ -25,7 +25,7 @@ describe('Phase C — routing config (lib/routes)', () => { }); it('leaves the landing + free funnel open (dashboard, scan stay public)', () => { - ['/', '/dashboard', '/scan', '/pricing', '/blog', '/terminal', '/login'].forEach((r) => { + ['/', '/dashboard', '/scan', '/pricing', '/blog', '/login'].forEach((r) => { expect(routes.isGatedRoute(r)).toBe(false); }); }); @@ -37,12 +37,17 @@ describe('Phase C — routing config (lib/routes)', () => { it('resolves hash deep-link aliases to real routes', () => { expect(routes.resolveHashAlias('#scan')).toBe('/scan'); - expect(routes.resolveHashAlias('#terminal')).toBe('/terminal'); + // Session 57 (Phase 0) — /terminal retired; old links land on the slate. + expect(routes.resolveHashAlias('#terminal')).toBe('/dashboard'); expect(routes.resolveHashAlias('#slate')).toBe('/dashboard'); expect(routes.resolveHashAlias('#nope')).toBeNull(); expect(routes.resolveHashAlias('')).toBeNull(); }); + it('drops the retired /terminal surface from OPEN_ROUTES', () => { + expect(routes.OPEN_ROUTES).not.toContain('/terminal'); + }); + it('keeps GATED and OPEN route lists disjoint', () => { const overlap = routes.GATED_ROUTES.filter((r) => routes.OPEN_ROUTES.includes(r)); expect(overlap).toEqual([]); @@ -77,11 +82,18 @@ describe('Phase C — Nav conversion', () => { expect(src).toContain("fontFamily: 'var(--mono)'"); expect(src).toContain("'var(--g-a)'"); }); - it('exposes the primary Slate/Terminal/Scan/Ledger routes', () => { - ['/dashboard', '/terminal', '/scan', '/ledger'].forEach((href) => { + it('exposes the primary Slate/Scan/Ledger routes', () => { + ['/dashboard', '/scan', '/ledger'].forEach((href) => { expect(src).toContain(href); }); }); + // Session 57 (Phase 0) — Terminal (fabricated surface) and the duplicate + // ›Query pill are gone from the nav. Don't re-add either. + it('does not link /terminal or render the ›Query duplicate', () => { + expect(src).not.toContain("href: '/terminal'"); + expect(src).not.toContain('data-search-trigger'); + expect(src).not.toContain('>Query<'); + }); }); describe('Phase C — Footer (system voice)', () => { diff --git a/tests/unit/vyndrCoreScreens.test.js b/tests/unit/vyndrCoreScreens.test.js index 191f4f6..99c3ff7 100644 --- a/tests/unit/vyndrCoreScreens.test.js +++ b/tests/unit/vyndrCoreScreens.test.js @@ -124,16 +124,22 @@ describe('Phase D — ClaimMeter', () => { }); }); -describe('Phase D — Terminal page (real intelligence, not a stub)', () => { - const src = read('app/terminal/page.tsx'); - it('is no longer a RouteStub', () => { - expect(src).not.toContain('RouteStub'); +// Session 57 (Phase 0) — the Terminal surface was fabricated sample data +// (Finals-era NBA content rendered as live intelligence). The route now +// redirects to /dashboard; the layouts survive UNROUTED as §12 templates. +describe('Phase 0 — Terminal retired (fabricated surface)', () => { + it('the route redirects to the slate instead of rendering fake intel', () => { + const src = read('app/terminal/page.tsx'); + expect(src).toContain("redirect('/dashboard')"); + expect(src).not.toContain('INJURY_WIRE'); + expect(src).not.toContain('Wembanyama'); }); - it('uses the intel-surface and renders the VVI / cascade / leaders sections', () => { - expect(src).toContain('intel-surface'); - expect(src).toContain('VOLATILITY INDEX'); - expect(src).toContain('INJURY WIRE'); - expect(src).toContain('GRADEABLE LEADERS'); + it('preserves the VVI / cascade / leaders layouts as unrouted templates', () => { + const tpl = read('components/intel/TerminalTemplates.tsx'); + expect(tpl).toContain('NOT ROUTED'); + expect(tpl).toContain('VOLATILITY INDEX'); + expect(tpl).toContain('INJURY WIRE'); + expect(tpl).toContain('GRADEABLE LEADERS'); }); }); diff --git a/tests/unit/vyndrMobile.test.js b/tests/unit/vyndrMobile.test.js index 0951b26..b34e377 100644 --- a/tests/unit/vyndrMobile.test.js +++ b/tests/unit/vyndrMobile.test.js @@ -11,13 +11,18 @@ const manifest = require('../../web/public/manifest.json'); describe('Phase F — BottomTabBar (5-tab spec)', () => { const src = read('components/BottomTabBar.tsx'); + // Session 57 (Phase 0) — Terminal tab retired (fabricated surface); + // Explore holds its slot so the bar keeps 5 tabs. it('renders the five spec tabs', () => { - ['Slate', 'Terminal', 'Scan', 'Ledger', 'More'].forEach((label) => { + ['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((label) => { expect(src).toContain(`'${label}'`); }); }); it('routes the primary tabs to real pages', () => { - ['/dashboard', '/terminal', '/scan', '/ledger'].forEach((href) => expect(src).toContain(href)); + ['/dashboard', '/explore', '/scan', '/ledger'].forEach((href) => expect(src).toContain(href)); + }); + it('does not link the retired /terminal surface', () => { + expect(src).not.toContain('/terminal'); }); it('makes Scan the prominent (primary) action in grade-green', () => { expect(src).toContain('primary: true'); @@ -90,11 +95,12 @@ describe('Phase F — PWA manifest + viewport (§11)', () => { expect(manifest.theme_color.toLowerCase()).toBe('#06060b'); expect(manifest.background_color.toLowerCase()).toBe('#06060b'); }); - it('ships deep-link shortcuts for Slate / Scan / Terminal', () => { + it('ships deep-link shortcuts for Slate / Scan / Ledger', () => { const urls = (manifest.shortcuts || []).map((s) => s.url); expect(urls).toContain('/dashboard'); expect(urls).toContain('/scan'); - expect(urls).toContain('/terminal'); + expect(urls).toContain('/ledger'); + expect(urls).not.toContain('/terminal'); // retired (Session 57, Phase 0) }); it('declares its categories', () => { expect(manifest.categories).toContain('sports'); diff --git a/tests/unit/vyndrParityQA.test.js b/tests/unit/vyndrParityQA.test.js index 6b1e4f4..5583c1f 100644 --- a/tests/unit/vyndrParityQA.test.js +++ b/tests/unit/vyndrParityQA.test.js @@ -54,9 +54,10 @@ describe('QA.5 — wordmark everywhere', () => { }); describe('QA.11 — mobile parity (5-tab bar)', () => { - it('BottomTabBar declares Slate/Terminal/Scan/Ledger/More', () => { + // Session 57 (Phase 0) — Terminal retired; Explore holds its slot. + it('BottomTabBar declares Slate/Explore/Scan/Ledger/More', () => { const src = read('components/BottomTabBar.tsx'); - ['Slate', 'Terminal', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`)); + ['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`)); }); }); diff --git a/tests/unit/vyndrSystems.test.js b/tests/unit/vyndrSystems.test.js index e113ae3..86628c1 100644 --- a/tests/unit/vyndrSystems.test.js +++ b/tests/unit/vyndrSystems.test.js @@ -130,13 +130,22 @@ describe('Phase G.1 — living-layer tick engine (§8)', () => { liveTick.tick(); expect(calls).toBe(2); // unsubscribed }); - it('advances tick and creeps the graded count, with a fresh state object', () => { + it('advances tick with a fresh state object', () => { const first = liveTick.state; for (let i = 0; i < 7; i++) liveTick.tick(); expect(liveTick.state.tick).toBe(7); - expect(liveTick.state.graded).toBeGreaterThan(first.graded); expect(liveTick.state).not.toBe(first); // new object → React re-renders }); + // Session 57 (Phase 0) — the fake header counters are dead. The tick engine + // must carry NO display data: no auto-incrementing graded count, no + // sin-driven neural %. Real numbers come from /api/snapshot/summary only. + it('carries no fabricated display counters', () => { + liveTick.tick(); + expect(Object.keys(liveTick.state)).toEqual(['tick']); + const src = read('lib/liveTick.js'); + expect(src).not.toContain('neural'); + expect(src).not.toContain('247'); + }); }); describe('Phase G.4 — checkout (§12)', () => { @@ -155,6 +164,15 @@ describe('Phase G — React glue wired correctly', () => { expect(src).toContain('ekg-track'); expect(src).toContain('count-tick'); // LiveNumber pop }); + // Session 57 (Phase 0) — the heartbeat's numbers are REAL: graded count + + // sync age come from /api/snapshot/summary; the fake neural % is gone. + it('HeartbeatBar reads /api/snapshot/summary and shows no neural %', () => { + const src = read('components/vyndr/LiveLayer.tsx'); + expect(src).toContain('/api/snapshot/summary'); + expect(src).not.toContain('neural'); + expect(src).toContain('SYNC'); // elapsed-since-snapshot clock + expect(src).toContain('updated_at'); + }); it('GlobalHosts registers the design globals + applies prefs on mount', () => { const src = read('components/vyndr/GlobalHosts.tsx'); expect(src).toContain('window.__prefs'); diff --git a/web/public/manifest.json b/web/public/manifest.json index e79ccc6..c2aa8b1 100644 --- a/web/public/manifest.json +++ b/web/public/manifest.json @@ -12,7 +12,7 @@ "shortcuts": [ { "name": "The Slate", "short_name": "Slate", "url": "/dashboard", "description": "Tonight's games and graded props" }, { "name": "Scan a prop", "short_name": "Scan", "url": "/scan", "description": "Grade any player prop" }, - { "name": "The Terminal", "short_name": "Terminal", "url": "/terminal", "description": "League intelligence hub" } + { "name": "The Ledger", "short_name": "Ledger", "url": "/ledger", "description": "Every grade, every result" } ], "icons": [ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }, diff --git a/web/src/app/api/snapshot/summary/route.ts b/web/src/app/api/snapshot/summary/route.ts new file mode 100644 index 0000000..6e69491 --- /dev/null +++ b/web/src/app/api/snapshot/summary/route.ts @@ -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 }); + } +} diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 49c3bcc..63194cd 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -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 ? ( ) : 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.

NO SLATE

-

No games on the slate tonight.

+

{emptyStateCopy(sport.toLowerCase()).title}

- Check the Ledger to review past grades, or explore the Leaderboard. + {emptyStateCopy(sport.toLowerCase()).body}

View the Ledger → diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4ad014b..84e5871 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -138,9 +138,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
- {/* 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. */}
- - - Query - - {/* Preferences (language / odds format / accessibility) — §9/§10 */}
diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index f5178ec..10b9945 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -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
) : ( -

No games published yet today. Check back closer to first pitch / tip-off / kickoff.

+ // 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 ( + <> +

{title}

+

{body}

+ + ); + })() )}
)} diff --git a/web/src/components/intel/TerminalTemplates.tsx b/web/src/components/intel/TerminalTemplates.tsx new file mode 100644 index 0000000..64d0864 --- /dev/null +++ b/web/src/components/intel/TerminalTemplates.tsx @@ -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 ( +
+ {/* HEADER */} +
+ ▚ LEAGUE INTELLIGENCE +

THE TERMINAL

+

+ The context the books hide — injury cascades, volatility, and the spots tonight's slate underprices. +

+
+ + {/* VVI — IMPACTED GAMES (intel surface) */} + VYNDR VOLATILITY INDEX · MOST-IMPACTED GAMES +
+ {IMPACTED_GAMES.map((g) => ( +
+
+
+
+ + {g.match} +
+
+
{g.vvi}
+
VVI
+
+
+
{g.note}
+
+ {g.drivers.map((d) => ( + {d} + ))} +
+
{g.graded} graded props · {g.time}
+
+
+ ))} +
+ +
+ {/* INJURY CASCADES */} +
+ INJURY WIRE · CASCADE ANALYSIS +
+ {INJURY_WIRE.map((w) => ( +
+
+
+ + {w.player} + {w.team} +
+ {w.status} +
+
{w.injury} · {w.posted}
+
+ {w.cascade.map((c, i) => ( +
+ + + {c.player} + · {c.stat} ▲ {c.delta} + + +
+ ))} +
+
+ ))} +
+
+ + {/* RIGHT COLUMN */} +
+ {/* FACTOR PULSE */} +
+ FACTOR PULSE · FIRING NOW +
+ {FACTOR_PULSE.map((f) => ( +
+
{f.count}
+
{f.label}
+
{f.hint}
+
+ ))} +
+
+ + {/* GRADEABLE LEADERS */} +
+ GRADEABLE LEADERS + {LEADERS.map((grp) => ( +
+
{grp.key}
+
+ {grp.rows.map((r) => ( +
+ {r.player} {r.team} + {r.val} + +
+ ))} +
+
+ ))} +
+
+
+ + {/* MATCHUP EXPLOITS */} +
+ MATCHUP EXPLOITS · UNDERPRICED SPOTS +
+ {MATCHUP_EXPLOITS.map((m, i) => ( +
+
+ + {m.team} + {m.rank} +
+
{m.stat}
+
↳ {m.exploit}
+
+ ))} +
+
+
+ ); +} diff --git a/web/src/components/vyndr/LiveLayer.tsx b/web/src/components/vyndr/LiveLayer.tsx index 2eb1d0d..a42a218 100644 --- a/web/src/components/vyndr/LiveLayer.tsx +++ b/web/src/components/vyndr/LiveLayer.tsx @@ -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(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 (
- - graded + {summary !== null && ( + + graded + + )} + + SYNC {elapsed !== null ? syncLabel(elapsed) : '—'} - - neural {live.neural}% - - SYNC {clock} ); } diff --git a/web/src/components/vyndr/Ticker.tsx b/web/src/components/vyndr/Ticker.tsx index 81b3100..da12554 100644 --- a/web/src/components/vyndr/Ticker.tsx +++ b/web/src/components/vyndr/Ticker.tsx @@ -27,11 +27,19 @@ const TAG_COLORS: Record = { 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 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(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) => (