d3637e7abd
- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
StatStrip violations fixed: MovementChip before the grade (market
context before model output); ViabilityChips after the archetype
(identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
real {t,line} points per grade (seeded with the lock, deduped when
flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
/api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
buckets, outliers clamped) only past the centralized n>=20 gate;
ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
players via /api/players/search per sport + static lib/teams.js
(soccer deliberately absent); Nav search icon + Search first in the
mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
(4 decorative font files off the slow-4G critical path).
2654 -> 2698 tests (226 suites) green; web build exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { usePathname } from 'next/navigation';
|
||
|
||
/**
|
||
* VYNDR 2.0 mobile tab bar (§6, Session 37). The PWA's primary navigation —
|
||
* 5 tabs: Slate · Explore · Scan · Ledger · More. Scan is prominent (raised,
|
||
* grade-green) because it's the core action. More opens a bottom sheet with
|
||
* every secondary route. Hidden ≥768px via globals.css (.mobile-tab-bar).
|
||
*
|
||
* Session 57 (Phase 0): the Terminal tab is gone — that surface was fabricated
|
||
* and now redirects to /dashboard. Explore (real graded-props browser) takes
|
||
* its slot so the bar keeps 5 tabs.
|
||
*
|
||
* Shown for everyone (anon included): Slate/Explore/Scan are open routes and
|
||
* this is the only mobile nav. Gated routes (Ledger, Account…) bounce through
|
||
* the client AuthGate when an anon user taps them.
|
||
*/
|
||
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: 'explore', label: 'Explore', href: '/explore', icon: ExploreIcon },
|
||
{ id: 'scan', label: 'Scan', href: '/scan', icon: ScanIcon, primary: true },
|
||
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
|
||
{ id: 'more', label: 'More', icon: MoreIcon, isSheet: true },
|
||
];
|
||
|
||
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.
|
||
// Session 59 (work-order 3.1) — '/' REMOVED from this set. Hiding the bar on
|
||
// the landing left anonymous phones with ZERO navigation (the desktop links
|
||
// hide <768px and the hamburger was retired in S37) — the audit's vanished-
|
||
// nav-at-390px finding. The tab bar is the only mobile nav; it shows
|
||
// everywhere except true auth flows.
|
||
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 [moreOpen, setMoreOpen] = useState(false);
|
||
|
||
if (HIDE_ON.has(pathname)) return null;
|
||
|
||
return (
|
||
<>
|
||
{/* 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
|
||
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))' }}
|
||
>
|
||
<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>
|
||
{/* S6 (A1 board) — global search, first item in the sheet.
|
||
Same modal as ⌘K / the Nav icon (window.__search). */}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMoreOpen(false);
|
||
if (typeof window !== 'undefined' && window.__search) window.__search();
|
||
}}
|
||
className="mono"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
width: '100%',
|
||
minHeight: 48,
|
||
padding: '0 18px',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
color: 'var(--g-a)',
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
letterSpacing: '0.06em',
|
||
textTransform: 'uppercase',
|
||
borderBottom: '1px solid var(--border)',
|
||
}}
|
||
>
|
||
Search
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" />
|
||
</svg>
|
||
</button>
|
||
{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
|
||
style={{
|
||
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)',
|
||
}}
|
||
>
|
||
<Icon color="#04140f" />
|
||
</span>
|
||
<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>
|
||
);
|
||
|
||
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>
|
||
);
|
||
}
|
||
return (
|
||
<a key={t.id} href={t.href} aria-label={t.label} style={sharedStyle}>
|
||
{body}
|
||
</a>
|
||
);
|
||
})}
|
||
</nav>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ── 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">
|
||
<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 ExploreIcon({ color }: { color: string }) {
|
||
return (
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="9" /><path d="M15.5 8.5l-2 5-5 2 2-5z" />
|
||
</svg>
|
||
);
|
||
}
|
||
function ScanIcon({ color }: { color: string }) {
|
||
return (
|
||
<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 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" />
|
||
</svg>
|
||
);
|
||
}
|
||
function MoreIcon({ color }: { color: string }) {
|
||
return (
|
||
<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>
|
||
);
|
||
}
|