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
61 changes: 42 additions & 19 deletions apps/website/e2e/home-stage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect, type Page } from '@playwright/test';
import { STAGE_CLOSE } from '../src/lib/positioning';

/** Drives the pinned act: the section is 6 viewports tall; scroll to a fraction of its travel. */
async function scrollAct(page: Page, p: number) {
Expand Down Expand Up @@ -56,43 +57,65 @@ test.describe('homepage stage', () => {
expect(Math.abs(heights.act - 6 * heights.viewport)).toBeLessThanOrEqual(4);
});

test('scroll drives the act: progress, cues, and the declared hold', async ({
test('scroll drives the act: segments, checks, the hold, and the ledger', async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
// With the rAF settle this takes ~1-2 s, well inside StageAct's 8 s
// READY_TIMEOUT_MS after which the act is swapped for the stills.
await expect(page.locator('html')).toHaveClass(/sc-ready/);
await scrollAct(page, 0.05);
expect(await progress(page)).toBeGreaterThan(0);
const act = page.locator('[data-stage-act]');
const tools = page.locator('[data-stage-segment="stream"]');
const stream = page
.getByTestId('stage-rail-beat')
.and(page.locator('[data-beat="stream"]'));
const streamCheck = stream.locator('[data-stage-check]');
// Inside the Tools beat (0..0.2167): the segment is `now`, the block is
// the greeting cue (full from p = 0), and its check is not yet filled.
await scrollAct(page, 0.05);
expect(await progress(page)).toBeGreaterThan(0);
await expect(tools).toHaveAttribute('data-beat-state', 'now');
await expect(stream).toHaveCSS('opacity', '1');
await expect(streamCheck).not.toHaveAttribute('data-checked', '');
// Past the Tools settle point (its window end): done and checked.
await scrollAct(page, 0.3);
await expect(tools).toHaveAttribute('data-beat-state', 'done');
await expect(streamCheck).toHaveAttribute('data-checked', '');
// Inside the approve hold: approve spans 0.4167..0.8167 of the act and the
// hold is 35–70% of it (0.5567..0.6967). 0.68 also sits on the last hold
// line's plateau (its cue opens at 0.65, full from ~0.678).
// hold is 35–70% of it (0.5567..0.6967). The one hold line is cued across
// that range, so 0.68 sits on its plateau.
await scrollAct(page, 0.68);
await expect(page.locator('[data-stage-act]')).toHaveAttribute(
'data-sc-verify-hold',
'true'
);
await expect(page.getByTestId('stage-rail-hold').last()).toHaveCSS(
await expect(act).toHaveAttribute('data-sc-verify-hold', 'true');
await expect(page.getByTestId('stage-rail-hold')).toHaveCSS(
'opacity',
/^(0\.[5-9]\d*|1)$/
);
await scrollAct(page, 0.8);
await expect(page.locator('[data-stage-act]')).not.toHaveAttribute(
'data-sc-verify-hold',
'true'
);
await expect(act).not.toHaveAttribute('data-sc-verify-hold', 'true');
// The end: the ledger is fully in, owns the pointer, every check is
// filled, and the install command is the one the copy declares.
await scrollAct(page, 1);
await expect(
page
.getByTestId('stage-rail-beat')
.and(page.locator('[data-beat="render"]'))
).toHaveCSS('opacity', '1');
const close = page.getByTestId('stage-rail-close');
await expect(close).toHaveCSS('opacity', '1');
await expect(close).toHaveAttribute('data-active', '');
await expect(close.locator('[data-stage-check][data-checked]')).toHaveCount(
4
);
await expect(close.locator('code')).toHaveText(STAGE_CLOSE.install);
});

test('a segment click scrolls the act to its beat', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
await expect(page.locator('html')).toHaveClass(/sc-ready/);
await scrollAct(page, 0.05);
await page.locator('[data-stage-segment="persist"]').click();
// Persist owns 0.2167..0.4167 of the act; the click lands 2% inside it.
await expect
.poll(() => progress(page), { timeout: 2_000 })
.toBeGreaterThan(0.2167);
expect(await progress(page)).toBeLessThan(0.4167);
});

test('the frame answers and the verify state changes between positions', async ({
Expand Down
15 changes: 13 additions & 2 deletions apps/website/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const nextConfig: WithNxOptions = {
'content/docs/**/*.mdx',
// Growth validates blog observations against the catalog at request time.
'content/blog/**/*.mdx',
// The homepage stage proof lines are derived from the demo recording at
// build time; traced as a safety net so a runtime read cannot 500.
'../../examples/chat/angular/public/stage-replay.json',
],
},
skipTrailingSlashRedirect: true,
Expand All @@ -32,8 +35,16 @@ export const nextConfig: WithNxOptions = {
// deletion, so every retired path lands on /privacy rather than a 404.
redirects: async () => [
{ source: '/docs/telemetry', destination: '/privacy', permanent: true },
{ source: '/docs/telemetry/:path*', destination: '/privacy', permanent: true },
{ source: '/api/markdown/telemetry', destination: '/privacy', permanent: true },
{
source: '/docs/telemetry/:path*',
destination: '/privacy',
permanent: true,
},
{
source: '/api/markdown/telemetry',
destination: '/privacy',
permanent: true,
},
{
source: '/api/markdown/telemetry/:path*',
destination: '/privacy',
Expand Down
25 changes: 21 additions & 4 deletions apps/website/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { HomeFAQ } from '../components/landing/HomeFAQ';
import { FinalCTA } from '../components/landing/FinalCTA';
import { RecentArticles } from '../components/landing/RecentArticles';
import { PROVE_IT_ROWS } from '../lib/positioning';
// The homepage must stay statically rendered (no cookies()/headers()/dynamic):
// the proof lines are read from the demo recording at build time, and the file
// is traced into the deployment only as a safety net.
import { STAGE_PROOF } from '../lib/stage-proof';
import {
createPageMetadata,
HERO_SECONDARY_HREF,
Expand Down Expand Up @@ -35,18 +39,31 @@ export default function HomePage() {
{/* The four capability beats (stream, persist, approve, render): stills
by default, the pinned live act on wide, motion-tolerant viewports
(live-stage spec §3, §8). Copy lives in STAGE_RAIL (positioning.ts). */}
<Stage />
<Stage proof={STAGE_PROOF} />

<FinalCTA
variant="dark"
rows={PROVE_IT_ROWS}
headline="Prove the Angular UI before you connect the backend."
subtext="Start with a fake agent, render a real Threadplane surface, then swap in LangGraph or AG-UI when the integration is ready."
primary={{ label: 'Start the quickstart', href: INSTALL_OPTIONS[0].quickstartHref, ctaId: 'hero_quickstart' }}
secondary={{ label: 'Run live examples', href: HERO_SECONDARY_HREF, ctaId: 'hero_live_demo' }}
primary={{
label: 'Start the quickstart',
href: INSTALL_OPTIONS[0].quickstartHref,
ctaId: 'hero_quickstart',
}}
secondary={{
label: 'Run live examples',
href: HERO_SECONDARY_HREF,
ctaId: 'hero_live_demo',
}}
caption="MIT · no account, no cloud"
captionLink={{ label: 'Talk to an engineer', href: '/contact' }}
captionLinks={[{ label: 'Setup prompt for coding agents', href: '/docs/chat/getting-started/coding-agents' }]}
captionLinks={[
{
label: 'Setup prompt for coding agents',
href: '/docs/chat/getting-started/coding-agents',
},
]}
/>
<TeamsBlock formPolicy={formPolicy} />
<HomeFAQ />
Expand Down
131 changes: 119 additions & 12 deletions apps/website/src/components/landing/Stage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Stage } from './Stage';
import { engineRoot } from './StageAct';
import {
STAGE_BEATS,
beatWindows,
type StageBeat,
} from '../../lib/stage-beats';

/** Stands in for `STAGE_PROOF`: the page derives these from the recording. */
const PROOF: Record<StageBeat, string> = {
stream: '312 events · 1 tool call · 3 sources',
persist: 'reloaded · 10 checkpoints · forked at step 1',
approve: '1 interrupt pending · checkpoint 10 of 10',
render: '1 surface · 6 components · no generated code ran',
};

// The engine is an IIFE that reads matchMedia at load and measures layout on
// mount; neither is meaningful in jsdom. The act only needs the global.
Expand Down Expand Up @@ -64,15 +77,22 @@ describe('engineRoot', () => {
describe('Stage', () => {
it('renders the stills on the server and keeps them on a narrow viewport', async () => {
mockViewport(390, false);
render(<Stage />);
render(<Stage proof={PROOF} />);
await flush();
expect(screen.getAllByTestId('stage-still-beat')).toHaveLength(4);
expect(document.querySelector('[data-stage-act]')).toBeNull();
// The real stills, carrying the same proof and the ledger ending.
expect(
document.querySelector(
'[data-testid="stage-still-beat"][data-beat="stream"] [data-stage-proof]'
)!.textContent
).toBe(PROOF.stream);
expect(screen.getByTestId('stage-stills-close')).toBeTruthy();
});

it('keeps the stills under reduced motion on a wide viewport', async () => {
mockViewport(1440, true);
render(<Stage />);
render(<Stage proof={PROOF} />);
await flush();
expect(screen.getAllByTestId('stage-still-beat')).toHaveLength(4);
expect(document.querySelector('[data-stage-act]')).toBeNull();
Expand All @@ -93,19 +113,42 @@ describe('Stage', () => {
);
const mount = vi.fn();
window.ScrollCraft = { mount, reduce: false, instances: [] } as never;
render(<Stage />);
render(<Stage proof={PROOF} />);
await flush();
const actEl = document.querySelector('[data-stage-act]');
expect(actEl).not.toBeNull();
expect(actEl?.getAttribute('data-sc-act')).toBe('pin');
expect(actEl?.getAttribute('data-sc-span')).toBe('6');
expect(actEl?.getAttribute('data-state')).toBe('mounting');
expect(actEl?.querySelector('[data-sc-stage]')).not.toBeNull();
// 4 beats + 3 hold lines
// The rail: the segment bar, four beat blocks stacked in one cell, one
// hold line, and the closing ledger.
const act = actEl!;
expect(act.querySelectorAll('[data-stage-segment]')).toHaveLength(4);
expect(
[...act.querySelectorAll('[data-stage-segment]')].map(
(s) => s.textContent
)
).toEqual(['Tools', 'Persist', 'Approve', 'Render']);
expect(
act.querySelectorAll('[data-testid="stage-rail-beat"]')
).toHaveLength(4);
// One check per beat block, four in the ledger.
expect(act.querySelectorAll('[data-stage-check]')).toHaveLength(4 + 4);
expect(
act.querySelector('[data-testid="stage-rail-hold"]')!.textContent
).toBe('Keep scrolling to approve.');
expect(
act.querySelector('[data-testid="stage-rail-close"]')
).not.toBeNull();
expect(
actEl?.querySelectorAll('[data-sc-cue]').length
).toBeGreaterThanOrEqual(7);
expect(actEl?.querySelectorAll('.stage-rail-beat')).toHaveLength(4);
act.querySelector('[data-testid="stage-rail-close"]')!.textContent
).toContain('Feature complete for the final mile.');
expect(
act.querySelector(
'[data-testid="stage-rail-beat"][data-beat="stream"] [data-stage-proof]'
)!.textContent
).toBe(PROOF.stream);
expect(screen.queryAllByTestId('stage-still-beat')).toHaveLength(0);
// The engine collects acts with root.querySelectorAll('[data-sc-act]'),
// which matches descendants only — so the mount root must contain the act
Expand All @@ -115,18 +158,82 @@ describe('Stage', () => {
expect(root).not.toBe(actEl);
expect(root === document || root.contains(actEl)).toBe(true);
expect(root.querySelectorAll('[data-sc-act]')).toContain(actEl);
// Keyboard path: the pin is skippable, and the opacity-hidden rail CTAs
// are out of the tab order.
// Keyboard path: the pin is skippable, the segment bar stays in the tab
// order (it is always visible), and the opacity-hidden cue CTAs are out.
const skip = actEl?.querySelector('a.stage-skip');
expect(skip?.getAttribute('href')).toBe('#stage-end');
expect(document.getElementById('stage-end')).not.toBeNull();
const ctas = actEl?.querySelectorAll('.stage-rail-beat .feature-block-cta');
expect(ctas).toHaveLength(4);
ctas?.forEach((a) => expect(a.getAttribute('tabindex')).toBe('-1'));
const segments = actEl!.querySelectorAll('a.stage-seg');
expect(segments).toHaveLength(4);
segments.forEach((a) => expect(a.hasAttribute('tabindex')).toBe(false));
const cueLinks = actEl!.querySelectorAll(
'.stage-rail-beat a, .stage-rail-close a'
);
expect(cueLinks).toHaveLength(4 + 4 + 1);
cueLinks.forEach((a) => expect(a.getAttribute('tabindex')).toBe('-1'));
// Each segment's href resolves to its beat block, so the anchor works
// even when the click handler does not run.
for (const b of STAGE_BEATS) {
const seg = actEl!.querySelector(`[data-stage-segment="${b}"]`);
expect(seg?.getAttribute('href')).toBe(`#stage-${b}`);
const block = document.getElementById(`stage-${b}`);
expect(block?.getAttribute('data-stage-beat')).toBe(b);
}
expect(
actEl!
.querySelector('[data-testid="stage-rail-close"]')!
.hasAttribute('data-stage-close')
).toBe(true);
const iframe = actEl?.querySelector('iframe');
expect(iframe?.getAttribute('src')).toBe(
'https://demo.threadplane.ai/stage?t=0'
);
expect(iframe?.getAttribute('tabindex')).toBe('-1');
});

it('a segment click scrolls the page to the start of that beat', async () => {
mockViewport(1440, false);
vi.stubGlobal(
'IntersectionObserver',
class {
observe() {
/* no-op */
}
disconnect() {
/* no-op */
}
}
);
window.ScrollCraft = {
mount: vi.fn(),
reduce: false,
instances: [],
} as never;
const scrollTo = vi.fn();
vi.stubGlobal('scrollTo', scrollTo);
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 900,
});
render(<Stage proof={PROOF} />);
await flush();
const section = document.querySelector('[data-stage-act]') as HTMLElement;
// The engine sets the act's height to STAGE_SPAN viewports; jsdom does
// not lay out, so the travel is given here.
Object.defineProperty(section, 'offsetHeight', {
configurable: true,
value: 6000,
});
const travel = 6000 - 900;
const persist = section.querySelector(
'[data-stage-segment="persist"]'
) as HTMLAnchorElement;
persist.click();
expect(scrollTo).toHaveBeenCalledTimes(1);
const arg = scrollTo.mock.calls[0][0] as ScrollToOptions;
expect(arg.behavior).toBe('smooth');
const w = beatWindows()[STAGE_BEATS.indexOf('persist')];
expect(arg.top).toBeGreaterThanOrEqual(travel * w.from);
expect(arg.top).toBeLessThan(travel * w.to);
});
});
12 changes: 9 additions & 3 deletions apps/website/src/components/landing/Stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { StageStills } from './StageStills';
import { StageAct } from './StageAct';
import type { StageBeat } from '../../lib/stage-beats';

export const STAGE_MIN_WIDTH = 1024;
type Mode = 'stills' | 'act';
Expand All @@ -12,6 +13,11 @@ function actAllowed(): boolean {
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

interface Props {
/** One proof line per beat (`STAGE_PROOF`): derived on the server, handed down as a plain object. */
proof: Record<StageBeat, string>;
}

/**
* Spec §8: the stills are the default (no JS, narrow, reduced motion, frame
* failure); the pinned act is an upgrade decided after hydration so the server
Expand All @@ -24,15 +30,15 @@ function actAllowed(): boolean {
* visitor has scrolled far. `#stage-end` is the skip-link target rendered in
* act mode only: the stills have no hidden focusables to skip.
*/
export function Stage() {
export function Stage({ proof }: Props) {
const [mode, setMode] = useState<Mode>('stills');
useEffect(() => {
if (actAllowed()) setMode('act');
}, []);
if (mode === 'stills') return <StageStills />;
if (mode === 'stills') return <StageStills proof={proof} />;
return (
<>
<StageAct onFallback={() => setMode('stills')} />
<StageAct onFallback={() => setMode('stills')} proof={proof} />
<span id="stage-end" tabIndex={-1} className="sr-only" />
</>
);
Expand Down
Loading
Loading