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.

diff --git a/website/app/what-is-webjs/page.ts b/website/app/what-is-webjs/page.ts index 8ab898671..0ec58cd0a 100644 --- a/website/app/what-is-webjs/page.ts +++ b/website/app/what-is-webjs/page.ts @@ -117,17 +117,17 @@ export function generateMetadata() { title: TITLE, description: DESCRIPTION, url: CANONICAL, - image: `${SITE_URL}/public/og.png`, + image: `${SITE_URL}/public/og-what.png`, 'image:width': '1200', 'image:height': '630', - 'image:alt': 'WebJs, a full-stack JavaScript framework built on web components', + 'image:alt': 'What is WebJs? An AI-first full-stack JavaScript web framework built on web components', 'site_name': 'WebJs', }, twitter: { card: 'summary_large_image', title: TITLE, description: DESCRIPTION, - image: `${SITE_URL}/public/og.png`, + image: `${SITE_URL}/public/og-what.png`, }, jsonLd: [ { @@ -234,18 +234,19 @@ export default function WhatIsWebJs() { return html`
-
-

+
+

What is WebJs?

-

+

WebJs is an AI-first full-stack JavaScript web framework built on web 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 instead of + 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 HTML every model diff --git a/website/app/why-webjs/page.ts b/website/app/why-webjs/page.ts index eebf55e17..a959648f4 100644 --- a/website/app/why-webjs/page.ts +++ b/website/app/why-webjs/page.ts @@ -9,9 +9,13 @@ import { DOCS_START_PATH, GH_URL, NEW_TAB } from '#lib/links.ts'; * * The developer pitch page. Where the home page shows the framework's shape * (progressive enhancement, the three-file stack, the weight stats), this page - * makes the single argument that matters most for the AI era: an agent can read - * the whole framework, so it needs no training data and no single blessed model - * to build a WebJs app well. + * makes the single argument that matters most for the AI era: an agent reads the + * framework source it is calling, from the app's own node_modules at the version + * installed there, so it needs no training data and no single blessed model to + * build a WebJs app well. The claim is about LOCATION, not volume. Nothing reads + * the whole framework (see the comment above DESCRIPTION in app/layout.ts for + * the line counts), and the located version is the stronger claim anyway, + * because it holds however large the framework grows. * * It deliberately reuses the home page's design language (the KICKER label, the * section rhythm, the terminal "windows", the bento grid, and the closing CTA @@ -109,17 +113,20 @@ export default function Why() { return html`

-
-

+
+

The framework your AI agent already understands

-

- WebJs is a full-stack JavaScript framework with no build step, so - nothing is hidden from your agent. The framework ships in node_modules as - plain JavaScript, so an agent opens the file 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, on the web components and standard HTML it already knows. +

+ WebJs is a full-stack JavaScript framework with no + build step, so nothing is hidden from your agent. The framework ships in + node_modules as plain JavaScript, at the version your app installed. +

+

+ An agent opens the file 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, on the web components and standard HTML it + already knows.

-

Your app code, served to the browser as written

+

Your app code, served as written

${DOTS}terminal
$ 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 <span class="accent">WebJs</span>?';
+
+// 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 = '<b>An AI-first full-stack JavaScript web framework built on web components,</b> 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. <span class="q">It reads, navigates and submits before a single script runs.</span>',
+  },
+  {
+    label: 'Every component',
+    text: 'A native custom element. <span class="q">Server actions and file-based routing, on Node 24+ or Bun.</span>',
+  },
+];
+
+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: `<div class="mid">
+      <h1>${TITLE}</h1>
+      <div class="sub">${SUB}</div>
+      ${panels(FACTS)}
+    </div>`,
+  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 <span class="accent">AI agent</span> 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 = '<b>A full-stack JavaScript framework</b> with no build step, so nothing is hidden from your agent.';
 
-const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
-<link rel="preconnect" href="https://fonts.googleapis.com">
-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500;600;700;800&family=Inter:wght@400;500&family=JetBrains+Mono:wght@500&display=swap">
-<style>
-  *{ 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;
-  }
-  .glow{
-    position:absolute; inset:0; pointer-events:none;
-    background:
-      radial-gradient(58% 50% at 50% -8%, color-mix(in oklch, ${T.accentLive} 26%, transparent), transparent 72%),
-      radial-gradient(46% 42% at 90% 6%, color-mix(in oklch, ${T.accentLive} 20%, transparent), transparent 70%),
-      radial-gradient(70% 60% at 50% 120%, ${T.bgDeep}, transparent 60%);
-  }
-  .frame{
-    position:relative; z-index:1;
-    width:100%; height:100%;
-    padding:72px 76px;
-    display:flex; flex-direction:column;
-  }
-  .brand{ display:flex; align-items:center; gap:16px; }
-  .mark{
-    width:46px; height:46px; border-radius:15px;
-    background:linear-gradient(135deg, ${T.logoFrom}, ${T.logoTo});
-    box-shadow:0 6px 22px color-mix(in oklch, ${T.logoFrom} 40%, transparent),
-               inset 0 1px 0 color-mix(in oklch, white 30%, transparent);
-  }
-  .word{ font-family:'Inter Tight',sans-serif; font-weight:700; font-size:31px; letter-spacing:-0.02em; }
-  .kicker{
-    font-family:'JetBrains Mono',monospace; font-weight:500;
-    font-size:16px; letter-spacing:0.18em; text-transform:uppercase;
-    color:${T.accent};
-  }
-  .top{ display:flex; align-items:center; justify-content:space-between; }
-  .mid{ flex:1; display:flex; flex-direction:column; justify-content:center; gap:34px; padding-top:40px; }
+const FACTS = [
+  {
+    label: 'Framework source',
+    text: 'No build, <span class="mono">node_modules</span> holds plain JS. <span class="q">The agent opens the file it is calling instead of recalling an API from training data.</span>',
+  },
+  {
+    label: 'Your app code',
+    text: 'Served to the browser as written. <span class="q">The agent debugs the running app against the real source, not a bundle.</span>',
+  },
+];
+
+const TAGS = 'NO TRAINING DATA  ·  NO BUNDLER  ·  ANY MODEL';
+
+await renderCard({
+  out: resolve(process.argv[2] || 'public/og-why.png'),
+  // This card is the fullest of the three (a headline, a sub and two panels),
+  // so it has the least slack and the most to lose from an edit that runs it
+  // over.
+  fit: { from: 52, to: 30 },
+  css: `${PANEL_CSS}
+  .frame{ padding:56px 76px; }
   h1{
     font-family:'Inter Tight',sans-serif; font-weight:800;
     font-size:52px; line-height:1.05; letter-spacing:-0.035em;
@@ -86,88 +57,13 @@ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
   }
   .sub{
     font-size:22px; line-height:1.4; color:${T.fgMuted}; font-weight:400;
-    max-width:34ch; margin-top:-14px;
+    max-width:38ch; margin-top:-12px;
   }
-  .sub b{ color:${T.fg}; font-weight:600; }
-  .accent{
-    background:linear-gradient(105deg, ${T.accent}, color-mix(in oklch, ${T.accentLive} 72%, ${T.fg}));
-    -webkit-background-clip:text; background-clip:text; color:transparent;
-  }
-  /* Two fact cards: the framework in node_modules, and the app served as
-     written. Each pairs a mono eyebrow with a short benefit line, so the card
-     stays scannable at timeline size while carrying the two-fact story. */
-  .cards{ display:grid; grid-template-columns:1fr 1fr; gap:20px; }
-  .card{
-    border:1px solid ${T.border}; border-radius:18px;
-    background:color-mix(in oklch, ${T.bgDeep} 60%, transparent);
-    padding:24px 26px; display:flex; flex-direction:column; gap:12px;
-  }
-  .clabel{
-    display:flex; align-items:center; gap:9px;
-    font-family:'JetBrains Mono',monospace; font-weight:500;
-    font-size:14px; letter-spacing:0.1em; text-transform:uppercase; color:${T.accent};
-  }
-  .cnum{ color:${T.fgSubtle}; }
-  .ctext{ font-size:21px; line-height:1.42; color:${T.fg}; font-weight:400; }
-  .ctext b{ font-weight:600; }
-  .ctext .q{ color:${T.fgMuted}; }
-  .mono{ font-family:'JetBrains Mono',monospace; font-size:0.86em; color:${T.fgMuted}; }
-  .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}; }
-  hr{ border:0; border-top:1px solid ${T.border}; margin-bottom:24px; }
-</style></head>
-<body>
-  <div class="glow"></div>
-  <div class="frame">
-    <div class="top">
-      <div class="brand"><div class="mark"></div><div class="word">webjs</div></div>
-      <div class="kicker">Built for the AI era</div>
-    </div>
-    <div class="mid">
-      <h1>The framework your <span class="accent">AI agent</span> already understands</h1>
-      <div class="sub">A <b>full-stack JavaScript framework</b> your AI agent can read end to end.</div>
-      <div class="cards">
-        <div class="card">
-          <div class="clabel"><span class="cnum">01</span> Framework source</div>
-          <div class="ctext">No build, <span class="mono">node_modules</span> holds plain JS. <span class="q">The agent reads the whole framework and fits it into context.</span></div>
-        </div>
-        <div class="card">
-          <div class="clabel"><span class="cnum">02</span> Your app code</div>
-          <div class="ctext">Served to the browser as written. <span class="q">The agent debugs the running app against the real source, not a bundle.</span></div>
-        </div>
-      </div>
-    </div>
-    <div>
-      <hr>
-      <div class="foot">
-        <div class="tags"><span class="dot"></span>NO TRAINING DATA  ·  NO BUNDLER  ·  ANY MODEL</div>
-        <div>github.com/webjsdev/webjs</div>
-      </div>
-    </div>
-  </div>
-</body></html>`;
-
-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: `<div class="mid">
+      <h1>${TITLE}</h1>
+      <div class="sub">${SUB}</div>
+      ${panels(FACTS)}
+    </div>`,
+  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 <img> 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', '<span class="accent">Production-ready</span>');
 
-// 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 = '<b>An AI-first full-stack JavaScript web components framework</b> with no build step.';
 
-const html0 = `<!doctype html><html lang="en"><head><meta charset="utf-8">
-<link rel="preconnect" href="https://fonts.googleapis.com">
-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500;600;700;800&family=Inter:wght@400;500&family=JetBrains+Mono:wght@500&display=swap">
-<style>
-  *{ 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;
-  }
-  .glow{
-    position:absolute; inset:0; pointer-events:none;
-    background:
-      radial-gradient(58% 50% at 50% -8%, color-mix(in oklch, ${T.accentLive} 26%, transparent), transparent 72%),
-      radial-gradient(46% 42% at 90% 6%, color-mix(in oklch, ${T.accentLive} 20%, transparent), transparent 70%),
-      radial-gradient(70% 60% at 50% 120%, ${T.bgDeep}, transparent 60%);
-  }
-  .frame{
-    position:relative; z-index:1;
-    width:100%; height:100%;
-    padding:72px 76px;
-    display:flex; flex-direction:column;
-  }
-  .brand{ display:flex; align-items:center; }
-  .brand svg{ height:44px; width:auto; display:block; }
-  .mid{ flex:1; display:flex; flex-direction:column; justify-content:center; }
+// The two halves of the promise above, each stated as something checkable. The
+// first is the site's own account of what a scaffold decides for you; the
+// second is what "no build step" means in practice rather than as a slogan.
+const FACTS = [
+  {
+    label: 'From one prompt',
+    text: 'The architecture, a real database and a design system <span class="q">arrive without being specified.</span>',
+  },
+  {
+    label: 'What you ship',
+    text: 'Source files are served as native ES modules, <span class="q">so what you write is what runs.</span>',
+  },
+];
+
+// Three claims, so the strip cannot afford a repeated suffix: this read
+// "AI-FIRST / WEB-COMPONENTS-FIRST / NO BUILD", and two of them ending the same
+// way landed as a tic rather than as two separate stances. "-first" was also
+// the wrong word for the middle one. Web components are not a preference this
+// framework ranks highly, they are its component model, the way Next is
+// React-based rather than React-first. As a bare fact it matches the register
+// of NO BUILD beside it. The hyphens went with the suffix, since they only ever
+// bound the compound modifier and the platform feature is two plain words.
+const TAGS = 'AI-FIRST  ·  WEB COMPONENTS  ·  NO BUILD';
+
+await renderCard({
+  out: resolve(process.argv[2] || 'public/og.png'),
+  fit: { from: 52, to: 30 },
+  css: `${PANEL_CSS}
+  .frame{ padding:56px 76px; }
   h1{
     font-family:'Inter Tight',sans-serif; font-weight:800;
-    font-size:66px; line-height:1.05; letter-spacing:-0.035em;
-    max-width:18ch;
+    /* Set by the fit pass, so this is a starting point rather than the
+       designed size. */
+    font-size:52px; line-height:1.05; letter-spacing:-0.035em;
+    max-width:22ch;
   }
-  .accent{
-    white-space:nowrap;
-    background:linear-gradient(105deg, ${T.accent}, color-mix(in oklch, ${T.accentLive} 72%, ${T.fg}));
-    -webkit-background-clip:text; background-clip:text; color:transparent;
+  .sub{
+    font-size:22px; line-height:1.4; color:${T.fgMuted}; font-weight:400;
+    max-width:40ch; margin-top:-12px;
   }
-  p.lede{
-    margin-top:26px; max-width:30ch;
-    font-size:25px; line-height:1.5; color:${T.fgMuted};
-  }
-  .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}; }
-  hr{ border:0; border-top:1px solid ${T.border}; margin-bottom:24px; }
-</style></head>
-<body>
-  <div class="glow"></div>
-  <div class="frame">
-    <div class="brand">__LOCKUP__</div>
-    <div class="mid">
-      <h1>The <span class="accent">web framework</span> for AI agents</h1>
-      <p class="lede">A full-stack framework built on web components, SSR, and progressive enhancement, with zero build step. Standards that outlast frameworks.</p>
-    </div>
-    <div>
-      <hr>
-      <div class="foot">
-        <div class="tags"><span class="dot"></span>AI-FIRST  ·  WEB-COMPONENTS-FIRST  ·  NO BUILD</div>
-        <div>github.com/webjsdev/webjs</div>
-      </div>
-    </div>
-  </div>
-</body></html>`;
-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: `<div class="mid">
+      <h1>${TITLE_HTML}</h1>
+      <div class="sub">${SUB}</div>
+      ${panels(FACTS)}
+    </div>`,
+  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 <img> 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) => `<div class="cards">
+        ${facts
+          .map(
+            (f) => `<div class="card">
+          <div class="clabel">${f.label}</div>
+          <div class="ctext">${f.text}</div>
+        </div>`,
+          )
+          .join('\n        ')}
+      </div>`;
+
+/**
+ * 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 = `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<style>${FONT_FACES}${BASE_CSS}${css}</style></head>
+<body>
+  <div class="bar"></div>
+  <div class="frame">
+    <div class="top">
+      <div class="brand">${LOCKUP}</div>
+      <div class="kicker">${kicker}</div>
+    </div>
+    ${body}
+    <div>
+      <hr>
+      <div class="foot">
+        <div class="tags"><span class="dot"></span>${tags}</div>
+        <div>github.com/webjsdev/webjs</div>
+      </div>
+    </div>
+  </div>
+</body></html>`;
+
+  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);
+}