Files
vyndr/web/src/components/BottomTabBar.tsx
T
builtbykev d3637e7abd S6 (a1): display — the full picture under the grammar
- 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>
2026-07-11 20:08:24 -04:00

272 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}