diff --git a/apps/docs/README.md b/apps/docs/README.md index 8e3ef66f..94bd3d11 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -83,15 +83,16 @@ from the same Markdown frontmatter used by the site; new pages receive cards aut metadata share the same image, title, description, and accessible image text. Canonical URLs and social image URLs always point to the published GitHub Pages site, including when previewing locally. -- `lib/og-image.tsx` defines the Bloxwap layout, accent colors, and typography. +- `lib/og-image.tsx` defines the GitHub-style card layout: a `section/Title` path headline, description, app icon, + a facts row with the wordmark, and the brand palette as a bottom bar. - `lib/brand.ts` vendors the official brand geometry from the Bloxwap monorepo's `packages/tokens/src/brand.ts`. Cards draw the wordmark from these SVG paths. Re-copy the geometry from the monorepo when the marks change. - `lib/social.ts` defines the published URL and metadata. Update it if the repository or public hostname changes. - `app/og/[...slug]/route.tsx` generates `/og/index.png`, `/og/docs.png`, and `/og/docs/.png` during the static build. -Cards use Nunito Black for headlines, Nunito Bold for descriptions, and Space Grotesk Bold for product and category labels. -The static Nunito faces come from the Bloxwap monorepo's `packages/og/assets/`; their license is included as -`public/fonts/Nunito-OG-OFL.txt`. The existing Space Grotesk face retains its `public/fonts/SpaceGrotesk-OFL.txt` notice. +Cards use Nunito Black for page titles and Nunito Bold for the path prefix, description, and facts. The static Nunito +faces come from the Bloxwap monorepo's `packages/og/assets/`; their license is included as +`public/fonts/Nunito-OG-OFL.txt`. The renderer uses bundled fonts and brand geometry locally; it needs no font CDN, API credentials, or image server after deployment. Generated PNGs live in `out/` and are included in the GitHub Pages artifact. diff --git a/apps/docs/app/og/[...slug]/route.tsx b/apps/docs/app/og/[...slug]/route.tsx index fabf0bf7..7cbe931e 100644 --- a/apps/docs/app/og/[...slug]/route.tsx +++ b/apps/docs/app/og/[...slug]/route.tsx @@ -14,24 +14,19 @@ export function generateStaticParams() { export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) { const path = `/og/${(await params).slug.join("/")}`; if (path === socialImagePath("/")) { - return createSocialImage({ - title: "Build on Hyperliquid.", - description: homeDescription, - category: "TYPESCRIPT · JAVASCRIPT", - home: true, - }); + return createSocialImage({ prefix: "bloxwap", title: "hyperliquid", description: homeDescription }); } const page = source.getPages().find((page) => socialImagePath(page.url) === path); if (!page) return new Response("Not found", { status: 404 }); return createSocialImage({ + prefix: page.url.startsWith("/docs/guides") + ? "guides" + : page.url.startsWith("/docs/reference") + ? "reference" + : "docs", title: page.data.title, description: page.data.description ?? homeDescription, - category: page.url.startsWith("/docs/guides") - ? "GUIDES" - : page.url.startsWith("/docs/reference") - ? "REFERENCE" - : "DOCUMENTATION", }); } diff --git a/apps/docs/lib/og-image.tsx b/apps/docs/lib/og-image.tsx index 20661e6b..6e07ae65 100644 --- a/apps/docs/lib/og-image.tsx +++ b/apps/docs/lib/og-image.tsx @@ -1,46 +1,46 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { ImageResponse } from "next/og"; -import { BRAND_GREEN, BRAND_WORDMARK, BRAND_X } from "@/lib/brand"; +import { BRAND_GREEN, BRAND_ICON, BRAND_WORDMARK, BRAND_X } from "@/lib/brand"; import { socialImageSize } from "@/lib/social"; -// The Bloxwap social card, ported from monorepo/packages/og (og-card.tsx and brand-mark.tsx) and -// laid out like the @bloxwap/chart docs cards: the drawn wordmark with a "· HYPERLIQUID" product -// label, a category top right, a 24px accent rail, and a Nunito Black headline anchored above the -// description. Colors are sRGB hex for the Satori/resvg renderer. +// The Bloxwap social card, laid out like a GitHub repository card: a "prefix/title" path headline +// and description on the left, the app icon as the avatar top right, a row of facts with the +// wordmark bottom right, and the brand palette as the language bar along the bottom edge. +// Colors are sRGB hex for the Satori/resvg renderer. const BRAND = { black: "#0A0A0A", text: "#F5F7FA", muted: "rgba(245,247,250,0.62)", - dot: "rgba(245,247,250,0.28)", - category: "#A1A1A1", }; -/** Accent palette in the monorepo's order; the seeded hash below indexes it. */ -const ACCENT_PALETTE = [BRAND_GREEN, "#35B5FF", "#B300FF", "#FF479C", "#FFFB38"] as const; +/** The brand palette in the monorepo's order, drawn as equal segments of the bottom bar. */ +const PALETTE = [BRAND_GREEN, "#35B5FF", "#B300FF", "#FF479C", "#FFFB38"] as const; const assets = Promise.all([ readFile(join(process.cwd(), "public/fonts/Nunito-Bold.ttf")), readFile(join(process.cwd(), "public/fonts/Nunito-Black.ttf")), - readFile(join(process.cwd(), "public/fonts/SpaceGrotesk-Bold.ttf")), ]); -/** Deterministic FNV-1a, as monorepo/packages/og/src/accent.ts: a page keeps its color. */ -function accentFor(seed: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < seed.length; i += 1) hash = Math.imul(hash ^ seed.charCodeAt(i), 0x01000193); - return ACCENT_PALETTE[(hash >>> 0) % ACCENT_PALETTE.length]; -} - -/** Headline size, tracking and leading step down as the title grows (monorepo HEADLINE_TIERS). */ +/** Headline size and leading step down as the title grows; the path prefix counts toward it. */ const HEADLINE_TIERS = [ - { max: 14, size: 148, tracking: -5, line: 0.94 }, - { max: 28, size: 116, tracking: -4, line: 0.95 }, - { max: 46, size: 92, tracking: -3, line: 0.96 }, - { max: 66, size: 72, tracking: -2, line: 0.98 }, - { max: Number.POSITIVE_INFINITY, size: 58, tracking: -1.5, line: 1 }, + { max: 24, size: 76, line: 1.08 }, + { max: 44, size: 64, line: 1.1 }, + { max: Number.POSITIVE_INFINITY, size: 54, line: 1.12 }, ] as const; +/** Lucide icon paths (24px grid, 2px round strokes) for the facts row. */ +const ICONS = { + code: ["m18 16 4-4-4-4", "m6 8-4 4 4 4", "m14.5 4-5 16"], + scale: [ + "M12 3v18", + "m19 8 3 8a5 5 0 0 1-6 0zV7", + "M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1", + "m5 8 3 8a5 5 0 0 1-6 0zV7", + "M7 21h10", + ], +} as const; + /** Clamp by code point so a surrogate pair is never split. */ function clamp(text: string, max: number): string { const points = [...text.trim()]; @@ -96,130 +96,140 @@ function Wordmark({ capHeight }: { capHeight: number }) { ); } +/** The app icon (bloxwap-icon-green.svg) in the avatar slot. */ +function AppIcon({ size }: { size: number }) { + return ( + + + + {BRAND_ICON.paths.map((d) => ( + + ))} + + + ); +} + +function Fact({ icon, label }: { icon: keyof typeof ICONS; label: string }) { + return ( +
+ + + {ICONS[icon].map((d) => ( + + ))} + + + {label} +
+ ); +} + type Card = { + /** Muted path segment before the slash, e.g. "bloxwap" or "guides". */ + prefix: string; title: string; description: string; - /** Top-right category, e.g. "GUIDES". */ - category: string; - /** The landing card: green accent and the two-line landing headline. */ - home?: boolean; }; /** Rendered at build time; GitHub Pages serves the resulting PNG without a server. */ -export async function createSocialImage({ title, description, category, home = false }: Card) { - const [bold, black, display] = await assets; - const headline = clamp(title, 90); - const tier = HEADLINE_TIERS.find((t) => headline.length <= t.max) ?? HEADLINE_TIERS[HEADLINE_TIERS.length - 1]; - const accent = home ? BRAND_GREEN : accentFor(headline); - const body = clamp(description, 180); +export async function createSocialImage({ prefix, title, description }: Card) { + const [bold, black] = await assets; + const headline = clamp(title, 72); + const tier = + HEADLINE_TIERS.find((t) => prefix.length + 1 + headline.length <= t.max) ?? + HEADLINE_TIERS[HEADLINE_TIERS.length - 1]; + // Satori wraps flex items, not text runs, so each word is its own item after the prefix. + const words = headline.split(/\s+/); return new ImageResponse(
-
-
- {/* Eyebrow: the Space Grotesk caps sit on the wordmark's baseline (monorepo BrandEyebrow). */} -
- +
+
+
+ {prefix}/ + {words.map((word, i) => ( + + {word} + + ))} +
- · - HYPERLIQUID + {clamp(description, 150)}
- - {category} - +
-
-
- {/* Satori lays out a fragment's children on one row, so the home lines are separate divs. */} - {home - ? ["Build on", "Hyperliquid."].map((line) => ( -
- {line} -
- )) - : headline} -
-
- {body} +
+
+
+ +
+
+
+ {PALETTE.map((color) => ( +
+ ))} +
, { ...socialImageSize, fonts: [ { name: "Nunito", data: bold, weight: 700, style: "normal" }, { name: "Nunito", data: black, weight: 900, style: "normal" }, - { name: "Space Grotesk", data: display, weight: 700, style: "normal" }, ], }, );