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:
+70
-2
@@ -4,8 +4,76 @@
|
||||
2026-06-16
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v36.0 — VYNDR 2.0 design system, Phase E: remaining screens —
|
||||
dashboard Bloomberg lines, compare/invite/help/about, login/pricing (Session 36)
|
||||
SHIP BUILD v37.0 — VYNDR 2.0 design system, Phase F: mobile parity —
|
||||
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
|
||||
|
||||
|
||||
@@ -236,6 +236,24 @@ The frame every page sits in. Frontend-only.
|
||||
- **Reskinned** (logic preserved): `login` (scanlines + "ACCESS THE SIGNAL"),
|
||||
`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
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+157
-111
@@ -1,39 +1,123 @@
|
||||
'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 (
|
||||
<>
|
||||
{/* 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>
|
||||
{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"
|
||||
@@ -46,139 +130,101 @@ export default function BottomTabBar() {
|
||||
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)',
|
||||
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.href ? (pathname === t.href || pathname.startsWith(`${t.href}/`)) : false;
|
||||
const color = active ? 'var(--grade-a)' : 'var(--text-secondary)';
|
||||
const active = t.isSheet ? moreOpen : isActive(pathname, t.href);
|
||||
const color = active ? 'var(--g-a)' : 'var(--text-2)';
|
||||
const Icon = t.icon;
|
||||
const isParlay = t.id === 'parlay';
|
||||
const onClick = () => {
|
||||
if (isParlay) open();
|
||||
};
|
||||
|
||||
const inner = (
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Icon color={color} />
|
||||
<span>{t.label}</span>
|
||||
{isParlay && legCount > 0 && (
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
|
||||
if (isParlay || !t.href) {
|
||||
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={onClick} style={{ flex: 1, background: 'transparent', border: 'none', padding: 0 }}>
|
||||
{inner}
|
||||
<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} style={{ flex: 1, padding: 0, textDecoration: 'none' }}>
|
||||
{inner}
|
||||
<a key={t.id} href={t.href} aria-label={t.label} style={sharedStyle}>
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
<style jsx>{`
|
||||
@media (min-width: 768px) {
|
||||
:global(.mobile-tab-bar) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user