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
+70 -2
View File
@@ -4,8 +4,76 @@
2026-06-16 2026-06-16
## Current Phase ## Current Phase
SHIP BUILD v36.0 — VYNDR 2.0 design system, Phase E: remaining screens SHIP BUILD v37.0 — VYNDR 2.0 design system, Phase F: mobile parity
dashboard Bloomberg lines, compare/invite/help/about, login/pricing (Session 36) 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 ## Session 36 (2026-06-16) — SHIPPED
+18
View File
@@ -236,6 +236,24 @@ The frame every page sits in. Frontend-only.
- **Reskinned** (logic preserved): `login` (scanlines + "ACCESS THE SIGNAL"), - **Reskinned** (logic preserved): `login` (scanlines + "ACCESS THE SIGNAL"),
`pricing` (+ ClaimMeter). `account` redirects to `/profile`. `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 ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+106
View File
@@ -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'");
});
});
+6 -1
View File
@@ -8,7 +8,12 @@
"background_color": "#06060B", "background_color": "#06060B",
"theme_color": "#06060B", "theme_color": "#06060B",
"orientation": "portrait-primary", "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": [ "icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }, { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "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::before,
html[data-font="readable"] .wm::after { opacity: 0.45 !important; } 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', width: 'device-width',
initialScale: 1, initialScale: 1,
maximumScale: 5, 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 }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
+1 -1
View File
@@ -114,7 +114,7 @@ export default function TerminalPage() {
))} ))}
</div> </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 */} {/* INJURY CASCADES */}
<div> <div>
<SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead> <SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead>
+181 -135
View File
@@ -1,184 +1,230 @@
'use client'; 'use client';
import { useState } from 'react';
import { usePathname } from 'next/navigation'; 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 }, * VYNDR 2.0 mobile tab bar (§6, Session 37). The PWA's primary navigation —
{ id: 'scan', label: 'Read', href: '/scan', icon: ScanIcon }, * 5 tabs: Slate · Terminal · Scan · Ledger · More. Scan is prominent (raised,
{ id: 'parlay', label: 'Parlay', href: null, icon: ParlayIcon }, * 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: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
{ id: 'profile', label: 'Profile', href: '/profile', icon: ProfileIcon }, { id: 'more', label: 'More', icon: MoreIcon, isSheet: true },
] as const; ];
// 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', '/']); 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() { export default function BottomTabBar() {
const pathname = usePathname() || '/'; const pathname = usePathname() || '/';
const { open, legCount } = useParlay(); const [moreOpen, setMoreOpen] = useState(false);
// 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();
if (HIDE_ON.has(pathname)) return null; if (HIDE_ON.has(pathname)) return null;
if (loading || !user) return null;
return ( return (
<nav <>
role="navigation" {/* More bottom sheet */}
aria-label="Primary" {moreOpen && (
className="mobile-tab-bar" <div
style={{ className="fade-in mobile-tab-bar"
position: 'fixed', role="dialog"
bottom: 0, aria-modal="true"
left: 0, aria-label="More navigation"
right: 0, onClick={() => setMoreOpen(false)}
height: 64, 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' }}
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 = (
<div <div
style={{ onClick={(e) => e.stopPropagation()}
flex: 1, className="sheet-up scanlines"
display: 'flex', 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))' }}
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',
}}
> >
<Icon color={color} /> <div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 4px' }}>
<span>{t.label}</span> <div style={{ width: 38, height: 4, borderRadius: 2, background: 'var(--border-hi)' }} />
{isParlay && legCount > 0 && ( </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 <span
className="mono"
style={{ style={{
position: 'absolute', width: 46,
top: 6, height: 46,
right: 'calc(50% - 22px)', marginTop: -16,
minWidth: 18, borderRadius: '50%',
height: 18, background: 'var(--g-a)',
padding: '0 5px', border: '3px solid var(--bg-0)',
borderRadius: 999, display: 'flex',
background: 'var(--grade-a)',
color: 'var(--bg-primary)',
fontSize: 10,
fontWeight: 800,
display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
boxShadow: '0 0 16px rgba(0,212,160,.5)',
}} }}
> >
{legCount} <Icon color="#04140f" />
</span> </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>
); ) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4, color }}>
if (isParlay || !t.href) { <Icon color={color} />
return ( <span className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.03em' }}>{t.label}</span>
<button key={t.id} onClick={onClick} style={{ flex: 1, background: 'transparent', border: 'none', padding: 0 }}> </div>
{inner}
</button>
); );
}
return (
<a key={t.id} href={t.href} style={{ flex: 1, padding: 0, textDecoration: 'none' }}>
{inner}
</a>
);
})}
<style jsx>{` const sharedStyle: React.CSSProperties = { flex: 1, minWidth: 44, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'none' };
@media (min-width: 768px) {
:global(.mobile-tab-bar) { if (t.isSheet) {
display: none !important; return (
<button key={t.id} onClick={() => setMoreOpen((o) => !o)} aria-label="More" aria-expanded={moreOpen} style={sharedStyle}>
{body}
</button>
);
} }
} return (
`}</style> <a key={t.id} href={t.href} aria-label={t.label} style={sharedStyle}>
</nav> {body}
</a>
);
})}
</nav>
</>
); );
} }
// Lightweight inline SVG icons — keeps the bundle slim and avoids icon-lib install /* ── inline SVG icons (slim, no icon lib) ── */
function HomeIcon({ color }: { color: string }) { function SlateIcon({ color }: { color: string }) {
return ( return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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" /> <rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<path d="M5 10v10h14V10" /> <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> </svg>
); );
} }
function ScanIcon({ color }: { color: string }) { function ScanIcon({ color }: { color: string }) {
return ( return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" /> <circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="1.6" fill={color} stroke="none" />
<path d="M21 21l-4.3-4.3" />
</svg> </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 }) { function LedgerIcon({ color }: { color: string }) {
return ( return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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 4h16v16H4z" /><path d="M4 9h16" /><path d="M9 4v16" />
<path d="M4 9h16" />
<path d="M9 4v16" />
</svg> </svg>
); );
} }
function MoreIcon({ color }: { color: string }) {
function ProfileIcon({ color }: { color: string }) {
return ( return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill={color} stroke="none">
<circle cx="12" cy="8" r="4" /> <circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle cx="19" cy="12" r="2" />
<path d="M4 21c1.5-4 5-6 8-6s6.5 2 8 6" />
</svg> </svg>
); );
} }
+4 -1
View File
@@ -310,13 +310,16 @@ export default function Nav() {
</a> </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 <button
className="nav-mobile-toggle" className="nav-mobile-toggle"
aria-label="Toggle menu" aria-label="Toggle menu"
aria-expanded={mobileOpen} aria-expanded={mobileOpen}
onClick={() => setMobileOpen((o) => !o)} onClick={() => setMobileOpen((o) => !o)}
style={{ style={{
display: 'flex', display: 'none',
background: 'transparent', background: 'transparent',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: 8, 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 className="label" style={{ position: 'relative', zIndex: 2, color: 'rgba(232,255,244,.5)', marginBottom: 2 }}>VYNDR GRADE</div>
<div <div
key={replayKey} key={replayKey}
className="grade-reveal" className="grade-reveal grade-hero"
style={{ style={{
position: 'relative', position: 'relative',
zIndex: 2, zIndex: 2,