diff --git a/src/app/benchmarks/[slug]/[chain]/opengraph-image.tsx b/src/app/benchmarks/[slug]/[chain]/opengraph-image.tsx
index 6400afa6..328f0d06 100644
--- a/src/app/benchmarks/[slug]/[chain]/opengraph-image.tsx
+++ b/src/app/benchmarks/[slug]/[chain]/opengraph-image.tsx
@@ -1,6 +1,7 @@
import { ImageResponse } from "next/og";
import { getBenchmark } from "@/data/benchmarks";
-import { headlineSentence, leader } from "@/lib/citation";
+import { leader } from "@/lib/citation";
+import { OgClaimSentence } from "@/lib/og-claim";
import { fmtUnit } from "@/lib/format";
import { CATEGORY_COLOR } from "@/lib/category-colors";
import { loadBenchmark } from "@/lib/spec";
@@ -33,7 +34,6 @@ export default async function OG({
const headline = top
? `${top.name} leads at ${fmtUnit(top.value, b.unit)}`
: "Awaiting first run";
- const sentence = headlineSentence(b);
const catColor = CATEGORY_COLOR[b.category] ?? "#7a2e1f";
const titleText = `${b.title} on ${chainLabel}`;
@@ -94,18 +94,7 @@ export default async function OG({
>
{titleText}
-
- {top ? sentence : b.subtitle}
-
+
matchesChainSlug(c.value, chainId))
@@ -123,18 +123,7 @@ export default async function OG({
>
{titleText}
-
- {top ? sentence : b.subtitle}
-
+
matchesChainSlug(c.value, chainId))
@@ -133,18 +133,7 @@ export default async function TwitterImage({
>
{titleText}
-
- {top ? sentence : b.subtitle}
-
+
{p.name}
-
- {appearancesLine} on OpenChainBench
-
+ 0
+ ? `#1 on ${p.wins} of ${p.appearances.length} benchmark${p.appearances.length === 1 ? "" : "s"}`
+ : appearancesLine
+ }
+ rest={p.wins > 0 ? "measured live on OpenChainBench" : "on OpenChainBench"}
+ />
, { ...size });
const title = report.title;
+ // Lead clause of the hero finding is the card's highlighted line; the
+ // remainder keeps the previous ~140 character budget.
+ const finding = (() => {
+ const { lead, rest } = splitLead(report.heroFinding);
+ const room = Math.max(0, 140 - lead.length);
+ return {
+ lead,
+ rest: rest.length > room ? rest.slice(0, Math.max(0, room - 3)).trimEnd() + "..." : rest,
+ };
+ })();
const fontSize = title.length > 60 ? 58 : title.length > 40 ? 68 : 80;
return new ImageResponse(
@@ -83,21 +94,7 @@ export default async function OG({
>
{title}
-
- {report.heroFinding.length > 140
- ? report.heroFinding.slice(0, 137) + "..."
- : report.heroFinding}
-
+
."). `claim` is
+ * empty when there is no leader to assert. Joined with one space they
+ * are exactly `headlineSentence`. */
+export function headlineParts(b: Benchmark): { claim: string; rest: string } {
if (b.dataConfidence === "insufficient") {
- return `${b.title}. Insufficient data to assert a leader.`;
+ return { claim: "", rest: `${b.title}. Insufficient data to assert a leader.` };
}
const top = leader(b);
- if (!top) return `${b.title}. Awaiting first run.`;
+ if (!top) return { claim: "", rest: `${b.title}. Awaiting first run.` };
const value = fmtUnit(top.value, b.unit);
const verb = b.higherIsBetter ? "leads" : "posts the lowest";
- return `${top.name} ${verb} ${b.metric.toLowerCase()} at ${value} ${windowSuffix(b.unit)} on ${b.title}.`;
+ return {
+ claim: `${top.name} ${verb} ${b.metric.toLowerCase()} at ${value}`,
+ rest: `${windowSuffix(b.unit)} on ${b.title}.`,
+ };
}
/** Pasteable attribution string. Standard convention: "
Source: OpenChainBench (url)". */
diff --git a/src/lib/og-claim.tsx b/src/lib/og-claim.tsx
new file mode 100644
index 00000000..59fe5261
--- /dev/null
+++ b/src/lib/og-claim.tsx
@@ -0,0 +1,152 @@
+import type { Benchmark } from "@/types/benchmark";
+import { headlineParts } from "@/lib/citation";
+
+/** Marker colour behind the leader claim on the social cards. Warm
+ * orange on the cream card ground: readable at thumbnail size, and not
+ * a category colour, so it means "the number" on every bench alike. */
+export const OG_CLAIM_MARKER = "#ffb257";
+
+/**
+ * The headline sentence for OG / Twitter cards, with the leader claim
+ * ("GNS posts the lowest p/f ratio at 1.559x") run through a highlighter
+ * and the qualifier ("(p50, 24h) on .") underneath in the muted
+ * italic the cards already use. Satori lays each child span out as a
+ * flex item, so the claim sits on its own line and wraps within itself
+ * when it is long. Falls back to the subtitle when there is no leader.
+ */
+export function OgClaimSentence({
+ benchmark,
+ fallback,
+}: {
+ benchmark: Benchmark;
+ fallback: string;
+}) {
+ const { claim, rest } = headlineParts(benchmark);
+ // The title is already the card's headline; keep only the window
+ // qualifier ("(p50, 24h)") under the claim instead of repeating it.
+ const qualifier = rest.replace(` on ${benchmark.title}.`, "");
+ if (!claim) {
+ return (
+
+ {fallback}
+
+ );
+ }
+ return (
+
+
+ {claim}
+
+
+ {qualifier}
+
+
+ );
+}
+
+/**
+ * Generic form for cards whose body is free text (report hero finding,
+ * product line): `lead` goes through the highlighter, `rest` follows in
+ * the muted italic. Same marker, same sizes as the bench claim.
+ */
+export function OgHighlight({ lead, rest }: { lead: string; rest?: string }) {
+ return (
+
+
+ {lead}
+
+ {rest ? (
+
+ {rest}
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Split a finding into a short lead clause and the remainder: cut at the
+ * first ", " / ". " / "; " / " — " boundary at or after `min` chars, and
+ * never past `max`. Text with no usable boundary is all lead when short,
+ * else cut at the last space before `max`.
+ */
+export function splitLead(
+ text: string,
+ { min = 24, max = 84 }: { min?: number; max?: number } = {},
+): { lead: string; rest: string } {
+ const t = text.trim();
+ const re = /,\s|\.\s|;\s|\s[—–-]\s/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(t))) {
+ if (m.index > max) break;
+ if (m.index >= min) {
+ return {
+ lead: t.slice(0, m.index).trim(),
+ rest: t.slice(m.index + m[0].length).trim(),
+ };
+ }
+ }
+ if (t.length <= max) return { lead: t, rest: "" };
+ const cut = t.lastIndexOf(" ", max);
+ return cut > min
+ ? { lead: t.slice(0, cut).trim(), rest: t.slice(cut).trim() }
+ : { lead: t.slice(0, max).trim(), rest: t.slice(max).trim() };
+}