diff --git a/README.md b/README.md
index 5c33fff35..f716f68a3 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,9 @@
components.** It server-renders every page and component to real HTML,
needs no build step or bundler, and runs on Node 24+ or Bun.
-Nothing is hidden from your agent. The framework ships in `node_modules` as
-plain JavaScript, so an agent opens the router or the renderer it is calling
+You get production-ready architecture from your very first prompt, and none of
+it is hidden from your agent. The framework ships in `node_modules` as plain
+JavaScript, so an agent opens the router or the renderer it is calling
instead of recalling an API from training data, and your app code is served to
the browser exactly as written. Any model debugs the running app against the
real source, with no single blessed model, on the web components and standard
diff --git a/website/app/layout.ts b/website/app/layout.ts
index 610d177a4..011866707 100644
--- a/website/app/layout.ts
+++ b/website/app/layout.ts
@@ -43,7 +43,7 @@ const TITLE = 'WebJs: Full-stack web components framework, no build step';
// The true version of that idea is about LOCATION, not volume: the source sits
// in the app's own node_modules at the installed version, so an agent opens
// the file it needs instead of recalling an API from training data.
-const DESCRIPTION = 'A full-stack web components framework with no build step, built for AI agents. Server-rendered pages, server actions, and file-based routing. Node 24+ or Bun.';
+const DESCRIPTION = 'An AI-first full-stack JavaScript web components framework with no build step. Production-ready architecture from your very first prompt. Node 24+ or Bun.';
const NAV = [
{ label: 'Docs', href: DOCS_START_PATH, ext: false },
diff --git a/website/app/page.ts b/website/app/page.ts
index 016ba304d..69df58a60 100644
--- a/website/app/page.ts
+++ b/website/app/page.ts
@@ -449,7 +449,7 @@ export default function LandingPage() {
title's longest line from 768 up. Re-measure all of them before
touching any of the three numbers; they are chosen against the wrap
points, not picked. -->
-
+
Conventions your agent follows. Architecture you still own.
- WebJs is a full-stack web components framework with no build step.
- Nothing is hidden from your agent, or from you.
+ WebJs is an AI-first full-stack JavaScript web components framework with no build step.
+ You get production-ready architecture from your very first prompt.
$ curl localhost:5001/components/counter.ts
diff --git a/website/public/input.css b/website/public/input.css
index 983e96740..5062cbe84 100644
--- a/website/public/input.css
+++ b/website/public/input.css
@@ -78,6 +78,19 @@
--text-section: clamp(20px, 3vw, 26px);
--text-display: clamp(2.9rem, 1.55rem + 4.5vw, 5.5rem);
+ /* The marketing HERO h1, shared by /, /what-is-webjs and /why-webjs so the
+ three read as one page family. It is a SEPARATE step from --text-display
+ (which the /brand h1 still uses) because a hero headline and a page title
+ want different floors: this curve's floor is 1.5rem where --text-display's
+ is 2.9rem, and 2.9rem is reached by the preferred term at a 476px viewport,
+ so every phone rendered one identical size and the type stopped responding
+ exactly where it mattered.
+
+ It lived inline on the home page until the three heroes were aligned. The
+ numbers are solved against WRAP POINTS rather than picked, so the long
+ comment above the home h1 in app/page.ts is the reference for what each one
+ costs. Re-measure ALL THREE heroes before touching any of them now. */
+ --text-hero-h1: clamp(1.5rem, 0.53rem + 5.5vw, 4.125rem);
--text-h2: clamp(1.7rem, 1.2rem + 1.4vw, 2.5rem);
/* Two lede steps, because the landing hero and a page lede are not the same
role and were fighting over one number. The hero wants to be half the
diff --git a/website/public/og-what.png b/website/public/og-what.png
new file mode 100644
index 000000000..0b0da4ac7
Binary files /dev/null and b/website/public/og-what.png differ
diff --git a/website/public/og-why.png b/website/public/og-why.png
index efc60e721..48c979eeb 100644
Binary files a/website/public/og-why.png and b/website/public/og-why.png differ
diff --git a/website/public/og.png b/website/public/og.png
index f1540ce32..2259acd13 100644
Binary files a/website/public/og.png and b/website/public/og.png differ
diff --git a/website/scripts/generate-og-what.mjs b/website/scripts/generate-og-what.mjs
new file mode 100644
index 000000000..9b57552d8
--- /dev/null
+++ b/website/scripts/generate-og-what.mjs
@@ -0,0 +1,67 @@
+/**
+ * Regenerate public/og-what.png, the 1200x630 social card for /what-is-webjs.
+ *
+ * node scripts/generate-og-what.mjs
+ *
+ * That page had no card of its own and unfurled with the site-wide og.png,
+ * whose headline is the product tagline. It is the page built to answer one
+ * question (exact-match title, h1 and slug, a definition in the first 160
+ * characters, a visible FAQ backing FAQPage JSON-LD), so it is the likeliest of
+ * the three to be shared AS an answer, and it was the one arriving under a card
+ * that answered something else.
+ *
+ * The shell (palette, faces, lockup, top row, panels, footer, render) lives in
+ * scripts/lib/og-card.mjs and is shared with the other two cards. Only this
+ * card's own content is here.
+ */
+import { resolve } from 'node:path';
+import { T, PANEL_CSS, panels, renderCard } from './lib/og-card.mjs';
+
+// The page's own h1, which is also its and its URL slug. The whole
+// point of the page is that those three match the question, so the card has no
+// business paraphrasing it.
+const TITLE = 'What is WebJs?';
+
+// The definition sentence from the page, which is also the first 160 characters
+// of its meta description, so the card and the search result agree.
+const SUB = 'An AI-first full-stack JavaScript web framework built on web components, server-rendered with no build step.';
+
+// The definition above says what it IS. These say what that MEANS, and they
+// carry the feature terms the site-wide meta description gave up to fit.
+const FACTS = [
+ {
+ label: 'Every page',
+ text: 'Server-rendered to real HTML. It reads, navigates and submits before a single script runs.',
+ },
+ {
+ label: 'Every component',
+ text: 'A native custom element. Server actions and file-based routing, on Node 24+ or Bun.',
+ },
+];
+
+const TAGS = 'WEB COMPONENTS · SSR · NO BUILD STEP';
+
+await renderCard({
+ out: resolve(process.argv[2] || 'public/og-what.png'),
+ fit: { from: 62, to: 30 },
+ css: `${PANEL_CSS}
+ .frame{ padding:56px 76px; }
+ h1{
+ font-family:'Inter Tight',sans-serif; font-weight:800;
+ /* Starts larger than the other two because this headline is four words and
+ has room the others do not. The fit pass still owns the final value. */
+ font-size:62px; line-height:1.05; letter-spacing:-0.035em;
+ max-width:20ch;
+ }
+ .sub{
+ font-size:22px; line-height:1.4; color:${T.fgMuted}; font-weight:400;
+ max-width:40ch; margin-top:-12px;
+ }
+ .sub b{ color:${T.fg}; font-weight:600; }`,
+ body: `
+
${TITLE}
+
${SUB}
+ ${panels(FACTS)}
+
`,
+ tags: TAGS,
+});
diff --git a/website/scripts/generate-og-why.mjs b/website/scripts/generate-og-why.mjs
index 999fd6de2..910dad2a4 100644
--- a/website/scripts/generate-og-why.mjs
+++ b/website/scripts/generate-og-why.mjs
@@ -1,84 +1,55 @@
/**
- * Regenerate public/og-why.png, the 1200x630 social card for the /why pitch page.
- *
- * A sibling of scripts/generate-og.mjs (the home-page card): same dark, on-brand
- * look and the same render pipeline (headless Chromium at 2x, downscaled to an
- * exact 1200x630 with ImageMagick for crisp text), but with the pitch page's
- * AI-era headline and tags. Run it whenever the /why headline or look changes:
+ * Regenerate public/og-why.png, the 1200x630 social card for /why-webjs.
*
* node scripts/generate-og-why.mjs
*
- * Prerequisites: ImageMagick (the `magick` binary) on PATH. Playwright is a
- * website devDependency and resolves from node_modules. The tokens below are
- * copied from the :root[data-theme='dark'] block in app/layout.ts so the card
- * and the site stay in lockstep.
+ * The shell (palette, faces, lockup, top row, panels, footer, render) lives in
+ * scripts/lib/og-card.mjs and is shared with the other two cards. Only this
+ * card's own content is here.
*/
-import { chromium } from 'playwright';
-import { execFileSync } from 'node:child_process';
-import { mkdtempSync, rmSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join, resolve } from 'node:path';
+import { resolve } from 'node:path';
+import { T, PANEL_CSS, panels, renderCard } from './lib/og-card.mjs';
-const OUT = resolve(process.argv[2] || 'public/og-why.png');
+// Verbatim from the page's own h1, so a shared link and the page it opens say
+// the same sentence.
+const TITLE = 'The framework your AI agent already understands';
-// Dark-theme tokens, copied from app/layout.ts. An OG card is not
-// theme-adaptive (social unfurlers render one static image), so it carries the
-// dark surfaces and the dark navbar logo mark.
-const T = {
- bg: 'oklch(0 0 0)',
- bgDeep: 'oklch(0.135 0 0)',
- fg: 'oklch(0.96 0 0)',
- fgMuted: 'oklch(0.74 0 0)',
- fgSubtle: 'oklch(0.62 0 0)',
- accent: 'oklch(0.7 0.16 52)',
- accentLive: 'oklch(0.63 0.17 50)',
- border: 'oklch(0.32 0 0 / 0.9)',
- logoFrom: 'oklch(0.8 0.16 58)',
- logoTo: 'oklch(0.62 0.18 44)',
-};
+// This card used to say the framework was one an agent "can read end to end",
+// and that the agent "reads the whole framework and fits it into context".
+// Both are false and the repo already knew it: the comment above DESCRIPTION in
+// app/layout.ts records that packages/core/src alone is 23,465 lines and core
+// plus server is 50,511, so nothing reads it end to end and none of it fits in
+// a context window. The claim outlived the section it was written for, and a
+// generated card is exactly where that survives unreviewed.
+//
+// The true version of the idea is about LOCATION rather than volume, which is
+// what the page itself has said all along: the source sits in the app's own
+// node_modules at the installed version, so the agent opens the file it is
+// calling instead of recalling an API from training data. That is also the
+// stronger claim, because it holds no matter how large the framework grows.
+const SUB = 'A full-stack JavaScript framework with no build step, so nothing is hidden from your agent.';
-const html = `
-
-
-
-
-
-
-
-
-
webjs
-
Built for the AI era
-
-
-
The framework your AI agent already understands
-
A full-stack JavaScript framework your AI agent can read end to end.
-
-
-
01 Framework source
-
No build, node_modules holds plain JS. The agent reads the whole framework and fits it into context.
-
-
-
02 Your app code
-
Served to the browser as written. The agent debugs the running app against the real source, not a bundle.
-
-
-
-
-
-
-
NO TRAINING DATA · NO BUNDLER · ANY MODEL
-
github.com/webjsdev/webjs
-
-
-
-`;
-
-const tmp = mkdtempSync(join(tmpdir(), 'webjs-og-why-'));
-const big = join(tmp, 'og-2x.png');
-
-const browser = await chromium.launch();
-try {
- const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 2 });
- await page.setContent(html, { waitUntil: 'networkidle' });
- await page.evaluate(() => document.fonts.ready);
- await page.screenshot({ path: big, clip: { x: 0, y: 0, width: 1200, height: 630 } });
-} finally {
- await browser.close();
-}
-
-// Downscale the 2400x1260 capture to an exact 1200x630 for crisp text, strip
-// metadata, and use max PNG compression (PNG is the safe og:image format for
-// every social unfurler, and the gradient-heavy card bands under lossy codecs).
-execFileSync('magick', [big, '-resize', '1200x630', '-strip', '-define', 'png:compression-level=9', OUT], { stdio: 'inherit' });
-rmSync(tmp, { recursive: true, force: true });
-console.log('wrote', OUT);
+ .sub b{ color:${T.fg}; font-weight:600; }`,
+ body: `
+
${TITLE}
+
${SUB}
+ ${panels(FACTS)}
+
`,
+ tags: TAGS,
+});
diff --git a/website/scripts/generate-og.mjs b/website/scripts/generate-og.mjs
index 6a4fcfeaa..656af6993 100644
--- a/website/scripts/generate-og.mjs
+++ b/website/scripts/generate-og.mjs
@@ -1,142 +1,77 @@
/**
- * Regenerate public/og.png, the 1200x630 social card.
+ * Regenerate public/og.png, the site-wide 1200x630 social card.
*
- * Manual dev tool, not part of the build or deploy. It renders an on-brand
- * dark card with headless Chromium (Playwright) at 2x, then downscales to an
- * exact 1200x630 with ImageMagick for crisp text. Run it whenever the headline
- * or look changes:
+ * Manual dev tool, not part of the build or deploy. Run it whenever the
+ * headline, the tagline or the palette changes:
*
* node scripts/generate-og.mjs
*
- * Prerequisites: ImageMagick (the `magick` binary) on PATH. Playwright is a
- * website devDependency (shared with the browser-test toolchain) and resolves
- * from node_modules. ImageMagick is the only external, non-npm tool. The card
- * mirrors the dark-theme design tokens declared in
- * app/layout.ts
- * (background, foreground, accent, the warm accent glow) and the hero
- * headline, so a regenerated card always matches the live site.
+ * The shell (palette, faces, lockup, top row, panels, footer, render) lives in
+ * scripts/lib/og-card.mjs and is shared with the other two cards. Only this
+ * card's own content is here. See that file for why the cards are light while
+ * the site is dark-first.
*/
-import { chromium } from 'playwright';
-import { execFileSync } from 'node:child_process';
-import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join, resolve } from 'node:path';
+import { resolve } from 'node:path';
+import { T, PANEL_CSS, panels, renderCard } from './lib/og-card.mjs';
-const OUT = resolve(process.argv[2] || 'public/og.png');
-// The card carries the REAL lockup file rather than a redrawn copy, for the
-// same reason the site does: a redraw is how the logo drifted once already.
-// Inlined rather than so the card render has no file:// fetch to race.
-const LOCKUP_SVG = readFileSync(resolve('public/brand/webjs-lockup-on-dark.svg'), 'utf8');
+// The promise, verbatim from the homepage hero and the site-wide meta
+// description. One string on every surface, so a shared link and the page it
+// opens say the same sentence.
+const TITLE = 'Production-ready architecture from your very first prompt';
+// Accented through the first two words only. The whole headline in accent
+// competes with the lockup above it, and accenting the tail buries the
+// differentiating claim in the colour the eye reaches last.
+const TITLE_HTML = TITLE.replace('Production-ready', 'Production-ready');
-// Dark-theme tokens, copied from the :root[data-theme='dark'] block in
-// app/layout.ts so the card and the site stay in lockstep (pure-black
-// surfaces, warm hue-52 accent).
-const T = {
- bg: 'oklch(0 0 0)',
- bgDeep: 'oklch(0.135 0 0)',
- fg: 'oklch(0.96 0 0)',
- fgMuted: 'oklch(0.74 0 0)',
- fgSubtle: 'oklch(0.62 0 0)',
- accent: 'oklch(0.78 0.18 58)',
- accentLive: 'oklch(0.78 0.18 58)',
- border: 'oklch(0.32 0 0 / 0.9)',
- // The logo mark stops, copied from the dark-theme --logo-from/--logo-to in
- // app/layout.ts. An OG card is not theme-adaptive (social unfurlers render
- // one static image), so the dark card carries the DARK navbar mark.
- logoFrom: 'oklch(0.8 0.16 58)',
- logoTo: 'oklch(0.62 0.18 44)',
-};
+// What the thing IS, under what it PROMISES. The lockup already says the name,
+// so the sentence opens on the category instead of repeating it.
+const SUB = 'An AI-first full-stack JavaScript web components framework with no build step.';
-const html0 = `
-
-
-
-
-
-
-
-
__LOCKUP__
-
-
The web framework for AI agents
-
A full-stack framework built on web components, SSR, and progressive enhancement, with zero build step. Standards that outlast frameworks.
-
-
-
-
-
AI-FIRST · WEB-COMPONENTS-FIRST · NO BUILD
-
github.com/webjsdev/webjs
-
-
-
-`;
-const html = html0.replace('__LOCKUP__', LOCKUP_SVG);
-
-const tmp = mkdtempSync(join(tmpdir(), 'webjs-og-'));
-const big = join(tmp, 'og-2x.png');
-
-const browser = await chromium.launch();
-try {
- const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 2 });
- await page.setContent(html, { waitUntil: 'networkidle' });
- await page.evaluate(() => document.fonts.ready);
- await page.screenshot({ path: big, clip: { x: 0, y: 0, width: 1200, height: 630 } });
-} finally {
- await browser.close();
-}
-
-// Downscale the 2400x1260 capture to an exact 1200x630 for crisp text, and
-// losslessly optimize: strip metadata and use max PNG compression. Lossy
-// quantization / WebP are deliberately avoided here: the card is gradient-heavy
-// (low color counts band the soft radials) and PNG is the safe og:image format
-// for every social unfurler.
-execFileSync('magick', [big, '-resize', '1200x630', '-strip', '-define', 'png:compression-level=9', OUT], { stdio: 'inherit' });
-rmSync(tmp, { recursive: true, force: true });
-console.log('wrote', OUT);
+ .sub b{ color:${T.fg}; font-weight:600; }`,
+ body: `
+
${TITLE_HTML}
+
${SUB}
+ ${panels(FACTS)}
+
`,
+ tags: TAGS,
+});
diff --git a/website/scripts/lib/og-card.mjs b/website/scripts/lib/og-card.mjs
new file mode 100644
index 000000000..74dd7d45a
--- /dev/null
+++ b/website/scripts/lib/og-card.mjs
@@ -0,0 +1,244 @@
+/**
+ * Shared pipeline for the 1200x630 social cards.
+ *
+ * There are three of them now (the home card, /what-is-webjs and /why-webjs),
+ * and they differ only in their content and their middle block. Everything
+ * around that is identical: the palette, the inlined faces, the lockup, the
+ * frame, the footer, and the render itself. Three copies of that was the point
+ * where a fix stopped landing on every card, which is exactly how the /why card
+ * ended up carrying a retired logo and a claim the site had already corrected.
+ *
+ * So the SHELL lives here and each card owns only its own middle. This is not a
+ * card framework: a card that wants a different structure writes its own CSS
+ * and passes it in, which is what the /why card does for its two fact panels.
+ *
+ * The cards are LIGHT while the site is dark-first, and that is deliberate. A
+ * social card is a static image with no theme to follow, rendered once and then
+ * read inside someone else's surface: an X timeline, a Slack unfurl, an
+ * iMessage bubble, a LinkedIn feed. Those surfaces are overwhelmingly light,
+ * and a near-black card sits in them as a hole rather than as a card.
+ *
+ * Prerequisites: ImageMagick (the `magick` binary) on PATH. Playwright is a
+ * website devDependency and resolves from node_modules.
+ */
+import { chromium } from 'playwright';
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+
+/**
+ * The LIGHT half of the light-dark() token pairs in app/layout.ts, resolved
+ * here because a static PNG has no light-dark() to resolve. Keep in lockstep
+ * with that block.
+ */
+export const T = {
+ bg: 'oklch(0.985 0.008 75)',
+ bgSubtle: 'oklch(0.96 0.008 75)',
+ fg: 'oklch(0.20 0.018 60)',
+ fgMuted: 'oklch(0.44 0.02 60)',
+ fgSubtle: 'oklch(0.50 0.02 65)',
+ border: 'oklch(0.88 0.012 70)',
+ accent: 'oklch(0.54 0.16 52)',
+};
+
+// Inlined as data URIs rather than fetched from Google Fonts, so a render has
+// no network to fail and cannot silently ship a card set in a system fallback
+// face. Same three files the site preloads, so a card and a page cannot show
+// different type. Each is one variable file covering every weight.
+const fontDataUri = (file) =>
+ `data:font/woff2;base64,${readFileSync(resolve('public/fonts', file)).toString('base64')}`;
+
+export const FONT_FACES = `
+ @font-face{font-family:'Inter Tight';font-weight:100 900;src:url('${fontDataUri('inter-tight.woff2')}') format('woff2');}
+ @font-face{font-family:'Inter';font-weight:100 900;src:url('${fontDataUri('inter.woff2')}') format('woff2');}
+ @font-face{font-family:'JetBrains Mono';font-weight:100 800;src:url('${fontDataUri('jetbrains-mono.woff2')}') format('woff2');}
+`;
+
+// The REAL lockup file rather than a redrawn copy, for the same reason the site
+// uses it: a redraw is how the logo drifted once already, and a card is where
+// nobody notices, because it is generated once and then only ever seen inside
+// somebody else's timeline. Inlined rather than so the render has no
+// file:// fetch to race.
+export const LOCKUP = readFileSync(resolve('public/brand/webjs-lockup-on-light.svg'), 'utf8');
+
+/** The frame, the accent bar and the footer, shared by every card. */
+export const BASE_CSS = `
+ *{ margin:0; padding:0; box-sizing:border-box; }
+ html,body{ width:1200px; height:630px; }
+ body{
+ font-family:'Inter',system-ui,sans-serif;
+ background:${T.bg};
+ color:${T.fg};
+ position:relative;
+ overflow:hidden;
+ }
+ /* The one piece of colour that survives a thumbnail. Scaled into a timeline
+ the headline is unreadable but the bar still reads as the brand's orange,
+ which is what makes a card recognisable before it is legible. */
+ .bar{ position:absolute; left:0; top:0; width:100%; height:10px; background:${T.accent}; }
+ .frame{
+ position:relative; z-index:1;
+ width:100%; height:100%;
+ display:flex; flex-direction:column;
+ }
+ .top{ display:flex; align-items:center; justify-content:space-between; }
+ /* The middle block is spaced from BOTH neighbours here rather than in each
+ card. When the cards owned it, buying clearance above the footer was paid
+ for out of the gap under the lockup, and the headline collided with it. */
+ .mid{
+ flex:1; display:flex; flex-direction:column; justify-content:center;
+ gap:26px; padding-top:30px;
+ }
+ .brand{ display:flex; align-items:center; }
+ .brand svg{ height:40px; width:auto; display:block; }
+ /* The kicker sits opposite the lockup on every card. It is the one line that
+ says what the whole family is FOR, so it is not a per-card decision. */
+ .kicker{
+ font-family:'JetBrains Mono',monospace; font-weight:500;
+ font-size:16px; letter-spacing:0.18em; text-transform:uppercase;
+ color:${T.accent};
+ }
+ .accent{ color:${T.accent}; }
+ .foot{
+ display:flex; align-items:center; justify-content:space-between;
+ font-family:'JetBrains Mono',monospace; font-weight:500;
+ font-size:15px; letter-spacing:0.04em; color:${T.fgSubtle};
+ }
+ .foot .tags{ display:flex; align-items:center; gap:10px; text-transform:uppercase; }
+ .dot{ width:7px; height:7px; border-radius:50%; background:${T.accent}; }
+ /* Both sides, not just the one. With margin-bottom alone the rule kept its
+ distance from the footer text and none at all from whatever the middle
+ block ended on, so a card whose middle ends in a bordered panel had two
+ lines nearly touching. The middle block is flex:1, so this space is taken
+ from it rather than added to the card, and the fit pass absorbs the
+ difference. */
+ hr{ border:0; border-top:1px solid ${T.border}; margin-top:28px; margin-bottom:24px; }
+`;
+
+/**
+ * Two fact panels, the structure that fills the lower half of a card.
+ *
+ * A card is 1200 wide and a headline capped at a readable measure uses maybe
+ * half of it, so without something down here the right side reads as empty
+ * rather than as space. Each panel pairs a mono eyebrow with one short line, so
+ * the card stays scannable at the size a timeline actually renders it.
+ *
+ * Opt in by spreading PANEL_CSS into the card's own css and calling panels().
+ * It is shared rather than built into the shell because a card is free not to
+ * have them.
+ */
+export const PANEL_CSS = `
+ .cards{ display:grid; grid-template-columns:1fr 1fr; gap:20px; }
+ .card{
+ border:1px solid ${T.border}; border-radius:18px;
+ background:${T.bgSubtle};
+ padding:24px 26px; display:flex; flex-direction:column; gap:12px;
+ }
+ .clabel{
+ font-family:'JetBrains Mono',monospace; font-weight:500;
+ font-size:14px; letter-spacing:0.1em; text-transform:uppercase; color:${T.accent};
+ }
+ .ctext{ font-size:21px; line-height:1.42; color:${T.fg}; font-weight:400; }
+ .ctext .q{ color:${T.fgMuted}; }
+ .mono{ font-family:'JetBrains Mono',monospace; font-size:0.86em; color:${T.fgMuted}; }
+`;
+
+/** @param {{label:string,text:string}[]} facts */
+export const panels = (facts) => `
+ ${facts
+ .map(
+ (f) => `
+
${f.label}
+
${f.text}
+
`,
+ )
+ .join('\n ')}
+
`;
+
+/**
+ * Render one card to `out`.
+ *
+ * @param {object} o
+ * @param {string} o.css card-specific CSS, appended after BASE_CSS
+ * @param {string} o.body the card's middle block, between top row and footer
+ * @param {string} [o.kicker] the mono line opposite the lockup
+ * @param {string} o.tags the mono footer strip, left of the repo url
+ * @param {string} o.out absolute output path
+ * @param {{from:number,to:number}} [o.fit] headline size range for the fit pass
+ */
+export async function renderCard({ css, body, tags, out, kicker = 'Built for the AI era', fit = { from: 70, to: 34 } }) {
+ const html = `
+
+
+
+
+
+
${LOCKUP}
+
${kicker}
+
+ ${body}
+
+
+
+
${tags}
+
github.com/webjsdev/webjs
+
+
+
+`;
+
+ const tmp = mkdtempSync(join(tmpdir(), 'webjs-og-'));
+ const big = join(tmp, 'og-2x.png');
+ const browser = await chromium.launch();
+ let titlePx;
+ try {
+ const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 2 });
+ await page.setContent(html, { waitUntil: 'load' });
+ await page.evaluate(() => document.fonts.ready);
+ // Shrink the headline until the card holds it. A 1200x630 card has nowhere
+ // for overflow to go, so a headline one word too long is simply cut off in
+ // every unfurl, with nothing in the render to signal it. Stepping down in
+ // ones keeps a short headline exactly as it was drawn and costs a longer
+ // one as little size as it can.
+ //
+ // The test is the FOOTER's bottom against the frame's padding box, not
+ // scrollHeight against clientHeight. That earlier test could never fail:
+ // the frame is a fixed-height flex container and body is overflow:hidden,
+ // so its scrollHeight is pinned to its clientHeight however far the content
+ // spills. The loop returned on its first iteration every time, and the
+ // cards had been quietly rendering their footers outside the bottom padding
+ // (605px against a 566px limit on the fullest one) with nothing to say so.
+ titlePx = await page.evaluate(({ from, to }) => {
+ const h1 = document.querySelector('h1');
+ const frame = document.querySelector('.frame');
+ const foot = document.querySelector('.foot');
+ const limit =
+ frame.getBoundingClientRect().bottom - parseFloat(getComputedStyle(frame).paddingBottom);
+ for (let px = from; px >= to; px -= 1) {
+ h1.style.fontSize = px + 'px';
+ // Half a pixel of tolerance, since both sides are fractional.
+ if (foot.getBoundingClientRect().bottom <= limit + 0.5) return px;
+ }
+ return null;
+ }, fit);
+ await page.screenshot({ path: big, clip: { x: 0, y: 0, width: 1200, height: 630 } });
+ } finally {
+ await browser.close();
+ }
+ // A null means even the smallest step overflowed, so the card needs less
+ // CONTENT rather than smaller type. Loud, because the render still produces a
+ // plausible-looking png with its footer cut off.
+ if (titlePx === null) {
+ console.warn(`WARNING: ${out} overflows at ${fit.to}px. Shorten the copy; the footer is clipped.`);
+ } else if (titlePx < fit.from) {
+ console.log(`Headline set at ${titlePx}px to fit the card.`);
+ }
+
+ // Downscale the 2400x1260 capture to an exact 1200x630 for crisp text, strip
+ // metadata, and use max PNG compression. PNG rather than WebP because it is
+ // the safe og:image format for every social unfurler.
+ execFileSync('magick', [big, '-resize', '1200x630', '-strip', '-define', 'png:compression-level=9', out], { stdio: 'inherit' });
+ rmSync(tmp, { recursive: true, force: true });
+ console.log('wrote', out);
+}