Skip to content
Merged
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
127 changes: 92 additions & 35 deletions src/components/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import { Menu, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { SearchTrigger } from "@/components/search/search-trigger";
import { SiteLogoSwitcher } from "@/components/site-logo-switcher";
import { SiteSubNav } from "@/components/site-sub-nav";
import { ThemeToggle } from "@/components/theme-toggle";

function GithubIcon({ size = 15 }: { size?: number }) {
Expand All @@ -15,16 +17,35 @@ function GithubIcon({ size = 15 }: { size?: number }) {
);
}

const NAV = [
{ href: "/benchmarks", label: "Benchmarks" },
{ href: "/products", label: "Products" },
{ href: "/methodology", label: "Methodology" },
{ href: "/about", label: "About" },
{ href: "/contribute", label: "Contribute" },
type NavItem = { href: string; label: string; match: (p: string) => boolean };

// Active-state predicates. Bench/product detail pages share the same
// tab as the index, so `/benchmarks/aggregator-head-lag` highlights
// the "Benchmarks" tab. `/` matches exact only — without that, every
// route would inherit a "Home" highlight.
const NAV: NavItem[] = [
{
href: "/benchmarks",
label: "Benchmarks",
match: (p) => p === "/benchmarks" || p.startsWith("/benchmarks/"),
},
{
href: "/products",
label: "Products",
match: (p) => p === "/products" || p.startsWith("/products/"),
},
{
href: "/methodology",
label: "Methodology",
match: (p) => p === "/methodology",
},
{ href: "/about", label: "About", match: (p) => p === "/about" },
{ href: "/contribute", label: "Contribute", match: (p) => p === "/contribute" },
];

export function SiteHeader() {
const [open, setOpen] = useState(false);
const pathname = usePathname() ?? "/";

// Close the mobile menu when the viewport crosses md so the dropdown
// doesn't stick around as the desktop nav reappears.
Expand All @@ -38,6 +59,12 @@ export function SiteHeader() {
return () => mql.removeEventListener("change", onChange);
}, [open]);

// Close the mobile menu on route change so a tap on a nav item collapses
// the drawer without the consumer having to wire onClick on every link.
useEffect(() => {
setOpen(false);
}, [pathname]);

return (
<div
// Sticky everywhere by default. Only iOS in-app WebViews (Telegram,
Expand All @@ -47,10 +74,10 @@ export function SiteHeader() {
// not visible there in practice. Override lives in globals.css
// (`html.ios-webview .site-header-root` rule) so the WebView-only
// selector stays out of the Tailwind class soup here.
className="site-header-root sticky top-0 z-50 flex flex-col font-sans bg-surface"
className="site-header-root sticky top-0 z-50 flex flex-col font-sans bg-surface/95 backdrop-blur supports-[backdrop-filter]:bg-surface/80"
>
<header className="border-b border-rule py-4 md:py-5 px-4 sm:px-6 shrink-0 text-sm bg-surface relative">
<div className="max-w-[1400px] mx-auto flex items-center justify-between gap-3">
<header className="border-b border-rule px-4 sm:px-6 shrink-0 text-sm relative">
<div className="max-w-[1400px] mx-auto flex items-center justify-between gap-3 h-14 md:h-16">
<div className="flex items-center gap-2">
<SiteLogoSwitcher size={22} />
<Link
Expand All @@ -64,28 +91,49 @@ export function SiteHeader() {
</Link>
</div>

<nav className="hidden md:flex items-center gap-8 text-ink-muted font-medium">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className="hover:text-ink transition-colors"
>
{item.label}
</Link>
))}
{/* Center nav - CMC-style: underline under the active section.
Items keep a constant pb to avoid layout shift between
active / inactive states. */}
<nav className="hidden md:flex items-center h-full gap-7 text-[15px] font-medium">
{NAV.map((item) => {
const active = item.match(pathname);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={[
"relative flex items-center h-full transition-colors",
active
? "text-ink"
: "text-ink-muted hover:text-ink",
].join(" ")}
>
{item.label}
{active && (
<span
aria-hidden
className="absolute inset-x-0 -bottom-px h-[2px] bg-accent"
/>
)}
</Link>
);
})}
</nav>

{/* Right utilities - search + github + theme. No separator pipe;
the gap-based spacing handles visual grouping. */}
<div className="hidden md:flex items-center gap-5 text-ink-muted">
<SearchTrigger variant="desktop" />
<span className="text-rule-strong">|</span>
<a
href="https://github.com/ChainBench/OpenChainBench"
className="inline-flex items-center gap-1.5 hover:text-ink transition-colors"
className="inline-flex items-center hover:text-ink transition-colors"
aria-label="View source on GitHub"
>
<GithubIcon size={15} />
GitHub
<GithubIcon size={16} />
</a>
<ThemeToggle />
</nav>
</div>

<div className="md:hidden flex items-center -mr-2">
<SearchTrigger variant="mobile" />
Expand All @@ -108,17 +156,24 @@ export function SiteHeader() {
className="md:hidden absolute left-0 right-0 top-full border-b border-rule bg-surface shadow-lg"
>
<ul className="max-w-[1400px] mx-auto px-4 sm:px-6 py-2 flex flex-col">
{NAV.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="flex items-center min-h-[44px] py-2 text-ink-muted hover:text-ink transition-colors"
onClick={() => setOpen(false)}
>
{item.label}
</Link>
</li>
))}
{NAV.map((item) => {
const active = item.match(pathname);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={[
"flex items-center min-h-[44px] py-2 transition-colors",
active ? "text-ink font-semibold" : "text-ink-muted hover:text-ink",
].join(" ")}
onClick={() => setOpen(false)}
>
{item.label}
</Link>
</li>
);
})}
<li className="border-t border-rule mt-1 pt-1 flex items-center gap-4">
<a
href="https://github.com/ChainBench/OpenChainBench"
Expand All @@ -137,6 +192,8 @@ export function SiteHeader() {
</nav>
)}
</header>

