diff --git a/apps/website/public/screenshots/hero-walkthrough-poster-mobile.webp b/apps/website/public/screenshots/hero-walkthrough-poster-mobile.webp
new file mode 100644
index 000000000..2e9b082f9
Binary files /dev/null and b/apps/website/public/screenshots/hero-walkthrough-poster-mobile.webp differ
diff --git a/apps/website/public/screenshots/hero-walkthrough-poster.webp b/apps/website/public/screenshots/hero-walkthrough-poster.webp
index ac4342fb9..fe00a4acd 100644
Binary files a/apps/website/public/screenshots/hero-walkthrough-poster.webp and b/apps/website/public/screenshots/hero-walkthrough-poster.webp differ
diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx
index ee224d07b..fb1422523 100644
--- a/apps/website/src/app/layout.tsx
+++ b/apps/website/src/app/layout.tsx
@@ -10,7 +10,7 @@ import { JsonLd } from '../components/shared/JsonLd';
import { rootJsonLd } from '../lib/structured-data';
import {
DEFAULT_META_DESCRIPTION,
- DEFAULT_SOCIAL_IMAGE,
+ DEFAULT_SOCIAL_IMAGE_META,
LONG_SUBHEAD,
PRIMARY_TAGLINE,
SITE_NAME,
@@ -48,13 +48,13 @@ export const metadata: Metadata = {
type: 'website',
siteName: SITE_NAME,
url: '/',
- images: [DEFAULT_SOCIAL_IMAGE],
+ images: [DEFAULT_SOCIAL_IMAGE_META],
},
twitter: {
card: 'summary_large_image',
title: 'Threadplane',
description: LONG_SUBHEAD,
- images: [DEFAULT_SOCIAL_IMAGE],
+ images: [DEFAULT_SOCIAL_IMAGE_META],
},
};
diff --git a/apps/website/src/app/opengraph-image.tsx b/apps/website/src/app/opengraph-image.tsx
index 25379b007..59c77badc 100644
--- a/apps/website/src/app/opengraph-image.tsx
+++ b/apps/website/src/app/opengraph-image.tsx
@@ -4,20 +4,79 @@
* Renders a 1200×630 PNG at request time via Next.js ImageResponse.
* Per-route overrides can be added by dropping an `opengraph-image.tsx`
* file in any route folder.
+ *
+ * DESIGNED FOR FEED SIZE, NOT FOR THE FULL-SIZE PNG. Timelines render this
+ * around 500px wide and Slack unfurls it narrower still, so every size here is
+ * chosen against that ~0.42× rendering: the wordmark's 100px lands at 42px,
+ * the category line's 52px at 22px, the body's 36px at 15px, the runtime
+ * pill's 34px at 14px. Nothing is below 30px source, because ~12px rendered is
+ * where text stops being read and starts being texture.
+ *
+ * That budget is the whole design. The card it replaces spent its legibility
+ * on an 18px eyebrow, three 15px pills and a three-line 26px paragraph — all
+ * of which dissolved into grey noise at feed scale — and left the product name
+ * as the smallest type on the card. Four elements is what fits: who this is,
+ * what it is, what you get, and what it plugs into.
+ *
+ * The stack is centred rather than left-aligned like the site's hero, for one
+ * reason: surfaces that show a share image as a square or 4:3 thumbnail
+ * centre-crop it, and a left-aligned column loses its first word or two when
+ * they do. Centred, the product name survives every crop that keeps the middle.
+ *
+ * Colours are the production dark-surface tokens resolved to literals, because
+ * Satori cannot read CSS variables. Sources:
+ * - apps/website/src/styles/ui.css `[data-ui="section"][data-surface="dark"]`
+ * (canvas gradient, text ramp, dark-scope accent, accent seam) and
+ * landing.css `.proof-strip::before` (the radial accent glow)
+ * - libs/design-tokens/src/lib/theme.css (--color-angular-red)
+ * If either changes, re-resolve them here.
*/
import { ImageResponse } from 'next/og';
-import { HERO_H1, POSITIONING_PROOF_POINTS, PRIMARY_TAGLINE, SHORT_POSITIONING_DESCRIPTION } from '../lib/positioning';
+import { HERO_H1, HERO_SUBHEAD, POSITIONING_PROOF_POINTS, PRIMARY_TAGLINE } from '../lib/positioning';
import { loadCardFonts } from './og-font';
// Node runtime (not edge) so we can read the bundled Garamond TTF off disk.
// Font loading lives in ./og-font so the TTF stays statically traceable.
export const runtime = 'nodejs';
-export const alt = PRIMARY_TAGLINE;
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
+/** Dark-surface tokens (ui.css) + brand red (theme.css), resolved for Satori. */
+const TOKENS = {
+ /** --color-canvas gradient on [data-surface="dark"] */
+ canvas: 'linear-gradient(180deg, #161616 0%, #0e0e0e 100%)',
+ /** --color-text-primary rgb(245, 245, 245) */
+ textPrimary: '#f5f5f5',
+ /** --color-text-secondary rgb(200, 200, 200) */
+ textSecondary: '#c8c8c8',
+ /** --color-text-muted rgb(160, 160, 160) */
+ textMuted: '#a0a0a0',
+ /** --color-accent in the dark scope (= --color-accent-light) */
+ accent: '#64c3fd',
+ /** --color-accent-surface rgba(100, 195, 253, 0.08) */
+ accentSurface: 'rgba(100, 195, 253, 0.08)',
+ /** --color-accent-border rgba(100, 195, 253, 0.2) */
+ accentBorder: 'rgba(100, 195, 253, 0.2)',
+ /** --color-border-strong rgb(60, 60, 60) */
+ borderStrong: '#3c3c3c',
+ /** --color-angular-red */
+ angularRed: '#DD0031',
+} as const;
+
+/**
+ * "Threadplane" — taken from the tagline rather than retyped, so the card and
+ * the
can never disagree about the product name. Falls back to the
+ * whole tagline if the em dash ever goes away.
+ */
+const BRAND_NAME = PRIMARY_TAGLINE.split('—')[0].trim() || PRIMARY_TAGLINE;
+/** "LangGraph + AG-UI" — the first proof point is the runtime claim. */
+const RUNTIMES = POSITIONING_PROOF_POINTS[0].label;
+
+/** Describes what the card actually says, not just the page it links to. */
+export const alt = `${PRIMARY_TAGLINE}. ${HERO_SUBHEAD} Works with ${RUNTIMES}.`;
+
export default async function OpenGraphImage() {
- const fonts = await loadCardFonts({ mono: true });
+ const fonts = await loadCardFonts();
return new ImageResponse(
(
@@ -25,87 +84,145 @@ export default async function OpenGraphImage() {
style={{
width: '100%',
height: '100%',
- background: 'linear-gradient(135deg, #fafbfc 0%, #eaf3ff 100%)',
+ background: TOKENS.canvas,
display: 'flex',
flexDirection: 'column',
- padding: '72px 80px',
- color: '#1a1a2e',
+ color: TOKENS.textPrimary,
fontFamily: 'Inter, sans-serif',
+ position: 'relative',
+ overflow: 'hidden',
}}
>
- {/* Eyebrow */}
-
- Threadplane · MIT
-
-
- {/* Headline — EB Garamond serif matches marketing-site h1 */}
+ {/*
+ The accent glow the homepage's dark proof band rises behind
+ (landing.css `.proof-strip::before`), scaled to the card and pooled
+ above the wordmark. It gives the flat canvas some depth without
+ putting anything on it that has to be read.
+ */}
- {HERO_H1}
-
+ />
- {/* Subhead */}
+ {/*
+ Brand seam. ui.css draws a 1px accent line at every light→dark
+ section boundary; at feed scale 1px is invisible, so the card states
+ it at 8px and runs it Angular-red → accent-blue. At thumbnail sizes
+ where the copy has gone soft it is still a legible brand signal.
+ */}
- );
-}
diff --git a/apps/website/src/components/landing/HeroDemo.spec.tsx b/apps/website/src/components/landing/HeroDemo.spec.tsx
index 9e05749eb..d7276ee22 100644
--- a/apps/website/src/components/landing/HeroDemo.spec.tsx
+++ b/apps/website/src/components/landing/HeroDemo.spec.tsx
@@ -78,6 +78,65 @@ describe('HeroDemo', () => {
expect(container.querySelector('iframe')).toBeNull();
});
+ /**
+ * The desktop poster shrunk into a ~348px phone stage is an unreadable
+ * smudge, so a phone gets its own capture. Both sources have to reach the
+ * markup, the has to precede the (a takes the FIRST
+ * matching source, and an that came first would win every time), and
+ * the media query has to stay on the same 768px boundary as the stage's
+ * portrait ratio in landing.css and as MIN_AUTOPLAY_WIDTH.
+ */
+ it('offers a phone-width poster source ahead of the desktop img', async () => {
+ installEnv();
+ const { HeroDemo, HERO_POSTER, HERO_POSTER_MOBILE, HERO_POSTER_MOBILE_MEDIA } = await import('./HeroDemo');
+ const { container } = render();
+ const picture = container.querySelector('picture') as HTMLElement;
+ expect(picture).toBeTruthy();
+ const source = picture.querySelector('source') as HTMLSourceElement;
+ expect(source.getAttribute('srcset')).toBe(HERO_POSTER_MOBILE);
+ expect(HERO_POSTER_MOBILE).not.toBe(HERO_POSTER);
+ expect(source.getAttribute('media')).toBe('(max-width: 767px)');
+ expect(HERO_POSTER_MOBILE_MEDIA).toBe('(max-width: 767px)');
+ // 585x975 — the 3:5 phone capture, so `object-fit: cover` crops nothing.
+ expect(source.getAttribute('width')).toBe('585');
+ expect(source.getAttribute('height')).toBe('975');
+ expect([...picture.children].map((el) => el.tagName)).toEqual(['SOURCE', 'IMG']);
+ });
+
+ /**
+ * Both posters are recorded artifacts, not build output, so a rename or a
+ * lost file would ship a hero with a broken image and nothing would fail
+ * until someone looked at the page.
+ */
+ it('ships both posters, with the phone one no heavier than the desktop one', async () => {
+ const { HERO_POSTER, HERO_POSTER_MOBILE } = await import('./HeroDemo');
+ const { resolveWebsiteDir } = await import('../../lib/website-dir');
+ const { statSync } = await import('node:fs');
+ const { join } = await import('node:path');
+ const sizeOf = (publicPath: string) =>
+ statSync(join(resolveWebsiteDir(), 'public', publicPath)).size;
+ expect(sizeOf(HERO_POSTER)).toBeGreaterThan(0);
+ // A phone downloads only this one, so it must not cost more than what it
+ // replaces — the whole point is a lighter, legible LCP on the small screen.
+ expect(sizeOf(HERO_POSTER_MOBILE)).toBeLessThanOrEqual(sizeOf(HERO_POSTER));
+ });
+
+ /**
+ * The poster is the LCP element on every viewport. Wrapping it in a
+ * must not cost it its priority hints or its class, or the swap
+ * buys legibility and pays for it in load time.
+ */
+ it('keeps the poster eager and high priority inside the picture', async () => {
+ installEnv();
+ const { HeroDemo } = await import('./HeroDemo');
+ const { container } = render();
+ const img = container.querySelector('picture > img') as HTMLImageElement;
+ expect(img.getAttribute('loading')).toBe('eager');
+ expect(img.getAttribute('fetchpriority')).toBe('high');
+ expect(img.getAttribute('decoding')).toBe('async');
+ expect(img.className).toBe('hero-demo-poster');
+ });
+
it('mounts the iframe when visible on desktop and reveals it on ready from the demo origin', async () => {
installEnv();
const { HeroDemo } = await import('./HeroDemo');
diff --git a/apps/website/src/components/landing/HeroDemo.tsx b/apps/website/src/components/landing/HeroDemo.tsx
index b73703b62..5ef5ed8d3 100644
--- a/apps/website/src/components/landing/HeroDemo.tsx
+++ b/apps/website/src/components/landing/HeroDemo.tsx
@@ -6,10 +6,27 @@ import { trackCtaClick } from '../../lib/analytics/client';
export const HERO_DEMO_ORIGIN = 'https://demo.threadplane.ai';
export const HERO_DEMO_URL = `${HERO_DEMO_ORIGIN}/hero`;
export const HERO_POSTER = '/screenshots/hero-walkthrough-poster.webp';
+/**
+ * The phone-width capture of the same walkthrough beat (585x975, 3:5). It is
+ * a separate render, not a crop: the desktop poster shrunk to a ~348px phone
+ * stage is an unreadable smudge, and cropping it slices the right edge off
+ * every line of prose. Recorded by
+ * `examples/chat/angular/e2e/record-hero-poster-mobile.record.ts`.
+ */
+export const HERO_POSTER_MOBILE = '/screenshots/hero-walkthrough-poster-mobile.webp';
const POSTER_W = 1200;
const POSTER_H = 720;
+const POSTER_MOBILE_W = 585;
+const POSTER_MOBILE_H = 975;
const READY_TIMEOUT_MS = 8000;
const MIN_AUTOPLAY_WIDTH = 768;
+/**
+ * Kept in lockstep with the `@media (max-width: 767px)` block in landing.css
+ * that gives `.hero-demo-stage` its 3:5 portrait ratio, and with
+ * MIN_AUTOPLAY_WIDTH above: the phone poster is served exactly where the stage
+ * is portrait and exactly where the iframe does not autoplay.
+ */
+export const HERO_POSTER_MOBILE_MEDIA = '(max-width: 767px)';
const MESSAGE_TYPE = 'tplane-hero';
type State = 'poster' | 'playRequested' | 'mounting' | 'ready' | 'fallback';
@@ -123,17 +140,33 @@ export function HeroDemo() {
-
+ {/*
+ A rather than srcset/sizes: the two posters are different
+ renders of the same moment at different aspect ratios, so the choice
+ is art direction — the browser must pick by viewport, not by device
+ pixel ratio. The keeps every LCP attribute; the stage's own
+ aspect-ratio (not these intrinsic dimensions) sizes the box, so the
+ source swap can shift nothing.
+ */}
+
+
+
+
{mounted ? (