Files
vyndr/web/src/components/Nav.tsx
T
builtbykev 80683e71b4 Session 43: Data pipeline + audit fixes + depth chart foundation (2045 tests)
P0 fixes + wiring real data into the S42 Player Intelligence architecture.

- P0 dropdown z-index: the nav's backdrop-filter stacking context let the
  Ticker/HeartbeatBar paint over the avatar/More dropdowns and eat clicks.
  nav now position:relative zIndex:2; menus zIndex:100. Avatar Settings ->
  /settings.
- Real MLB stats: mlbStatsAdapter.searchPlayer + getPlayerStats (name->id->
  season+gamelog). playerIntelService.resolvePlayerStats normalizes into the
  archetype classifier; getPlayerIntel returns found:true + real season +
  archetype classified from real stats. NBA via nbaStatsClient (degrades).
- Game cards: slateAdapter.groupPropsByPlayer (playerStrips, name once) +
  mapPitchers (MLB probables), folded into mapScheduleToGameCards. Legacy
  GameCard line grid renders BookChip (brand colors) not grey text.
- Grade card intel: analyzeViaEngine1.buildIntelFields computes stat-context +
  form/usage/matchup/rest from the existing feature vector (zero extra I/O);
  gradeAdapter lights up the card sections. Archetype deferred (needs season
  line at grade time).
- Depth chart foundation: depthChartService (getLineup/getDepthChart/
  getCascadeProjection) + /api/stats/lineup|depth|cascade, graceful + injectable.
- Mobile: player hero name overflow-wrap + 24px on <=640px (was clipping).

Backend 2011 -> 2045 tests (+34), 163 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:38:06 -04:00