<SiteSubNav pathname={pathname} />
</div>
);
}
82 changes: 82 additions & 0 deletions src/components/site-sub-nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import Link from "next/link";
import { CATEGORIES } from "@/lib/categories";

type SubItem = { href: string; label: string; match: (p: string) => boolean };

// Per-section contextual sub-nav. Modelled on the CMC chart-hub pattern
// (horizontally scrollable category tabs below the main nav). The
// bench section is the only one with a deep enough taxonomy to need
// this today; other sections render nothing rather than an empty
// 40px-tall strip that would just push content down.
function getItems(pathname: string): SubItem[] | null {
if (pathname === "/benchmarks" || pathname.startsWith("/benchmarks/")) {
const items: SubItem[] = [
{
href: "/benchmarks",
label: "All",
match: (p) =>
p === "/benchmarks" ||
(p.startsWith("/benchmarks/") && !p.startsWith("/benchmarks/category/")),
},
];
for (const c of CATEGORIES) {
const href = `/benchmarks/category/${c.slug}`;
items.push({
href,
label: c.label,
match: (p) => p === href,
});
}
return items;
}
return null;
}

export function SiteSubNav({ pathname }: { pathname: string }) {
const items = getItems(pathname);
if (!items) return null;

return (
<div className="border-b border-rule bg-surface/95 backdrop-blur supports-[backdrop-filter]:bg-surface/80">
<nav
aria-label="Section navigation"
className="max-w-[1400px] mx-auto px-4 sm:px-6"
>
{/* Horizontal scroll on mobile, no scrollbar. Edge-fade hint via
mask-image so the cut-off tabs read as scrollable rather than
clipped. */}
<ul
className="flex items-stretch gap-1 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [mask-image:linear-gradient(to_right,black_0,black_calc(100%-24px),transparent_100%)] md:[mask-image:none]"
>
{items.map((item) => {
const active = item.match(pathname);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={[
"relative inline-flex items-center h-10 px-3 text-[13px] font-medium transition-colors",
active
? "text-ink"
: "text-ink-muted hover:text-ink",
].join(" ")}
>
{item.label}
{active && (
<span
aria-hidden
className="absolute inset-x-2 -bottom-px h-[2px] bg-accent rounded-full"
/>
)}
</Link>
</li>
);
})}
</ul>
</nav>
</div>
);
}
14 changes: 14 additions & 0 deletions src/lib/bench-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,19 @@ describe("renderTemplate", () => {
"{{best_name:chain:solana}}",
);
});

test("matches chain key case-insensitively against the stash", () => {
// perp-fees declares dimensions.chain values as `ETH`, `BTC`, `SOL`
// (uppercase asset codes), so the stash ends up keyed `ETH` etc.
// Without case folding, `{{best_p50:chain:BTC}}` in an editorial
// line would fall through and render the raw token.
const upper = bench([r("alpha", "Alpha", 100), r("beta", "Beta", 200)]);
upper.bestPerChain = {
ETH: { name: "Alpha", slug: "alpha", ms: { p50: 100, p90: 0, p99: 0, mean: 100 }, successRate: 1 },
BTC: { name: "Beta", slug: "beta", ms: { p50: 200, p90: 0, p99: 0, mean: 200 }, successRate: 1 },
};
expect(renderTemplate("{{best_name:chain:BTC}}", upper)).toBe("Beta");
expect(renderTemplate("{{best_p50:chain:eth}}", upper)).toBe("100 ms");
});
});
});
65 changes: 56 additions & 9 deletions src/lib/bench-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,33 @@ const CHAIN_TEMPLATE_RE =

