Skip to content

Commit 4ceea5e

Browse files
bloveclaude
andauthored
feat(website): phone-width hero poster, re-recorded desktop poster, redesigned OG card (#1002)
* feat(website): redesign the Open Graph card for feed legibility The old card put the product name in an 18px eyebrow, the runtimes and the capability list inside a three-line 26px paragraph, and three 15px mono pills along the bottom. At the ~500px a timeline actually renders a share card, all of that is grey texture: the only legible element was the headline, and the brand name was the smallest type on the card. Rebuilt around what survives a 0.42x downscale. Four elements, none below 30px source: the wordmark at 100px, the category line (HERO_H1, now on one line) at 52px, HERO_SUBHEAD at 36px for what you get, and a LangGraph + AG-UI pill at 34px for what it plugs into. Centred rather than left-aligned so a square thumbnail crop keeps the product name. Moved onto the production dark-surface tokens from ui.css ([data-ui="section"][data-surface="dark"]) resolved to literals, since Satori cannot read CSS variables, plus the radial accent glow from landing.css .proof-strip::before and --color-angular-red for the seam. Text contrast is 16.8:1 (primary), 11:1 (secondary) and 8.6:1 (accent). Copy stays single-sourced: the wordmark is derived from PRIMARY_TAGLINE rather than retyped, and the runtime claim is POSITIONING_PROOF_POINTS[0]. `alt` now describes what the card says instead of naming the page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): ship the OG card's dimensions and alt text Next's file-convention metadata (the alt and size exports in app/opengraph-image.tsx) is overridden the moment openGraph.images is set explicitly. Both layout.tsx and createPageMetadata passed a bare URL string, so production emitted og:image alone — no width or height for a platform to lay the card out before fetching it, and no alt text at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(examples): record the hero poster at phone width A parallel to record-hero-poster.record.ts that drives /hero at 390x650 and writes hero-walkthrough-poster-mobile.webp. Same beat as the desktop script — the first streamed reply — because 650 CSS px is the shortest frame that holds the whole answer from its first line, and because matching beats mean crossing the breakpoint swaps the source without changing the story. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): ship a phone-width hero poster The hero poster is the server-rendered LCP and the only thing a phone sees until it taps Play. It was a 1200x720 desktop capture; at 390px it landed in a 348px stage, so the last change narrowed the stage to 4/5 and pushed object-position to 40% so cover would crop rather than shrink. That bought legibility and paid for it in credibility: every line of prose lost its right edge, and the frame read as a broken screenshot. Replaced with a second render of the same moment at phone dimensions (585x975, 33,576 bytes against the desktop poster's 37,718), chosen by a <picture> media source on the same 768px boundary the stage ratio and MIN_AUTOPLAY_WIDTH already use. The stage below the breakpoint now holds 3/5 — the capture's own ratio — so object-fit: cover crops nothing and the object-position nudge is gone. currentSrc resolves to the phone poster at 390 and to the desktop one at 768 and 1440; the stage box measures the same with the image aborted as with it loaded at all three widths, so the swap costs no layout shift. Two follow-on fixes the new asset exposed: the phone frame ends on the demo's own "Take control" button, which read straight through the play control's 97% fill and sat half-uncovered beneath it. The fill is now opaque and the control drops to 12px on phones so it lands squarely on the dead one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): re-record both hero posters on the shipping layout The desktop poster was stale. It showed "Take control" floating over the composer — the absolutely-positioned layout replaced in #998 — and a beat where the walkthrough's second prompt was already typed into the composer. It is the LCP element for every desktop visitor, so the largest image on the homepage was showing a UI that no longer exists. Both recorders now hold 2500ms after the interrupt detaches instead of 1500ms. That is about the scripted cursor, not the text: HOLD_AFTER_ANSWER_MS (2000) plus CURSOR_MOVE_MS (650) means at 1500ms the arrow is still parked where it pressed Accept, which at phone width dropped it onto the word `retain` in step 3 — an artifact rather than a hint that the demo is live. At 2500ms it has reached the composer, which is the beat the desktop script's docstring has always claimed. The beat intent is otherwise unchanged: the first streamed reply, with the user turn, the tool call and the whole answer on screen. Both scripts now assert what they captured — `.hero__take` visible in normal flow, composer empty — so a drifted wait or another layout change fails the recording instead of silently shipping a poster of a UI we no longer have. Desktop 37,718 -> 38,114 bytes; phone 33,576 -> 33,624. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 971581c commit 4ceea5e

12 files changed

Lines changed: 466 additions & 134 deletions
32.8 KB
Loading
396 Bytes
Loading