443 lines
17 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';
import { useAuth } from '@/contexts/AuthContext';
import { Wordmark, Ticker } from '@/components/vyndr';
import { HeartbeatBar } from '@/components/vyndr/LiveLayer';
import NotificationBell from '@/components/NotificationBell';
// Nav labels are English literals for now; nav-string i18n lands in Phase G
// (Session 38) once the locale dictionaries carry slate/terminal/etc. keys.
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / TERMINAL / SCAN /
// LEDGER; everything else lives under a More dropdown. All nav chrome is
// JetBrains Mono, uppercase, 11px — system language, not SaaS sans. Active
// route is grade-green (--g-a); a Ticker runs under the bar.
const PRIMARY = [
{ id: 'slate', label: 'Slate', href: '/dashboard' },
{ id: 'terminal', label: 'Terminal', href: '/terminal' },
{ id: 'scan', label: 'Scan', href: '/scan' },
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
];
const MORE = [
{ label: 'Explore', href: '/explore' },
{ label: 'Compare', href: '/compare' },
{ label: 'Tracker', href: '/tracker' },
{ label: 'The Report', href: '/blog' },
{ label: 'Invite', href: '/invite' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Settings', href: '/settings' },
];
const TICKER_ITEMS = [
{ tag: 'A+', text: 'Signal detected · Wembanyama Points', glow: true },
{ tag: 'LIVE', text: 'NYK vs SA · Q3 4:22', color: 'var(--live)' },
{ tag: 'CASCADE', text: 'Murray OUT → Jokic usage', delta: '▲+12%', color: 'var(--g-b)' },
{ tag: 'MOVE', text: 'Tatum o27.5 → o26.5', delta: '▼-1.0', color: 'var(--amber)' },
];
function isActive(pathname: string, href: string) {
return pathname === href || pathname.startsWith(href + '/');
}
const linkStyle = (active: boolean) => ({
fontFamily: 'var(--mono)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase' as const,
color: active ? 'var(--g-a)' : 'var(--text-1)',
textDecoration: 'none',
whiteSpace: 'nowrap' as const,
transition: 'color .15s',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px 4px',
});
export default function Nav() {
const { user, tier, scansRemaining, signOut } = useAuth();
const pathname = usePathname() || '';
const [menuOpen, setMenuOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const showReadCounter =
pathname.startsWith('/scan') || pathname.startsWith('/dashboard');
const moreActive = MORE.some((l) => isActive(pathname, l.href));
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50 }}>
<nav
style={{
// Session 43 (P0) — the nav's backdrop-filter creates a stacking
// context; the Ticker + HeartbeatBar render after it as siblings, so
// without this they painted OVER the avatar/More dropdowns (which
// overflow below the 60px bar) and ate the clicks. position+zIndex
// floats the whole nav (and its dropdowns) above those living-layer
// bars so menu items are clickable again.
position: 'relative',
zIndex: 2,
height: 60,
borderBottom: '1px solid var(--border)',
background: 'rgba(6, 6, 11, 0.86)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
}}
>
<div
style={{
maxWidth: 1320,
margin: '0 auto',
padding: '0 24px',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 18,
}}
>
{/* Left — wordmark + primary links */}
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
<a href="/" aria-label="VYNDR — home" style={{ display: 'inline-flex', alignItems: 'center' }}>
<Wordmark size="md" cursor beta />
</a>
<div className="nav-desktop" style={{ display: 'none', gap: 14, alignItems: 'center' }}>
{PRIMARY.map((l) => (
<a
key={l.id}
href={l.href}
className="glitch-hover"
style={linkStyle(isActive(pathname, l.href))}
onMouseEnter={(e) => {
if (!isActive(pathname, l.href)) e.currentTarget.style.color = 'var(--text-0)';
}}
onMouseLeave={(e) => {
if (!isActive(pathname, l.href)) e.currentTarget.style.color = 'var(--text-1)';
}}
>
{l.label}
</a>
))}
{/* More dropdown */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setMoreOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={moreOpen}
style={linkStyle(moreActive)}
>
More
</button>
{moreOpen && (
<div
role="menu"
onMouseLeave={() => setMoreOpen(false)}
style={{
position: 'absolute',
left: 0,
top: 'calc(100% + 8px)',
zIndex: 100,
minWidth: 180,
background: 'var(--bg-2)',
border: '1px solid var(--border-hi)',
borderRadius: 8,
padding: 6,
boxShadow: '0 12px 32px rgba(0,0,0,.5)',
}}
>
{MORE.map((l) => (
<a
key={l.href}
href={l.href}
role="menuitem"
onClick={() => setMoreOpen(false)}
style={{
display: 'block',
padding: '9px 10px',
borderRadius: 6,
fontFamily: 'var(--mono)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: isActive(pathname, l.href) ? 'var(--g-a)' : 'var(--text-1)',
textDecoration: 'none',
}}
>
{l.label}
</a>
))}
</div>
)}
</div>
</div>
</div>
{/* Right — search, bell, read meter / plan, avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<a
href="/scan"
title="Query the system"
className="mono"
style={{
display: 'none',
alignItems: 'center',
gap: 7,
height: 28,
padding: '0 10px',
borderRadius: 7,
border: '1px solid var(--border-hi)',
background: 'var(--bg-2)',
color: 'var(--text-1)',
textDecoration: 'none',
fontSize: 11,
letterSpacing: '0.06em',
}}
data-search-trigger
>
<span style={{ color: 'var(--g-a)' }}></span>
<span>Query</span>
</a>
{/* Preferences (language / odds format / accessibility) — §9/§10 */}
<button
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
aria-label="Language & accessibility preferences"
title="Language & accessibility"
style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid var(--border-hi)', background: 'var(--bg-2)', color: 'var(--text-1)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</svg>
</button>
{user && <NotificationBell />}
{user ? (
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{showReadCounter && scansRemaining != null && tier === 'free' && (
// Read meter is a live paywall trigger (§12): tapping it (or
// hitting 0) opens the paywall.
<button
onClick={() => typeof window !== 'undefined' && window.__goPaywall && window.__goPaywall()}
className="mono"
aria-label={`${scansRemaining} of 5 reads remaining — upgrade`}
style={{
fontSize: 11,
fontWeight: 700,
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
color: scansRemaining <= 1 ? 'var(--g-c)' : 'var(--text-1)',
}}
>
{scansRemaining}/5 · MO
</button>
)}
{tier !== 'free' && (
<span
className="mono"
title="Unlimited on your plan"
style={{
fontSize: 11,
fontWeight: 700,
color: 'var(--g-a)',
border: '1px solid rgba(0,212,160,.3)',
borderRadius: 100,
padding: '4px 10px',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
{tier}
</span>
)}
<button
onClick={() => setMenuOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={menuOpen}
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--acc-1), var(--bg-3))',
border: '1px solid var(--border-hi)',
color: 'var(--g-a)',
cursor: 'pointer',
fontFamily: 'var(--mono)',
fontWeight: 800,
fontSize: 12,
}}
>
{user.email?.charAt(0).toUpperCase() || 'V'}
</button>
{menuOpen && (
<div
role="menu"
onMouseLeave={() => setMenuOpen(false)}
style={{
position: 'absolute',
right: 0,
top: 'calc(100% + 8px)',
zIndex: 100,
minWidth: 220,
background: 'var(--bg-2)',
border: '1px solid var(--border-hi)',
borderRadius: 8,
padding: 8,
boxShadow: '0 12px 32px rgba(0,0,0,.5)',
}}
>
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
Signed in as
</div>
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{user.email}
</div>
<div className="mono" style={{ marginTop: 6, fontSize: 11, color: 'var(--g-a)', textTransform: 'uppercase' }}>
{tier} tier
</div>
</div>
<a href="/account" role="menuitem" style={menuItem}>Account</a>
<a href="/settings" role="menuitem" style={menuItem}>Settings</a>
{tier === 'free' && (
<a href="/pricing" role="menuitem" style={{ ...menuItem, color: 'var(--g-a)' }}>
Upgrade $14.99/mo
</a>
)}
<button
onClick={() => {
void signOut();
setMenuOpen(false);
}}
role="menuitem"
style={{ ...menuItem, width: '100%', textAlign: 'left', background: 'transparent', border: 'none', cursor: 'pointer' }}
>
Log out
</button>
</div>
)}
</div>
) : (
<a
href="/login"
className="mono"
style={{
fontSize: 12,
fontWeight: 700,
padding: '7px 14px',
borderRadius: 7,
border: '1px solid var(--g-a)',
background: 'transparent',
color: 'var(--g-a)',
textDecoration: 'none',
letterSpacing: '0.04em',
}}
>
Sign In
</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: 'none',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 6,
color: 'var(--text-0)',
cursor: 'pointer',
}}
>
{mobileOpen ? '×' : '≡'}
</button>
</div>
</div>
{mobileOpen && (
<div className="nav-mobile-panel" style={{ borderTop: '1px solid var(--border)', background: 'var(--bg-1)', padding: 12 }}>
<div style={{ display: 'grid', gap: 2 }}>
{[...PRIMARY.map((p) => ({ label: p.label, href: p.href })), ...MORE].map((l) => (
<a
key={l.href}
href={l.href}
onClick={() => setMobileOpen(false)}
style={{
padding: '12px 14px',
fontFamily: 'var(--mono)',
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: isActive(pathname, l.href) ? 'var(--g-a)' : 'var(--text-0)',
textDecoration: 'none',
borderRadius: 8,
}}
>
{l.label}
</a>
))}
{user ? (
<button
onClick={() => {
void signOut();
setMobileOpen(false);
}}
style={{ textAlign: 'left', padding: '12px 14px', fontSize: 13, color: 'var(--text-1)', background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
Log out
</button>
) : (
<a href="/login" onClick={() => setMobileOpen(false)} style={{ marginTop: 6, padding: 12, textAlign: 'center', borderRadius: 7, border: '1px solid var(--g-a)', color: 'var(--g-a)', textDecoration: 'none', fontFamily: 'var(--mono)', fontWeight: 700, textTransform: 'uppercase' }}>
Sign In
</a>
)}
</div>
</div>
)}
</nav>
{/* Ticker + heartbeat under the bar (§8 living layer) */}
<Ticker items={TICKER_ITEMS} height={32} />
<HeartbeatBar />
<style jsx>{`
@media (min-width: 768px) {
:global(.nav-desktop) {
display: flex !important;
}
:global(.nav-mobile-toggle) {
display: none !important;
}
:global(.nav-mobile-panel) {
display: none !important;
}
:global([data-search-trigger]) {
display: inline-flex !important;
}
}
`}</style>
</div>
);
}
const menuItem: React.CSSProperties = {
display: 'block',
padding: '10px 12px',
fontSize: 13,
color: 'var(--text-1)',
textDecoration: 'none',
fontFamily: 'var(--sans)',
};