From 92fb9257d3bdf6d5c511c9bf1bd0c5f38fe61b48 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 12:29:26 -0700 Subject: [PATCH 1/3] fix(hero): make the parent/frame visibility handshake self-healing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded walkthrough could reach `ready` and then sit on the empty welcome state forever, because the whole handshake rode on a single message that either side could drop. Parent (HeroDemo): the first visibility post fired while the iframe's contentWindow was still `about:blank`, so it was dropped with the "target origin does not match the recipient window's origin" console error that appeared on every production load. It now posts only after the frame's window has navigated — proven by the iframe `load` event or by the frame having spoken to us — and it answers EVERY `ready`, not just the first, with the current visibility. Frame (HeroMode): the `message` listener is registered inside `boot()`, after the lazy route chunk has loaded, so the parent's `load`-time post could land before anyone was listening and nothing retried. An embedded frame now re-announces `ready` every 500ms (capped at 10s) until any visibility message arrives, then stops; it also stops on destroy. HeroBridge's origin allowlist and `e.source === parent` check are untouched. Verified locally against the real iframe with the demo served from localhost: 0/12 loads started the walkthrough before, 12/12 after, and the postMessage console error is gone. Co-Authored-By: Claude Fable 5.1 --- .../src/components/landing/HeroDemo.spec.tsx | 57 ++++++++++++++++ .../src/components/landing/HeroDemo.tsx | 50 ++++++++++---- .../src/app/hero/hero-mode.component.spec.ts | 65 +++++++++++++++++++ .../src/app/hero/hero-mode.component.ts | 58 ++++++++++++++++- 4 files changed, 214 insertions(+), 16 deletions(-) diff --git a/apps/website/src/components/landing/HeroDemo.spec.tsx b/apps/website/src/components/landing/HeroDemo.spec.tsx index 7f47003a4..9e05749eb 100644 --- a/apps/website/src/components/landing/HeroDemo.spec.tsx +++ b/apps/website/src/components/landing/HeroDemo.spec.tsx @@ -110,6 +110,60 @@ describe('HeroDemo', () => { expect(post).toHaveBeenCalledWith({ type: 'tplane-hero', visible: true }, 'https://demo.threadplane.ai'); }); + /** + * A freshly created iframe's contentWindow is still `about:blank`, so posting + * with the demo's target origin is dropped and logs a console error on every + * single load. Nothing may be posted before the frame has navigated. + */ + it('posts nothing to the frame before it has navigated', async () => { + installEnv(); + const { HeroDemo } = await import('./HeroDemo'); + const { container } = render(); + act(() => { + ioCallback?.([{ isIntersecting: true }]); + }); + const iframe = container.querySelector('iframe') as HTMLIFrameElement; + const post = stubContentWindow(iframe); + act(() => { + ioCallback?.([{ isIntersecting: false }]); + }); + act(() => { + ioCallback?.([{ isIntersecting: true }]); + }); + expect(post).not.toHaveBeenCalled(); + // Guards the assertion above against passing vacuously on a dead spy. + act(() => { + fireEvent.load(iframe); + }); + expect(post).toHaveBeenCalledWith({ type: 'tplane-hero', visible: true }, 'https://demo.threadplane.ai'); + }); + + /** + * The frame re-announces `ready` until it hears a visibility message, so the + * parent must answer EVERY announcement — that ack is what recovers the + * handshake when the first post was lost. + */ + it('re-posts the current visibility on every ready announcement', async () => { + installEnv(); + const { HeroDemo } = await import('./HeroDemo'); + const { container } = render(); + act(() => { + ioCallback?.([{ isIntersecting: true }]); + }); + const post = stubContentWindow(container.querySelector('iframe') as HTMLIFrameElement); + act(() => { + frameReady(); + }); + // A `ready` proves the frame navigated, so the ack lands with no load event. + expect(post).toHaveBeenCalledWith({ type: 'tplane-hero', visible: true }, 'https://demo.threadplane.ai'); + + post.mockClear(); + act(() => { + frameReady(); + }); + expect(post).toHaveBeenCalledWith({ type: 'tplane-hero', visible: true }, 'https://demo.threadplane.ai'); + }); + it('ignores ready from a foreign origin and falls back after the timeout', async () => { installEnv(); const { HeroDemo } = await import('./HeroDemo'); @@ -139,6 +193,9 @@ describe('HeroDemo', () => { }); const iframe = container.querySelector('iframe') as HTMLIFrameElement; const post = stubContentWindow(iframe); + act(() => { + fireEvent.load(iframe); + }); act(() => { vi.advanceTimersByTime(8000); }); diff --git a/apps/website/src/components/landing/HeroDemo.tsx b/apps/website/src/components/landing/HeroDemo.tsx index 21e935dee..b73703b62 100644 --- a/apps/website/src/components/landing/HeroDemo.tsx +++ b/apps/website/src/components/landing/HeroDemo.tsx @@ -29,9 +29,20 @@ export function HeroDemo() { const [state, setState] = useState('poster'); const [visible, setVisible] = useState(false); const [needsClick, setNeedsClick] = useState(false); + /** + * True once the frame's window has actually navigated to the demo origin — + * proven by the iframe's `load` event, or by the frame having spoken to us. + * A freshly created iframe's `contentWindow` is still `about:blank`, and + * posting to it with the demo's target origin is silently dropped (and logs + * "the target origin ... does not match the recipient window's origin"), so + * the visibility handshake must not start before this flips. + */ + const [frameLoaded, setFrameLoaded] = useState(false); const rootRef = useRef(null); const iframeRef = useRef(null); const lastFrameState = useRef(null); + /** Read by the message handler, which is registered once and never re-bound. */ + const visibleRef = useRef(false); // Visibility. useEffect(() => { @@ -59,13 +70,27 @@ export function HeroDemo() { return () => clearTimeout(t); }, [state]); + const postVisibility = useCallback((v: boolean) => { + iframeRef.current?.contentWindow?.postMessage({ type: MESSAGE_TYPE, visible: v }, HERO_DEMO_ORIGIN); + }, []); + // Frame → website messages. useEffect(() => { const onMessage = (e: MessageEvent) => { if (e.origin !== HERO_DEMO_ORIGIN) return; const d = e.data as { type?: string; state?: string } | null; if (!d || d.type !== MESSAGE_TYPE || typeof d.state !== 'string') return; - if (d.state === 'ready') setState((s) => (s === 'mounting' || s === 'fallback' ? 'ready' : s)); + // The frame spoke, so its window has navigated — safe to post to even if + // we somehow never saw the iframe's own load event. + setFrameLoaded(true); + if (d.state === 'ready') { + setState((s) => (s === 'mounting' || s === 'fallback' ? 'ready' : s)); + // Answer EVERY `ready`, not just the first. The frame re-announces + // `ready` until it has heard a visibility message, so this ack is what + // recovers the handshake when our first post was lost — otherwise the + // frame sits on its empty welcome state and never starts. + postVisibility(visibleRef.current); + } if (d.state === lastFrameState.current) return; lastFrameState.current = d.state; if (d.state === 'live') trackCtaClick({ cta_id: 'hero_demo_takeover', track: 'developer', surface: 'home' }); @@ -73,19 +98,20 @@ export function HeroDemo() { }; window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); - }, []); + }, [postVisibility]); const mounted = state === 'mounting' || state === 'ready' || state === 'fallback'; - // Website → frame visibility. Posted whenever the iframe is mounted — while - // mounting, once ready, and after the ready timeout has already dropped us - // into `fallback` (and again on the iframe's load event) — so a frame whose - // referrer was stripped can learn our origin from this message and replay - // its `ready` state to us late. + // Website → frame visibility. Posted whenever the iframe is mounted AND has + // navigated — while mounting, once ready, and after the ready timeout has + // already dropped us into `fallback` — so a frame whose referrer was + // stripped can learn our origin from this message and replay its `ready` + // state to us late. useEffect(() => { - if (!mounted) return; - iframeRef.current?.contentWindow?.postMessage({ type: MESSAGE_TYPE, visible }, HERO_DEMO_ORIGIN); - }, [visible, mounted]); + visibleRef.current = visible; + if (!mounted || !frameLoaded) return; + postVisibility(visible); + }, [visible, mounted, frameLoaded, postVisibility]); const play = useCallback(() => { trackCtaClick({ cta_id: 'hero_demo_play', track: 'developer', surface: 'home' }); @@ -115,9 +141,7 @@ export function HeroDemo() { title="Threadplane live demo" className="hero-demo-iframe" allow="clipboard-write" - onLoad={() => - iframeRef.current?.contentWindow?.postMessage({ type: MESSAGE_TYPE, visible: true }, HERO_DEMO_ORIGIN) - } + onLoad={() => setFrameLoaded(true)} /> ) : null} {needsClick && !mounted ? ( diff --git a/examples/chat/angular/src/app/hero/hero-mode.component.spec.ts b/examples/chat/angular/src/app/hero/hero-mode.component.spec.ts index d57bd264b..c5440b036 100644 --- a/examples/chat/angular/src/app/hero/hero-mode.component.spec.ts +++ b/examples/chat/angular/src/app/hero/hero-mode.component.spec.ts @@ -276,3 +276,68 @@ describe('HeroMode embedded initial frame state', () => { } }); }); + +/** + * The production stall: the parent's single `visible` post landed before this + * component registered its `message` listener, so the frame sat on the empty + * welcome state forever. The frame must keep announcing itself until answered. + */ +describe('HeroMode embedded ready re-announcement', () => { + const originalParent = Object.getOwnPropertyDescriptor(window, 'parent'); + let fx: ComponentFixture; + let states: string[]; + let onVisible: (v: boolean) => void; + + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const readyCount = () => states.filter((s) => s === 'ready').length; + + beforeEach(async () => { + Object.defineProperty(window, 'parent', { configurable: true, value: {} }); + HeroMode.disableAutoBootForTests(); + TestBed.configureTestingModule({ imports: [HeroMode] }); + TestBed.overrideComponent(HeroMode, { + set: { + providers: HeroMode.providersForTest( + new HeroReplayTransport({ sleep: async () => void 0 }, async () => recording), + ), + }, + }); + fx = TestBed.createComponent(HeroMode); + states = []; + onVisible = () => void 0; + fx.componentInstance.bridge = { + postState: (s) => states.push(s), + onVisibility: (cb) => { + onVisible = cb; + return () => void 0; + }, + }; + fx.detectChanges(); + await fx.componentInstance.boot(); + fx.detectChanges(); + }); + + afterEach(() => { + HeroMode.enableAutoBoot(); + if (originalParent) Object.defineProperty(window, 'parent', originalParent); + }); + + it('keeps re-announcing ready while the embedder has not answered', async () => { + // HERO_READY_ANNOUNCE_MS is 500, so ~1.2s must carry at least two repeats + // on top of the announcement boot() already made. + expect(readyCount()).toBe(1); + await sleep(1200); + expect(readyCount()).toBeGreaterThanOrEqual(3); + }); + + it('stops re-announcing as soon as a visibility message arrives', async () => { + await sleep(600); + // Guards this test against passing vacuously on a frame that never announced. + expect(readyCount()).toBeGreaterThanOrEqual(2); + + onVisible(true); + const settled = readyCount(); + await sleep(1200); + expect(readyCount()).toBe(settled); + }); +}); diff --git a/examples/chat/angular/src/app/hero/hero-mode.component.ts b/examples/chat/angular/src/app/hero/hero-mode.component.ts index aa6366261..8fca7fd45 100644 --- a/examples/chat/angular/src/app/hero/hero-mode.component.ts +++ b/examples/chat/angular/src/app/hero/hero-mode.component.ts @@ -53,6 +53,22 @@ const TYPE_DELAY_MS = 40; */ const READ_PAUSE_MS = 1200; +/** + * The embed handshake is a race the frame cannot win on its own. The parent + * posts `{ visible: true }` on the iframe's `load` event, but this component + * only registers its `message` listener inside `boot()` — after the lazy route + * chunk has loaded and Angular has rendered — so that single post can land + * before anyone is listening and be lost forever. Rather than trusting one + * message, a frame that has not yet heard from its embedder keeps announcing + * `ready`; the parent answers every announcement with the current visibility. + */ +export const HERO_READY_ANNOUNCE_MS = 500; +export const HERO_READY_ANNOUNCE_MAX_MS = 10_000; + +function isEmbedded(): boolean { + return typeof window !== 'undefined' && window.parent !== window; +} + /** * The live agent's thread id, held per component instance (NOT at module * scope): a second visit to /hero must start a genuinely new thread, which is @@ -245,11 +261,16 @@ export class HeroMode implements HeroScriptHost { */ private scriptDriving = false; + /** Set by the FIRST visibility message; ends the `ready` re-announcements. */ + private embedderAnswered = false; + private readyAnnounce: ReturnType | null = null; + constructor() { afterNextRender(() => { if (HeroMode.autoBoot) void this.boot(); }); this.destroyRef.onDestroy(() => this.runner?.stop()); + this.destroyRef.onDestroy(() => this.stopAnnouncingReady()); } /** Public so a spec can boot explicitly with `autoBoot` disabled. */ @@ -270,7 +291,7 @@ export class HeroMode implements HeroScriptHost { // In record mode the fixture is the artifact being produced, so there is // nothing to await — the script drives the LIVE agent from the start. if (isRecordMode()) { - this.bridge.postState('ready'); + this.announceReady(); this.startWhenUnembedded(); return; } @@ -280,10 +301,10 @@ export class HeroMode implements HeroScriptHost { } catch (err) { console.error('hero recording unavailable; staying live', err); this.mode.set('live'); - this.bridge.postState('ready'); + this.announceReady(); return; } - this.bridge.postState('ready'); + this.announceReady(); this.startWhenUnembedded(); } catch (err) { console.error('hero boot failed; staying live', err); @@ -305,7 +326,38 @@ export class HeroMode implements HeroScriptHost { void this.runner.loop(); } + /** + * Posts `ready`, then — while embedded and still unanswered — keeps posting + * it so a parent that missed the first exchange still learns the frame is up + * and re-sends its visibility. Capped, because a page that is genuinely not + * listening (an embedder that never implements the protocol) should not have + * this frame talking to it forever. + */ + private announceReady(): void { + this.bridge.postState('ready'); + this.stopAnnouncingReady(); + if (this.embedderAnswered || !isEmbedded()) return; + const deadline = Date.now() + HERO_READY_ANNOUNCE_MAX_MS; + this.readyAnnounce = setInterval(() => { + if (this.embedderAnswered || Date.now() >= deadline) { + this.stopAnnouncingReady(); + return; + } + this.bridge.postState('ready'); + }, HERO_READY_ANNOUNCE_MS); + } + + private stopAnnouncingReady(): void { + if (this.readyAnnounce === null) return; + clearInterval(this.readyAnnounce); + this.readyAnnounce = null; + } + private setEmbedVisible(v: boolean): void { + // Any visibility message — true or false — proves the embedder is on the + // other end of the handshake, so the announcements have done their job. + this.embedderAnswered = true; + this.stopAnnouncingReady(); this.embedVisible.set(v); this.applyVisibility(); } From 14ca554454fe75f0cbca6331e20128f09f88eb9c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 12:29:37 -0700 Subject: [PATCH 2/3] fix(hero): keep the take-control pill clear of the transcript and composer `.hero__take` was `position: absolute; bottom: 72px`, so it floated over the chat surface: at 768px it sat on the streaming answer and half-covered the composer's send button, and at 390px it landed on "How can I help?". Moved into normal flow as the last row of the `.hero` column, so it can never overlap anything at any width. Sampled at six points across a full walkthrough at 768px and 390px: the pill's box previously hit chat-welcome / chat-typing-indicator / chat-scroll at every sample, and now hits nothing inside the surface. Co-Authored-By: Claude Fable 5.1 --- examples/chat/angular/src/app/hero/hero-mode.component.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/chat/angular/src/app/hero/hero-mode.component.ts b/examples/chat/angular/src/app/hero/hero-mode.component.ts index 8fca7fd45..84e102df9 100644 --- a/examples/chat/angular/src/app/hero/hero-mode.component.ts +++ b/examples/chat/angular/src/app/hero/hero-mode.component.ts @@ -189,7 +189,10 @@ function heroProviders(replay?: HeroReplayTransport): Provider[] { .hero__interrupt { padding: 8px 12px 0; } .hero__banner { margin: 0; padding: 8px 12px; font: 13px/1.4 system-ui, sans-serif; background: rgba(47,111,79,.08); border-bottom: 1px solid rgba(47,111,79,.3); } .hero__link { margin-left: 8px; background: none; border: 0; padding: 0; color: inherit; text-decoration: underline; cursor: pointer; font: inherit; } - .hero__take { position: absolute; left: 50%; bottom: 72px; transform: translateX(-50%); z-index: 10; padding: 8px 14px; border-radius: 999px; border: 0; background: #111; color: #fff; font: 600 13px/1 system-ui, sans-serif; cursor: pointer; box-shadow: 0 6px 18px rgba(0,0,0,.25); } + /* In normal flow, NOT absolutely positioned: floating it over the surface + put it on top of the streaming answer at 768px and on top of the empty + welcome copy at 390px, and it half-covered the composer's send button. */ + .hero__take { flex: none; align-self: center; margin: 8px 12px 12px; padding: 8px 14px; border-radius: 999px; border: 0; background: #111; color: #fff; font: 600 13px/1 system-ui, sans-serif; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.18); } .hero__take:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } `, ], From da1326d9ef8d658f55cddc3dd54bde8d5ea72159 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 12:30:32 -0700 Subject: [PATCH 3/3] fix(website): make code panes readable, give buttons a real focus ring, fix the mobile hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects found on the deployed homepage. Code panes hid their payload. `white-space: pre` plus `overflow-x: auto` is invisible clipping on macOS, where the overlay scrollbar draws nothing until a scroll is already under way: the runtime-parity pane ended mid-string at `assistantId: 'agent` (scrollWidth 609 / clientWidth 550 at 1440px) and the install dialog showed `... @langchain/langgraph @langcha`. Every shiki pane and every install-dialog pane now soft-wraps (`pre-wrap`), with `break-word` as the backstop for a run that carries no whitespace at all. A scrollbar was measured and rejected as the affordance: `scrollbar-width: thin` and `::-webkit-scrollbar` both leave 0px of layout gutter on macOS. The install command wraps outright — it is one shell line with no meaningful breaks, and copy still takes the full string from state. Buttons had no visible focus ring. The UA default `outline: auto 1px rgb(0, 95, 204)` is invisible on the primary button's own #004090 fill, which is exactly where focus lands when the install dialog closes. The mobile hero read as bare text on a smudge. The play control's `#111` fill was camouflaged against the near-black poster, and its `left: 50%` inset let it shrink-to-fit into half the stage. The 1200x720 desktop poster is now cropped rather than shrunk below 768px, so the chat is legible at ~2x. Verified against a production build at 1440/768/390: all 17 code panes report scrollWidth == clientWidth. Co-Authored-By: Claude Fable 5.1 --- .../src/components/landing/InstallDialog.tsx | 6 +- apps/website/src/styles/landing.css | 113 ++++++++++++++++-- .../src/styles/style-contracts.spec.ts | 38 ++++++ apps/website/src/styles/ui.css | 18 +++ 4 files changed, 165 insertions(+), 10 deletions(-) diff --git a/apps/website/src/components/landing/InstallDialog.tsx b/apps/website/src/components/landing/InstallDialog.tsx index 3b682e357..09762cb51 100644 --- a/apps/website/src/components/landing/InstallDialog.tsx +++ b/apps/website/src/components/landing/InstallDialog.tsx @@ -77,7 +77,11 @@ export function InstallDialog({ open, onClose }: InstallDialogProps) {
  • Run this in your Angular project

    -
    {option.command}
    + {/* The command is one shell line, not code with meaningful line + breaks — it wraps rather than scrolls, so the whole of it is + visible at every width. Copy takes `option.command` from state, + so the button still copies the single unwrapped string. */} +
    {option.command}

    {option.peersNote}

  • diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index a7e6cfcc5..0d915494b 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -850,6 +850,46 @@ padding: 16px 20px; } +/* A code pane must never hide its payload with no signal that it is hiding + * anything. + * + * These panes were `white-space: pre` (the
     default) plus
    + * `overflow-x: auto`, and macOS overlay scrollbars draw NOTHING until a scroll
    + * is already under way. So the runtime-parity pane — the page's central
    + * mechanism claim — ended mid-string at `assistantId: 'agent` (measured
    + * scrollWidth 609 vs clientWidth 550 at 1440px, and ~126-260px of overflow at
    + * 390px) and looked complete. Wrapping, in two steps:
    + *
    + *   1. `pre-wrap` soft-wraps at whitespace already in the source, so a long
    + *      argument list folds onto a second line. Indentation and blank lines
    + *      survive and no token is split. This alone clears every pane on the
    + *      homepage at 1440 and 768.
    + *   2. `break-word` is the backstop for what step 1 cannot fold: a run with no
    + *      whitespace in it that is wider than the whole pane — a dotted assertion
    + *      chain like `expect(fixture.nativeElement.textContent).toContain('Hello`
    + *      (474px in a 348px pane at 390px). `break-word`, unlike `anywhere`, only
    + *      splits a word that cannot fit on a line of its own, so it never changes
    + *      a line that already wraps at a space, and it leaves min-content sizing
    + *      alone so the grid columns do not collapse.
    + *
    + * Wrapping, not a scrollbar, is what carries this: `scrollbar-width` and
    + * `::-webkit-scrollbar` were both measured at 0px of layout gutter on macOS —
    + * the platform keeps the overlay scrollbar either way, which is the whole bug.
    + * The thin scrollbar below is a real improvement only where scrollbars are
    + * classic (Windows, Linux, macOS with "always show"), so it stays as polish and
    + * not as the affordance.
    + *
    + * Do not drop `pre-wrap` back to `pre`; the failure is silent by construction.
    + * Held by a style contract (styles/style-contracts.spec.ts). */
    +.shiki[data-ui="highlighted-code"] > pre.shiki,
    +.install-dialog-code {
    +  white-space: pre-wrap;
    +  overflow-wrap: break-word;
    +  overflow-x: auto;
    +  scrollbar-width: thin;
    +  scrollbar-color: rgb(255 255 255 / 35%) transparent;
    +}
    +
     /* RenderCodeShowcase — components/landing/render/RenderCodeShowcase.tsx */
     .render-code {
       padding: 80px 32px;
    @@ -1387,6 +1427,16 @@
       margin: 0 0 8px; padding: 10px 12px; border-radius: 8px; overflow-x: auto;
       background: #1c1c1e; color: #e8e8e8; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5;
     }
    +/* The install command is the primary CTA's whole payload and it is a single
    + * shell line — 774px of it in a 470px pane, so the reader saw
    + * `npm install @threadplane/chat @threadplane/langgraph @langcha…` and nothing
    + * told them the rest existed. It has no meaningful line breaks, so wrapping is
    + * strictly better than scrolling here: `anywhere` is the last resort for a
    + * package name longer than a narrow pane. Held by a style contract. */
    +.install-dialog-command {
    +  white-space: pre-wrap;
    +  overflow-wrap: anywhere;
    +}
     .install-dialog-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; }
     .install-dialog-trust { margin: 12px 0 0; font-size: 12px; color: var(--color-text-muted); }
     
    @@ -1429,28 +1479,52 @@
       visibility: hidden;
       transition: opacity 300ms ease, visibility 0s linear 300ms;
     }
    +/* The control had chrome already — `background: #111` with a black drop
    + * shadow — but the poster it sits on is a near-black chat surface, so on a
    + * phone (the only viewport that shows it: autoplay is off below 768px) it read
    + * as bare white text floating over the screenshot. A light pill with a dark
    + * ring is legible on this poster and on any lighter one that replaces it, and
    + * the row is sized to a 44px touch target. */
     .hero-demo-play,
     .hero-demo-fallback {
    +  /* Centred by auto margins between two zero insets, not by
    +     `left: 50%` + `translateX(-50%)`: with only `left` set, an absolutely
    +     positioned box may shrink-to-fit into the remaining HALF of the stage, and
    +     on a phone that folded "Play walkthrough" onto two lines. */
       position: absolute;
    -  left: 50%;
    +  left: 0;
    +  right: 0;
       bottom: 20px;
    -  transform: translateX(-50%);
    -  padding: 10px 16px;
    +  width: fit-content;
    +  max-width: calc(100% - 32px);
    +  margin-inline: auto;
    +  white-space: nowrap;
    +  display: inline-flex;
    +  align-items: center;
    +  gap: 8px;
    +  min-height: 44px;
    +  padding: 10px 20px;
       border-radius: 999px;
    -  border: 0;
    -  background: #111;
    -  color: #fff;
    +  border: 1px solid rgb(0 0 0 / 12%);
    +  background: rgb(248 248 248 / 97%);
    +  color: #111;
       font-family: var(--font-inter);
    -  font-size: 14px;
    +  font-size: 15px;
       font-weight: 600;
       text-decoration: none;
       cursor: pointer;
    -  box-shadow: 0 6px 18px rgb(0 0 0 / 25%);
    +  box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
    +}
    +.hero-demo-play::before {
    +  content: '▶';
    +  font-size: 10px;
    +  line-height: 1;
     }
     .hero-demo-play:focus-visible,
     .hero-demo-fallback:focus-visible {
    -  outline: 2px solid #fff;
    +  outline: 2px solid #111;
       outline-offset: 2px;
    +  box-shadow: 0 0 0 4px rgb(255 255 255 / 90%);
     }
     @media (prefers-reduced-motion: reduce) {
       .hero-demo-iframe,
    @@ -1459,6 +1533,27 @@
         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.
    + *
    + * This is damage control on a desktop asset, not a mobile design: a poster
    + * captured at phone width is the real fix. */
    +@media (max-width: 767px) {
    +  .hero-demo-stage {
    +    aspect-ratio: 4 / 5;
    +  }
    +  .hero-demo-poster {
    +    object-position: 40% top;
    +  }
    +}
     
     /* ---------- RuntimeParity — components/landing/RuntimeParity.tsx ---------- */
     .parity {
    diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts
    index 2ca79d7e5..a3d2a901d 100644
    --- a/apps/website/src/styles/style-contracts.spec.ts
    +++ b/apps/website/src/styles/style-contracts.spec.ts
    @@ -214,6 +214,44 @@ const CONTRACTS: StyleContract[] = [
           'grid-template-columns': /grid-template-columns:/,
         },
       },
    +  {
    +    file: 'landing.css',
    +    selector: '.shiki[data-ui="highlighted-code"] > pre.shiki',
    +    why: 'A code pane that clips is silent: macOS overlay scrollbars draw nothing until a scroll starts (measured — `scrollbar-width` and `::-webkit-scrollbar` both leave 0px of layout gutter there), so the runtime-parity pane shipped ending mid-string at `assistantId: \'agent` (scrollWidth 609 / clientWidth 550 at 1440px) and looked complete. Wrapping is the fix, not the scrollbar: `pre-wrap` folds at existing whitespace and `break-word` catches the run that has none. Losing either leaves a pane that hides its payload with no affordance.',
    +    requires: {
    +      'white-space': /white-space:\s*pre-wrap/,
    +      'overflow-wrap': /overflow-wrap:\s*break-word/,
    +      'overflow-x': /overflow-x:\s*auto/,
    +    },
    +  },
    +  {
    +    file: 'landing.css',
    +    selector: '.install-dialog-command',
    +    why: 'The install command is the primary CTA\'s entire payload — 774px of shell in a 470px pane, so the reader saw `npm install @threadplane/chat @threadplane/langgraph @langcha…`. It is one line with no meaningful breaks, so it must wrap rather than scroll. Losing this silently truncates the one string the page exists to hand over.',
    +    requires: {
    +      'white-space': /white-space:\s*pre-wrap/,
    +      'overflow-wrap': /overflow-wrap:\s*anywhere/,
    +    },
    +  },
    +  {
    +    file: 'ui.css',
    +    selector: '[data-ui="button"]:focus-visible',
    +    why: "Without this the UA default `outline: auto 1px rgb(0, 95, 204)` draws a blue hairline on the primary button's own #004090 fill — invisible. Closing the install dialog returns focus to that button, so the keyboard user is left with no idea where they are. The ring uses --color-accent, which the dark section scope re-points, so it survives on both surfaces.",
    +    requires: {
    +      outline: /outline:\s*2px\s+solid\s+var\(--color-accent\)/,
    +      'outline-offset': /outline-offset:\s*2px/,
    +    },
    +  },
    +  {
    +    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.',
    +    requires: {
    +      background: /background:\s*rgb\(248 248 248/,
    +      border: /border:\s*1px solid/,
    +      'min-height': /min-height:\s*44px/,
    +    },
    +  },
     ];
     
     function baseDeclarationsFor(css: string, selector: string): string {
    diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css
    index 2df245c73..bc467d381 100644
    --- a/apps/website/src/styles/ui.css
    +++ b/apps/website/src/styles/ui.css
    @@ -245,6 +245,24 @@
       border: 1px solid transparent;
       box-shadow: none;
     }
    +/* Keyboard focus ring.
    + *
    + * Without this the UA default applies — `outline: auto 1px rgb(0, 95, 204)` in
    + * Chrome — which is a thin blue hairline drawn against the primary button's
    + * own `--color-accent` (#004090) fill. Effectively invisible, and the place it
    + * matters most is the one place focus lands programmatically: closing the
    + * install dialog returns focus to the "Install Threadplane" trigger, and the
    + * keyboard user could not see where they were.
    + *
    + * The offset ring is `--color-accent`, which the dark section scope above
    + * re-points to #64c3fd, so it stays high-contrast on both surfaces. The inner
    + * `--color-surface` halo separates ring from fill on `primary`, where fill and
    + * ring are otherwise the same colour family. Held by a style contract. */
    +[data-ui="button"]:focus-visible {
    +  outline: 2px solid var(--color-accent);
    +  outline-offset: 2px;
    +  box-shadow: 0 0 0 2px var(--color-surface);
    +}
     
     /* UI primitive — FAQ.
      * The summary::-webkit-details-marker hide, open-state chevron rotation, and