Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/website/src/components/landing/HeroDemo.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<HeroDemo />);
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(<HeroDemo />);
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');
Expand Down Expand Up @@ -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);
});
Expand Down
50 changes: 37 additions & 13 deletions apps/website/src/components/landing/HeroDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,20 @@ export function HeroDemo() {
const [state, setState] = useState<State>('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<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const lastFrameState = useRef<string | null>(null);
/** Read by the message handler, which is registered once and never re-bound. */
const visibleRef = useRef(false);

// Visibility.
useEffect(() => {
Expand Down Expand Up @@ -59,33 +70,48 @@ 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' });
if (d.state === 'replay') trackCtaClick({ cta_id: 'hero_demo_replay', track: 'developer', surface: 'home' });
};
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' });
Expand Down Expand Up @@ -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 ? (
Expand Down
6 changes: 5 additions & 1 deletion apps/website/src/components/landing/InstallDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ export function InstallDialog({ open, onClose }: InstallDialogProps) {

<li className="install-dialog-step">
<h3 className="install-dialog-step-title">Run this in your Angular project</h3>
<pre className="install-dialog-code"><code data-testid="install-command">{option.command}</code></pre>
{/* 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. */}
<pre className="install-dialog-code install-dialog-command"><code data-testid="install-command">{option.command}</code></pre>
<p className="install-dialog-step-note">{option.peersNote}</p>
</li>

Expand Down
113 changes: 104 additions & 9 deletions apps/website/src/styles/landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pre> 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;
Expand Down Expand Up @@ -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); }

Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions apps/website/src/styles/style-contracts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions apps/website/src/styles/ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading