Skip to content

Commit 4d25cfb

Browse files
bloveclaude
andauthored
feat(website): the stage rail is a completeness ledger (#1043)
* docs: spec — stage rail redesign as a completeness ledger Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: plan — stage rail redesign as a completeness ledger Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): stage rail copy becomes a four-claim ledger Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): stage proof lines derived from the recording Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): beat settle points, segment states, and the hold and closing cues Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): stage publisher drives the segment bar and the checks Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): the stage rail is a completeness ledger — segments, checks, one claim, one link, one proof Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): stage proof counts the interrupts, pins every line, and traces the recording Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(website): beat map polish and a rewind test for the rail state Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): stage stills carry the ledger Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): stage rail — visible cue owns the pointer, segments are keyboardable, anchors resolve, hold and ledger layout Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): stage e2e follows the ledger rail The scroll test now asserts the rail's anatomy: segment state and check fill at the Tools boundary, the one hold line on its plateau, and at the end the ledger at full opacity with four filled checks, the pointer, and the install command the copy declares. A new test clicks the Persist segment and polls act progress into the persist window. The scroll-craft harness reported the hold line and the Render block never reaching full opacity: their 30%-of-window default ramps left the plateaus between the harness's eight sample points, and the ledger's 60% ramp-in only reached 0.43 at the last in-act sample (masked in the report by a text-key collision with the Tools block). The hold line now overshoots the threshold by 20% of the approve span with a 0.3/0.2 ramp, the Render block arrives over the first tenth of its window, and the ledger ramps in over a fifth of the render tail. A spec case models the engine's ramps and requires every rail cue to sit on its plateau at one of the harness's sample points. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(website): the ledger cue keeps real margin at the harness sample, and plateaus are asserted independently of it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9fbaf8f commit 4d25cfb

20 files changed

Lines changed: 2176 additions & 243 deletions

apps/website/e2e/home-stage.spec.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { test, expect, type Page } from '@playwright/test';
2+
import { STAGE_CLOSE } from '../src/lib/positioning';
23

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

