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() { ))} -