Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Base Chain Dashboard

One UI for Base network dashboards and stats: snapshots, network upgrades, and
the Vibenet devnet explorer/faucet, under a single shell.
One UI for Base network dashboards and stats: snapshots and the Vibenet devnet
explorer/faucet, under a single shell.

A standalone Next.js (App Router) app, deployed on Vercel. Migrated out of the
internal Nx template so it builds and ships with the standard Next toolchain.
Expand Down Expand Up @@ -61,7 +61,6 @@ outside this repo at the org/platform level.

- `app/` — App Router routes and API handlers
- `snapshots/` — Reth v2 snapshot browser (`/api/snapshots` reads Cloudflare R2)
- `upgrades/` — network upgrade schedule, changelog, per-fork detail
- `vibenet/` — devnet explorer and faucet
- `components/` — shared shell and UI primitives
- `theme.ts` / `spectrum.ts` / `globals.css` — BDS design tokens
Expand Down
100 changes: 34 additions & 66 deletions app/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ import { AnimatePresence, easeOut, motion, useIsPresent, useMotionTemplate, useM
import { Toaster } from 'sonner';

import { getActiveParent, isChildActive, isTopNavActive, navActiveParent, navHighlightPath, NAV_ITEMS, NavIcon, titleForPath } from '../navigation';
import { BLUE, BORDER, DISABLED, INK, MUTED, SELECTED } from '../theme';
import { getChangeBySlug } from '../upgrades/data/changes';
import { demoBreadcrumb } from '../vibenet/demos/catalogue';
import { getUpgradeById } from '../upgrades/data/upgrades';

import { BLUE, BORDER, BRAND_BLUE, DISABLED, INK, MUTED, SELECTED } from '../theme';
import { demoLabel, demoBreadcrumb } from '../vibenet/demos/catalogue';

import { trackNavClick } from '../analytics/events';
import { navExitingHighlightPath, navSlideDirection, type NavPresenceCustom } from './nav-motion';
Expand Down Expand Up @@ -95,8 +94,8 @@ const styles: Record<string, CSSProperties> = {
borderRadius: 999,
padding: '1px 6px',
},
// Pinned below the sliding panes so Status/Support/Docs/Blog stay put when
// a section sub-nav slides in. The switch sits on the last row beside Blog.
// Pinned below the sliding panes so Upgrades/Docs/Status/Support/Blog stay put
// when a section sub-nav slides in. The switch sits on the last row beside Blog.
sidebarFooter: {
display: 'flex',
flexDirection: 'column',
Expand Down Expand Up @@ -203,22 +202,6 @@ function NavGlyph({ name }: NavGlyphProps) {
<path d="M9 16.5826C9 14.604 10.604 13 12.5826 13H12.6972C13.8235 13 14.8753 12.4371 15.5 11.5C16.1247 10.5629 17.1765 10 18.3028 10H21.6972C22.8235 10 23.8753 10.5629 24.5 11.5C25.1247 12.4371 26.1765 13 27.3028 13H27.4174C29.396 13 31 14.604 31 16.5826V26C31 28.2091 29.2091 30 27 30H13C10.7909 30 9 28.2091 9 26V16.5826Z" />
</svg>
);
case 'upgrades':
return (
<svg {...common}>
<path d="M3 6.7C3 4.65 4.65 3 6.7 3h10.6C19.35 3 21 4.65 21 6.7v10.6c0 2.05-1.65 3.7-3.7 3.7H6.7C4.65 21 3 19.35 3 17.3V6.7Z" />
<path
d="M12.2 16.5V7.9M16.9 12.6 12.2 7.9 7.6 12.6"
className="nav-upgrades-arrow"
/>
</svg>
);
case 'changelog':
return (
<svg {...common} viewBox="0 0 24 24" strokeWidth={1.8} className="nav-changelog-icon">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
</svg>
);
case 'vibenet':
return (
<svg {...common} viewBox="4 3 34 34" strokeWidth={2.5} className="nav-vibenet-icon">
Expand Down Expand Up @@ -581,6 +564,27 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC
</div>

<div style={styles.sidebarFooter} className="sidebar-gutter">
<a href="https://docs.base.org/base-chain/specs/upgrades/overview" target="_blank" rel="noreferrer" className={SIDEBAR_FOOTER_LINK} style={styles.footerLink}>
<span className={SIDEBAR_FOOTER_LINK_LABEL}>
<span style={styles.footerIcon}>
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6.7C3 4.65 4.65 3 6.7 3h10.6C19.35 3 21 4.65 21 6.7v10.6c0 2.05-1.65 3.7-3.7 3.7H6.7C4.65 21 3 19.35 3 17.3V6.7Z" />
<path d="M12.2 16.5V7.9M16.9 12.6 12.2 7.9 7.6 12.6" />
</svg>
</span>
<span className={TEXT_TRIM}>Upgrades</span>
</span>
</a>
<a href="https://docs.base.org" target="_blank" rel="noreferrer" className={SIDEBAR_FOOTER_LINK} style={styles.footerLink}>
<span className={SIDEBAR_FOOTER_LINK_LABEL}>
<span style={styles.footerIcon}>
<svg width={18} height={18} viewBox="6 6 28 28" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 15H19.5M15 25H19.5M15 20H16M24 15L25 15M24 25H25M21 20H25M13 31H27C29.2091 31 31 29.2091 31 27V13C31 10.7909 29.2091 9 27 9H13C10.7909 9 9 10.7909 9 13V27C9 29.2091 10.7909 31 13 31Z" />
</svg>
</span>
<span className={TEXT_TRIM}>Docs</span>
</span>
</a>
<a href="https://status.base.org" target="_blank" rel="noreferrer" className={SIDEBAR_FOOTER_LINK} style={styles.footerLink}>
<span className={SIDEBAR_FOOTER_LINK_LABEL}>
<span style={styles.footerIcon}>
Expand All @@ -601,32 +605,18 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC
<span className={TEXT_TRIM}>Support</span>
</span>
</a>
<a href="https://docs.base.org" target="_blank" rel="noreferrer" className={SIDEBAR_FOOTER_LINK} style={styles.footerLink}>
<a href="https://blog.base.org" target="_blank" rel="noreferrer" className={SIDEBAR_FOOTER_LINK} style={styles.footerLink}>
<span className={SIDEBAR_FOOTER_LINK_LABEL}>
<span style={styles.footerIcon}>
<svg width={18} height={18} viewBox="6 6 28 28" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 15H19.5M15 25H19.5M15 20H16M24 15L25 15M24 25H25M21 20H25M13 31H27C29.2091 31 31 29.2091 31 27V13C31 10.7909 29.2091 9 27 9H13C10.7909 9 9 10.7909 9 13V27C9 29.2091 10.7909 31 13 31Z" />
<svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M12.75 19.5V18.75C12.75 16.76 11.96 14.85 10.55 13.45C9.15 12.04 7.24 11.25 5.25 11.25H4.5M4.5 4.5H5.25C13.12 4.5 19.5 10.88 19.5 18.75V19.5M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
</span>
<span className={TEXT_TRIM}>Docs</span>
<span className={TEXT_TRIM}>Blog</span>
</span>
</a>
<div style={styles.footerLastRow}>
<a href="https://blog.base.org" target="_blank" rel="noreferrer" className={SIDEBAR_FOOTER_LINK} style={{ ...styles.footerLink, flex: 1 }}>
<span className={SIDEBAR_FOOTER_LINK_LABEL}>
<span style={styles.footerIcon}>
<svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M12.75 19.5V18.75C12.75 16.76 11.96 14.85 10.55 13.45C9.15 12.04 7.24 11.25 5.25 11.25H4.5M4.5 4.5H5.25C13.12 4.5 19.5 10.88 19.5 18.75V19.5M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
</span>
<span className={TEXT_TRIM}>Blog</span>
</span>
</a>
{/* `role="switch"` rather than a plain button: the control reports a state
rather than firing an action, so screen readers announce "on"/"off"
against a stable label instead of the label itself changing. The switch
renders bare, so that name comes from `aria-label` — the visible track
is decorative and hidden from the tree. */}
<div style={styles.footerLastRow} className="justify-between -mb-1.5">
<span className="pl-2.5 text-[12px] font-medium text-bds-gray-20 dark:text-bds-gray-40">Developer Console</span>
<button
type="button"
role="switch"
Expand All @@ -638,11 +628,11 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC
>
<span
aria-hidden
className={`${THEME_SWITCH_ANIM} bg-bds-gray-50 transition-colors duration-[180ms] motion-reduce:transition-none group-hover:bg-bds-gray-70 dark:bg-brand-blue dark:group-hover:bg-[color-mix(in_srgb,var(--bds-brand)_82%,white)] group-focus-visible:outline group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-brand-blue`}
className={`${THEME_SWITCH_ANIM} bg-brand-blue transition-colors duration-[180ms] motion-reduce:transition-none group-hover:bg-[color-mix(in_srgb,var(--bds-brand)_82%,black)] dark:bg-brand-blue dark:group-hover:bg-[color-mix(in_srgb,var(--bds-brand)_82%,white)] group-focus-visible:outline group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-brand-blue`}
style={styles.switchTrack}
>
<span
className={`${THEME_SWITCH_ANIM} text-bds-gray-50 transition-[transform,color] duration-[180ms] ease-out motion-reduce:transition-none dark:translate-x-[14px] dark:text-brand-blue`}
className={`${THEME_SWITCH_ANIM} text-brand-blue transition-[transform,color] duration-[180ms] ease-out motion-reduce:transition-none dark:translate-x-[14px] dark:text-brand-blue`}
style={styles.switchThumb}
>
<ThemeIcon />
Expand Down Expand Up @@ -843,28 +833,6 @@ export function AppShell({ children }: PropsWithChildren) {
>
<div id="topbar-actions-slot" className="absolute right-7 top-1/2 z-10 flex -translate-y-1/2 items-center gap-2" />
{(() => {
const slugMatch = pathname.match(/^\/upgrades\/changelog\/(.+)$/);
if (slugMatch) {
const change = getChangeBySlug(slugMatch[1]);
return (
<Breadcrumb
parentLabel="Changelog"
parentHref="/upgrades/changelog"
childLabel={change?.title ?? slugMatch[1]}
/>
);
}
const upgradeMatch = pathname.match(/^\/upgrades\/upgrade\/(.+)$/);
if (upgradeMatch) {
const upgrade = getUpgradeById(upgradeMatch[1]);
return (
<Breadcrumb
parentLabel="Upgrades"
parentHref="/upgrades"
childLabel={upgrade?.name ?? upgradeMatch[1]}
/>
);
}
if (pathname.startsWith('/vibenet') && pathname !== '/vibenet') {
let childLabel = title;
let middle: { label: string; href: string } | undefined;
Expand Down
139 changes: 139 additions & 0 deletions app/components/ui/CommandLine.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';

import { cn } from './cn';
import { COPY_SQUARES_PATH_40 } from './icons';
import { Text } from './Text';

const SHIMMER_GRADIENT =
'linear-gradient(90deg, currentColor 0%, currentColor 40%, var(--shimmer-highlight) 50%, currentColor 60%, currentColor 100%)';

// Single-line, `$`-prefixed command box: a shimmer sweep on the command when it
// changes, a marquee that scrolls overflowing text on hover, and a copy button
// whose icon morphs to a check. Shared by the snapshots configurator and the
// homepage download box so both stay in sync.
export function CommandLine({
command,
onCopy,
className,
}: {
command: string;
onCopy?: () => void;
className?: string;
}) {
const [copied, setCopied] = useState(false);
const [hovered, setHovered] = useState(false);
const textRef = useRef<HTMLSpanElement>(null);
const shimmerRef = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [overflowPx, setOverflowPx] = useState(0);

useEffect(() => {
const measure = () => {
if (textRef.current && containerRef.current) {
setOverflowPx(Math.max(0, textRef.current.scrollWidth - containerRef.current.clientWidth));
}
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [command]);

useEffect(() => {
const animation = shimmerRef.current?.animate(
[{ backgroundPosition: '100% 0%' }, { backgroundPosition: '0% 0%' }],
{ duration: 700, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' },
);
return () => animation?.cancel();
}, [command]);

const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(command).then(() => {
setCopied(true);
onCopy?.();
setTimeout(() => setCopied(false), 2000);
});
}, [command, onCopy]);

return (
<div
className={cn(
'flex w-full items-center gap-2 rounded-lg border border-bds-gray-10 bg-background px-3 py-2',
className,
)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div ref={containerRef} className="relative min-w-0 flex-1 overflow-hidden">
<motion.span
ref={textRef}
animate={{ x: hovered && overflowPx > 0 ? -overflowPx : 0 }}
transition={hovered ? { duration: overflowPx / 100, ease: 'linear' } : { duration: 0.3, ease: 'easeOut' }}
className="block whitespace-nowrap"
>
<span
ref={shimmerRef}
className="bg-[length:300%_100%] bg-clip-text bg-no-repeat [--shimmer-highlight:var(--bds-blue-15)] dark:[--shimmer-highlight:var(--bds-brand)]"
style={{ backgroundImage: SHIMMER_GRADIENT, WebkitTextFillColor: 'transparent' }}
>
<Text as="span" variant="label.mono">
<span className="text-bds-gray-40" style={{ WebkitTextFillColor: 'initial' }}>$</span> {command}
</Text>
</span>
</motion.span>
{overflowPx > 0 && (
<motion.div
animate={{ opacity: hovered ? 0 : 1 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-background to-transparent"
/>
)}
</div>
<button
type="button"
onClick={handleCopy}
className="relative ml-1 inline-flex h-5 w-5 shrink-0 items-center justify-center text-bds-gray-60 transition-colors hover:text-foreground"
aria-label="Copy command"
>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.svg
key="check"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.15 }}
>
<path d="M20 6 9 17l-5-5" />
</motion.svg>
) : (
<motion.svg
key="copy"
width={20}
height={20}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.15 }}
>
<path d={COPY_SQUARES_PATH_40} stroke="currentColor" strokeWidth={2.5} />
</motion.svg>
)}
</AnimatePresence>
</button>
</div>
);
}
2 changes: 1 addition & 1 deletion app/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type EmptyStateProps = {
};

// Shared "nothing here" / "coming soon" message. Consolidates the repeated
// muted-text-in-a-card blocks across the changelog, carousels, and detail view.
// muted-text-in-a-card blocks across list and detail views.
export function EmptyState({ description, title, bordered = true, className }: EmptyStateProps) {
return (
<div
Expand Down
5 changes: 0 additions & 5 deletions app/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,6 @@ describe('isChildActive', () => {
});

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);
});
Expand Down
4 changes: 1 addition & 3 deletions app/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BENCHMARK_ENABLED } from './benchmark/flag';
import { EXPLORER_ENABLED, EXPLORER_LABEL } from './internal-explorer/flag';
import { demoBreadcrumb } from './vibenet/demos/catalogue';

export type NavIcon = 'home' | 'snapshots' | 'upgrades' | 'changelog' | 'vibenet' | 'overview' | 'demos' | 'faucet' | 'explorer' | 'internal-explorer' | 'benchmark' | 'runs' | 'loadtest';
export type NavIcon = 'home' | 'snapshots' | 'vibenet' | 'overview' | 'demos' | 'faucet' | 'explorer' | 'internal-explorer' | 'benchmark' | 'runs' | 'loadtest';

export type NavChild = {
label: string;
Expand Down Expand Up @@ -32,8 +32,6 @@ export const NAV_ITEMS: NavItem[] = [
{ label: 'Explorer', href: '/vibenet/explorer', icon: 'explorer' },
],
},
{ label: 'Upgrades', href: '/upgrades', icon: 'upgrades', enabled: true },
{ label: 'Changelog', href: '/upgrades/changelog', icon: 'changelog', enabled: true },
{ label: 'Snapshots', href: '/snapshots', icon: 'snapshots', enabled: true },
// Internal Explorer is internal-only; present only in the internal build
// target (deploy.config.mjs). See app/internal-explorer/flag.ts.
Expand Down
Loading
Loading