diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx
index c71076ae..488a6aa3 100644
--- a/src/app/benchmarks/[slug]/page.tsx
+++ b/src/app/benchmarks/[slug]/page.tsx
@@ -13,6 +13,7 @@ import { TimeSeriesChart } from "@/components/time-series-chart";
import { LedgerTable } from "@/components/ledger-table";
import { RegionGrid } from "@/components/region-grid";
import { BigNumber } from "@/components/big-number";
+import { ShareSection } from "@/components/share-section";
import { fmtUnit, unitSuffix, fmtValue } from "@/lib/format";
type Params = { slug: string };
@@ -205,45 +206,20 @@ export default async function BenchmarkPage({
- {/* Reproduce / cite — collapsed */}
+ {/* Share / export */}
{!isDraft && (
-
-
-
- Reproduce · cite · source
-
-
-
-
-
-
-
- BibTeX
-
-
-{`@misc{openchainbench-${benchmark.number},
- author = {{OpenChainBench}},
- title = {${benchmark.title}},
- year = {${new Date(benchmark.lastRunAt).getFullYear()}},
- howpublished = {\\url{https://openchainbench.xyz/benchmarks/${benchmark.slug}}},
- note = {Run on ${formatLastRun(benchmark.lastRunAt)}}
-}`}
-
-
-
-
+
+ )}
+
+ {/* Source code link — bottom of page */}
+ {!isDraft && (
+
+ Source code{" "}
+
+ {benchmark.source.replace("https://github.com/", "github.com/")}
+
+
+
)}
{/* Other benchmarks */}
diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx
new file mode 100644
index 00000000..d809eb08
--- /dev/null
+++ b/src/app/benchmarks/[slug]/share-card/route.tsx
@@ -0,0 +1,500 @@
+import { ImageResponse } from "next/og";
+import { getBenchmark } from "@/data/benchmarks";
+import { buildProviderColors } from "@/lib/series-colors";
+import { fmtUnit, fmtValue, unitSuffix } from "@/lib/format";
+
+export const runtime = "nodejs";
+
+const SIZE = { width: 1200, height: 630 };
+const PAPER = "#f8f3eb";
+const PAPER_SOFT = "#f3eee0";
+const INK = "#181614";
+const INK_SOFT = "#4a443c";
+const INK_MUTED = "#7a7166";
+const INK_FAINT = "#a59b87";
+const RULE = "rgba(24, 22, 20, 0.12)";
+const GOOD = "#3d6d3d";
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ slug: string }> }
+) {
+ const { slug } = await params;
+ const benchmark = await getBenchmark(slug);
+ if (!benchmark) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const url = new URL(request.url);
+ const template = (url.searchParams.get("template") ?? "ranking") as
+ | "ranking"
+ | "snapshot";
+
+ if (template === "snapshot") {
+ return renderSnapshot(benchmark);
+ }
+ return renderRanking(benchmark);
+}
+
+async function renderRanking(
+ benchmark: NonNullable>>
+) {
+ const sorted = [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50);
+ const colors = buildProviderColors(benchmark.results);
+ const maxP50 = Math.max(...sorted.map((r) => r.ms.p50)) || 1;
+
+ // Chart geometry
+ const chartHeight = 320;
+ const barAreaPaddingTop = 60;
+
+ return new ImageResponse(
+ (
+
+ {/* Header */}
+
+
+
+
+ {benchmark.category.toUpperCase()}
+
+
+ № {benchmark.number} · 24h
+
+
+
+ {benchmark.title}
+
+
+ Provider ranking by p50 · ascending. Lower is faster.
+
+
+
+ {/* Bars */}
+
+ {sorted.map((r) => {
+ const heightPx = Math.max(
+ 28,
+ (r.ms.p50 / maxP50) * chartHeight
+ );
+ const color = colors.get(r.slug) ?? INK_SOFT;
+ return (
+
+
+ {fmtValue(r.ms.p50, benchmark.unit)}
+
+ {unitSuffix(benchmark.unit).trim()}
+
+
+
+
+ {r.name}
+
+
+ p99 {fmtUnit(r.ms.p99, benchmark.unit)}
+
+
+ );
+ })}
+
+
+ {/* Footer */}
+
+ openchainbench.xyz
+
+ {benchmark.results.length} providers ·{" "}
+ {Math.round(benchmark.sampleSize).toLocaleString()} samples
+
+
+
+ ),
+ { ...SIZE }
+ );
+}
+
+async function renderSnapshot(
+ benchmark: NonNullable>>
+) {
+ const sorted = [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50);
+ const colors = buildProviderColors(benchmark.results);
+
+ const seriesList = sorted
+ .map((r) => ({
+ slug: r.slug,
+ name: r.name,
+ values: benchmark.extras.series24h[r.slug] ?? [],
+ color: colors.get(r.slug) ?? INK_SOFT,
+ p50: r.ms.p50,
+ }))
+ .filter((s) => s.values.length > 1);
+
+ // Chart geometry — internal SVG-style coordinate space
+ const chartW = 1060;
+ const chartH = 320;
+ const chartTop = 240;
+ const chartLeft = 70;
+
+ const all = seriesList.flatMap((s) => s.values);
+ const min = all.length ? Math.min(...all) : 0;
+ const max = all.length ? Math.max(...all) : 1;
+ const range = max - min || 1;
+ const maxLen = Math.max(...seriesList.map((s) => s.values.length), 1);
+
+ return new ImageResponse(
+ (
+
+ {/* Header */}
+
+
+
+
+ {benchmark.category.toUpperCase()}
+
+
+
+ {benchmark.title}
+
+
+ {benchmark.subtitle}
+
+
+
+ {/* Chart background */}
+
+
+ {/* Lines (rendered via SVG) */}
+
+
+ {/* Legend */}
+
+ {seriesList.map((s) => (
+
+
+
{s.name}
+
+ {fmtUnit(s.p50, benchmark.unit)}
+
+
+ ))}
+
+
+ {/* Footer */}
+
+ openchainbench.xyz
+
+ {Math.round(benchmark.sampleSize).toLocaleString()} samples · 24h
+
+
+
+ ),
+ { ...SIZE }
+ );
+}
diff --git a/src/components/share-section.tsx b/src/components/share-section.tsx
new file mode 100644
index 00000000..2d1784f2
--- /dev/null
+++ b/src/components/share-section.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useState } from "react";
+import { Download, ChevronDown } from "lucide-react";
+
+type Template = {
+ id: string;
+ label: string;
+ description: string;
+};
+
+const TEMPLATES: Template[] = [
+ {
+ id: "ranking",
+ label: "Ranking",
+ description: "Provider bars sorted by p50, with values and tail at p99.",
+ },
+ {
+ id: "snapshot",
+ label: "Snapshot",
+ description: "24-hour multi-line chart of every provider, with a legend.",
+ },
+];
+
+type Props = {
+ slug: string;
+ title: string;
+};
+
+export function ShareSection({ slug, title }: Props) {
+ const [activeId, setActiveId] = useState("ranking");
+
+ const cardSrc = (templateId: string) =>
+ `/benchmarks/${slug}/share-card?template=${templateId}`;
+
+ async function handleDownload(templateId: string) {
+ const url = cardSrc(templateId);
+ try {
+ const res = await fetch(url);
+ const blob = await res.blob();
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(blob);
+ a.download = `${slug}-${templateId}.png`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(a.href);
+ } catch (err) {
+ console.error("download failed", err);
+ }
+ }
+
+ return (
+
+
+
+ Share · export · embed
+
+
+
+
+
+ Pick a layout and download a 1200×630 PNG ready for Twitter, Reddit, LinkedIn or any OG-card embed. Same data, same colors as this dashboard.
+
+
+ {/* Tabs */}
+
+ {TEMPLATES.map((t) => (
+
+ ))}
+
+
+ {/* Active preview */}
+ {TEMPLATES.map((t) => {
+ if (t.id !== activeId) return null;
+ return (
+
+
{t.description}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
})
+
+
+
+
+ Open raw ↗
+
+
+
+ );
+ })}
+
+
+ );
+}