Session 37: Design system Phase F — mobile parity: 5-tab bar, More sheet, PWA polish (1872 tests)

VYNDR 2.0 conversion, Phase F (mobile is the PWA we launch first). Frontend-only;
zero backend changes.

- BottomTabBar rewritten to the §6 5-tab spec: Slate/Terminal/Scan/Ledger/More,
  with Scan as the prominent raised grade-green action. Shown for anon too (only
  mobile nav). Integrated More bottom sheet (sheet-up, backdrop dismiss, 48px mono
  rows). iOS safe-area + 44px touch targets.
- Nav hamburger retired on mobile (tab bar owns nav).
- globals.css mobile section: tab-bar hidden >=768, main bottom padding,
  grade-hero 80px, terminal-grid stacks, game-lines horizontal scroll.
- PWA: manifest shortcuts (Slate/Scan/Terminal) + categories; viewport-fit=cover.

Gotcha: `as const` on the TABS array broke type-check (distinct literal types);
fixed with a shared TabDef interface.

19 new tests. Backend 1853 -> 1872, 145 suites, zero regressions. Web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-16 10:11:52 -04:00
parent 612f5e0b72
commit f88961885c
10 changed files with 433 additions and 141 deletions
+6 -1
View File
@@ -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" },
+43
View File
@@ -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; }
}
+3
View File
@@ -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 }) {
+1 -1
View File
@@ -114,7 +114,7 @@ export default function TerminalPage() {
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
<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>
+181 -135
View File
@@ -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 (
<nav
role="navigation"
aria-label="Primary"
className="mobile-tab-bar"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 64,
zIndex: 40,
display: 'flex',
borderTop: '1px solid var(--border)',
background: 'rgba(10,10,15,0.92)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{TABS.map((t) => {
const active = t.href ? (pathname === t.href || pathname.startsWith(`${t.href}/`)) : false;
const color = active ? 'var(--grade-a)' : 'var(--text-secondary)';
const Icon = t.icon;
const isParlay = t.id === 'parlay';
const onClick = () => {
if (isParlay) open();
};
const inner = (
<>
{/* More bottom sheet */}
{moreOpen && (
<div
className="fade-in mobile-tab-bar"
role="dialog"
aria-modal="true"
aria-label="More navigation"
onClick={() => setMoreOpen(false)}
style={{ position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(6,6,11,.6)', backdropFilter: 'blur(3px)', WebkitBackdropFilter: 'blur(3px)', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
color,
fontSize: 10,
fontWeight: 600,
textDecoration: 'none',
fontFamily: 'inherit',
border: 'none',
background: 'transparent',
cursor: 'pointer',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
className="sheet-up scanlines"
style={{ background: 'var(--bg-1)', borderTop: '1px solid var(--border-hi)', borderRadius: '18px 18px 0 0', maxHeight: '82%', overflowY: 'auto', paddingBottom: 'calc(16px + env(safe-area-inset-bottom, 0px))' }}
>
<Icon color={color} />
<span>{t.label}</span>
{isParlay && legCount > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 4px' }}>
<div style={{ width: 38, height: 4, borderRadius: 2, background: 'var(--border-hi)' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 18px 12px', borderBottom: '1px solid var(--border)' }}>
<span className="mono" style={{ fontSize: 15, fontWeight: 800, letterSpacing: '0.04em' }}>MORE</span>
<button
onClick={() => setMoreOpen(false)}
aria-label="Close"
style={{ background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, color: 'var(--text-1)', width: 32, height: 32, cursor: 'pointer', fontSize: 16 }}
>
×
</button>
</div>
<div>
{MORE_ITEMS.map((it) => (
<a
key={it.href}
href={it.href}
onClick={() => setMoreOpen(false)}
className="mono"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 48,
padding: '0 18px',
color: isActive(pathname, it.href) ? 'var(--g-a)' : 'var(--text-0)',
textDecoration: 'none',
fontSize: 13,
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
borderBottom: '1px solid var(--border)',
}}
>
{it.label}
<span style={{ color: 'var(--text-2)' }}></span>
</a>
))}
</div>
</div>
</div>
)}
<nav
role="navigation"
aria-label="Primary"
className="mobile-tab-bar"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 64,
zIndex: 40,
display: 'flex',
alignItems: 'stretch',
borderTop: '1px solid var(--border-hi)',
background: 'rgba(14,14,22,0.94)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}}
>
{TABS.map((t) => {
const active = t.isSheet ? moreOpen : isActive(pathname, t.href);
const color = active ? 'var(--g-a)' : 'var(--text-2)';
const Icon = t.icon;
const body = t.primary ? (
// Scan — prominent raised action
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', gap: 3, paddingTop: 4 }}>
<span
className="mono"
style={{
position: 'absolute',
top: 6,
right: 'calc(50% - 22px)',
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--grade-a)',
color: 'var(--bg-primary)',
fontSize: 10,
fontWeight: 800,
display: 'inline-flex',
width: 46,
height: 46,
marginTop: -16,
borderRadius: '50%',
background: 'var(--g-a)',
border: '3px solid var(--bg-0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 16px rgba(0,212,160,.5)',
}}
>
{legCount}
<Icon color="#04140f" />
</span>
)}
</div>
);
if (isParlay || !t.href) {
return (
<button key={t.id} onClick={onClick} style={{ flex: 1, background: 'transparent', border: 'none', padding: 0 }}>
{inner}
</button>
<span className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.03em', color: active ? 'var(--g-a)' : 'var(--text-1)' }}>{t.label}</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4, color }}>
<Icon color={color} />
<span className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.03em' }}>{t.label}</span>
</div>
);
}
return (
<a key={t.id} href={t.href} style={{ flex: 1, padding: 0, textDecoration: 'none' }}>
{inner}
</a>
);
})}
<style jsx>{`
@media (min-width: 768px) {
:global(.mobile-tab-bar) {
display: none !important;
const sharedStyle: React.CSSProperties = { flex: 1, minWidth: 44, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'none' };
if (t.isSheet) {
return (
<button key={t.id} onClick={() => setMoreOpen((o) => !o)} aria-label="More" aria-expanded={moreOpen} style={sharedStyle}>
{body}
</button>
);
}
}
`}</style>
</nav>
return (
<a key={t.id} href={t.href} aria-label={t.label} style={sharedStyle}>
{body}
</a>
);
})}
</nav>
</>
);
}
// 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 (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12 12 3l9 9" />
<path d="M5 10v10h14V10" />
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
);
}
function TerminalIcon({ 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" />
</svg>
);
}
function ScanIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="1.6" fill={color} stroke="none" />
</svg>
);
}
function ParlayIcon({ 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="4" rx="1" />
<rect x="3" y="10" width="18" height="4" rx="1" />
<rect x="3" y="16" width="18" height="4" rx="1" />
</svg>
);
}
function LedgerIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16v16H4z" />
<path d="M4 9h16" />
<path d="M9 4v16" />
<path d="M4 4h16v16H4z" /><path d="M4 9h16" /><path d="M9 4v16" />
</svg>
);
}
function ProfileIcon({ color }: { color: string }) {
function MoreIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />
<path d="M4 21c1.5-4 5-6 8-6s6.5 2 8 6" />
<svg width="20" height="20" viewBox="0 0 24 24" fill={color} stroke="none">
<circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle cx="19" cy="12" r="2" />
</svg>
);
}
+4 -1
View File
@@ -310,13 +310,16 @@ export default function Nav() {
</a>
)}
{/* 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. */}
<button
className="nav-mobile-toggle"
aria-label="Toggle menu"
aria-expanded={mobileOpen}
onClick={() => setMobileOpen((o) => !o)}
style={{
display: 'flex',
display: 'none',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 8,
+1 -1
View File
@@ -100,7 +100,7 @@ export default function GradeResultCard({
<div className="label" style={{ position: 'relative', zIndex: 2, color: 'rgba(232,255,244,.5)', marginBottom: 2 }}>VYNDR GRADE</div>
<div
key={replayKey}
className="grade-reveal"
className="grade-reveal grade-hero"
style={{
position: 'relative',
zIndex: 2,