diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 4ee789d..c0c8910 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,8 +4,76 @@ 2026-06-16 ## Current Phase -SHIP BUILD v36.0 — VYNDR 2.0 design system, Phase E: remaining screens — -dashboard Bloomberg lines, compare/invite/help/about, login/pricing (Session 36) +SHIP BUILD v37.0 — VYNDR 2.0 design system, Phase F: mobile parity — +5-tab bar, More sheet, mobile CSS, PWA manifest/viewport (Session 37) + +## Session 37 (2026-06-16) — SHIPPED + +Phase F: mobile parity — the mobile build is the PWA we launch first. Converted +the bottom nav to the §6 5-tab spec, added the More bottom sheet, mobile CSS, +and PWA polish. Frontend-only; ZERO backend changes. Backend 1853 → **1872 +tests** (+19), 145 suites, zero regressions. Web build clean (exit 0). + +### F.2/F.3 — Bottom tab bar + More sheet (`components/BottomTabBar.tsx` rewritten) +- 5 tabs per §6: **Slate · Terminal · Scan · Ledger · More**. **Scan is the + prominent raised action** — a 46px grade-green circle lifted above the bar + (it's the core action). Active = `--g-a`, inactive = `--text-2`, 9px mono + labels, 64px bar, `env(safe-area-inset-bottom)`, ≥44px touch targets. +- **More** opens an integrated bottom sheet: `fade-in` backdrop (dismiss on tap) + + `sheet-up` scanlines panel, handle pill, MORE title + × close, 10 secondary + routes (Compare/Tracker/Report/Invite/Pricing/Account/Settings/Help/About/ + Responsible) as 48px mono rows. Dismisses on item nav / backdrop / ×. +- BEHAVIOR CHANGE: the bar now shows for ANON users too (previously hidden when + signed-out). Slate/Terminal/Scan are open routes and this is the only mobile + nav; gated taps (Ledger/Account) bounce through the Session-34 AuthGate. Still + hidden on auth flows + landing (`HIDE_ON`). Dropped the old Parlay tab (the + global ParlayTray keeps its own trigger). + +### F.4 — Mobile header +- Retired the Nav hamburger on mobile (`display:none`) — the tab bar + More sheet + own mobile nav now. The Nav already hid desktop links <768px, so the mobile + header is just wordmark + bell + read-meter + avatar. Mobile panel is now dead + code (harmless). + +### F.5 — Mobile CSS (`globals.css`) +- `.mobile-tab-bar` hidden ≥768px; `main` bottom-padded `calc(84px + safe-area)` + <768px so content clears the bar; `.grade-hero` → 80px <640px (GradeResultCard + letter tagged `grade-hero`); `.terminal-grid` stacks to 1fr ≤768px (Terminal + grid tagged); `.game-lines-grid` horizontal-scroll + sticky `.team-col` <640px. + +### F.6 — PWA polish +- `manifest.json`: added **shortcuts** (Slate/Scan/Terminal deep-links), categories + → `["sports","finance","productivity"]`. Already standalone + theme/bg `#06060B`. +- `layout.tsx` viewport: added `viewportFit: 'cover'` (notch/home-indicator) so + the tab bar's safe-area padding has room. apple-mobile-web-app-* already set + via metadata.appleWebApp (Session 27). + +### Files created +- `tests/unit/vyndrMobile.test.js` (19 tests: 5 tabs, prominent Scan, safe area, + More sheet items/backdrop/44px, mobile CSS rules, Nav hamburger hidden, + grade-hero hook, manifest standalone/shortcuts/categories, viewport-fit) + +### Files modified +- `web/src/components/BottomTabBar.tsx` (full rewrite), `web/src/components/Nav.tsx` + (hamburger hidden), `web/src/app/globals.css` (mobile section), + `web/src/components/vyndr/GradeResultCard.tsx` (grade-hero class), + `web/src/app/terminal/page.tsx` (terminal-grid class), + `web/public/manifest.json` (shortcuts/categories), `web/src/app/layout.tsx` + (viewportFit) + +### Gotcha logged +- `as const` on the TABS array made each entry a distinct literal type, so + `isSheet`/`primary`/`href` failed type-check on tabs lacking them. Fixed with a + shared `TabDef` interface. (The build worker exits code 1 on type errors; a + piped `| tail` masked it — always check the build exit code, not just the tail.) + +### Deferred (Sessions 38+) +- Living-layer ticker/heartbeat data, i18n, a11y toggle wiring, SW, paywall, + parlay math = Phase G (Session 38). §13 QA = Session 39. + +--- + +## Session 36 (2026-06-16) — SHIPPED ## Session 36 (2026-06-16) — SHIPPED diff --git a/CLAUDE.md b/CLAUDE.md index ae1963e..66704c8 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,6 +236,24 @@ The frame every page sits in. Frontend-only. - **Reskinned** (logic preserved): `login` (scanlines + "ACCESS THE SIGNAL"), `pricing` (+ ClaimMeter). `account` redirects to `/profile`. +## VYNDR 2.0 Mobile Parity (Session 37 — Phase F) +- **Bottom tab bar** = `components/BottomTabBar.tsx`: 5 tabs (Slate/Terminal/ + Scan/Ledger/More), Scan is the prominent raised grade-green action, More opens + an integrated bottom sheet. Shown for ALL users (anon included — it's the only + mobile nav); hidden on auth flows + landing via `HIDE_ON`, and hidden ≥768px + via the `.mobile-tab-bar` rule in globals.css. The Nav hamburger is retired on + mobile (the tab bar owns nav); the Nav's mobile panel is now dead code. +- **Mobile CSS** lives in the "MOBILE PARITY" section of globals.css: `main` + bottom-padding clears the 64px bar + safe-area <768px; `.grade-hero` (the + GradeResultCard letter) → 80px <640px; `.terminal-grid` stacks <768px; + `.game-lines-grid` horizontal-scroll. Class hooks: `grade-hero`, `terminal-grid`. +- **PWA**: manifest has shortcuts (Slate/Scan/Terminal) + categories + [sports,finance,productivity]; layout viewport sets `viewportFit: 'cover'`. +- BUILD GOTCHA: don't use `as const` on heterogeneous config arrays (TABS) — it + makes each entry a distinct literal type and optional props fail type-check. + Use a shared interface. And the build worker exits code 1 on type errors — + check the build EXIT CODE, not just a `| tail` of its output. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/tests/unit/vyndrMobile.test.js b/tests/unit/vyndrMobile.test.js new file mode 100644 index 0000000..0951b26 --- /dev/null +++ b/tests/unit/vyndrMobile.test.js @@ -0,0 +1,106 @@ +// VYNDR 2.0 — Phase F mobile parity (Session 37): 5-tab bar, More sheet, +// mobile CSS, PWA manifest/viewport. The manifest is required as JSON; the +// .tsx/.css are asserted against source text (plain-JS Jest, no transform). + +const fs = require('fs'); +const path = require('path'); + +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const manifest = require('../../web/public/manifest.json'); + +describe('Phase F — BottomTabBar (5-tab spec)', () => { + const src = read('components/BottomTabBar.tsx'); + it('renders the five spec tabs', () => { + ['Slate', 'Terminal', '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)); + }); + it('makes Scan the prominent (primary) action in grade-green', () => { + expect(src).toContain('primary: true'); + expect(src).toContain("background: 'var(--g-a)'"); + }); + it('marks the active tab grade-green, inactive faint', () => { + expect(src).toContain("'var(--g-a)'"); + expect(src).toContain("'var(--text-2)'"); + }); + it('respects the iOS safe area', () => { + expect(src).toContain('env(safe-area-inset-bottom'); + }); + it('uses the mobile-only class so it hides on desktop', () => { + expect(src).toContain("className=\"mobile-tab-bar\""); + }); +}); + +describe('Phase F — More bottom sheet', () => { + const src = read('components/BottomTabBar.tsx'); + it('lists the secondary routes', () => { + ['/compare', '/tracker', '/blog', '/invite', '/pricing', '/account', '/help', '/about', '/responsible-gambling'].forEach((href) => + expect(src).toContain(href), + ); + }); + it('slides up with a dismissible backdrop', () => { + expect(src).toContain('sheet-up'); + expect(src).toContain('rgba(6,6,11,.6)'); // backdrop + expect(src).toContain('onClick={() => setMoreOpen(false)}'); // tap-to-dismiss + }); + it('gives sheet items a ≥44px touch target', () => { + expect(src).toContain('minHeight: 48'); + }); +}); + +describe('Phase F — mobile CSS (globals.css)', () => { + const css = read('app/globals.css'); + it('hides the tab bar at the desktop breakpoint', () => { + expect(css).toMatch(/@media \(min-width: 768px\)[\s\S]*?\.mobile-tab-bar \{ display: none/); + }); + it('pads main for the bottom bar on mobile with safe-area', () => { + expect(css).toMatch(/@media \(max-width: 767px\)/); + expect(css).toContain('env(safe-area-inset-bottom, 0px)'); + }); + it('shrinks the grade hero and stacks the terminal grid on mobile', () => { + expect(css).toContain('.grade-hero { font-size: 80px'); + expect(css).toContain('.terminal-grid { grid-template-columns: 1fr'); + }); + it('enables horizontal scroll for book tables', () => { + expect(css).toContain('.game-lines-grid'); + expect(css).toContain('overflow-x: auto'); + }); +}); + +describe('Phase F — Nav mobile header', () => { + it('retires the hamburger on mobile (tab bar owns nav)', () => { + const src = read('components/Nav.tsx'); + expect(src).toMatch(/nav-mobile-toggle[\s\S]*?display: 'none'/); + }); +}); + +describe('Phase F — GradeResultCard mobile hook', () => { + it('tags the grade letter with the grade-hero class', () => { + expect(read('components/vyndr/GradeResultCard.tsx')).toContain('grade-hero'); + }); +}); + +describe('Phase F — PWA manifest + viewport (§11)', () => { + it('is a standalone PWA themed to the void', () => { + expect(manifest.display).toBe('standalone'); + expect(manifest.theme_color.toLowerCase()).toBe('#06060b'); + expect(manifest.background_color.toLowerCase()).toBe('#06060b'); + }); + it('ships deep-link shortcuts for Slate / Scan / Terminal', () => { + const urls = (manifest.shortcuts || []).map((s) => s.url); + expect(urls).toContain('/dashboard'); + expect(urls).toContain('/scan'); + expect(urls).toContain('/terminal'); + }); + it('declares its categories', () => { + expect(manifest.categories).toContain('sports'); + expect(manifest.categories).toContain('productivity'); + }); + it('sets viewport-fit=cover for the notch', () => { + expect(read('app/layout.tsx')).toContain("viewportFit: 'cover'"); + }); +}); diff --git a/web/public/manifest.json b/web/public/manifest.json index 3490386..e79ccc6 100644 --- a/web/public/manifest.json +++ b/web/public/manifest.json @@ -8,7 +8,12 @@ "background_color": "#06060B", "theme_color": "#06060B", "orientation": "portrait-primary", - "categories": ["sports", "finance", "entertainment"], + "categories": ["sports", "finance", "productivity"], + "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" } + ], "icons": [ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }, { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 805d702..0d6b219 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1221,3 +1221,46 @@ html[data-font="readable"] { } html[data-font="readable"] .wm::before, html[data-font="readable"] .wm::after { opacity: 0.45 !important; } + +/* ============================================================ + MOBILE PARITY (§6, §F — Session 37) + Mobile is the primary (PWA) layout; desktop is the enhancement. + ============================================================ */ + +/* The 5-tab bar is mobile-only — hidden once the desktop nav has room. */ +@media (min-width: 768px) { + .mobile-tab-bar { display: none !important; } +} + +/* Clear the fixed bottom tab bar (64px + iOS safe area) so content isn't + hidden behind it. Desktop keeps the standard 80px footer breathing room. */ +@media (max-width: 767px) { + main { + padding-bottom: calc(84px + env(safe-area-inset-bottom, 0px)) !important; + } +} + +/* Grade hero zone: full-width, slightly smaller letter on phones. */ +@media (max-width: 640px) { + .grade-hero { font-size: 80px !important; } +} + +/* Book-line tables scroll horizontally on small screens with a sticky + first (team/book) column so the matchup stays anchored while prices scroll. */ +@media (max-width: 640px) { + .game-lines-grid { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .game-lines-grid .team-col { + position: sticky; + left: 0; + z-index: 1; + background: var(--bg-1); + } +} + +/* Terminal's multi-column grid stacks on mobile. */ +@media (max-width: 768px) { + .terminal-grid { grid-template-columns: 1fr !important; } +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 106a308..938ca52 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -89,6 +89,9 @@ export const viewport: Viewport = { width: 'device-width', initialScale: 1, maximumScale: 5, + // Session 37 — extend under the iOS notch / home indicator so the + // mobile tab bar's env(safe-area-inset-*) padding has room to work. + viewportFit: 'cover', }; export default async function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/web/src/app/terminal/page.tsx b/web/src/app/terminal/page.tsx index de7bab6..1f101ba 100644 --- a/web/src/app/terminal/page.tsx +++ b/web/src/app/terminal/page.tsx @@ -114,7 +114,7 @@ export default function TerminalPage() { ))} -
+
{/* INJURY CASCADES */}
INJURY WIRE · CASCADE ANALYSIS diff --git a/web/src/components/BottomTabBar.tsx b/web/src/components/BottomTabBar.tsx index 8866600..285afa7 100644 --- a/web/src/components/BottomTabBar.tsx +++ b/web/src/components/BottomTabBar.tsx @@ -1,184 +1,230 @@ 'use client'; +import { useState } from 'react'; import { usePathname } from 'next/navigation'; -import { useParlay } from '@/contexts/ParlayContext'; -// Session 17 — gate the mobile bottom nav behind authentication. -// Anonymous visitors landing on /pricing previously saw the full -// Home/Read/Parlay/Ledger/Profile bar before signing up — confusing -// surface area, and it visually overlapped the cookie-consent banner -// (both pinned to bottom: 0). -import { useAuth } from '@/contexts/AuthContext'; -const TABS = [ - { id: 'home', label: 'Home', href: '/dashboard', icon: HomeIcon }, - { id: 'scan', label: 'Read', href: '/scan', icon: ScanIcon }, - { id: 'parlay', label: 'Parlay', href: null, icon: ParlayIcon }, +/** + * 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, + * 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 + * this is the only mobile nav. Gated routes (Ledger, Account…) bounce through + * the client AuthGate when an anon user taps them. + */ +type TabDef = { + id: string; + label: string; + icon: React.ComponentType<{ color: string }>; + href?: string; + primary?: boolean; + isSheet?: boolean; +}; + +const TABS: TabDef[] = [ + { id: 'slate', label: 'Slate', href: '/dashboard', icon: SlateIcon }, + { id: 'terminal', label: 'Terminal', href: '/terminal', icon: TerminalIcon }, + { id: 'scan', label: 'Scan', href: '/scan', icon: ScanIcon, primary: true }, { id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon }, - { id: 'profile', label: 'Profile', href: '/profile', icon: ProfileIcon }, -] as const; + { id: 'more', label: 'More', icon: MoreIcon, isSheet: true }, +]; -// Pages where the bottom tab bar should stay hidden (auth flows, landing). +const MORE_ITEMS = [ + { label: 'Compare', href: '/compare' }, + { label: 'Tracker', href: '/tracker' }, + { label: 'The Report', href: '/blog' }, + { label: 'Invite Friends', href: '/invite' }, + { label: 'Pricing', href: '/pricing' }, + { label: 'Account', href: '/account' }, + { label: 'Settings', href: '/settings/security' }, + { label: 'Help & FAQ', href: '/help' }, + { label: 'About', href: '/about' }, + { label: 'Responsible Play', href: '/responsible-gambling' }, +]; + +// Auth flows own the full screen — no app chrome. const HIDE_ON = new Set(['/login', '/signup', '/auth/callback', '/']); +function isActive(pathname: string, href?: string) { + if (!href) return false; + return pathname === href || pathname.startsWith(`${href}/`); +} + export default function BottomTabBar() { const pathname = usePathname() || '/'; - const { open, legCount } = useParlay(); - // Session 17 — bar hidden for anonymous visitors. The bar's - // destinations (Ledger, Profile, Parlay) all require auth anyway; - // pre-auth visitors just see broken links. Authentication state - // resolves async; while `loading` is true we err on the side of - // hiding the bar to avoid a flash for signed-out users. - const { user, loading } = useAuth(); + const [moreOpen, setMoreOpen] = useState(false); if (HIDE_ON.has(pathname)) return null; - if (loading || !user) return null; return ( - + return ( + + {body} + + ); + })} + + ); } -// Lightweight inline SVG icons — keeps the bundle slim and avoids icon-lib install -function HomeIcon({ color }: { color: string }) { +/* ── inline SVG icons (slim, no icon lib) ── */ +function SlateIcon({ color }: { color: string }) { return ( - - + + + + ); +} +function TerminalIcon({ color }: { color: string }) { + return ( + + ); } - function ScanIcon({ color }: { color: string }) { return ( - - - + + ); } - -function ParlayIcon({ color }: { color: string }) { - return ( - - - - - - ); -} - function LedgerIcon({ color }: { color: string }) { return ( - - - + ); } - -function ProfileIcon({ color }: { color: string }) { +function MoreIcon({ color }: { color: string }) { return ( - - - + + ); } diff --git a/web/src/components/Nav.tsx b/web/src/components/Nav.tsx index 6661a48..c28780d 100644 --- a/web/src/components/Nav.tsx +++ b/web/src/components/Nav.tsx @@ -310,13 +310,16 @@ export default function Nav() { )} + {/* Session 37 — the mobile bottom tab bar + More sheet now own + mobile navigation, so the hamburger is retired (display:none). + The mobile panel below is consequently dead code but harmless. */}
VYNDR GRADE