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/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
    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..84e102df9 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
    @@ -173,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; }
         `,
       ],
    @@ -245,11 +264,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 +294,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 +304,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 +329,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();
       }