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 */} - <div - style={{ - fontFamily: 'JetBrains Mono, monospace', - fontSize: 18, - letterSpacing: '0.12em', - color: '#004090', - fontWeight: 700, - textTransform: 'uppercase', - marginBottom: 28, - }} - > - Threadplane · MIT - </div> - - {/* 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. + */} <div style={{ - fontFamily: 'EB Garamond, Georgia, serif', - fontSize: 76, - lineHeight: 1.05, - fontWeight: 700, - letterSpacing: '-0.02em', - color: '#1a1a2e', - marginBottom: 24, - maxWidth: 980, + display: 'flex', + position: 'absolute', + top: -230, + right: 190, + width: 820, + height: 720, + background: + 'radial-gradient(circle, rgba(100, 195, 253, 0.15) 0%, rgba(100, 195, 253, 0.05) 55%, rgba(100, 195, 253, 0) 75%)', }} - > - {HERO_H1} - </div> + /> - {/* 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. + */} <div style={{ - fontSize: 26, - lineHeight: 1.45, - color: '#555770', - maxWidth: 920, - marginBottom: 'auto', + display: 'flex', + height: 8, + background: `linear-gradient(90deg, ${TOKENS.angularRed} 0%, ${TOKENS.angularRed} 24%, ${TOKENS.accent} 46%, ${TOKENS.accent} 100%)`, }} - > - {SHORT_POSITIONING_DESCRIPTION} - </div> + /> - {/* Footer row — pill trust signals + wordmark */} <div style={{ display: 'flex', + flexDirection: 'column', + flex: 1, + justifyContent: 'center', alignItems: 'center', - justifyContent: 'space-between', - marginTop: 36, + textAlign: 'center', + padding: '0 68px', }} > - <div style={{ display: 'flex', gap: 12 }}> - {POSITIONING_PROOF_POINTS.slice(0, 3).map((proofPoint, index) => ( - <PillBadge key={proofPoint.label} tone={index === 0 ? 'accent' : 'neutral'}> - {proofPoint.label} - </PillBadge> - ))} - </div> + {/* Wordmark. Biggest thing on the card: nobody knows the name yet. */} <div style={{ display: 'flex', alignItems: 'center', - gap: 10, + gap: 26, fontFamily: 'EB Garamond, Georgia, serif', - fontSize: 22, + fontSize: 100, fontWeight: 700, - color: '#1a1a2e', + lineHeight: 1, + letterSpacing: '-0.015em', + color: TOKENS.textPrimary, + }} + > + <span style={{ fontSize: 68 }}>🛩️</span> + <span>{BRAND_NAME}</span> + </div> + + {/* Category, one line: what the thing is. */} + <div + style={{ + display: 'flex', + marginTop: 34, + fontSize: 52, + fontWeight: 600, + lineHeight: 1.15, + letterSpacing: '-0.01em', + color: TOKENS.textPrimary, + }} + > + {HERO_H1} + </div> + + {/* What you get. Wrapped to two lines on purpose — see the header. */} + <div + style={{ + display: 'flex', + marginTop: 26, + maxWidth: 800, + fontSize: 36, + lineHeight: 1.38, + color: TOKENS.textSecondary, + }} + > + {HERO_SUBHEAD} + </div> + + {/* Footer: the runtimes, stated loudly, plus the licence. */} + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 24, + marginTop: 44, }} > - <span style={{ fontSize: 28 }}>🛩️</span> - <span>threadplane.ai</span> + <div + style={{ + display: 'flex', + alignItems: 'center', + padding: '12px 26px', + borderRadius: 999, + background: TOKENS.accentSurface, + border: `1px solid ${TOKENS.accentBorder}`, + fontSize: 34, + fontWeight: 600, + color: TOKENS.accent, + }} + > + {RUNTIMES} + </div> + <div + style={{ + display: 'flex', + alignItems: 'center', + padding: '12px 26px', + borderRadius: 999, + border: `1px solid ${TOKENS.borderStrong}`, + fontSize: 34, + fontWeight: 600, + color: TOKENS.textMuted, + }} + > + MIT · open source + </div> </div> </div> </div> @@ -116,42 +233,3 @@ export default async function OpenGraphImage() { }, ); } - -interface PillBadgeProps { - tone: 'accent' | 'neutral'; - children: React.ReactNode; -} - -function PillBadge({ tone, children }: PillBadgeProps) { - const styles = { - accent: { - bg: 'rgba(0, 64, 144, 0.08)', - border: 'rgba(0, 64, 144, 0.18)', - color: '#004090', - }, - neutral: { - bg: '#ffffff', - border: '#e6e8ee', - color: '#555770', - }, - }[tone]; - - return ( - <div - style={{ - display: 'flex', - alignItems: 'center', - padding: '8px 18px', - borderRadius: 999, - background: styles.bg, - border: `1px solid ${styles.border}`, - color: styles.color, - fontFamily: 'JetBrains Mono, monospace', - fontSize: 15, - fontWeight: 600, - }} - > - {children} - </div> - ); -} 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 <source> has to precede the <img> (a <picture> takes the FIRST + * matching source, and an <img> 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(<HeroDemo />); + 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 + * <picture> 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(<HeroDemo />); + 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() { <div ref={rootRef} className="hero-demo" data-hero-demo data-state={state}> <BrowserFrame url="demo.threadplane.ai/hero" elevation="lg" className="hero-demo-frame"> <div className="hero-demo-stage"> - <img - src={HERO_POSTER} - width={POSTER_W} - height={POSTER_H} - alt="Threadplane chat replaying a recorded LangGraph run: a user prompt, a request_approval tool call, and the streamed three-step cleanup plan" - className="hero-demo-poster" - loading="eager" - decoding="async" - // React 19 lowercases this attribute; the spec asserts the DOM value. - fetchPriority="high" - /> + {/* + A <picture> 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 <img> keeps every LCP attribute; the stage's own + aspect-ratio (not these intrinsic dimensions) sizes the box, so the + source swap can shift nothing. + */} + <picture> + <source + media={HERO_POSTER_MOBILE_MEDIA} + srcSet={HERO_POSTER_MOBILE} + width={POSTER_MOBILE_W} + height={POSTER_MOBILE_H} + /> + <img + src={HERO_POSTER} + width={POSTER_W} + height={POSTER_H} + alt="Threadplane chat replaying a recorded LangGraph run: a user prompt, a request_approval tool call, and the streamed three-step cleanup plan" + className="hero-demo-poster" + loading="eager" + decoding="async" + // React 19 lowercases this attribute; the spec asserts the DOM value. + fetchPriority="high" + /> + </picture> {mounted ? ( <iframe ref={iframeRef} diff --git a/apps/website/src/lib/site-metadata.spec.ts b/apps/website/src/lib/site-metadata.spec.ts index d33b2e14b..8e442b645 100644 --- a/apps/website/src/lib/site-metadata.spec.ts +++ b/apps/website/src/lib/site-metadata.spec.ts @@ -134,6 +134,17 @@ describe('createPageMetadata article fields', () => { expect(openGraph['tags']).toEqual(['angular', 'ag-ui']); }); + it('ships the default card with dimensions and alt, not a bare URL', () => { + // A bare string overrides Next's file-convention metadata, so og:image:width, + // og:image:height and og:image:alt never reached the HTML. + const metadata = createPageMetadata({ title: 't', description: 'd', pathname: '/', type: 'website' }); + const openGraph = metadata.openGraph as { images: { url: string; width: number; height: number; alt: string }[] }; + expect(openGraph.images[0].url).toBe('/opengraph-image'); + expect(openGraph.images[0].width).toBe(1200); + expect(openGraph.images[0].height).toBe(630); + expect(openGraph.images[0].alt).toMatch(/agent UI framework for Angular/); + }); + it('accepts a page-specific social image', () => { const metadata = createPageMetadata({ title: 'Post — Threadplane', diff --git a/apps/website/src/lib/site-metadata.ts b/apps/website/src/lib/site-metadata.ts index 6af5716fc..28c4c9f24 100644 --- a/apps/website/src/lib/site-metadata.ts +++ b/apps/website/src/lib/site-metadata.ts @@ -7,6 +7,21 @@ import { SITE_ORIGIN } from './site-origin'; export { SITE_ORIGIN }; export const SITE_NAME = 'Threadplane'; export const DEFAULT_SOCIAL_IMAGE = '/opengraph-image'; + +/** + * The default card as an object, not a bare URL. + * + * Next's file-convention metadata (the `alt`/`size` exports in + * `app/opengraph-image.tsx`) is overridden the moment `openGraph.images` is set + * explicitly, so a bare string shipped `og:image` alone — no dimensions for a + * platform to lay the card out before fetching it, and no alt text at all. + */ +export const DEFAULT_SOCIAL_IMAGE_META = { + url: DEFAULT_SOCIAL_IMAGE, + width: 1200, + height: 630, + alt: 'Threadplane — the AI agent UI framework for Angular. Chat, threads, approvals, and generative UI on Signals and DI, for LangGraph and AG-UI.', +} as const; export { CODING_AGENT_PROMPT, COMPONENT_SNIPPET, @@ -106,7 +121,8 @@ export interface PageMetadataOptions { description: string; pathname: string; type?: 'article' | 'website'; - /** Social image path; resolved against `metadataBase` from the root layout. */ + /** Social image path; resolved against `metadataBase` from the root layout. + * Omit to get {@link DEFAULT_SOCIAL_IMAGE_META}, which carries dimensions and alt. */ image?: string; /** Present only for article-type pages; omitted entirely for landing pages. */ article?: ArticleMetadata; @@ -117,7 +133,7 @@ export function createPageMetadata({ description, pathname, type = 'article', - image = DEFAULT_SOCIAL_IMAGE, + image, article, }: PageMetadataOptions): Metadata { const canonicalPath = getCanonicalPath(pathname); @@ -136,7 +152,7 @@ export function createPageMetadata({ url: canonicalPath, siteName: SITE_NAME, type, - images: [image], + images: [image ?? DEFAULT_SOCIAL_IMAGE_META], ...(article && { publishedTime: article.publishedTime, modifiedTime: resolveModifiedTime(article.publishedTime, article.modifiedTime), @@ -148,7 +164,7 @@ export function createPageMetadata({ card: 'summary_large_image', title, description, - images: [image], + images: [image ?? DEFAULT_SOCIAL_IMAGE_META], }, }; } diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 54610d8f0..be5ddd4a2 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1437,6 +1437,13 @@ aspect-ratio: 1200 / 720; background: #0f1116; } +/* The poster is wrapped in a <picture> for the phone-width source. A <picture> + is an inline box with no height of its own, so without this the img's + `height: 100%` would resolve against `auto` and collapse the poster. */ +.hero-demo-stage picture { + display: block; + height: 100%; +} .hero-demo-poster { display: block; transition: opacity 300ms ease; @@ -1488,7 +1495,10 @@ padding: 10px 20px; border-radius: 999px; border: 1px solid rgb(0 0 0 / 12%); - background: rgb(248 248 248 / 97%); + /* Fully opaque, not 97%: the phone poster ends on the demo's own white + "Take control ↗" text, which read straight through the 3% and left the + live control looking double-exposed. */ + background: rgb(248 248 248); color: #111; font-family: var(--font-inter); font-size: 15px; @@ -1515,25 +1525,28 @@ transition: none; } } -/* The poster is a 1200x720 desktop capture. Held at the desktop aspect ratio a - * 390px phone renders it at 0.325 scale, which turns every line of the chat - * into a grey smudge — the hero then proves nothing. Narrowing the stage lets - * `object-fit: cover` crop instead of shrink: at 390px the stage measures - * 348x435, the image scales 0.60 instead of 0.29 and the visible window is - * ~576 of the poster's 1200 columns, so the prompt bubble and the streamed plan - * are read at roughly twice the size. `40%` starts that window at the poster's - * text column (x=245) rather than in - * its empty left gutter. The stage also governs the iframe, so a phone that - * taps Play gets a taller, more usable chat viewport than the 16:9 letterbox. +/* Below the breakpoint the stage shows the phone-width poster, which is a + * 390x650 capture — exactly 3:5. Matching that ratio here is what makes + * `object-fit: cover` a no-op: the frame is shown whole, at 390/348 ≈ 1.12 + * scale, so every line of the chat is read at its designed size instead of + * being shrunk to 0.29 or cropped at the right edge. The stage governs the + * iframe too, so a phone that taps Play gets the same portrait chat viewport + * the poster promised rather than a 16:9 letterbox. * - * This is damage control on a desktop asset, not a mobile design: a poster - * captured at phone width is the real fix. */ + * The breakpoint is shared three ways and must move as one: this ratio, the + * <source media> in HeroDemo.tsx, and MIN_AUTOPLAY_WIDTH. */ @media (max-width: 767px) { .hero-demo-stage { - aspect-ratio: 4 / 5; + aspect-ratio: 3 / 5; } - .hero-demo-poster { - object-position: 40% top; + /* The phone poster shows the demo's whole frame, right down to its own + "Take control ↗" button — which lands 20-29px above the stage floor and + is a still, so tapping it does nothing. Dropping the live control from + 20px to 12px parks it squarely on top of that dead one instead of + half-covering it, so a phone sees one button, not two. */ + .hero-demo-play, + .hero-demo-fallback { + bottom: 12px; } } diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts index 375ac969e..07250e923 100644 --- a/apps/website/src/styles/style-contracts.spec.ts +++ b/apps/website/src/styles/style-contracts.spec.ts @@ -245,13 +245,22 @@ const CONTRACTS: StyleContract[] = [ { file: 'landing.css', selector: '.hero-demo-play', - why: 'The play control only ever renders on phones (autoplay is off below 768px) and sits on a near-black poster. It previously had `background: #111` and a black shadow, which read as bare white text with no button chrome. The light fill and dark ring are what make it look clickable.', + why: 'The play control only ever renders on phones (autoplay is off below 768px) and sits on a near-black poster. It previously had `background: #111` and a black shadow, which read as bare white text with no button chrome. The light fill and dark ring are what make it look clickable. The fill is also fully opaque: at 97% the phone poster\'s own "Take control ↗" text read straight through it.', requires: { - background: /background:\s*rgb\(248 248 248/, + background: /background:\s*rgb\(248 248 248\)/, border: /border:\s*1px solid/, 'min-height': /min-height:\s*44px/, }, }, + { + file: 'landing.css', + selector: '.hero-demo-stage picture', + why: 'The hero poster — the page\'s LCP element — is `height: 100%` of the stage. Wrapping it in a <picture> for the phone-width source put an inline box with no height of its own in between, so without an explicit block height here the percentage resolves against `auto` and the poster collapses to nothing.', + requires: { + display: /display:\s*block/, + height: /height:\s*100%/, + }, + }, { file: 'landing.css', selector: '.marker-highlight', @@ -420,4 +429,44 @@ describe('style contracts', () => { expect(mediaBlock(compoundCss, '(pointer: coarse)')).not.toContain('.b'); }); }); + + describe('landing.css hero demo stage ratio', () => { + const css = loadStylesheet('landing.css'); + const phone = mediaBlock(css, '(max-width: 767px)'); + + /** + * The stage box, not the image, is what `object-fit: cover` crops against. + * The phone poster is a 390x650 capture, so the stage has to hold 3:5 below + * the breakpoint; at the desktop 1200/720 it would letterbox the portrait + * frame down to a two-line sliver. This replaces a `4 / 5` + + * `object-position: 40% top` pair that existed only to crop the DESKTOP + * capture into something readable, which sliced the right edge off every + * line of prose. + */ + it('holds the phone poster ratio below the breakpoint', () => { + expect(declarationsFor(phone, '.hero-demo-stage')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/); + }); + + it('keeps the desktop stage at the desktop capture ratio', () => { + expect(baseDeclarationsFor(css, '.hero-demo-stage')).toMatch(/aspect-ratio:\s*1200\s*\/\s*720/); + }); + + /** + * With the asset and the stage on the same ratio, cover crops nothing. + * A re-introduced object-position would mean someone is cropping again. + */ + it('does not nudge the poster away from its own frame', () => { + expect(declarationsFor(phone, '.hero-demo-poster')).not.toMatch(/object-position:/); + expect(baseDeclarationsFor(css, '.hero-demo-poster')).not.toMatch(/object-position:/); + }); + + /** + * Guards the two assertions above against passing vacuously: landing.css + * carries several `(max-width: 767px)` blocks, and mediaBlock() merges + * them, so an empty merge would satisfy every `not.toMatch` here. + */ + it('actually found the phone block', () => { + expect(phone).toContain('.hero-demo-stage'); + }); + }); }); diff --git a/examples/chat/angular/e2e/record-hero-poster-mobile.record.ts b/examples/chat/angular/e2e/record-hero-poster-mobile.record.ts new file mode 100644 index 000000000..56f2fbac6 --- /dev/null +++ b/examples/chat/angular/e2e/record-hero-poster-mobile.record.ts @@ -0,0 +1,58 @@ +/** + * NOT a test. Captures the phone-width companion to + * `record-hero-poster.record.ts`: the same walkthrough beat rendered at phone + * dimensions, rather than cropped out of the 1200x720 desktop capture. + * + * Beat: the FIRST STREAMED REPLY, the same beat as the desktop poster, so + * crossing the 768px breakpoint swaps the source without changing the story. + * 650 CSS px is the shortest height at which the whole answer fits from its + * first line — "Approved. Here is the cleanup I would run:" through the + * three-step plan, the message actions and the composer — with nothing sliced + * at the top edge. The approval-interrupt beat was the other candidate and was + * rejected twice over: at phone height the panel slices the user's prompt + * bubble behind it, and a still of a live Accept / Edit / Respond dialog + * invites taps that do nothing. + * + * The 2500ms wait is shared with the desktop recorder and is about the scripted + * cursor, not the text: at 1500ms it is still parked where it pressed Accept, + * which at phone width drops the arrowhead onto the word `retain` in step 3. + * By 2500ms it has reached the composer, which reads as the walkthrough about + * to type again rather than as a smudge on the prose. + * + * Geometry: 390x650 is the phone design width the reviews already use, and it + * is exactly 3:5 — the ratio `.hero-demo-stage` holds below 768px — so + * `object-fit: cover` crops nothing. The frame is captured at + * deviceScaleFactor 2 for crisp glyph rasterisation and shipped resized to + * 585x975 (1.5x): the poster is displayed ~348 CSS px wide on a phone, and 2x + * would cost ~51KB against the desktop poster's 37KB. + * + * npx playwright test --config examples/chat/angular/e2e/record-hero.config.ts record-hero-poster-mobile + */ +import { expect, test } from '@playwright/test'; +import { resolve } from 'node:path'; +import sharp from 'sharp'; + +const OUT = resolve( + __dirname, + '../../../../apps/website/public/screenshots/hero-walkthrough-poster-mobile.webp', +); +const SHIP_WIDTH = 585; + +test.use({ viewport: { width: 390, height: 650 }, deviceScaleFactor: 2 }); + +test('capture mobile hero poster', async ({ page }) => { + await page.goto('/hero'); + // The scripted cursor types prompt 1, sends, and the replay pauses on the + // approval interrupt; the script then presses Accept. + const interruptPanel = page.locator('chat-interrupt-panel'); + await interruptPanel.waitFor({ timeout: 60_000 }); + await interruptPanel.waitFor({ state: 'detached', timeout: 60_000 }); + await page.waitForTimeout(2500); + // Guards the beat: `.hero__take` ships in normal flow, and a composer with + // the next prompt already typed into it means the wait has drifted late. + await expect(page.locator('.hero__take')).toBeVisible(); + await expect(page.locator('[data-hero-surface] textarea')).toHaveValue(''); + const png = await page.screenshot({ type: 'png', fullPage: false }); + await sharp(png).resize({ width: SHIP_WIDTH }).webp({ quality: 55, effort: 6 }).toFile(OUT); + console.log(`wrote ${OUT}`); +}); diff --git a/examples/chat/angular/e2e/record-hero-poster.record.ts b/examples/chat/angular/e2e/record-hero-poster.record.ts index 60d485f49..08767734c 100644 --- a/examples/chat/angular/e2e/record-hero-poster.record.ts +++ b/examples/chat/angular/e2e/record-hero-poster.record.ts @@ -8,8 +8,18 @@ * "typing the first prompt" frame is mostly empty canvas; this one shows the * user turn, the tool call, the rendered answer and the cursor heading back to * the composer — it reads as a product, not as a blank chat box. + * + * The wait is 2500ms rather than the 1500ms this script used to hold, because + * the two land the scripted cursor in different places. At 1500ms it is still + * parked where it pressed Accept, which on the phone capture put the arrowhead + * on top of the word `retain` in step 3 — an artifact, not a hint that the demo + * is live. HOLD_AFTER_ANSWER_MS (2000) plus CURSOR_MOVE_MS (650) puts it at the + * composer at ~2650ms and typing starts immediately after, so 2500ms catches it + * arriving with the composer still empty. The empty-composer assertion below is + * what keeps a mistimed capture from shipping silently: the poster this + * replaced had the second prompt already typed into it. */ -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { resolve } from 'node:path'; import sharp from 'sharp'; @@ -22,7 +32,12 @@ test('capture hero poster', async ({ page }) => { const interruptPanel = page.locator('chat-interrupt-panel'); await interruptPanel.waitFor({ timeout: 60_000 }); await interruptPanel.waitFor({ state: 'detached', timeout: 60_000 }); - await page.waitForTimeout(1500); + await page.waitForTimeout(2500); + // The frame has to show what ships today, not a layout we have replaced. + // `.hero__take` was moved out of absolute positioning and into normal flow; + // the poster this replaced still had it floating over the composer. + await expect(page.locator('.hero__take')).toBeVisible(); + await expect(page.locator('[data-hero-surface] textarea')).toHaveValue(''); const png = await page.screenshot({ type: 'png', fullPage: false }); await sharp(png).webp({ quality: 82 }).toFile(OUT); console.log(`wrote ${OUT}`);