apps/website/src/app/layout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { JsonLd } from '../components/shared/JsonLd';
1010
import { rootJsonLd } from '../lib/structured-data';
1111
import {
1212
DEFAULT_META_DESCRIPTION,
13-
DEFAULT_SOCIAL_IMAGE,
13+
DEFAULT_SOCIAL_IMAGE_META,
1414
LONG_SUBHEAD,
1515
PRIMARY_TAGLINE,
1616
SITE_NAME,
@@ -48,13 +48,13 @@ export const metadata: Metadata = {
4848
type: 'website',
4949
siteName: SITE_NAME,
5050
url: '/',
51-
images: [DEFAULT_SOCIAL_IMAGE],
51+
images: [DEFAULT_SOCIAL_IMAGE_META],
5252
},
5353
twitter: {
5454
card: 'summary_large_image',
5555
title: 'Threadplane',
5656
description: LONG_SUBHEAD,
57-
images: [DEFAULT_SOCIAL_IMAGE],
57+
images: [DEFAULT_SOCIAL_IMAGE_META],
5858
},
5959
};
6060

apps/website/src/app/opengraph-image.tsx

Lines changed: 174 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,108 +4,225 @@
44
* Renders a 1200×630 PNG at request time via Next.js ImageResponse.
55
* Per-route overrides can be added by dropping an `opengraph-image.tsx`
66
* file in any route folder.
7+
*
8+
* DESIGNED FOR FEED SIZE, NOT FOR THE FULL-SIZE PNG. Timelines render this
9+
* around 500px wide and Slack unfurls it narrower still, so every size here is
10+
* chosen against that ~0.42× rendering: the wordmark's 100px lands at 42px,
11+
* the category line's 52px at 22px, the body's 36px at 15px, the runtime
12+
* pill's 34px at 14px. Nothing is below 30px source, because ~12px rendered is
13+
* where text stops being read and starts being texture.
14+
*
15+
* That budget is the whole design. The card it replaces spent its legibility
16+
* on an 18px eyebrow, three 15px pills and a three-line 26px paragraph — all
17+
* of which dissolved into grey noise at feed scale — and left the product name
18+
* as the smallest type on the card. Four elements is what fits: who this is,
19+
* what it is, what you get, and what it plugs into.
20+
*
21+
* The stack is centred rather than left-aligned like the site's hero, for one
22+
* reason: surfaces that show a share image as a square or 4:3 thumbnail
23+
* centre-crop it, and a left-aligned column loses its first word or two when
24+
* they do. Centred, the product name survives every crop that keeps the middle.
25+
*
26+
* Colours are the production dark-surface tokens resolved to literals, because
27+
* Satori cannot read CSS variables. Sources:
28+
* - apps/website/src/styles/ui.css `[data-ui="section"][data-surface="dark"]`
29+
* (canvas gradient, text ramp, dark-scope accent, accent seam) and
30+
* landing.css `.proof-strip::before` (the radial accent glow)
31+
* - libs/design-tokens/src/lib/theme.css (--color-angular-red)
32+
* If either changes, re-resolve them here.
733
*/
834
import { ImageResponse } from 'next/og';
9-
import { HERO_H1, POSITIONING_PROOF_POINTS, PRIMARY_TAGLINE, SHORT_POSITIONING_DESCRIPTION } from '../lib/positioning';
35+
import { HERO_H1, HERO_SUBHEAD, POSITIONING_PROOF_POINTS, PRIMARY_TAGLINE } from '../lib/positioning';
1036
import { loadCardFonts } from './og-font';
1137

1238
// Node runtime (not edge) so we can read the bundled Garamond TTF off disk.
1339
// Font loading lives in ./og-font so the TTF stays statically traceable.
1440
export const runtime = 'nodejs';
15-
export const alt = PRIMARY_TAGLINE;
1641
export const size = { width: 1200, height: 630 };
1742
export const contentType = 'image/png';
1843