/** Per-chain leader / trailer lookups against the Benchmark stash
* populated by spec.ts. Inlined (not re-imported from spec.ts) to
* avoid a spec.ts → bench-template.ts → spec.ts circular import. */
* avoid a spec.ts → bench-template.ts → spec.ts circular import.
*
* Case-insensitive lookup: the stash is keyed by the raw YAML value
* (`spec.dimensions.chain[*].value`), which authors write as either
* `solana` or `BTC` depending on convention. Without the fold,
* `{{best_p50:chain:BTC}}` on perp-fees (uppercase asset codes) misses
* a stash whose only entry is keyed `BTC`, because the template
* resolver lowercases `chain` before lookup.
*/
function findChainEntry<T>(
stash: Record<string, T> | undefined,
chain: string,
): T | undefined {
if (!stash) return undefined;
const direct = stash[chain];
if (direct) return direct;
const lower = chain.toLowerCase();
for (const [k, v] of Object.entries(stash)) {
if (k.toLowerCase() === lower) return v;
}
return undefined;
}
function bestForChain(b: Benchmark, chain: string): ProviderResult | undefined {
return b.bestPerChain?.[chain];
return findChainEntry(b.bestPerChain, chain);
}
function worstForChain(b: Benchmark, chain: string): ProviderResult | undefined {
return b.worstPerChain?.[chain];
return findChainEntry(b.worstPerChain, chain);
}

export function renderTemplate(text: string, benchmark: Benchmark): string {
Expand All @@ -77,21 +98,20 @@ export function renderTemplate(text: string, benchmark: Benchmark): string {
CHAIN_TEMPLATE_RE,
(whole, keyword: string, chain: string) => {
const k = keyword.toLowerCase();
const chainKey = chain.toLowerCase();
if (k === "best_name") {
const lead = bestForChain(benchmark, chainKey);
const lead = bestForChain(benchmark, chain);
return lead ? lead.name : whole;
}
if (k === "best_p50") {
const lead = bestForChain(benchmark, chainKey);
const lead = bestForChain(benchmark, chain);
return lead ? fmtUnit(lead.ms.p50, benchmark.unit) : whole;
}
if (k === "worst_name") {
const trailer = worstForChain(benchmark, chainKey);
const trailer = worstForChain(benchmark, chain);
return trailer ? trailer.name : whole;
}
if (k === "worst_p50") {
const trailer = worstForChain(benchmark, chainKey);
const trailer = worstForChain(benchmark, chain);
return trailer ? fmtUnit(trailer.ms.p50, benchmark.unit) : whole;
}
return whole;
Expand Down Expand Up @@ -131,13 +151,40 @@ export function renderTemplate(text: string, benchmark: Benchmark): string {
}

/** Apply renderTemplate to every editorial field that supports it. The
* Benchmark object is mutated in place and returned for convenience. */
* Benchmark object is mutated in place and returned for convenience.
*
* Fields covered:
* - abstract, findings, seoIntro, faq, perChainExplainer: page body copy.
* - seoTitle, seoDescription: meta tags + RSC props serialized to the
* client. Without rendering these, a `{{best_name}}` token in
* `seo_description` survives into the response payload (and was
* visible in dev tools / view-source as raw template syntax). The
* bench page's generateMetadata also rendered against `b` directly,
* but anywhere else that read the field (the bench object passed
* to client components, /api/citable downstream consumers) got the
* raw token.
* - subtitle, methodology, disclaimer: same risk, smaller blast
* radius today but no reason to leave them unprocessed.
*/
export function renderBenchmarkText(benchmark: Benchmark): Benchmark {
benchmark.abstract = renderTemplate(benchmark.abstract, benchmark);
benchmark.findings = benchmark.findings.map((f) => renderTemplate(f, benchmark));
benchmark.methodology = benchmark.methodology.map((m) =>
renderTemplate(m, benchmark),
);
benchmark.subtitle = renderTemplate(benchmark.subtitle, benchmark);
if (benchmark.seoTitle) {
benchmark.seoTitle = renderTemplate(benchmark.seoTitle, benchmark);
}
if (benchmark.seoDescription) {
benchmark.seoDescription = renderTemplate(benchmark.seoDescription, benchmark);
}
if (benchmark.seoIntro) {
benchmark.seoIntro = renderTemplate(benchmark.seoIntro, benchmark);
}
if (benchmark.disclaimer) {
benchmark.disclaimer = renderTemplate(benchmark.disclaimer, benchmark);
}
if (benchmark.faq) {
benchmark.faq = benchmark.faq.map((item) => ({
q: renderTemplate(item.q, benchmark),
Expand Down
Loading
Loading