{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}
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();
}