44+
/** Dark-surface tokens (ui.css) + brand red (theme.css), resolved for Satori. */
45+
const TOKENS = {
46+
/** --color-canvas gradient on [data-surface="dark"] */
47+
canvas: 'linear-gradient(180deg, #161616 0%, #0e0e0e 100%)',
48+
/** --color-text-primary rgb(245, 245, 245) */
49+
textPrimary: '#f5f5f5',
50+
/** --color-text-secondary rgb(200, 200, 200) */
51+
textSecondary: '#c8c8c8',
52+
/** --color-text-muted rgb(160, 160, 160) */
53+
textMuted: '#a0a0a0',
54+
/** --color-accent in the dark scope (= --color-accent-light) */
55+
accent: '#64c3fd',
56+
/** --color-accent-surface rgba(100, 195, 253, 0.08) */
57+
accentSurface: 'rgba(100, 195, 253, 0.08)',
58+
/** --color-accent-border rgba(100, 195, 253, 0.2) */
59+
accentBorder: 'rgba(100, 195, 253, 0.2)',
60+
/** --color-border-strong rgb(60, 60, 60) */
61+
borderStrong: '#3c3c3c',
62+
/** --color-angular-red */
63+
angularRed: '#DD0031',
64+
} as const;
65+
66+
/**
67+
* "Threadplane" — taken from the tagline rather than retyped, so the card and
68+
* the <title> can never disagree about the product name. Falls back to the
69+
* whole tagline if the em dash ever goes away.
70+
*/
71+
const BRAND_NAME = PRIMARY_TAGLINE.split('—')[0].trim() || PRIMARY_TAGLINE;
72+
/** "LangGraph + AG-UI" — the first proof point is the runtime claim. */
73+
const RUNTIMES = POSITIONING_PROOF_POINTS[0].label;
74+
75+
/** Describes what the card actually says, not just the page it links to. */
76+
export const alt = `${PRIMARY_TAGLINE}. ${HERO_SUBHEAD} Works with ${RUNTIMES}.`;
77+
1978
export default async function OpenGraphImage() {
20-
const fonts = await loadCardFonts({ mono: true });
79+
const fonts = await loadCardFonts();
2180

2281
return new ImageResponse(
2382
(
2483
<div
2584
style={{
2685
width: '100%',
2786
height: '100%',
28-
background: 'linear-gradient(135deg, #fafbfc 0%, #eaf3ff 100%)',
87+
background: TOKENS.canvas,
2988
display: 'flex',
3089
flexDirection: 'column',
31-
padding: '72px 80px',
32-
color: '#1a1a2e',
90+
color: TOKENS.textPrimary,
3391
fontFamily: 'Inter, sans-serif',
92+
position: 'relative',
93+
overflow: 'hidden',
3494
}}
3595
>
36-
{/* Eyebrow */}
37-
<div
38-
style={{
39-
fontFamily: 'JetBrains Mono, monospace',
40-
fontSize: 18,
41-
letterSpacing: '0.12em',
42-
color: '#004090',
43-
fontWeight: 700,
44-
textTransform: 'uppercase',
45-
marginBottom: 28,
46-
}}
47-
>
48-
Threadplane · MIT
49-
</div>
50-
51-
{/* Headline — EB Garamond serif matches marketing-site h1 */}
96+
{/*
97+
The accent glow the homepage's dark proof band rises behind
98+
(landing.css `.proof-strip::before`), scaled to the card and pooled
99+
above the wordmark. It gives the flat canvas some depth without
100+
putting anything on it that has to be read.
101+
*/}
52102
<div
53103
style={{
54-
fontFamily: 'EB Garamond, Georgia, serif',
55-
fontSize: 76,
56-
lineHeight: 1.05,
57-
fontWeight: 700,
58-
letterSpacing: '-0.02em',
59-
color: '#1a1a2e',
60-
marginBottom: 24,
61-
maxWidth: 980,
104+
display: 'flex',
105+
position: 'absolute',
106+
top: -230,
107+
right: 190,
108+
width: 820,
109+
height: 720,
110+
background:
111+
'radial-gradient(circle, rgba(100, 195, 253, 0.15) 0%, rgba(100, 195, 253, 0.05) 55%, rgba(100, 195, 253, 0) 75%)',
62112
}}
63-
>
64-
{HERO_H1}
65-
</div>
113+
/>
66114

67-
{/* Subhead */}
115+
{/*
116+
Brand seam. ui.css draws a 1px accent line at every light→dark
117+
section boundary; at feed scale 1px is invisible, so the card states
118+
it at 8px and runs it Angular-red → accent-blue. At thumbnail sizes
119+
where the copy has gone soft it is still a legible brand signal.
120+
*/}
68121
<div
69122
style={{
70-
fontSize: 26,
71-
lineHeight: 1.45,
72-
color: '#555770',
73-
maxWidth: 920,
74-
marginBottom: 'auto',
123+
display: 'flex',
124+
height: 8,
125+
background: `linear-gradient(90deg, ${TOKENS.angularRed} 0%, ${TOKENS.angularRed} 24%, ${TOKENS.accent} 46%, ${TOKENS.accent} 100%)`,
75126
}}
76-
>
77-
{SHORT_POSITIONING_DESCRIPTION}
78-
</div>
127+
/>
79128

80-
{/* Footer row — pill trust signals + wordmark */}
81129
<div
82130
style={{
83131
display: 'flex',
132+
flexDirection: 'column',
133+
flex: 1,
134+
justifyContent: 'center',
84135
alignItems: 'center',
85-
justifyContent: 'space-between',
86-
marginTop: 36,
136+
textAlign: 'center',
137+
padding: '0 68px',
87138
}}
88139
>
89-
<div style={{ display: 'flex', gap: 12 }}>
90-
{POSITIONING_PROOF_POINTS.slice(0, 3).map((proofPoint, index) => (
91-
<PillBadge key={proofPoint.label} tone={index === 0 ? 'accent' : 'neutral'}>
92-
{proofPoint.label}
93-
</PillBadge>
94-
))}
95-
</div>
140+
{/* Wordmark. Biggest thing on the card: nobody knows the name yet. */}
96141
<div
97142
style={{
98143
display: 'flex',
99144
alignItems: 'center',
100-
gap: 10,
145+
gap: 26,
101146
fontFamily: 'EB Garamond, Georgia, serif',
102-
fontSize: 22,
147+
fontSize: 100,
103148
fontWeight: 700,
104-
color: '#1a1a2e',
149+
lineHeight: 1,
150+
letterSpacing: '-0.015em',
151+
color: TOKENS.textPrimary,
152+
}}
153+
>
154+
<span style={{ fontSize: 68 }}>🛩️</span>
155+
<span>{BRAND_NAME}</span>
156+
</div>
157+
158+
{/* Category, one line: what the thing is. */}
159+
<div
160+
style={{
161+
display: 'flex',
162+
marginTop: 34,
163+
fontSize: 52,
164+
fontWeight: 600,
165+
lineHeight: 1.15,
166+
letterSpacing: '-0.01em',
167+
color: TOKENS.textPrimary,
168+
}}
169+
>
170+
{HERO_H1}
171+
</div>
172+
173+
{/* What you get. Wrapped to two lines on purpose — see the header. */}
174+
<div
175+
style={{
176+
display: 'flex',
177+
marginTop: 26,
178+
maxWidth: 800,
179+
fontSize: 36,
180+
lineHeight: 1.38,
181+
color: TOKENS.textSecondary,
182+
}}
183+
>
184+
{HERO_SUBHEAD}
185+
</div>
186+
187+
{/* Footer: the runtimes, stated loudly, plus the licence. */}
188+
<div
189+
style={{
190+
display: 'flex',
191+
alignItems: 'center',
192+
justifyContent: 'center',
193+
gap: 24,
194+
marginTop: 44,
105195
}}
106196
>
107-
<span style={{ fontSize: 28 }}>🛩️</span>
108-
<span>threadplane.ai</span>
197+
<div
198+
style={{
199+
display: 'flex',
200+
alignItems: 'center',
201+
padding: '12px 26px',
202+
borderRadius: 999,
203+
background: TOKENS.accentSurface,
204+
border: `1px solid ${TOKENS.accentBorder}`,
205+
fontSize: 34,
206+
fontWeight: 600,
207+
color: TOKENS.accent,
208+
}}
209+
>
210+
{RUNTIMES}
211+
</div>
212+
<div
213+
style={{
214+
display: 'flex',
215+
alignItems: 'center',
216+
padding: '12px 26px',
217+
borderRadius: 999,
218+
border: `1px solid ${TOKENS.borderStrong}`,
219+
fontSize: 34,
220+
fontWeight: 600,
221+
color: TOKENS.textMuted,
222+
}}
223+
>
224+
MIT · open source
225+
</div>
109226
</div>
110227
</div>
111228
</div>
@@ -116,42 +233,3 @@ export default async function OpenGraphImage() {
116233
},
117234
);
118235
}
119-
120-
interface PillBadgeProps {
121-
tone: 'accent' | 'neutral';
122-
children: React.ReactNode;
123-
}
124-
125-
function PillBadge({ tone, children }: PillBadgeProps) {
126-
const styles = {
127-
accent: {
128-
bg: 'rgba(0, 64, 144, 0.08)',
129-
border: 'rgba(0, 64, 144, 0.18)',
130-
color: '#004090',
131-
},
132-
neutral: {
133-
bg: '#ffffff',
134-
border: '#e6e8ee',
135-
color: '#555770',
136-
},
137-
}[tone];
138-
139-
return (
140-
<div
141-
style={{
142-
display: 'flex',
143-
alignItems: 'center',
144-
padding: '8px 18px',
145-
borderRadius: 999,
146-
background: styles.bg,
147-
border: `1px solid ${styles.border}`,
148-
color: styles.color,
149-
fontFamily: 'JetBrains Mono, monospace',
150-
fontSize: 15,
151-
fontWeight: 600,
152-
}}
153-
>
154-
{children}
155-
</div>
156-
);
157-
}

0 commit comments

Comments
 (0)