59-
test('scroll drives the act: progress, cues, and the declared hold', async ({
60+
test('scroll drives the act: segments, checks, the hold, and the ledger', async ({
6061
page,
6162
}) => {
6263
await page.setViewportSize({ width: 1440, height: 900 });
6364
await page.goto('/');
6465
// With the rAF settle this takes ~1-2 s, well inside StageAct's 8 s
6566
// READY_TIMEOUT_MS after which the act is swapped for the stills.
6667
await expect(page.locator('html')).toHaveClass(/sc-ready/);
67-
await scrollAct(page, 0.05);
68-
expect(await progress(page)).toBeGreaterThan(0);
68+
const act = page.locator('[data-stage-act]');
69+
const tools = page.locator('[data-stage-segment="stream"]');
6970
const stream = page
7071
.getByTestId('stage-rail-beat')
7172
.and(page.locator('[data-beat="stream"]'));
73+
const streamCheck = stream.locator('[data-stage-check]');
74+
// Inside the Tools beat (0..0.2167): the segment is `now`, the block is
75+
// the greeting cue (full from p = 0), and its check is not yet filled.
76+
await scrollAct(page, 0.05);
77+
expect(await progress(page)).toBeGreaterThan(0);
78+
await expect(tools).toHaveAttribute('data-beat-state', 'now');
7279
await expect(stream).toHaveCSS('opacity', '1');
80+
await expect(streamCheck).not.toHaveAttribute('data-checked', '');
81+
// Past the Tools settle point (its window end): done and checked.
82+
await scrollAct(page, 0.3);
83+
await expect(tools).toHaveAttribute('data-beat-state', 'done');
84+
await expect(streamCheck).toHaveAttribute('data-checked', '');
7385
// Inside the approve hold: approve spans 0.4167..0.8167 of the act and the
74-
// hold is 35–70% of it (0.5567..0.6967). 0.68 also sits on the last hold
75-
// line's plateau (its cue opens at 0.65, full from ~0.678).
86+
// hold is 35–70% of it (0.5567..0.6967). The one hold line is cued across
87+
// that range, so 0.68 sits on its plateau.
7688
await scrollAct(page, 0.68);
77-
await expect(page.locator('[data-stage-act]')).toHaveAttribute(
78-
'data-sc-verify-hold',
79-
'true'
80-
);
81-
await expect(page.getByTestId('stage-rail-hold').last()).toHaveCSS(
89+
await expect(act).toHaveAttribute('data-sc-verify-hold', 'true');
90+
await expect(page.getByTestId('stage-rail-hold')).toHaveCSS(
8291
'opacity',
8392
/^(0\.[5-9]\d*|1)$/
8493
);
8594
await scrollAct(page, 0.8);
86-
await expect(page.locator('[data-stage-act]')).not.toHaveAttribute(
87-
'data-sc-verify-hold',
88-
'true'
89-
);
95+
await expect(act).not.toHaveAttribute('data-sc-verify-hold', 'true');
96+
// The end: the ledger is fully in, owns the pointer, every check is
97+
// filled, and the install command is the one the copy declares.
9098
await scrollAct(page, 1);
91-
await expect(
92-
page
93-
.getByTestId('stage-rail-beat')
94-
.and(page.locator('[data-beat="render"]'))
95-
).toHaveCSS('opacity', '1');
99+
const close = page.getByTestId('stage-rail-close');
100+
await expect(close).toHaveCSS('opacity', '1');
101+
await expect(close).toHaveAttribute('data-active', '');
102+
await expect(close.locator('[data-stage-check][data-checked]')).toHaveCount(
103+
4
104+
);
105+
await expect(close.locator('code')).toHaveText(STAGE_CLOSE.install);
106+
});
107+
108+
test('a segment click scrolls the act to its beat', async ({ page }) => {
109+
await page.setViewportSize({ width: 1440, height: 900 });
110+
await page.goto('/');
111+
await expect(page.locator('html')).toHaveClass(/sc-ready/);
112+
await scrollAct(page, 0.05);
113+
await page.locator('[data-stage-segment="persist"]').click();
114+
// Persist owns 0.2167..0.4167 of the act; the click lands 2% inside it.
115+
await expect
116+
.poll(() => progress(page), { timeout: 2_000 })
117+
.toBeGreaterThan(0.2167);
118+
expect(await progress(page)).toBeLessThan(0.4167);
96119
});
97120

98121
test('the frame answers and the verify state changes between positions', async ({

apps/website/next.config.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export const nextConfig: WithNxOptions = {
2424
'content/docs/**/*.mdx',
2525
// Growth validates blog observations against the catalog at request time.
2626
'content/blog/**/*.mdx',
27+
// The homepage stage proof lines are derived from the demo recording at
28+
// build time; traced as a safety net so a runtime read cannot 500.
29+
'../../examples/chat/angular/public/stage-replay.json',
2730
],
2831
},
2932
skipTrailingSlashRedirect: true,
@@ -32,8 +35,16 @@ export const nextConfig: WithNxOptions = {
3235
// deletion, so every retired path lands on /privacy rather than a 404.
3336
redirects: async () => [
3437
{ source: '/docs/telemetry', destination: '/privacy', permanent: true },
35-
{ source: '/docs/telemetry/:path*', destination: '/privacy', permanent: true },
36-
{ source: '/api/markdown/telemetry', destination: '/privacy', permanent: true },
38+
{
39+
source: '/docs/telemetry/:path*',
40+
destination: '/privacy',
41+
permanent: true,
42+
},
43+
{
44+
source: '/api/markdown/telemetry',
45+
destination: '/privacy',
46+
permanent: true,
47+
},
3748
{
3849
source: '/api/markdown/telemetry/:path*',
3950
destination: '/privacy',

apps/website/src/app/page.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { HomeFAQ } from '../components/landing/HomeFAQ';
77
import { FinalCTA } from '../components/landing/FinalCTA';
88
import { RecentArticles } from '../components/landing/RecentArticles';
99
import { PROVE_IT_ROWS } from '../lib/positioning';
10+
// The homepage must stay statically rendered (no cookies()/headers()/dynamic):
11+
// the proof lines are read from the demo recording at build time, and the file
12+
// is traced into the deployment only as a safety net.
13+
import { STAGE_PROOF } from '../lib/stage-proof';
1014
import {
1115
createPageMetadata,
1216
HERO_SECONDARY_HREF,
@@ -35,18 +39,31 @@ export default function HomePage() {
3539
{/* The four capability beats (stream, persist, approve, render): stills
3640
by default, the pinned live act on wide, motion-tolerant viewports
3741
(live-stage spec §3, §8). Copy lives in STAGE_RAIL (positioning.ts). */}
38-
<Stage />
42+
<Stage proof={STAGE_PROOF} />
3943

4044
<FinalCTA
4145
variant="dark"
4246
rows={PROVE_IT_ROWS}
4347
headline="Prove the Angular UI before you connect the backend."
4448
subtext="Start with a fake agent, render a real Threadplane surface, then swap in LangGraph or AG-UI when the integration is ready."
45-
primary={{ label: 'Start the quickstart', href: INSTALL_OPTIONS[0].quickstartHref, ctaId: 'hero_quickstart' }}
46-
secondary={{ label: 'Run live examples', href: HERO_SECONDARY_HREF, ctaId: 'hero_live_demo' }}
49+
primary={{
50+
label: 'Start the quickstart',
51+
href: INSTALL_OPTIONS[0].quickstartHref,
52+
ctaId: 'hero_quickstart',
53+
}}
54+
secondary={{
55+
label: 'Run live examples',
56+
href: HERO_SECONDARY_HREF,
57+
ctaId: 'hero_live_demo',
58+
}}
4759
caption="MIT · no account, no cloud"
4860
captionLink={{ label: 'Talk to an engineer', href: '/contact' }}
49-
captionLinks={[{ label: 'Setup prompt for coding agents', href: '/docs/chat/getting-started/coding-agents' }]}
61+
captionLinks={[
62+
{
63+
label: 'Setup prompt for coding agents',
64+
href: '/docs/chat/getting-started/coding-agents',
65+
},
66+
]}
5067
/>
5168
<TeamsBlock formPolicy={formPolicy} />
5269
<HomeFAQ />

apps/website/src/components/landing/Stage.spec.tsx

Lines changed: 119 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ import { render, screen } from '@testing-library/react';
44
import { afterEach, describe, expect, it, vi } from 'vitest';
55
import { Stage } from './Stage';
66
import { engineRoot } from './StageAct';
7+
import {
8+
STAGE_BEATS,
9+
beatWindows,
10+
type StageBeat,
11+
} from '../../lib/stage-beats';
12+
13+
/** Stands in for `STAGE_PROOF`: the page derives these from the recording. */
14+
const PROOF: Record<StageBeat, string> = {
15+
stream: '312 events · 1 tool call · 3 sources',
16+
persist: 'reloaded · 10 checkpoints · forked at step 1',
17+
approve: '1 interrupt pending · checkpoint 10 of 10',
18+
render: '1 surface · 6 components · no generated code ran',
19+
};
720

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

7393
it('keeps the stills under reduced motion on a wide viewport', async () => {
7494
mockViewport(1440, true);
75-
render(<Stage />);
95+
render(<Stage proof={PROOF} />);
7696
await flush();
7797
expect(screen.getAllByTestId('stage-still-beat')).toHaveLength(4);
7898
expect(document.querySelector('[data-stage-act]')).toBeNull();
@@ -93,19 +113,42 @@ describe('Stage', () => {
93113
);
94114
const mount = vi.fn();
95115
window.ScrollCraft = { mount, reduce: false, instances: [] } as never;
96-
render(<Stage />);
116+
render(<Stage proof={PROOF} />);
97117
await flush();
98118
const actEl = document.querySelector('[data-stage-act]');
99119
expect(actEl).not.toBeNull();
100120
expect(actEl?.getAttribute('data-sc-act')).toBe('pin');
101121
expect(actEl?.getAttribute('data-sc-span')).toBe('6');
102122
expect(actEl?.getAttribute('data-state')).toBe('mounting');
103123
expect(actEl?.querySelector('[data-sc-stage]')).not.toBeNull();
104-
// 4 beats + 3 hold lines
124+
// The rail: the segment bar, four beat blocks stacked in one cell, one
125+
// hold line, and the closing ledger.
126+
const act = actEl!;
127+
expect(act.querySelectorAll('[data-stage-segment]')).toHaveLength(4);
128+
expect(
129+
[...act.querySelectorAll('[data-stage-segment]')].map(
130+
(s) => s.textContent
131+
)
132+
).toEqual(['Tools', 'Persist', 'Approve', 'Render']);
133+
expect(
134+
act.querySelectorAll('[data-testid="stage-rail-beat"]')
135+
).toHaveLength(4);
136+
// One check per beat block, four in the ledger.
137+
expect(act.querySelectorAll('[data-stage-check]')).toHaveLength(4 + 4);
138+
expect(
139+
act.querySelector('[data-testid="stage-rail-hold"]')!.textContent
140+
).toBe('Keep scrolling to approve.');
141+
expect(
142+
act.querySelector('[data-testid="stage-rail-close"]')
143+
).not.toBeNull();
105144
expect(
106-
actEl?.querySelectorAll('[data-sc-cue]').length
107-
).toBeGreaterThanOrEqual(7);
108-
expect(actEl?.querySelectorAll('.stage-rail-beat')).toHaveLength(4);
145+
act.querySelector('[data-testid="stage-rail-close"]')!.textContent
146+
).toContain('Feature complete for the final mile.');
147+
expect(
148+
act.querySelector(
149+
'[data-testid="stage-rail-beat"][data-beat="stream"] [data-stage-proof]'
150+
)!.textContent
151+
).toBe(PROOF.stream);
109152
expect(screen.queryAllByTestId('stage-still-beat')).toHaveLength(0);
110153
// The engine collects acts with root.querySelectorAll('[data-sc-act]'),
111154
// which matches descendants only — so the mount root must contain the act
@@ -115,18 +158,82 @@ describe('Stage', () => {
115158
expect(root).not.toBe(actEl);
116159
expect(root === document || root.contains(actEl)).toBe(true);
117160
expect(root.querySelectorAll('[data-sc-act]')).toContain(actEl);
118-
// Keyboard path: the pin is skippable, and the opacity-hidden rail CTAs
119-
// are out of the tab order.
161+
// Keyboard path: the pin is skippable, the segment bar stays in the tab
162+
// order (it is always visible), and the opacity-hidden cue CTAs are out.
120163
const skip = actEl?.querySelector('a.stage-skip');
121164
expect(skip?.getAttribute('href')).toBe('#stage-end');
122165
expect(document.getElementById('stage-end')).not.toBeNull();
123-
const ctas = actEl?.querySelectorAll('.stage-rail-beat .feature-block-cta');
124-
expect(ctas).toHaveLength(4);
125-
ctas?.forEach((a) => expect(a.getAttribute('tabindex')).toBe('-1'));
166+
const segments = actEl!.querySelectorAll('a.stage-seg');
167+
expect(segments).toHaveLength(4);
168+
segments.forEach((a) => expect(a.hasAttribute('tabindex')).toBe(false));
169+
const cueLinks = actEl!.querySelectorAll(
170+
'.stage-rail-beat a, .stage-rail-close a'
171+
);
172+
expect(cueLinks).toHaveLength(4 + 4 + 1);
173+
cueLinks.forEach((a) => expect(a.getAttribute('tabindex')).toBe('-1'));
174+
// Each segment's href resolves to its beat block, so the anchor works
175+
// even when the click handler does not run.
176+
for (const b of STAGE_BEATS) {
177+
const seg = actEl!.querySelector(`[data-stage-segment="${b}"]`);
178+
expect(seg?.getAttribute('href')).toBe(`#stage-${b}`);
179+
const block = document.getElementById(`stage-${b}`);
180+
expect(block?.getAttribute('data-stage-beat')).toBe(b);
181+
}
182+
expect(
183+
actEl!
184+
.querySelector('[data-testid="stage-rail-close"]')!
185+
.hasAttribute('data-stage-close')
186+
).toBe(true);
126187
const iframe = actEl?.querySelector('iframe');
127188
expect(iframe?.getAttribute('src')).toBe(
128189
'https://demo.threadplane.ai/stage?t=0'
129190
);
130191
expect(iframe?.getAttribute('tabindex')).toBe('-1');
131192
});
193+
194+
it('a segment click scrolls the page to the start of that beat', async () => {
195+
mockViewport(1440, false);
196+
vi.stubGlobal(
197+
'IntersectionObserver',
198+
class {
199+
observe() {
200+
/* no-op */
201+
}
202+
disconnect() {
203+
/* no-op */
204+
}
205+
}
206+
);
207+
window.ScrollCraft = {
208+
mount: vi.fn(),
209+
reduce: false,
210+
instances: [],
211+
} as never;
212+
const scrollTo = vi.fn();
213+
vi.stubGlobal('scrollTo', scrollTo);
214+
Object.defineProperty(window, 'innerHeight', {
215+
configurable: true,
216+
value: 900,
217+
});
218+
render(<Stage proof={PROOF} />);
219+
await flush();
220+
const section = document.querySelector('[data-stage-act]') as HTMLElement;
221+
// The engine sets the act's height to STAGE_SPAN viewports; jsdom does
222+
// not lay out, so the travel is given here.
223+
Object.defineProperty(section, 'offsetHeight', {
224+
configurable: true,
225+
value: 6000,
226+
});
227+
const travel = 6000 - 900;
228+
const persist = section.querySelector(
229+
'[data-stage-segment="persist"]'
230+
) as HTMLAnchorElement;
231+
persist.click();
232+
expect(scrollTo).toHaveBeenCalledTimes(1);
233+
const arg = scrollTo.mock.calls[0][0] as ScrollToOptions;
234+
expect(arg.behavior).toBe('smooth');
235+
const w = beatWindows()[STAGE_BEATS.indexOf('persist')];
236+
expect(arg.top).toBeGreaterThanOrEqual(travel * w.from);
237+
expect(arg.top).toBeLessThan(travel * w.to);
238+
});
132239
});

apps/website/src/components/landing/Stage.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useEffect, useState } from 'react';
33
import { StageStills } from './StageStills';
44
import { StageAct } from './StageAct';
5+
import type { StageBeat } from '../../lib/stage-beats';
56

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

16+
interface Props {
17+
/** One proof line per beat (`STAGE_PROOF`): derived on the server, handed down as a plain object. */
18+
proof: Record<StageBeat, string>;
19+
}
20+
1521
/**
1622
* Spec §8: the stills are the default (no JS, narrow, reduced motion, frame
1723
* failure); the pinned act is an upgrade decided after hydration so the server
@@ -24,15 +30,15 @@ function actAllowed(): boolean {
2430
* visitor has scrolled far. `#stage-end` is the skip-link target rendered in
2531
* act mode only: the stills have no hidden focusables to skip.
2632
*/
27-
export function Stage() {
33+
export function Stage({ proof }: Props) {
2834
const [mode, setMode] = useState<Mode>('stills');
2935
useEffect(() => {
3036
if (actAllowed()) setMode('act');
3137
}, []);
32-
if (mode === 'stills') return <StageStills />;
38+
if (mode === 'stills') return <StageStills proof={proof} />;
3339
return (
3440
<>
35-
<StageAct onFallback={() => setMode('stills')} />
41+
<StageAct onFallback={() => setMode('stills')} proof={proof} />
3642
<span id="stage-end" tabIndex={-1} className="sr-only" />
3743
</>
3844
);

0 commit comments

Comments
 (0)