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 ( + <div + style={{ + display: "flex", + fontSize: 28, + fontStyle: "italic", + color: "#4a443c", + marginTop: 18, + maxWidth: 1080, + }} + > + {fallback} + </div> + ); + } + return ( + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + marginTop: 18, + maxWidth: 1080, + }} + > + <span + style={{ + fontSize: 30, + fontWeight: 700, + color: "#1c1a17", + backgroundColor: OG_CLAIM_MARKER, + padding: "4px 12px", + borderRadius: 6, + }} + > + {claim} + </span> + <span + style={{ + fontSize: 26, + fontStyle: "italic", + color: "#4a443c", + marginTop: 10, + }} + > + {qualifier} + </span> + </div> + ); +} + +/** + * 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 ( + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + marginTop: 18, + maxWidth: 1080, + }} + > + <span + style={{ + fontSize: 30, + fontWeight: 700, + color: "#1c1a17", + backgroundColor: OG_CLAIM_MARKER, + padding: "4px 12px", + borderRadius: 6, + }} + > + {lead} + </span> + {rest ? ( + <span + style={{ + fontSize: 26, + fontStyle: "italic", + color: "#4a443c", + marginTop: 10, + lineHeight: 1.4, + }} + > + {rest} + </span> + ) : null} + </div> + ); +} + +/** + * 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() }; +}