diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index b88f1e7..1cb9424 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -3,15 +3,14 @@ import { CSSProperties, MouseEvent as ReactMouseEvent, PropsWithChildren, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { AnimatePresence, motion } from 'motion/react'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { Toaster } from 'sonner'; -import { getActiveParent, NAV_ITEMS, NavChild, NavIcon } from '../navigation'; +import { getActiveParent, isChildActive, isTopNavActive, navActiveParent, navHighlightPath, NAV_ITEMS, NavIcon, titleForPath } from '../navigation'; import { BLUE, BORDER, BRAND_BLUE, DISABLED, INK, MUTED, SELECTED } from '../theme'; import { getChangeBySlug } from '../upgrades/data/changes'; import { demoLabel } from '../vibenet/demos/catalogue'; import { getUpgradeById } from '../upgrades/data/upgrades'; -import { titleForPath } from '../navigation'; import { trackNavClick } from '../analytics/events'; import { navSlideDirection } from './nav-motion'; @@ -181,8 +180,8 @@ const styles: Record = { padding: '0 28px', }, topbarTitle: { fontSize: 16, fontWeight: 500 }, - content: { flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' as const }, - contentInner: { width: '100%', maxWidth: 1280, margin: '0 auto', padding: '24px 28px 80px', flex: 1, display: 'flex', flexDirection: 'column' as const }, + content: { flex: 1, overflowY: 'auto', overflowX: 'hidden', display: 'flex', flexDirection: 'column' as const, minWidth: 0 }, + contentInner: { width: '100%', maxWidth: 1280, margin: '0 auto', padding: '24px 28px 80px', flex: 1, display: 'flex', flexDirection: 'column' as const, minWidth: 0 }, }; function NavGlyph({ name }: NavGlyphProps) { @@ -312,6 +311,10 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, l {active && ( ({ x: direction > 0 ? '60%' : '-60%', opacity: 0 }), center: { x: 0, opacity: 1 }, @@ -423,8 +421,8 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop // sub-nav slide sitting still for the whole navigation — the tap felt dead, then // everything moved at once. const [pendingPath, setPendingPath] = useState(null); - const activePath = pendingPath ?? pathname; - const activeParent = getActiveParent(activePath); + const activePath = navHighlightPath(pendingPath, pathname); + const activeParent = navActiveParent(pendingPath, pathname); const directionRef = useRef(1); const prevParentRef = useRef(activeParent?.href ?? null); const scrollViewportRef = useRef(null); @@ -447,7 +445,9 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop }, [pathname]); useEffect(() => { - if (!pendingPath) return; + // '/' is the back-header view: the route will not commit, so a timeout + // would snap the submenu back. Only time out real pending navigations. + if (!pendingPath || pendingPath === '/') return; const timer = setTimeout(() => setPendingPath(null), PENDING_PATH_TIMEOUT_MS); return () => clearTimeout(timer); }, [pendingPath]); @@ -461,7 +461,9 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop const selectPath = (href: string) => { setPendingPath(href); - onNavigate?.(); + // Keep the drawer open when the tap is the section root (Vibenet, + // Benchmark). getActiveParent already knows which hrefs have a submenu. + if (getActiveParent(href)?.href !== href) onNavigate?.(); }; const direction = directionRef.current; @@ -495,7 +497,10 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop style={{ ...styles.navLink, display: 'flex', alignItems: 'center', padding: '9px 6px 9px 2px', marginBottom: 4, color: 'var(--bds-gray-50)' }} onClick={(event) => { if (opensInNewTab(event)) return; - selectPath('/'); + // Stay on the current page and keep the mobile drawer + // open. href="/" remains for modified clicks (new tab). + event.preventDefault(); + setPendingPath('/'); }} > )} @@ -656,34 +649,44 @@ type GlobalBannerProps = { }; function GlobalBanner({ dismissed, onDismiss, className }: GlobalBannerProps) { - if (dismissed) return null; + const reducedMotion = useReducedMotion(); + const transition = reducedMotion ? { duration: 0 } : slideTransition; + return ( -
+ {!dismissed && ( + +
+
+
+ New! + EIP-8130: Accounts + + + Test on Vibenet + + +
+
+ +
+
)} - > -
-
- New! - EIP-8130: Accounts - - - Test on Vibenet - - -
-
- -
+ ); } @@ -712,10 +715,6 @@ export function AppShell({ children }: PropsWithChildren) { } }; - useEffect(() => { - setMenuOpen(false); - }, [pathname]); - useEffect(() => { if (!menuOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenuOpen(false); }; @@ -736,7 +735,7 @@ export function AppShell({ children }: PropsWithChildren) { setBannerDismissed(true)} - className="hidden md:flex" + className="hidden md:block" />
{/* Desktop sidebar */} @@ -748,7 +747,7 @@ export function AppShell({ children }: PropsWithChildren) {
{/* Static on touch: the morph is hover-driven, so base.org leaves its mobile mark static too. */} - + setMenuOpen(false)}>
diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx index 5522037..5afca88 100644 --- a/app/components/ui/Button.tsx +++ b/app/components/ui/Button.tsx @@ -41,9 +41,9 @@ export function Button({ if (size === 'sm') { sizeClasses = 'h-[34px] px-3 gap-1 pb-px'; } else if (arrow) { - sizeClasses = 'h-12 pl-5 pr-4 gap-1'; + sizeClasses = 'h-10 pl-4 pr-3 gap-1'; } else { - sizeClasses = 'h-12 pl-5 pr-5 gap-3'; + sizeClasses = 'h-10 px-4 gap-2'; } const classes = cn( diff --git a/app/components/ui/Text.tsx b/app/components/ui/Text.tsx index 2b5b74e..984e767 100644 --- a/app/components/ui/Text.tsx +++ b/app/components/ui/Text.tsx @@ -54,7 +54,7 @@ export const textVariantClasses: Record = { 'text-[13px] md:text-[14px] leading-[18px] md:leading-[20px] font-mono font-[400] tracking-[0px]', caption: 'text-[11px] md:text-[12px] leading-[14px] md:leading-[16px] font-[500] tracking-[0px] uppercase', - button: 'text-[15px] md:text-[16px] leading-[140%] font-base font-[400] tracking-[-0.01em]', + button: 'text-[14px] leading-[20px] font-base font-[400] tracking-[-0.01em]', footnote: 'text-[11px] md:text-[12px] leading-[14px] md:leading-[16px] font-[400] tracking-[0px]', }; diff --git a/app/globals.css b/app/globals.css index 98138e2..de41cc0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -811,26 +811,30 @@ button { padding-top: 56px; } - .activity-full-width { - width: 100vw; - margin-left: calc(-1 * (100vw - 100%) / 2); - } - /* iOS Safari auto-zooms the page when a focused input's font-size is < 16px, which shrinks the visual viewport and pushes fixed modals (and their close button) off-screen. Force >=16px on mobile so focusing a field never zooms. !important is needed to beat per-input Tailwind text-[13px]/[14px] utilities; the smaller visual sizing is preserved on >=768px. */ - input, - textarea, - select { - font-size: 16px !important; +@supports (-webkit-touch-callout: none) { + input, + textarea, + select { + font-size: 16px !important; + } } } -@media (min-width: 768px) { - .activity-full-width { - width: calc(100vw - 248px); - margin-left: calc(-1 * (100vw - 248px - 100%) / 2); - } +/* Activity drawer: span the content scrollport without 100vw. 100vw includes + the scrollbar and is wider than the nested max-w-5xl column, so it created + a horizontal scrollbar. `cqw` is the .content-scroll column's client width. */ +.content-scroll { + container-type: inline-size; + container-name: content-scroll; +} + +.activity-full-width { + width: auto; + margin-left: calc(50% - 50cqw); + margin-right: calc(50% - 50cqw); } diff --git a/app/navigation.test.ts b/app/navigation.test.ts new file mode 100644 index 0000000..1185fcd --- /dev/null +++ b/app/navigation.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + getActiveParent, + isChildActive, + isTopNavActive, + navActiveParent, + navHighlightPath, + pathMatches, + titleForPath, +} from './navigation'; + +describe('pathMatches', () => { + it('matches a prefix unless exact', () => { + expect(pathMatches('/vibenet', '/vibenet/faucet')).toBe(true); + expect(pathMatches('/vibenet', '/vibenet/faucet', true)).toBe(false); + }); + + it('treats / as exact', () => { + expect(pathMatches('/', '/')).toBe(true); + expect(pathMatches('/', '/vibenet')).toBe(false); + }); +}); + +describe('getActiveParent', () => { + it('returns the section for a child path', () => { + expect(getActiveParent('/vibenet')?.href).toBe('/vibenet'); + expect(getActiveParent('/vibenet/faucet')?.href).toBe('/vibenet'); + }); + + it('returns null on the root list', () => { + expect(getActiveParent('/')).toBeNull(); + expect(getActiveParent('/snapshots')).toBeNull(); + }); + + it('is the section root only for the parent href itself', () => { + expect(getActiveParent('/vibenet')?.href === '/vibenet').toBe(true); + expect(getActiveParent('/vibenet/faucet')?.href === '/vibenet/faucet').toBe(false); + }); +}); + +describe('isChildActive', () => { + it('honors exact child hrefs', () => { + expect(isChildActive({ label: 'Overview', href: '/vibenet', exact: true }, '/vibenet')).toBe(true); + expect(isChildActive({ label: 'Overview', href: '/vibenet', exact: true }, '/vibenet/faucet')).toBe(false); + }); +}); + +describe('isTopNavActive', () => { + it('does not highlight Upgrades on the changelog path', () => { + expect(isTopNavActive({ label: 'Upgrades', href: '/upgrades', icon: 'upgrades', enabled: true }, '/upgrades/changelog')).toBe(false); + expect(isTopNavActive({ label: 'Changelog', href: '/upgrades/changelog', icon: 'changelog', enabled: true }, '/upgrades/changelog')).toBe(true); + }); + + it('highlights a section root while inside it', () => { + expect(isTopNavActive({ label: 'Vibenet', href: '/vibenet', icon: 'vibenet', enabled: true, children: [] }, '/vibenet/faucet')).toBe(true); + }); + + it('does not mark Home active while Vibenet is on screen', () => { + const home = { label: 'Home', href: '/', icon: 'home' as const, enabled: true }; + const vibenet = { label: 'Vibenet', href: '/vibenet', icon: 'vibenet' as const, enabled: true, children: [] }; + const path = navHighlightPath('/', '/vibenet'); + expect(isTopNavActive(home, path)).toBe(false); + expect(isTopNavActive(vibenet, path)).toBe(true); + }); +}); + +describe('navHighlightPath', () => { + it('keeps the real page highlighted after the back header', () => { + expect(navHighlightPath('/', '/vibenet')).toBe('/vibenet'); + }); + + it('follows a real pending navigation', () => { + expect(navHighlightPath('/vibenet', '/')).toBe('/vibenet'); + expect(navHighlightPath(null, '/snapshots')).toBe('/snapshots'); + }); +}); + +describe('navActiveParent', () => { + it('shows the root list after the back header', () => { + expect(navActiveParent('/', '/vibenet')).toBeNull(); + }); + + it('shows the section pane for a section path', () => { + expect(navActiveParent('/vibenet', '/')).toEqual(getActiveParent('/vibenet')); + expect(navActiveParent(null, '/vibenet/faucet')).toEqual(getActiveParent('/vibenet/faucet')); + }); +}); + +describe('titleForPath', () => { + it('uses the child label inside a section', () => { + expect(titleForPath('/vibenet/faucet')).toBe('Faucet'); + expect(titleForPath('/vibenet')).toBe('Overview'); + }); +}); diff --git a/app/navigation.ts b/app/navigation.ts index 42987c5..9730402 100644 --- a/app/navigation.ts +++ b/app/navigation.ts @@ -58,21 +58,22 @@ export const NAV_ITEMS: NavItem[] = [ : []), ]; +/** Prefix match unless `exact` or the href is `/`. */ +export function pathMatches(href: string, pathname: string, exact = false): boolean { + if (exact || href === '/') return pathname === href; + return pathname === href || pathname.startsWith(`${href}/`); +} + export function titleForPath(pathname: string): string { if (pathname === '/') return 'Home'; for (const item of NAV_ITEMS) { if (item.children) { for (const child of item.children) { - if (child.exact && pathname === child.href) return child.label; - if (!child.exact && (pathname === child.href || pathname.startsWith(`${child.href}/`))) { - return child.label; - } + if (pathMatches(child.href, pathname, child.exact)) return child.label; } } } - const matches = NAV_ITEMS.filter( - (item) => item.href !== '/' && (pathname === item.href || pathname.startsWith(`${item.href}/`)), - ); + const matches = NAV_ITEMS.filter((item) => pathMatches(item.href, pathname)); if (matches.length === 0) return ''; matches.sort((a, b) => b.href.length - a.href.length); return matches[0].label; @@ -80,6 +81,31 @@ export function titleForPath(pathname: string): string { export function getActiveParent(pathname: string): NavItem | null { return NAV_ITEMS.find( - (item) => item.children && item.enabled && (pathname === item.href || pathname.startsWith(`${item.href}/`)), + (item) => item.children && item.enabled && pathMatches(item.href, pathname), ) ?? null; } + +/** + * Path for the active-row pill. `pendingPath === '/'` is the back-header + * pane only — the page has not moved, so highlight still follows `pathname`. + */ +export function navHighlightPath(pendingPath: string | null, pathname: string): string { + return pendingPath && pendingPath !== '/' ? pendingPath : pathname; +} + +/** Which sliding pane to show. Back-header (`'/'`) forces the root list. */ +export function navActiveParent(pendingPath: string | null, pathname: string): NavItem | null { + if (pendingPath === '/') return null; + return getActiveParent(pendingPath ?? pathname); +} + +export function isChildActive(child: NavChild, pathname: string): boolean { + return pathMatches(child.href, pathname, child.exact); +} + +export function isTopNavActive(item: NavItem, pathname: string): boolean { + if (!pathMatches(item.href, pathname)) return false; + return !NAV_ITEMS.some( + (other) => other.href !== item.href && other.href.startsWith(item.href) && pathMatches(other.href, pathname), + ); +} diff --git a/app/upgrades/UpgradesClient.tsx b/app/upgrades/UpgradesClient.tsx index bc53b8a..8a7bc46 100644 --- a/app/upgrades/UpgradesClient.tsx +++ b/app/upgrades/UpgradesClient.tsx @@ -262,7 +262,7 @@ function UpgradeView({ nowMs }: { nowMs: number }) { {upgrade.summary}
-
+
Sepolia diff --git a/app/vibenet/demos/_components/AccountDemoShell.tsx b/app/vibenet/demos/_components/AccountDemoShell.tsx index 6a43983..00eb206 100644 --- a/app/vibenet/demos/_components/AccountDemoShell.tsx +++ b/app/vibenet/demos/_components/AccountDemoShell.tsx @@ -69,7 +69,10 @@ export function AccountDemoShell({ return ( <> -
+ {/* flex-1 fills the content column so the activity drawer can sit at the + bottom on short pages (`mt-auto`). A 100vh min-height overshot the + padded scrollport and left extra scroll below the drawer. */} +
{/* Desktop: the switcher lives in the app top bar. Hidden until an account exists so the gate reads as a clean full-page empty state. */} {hasAccounts && topbarSlot ? createPortal(switcher, topbarSlot) : null} diff --git a/app/vibenet/demos/_shared/ActivityDrawer.tsx b/app/vibenet/demos/_shared/ActivityDrawer.tsx index 2f86e25..e8681d6 100644 --- a/app/vibenet/demos/_shared/ActivityDrawer.tsx +++ b/app/vibenet/demos/_shared/ActivityDrawer.tsx @@ -34,7 +34,7 @@ export function ActivityDrawer({ // `mt-auto` drops the bar to the bottom of the (full-height) flex column so // it rests at the viewport bottom on short pages; `sticky bottom-0` pins it // once content is tall enough to scroll. -
+
+
diff --git a/app/vibenet/demos/b20/components/PolicyModule.tsx b/app/vibenet/demos/b20/components/PolicyModule.tsx index 31d0cc2..0898d92 100644 --- a/app/vibenet/demos/b20/components/PolicyModule.tsx +++ b/app/vibenet/demos/b20/components/PolicyModule.tsx @@ -136,7 +136,7 @@ export function PolicyModule({ onChange={(e) => setAddress(e.target.value)} placeholder="Paste a token address" /> -
diff --git a/app/vibenet/layout.tsx b/app/vibenet/layout.tsx index acbe5fd..c2f12ac 100644 --- a/app/vibenet/layout.tsx +++ b/app/vibenet/layout.tsx @@ -18,9 +18,9 @@ type VibenetLayoutProps = { export default function VibenetLayout({ children }: VibenetLayoutProps) { return ( -
+
-
+
{children}