Skip to content

feat(website): phone-width hero poster, re-recorded desktop poster, redesigned OG card - #1002

Merged
blove merged 6 commits into
mainfrom
blove/hero-visual-assets
Sep 4, 2026
Merged

feat(website): phone-width hero poster, re-recorded desktop poster, redesigned OG card#1002
blove merged 6 commits into
mainfrom
blove/hero-visual-assets

Conversation

@blove

@blove blove commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The two images the homepage ships: the hero poster (the LCP element) and the Open Graph card.

A real phone poster, not a crop of a desktop one

The mobile hero was a hard crop of a 1200×720 desktop capture, and the CSS comment said so. Every line of prose died mid-word at the right edge and the prompt bubble ran off entirely.

It is now captured at phone size, 390×650 at 2× and shipped at 585×975. That height was chosen by looking at a matrix of heights and beats: shorter frames open mid-sentence, which reads as a scroll position rather than a composed frame. 390×650 is exactly 3:5, which lets the stage ratio match the asset so nothing is cropped.

The approval-interrupt beat looks like the stronger frame and was rejected for two reasons: at phone height the panel slices the user's prompt bubble, and a still of a live Accept dialog invites taps that do nothing.

A phone downloads this one instead of the desktop one, so mobile LCP bytes go down (33.6KB against 37.7KB) while the text gets about three times bigger.

The desktop poster was showing a UI that no longer exists

Found while verifying the phone one. It still showed "Take control" floating over the composer in the absolutely-positioned layout we replaced, and the composer already carrying the walkthrough's second prompt.

The cause was a wait, not a layout: the recorder held 1500ms after the interrupt detached, but the script's own constants put the cursor at the composer at ~2650ms. At 1500ms the cursor is still parked where it pressed Accept — which at phone width landed the arrowhead squarely on the retain code chip. Both posters are re-recorded at 2500ms, the only window where the cursor is heading to the composer with the composer still empty, and the artifact is gone from both.

Both recorders now assert what they captured before encoding: .hero__take visible, and the composer empty. That second assertion is exactly the check that would have caught the poster this replaces, so a drifted wait now fails loudly instead of shipping.

Source selection and LCP

A <picture> with one media source rather than srcset, because the two posters are different renders at different aspect ratios — art direction, keyed off viewport, not pixel density. Verified against a production build: the phone poster resolves at 390, the desktop one at 768 and 1440, with eager loading and high fetch priority intact at every width.

Zero layout shift, proven by aborting both image requests: the stage measures identically with no image at all, because its own aspect ratio sizes the box.

The damage-control CSS is gone. A mobile ratio remains, but derived from the asset (3/5) rather than chosen to crop a desktop one, and the object-position hack is removed — deliberately, since the stage also sizes the iframe, and without a ratio a phone tapping Play would get a 348×209 chat.

The OG card was illegible in a feed

Rendered and viewed at feed size first. Everything except the headline was under 11px: the brand name was the smallest type on the card, and LangGraph and AG-UI sat inside a low-contrast grey paragraph that reads as a smear. Three pills duplicated words above them at 6px. A 120px dead band sat in the middle.

Four elements now, nothing below 30px source, sized against a 0.42× rendering, on a dark substrate so the card has an edge against a white timeline. Centred rather than left-aligned — the left-aligned version was built first, then centre-cropped the way compact surfaces do, and "Threadplane" became "readplane".

Copy stays single-sourced, and the wordmark is derived from the tagline rather than retyped so the card and the page title cannot disagree. Contrast is 16.8:1 on primary text.

The card's alt and dimensions never shipped

Next's file-convention metadata is overridden the moment openGraph.images is set explicitly, and both the layout and the page-metadata helper passed a bare URL string. 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. Now an object carrying all four, with a spec pinning them.

Verification

Website and scripts suites green, 0 lint errors, production build succeeds with the OG route prerendered. Posters read at full size and in the rendered hero at 390, 768 and 1440.

Known and left alone: the OG body font is fetched from Google at build time and failed once mid-session, degrading to all-serif — documented as best-effort in og-font.ts.

🤖 Generated with Claude Code

blove and others added 5 commits September 4, 2026 14:54
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>
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>
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>
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>
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>
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 4, 2026 11:06pm UTC

Request Review

@blove
blove enabled auto-merge (squash) September 4, 2026 22:10

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit 4ceea5e into main Sep 4, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant