diff --git a/apps/website/e2e/home-stage.spec.ts b/apps/website/e2e/home-stage.spec.ts index 173481335..19ca73bb2 100644 --- a/apps/website/e2e/home-stage.spec.ts +++ b/apps/website/e2e/home-stage.spec.ts @@ -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) { @@ -56,7 +57,7 @@ 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 }); @@ -64,35 +65,57 @@ test.describe('homepage stage', () => { // 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 ({ diff --git a/apps/website/next.config.ts b/apps/website/next.config.ts index 0a349ac9a..b40c8809e 100644 --- a/apps/website/next.config.ts +++ b/apps/website/next.config.ts @@ -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, @@ -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', diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index bb8569bd0..d85b5e4c1 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -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, @@ -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). */} - + diff --git a/apps/website/src/components/landing/Stage.spec.tsx b/apps/website/src/components/landing/Stage.spec.tsx index d1e423894..5237224b4 100644 --- a/apps/website/src/components/landing/Stage.spec.tsx +++ b/apps/website/src/components/landing/Stage.spec.tsx @@ -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 = { + 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. @@ -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(); + render(); 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(); + render(); await flush(); expect(screen.getAllByTestId('stage-still-beat')).toHaveLength(4); expect(document.querySelector('[data-stage-act]')).toBeNull(); @@ -93,7 +113,7 @@ describe('Stage', () => { ); const mount = vi.fn(); window.ScrollCraft = { mount, reduce: false, instances: [] } as never; - render(); + render(); await flush(); const actEl = document.querySelector('[data-stage-act]'); expect(actEl).not.toBeNull(); @@ -101,11 +121,34 @@ describe('Stage', () => { 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 @@ -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(); + 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); + }); }); diff --git a/apps/website/src/components/landing/Stage.tsx b/apps/website/src/components/landing/Stage.tsx index 44fe38718..a968bd0fa 100644 --- a/apps/website/src/components/landing/Stage.tsx +++ b/apps/website/src/components/landing/Stage.tsx @@ -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'; @@ -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; +} + /** * 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 @@ -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('stills'); useEffect(() => { if (actAllowed()) setMode('act'); }, []); - if (mode === 'stills') return ; + if (mode === 'stills') return ; return ( <> - setMode('stills')} /> + setMode('stills')} proof={proof} /> ); diff --git a/apps/website/src/components/landing/StageAct.tsx b/apps/website/src/components/landing/StageAct.tsx index ef6690824..1cbf5c894 100644 --- a/apps/website/src/components/landing/StageAct.tsx +++ b/apps/website/src/components/landing/StageAct.tsx @@ -3,12 +3,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { BrowserFrame } from '../ui/BrowserFrame'; import { Container } from '../ui/Container'; -import { Eyebrow } from '../ui/Eyebrow'; -import { STAGE_HOLD_LINES, STAGE_RAIL } from '../../lib/positioning'; import { + HERO_TRUST_LINE, + STAGE_CLOSE, + STAGE_HOLD_LINE, + STAGE_RAIL, +} from '../../lib/positioning'; +import { + STAGE_BEATS, STAGE_SPAN, + beatWindows, + closeCue, cueFor, - holdLineCues, + holdCue, type StageBeat, } from '../../lib/stage-beats'; import { trackStageProgress } from '../../lib/analytics/client'; @@ -45,6 +52,8 @@ const mountedRoots = new WeakSet(); interface Props { onFallback: () => void; + /** One proof line per beat, derived from the recording on the server. */ + proof: Record; } /** @@ -52,7 +61,7 @@ interface Props { * the publisher turns it into `t`; the iframe is the real `/stage`. Nothing in * here sets React state per frame. */ -export function StageAct({ onFallback }: Props) { +export function StageAct({ onFallback, proof }: Props) { const sectionRef = useRef(null); const iframeRef = useRef(null); /** @@ -106,7 +115,23 @@ export function StageAct({ onFallback }: Props) { const onReady = useCallback(() => setReady(true), []); useStagePublisher(sectionRef, true, { frameWindow, track, onReady }); - const holdCues = holdLineCues(STAGE_HOLD_LINES.length); + /** + * A segment click scrolls to the start of that beat: the beat window's + * share of the act's travel (its height minus one viewport, which is what + * the pin scrubs across), nudged 2% in so the engine reports the beat and + * not the boundary. The act is only reached without reduced motion, so the + * smooth behaviour is unconditional. + */ + const scrollToBeat = (beat: StageBeat) => { + const el = sectionRef.current; + if (!el) return; + const top = el.getBoundingClientRect().top + window.scrollY; + const w = beatWindows()[STAGE_BEATS.indexOf(beat)]; + window.scrollTo({ + top: top + (el.offsetHeight - window.innerHeight) * (w.from + 0.02), + behavior: 'smooth', + }); + }; return (
- {/* The rail cues are hidden by opacity only, so their CTAs are taken - out of the tab order (below) and the pin is skippable as a whole. */} + {/* The rail cues are hidden by opacity only, so the links inside them + are out of the tab order (below); the segment bar is always visible + and stays keyboardable, and the pin is skippable as a whole. */} Skip the stage @@ -159,58 +185,117 @@ export function StageAct({ onFallback }: Props) { Open the live demo → + {/* The rail (stage-rail spec §3): the segment bar, then one cell + holding both the cues (one beat block per beat stacked so they + crossfade in place, with the hold line beneath) and the closing + ledger. The cues and the ledger share a cell rather than rows so + the ledger's height cannot push the hold line away from the + block. Segment and check state is written by the publisher, not + React. */}

- One real run: stream, persist, approve, render + One real run: tools, persist, approve, render

- {STAGE_RAIL.map((b) => ( -
-
- - {b.eyebrow} - + + {/* Every block shares one cell and is hidden by opacity alone, so + the stylesheet drops the pointer on all cues and the publisher + hands it back to the block whose beat is `now` (data-beat-state) + and to the ledger once render settles (data-active). */} +
+ {STAGE_RAIL.map((b) => ( +
-

{b.headline}

-

{b.body}

-
- {b.rows.map((row) => ( -
- - {row.claim} - - {row.api} -
- ))} -
+ ))} +

+ {STAGE_HOLD_LINE} +

+
+
+
    + {STAGE_RAIL.map((b) => ( +
  • +
  • + ))} +
+

{STAGE_CLOSE.claim}

+
+ {STAGE_CLOSE.install} - {b.cta.label} → + {STAGE_CLOSE.cta.label} →
- ))} - {STAGE_HOLD_LINES.map((line, i) => ( -

- {line} +

+ {HERO_TRUST_LINE} · LangGraph and AG-UI

- ))} +
diff --git a/apps/website/src/components/landing/StageStills.spec.tsx b/apps/website/src/components/landing/StageStills.spec.tsx index 28acbe1fe..d6102dbc5 100644 --- a/apps/website/src/components/landing/StageStills.spec.tsx +++ b/apps/website/src/components/landing/StageStills.spec.tsx @@ -3,11 +3,20 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { StageStills } from './StageStills'; -import { STAGE_RAIL } from '../../lib/positioning'; +import { STAGE_CLOSE, STAGE_RAIL } from '../../lib/positioning'; +import type { StageBeat } from '../../lib/stage-beats'; + +/** Stands in for `STAGE_PROOF`: the page derives these from the recording. */ +const PROOF: Record = { + 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', +}; describe('StageStills', () => { - it('renders four beats in order, each with its still, phone source, copy rows and cta', () => { - render(); + it('renders four beats in order, each with its still, phone source, and a filled beat block', () => { + render(); const beats = screen.getAllByTestId('stage-still-beat'); expect(beats.map((b) => b.getAttribute('data-beat'))).toEqual([ 'stream', @@ -16,26 +25,65 @@ describe('StageStills', () => { 'render', ]); for (const [i, b] of beats.entries()) { + const rail = STAGE_RAIL[i]; const img = b.querySelector('img')!; expect(img.getAttribute('src')).toBe( - `/screenshots/stage-${STAGE_RAIL[i].beat}.webp` + `/screenshots/stage-${rail.beat}.webp` ); - expect(img.getAttribute('alt')).toBe(STAGE_RAIL[i].stillAlt); + expect(img.getAttribute('alt')).toBe(rail.stillAlt); expect(img.getAttribute('loading')).toBe('lazy'); expect(b.querySelector('source')!.getAttribute('srcset')).toBe( - `/screenshots/stage-${STAGE_RAIL[i].beat}-mobile.webp` + `/screenshots/stage-${rail.beat}-mobile.webp` + ); + // The beat block (stage-rail spec §3.2, §6): the still IS the settle, + // so its check is always filled. + expect(b.querySelector('.stage-check[data-checked]')).not.toBeNull(); + expect(b.querySelector('.stage-claim')!.textContent).toBe(rail.claim); + expect(b.querySelector('a.stage-doc')!.getAttribute('href')).toBe( + rail.docs.href ); - expect(b.querySelectorAll('.feature-block-row')).toHaveLength(3); - expect(b.querySelector('a.feature-block-cta')!.getAttribute('href')).toBe( - STAGE_RAIL[i].cta.href + expect(b.querySelector('[data-stage-proof]')!.textContent).toBe( + PROOF[rail.beat] + ); + } + }); + + it('renders the ledger ending once after the four stills, with focusable links', () => { + render(); + const close = screen.getByTestId('stage-stills-close'); + const items = close.querySelectorAll('.stage-ledger li'); + expect(items).toHaveLength(4); + for (const [i, li] of [...items].entries()) { + expect(li.querySelector('.stage-check[data-checked]')).not.toBeNull(); + expect(li.textContent).toContain(STAGE_RAIL[i].claim); + expect(li.querySelector('a.stage-doc')!.getAttribute('href')).toBe( + STAGE_RAIL[i].docs.href ); } + expect(close.querySelector('.stage-claim')!.textContent).toBe( + STAGE_CLOSE.claim + ); + expect(close.querySelector('.stage-install code')!.textContent).toBe( + STAGE_CLOSE.install + ); + expect( + close.querySelector('a.stage-install-cta')!.getAttribute('href') + ).toBe(STAGE_CLOSE.cta.href); + expect(close.querySelector('.stage-trust')).not.toBeNull(); + // The stills are the no-JS and phone form: nothing here is hidden, so + // nothing is taken out of the tab order. Four beat docs links, four + // ledger links, one CTA. + const anchors = document.querySelectorAll('section#stage a'); + expect(anchors).toHaveLength(4 + 4 + 1); + anchors.forEach((a) => expect(a.hasAttribute('tabindex')).toBe(false)); }); + it('is the section the act replaces, with its anchors', () => { - render(); + render(); expect(document.querySelector('section#stage')).not.toBeNull(); + expect(document.getElementById('stage-heading')).not.toBeNull(); expect( - screen.getByRole('heading', { level: 3, name: STAGE_RAIL[2].headline }) + screen.getByRole('heading', { level: 3, name: STAGE_RAIL[2].claim }) ).toBeTruthy(); }); }); diff --git a/apps/website/src/components/landing/StageStills.tsx b/apps/website/src/components/landing/StageStills.tsx index 48feb97f1..cbf2ee3a4 100644 --- a/apps/website/src/components/landing/StageStills.tsx +++ b/apps/website/src/components/landing/StageStills.tsx @@ -1,8 +1,12 @@ import Link from 'next/link'; import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; -import { Eyebrow } from '../ui/Eyebrow'; -import { STAGE_RAIL } from '../../lib/positioning'; +import { + HERO_TRUST_LINE, + STAGE_CLOSE, + STAGE_RAIL, +} from '../../lib/positioning'; +import type { StageBeat } from '../../lib/stage-beats'; export const STAGE_STILL_MOBILE_MEDIA = '(max-width: 767px)'; const STILL_W = 1200; @@ -10,17 +14,27 @@ const STILL_H = 720; const STILL_MOBILE_W = 585; const STILL_MOBILE_H = 975; +interface Props { + /** One proof line per beat, derived from the recording on the server. */ + proof: Record; +} + /** - * The stage's non-pinned form (spec §8): the same four beats as four stacked - * stills from `/stage`, each with its rail copy. Server-rendered by default; - * `Stage` swaps in the pinned act on wide, motion-tolerant viewports. + * The stage's non-pinned form (spec §8, stage-rail spec §6): the same four + * beats as four stacked stills from `/stage`, each with its beat block, then + * the ledger ending once. Server-rendered by default; `Stage` swaps in the + * pinned act on wide, motion-tolerant viewports. + * + * This is the page's no-JS and phone form, so nothing is hidden and every + * link stays in the tab order. A still IS the settle, so its check is always + * filled. */ -export function StageStills() { +export function StageStills({ proof }: Props) { return (

- One real run: stream, persist, approve, render + One real run: tools, persist, approve, render

{STAGE_RAIL.map((b) => ( @@ -49,35 +63,45 @@ export function StageStills() { />
-
-
- - {b.eyebrow} - -
-

{b.headline}

-

{b.body}

-
- {b.rows.map((row) => ( -
- - {row.claim} - - {row.api} -
- ))} +
+
))}
+
+
    + {STAGE_RAIL.map((b) => ( +
  • +
  • + ))} +
+

{STAGE_CLOSE.claim}

+
+ {STAGE_CLOSE.install} + + {STAGE_CLOSE.cta.label} → + +
+

{HERO_TRUST_LINE} · LangGraph and AG-UI

+
); diff --git a/apps/website/src/components/landing/use-stage-publisher.spec.ts b/apps/website/src/components/landing/use-stage-publisher.spec.ts index e8a760784..16c7afd76 100644 --- a/apps/website/src/components/landing/use-stage-publisher.spec.ts +++ b/apps/website/src/components/landing/use-stage-publisher.spec.ts @@ -10,7 +10,13 @@ import { useStagePublisher, type StagePublisher, } from './use-stage-publisher'; -import { APPROVE_HOLD, beatWindows, timeAt } from '../../lib/stage-beats'; +import { + APPROVE_HOLD, + beatWindows, + settleAt, + STAGE_BEATS, + timeAt, +} from '../../lib/stage-beats'; const READY = { type: STAGE_MESSAGE_TYPE, @@ -36,10 +42,16 @@ function fromDemo(data: unknown) { } function setup( - opts: { frameWindow?: () => Window | null; ready?: boolean } = {} + opts: { + frameWindow?: () => Window | null; + ready?: boolean; + /** Builds rail markup before construction: the publisher queries it once. */ + rail?: (section: HTMLElement) => void; + } = {} ) { const section = document.createElement('section'); document.body.appendChild(section); + opts.rail?.(section); const posted: { m: unknown; origin: string }[] = []; const frame = { postMessage: (m: unknown, origin: string) => posted.push({ m, origin }), @@ -58,6 +70,24 @@ function setup( return { section, posted, track, onReady, pub, frame }; } +/** One segment, one beat block and one check per beat, plus the ledger, as the act renders them. */ +function fullRail(section: HTMLElement) { + for (const b of STAGE_BEATS) { + const seg = document.createElement('a'); + seg.setAttribute('data-stage-segment', b); + section.appendChild(seg); + const block = document.createElement('div'); + block.setAttribute('data-stage-beat', b); + section.appendChild(block); + const chk = document.createElement('span'); + chk.setAttribute('data-stage-check', b); + section.appendChild(chk); + } + const close = document.createElement('div'); + close.setAttribute('data-stage-close', ''); + section.appendChild(close); +} + afterEach(() => { current?.dispose(); current = null; @@ -187,6 +217,134 @@ describe('stage publisher', () => { ]); }); + it('writes segment states and check fills onto the rail as progress moves', () => { + const { section, pub } = setup({ rail: fullRail }); + const seg = (b: string) => + section + .querySelector(`[data-stage-segment="${b}"]`) + ?.getAttribute('data-beat-state'); + const checked = (b: string) => + section + .querySelector(`[data-stage-check="${b}"]`) + ?.hasAttribute('data-checked'); + section.style.setProperty('--sc-p', '0.05'); + pub.tick(); + expect(seg('stream')).toBe('now'); + expect(seg('persist')).toBe('todo'); + expect(checked('stream')).toBe(false); + section.style.setProperty('--sc-p', String(beatWindows()[1].from + 0.01)); + pub.tick(); + expect(seg('stream')).toBe('done'); + expect(checked('stream')).toBe(true); + section.style.setProperty('--sc-p', '1'); + pub.tick(); + expect( + section.querySelectorAll('[data-stage-check][data-checked]') + ).toHaveLength(4); + }); + + it('marks the current beat block `now` so the visible cue owns the pointer', () => { + const { section, pub } = setup({ rail: fullRail }); + const block = (b: string) => + section + .querySelector(`[data-stage-beat="${b}"]`) + ?.getAttribute('data-beat-state'); + section.style.setProperty('--sc-p', '0.05'); + pub.tick(); + expect(block('stream')).toBe('now'); + expect(block('persist')).toBe('todo'); + section.style.setProperty('--sc-p', String(beatWindows()[1].from + 0.01)); + pub.tick(); + expect(block('stream')).toBe('done'); + expect(block('persist')).toBe('now'); + }); + + it('activates the closing ledger only past the render settle, and deactivates it on rewind', () => { + const { section, pub } = setup({ rail: fullRail }); + const close = section.querySelector('[data-stage-close]')!; + const settle = settleAt('render'); + section.style.setProperty('--sc-p', String(settle - 0.01)); + pub.tick(); + expect(close.hasAttribute('data-active')).toBe(false); + section.style.setProperty('--sc-p', String(settle)); + pub.tick(); + expect(close.hasAttribute('data-active')).toBe(true); + section.style.setProperty('--sc-p', '1'); + pub.tick(); + expect(close.hasAttribute('data-active')).toBe(true); + section.style.setProperty('--sc-p', '0.5'); + pub.tick(); + expect(close.hasAttribute('data-active')).toBe(false); + }); + + it('un-fills the checks and resets the segments on a rewind, as the frame rewinds', () => { + const { section, pub } = setup({ rail: fullRail }); + section.style.setProperty('--sc-p', '1'); + pub.tick(); + expect( + section.querySelectorAll('[data-stage-check][data-checked]') + ).toHaveLength(4); + section.style.setProperty('--sc-p', '0.05'); + pub.tick(); + expect( + section.querySelectorAll('[data-stage-check][data-checked]') + ).toHaveLength(0); + expect( + section + .querySelector('[data-stage-segment="persist"]') + ?.getAttribute('data-beat-state') + ).toBe('todo'); + }); + + it('updates every check for a beat, in the beat block and in the closing ledger', () => { + const { section, pub } = setup({ + rail: (section) => { + for (const where of ['block', 'ledger']) { + const chk = document.createElement('span'); + chk.setAttribute('data-stage-check', 'stream'); + chk.setAttribute('data-where', where); + section.appendChild(chk); + } + }, + }); + section.style.setProperty('--sc-p', String(beatWindows()[1].from + 0.01)); + pub.tick(); + expect( + section.querySelectorAll('[data-stage-check="stream"][data-checked]') + ).toHaveLength(2); + }); + + it('ignores an unknown beat on a segment or check without throwing', () => { + const seg = document.createElement('a'); + seg.setAttribute('data-stage-segment', 'nope'); + const chk = document.createElement('span'); + chk.setAttribute('data-stage-check', 'nope'); + const { section, pub } = setup({ + rail: (section) => section.append(seg, chk), + }); + section.style.setProperty('--sc-p', '1'); + expect(() => pub.tick()).not.toThrow(); + expect(seg.hasAttribute('data-beat-state')).toBe(false); + expect(chk.hasAttribute('data-checked')).toBe(false); + }); + + it('a second tick at the same progress writes no segment or check attributes', () => { + const { section, pub } = setup({ rail: fullRail }); + const els = [ + ...section.querySelectorAll( + '[data-stage-segment], [data-stage-beat], [data-stage-check], [data-stage-close]' + ), + ]; + expect(els).toHaveLength(13); + section.style.setProperty('--sc-p', String(beatWindows()[1].from + 0.01)); + pub.tick(); + const sets = els.map((el) => vi.spyOn(el, 'setAttribute')); + const removes = els.map((el) => vi.spyOn(el, 'removeAttribute')); + pub.tick(); + for (const s of sets) expect(s).not.toHaveBeenCalled(); + for (const r of removes) expect(r).not.toHaveBeenCalled(); + }); + it('dispose removes the listener and stops posting', () => { const { section, posted, pub } = setup(); pub.dispose(); diff --git a/apps/website/src/components/landing/use-stage-publisher.ts b/apps/website/src/components/landing/use-stage-publisher.ts index 124c626b0..9804b31e9 100644 --- a/apps/website/src/components/landing/use-stage-publisher.ts +++ b/apps/website/src/components/landing/use-stage-publisher.ts @@ -3,6 +3,9 @@ import { beatAt, crossedThreshold, inHold, + isChecked, + segmentState, + STAGE_BEATS, timeAt, type StageBeat, type StageMilestone, @@ -38,6 +41,26 @@ function readProgress(el: HTMLElement): number { return Number.isFinite(v) ? v : 0; } +const isStageBeat = (v: string | null): v is StageBeat => + (STAGE_BEATS as readonly string[]).includes(v ?? ''); + +/** + * Rail elements keyed by a known beat, read once at construction. An element + * whose beat attribute is not one of `STAGE_BEATS` is skipped rather than + * fed to the beat math (which would index it as -1). + */ +function railElements( + section: HTMLElement, + attr: 'data-stage-segment' | 'data-stage-beat' | 'data-stage-check' +): { el: Element; beat: StageBeat }[] { + const out: { el: Element; beat: StageBeat }[] = []; + for (const el of section.querySelectorAll(`[${attr}]`)) { + const beat = el.getAttribute(attr); + if (isStageBeat(beat)) out.push({ el, beat }); + } + return out; +} + const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); @@ -83,6 +106,17 @@ export function createStagePublisher(deps: StagePublisherDeps): StagePublisher { const beatsSeen = new Set(); let disposed = false; let lastHello = -Infinity; + // The segment bar and the beat blocks both take `data-beat-state`: the bar + // lights, and the block whose beat is `now` gets the pointer back (every + // block shares one cell and is hidden by opacity alone). + const segments = [ + ...railElements(deps.section, 'data-stage-segment'), + ...railElements(deps.section, 'data-stage-beat'), + ]; + const checks = railElements(deps.section, 'data-stage-check'); + // The closing ledger is on top of that cell; it owns the pointer only once + // render has settled and the cue has faded it in. + const closes = [...deps.section.querySelectorAll('[data-stage-close]')]; const onMessage = (e: MessageEvent) => { if (e.origin !== STAGE_DEMO_ORIGIN) return; @@ -116,6 +150,27 @@ export function createStagePublisher(deps: StagePublisherDeps): StagePublisher { if (h) deps.section.setAttribute('data-sc-verify-hold', 'true'); else deps.section.removeAttribute('data-sc-verify-hold'); } + // Rail: segment states and check fills, written only on change so the + // harness signature and the browser's style recalc see nothing idle. + for (const { el, beat } of segments) { + const s = segmentState(beat, p); + if (el.getAttribute('data-beat-state') !== s) + el.setAttribute('data-beat-state', s); + } + for (const { el, beat } of checks) { + const on = isChecked(beat, p); + if (on !== el.hasAttribute('data-checked')) { + if (on) el.setAttribute('data-checked', ''); + else el.removeAttribute('data-checked'); + } + } + const closeOn = isChecked('render', p); + for (const el of closes) { + if (closeOn !== el.hasAttribute('data-active')) { + if (closeOn) el.setAttribute('data-active', ''); + else el.removeAttribute('data-active'); + } + } // Milestones. if (!entered && p > 0) { entered = true; diff --git a/apps/website/src/lib/positioning.spec.ts b/apps/website/src/lib/positioning.spec.ts index 504d75940..863dbd986 100644 --- a/apps/website/src/lib/positioning.spec.ts +++ b/apps/website/src/lib/positioning.spec.ts @@ -19,7 +19,8 @@ import { HOME_DESCRIPTION, HOME_TITLE, INSTALL_OPTIONS, - STAGE_HOLD_LINES, + STAGE_CLOSE, + STAGE_HOLD_LINE, STAGE_RAIL, } from './positioning'; import { STAGE_BEATS } from './stage-beats'; @@ -195,28 +196,33 @@ describe('homepage restructure copy (live-stage spec §3)', () => { }); describe('STAGE_RAIL', () => { - it('has one entry per beat in the beat map\'s order, three rows each, a cta, and still alt text', () => { + it('has one entry per beat in the beat map order, each a short claim with one docs link', () => { expect(STAGE_RAIL.map((b) => b.beat)).toEqual([...STAGE_BEATS]); for (const b of STAGE_RAIL) { - // The eyebrow is the beat's name; copy swapped between entries would break this. - expect(b.eyebrow.toLowerCase()).toBe(b.beat); - expect(b.headline.length).toBeGreaterThan(20); - expect(b.body.length).toBeGreaterThan(40); - expect(b.rows).toHaveLength(3); - for (const row of b.rows) { - expect(row.claim).not.toBe(''); - expect(row.api).not.toBe(''); - } - expect(b.cta.label).not.toBe(''); - expect(b.cta.href).toMatch(/^\//); + expect(b.label.length).toBeLessThanOrEqual(8); + expect(b.claim.length).toBeLessThanOrEqual(40); + expect(b.claim.endsWith('.')).toBe(true); + expect(b.docs.label).not.toBe(''); + expect(b.docs.href).toMatch(/^\//); expect(b.stillAlt.length).toBeGreaterThan(40); } - }); - it('carries the three hold lines from the spec, ending on the threshold instruction', () => { - expect(STAGE_HOLD_LINES).toEqual([ - 'The pause is a checkpoint, not a modal', - 'The run is frozen in durable state. Scroll all you like; nothing happens until someone decides', - 'Keep scrolling to approve', - ]); + expect(STAGE_RAIL.map((b) => b.label)).toEqual(['Tools', 'Persist', 'Approve', 'Render']); + }); + it('carries one hold line and the closing ledger copy', () => { + expect(STAGE_HOLD_LINE).toBe('Keep scrolling to approve.'); + expect(STAGE_CLOSE.claim).toBe('Feature complete for the final mile.'); + // The fake-agent install command is a single line, so the ending shows it whole (derived, never retyped). + expect(INSTALL_OPTIONS[0].command).not.toContain('\n'); + expect(STAGE_CLOSE.install).toBe(INSTALL_OPTIONS[0].command); + expect(STAGE_CLOSE.cta.href).toBe(INSTALL_OPTIONS[0].quickstartHref); + }); + it('keeps the rail under the word budget: four beats plus the ending', () => { + const words = (s: string) => s.trim().split(/\s+/).length; + const total = + STAGE_RAIL.reduce((n, b) => n + words(b.claim) + words(b.docs.label), 0) + + words(STAGE_HOLD_LINE) + + words(STAGE_CLOSE.claim) + + words(STAGE_CLOSE.cta.label); + expect(total).toBeLessThan(90); }); }); diff --git a/apps/website/src/lib/positioning.ts b/apps/website/src/lib/positioning.ts index 6de59344f..1fae06020 100644 --- a/apps/website/src/lib/positioning.ts +++ b/apps/website/src/lib/positioning.ts @@ -85,18 +85,19 @@ export const PROVE_IT_ROWS = [ { claim: 'Same UI code in test and production', api: 'Agent' }, ] as const; -// ── The stage rail (live-stage spec §4–6): the copy that stacks beside the -// pinned demo. Strings are verbatim from the four capability acts; the still +// ── The stage rail (stage-rail spec §3–5): the four-claim ledger beside the +// pinned demo. One label, one claim and one docs link per beat; the still // alt text describes public/screenshots/stage-.webp at the beat's settle. /** The beat map (`stage-beats.ts`) owns the beat names; the rail copy keys off it so the two cannot drift. */ export type StageBeatKey = StageBeat; export interface StageRailBeat { readonly beat: StageBeatKey; - readonly eyebrow: string; - readonly headline: string; - readonly body: string; - readonly rows: readonly { readonly claim: string; readonly api: string }[]; - readonly cta: { readonly label: string; readonly href: string }; + /** Segment label in the act navigation bar. */ + readonly label: string; + /** The one line the rail says for this beat. */ + readonly claim: string; + /** The page that proves it. */ + readonly docs: { readonly label: string; readonly href: string }; /** Alt text for the fallback still: what the frame shows at this beat's settle. */ readonly stillAlt: string; } @@ -104,68 +105,40 @@ export interface StageRailBeat { export const STAGE_RAIL: readonly StageRailBeat[] = [ { beat: 'stream', - eyebrow: 'Stream', - headline: 'The UI stays reactive through tokens, tools, errors, and state changes.', - body: 'injectAgent() hands back signals: messages(), status(), error(), isLoading(), and tool progress. Nothing to subscribe to, nothing to tear down.', - rows: [ - { claim: 'Signals, not promises', api: 'injectAgent()' }, - { claim: 'Tool progress as it happens', api: 'toolProgress()' }, - { claim: 'Same contract on LangGraph and AG-UI', api: 'Agent' }, - ], - cta: { label: 'Read the streaming guide', href: '/docs/langgraph/guides/streaming' }, + label: 'Tools', + claim: 'Tool calls and citations as signals.', + docs: { label: 'Tool calls', href: '/docs/chat/components/chat-tool-calls' }, stillAlt: 'Threadplane chat beside its devtools: a streamed answer about Angular signals with a Sources row of three citations, and the devtools Timeline listing seven checkpoints', }, { beat: 'persist', - eyebrow: 'Persist', - headline: 'A user can leave, return, inspect history, and continue.', - body: 'Thread selection, history, branch and replay UI in the Angular app. Durability itself comes from the runtime and persistence layer you connect — Threadplane exposes it, it does not fake it.', - rows: [ - { claim: 'Conversations restore across sessions', api: 'threadId + checkpoints' }, - { claim: 'Branch or replay from any point', api: 'branch / replay' }, - { claim: 'error() / status() / reload() on every agent', api: 'boundary signals' }, - ], - cta: { label: 'Persistence patterns', href: '/docs/langgraph/guides/persistence' }, + label: 'Persist', + claim: 'Durable threads, no license.', + docs: { label: 'Persistence', href: '/docs/langgraph/guides/persistence' }, stillAlt: 'The thread restored after a reload and forked from an earlier checkpoint: a "Make it a haiku instead." turn with its three-line haiku reply, the cleanup prompt just sent beneath it, and the devtools Timeline showing ten checkpoints across two steps', }, { beat: 'approve', - eyebrow: 'Approve', - headline: 'Irreversible work pauses for a human decision.', - body: 'interrupt() freezes the run inside the checkpoint. Your UI renders the proposal; submit({ resume }) continues with the decision on the record.', - rows: [ - { claim: 'The pause is a checkpoint, not a modal', api: 'interrupt()' }, - { claim: 'The proposal renders in your UI', api: '' }, - { claim: 'The decision lands beside the action it gated', api: 'submit({ resume })' }, - ], - cta: { label: 'Interrupt patterns', href: '/docs/langgraph/guides/interrupts' }, + label: 'Approve', + claim: 'Interrupts and approvals, built in.', + docs: { label: 'Interrupts', href: '/docs/langgraph/guides/interrupts' }, stillAlt: 'The agent paused inside delete_backups: an "Agent paused — review needed" panel with Accept, Edit, Respond and Ignore above a five-row table of backups, two marked retain, and the devtools Timeline holding at ten checkpoints', }, { beat: 'render', - eyebrow: 'Render', - headline: 'Agent output becomes components from your design system.', - body: 'The agent emits constrained structured output. Angular renders registered components — json-render and A2UI both speak it — with per-component fallback and a readiness gate. No generated code runs.', - rows: [ - { claim: 'Your design system, not a chat widget', api: '@threadplane/render' }, - { claim: 'Unknown specs degrade per component', api: 'fallback + readiness gate' }, - { claim: 'Schema on the server, trust in the client', api: 'validated specs' }, - ], - cta: { label: 'See @threadplane/render', href: '/render' }, + label: 'Render', + claim: 'Generative UI on A2UI and json-render.', + docs: { label: '@threadplane/render', href: '/render' }, stillAlt: "A generated contact form — Name, Email address, Subject, Message and a Send button — rendered from the agent's A2UI output inside the chat, with the render_a2ui_surface tool call above it", }, ]; -/** Spec §6: the copy that advances while recorded time is pinned at the interrupt. */ -export const STAGE_HOLD_LINES: readonly string[] = [ - 'The pause is a checkpoint, not a modal', - 'The run is frozen in durable state. Scroll all you like; nothing happens until someone decides', - 'Keep scrolling to approve', -]; +/** Spec §3.3: the only copy shown while recorded time is pinned at the interrupt, and the page's one scroll cue. */ +export const STAGE_HOLD_LINE = 'Keep scrolling to approve.'; // ── Install variants: the ONE place install commands live on the website ───── export type InstallVariant = 'fake' | 'langgraph' | 'ag_ui'; @@ -263,6 +236,17 @@ export const appConfig: ApplicationConfig = { }, ]; +/** + * Spec §3.4: the stage's last screen. `install` is derived from the install + * options so the command lives in one place; the fake-agent command is a + * single line, so it is shown whole. Declared after INSTALL_OPTIONS on purpose. + */ +export const STAGE_CLOSE = { + claim: 'Feature complete for the final mile.', + install: INSTALL_OPTIONS[0].command, + cta: { label: 'Spike it this week', href: INSTALL_OPTIONS[0].quickstartHref }, +} as const; + // ── Coding-agent quickstart prompt ─────────────────────────────────────────── export const CODING_AGENT_PROMPT = `Add Threadplane to this Angular application. diff --git a/apps/website/src/lib/stage-beats.spec.ts b/apps/website/src/lib/stage-beats.spec.ts index c3bf5a303..05e50d9c4 100644 --- a/apps/website/src/lib/stage-beats.spec.ts +++ b/apps/website/src/lib/stage-beats.spec.ts @@ -2,14 +2,20 @@ import { describe, expect, it } from 'vitest'; import { APPROVE_HOLD, APPROVE_THRESHOLD_P, + RENDER_TAIL, STAGE_BEATS, STAGE_SPAN, beatAt, beatWindows, + closeCue, crossedThreshold, cueFor, - holdLineCues, + holdCue, + HOLD_LINE_OVERSHOOT, inHold, + isChecked, + segmentState, + settleAt, timeAt, type StageReadyMessage, } from './stage-beats'; @@ -150,29 +156,128 @@ describe('cueFor', () => { const [stream, persist, , render] = beatWindows(); expect(cueFor('stream')).toBe(`0 ${fmt(stream.to)} 0 0.3`); expect(cueFor('stream')).toMatch(/^0 0\.21\d+ 0 0\.3$/); - expect(cueFor('render')).toBe(`${fmt(render.from)} 1 0.3 0`); + expect(cueFor('render')).toBe( + `${fmt(render.from)} ${fmt(settleAt('render'))} 0.1` + ); expect(cueFor('persist')).toBe(`${fmt(persist.from)} ${fmt(persist.to)}`); }); + it('ends the render block at the render settle so it crossfades into the ledger', () => { + const [, to] = cueFor('render').split(' ').map(Number); + expect(to).toBeCloseTo(settleAt('render'), 4); + expect(to).toBeLessThan(1); + expect(cueFor('render').split(' ')[1]).toBe(closeCue().split(' ')[0]); + }); }); -describe('holdLineCues', () => { - it('spreads the hold lines across the approve hold, strictly forward, inside the act', () => { - const a = beatWindows()[2]; - const cues = holdLineCues(3); - expect(cues).toHaveLength(3); - const parsed = cues.map((c) => c.split(' ').map(Number)); - parsed.forEach(([from, to]) => { - expect(from).toBeGreaterThanOrEqual(a.from); - expect(to).toBeLessThanOrEqual(1); - expect(to).toBeGreaterThan(from); - }); - parsed - .slice(1) - .forEach(([from], i) => expect(from).toBeGreaterThan(parsed[i][0])); - }); - it('lets the last cue linger past the threshold by 12% of the approve span', () => { +describe('settleAt / segmentState', () => { + it('settles tools and persist at their window end, approve at the threshold, render before its tail', () => { + const w = beatWindows(); + expect(settleAt('stream')).toBe(w[0].to); + expect(settleAt('persist')).toBe(w[1].to); + expect(settleAt('approve')).toBe(APPROVE_THRESHOLD_P); + expect(settleAt('render')).toBeCloseTo( + w[3].from + (w[3].to - w[3].from) * (1 - RENDER_TAIL), + 6 + ); + }); + it('reports done / now / todo per beat from progress', () => { + const w = beatWindows(); + expect(segmentState('stream', 0.05)).toBe('now'); + expect(segmentState('persist', 0.05)).toBe('todo'); + expect(segmentState('stream', w[1].from + 0.01)).toBe('done'); + expect(segmentState('approve', w[2].from + 0.01)).toBe('now'); + expect(segmentState('render', 1)).toBe('now'); + }); + it('a beat is checked once progress passes its settle', () => { + expect(isChecked('approve', APPROVE_THRESHOLD_P - 0.001)).toBe(false); + expect(isChecked('approve', APPROVE_THRESHOLD_P)).toBe(true); + expect(isChecked('render', 0.999)).toBe(true); + }); +}); + +describe('holdCue / closeCue', () => { + it('opens the hold line exactly where inHold starts and lingers past the threshold', () => { + const cue = holdCue(); + expect(cue.split(' ')).toHaveLength(4); + const [from, to] = cue.split(' ').map(Number); const a = beatWindows()[2]; - const [, to] = holdLineCues(3)[2].split(' ').map(Number); - expect(to).toBeCloseTo(APPROVE_THRESHOLD_P + (a.to - a.from) * 0.12, 4); + // The cue is printed to 4 decimals, so compare against the exact edge and + // probe inHold on either side of that edge. + const edge = a.from + (a.to - a.from) * APPROVE_HOLD.from; + expect(from).toBe(Number(edge.toFixed(4))); + expect(inHold(edge + 1e-6)).toBe(true); + expect(inHold(edge - 1e-6)).toBe(false); + expect(to).toBeGreaterThan(APPROVE_THRESHOLD_P); + expect(to).toBeCloseTo( + APPROVE_THRESHOLD_P + (a.to - a.from) * HOLD_LINE_OVERSHOOT, + 4 + ); + expect(cue.split(' ').slice(2).join(' ')).toBe('0.3 0.2'); + }); + it('fades the closing ledger in at the render settle and holds it to the end', () => { + const parts = closeCue().split(' '); + expect(parts).toHaveLength(4); + expect(Number(parts[0])).toBeCloseTo(settleAt('render'), 4); + expect(parts.slice(1).join(' ')).toBe('1 0.1 0'); + expect(closeCue()).toMatch(/ 1 0\.1 0$/); + }); + it('every rail cue is at full opacity on one of the harness sample points', () => { + // scroll-craft's cue model (src/vendor/scrollcraft/scrollcraft.js): ramps + // are fractions of the window, 0.3 each by default, smoothstepped; between + // the ramps the cue sits at 1. The harness (e2e/scroll-craft/shoot.mjs, + // --per-act 8) samples p = 0.02 + 0.96 * i/7 and reports a cue that never + // reaches 1 at any sample as a defect, so every plateau has to contain a + // sample — and with a margin, since the sample lands on a rounded pixel. + const opacity = (cue: string, p: number) => { + const n = cue.split(' ').map(Number); + const from = n[0]; + const to = n[1]; + const rIn = n.length > 2 ? n[2] : 0.3; + const rOut = n.length > 3 ? n[3] : 0.3; + const win = Math.max(to - from, 0.001); + const inEnd = from + win * rIn; + const outStart = to - win * rOut; + if (p < from) return 0; + if (p < inEnd) return (p - from) / (inEnd - from); + if (p <= outStart) return 1; + return 1 - (p - outStart) / (to - outStart); + }; + const samples = Array.from({ length: 8 }, (_, i) => 0.02 + (0.96 * i) / 7); + const margin = 0.002; // ≈ 9px of a 4500px travel at 1440×900 + const cues = { + ...Object.fromEntries(STAGE_BEATS.map((b) => [b, cueFor(b)])), + hold: holdCue(), + close: closeCue(), + }; + for (const [name, cue] of Object.entries(cues)) { + const full = samples.filter( + (p) => opacity(cue, p - margin) === 1 && opacity(cue, p + margin) === 1 + ); + expect( + full, + `${name} (${cue}) has no harness sample on its plateau` + ).not.toHaveLength(0); + // Sample-independent: a plateau a reader can see. The closing ledger's + // window is the render tail, so its floor is lower; the engine keeps it + // at 1 while the pinned act scrolls away. + const n = cue.split(' ').map(Number); + const win = n[1] - n[0]; + const plateau = win * (1 - (n[2] ?? 0.3) - (n[3] ?? 0.3)); + expect(plateau, `${name} plateau`).toBeGreaterThanOrEqual( + name === 'close' ? 0.02 : 0.04 + ); + } + }); + it('keeps every cue inside the act with from < to', () => { + const cues = [...STAGE_BEATS.map(cueFor), holdCue(), closeCue()]; + for (const cue of cues) { + const nums = cue.split(' ').map(Number); + nums.forEach((n) => { + expect(Number.isFinite(n)).toBe(true); + expect(n).toBeGreaterThanOrEqual(0); + expect(n).toBeLessThanOrEqual(1); + }); + expect(nums[0]).toBeLessThan(nums[1]); + } }); }); diff --git a/apps/website/src/lib/stage-beats.ts b/apps/website/src/lib/stage-beats.ts index e661945e2..dc5ebba6f 100644 --- a/apps/website/src/lib/stage-beats.ts +++ b/apps/website/src/lib/stage-beats.ts @@ -65,9 +65,15 @@ export function beatWindows(): readonly Readonly[] { const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x); const lerp = (a: number, b: number, f: number) => a + (b - a) * f; +/** Act-progress fraction printed for a cue: four decimals, no trailing zeros. */ +const fmt = (n: number) => String(+n.toFixed(4)); +/** The window that owns clamped progress `q`; the last one owns q = 1. Runs per frame, so no closure. */ function windowAt(q: number): Readonly { - return WINDOWS.find((x) => q < x.to) ?? LAST_WINDOW; + for (let i = 0; i < WINDOWS.length; i++) { + if (q < WINDOWS[i].to) return WINDOWS[i]; + } + return LAST_WINDOW; } export function beatAt(p: number): StageBeat { @@ -136,35 +142,81 @@ export function timeAt(p: number, ready: StageReadyMessage): number { } } +/** Act progress at which a beat's claim is proven on screen and its check fills. */ +export function settleAt(beat: StageBeat): number { + const w = WINDOWS[STAGE_BEATS.indexOf(beat)]; + if (beat === 'approve') return APPROVE_THRESHOLD_P; + if (beat === 'render') return w.from + (w.to - w.from) * (1 - RENDER_TAIL); + return w.to; +} + +/** Rail segment state for a beat at a progress: proven before it, being proven, or still ahead. */ +export type SegmentState = 'done' | 'now' | 'todo'; + +/** `now` for the beat that owns `p`, `done` for the ones before it, `todo` after. */ +export function segmentState(beat: StageBeat, p: number): SegmentState { + const current = beatAt(p); + if (beat === current) return 'now'; + return STAGE_BEATS.indexOf(beat) < STAGE_BEATS.indexOf(current) + ? 'done' + : 'todo'; +} + +/** + * True once `p` has reached the beat's settle point. Pure in `p`, so a check + * un-fills on rewind exactly as the frame rewinds. + */ +export function isChecked(beat: StageBeat, p: number): boolean { + return p >= settleAt(beat); +} + /** * `data-sc-cue` for a beat's rail block: "from to rampIn rampOut" as fractions - * of act progress. The first beat greets (full at p = 0), the last holds to - * the end (no leave ramp), the middle ones fade in and out inside their window. + * of act progress (the engine's ramps default to 0.3 of the window each). The + * first beat greets (full at p = 0), the middle ones fade in and out inside + * their window, and the last ends at its settle point so it crossfades into + * the closing ledger (`closeCue`). The render block arrives over the first + * tenth of its window: the approve block has fully left by then, so nothing + * crossfades, and its window is short enough that the default ramp would keep + * the block below full for the first third of the beat. + * + * Every cue's plateau (opacity 1) contains one of the scroll-craft harness's + * sample points, which the spec checks with the engine's own ramp model. */ export function cueFor(beat: StageBeat): string { const w = WINDOWS[STAGE_BEATS.indexOf(beat)]; - const fmt = (n: number) => String(+n.toFixed(4)); if (beat === STAGE_BEATS[0]) return `0 ${fmt(w.to)} 0 0.3`; if (beat === STAGE_BEATS[STAGE_BEATS.length - 1]) - return `${fmt(w.from)} 1 0.3 0`; + return `${fmt(w.from)} ${fmt(settleAt(beat))} 0.1`; return `${fmt(w.from)} ${fmt(w.to)}`; } +/** How far past the threshold the hold line lingers, as a fraction of the approve span. */ +export const HOLD_LINE_OVERSHOOT = 0.2; + /** - * Cue windows for the hold lines inside the approve beat, spread across the - * hold range. The last cue overshoots the hold by 12% of the approve span so - * "Keep scrolling to approve" lingers past the threshold and the instruction - * is still readable as the resume begins. + * Cue for the hold line inside the approve beat: opens where the hold starts, + * overshoots the threshold by `HOLD_LINE_OVERSHOOT` of the approve span so + * "Keep scrolling to approve" is still readable as the resume begins, and + * leaves over the last fifth of that so it is at full strength across the + * threshold itself. */ -export function holdLineCues(count: number): string[] { +export function holdCue(): string { const a = APPROVE_WINDOW; const span = a.to - a.from; - const start = a.from + span * APPROVE_HOLD.from; - const end = APPROVE_THRESHOLD_P; - const slot = (end - start) / count; - return Array.from({ length: count }, (_, i) => { - const from = start + slot * i; - const to = i === count - 1 ? end + span * 0.12 : from + slot * 1.15; - return `${+from.toFixed(4)} ${+Math.min(to, 1).toFixed(4)}`; - }); + const from = a.from + span * APPROVE_HOLD.from; + const to = Math.min(APPROVE_THRESHOLD_P + span * HOLD_LINE_OVERSHOOT, 1); + return `${fmt(from)} ${fmt(to)} 0.3 0.2`; +} + +/** + * Cue for the closing ledger: fades in at the render settle and holds to the + * end. Its window is the render tail alone, so the ramp-in takes a fifth of + * it, the same beat as the last check filling — the ledger is fully in with + * 2% of the act's scroll still to go rather than on its last pixel. + */ +export function closeCue(): string { + // rIn 0.1: the ledger's window is short (the render tail), so a wider ramp + // leaves its plateau with no margin around the harness's last sample. + return `${fmt(settleAt('render'))} 1 0.1 0`; } diff --git a/apps/website/src/lib/stage-proof.spec.ts b/apps/website/src/lib/stage-proof.spec.ts new file mode 100644 index 000000000..4bd907509 --- /dev/null +++ b/apps/website/src/lib/stage-proof.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +vi.mock('server-only', () => ({})); + +import { deriveStageProof, STAGE_PROOF } from './stage-proof'; + +const REC = resolve( + __dirname, + '../../../../examples/chat/angular/public/stage-replay.json' +); + +interface Run { + beat: string; + action: { kind: string }; + events: { event: Dict }[]; +} +type Dict = Record; + +/** Structured clone of one run so a case can edit it without touching the fixture. */ +const cloneRun = (r: Run): Run => JSON.parse(JSON.stringify(r)) as Run; + +describe('stage proof', () => { + const rec = JSON.parse(readFileSync(REC, 'utf8')); + const proof = deriveStageProof(rec); + + it('counts the first beat from the recording, never types it', () => { + expect(proof.stream).toBe('586 events · 1 tool call · 3 sources'); + expect(proof.stream.startsWith(`${rec.runs[0].events.length} events`)).toBe( + true + ); + }); + + it('counts the sources the frame badge shows, not the search hits', () => { + // The Sources badge counts additional_kwargs.citations on the final AI + // message; the committed take has three. + expect(proof.stream).toMatch(/ · 3 sources$/); + }); + + it('reads the reload, the checkpoint count and the fork step', () => { + expect(proof.persist).toMatch( + /^reloaded · \d+ checkpoints · forked at step \d+$/ + ); + }); + + it('maps the fork checkpointIndex onto the chronological step ordinal', () => { + // checkpointIndex 9 indexes the newest-first history the user forked + // FROM (the snapshot with 3 runs completed, 10 states): 10 - 9 = step 1, + // the first checkpoint. The devtools label that row `__start__`; the + // ordinal is the count the copy can be checked against. + expect(proof.persist).toBe('reloaded · 10 checkpoints · forked at step 1'); + }); + + it('reads the pending interrupt and the checkpoint count', () => { + // The approve run ends interrupted, so there is no snapshot with 5 runs + // completed; the line reads the latest one at or before that count. + expect(proof.approve).toBe('1 interrupt pending · checkpoint 10 of 10'); + }); + + it('counts the interrupts the approve run left pending', () => { + const twoInterrupts = { + ...rec, + runs: rec.runs.map((r: Run) => { + if (r.beat !== 'approve' || r.action.kind !== 'submit') return r; + const c = cloneRun(r); + let seen = 0; + for (const { event } of c.events) { + for (const holder of [event, event['data'] as Dict | undefined]) { + const list = holder?.['__interrupt__']; + if (Array.isArray(list) && list.length > 0) { + list.push(list[0]); + seen += 1; + } + } + } + expect(seen).toBeGreaterThan(0); + return c; + }), + }; + expect(deriveStageProof(twoInterrupts).approve).toBe( + '2 interrupts pending · checkpoint 10 of 10' + ); + }); + + it('reads the surface and its component count', () => { + expect(proof.render).toMatch( + /^1 surface · \d+ components · no generated code ran$/ + ); + }); + + it('counts every A2UI component in the surface, containers included', () => { + // Column + Name + Email address + Subject + Message + Send + its label. + expect(proof.render).toBe( + '1 surface · 7 components · no generated code ran' + ); + }); + + it('drops a segment it cannot derive instead of defaulting it', () => { + // Strip only additional_kwargs.citations from the final AI message of + // run 0's last `values` event; the events and the tool call stay. + const noCitations = { + ...rec, + runs: rec.runs.map((r: Run, i: number) => { + if (i !== 0) return r; + const c = cloneRun(r); + const last = c.events + .map(({ event }) => event) + .filter((ev) => ev['type'] === 'values') + .at(-1); + const messages = (last?.['data'] as Dict)['messages'] as { + type?: string; + additional_kwargs?: Dict; + }[]; + const ai = messages.filter((m) => m.type === 'ai').at(-1); + expect(Array.isArray(ai?.additional_kwargs?.['citations'])).toBe(true); + delete ai?.additional_kwargs?.['citations']; + return c; + }), + }; + const p = deriveStageProof(noCitations); + expect(p.stream).toBe('586 events · 1 tool call'); + }); + + it('drops the surface clauses when the render run has no surface', () => { + const noSurface = { + ...rec, + runs: rec.runs.map((r: Run) => + r.beat === 'render' ? { ...r, events: [] } : r + ), + }; + const p = deriveStageProof(noSurface); + expect(p.render).toBe('no generated code ran'); + }); + + it('is what the page ships', () => { + expect(STAGE_PROOF).toEqual(proof); + }); +}); diff --git a/apps/website/src/lib/stage-proof.ts b/apps/website/src/lib/stage-proof.ts new file mode 100644 index 000000000..f7109db92 --- /dev/null +++ b/apps/website/src/lib/stage-proof.ts @@ -0,0 +1,234 @@ +import 'server-only'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { StageBeat } from './stage-beats'; +import { resolveWebsiteDir } from './website-dir'; + +/** + * Proof lines for the stage rail (spec §4): counts read from the committed + * recording at build time. Nothing here is typed by hand; a segment whose + * number cannot be derived is omitted, never estimated. The one phrase that is + * a property rather than a count is "no generated code ran". + * + * Server-only: this module reads the recording with `node:fs`, so it must be + * imported from the server page and never from a `'use client'` file. + */ +interface RecordedRun { + beat: string; + action: { kind: string; checkpointIndex?: number }; + events: { event: unknown }[]; +} + +/** + * `histories[i].afterRun` is the number of runs that had COMPLETED when the + * snapshot was taken (so the snapshot after `runs[k]` has `afterRun === k + 1`, + * and the one the recorder took before `runs[k]` started has `afterRun === k`). + * A run that ends interrupted, and a reload, trigger no refresh, so not every + * count has a snapshot; look-ups below say which one they want. + */ +interface Recording { + runs: RecordedRun[]; + histories: { afterRun: number; states: unknown[] }[]; +} + +type Msg = { + type?: string; + name?: string; + content?: unknown; + additional_kwargs?: { citations?: unknown }; +}; + +type Dict = Record; + +function messagesOf(run: RecordedRun): Msg[] { + const out: Msg[] = []; + for (const { event } of run.events) { + const ev = event as Dict; + const lists = [ + ev['messages'], + (ev['data'] as Dict | undefined)?.['messages'], + ev['data'], + ]; + for (const l of lists) { + if (Array.isArray(l)) { + out.push(...(l.filter((m) => m && typeof m === 'object') as Msg[])); + } + } + } + return out; +} + +function toolResult(run: RecordedRun, name: string): unknown { + const m = messagesOf(run) + .filter((x) => x.type === 'tool' && x.name === name) + .at(-1); + if (!m || typeof m.content !== 'string') return undefined; + try { + return JSON.parse(m.content); + } catch { + return undefined; + } +} + +function toolCallNames(run: RecordedRun): Set { + const names = new Set(); + for (const m of messagesOf(run)) { + if (!Array.isArray(m.content)) continue; + for (const part of m.content as { type?: string; name?: string }[]) { + if (part?.type === 'function_call' && part.name) names.add(part.name); + } + } + return names; +} + +/** + * The Sources badge counts `additional_kwargs.citations` on the final AI + * message of the run's last state, so the proof line counts the same array. + */ +function citationCount(run: RecordedRun): number | null { + const last = run.events + .map(({ event }) => event as Dict) + .filter((ev) => ev['type'] === 'values') + .at(-1); + const messages = (last?.['data'] as Dict | undefined)?.['messages']; + if (!Array.isArray(messages)) return null; + const ai = (messages as Msg[]).filter((m) => m?.type === 'ai').at(-1); + const citations = ai?.additional_kwargs?.citations; + return Array.isArray(citations) ? citations.length : null; +} + +/** Largest `__interrupt__` array any event of the run carried (0 when none). */ +function interruptCount(run: RecordedRun): number { + let max = 0; + for (const { event } of run.events) { + const ev = event as Dict; + for (const list of [ + ev['__interrupt__'], + (ev['data'] as Dict | undefined)?.['__interrupt__'], + ]) { + if (Array.isArray(list) && list.length > max) max = list.length; + } + } + return max; +} + +function countKey(o: unknown, key: string): number { + if (Array.isArray(o)) return o.reduce((n, v) => n + countKey(v, key), 0); + if (o && typeof o === 'object') { + return Object.entries(o).reduce( + (n, [k, v]) => n + (k === key ? 1 : 0) + countKey(v, key), + 0 + ); + } + return 0; +} + +const join = (parts: (string | null)[]) => + parts.filter((p): p is string => p !== null).join(' · '); +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`; +const counted = (n: number | null, w: string) => (n ? plural(n, w) : null); + +export function deriveStageProof(rec: Recording): Record { + const run = (beat: string, kind: string): RecordedRun | undefined => + rec.runs.find((r) => r.beat === beat && r.action.kind === kind); + /** Checkpoint count of the latest snapshot taken with exactly `n` runs completed. */ + const histAfter = (n: number): number | null => + rec.histories.filter((h) => h.afterRun === n).at(-1)?.states.length ?? null; + /** Same, but the latest snapshot with at most `n` runs completed. */ + const histUpTo = (n: number): number | null => + rec.histories.filter((h) => h.afterRun <= n).at(-1)?.states.length ?? null; + + const r0 = run('stream', 'submit'); + const stream = join([ + counted(r0?.events.length ?? 0, 'event'), + counted(r0 ? toolCallNames(r0).size : 0, 'tool call'), + counted(r0 ? citationCount(r0) : null, 'source'), + ]); + + const reload = rec.runs.some((r) => r.action.kind === 'reload'); + const forkIdx = rec.runs.findIndex( + (r) => r.action.checkpointIndex !== undefined + ); + const fork = forkIdx >= 0 ? rec.runs[forkIdx] : undefined; + // The count the beat ends on: the snapshot after the fork run completed. + const checkpoints = fork ? histAfter(forkIdx + 1) : null; + // `checkpointIndex` indexes the history the user forked FROM, i.e. the + // snapshot taken before the fork run started (forkIdx runs completed). + // That list is newest-first, so index i of n is the chronological + // ordinal n - i: "forked at step 1" means the first of the checkpoints + // (the devtools label that same row `__start__`; the ordinal is the + // count the copy can be checked against, the label is not). + const forkFrom = fork ? histAfter(forkIdx) : null; + const forkStep = + fork && forkFrom ? forkFrom - (fork.action.checkpointIndex ?? 0) : 0; + const persist = join([ + reload ? 'reloaded' : null, + counted(checkpoints, 'checkpoint'), + forkStep > 0 ? `forked at step ${forkStep}` : null, + ]); + + const r4 = run('approve', 'submit'); + const interrupts = r4 ? interruptCount(r4) : 0; + const r4i = r4 ? rec.runs.indexOf(r4) : -1; + // The approve run ends interrupted, so the recorder took no snapshot with + // r4i + 1 runs completed; the latest one at or before that count is the + // history the interrupt is pending on. + const last = r4 ? histUpTo(r4i + 1) : null; + const approve = join([ + interrupts > 0 ? `${plural(interrupts, 'interrupt')} pending` : null, + last ? `checkpoint ${last} of ${last}` : null, + ]); + + const r6 = run('render', 'submit'); + const surface = r6 ? toolResult(r6, 'render_a2ui_surface') : undefined; + const surfaces = Array.isArray(surface) + ? countKey(surface, 'createSurface') + : 0; + const components = Array.isArray(surface) + ? countKey(surface, 'component') + : 0; + const render = join([ + counted(surfaces, 'surface'), + counted(components, 'component'), + 'no generated code ran', + ]); + + return { stream, persist, approve, render }; +} + +/** + * The committed recording the homepage stage replays; resolved from the app + * directory so `nx build website` (repo root) and `cd apps/website && vitest` + * read the same file. + */ +const RECORDING_PATH = resolve( + resolveWebsiteDir(), + '../../examples/chat/angular/public/stage-replay.json' +); + +function readRecording(): Recording { + let raw: string; + try { + raw = readFileSync(RECORDING_PATH, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + throw new Error( + code === 'ENOENT' + ? `stage-replay.json not found at ${RECORDING_PATH}; the website build reads the demo recording for the stage proof lines` + : `stage-replay.json at ${RECORDING_PATH} could not be read (${ + code ?? String(err) + })` + ); + } + try { + return JSON.parse(raw) as Recording; + } catch (err) { + throw new Error( + `stage-replay.json at ${RECORDING_PATH} is not valid JSON: ${String(err)}` + ); + } +} + +export const STAGE_PROOF: Record = deriveStageProof( + readRecording() +); diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index aa5baf8bc..691ffab23 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1287,7 +1287,7 @@ /* Stage — components/landing/Stage.tsx, StageStills.tsx, StageAct.tsx * The stills are the section's default form; the pinned act replaces them on * wide, motion-tolerant viewports after hydration. The stills reuse the - * feature-block row grammar so the two forms read as one section. */ + * rail grammar so the two forms read as one section. */ .stage-stills { display: grid; gap: 96px; @@ -1394,27 +1394,205 @@ font-size: 13px; justify-self: end; } -/* The rail beats stack in one grid cell so cues crossfade in place; the hold - * lines share a second cell beneath. */ +/* The rail (stage-rail spec §3): the segment bar on top, then ONE cell that + * the cues and the closing ledger share. The cues are their own grid: the + * beat blocks stacked in its first row so they crossfade in place, the hold + * line in its second. The ledger used to span two rail rows instead, and the + * grid split its height across both, so the hold line was pushed ~150px + * below the beat block; in one cell the ledger's height sizes the cell and + * nothing else. No min-height: the sticky pin already centers the rail. + * Segment and check state arrive as attributes from the publisher; the green + * is the render token, the one green the site has. */ .stage-rail { display: grid; grid-template-rows: auto auto; align-content: center; - min-height: 60vh; + row-gap: 20px; +} +.stage-rail-cues { + grid-area: 2 / 1; + align-self: start; + display: grid; + grid-template-rows: auto auto; + row-gap: 20px; +} +.stage-segs { + display: flex; + gap: 10px; +} +.stage-seg { + flex: 1; + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + font-weight: 600; + color: var(--color-text-secondary); + text-decoration: none; + padding-top: 10px; + position: relative; + opacity: 0.45; +} +.stage-seg::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + border-radius: 2px; + background: var(--color-border); +} +.stage-seg[data-beat-state='done'] { + opacity: 0.95; +} +.stage-seg[data-beat-state='done']::before { + background: var(--color-render-green); +} +.stage-seg[data-beat-state='now'] { + opacity: 1; + color: var(--color-text-primary); +} +.stage-seg[data-beat-state='now']::before { + background: var(--color-text-primary); } .stage-rail-beat { grid-area: 1 / 1; + display: flex; + gap: 14px; + align-items: flex-start; +} +.stage-check { + flex: none; + width: 22px; + height: 22px; + border-radius: 50%; + border: 1.5px solid var(--color-border-strong); + margin-top: 8px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.stage-check[data-checked]::after { + content: '✓'; + font-size: 12px; + font-weight: 700; +} +.stage-check[data-checked] { + background: var(--color-render-green); + border-color: var(--color-render-green); + color: #fff; +} +.stage-claim { + font-family: var(--font-garamond); + font-size: 30px; + line-height: 1.08; + font-weight: 500; + color: var(--color-text-primary); + margin: 0; +} +.stage-doc { + display: inline-block; + margin-top: 12px; + font-size: 12px; + opacity: 0.72; + text-decoration: none; + border-bottom: 1px solid var(--color-border); + color: inherit; +} +.stage-doc::after { + content: ' →'; +} +.stage-proof { + margin: 22px 0 0; + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-render-green); } .stage-rail-hold { grid-area: 2 / 1; - margin: 24px 0 0; + align-self: start; + margin: 0; font-size: 15px; line-height: 1.5; + opacity: 0.7; +} +.stage-rail-close { + grid-area: 2 / 1; +} +.stage-ledger { + list-style: none; + margin: 0 0 22px; + padding: 0; +} +.stage-ledger li { + display: flex; + align-items: center; + gap: 14px; + padding: 11px 0; + border-bottom: 1px solid var(--color-border); + font-size: 16px; +} +.stage-ledger .stage-check { + margin: 0; + width: 20px; + height: 20px; +} +/* The claim may wrap at the rail's width; the link never does. */ +.stage-ledger-claim { + flex: 1; + min-width: 0; +} +.stage-ledger .stage-doc { + margin: 0 0 0 auto; + white-space: nowrap; +} +.stage-rail-close .stage-claim { + font-size: 30px; +} +/* The install command is five packages long, so the chip wraps and the CTA + * drops beneath it rather than squeezing the rail. */ +.stage-install { + margin-top: 16px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.stage-install code { + background: var(--color-surface-dim); + padding: 8px 12px; + border-radius: 8px; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; } -/* Cues start hidden; the engine drives opacity and transform per frame. */ +.stage-install-cta { + font-size: 13px; + font-weight: 600; + text-decoration: none; +} +.stage-trust { + margin: 12px 0 0; + font-size: 12px; + opacity: 0.7; +} +/* Cues start hidden; the engine drives opacity and transform per frame. The + * beat blocks, the hold line and the ledger stack in one cell, so a hidden + * cue would still sit over the visible one and take its clicks: no cue owns + * the pointer until the publisher marks it the current beat or, for the + * ledger, active past the render settle. */ .stage-act [data-sc-cue] { opacity: 0; will-change: opacity, transform; + pointer-events: none; +} +.stage-rail-beat[data-beat-state='now'] { + pointer-events: auto; +} +.stage-rail-close[data-active] { + pointer-events: auto; } /* Keyboard path: the pin holds ~six viewports of scroll with nothing to tab * through, so the first focusable in the act skips past it. Hidden until @@ -1748,3 +1926,16 @@ letter-spacing: 0.04em; color: var(--color-text-muted); } + +/* The stills form of the beat block and the ledger — StageStills.tsx. The + * block mirrors `.stage-rail-beat` without the grid placement; the ending + * reuses `.stage-rail-close` (its `grid-area` is inert outside the rail grid) + * and only needs room after the last still. */ +.stage-still-text { + display: flex; + gap: 14px; + align-items: flex-start; +} +.stage-stills-close { + margin-top: 64px; +} diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts index 6689d31fa..8fa06ec5f 100644 --- a/apps/website/src/styles/style-contracts.spec.ts +++ b/apps/website/src/styles/style-contracts.spec.ts @@ -484,5 +484,38 @@ describe('style contracts', () => { expect(declarationsFor(css, '.stage-pin')).toMatch(/position:\s*sticky/); expect(declarationsFor(css, '.stage-act [data-sc-cue]')).toMatch(/opacity:\s*0/); }); + + /** + * The cues stack in one grid cell and are hidden by opacity alone, so a + * hidden ledger sits over the visible beat block and would take the click + * meant for its docs link. No cue owns the pointer until the publisher + * marks the block `now` or the ledger active. + */ + it("a hidden cue does not intercept the visible one's clicks", () => { + expect(declarationsFor(css, '.stage-act [data-sc-cue]')).toMatch(/pointer-events:\s*none/); + expect(declarationsFor(css, ".stage-rail-beat[data-beat-state='now']")).toMatch( + /pointer-events:\s*auto/ + ); + expect(declarationsFor(css, '.stage-rail-close[data-active]')).toMatch(/pointer-events:\s*auto/); + }); + + /** + * The rail's state is written as attributes by the publisher, not React, + * so nothing in a component test notices when the attribute has no rule + * behind it: the segment bar would never light and the checks never fill, + * and the ledger must share the cues' one cell: when it spanned two rail + * rows instead, the grid split its height across both and pushed the hold + * line ~150px below the beat block. + */ + it('the segment bar and the checks have a visible state, and the ledger shares the cues cell', () => { + expect(declarationsFor(css, ".stage-seg[data-beat-state='now']::before")).toMatch( + /background:/ + ); + expect(declarationsFor(css, '.stage-check[data-checked]')).toMatch(/background:/); + expect(declarationsFor(css, '.stage-rail-cues')).toMatch(/grid-area:\s*2\s*\/\s*1\s*;/); + expect(declarationsFor(css, '.stage-rail-close')).toMatch(/grid-area:\s*2\s*\/\s*1\s*;/); + expect(declarationsFor(css, '.stage-rail-beat')).toMatch(/grid-area:\s*1\s*\/\s*1\s*;/); + expect(declarationsFor(css, '.stage-rail-hold')).toMatch(/grid-area:\s*2\s*\/\s*1\s*;/); + }); }); }); diff --git a/docs/superpowers/plans/2026-09-06-stage-rail-redesign.md b/docs/superpowers/plans/2026-09-06-stage-rail-redesign.md new file mode 100644 index 000000000..6b9ec5bc4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-stage-rail-redesign.md @@ -0,0 +1,553 @@ +# Stage Rail Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the homepage stage's right column with a completeness ledger: a four-segment act navigation bar, one check + one claim + one docs link + one derived proof line per beat, a single hold line, and an ending that lists all four claims checked above "Feature complete for the final mile." with the install command. + +**Architecture:** Copy is single-sourced in `positioning.ts`; proof numbers are derived from the committed recording at build time by `stage-proof.ts` and passed down as props from the server page; the publisher (already DOM-only, ticking per frame) gains two attribute writes (segment state, check fill) driven by a new `settleAt(beat)` in `stage-beats.ts`; `StageAct` and `StageStills` render the new anatomy; the ending is one more cue whose window is the render beat's closing hold. + +**Tech Stack:** Next.js (apps/website, React 19, Vitest, Playwright), the vendored scroll-craft engine (unchanged), the demo recording `examples/chat/angular/public/stage-replay.json` (unchanged). + +**Spec:** `docs/superpowers/specs/2026-09-06-stage-rail-redesign-design.md`. + +--- + +## Conventions + +- Branch `blove/stage-rail-redesign` (cut from `origin/main` at `4c1db10c6`). Never `git stash`. Run commands from the repo root; website unit tests via `cd apps/website && npx vitest run ` for detail, `npx nx test website` for the whole suite. Only `npx nx build website` type-checks the website. +- Public copy is scanned by `apps/website/src/lib/public-copy.spec.ts` against `BANNED_CLAIMS`; no competitor names; the only scroll cue allowed is "Keep scrolling to approve". +- `landing.css` and `positioning.ts` are not prettier-clean at HEAD; match their neighbourhood style and never reformat them wholesale. +- The beat keys stay `stream | persist | approve | render` (they are the recording's and the beat map's). Only labels and claims change. +- The e2e dev server is `http://127.0.0.1:4308`; free the port before running (`lsof -iTCP:4308 -sTCP:LISTEN -n`). + +## File structure + +- Modify `apps/website/src/lib/positioning.ts` (+spec): `STAGE_RAIL` gets `label`, `claim`, `docs`; loses `eyebrow`, `headline`, `body`, `rows`, `cta`. New `STAGE_HOLD_LINE`, `STAGE_CLOSE`. Word-budget spec. +- Create `apps/website/src/lib/stage-proof.ts` (+spec): reads the recording, exports `deriveStageProof(recording)` and `STAGE_PROOF`. +- Modify `apps/website/src/lib/stage-beats.ts` (+spec): `settleAt(beat)`, `segmentState(beat, p)`. +- Modify `apps/website/src/components/landing/use-stage-publisher.ts` (+spec): writes `data-beat-state` on `[data-stage-segment]` and `data-checked` on `[data-stage-check]` when they change. +- Modify `apps/website/src/components/landing/StageAct.tsx`, `StageStills.tsx`, `Stage.tsx` (+`Stage.spec.tsx`): new anatomy, `proof` prop, segment click navigation, the ending cue. +- Modify `apps/website/src/app/page.tsx`: passes `STAGE_PROOF` to ``. +- Modify `apps/website/src/styles/landing.css` (+`style-contracts.spec.ts`): replace the `.stage-rail-*` rules. +- Modify `apps/website/e2e/home-stage.spec.ts`. + +--- + +### Task 1: Copy + +**Files:** modify `apps/website/src/lib/positioning.ts`, `apps/website/src/lib/positioning.spec.ts`. + +- [ ] **Step 1: Failing tests** — replace the two `STAGE_RAIL` cases in `positioning.spec.ts` with: + +```ts +describe('STAGE_RAIL', () => { + it('has one entry per beat in the beat map order, each a short claim with one docs link', () => { + expect(STAGE_RAIL.map((b) => b.beat)).toEqual([...STAGE_BEATS]); + for (const b of STAGE_RAIL) { + expect(b.label.length).toBeLessThanOrEqual(8); + expect(b.claim.length).toBeLessThanOrEqual(40); + expect(b.claim.endsWith('.')).toBe(true); + expect(b.docs.label).not.toBe(''); + expect(b.docs.href).toMatch(/^\//); + expect(b.stillAlt.length).toBeGreaterThan(40); + } + expect(STAGE_RAIL.map((b) => b.label)).toEqual(['Tools', 'Persist', 'Approve', 'Render']); + }); + it('carries one hold line and the closing ledger copy', () => { + expect(STAGE_HOLD_LINE).toBe('Keep scrolling to approve.'); + expect(STAGE_CLOSE.claim).toBe('Feature complete for the final mile.'); + expect(STAGE_CLOSE.install).toBe(INSTALL_OPTIONS[0].command.split('\n')[0]); + expect(STAGE_CLOSE.cta.href).toBe(INSTALL_OPTIONS[0].quickstartHref); + }); + it('keeps the rail under the word budget: four beats plus the ending', () => { + const words = (s: string) => s.trim().split(/\s+/).length; + const total = + STAGE_RAIL.reduce((n, b) => n + words(b.claim) + words(b.docs.label), 0) + + words(STAGE_HOLD_LINE) + + words(STAGE_CLOSE.claim) + + words(STAGE_CLOSE.cta.label); + expect(total).toBeLessThan(90); + }); +}); +``` +Check `INSTALL_OPTIONS[0].command` — if it is multi-line, the first line is the `npm i` command; otherwise use it whole. Adjust the assertion to the real shape and keep `STAGE_CLOSE.install` derived from it, not retyped. + +- [ ] **Step 2: Run** `cd apps/website && npx vitest run positioning` — FAIL. + +- [ ] **Step 3: Implement** — replace the `StageRailBeat` interface, `STAGE_RAIL`, and `STAGE_HOLD_LINES` with: + +```ts +export interface StageRailBeat { + readonly beat: StageBeatKey; + /** Segment label in the act navigation bar. */ + readonly label: string; + /** The one line the rail says for this beat. */ + readonly claim: string; + /** The page that proves it. */ + readonly docs: { readonly label: string; readonly href: string }; + /** Alt text for the fallback still: what the frame shows at this beat's settle. */ + readonly stillAlt: string; +} + +export const STAGE_RAIL: readonly StageRailBeat[] = [ + { + beat: 'stream', + label: 'Tools', + claim: 'Tool calls and citations as signals.', + docs: { label: 'Tool calls', href: '/docs/chat/components/chat-tool-calls' }, + stillAlt: '', + }, + { + beat: 'persist', + label: 'Persist', + claim: 'Durable threads, no license.', + docs: { label: 'Persistence', href: '/docs/langgraph/guides/persistence' }, + stillAlt: '', + }, + { + beat: 'approve', + label: 'Approve', + claim: 'Interrupts and approvals, built in.', + docs: { label: 'Interrupts', href: '/docs/langgraph/guides/interrupts' }, + stillAlt: '', + }, + { + beat: 'render', + label: 'Render', + claim: 'Generative UI on A2UI and json-render.', + docs: { label: '@threadplane/render', href: '/render' }, + stillAlt: '', + }, +]; + +/** Spec §3.3: the only copy shown while recorded time is pinned at the interrupt, and the page's one scroll cue. */ +export const STAGE_HOLD_LINE = 'Keep scrolling to approve.'; + +/** Spec §3.4: the last screen. `install` is derived from the install options so the command lives in one place. */ +export const STAGE_CLOSE = { + claim: 'Feature complete for the final mile.', + install: INSTALL_OPTIONS[0].command.split('\n')[0], + cta: { label: 'Spike it this week', href: INSTALL_OPTIONS[0].quickstartHref }, +} as const; +``` +`INSTALL_OPTIONS` is declared later in the file; move `STAGE_CLOSE` below it (or hoist `INSTALL_OPTIONS` above) so there is no TDZ error. Delete `STAGE_HOLD_LINES`. + +- [ ] **Step 4: Run** `npx vitest run positioning public-copy` — PASS. The build will be red until Tasks 4–5 update the consumers; that is expected. + +- [ ] **Step 5: Commit** `feat(website): stage rail copy becomes a four-claim ledger` + +--- + +### Task 2: Proof lines from the recording + +**Files:** create `apps/website/src/lib/stage-proof.ts`, `apps/website/src/lib/stage-proof.spec.ts`. + +The recording (`examples/chat/angular/public/stage-replay.json`, version 2) has `runs[]` (`beat`, `action.kind`, `events[{tMs,event}]`) and `histories[{afterRun, states[]}]`. Shapes found in the committed take: +- Run 0 (`stream`, submit): 586 events; the `search_documents` ToolMessage's `content` is a JSON string of an array of documents (`[{id,title,url,snippet,...}]`) — the number of sources. +- Runs 1–3 (`persist`): run 1 is the reload; run 3 is the fork with `action.checkpointIndex = 9`; `histories` with `afterRun === 4` has 10 states. +- Run 4 (`approve`, submit): an event `{ type: 'updates', __interrupt__: [{ value: { type: 'approval_request', ids: [...], ... } }] }`. +- Run 6 (`render`): the `render_a2ui_surface` ToolMessage `content` is a JSON string of an array of A2UI envelopes; component count = the number of entries under every `updateComponents.components` (or the same list under whatever key the envelope uses — inspect and count objects that carry a `component` key). + +- [ ] **Step 1: Failing test** + +```ts +// stage-proof.spec.ts +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { deriveStageProof, STAGE_PROOF } from './stage-proof'; + +const REC = resolve(__dirname, '../../../../examples/chat/angular/public/stage-replay.json'); + +describe('stage proof', () => { + const rec = JSON.parse(readFileSync(REC, 'utf8')); + const proof = deriveStageProof(rec); + it('counts the first beat from the recording, never types it', () => { + expect(proof.stream).toMatch(/^\d{3,} events · 1 tool call · \d sources$/); + expect(proof.stream.startsWith(`${rec.runs[0].events.length} events`)).toBe(true); + }); + it('reads the reload, the checkpoint count and the fork step', () => { + expect(proof.persist).toMatch(/^reloaded · \d+ checkpoints · forked at step \d+$/); + }); + it('reads the pending interrupt and the checkpoint count', () => { + expect(proof.approve).toMatch(/^1 interrupt pending · checkpoint \d+ of \d+$/); + }); + it('reads the surface and its component count', () => { + expect(proof.render).toMatch(/^1 surface · \d+ components · no generated code ran$/); + }); + it('drops a segment it cannot derive instead of defaulting it', () => { + const noCitations = { ...rec, runs: rec.runs.map((r: { beat: string }, i: number) => (i === 0 ? { ...r, events: [] } : r)) }; + const p = deriveStageProof(noCitations); + expect(p.stream).not.toMatch(/sources/); + expect(p.stream).not.toMatch(/NaN|undefined/); + }); + it('is what the page ships', () => { + expect(STAGE_PROOF).toEqual(proof); + }); +}); +``` + +- [ ] **Step 2: Run** `npx vitest run stage-proof` — FAIL. + +- [ ] **Step 3: Implement** + +```ts +// stage-proof.ts +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { StageBeat } from './stage-beats'; + +/** + * Proof lines for the stage rail (spec §4): counts read from the committed + * recording at build time. Nothing here is typed by hand; a segment whose + * number cannot be derived is omitted, never estimated. The one phrase that is + * a property rather than a count is "no generated code ran". + */ +interface RecordedRun { beat: string; action: { kind: string; checkpointIndex?: number }; events: { event: unknown }[] } +interface Recording { runs: RecordedRun[]; histories: { afterRun: number; states: unknown[] }[] } + +type Msg = { type?: string; name?: string; content?: unknown }; + +function messagesOf(run: RecordedRun): Msg[] { + const out: Msg[] = []; + for (const { event } of run.events) { + const ev = event as Record; + const lists = [ev['messages'], (ev['data'] as Record | undefined)?.['messages'], ev['data']]; + for (const l of lists) if (Array.isArray(l)) out.push(...(l.filter((m) => m && typeof m === 'object') as Msg[])); + } + return out; +} + +function toolResult(run: RecordedRun, name: string): unknown { + const m = messagesOf(run).filter((x) => x.type === 'tool' && x.name === name).at(-1); + if (!m || typeof m.content !== 'string') return undefined; + try { return JSON.parse(m.content); } catch { return undefined; } +} + +function toolCallNames(run: RecordedRun): Set { + const names = new Set(); + for (const m of messagesOf(run)) { + if (!Array.isArray(m.content)) continue; + for (const part of m.content as { type?: string; name?: string }[]) if (part?.type === 'function_call' && part.name) names.add(part.name); + } + return names; +} + +function countKey(o: unknown, key: string): number { + if (Array.isArray(o)) return o.reduce((n, v) => n + countKey(v, key), 0); + if (o && typeof o === 'object') return Object.entries(o).reduce((n, [k, v]) => n + (k === key ? 1 : 0) + countKey(v, key), 0); + return 0; +} + +const join = (parts: (string | null)[]) => parts.filter((p): p is string => p !== null).join(' · '); +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`; + +export function deriveStageProof(rec: Recording): Record { + const run = (beat: string, kind: string, nth = 0) => rec.runs.filter((r) => r.beat === beat && r.action.kind === kind)[nth]; + const histAfter = (i: number) => rec.histories.filter((h) => h.afterRun === i).at(-1)?.states.length ?? null; + + const r0 = run('stream', 'submit'); + const sources = Array.isArray(toolResult(r0, 'search_documents')) ? (toolResult(r0, 'search_documents') as unknown[]).length : null; + const stream = join([ + r0.events.length > 0 ? plural(r0.events.length, 'event') : null, + toolCallNames(r0).size > 0 ? plural(toolCallNames(r0).size, 'tool call') : null, + sources ? plural(sources, 'source') : null, + ]); + + const reload = rec.runs.some((r) => r.action.kind === 'reload'); + const fork = rec.runs.find((r) => r.action.checkpointIndex !== undefined); + const forkIdx = rec.runs.indexOf(fork!); + const checkpoints = fork ? histAfter(forkIdx + 1) : null; + // history() is newest-first: index i of n is step n - i. + const forkStep = fork && checkpoints !== null ? checkpoints - (fork.action.checkpointIndex ?? 0) : null; + const persist = join([reload ? 'reloaded' : null, checkpoints ? plural(checkpoints, 'checkpoint') : null, forkStep ? `forked at step ${forkStep}` : null]); + + const r4 = run('approve', 'submit'); + const interrupted = r4.events.some(({ event }) => Array.isArray((event as Record)['__interrupt__'])); + const r4i = rec.runs.indexOf(r4); + const last = histAfter(r4i) ?? histAfter(r4i + 1); + const approve = join([interrupted ? '1 interrupt pending' : null, last ? `checkpoint ${last} of ${last}` : null]); + + const r6 = run('render', 'submit'); + const surface = toolResult(r6, 'render_a2ui_surface'); + const surfaces = Array.isArray(surface) ? countKey(surface, 'createSurface') : 0; + const components = Array.isArray(surface) ? countKey(surface, 'component') : 0; + const render = join([surfaces ? plural(surfaces, 'surface') : null, components ? plural(components, 'component') : null, 'no generated code ran']); + + return { stream, persist, approve, render }; +} + +const RECORDING = resolve(process.cwd(), 'examples/chat/angular/public/stage-replay.json'); +const RECORDING_FROM_APP = resolve(process.cwd(), '../../examples/chat/angular/public/stage-replay.json'); + +function readRecording(): Recording { + for (const p of [RECORDING, RECORDING_FROM_APP]) { + try { return JSON.parse(readFileSync(p, 'utf8')) as Recording; } catch { /* next */ } + } + throw new Error('stage-replay.json not found; the website build reads the demo recording for the stage proof lines'); +} + +export const STAGE_PROOF: Record = deriveStageProof(readRecording()); +``` +The cwd differs between `nx build website` (repo root) and `cd apps/website && vitest`; the two candidates cover both. Verify the counts against the take by printing them once (expected order of magnitude: 586 events, 1 tool call, a small number of sources; 10 checkpoints; forked at step 1; checkpoint 10 of 10; 1 surface, ~7 components). If the search result's array length is not what the frame's Sources badge shows (3), find the field the badge counts and use that — the proof must match what the frame displays. + +- [ ] **Step 4: Run** — PASS. `npx eslint apps/website/src/lib/stage-proof.ts` clean. Confirm the module is server-only: it uses `node:fs`, so it must only be imported from `page.tsx` (server) — never from a `'use client'` file. + +- [ ] **Step 5: Commit** `feat(website): stage proof lines derived from the recording` + +--- + +### Task 3: Settle points and segment state + +**Files:** modify `apps/website/src/lib/stage-beats.ts`, `stage-beats.spec.ts`. + +- [ ] **Step 1: Failing tests** + +```ts +describe('settleAt / segmentState', () => { + it('settles tools and persist at their window end, approve at the threshold, render before its tail', () => { + const w = beatWindows(); + expect(settleAt('stream')).toBe(w[0].to); + expect(settleAt('persist')).toBe(w[1].to); + expect(settleAt('approve')).toBe(APPROVE_THRESHOLD_P); + expect(settleAt('render')).toBeCloseTo(w[3].from + (w[3].to - w[3].from) * (1 - RENDER_TAIL), 6); + }); + it('reports done / now / todo per beat from progress', () => { + const w = beatWindows(); + expect(segmentState('stream', 0.05)).toBe('now'); + expect(segmentState('persist', 0.05)).toBe('todo'); + expect(segmentState('stream', w[1].from + 0.01)).toBe('done'); + expect(segmentState('approve', w[2].from + 0.01)).toBe('now'); + expect(segmentState('render', 1)).toBe('now'); + }); + it('a beat is checked once progress passes its settle', () => { + expect(isChecked('approve', APPROVE_THRESHOLD_P - 0.001)).toBe(false); + expect(isChecked('approve', APPROVE_THRESHOLD_P)).toBe(true); + expect(isChecked('render', 0.999)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Implement** in `stage-beats.ts`: + +```ts +/** Act progress at which a beat's claim is proven on screen and its check fills. */ +export function settleAt(beat: StageBeat): number { + const w = WINDOWS[STAGE_BEATS.indexOf(beat)]; + if (beat === 'approve') return APPROVE_THRESHOLD_P; + if (beat === 'render') return w.from + (w.to - w.from) * (1 - RENDER_TAIL); + return w.to; +} +export type SegmentState = 'done' | 'now' | 'todo'; +export function segmentState(beat: StageBeat, p: number): SegmentState { + const current = beatAt(p); + if (beat === current) return 'now'; + return STAGE_BEATS.indexOf(beat) < STAGE_BEATS.indexOf(current) ? 'done' : 'todo'; +} +export function isChecked(beat: StageBeat, p: number): boolean { + return p >= settleAt(beat); +} +``` + +- [ ] **Step 3: Run** `npx vitest run stage-beats` — PASS. **Commit** `feat(website): beat settle points and segment states` + +--- + +### Task 4: Publisher writes segment and check state + +**Files:** modify `apps/website/src/components/landing/use-stage-publisher.ts`, `use-stage-publisher.spec.ts`. + +- [ ] **Step 1: Failing test** + +```ts +it('writes segment states and check fills onto the rail as progress moves', () => { + const { section, pub } = setup(); + for (const b of STAGE_BEATS) { + const seg = document.createElement('a'); seg.setAttribute('data-stage-segment', b); section.appendChild(seg); + const chk = document.createElement('span'); chk.setAttribute('data-stage-check', b); section.appendChild(chk); + } + section.style.setProperty('--sc-p', '0.05'); pub.tick(); + expect(section.querySelector('[data-stage-segment="stream"]')!.getAttribute('data-beat-state')).toBe('now'); + expect(section.querySelector('[data-stage-segment="persist"]')!.getAttribute('data-beat-state')).toBe('todo'); + expect(section.querySelector('[data-stage-check="stream"]')!.hasAttribute('data-checked')).toBe(false); + section.style.setProperty('--sc-p', String(beatWindows()[1].from + 0.01)); pub.tick(); + expect(section.querySelector('[data-stage-segment="stream"]')!.getAttribute('data-beat-state')).toBe('done'); + expect(section.querySelector('[data-stage-check="stream"]')!.hasAttribute('data-checked')).toBe(true); + section.style.setProperty('--sc-p', '1'); pub.tick(); + expect(section.querySelectorAll('[data-stage-check][data-checked]')).toHaveLength(4); +}); +``` + +- [ ] **Step 2: Implement** — in `createStagePublisher`, query the segments and checks once at construction (`section.querySelectorAll('[data-stage-segment]')`, `'[data-stage-check]'`), keep the last written state per element, and in `tick()` after the hold update: + +```ts +for (const el of segments) { + const s = segmentState(el.getAttribute('data-stage-segment') as StageBeat, p); + if (el.getAttribute('data-beat-state') !== s) el.setAttribute('data-beat-state', s); +} +for (const el of checks) { + const on = isChecked(el.getAttribute('data-stage-check') as StageBeat, p); + if (on !== el.hasAttribute('data-checked')) { if (on) el.setAttribute('data-checked', ''); else el.removeAttribute('data-checked'); } +} +``` +Attribute writes only when changed (the harness signature and the browser's style recalc both benefit). + +- [ ] **Step 3: Run** `npx vitest run use-stage-publisher` — PASS; eslint clean. **Commit** `feat(website): stage publisher drives the segment bar and the checks` + +--- + +### Task 5: The act + +**Files:** modify `apps/website/src/components/landing/StageAct.tsx`, `Stage.tsx`, `Stage.spec.tsx`, `apps/website/src/app/page.tsx`, `apps/website/src/styles/landing.css`, `apps/website/src/styles/style-contracts.spec.ts`. + +- [ ] **Step 1: Failing tests** — in `Stage.spec.tsx`'s wide case, replace the rail assertions with: + +```ts +const act = document.querySelector('[data-stage-act]')!; +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); +expect(act.querySelectorAll('[data-stage-check]')).toHaveLength(4 + 4); // one per beat block, four in the ledger +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(act.querySelector('[data-testid="stage-rail-close"]')!.textContent).toContain('Feature complete for the final mile.'); +for (const a of act.querySelectorAll('.stage-rail a')) expect(a.getAttribute('tabindex')).toBe('-1'); +expect(act.querySelector('[data-testid="stage-rail-beat"][data-beat="stream"] [data-stage-proof]')!.textContent).toBe(PROOF.stream); +``` +where `PROOF` is a fixture passed as `` in the spec. Also a case: clicking the Persist segment calls `window.scrollTo` with a top inside the persist window (stub `scrollTo`, give the section an `offsetHeight` via `Object.defineProperty`, `innerHeight` 900). + +- [ ] **Step 2: Implement `StageAct`** — props become `{ onFallback, proof: Record }`. Replace the rail markup: + +```tsx +
+

One real run: tools, persist, approve, render

+ + {STAGE_RAIL.map((b) => ( +
+
+ ))} +

{STAGE_HOLD_LINE}

+
+
    + {STAGE_RAIL.map((b) => ( +
  • + ))} +
+

{STAGE_CLOSE.claim}

+
{STAGE_CLOSE.install}{STAGE_CLOSE.cta.label} →
+

{HERO_TRUST_LINE} · LangGraph and AG-UI

+
+
+``` +with, in `stage-beats.ts` (add to Task 3 if you are there first): `holdCue()` = the approve hold range as a two-value cue (`start end` from `holdLineCues(1)[0]`, so replace `holdLineCues` with `holdCue()` returning one string and delete the count version), and `closeCue()` = `"${settleAt('render')} 1 0.3 0"`; and `cueFor('render')` must now END at the render settle (`"${from} ${settleAt('render')}"`, no closing hold) so the render block crossfades into the ledger. Update `cueFor`'s spec for the last beat accordingly. `scrollToBeat(beat)`: + +```ts +const scrollToBeat = (beat: StageBeat) => { + const el = sectionRef.current; if (!el) return; + const top = el.getBoundingClientRect().top + window.scrollY; + const w = beatWindows()[STAGE_BEATS.indexOf(beat)]; + window.scrollTo({ top: top + (el.offsetHeight - window.innerHeight) * (w.from + 0.02), behavior: 'smooth' }); +}; +``` +Remove the `Eyebrow`, `feature-block-*` usage and the `STAGE_HOLD_LINES`/`holdLineCues` imports from the act. `Stage.tsx` gains a `proof` prop and forwards it to both `StageAct` and `StageStills`. `page.tsx`: `import { STAGE_PROOF } from '../lib/stage-proof';` and `` (server component importing a `node:fs` module is fine; the client boundary receives a plain object). + +- [ ] **Step 3: CSS** — replace the `.stage-rail`, `.stage-rail-beat`, `.stage-rail-hold` rules with: + +```css +/* The rail: a segment bar on top, the beat blocks stacked in one cell so cues + * crossfade in place, the hold line and the closing ledger in cells beneath. */ +.stage-rail { display: grid; grid-template-rows: auto auto auto; align-content: center; min-height: 60vh; row-gap: 28px; } +.stage-segs { display: flex; gap: 10px; } +.stage-seg { flex: 1; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; font-weight: 600; color: var(--color-text-secondary); text-decoration: none; padding-top: 10px; position: relative; opacity: 0.45; } +.stage-seg::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; border-radius: 2px; background: var(--color-border); } +.stage-seg[data-beat-state='done'] { opacity: 0.95; } +.stage-seg[data-beat-state='done']::before { background: var(--color-accent-green, #2f6f4f); } +.stage-seg[data-beat-state='now'] { opacity: 1; color: var(--color-text-primary); } +.stage-seg[data-beat-state='now']::before { background: var(--color-text-primary); } +.stage-rail-beat { grid-area: 2 / 1; display: flex; gap: 14px; align-items: flex-start; } +.stage-check { flex: none; width: 22px; height: 22px; border-radius: 50%; border: 1.5px solid var(--color-border-strong, var(--color-border)); margin-top: 8px; display: inline-flex; align-items: center; justify-content: center; } +.stage-check[data-checked]::after { content: '✓'; font-size: 12px; font-weight: 700; } +.stage-check[data-checked] { background: var(--color-accent-green, #2f6f4f); border-color: var(--color-accent-green, #2f6f4f); color: #fff; } +.stage-claim { font-family: var(--font-serif); font-size: 34px; line-height: 1.08; font-weight: 500; margin: 0; } +.stage-doc { display: inline-block; margin-top: 12px; font-size: 12px; opacity: 0.55; text-decoration: none; border-bottom: 1px solid var(--color-border); color: inherit; } +.stage-doc::after { content: ' →'; } +.stage-proof { margin: 22px 0 0; font-family: var(--font-mono); font-size: 12px; color: var(--color-accent-green, #2f6f4f); } +.stage-rail-hold { grid-area: 3 / 1; margin: 0; font-size: 15px; opacity: 0.7; } +.stage-rail-close { grid-area: 2 / 1 / span 2; } +.stage-ledger { list-style: none; margin: 0 0 22px; padding: 0; } +.stage-ledger li { display: flex; align-items: center; gap: 14px; padding: 11px 0; border-bottom: 1px solid var(--color-border); font-size: 17px; } +.stage-ledger .stage-check { margin: 0; width: 20px; height: 20px; } +.stage-ledger .stage-doc { margin: 0 0 0 auto; } +.stage-rail-close .stage-claim { font-size: 30px; } +.stage-install { margin-top: 16px; display: flex; align-items: center; gap: 12px; } +.stage-install code { background: var(--color-surface-2, rgba(128,128,128,.12)); padding: 8px 12px; border-radius: 8px; font-size: 13px; } +.stage-install-cta { font-size: 13px; font-weight: 600; text-decoration: none; } +.stage-trust { margin: 12px 0 0; font-size: 12px; opacity: 0.5; } +``` +Use the token names that exist in `libs/design-tokens/src/lib/theme.css` (grep `--font-serif`, `--font-mono`, `--color-border`, `--color-text-secondary`, and an accent green; if there is no green token, use the literal `#2f6f4f` used by the Reliability live badge and say so). Style-contract cases: `.stage-seg[data-beat-state='now']` has a `::before` background; `.stage-check[data-checked]` has a background; `.stage-rail-close` spans two rows. + +- [ ] **Step 4: Run** `npx vitest run Stage style-contracts use-stage-publisher stage-beats` — PASS; eslint clean; `rm -rf apps/website/.next && npx nx build website` compiles. + +- [ ] **Step 5: Commit** `feat(website): the stage rail is a completeness ledger — segments, checks, one claim, one link, one proof` + +--- + +### Task 6: The stills + +**Files:** modify `apps/website/src/components/landing/StageStills.tsx`, `StageStills.spec.tsx`. + +- [ ] **Step 1: Failing test** — replace the row/CTA assertions: each beat article has `.stage-claim` = `STAGE_RAIL[i].claim`, a `.stage-doc` with the docs href, a `[data-stage-proof]` with the proof; after the four articles a `[data-testid="stage-stills-close"]` with four `.stage-ledger li`, the close claim, the install code, and the cta href. + +- [ ] **Step 2: Implement** — `StageStills({ proof })` renders per article: the picture, then `
`; after the list, the same close block as the act (`data-testid="stage-stills-close"`, links focusable here). Stills' checks are always filled (the still IS the settle). CSS: `.stage-still-text { display: flex; gap: 14px; align-items: flex-start; }` and reuse the rest. + +- [ ] **Step 3: Run** `npx vitest run StageStills` — PASS. **Commit** `feat(website): stage stills carry the ledger` + +--- + +### Task 7: e2e, harness, verification, PR + +**Files:** modify `apps/website/e2e/home-stage.spec.ts`. + +- [ ] **Step 1: e2e** — in test 2 replace the cue assertions with: at 5% the Tools segment has `data-beat-state="now"` and its beat block's check has no `data-checked`; at 30% Tools is `done` and checked; at 68% `[data-testid=stage-rail-hold]` opacity ≥ 0.5 and `data-sc-verify-hold="true"`; at 100% `[data-testid=stage-rail-close]` opacity is `1`, four `[data-stage-check][data-checked]` inside it, and the install code text equals the first line of `INSTALL_OPTIONS[0].command` (import from `../src/lib/positioning`). New test: clicking the Persist segment (use `page.locator('[data-stage-segment="persist"]').click({ force: true })` because of `tabIndex=-1`? no — click works on any element; force is not needed) scrolls the act so `--sc-p` lands inside the persist window within 1.5 s (`expect.poll`). Keep the live-frame test as is. Run `npx nx e2e website -- --grep "homepage stage"` → all pass; twice. + +- [ ] **Step 2: Harness** — free 4308, then: +``` +(npx nx serve website --configuration=production --port=4308 --skip-nx-cache > /serve.log 2>&1 &) +until curl -sf http://127.0.0.1:4308/ > /dev/null; do sleep 2; done +ln -sfn ../../../apps/website/content dist/apps/website/content +node apps/website/e2e/scroll-craft/verify-home.mjs --url http://127.0.0.1:4308 --out /stage-shots +``` +Expect "no dead scroll detected" and every cue peaking; read `desktop/sheet.png` and confirm the segments, the checks filling, the hold line, and the ledger ending. Kill the server. + +- [ ] **Step 3: Everything** +``` +npx nx test website +npx nx lint website 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -E "problems|error " +npx nx build website 2>&1 | grep -iE "error|compiled|failed" | head +npx nx e2e website -- --grep "homepage stage|homepage hero|landing page" +``` + +- [ ] **Step 4: PR** — push `blove/stage-rail-redesign`, `gh pr create` titled `feat(website): the stage rail is a completeness ledger` with a body covering why (busy, restating, no takeaway), what (segments, check+claim+link+proof, one hold line, the ledger ending, proof derived from the recording, beat one reframed as tool calls and citations), and tests; end with the Claude Code footer; `gh pr merge --auto --squash`. After merge and deploy: `STAGE_LIVE_FRAME=true BASE_URL=https://threadplane.ai npx playwright test apps/website/e2e/home-stage.spec.ts --config apps/website/playwright.config.ts` → all pass. + +--- + +## Self-review + +**Spec coverage:** §3.1 segment bar (Tasks 3–5: states from progress, click navigation, replaces the eyebrow); §3.2 beat block (Task 5 markup, Task 4 check fill at `settleAt`, links `tabIndex=-1`); §3.3 hold (Task 1 single line, Task 5 `holdCue()`); §3.4 ending (Task 5 `closeCue()` meeting the render settle, ledger, claim, install, cta, trust line); §4 proof (Task 2, omitted-not-defaulted, spec pins shapes, props from the server page); §5 copy (Task 1, keys unchanged); §6 stills (Task 6); §7 verification (Task 7 incl. the harness and the word budget in Task 1). + +**Placeholder scan:** `` in Task 1 means "copy the existing string verbatim" and is stated so. The A2UI component-count key is stated as "count objects carrying a `component` key" with an instruction to verify against the take; the sources count has the same verify instruction. No TBDs. + +**Type consistency:** `StageRailBeat` fields (`label`, `claim`, `docs`, `stillAlt`) are what Tasks 5–6 read; `settleAt`/`segmentState`/`isChecked` (Task 3) are what Task 4 calls; `data-stage-segment`/`data-stage-check`/`data-beat-state`/`data-checked` are the attributes Tasks 4, 5, 6, 7 share; `proof: Record` flows page → Stage → StageAct/StageStills; `holdCue()` and `closeCue()` replace `holdLineCues` everywhere (Task 5 deletes the old export and its spec cases). diff --git a/docs/superpowers/specs/2026-09-06-stage-rail-redesign-design.md b/docs/superpowers/specs/2026-09-06-stage-rail-redesign-design.md new file mode 100644 index 000000000..650ec2c81 --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-stage-rail-redesign-design.md @@ -0,0 +1,102 @@ +# Stage rail redesign: a completeness ledger + +**Date:** 2026-09-06 +**Status:** Design approved in brainstorming; awaiting spec review. +**Surface:** `apps/website` only: `src/lib/positioning.ts`, `src/components/landing/StageAct.tsx`, `StageStills.tsx`, `src/styles/landing.css`, a build-time script under `src/lib/`, and `e2e/home-stage.spec.ts`. +**Builds on:** `2026-09-05-homepage-live-stage-design.md` (the stage, the beat map, the hold, the protocol, the harness). Nothing in the frame, the recording, the beat map, the threshold, or analytics changes. + +## 1. Why + +The right column of the homepage stage is busy, text-heavy, and carries no takeaway. At the Approve beat it renders six text levels (eyebrow, serif headline, body paragraph, three claim/API rows, a link, three hold sentences), about 75 words per beat and 300 across the act, beside a frame that is itself full of text. The copy restates what the frame shows ("The proposal renders in your UI" while the proposal renders 400px to the left), one hold line duplicates a row above it, and nothing in the column is visual, so the crossfade between beats reads as "the paragraph changed". The four CTAs point away from the page at the moment the visitor is meant to scroll toward the peak. + +## 2. Decisions + +| Decision | Choice | +|---|---| +| What the whole scroller says | To a developer: Threadplane is feature complete for the final mile; do a spike and install it. The chat on the left is the user's journey; the rail is the developer's ledger of what ships. | +| What each beat says | One capability claim, no body copy: tool calls and citations as signals; durable threads, no license; interrupts and approvals, built in; generative UI on A2UI and json-render. | +| Beat one | Reframed, not replaced. Streaming alone is table stakes; the recorded first turn already runs a `search_documents` tool call and attaches citations, so the claim is tool calls and citations. Subagents as a beat is a separate follow-up that needs a graph change and a re-record. Memory is a runtime feature, not a Threadplane surface. Testing already owns the section below the stage. | +| Rail anatomy | A four-segment bar with beat labels on top, doubling as act navigation; a round check, one serif claim, one quiet docs link, one monospace proof line per beat. | +| Proof | Read from `stage-replay.json` at build time, never typed. A number that cannot be derived is omitted, not estimated. | +| The hold | The check, the claim, and one line: "Keep scrolling to approve." The three hold sentences are removed. | +| The ending | The four claims as a checked ledger with their docs links, then "Feature complete for the final mile.", the install command, "Spike it this week", and the trust line. | +| Third parties | The page names the standards (A2UI, json-render). Vendor names stay on the render page the link opens. No competitor names anywhere. | +| Copy rules | scroll-craft's hard rules stand: no invented numbers, one peak, the single sanctioned scroll cue ("Keep scrolling to approve"). | + +## 3. The rail + +### 3.1 Segment bar + +Four segments across the top of the rail, each a label in small caps (Tools, Persist, Approve, Render) over a 3px bar. State comes from act progress: a beat whose window has been passed is done (green bar, full-opacity label), the current beat is live (white bar), the rest are dim. Each segment is an anchor that scrolls the page to that beat's start (`beatWindows()[i].from` mapped to the act's travel), with `behavior: 'smooth'` unless reduced motion (which never reaches the act). The bar replaces the eyebrow. + +The segment states are driven by the same publisher tick that computes `t`: it writes `data-beat-state="done|now|todo"` on each segment when the state changes, so no React state per frame. + +### 3.2 Beat block + +One block per beat, stacked in the same grid cell so the engine crossfades them with `data-sc-cue` exactly as today: + +- A 22px round check to the left. Hollow while the beat is playing; filled green once act progress passes the beat's settle (the beat window's end for Tools and Persist, the threshold for Approve, the render tail for Render). The fill is a class toggled by the publisher (`data-checked`), not a cue. +- The claim, serif, one line at 1440 wide, no body text beneath. +- A docs link under the claim in 12px at half opacity with a trailing arrow. One per beat: `/docs/chat/components/chat-tool-calls`, `/docs/langgraph/guides/persistence`, `/docs/langgraph/guides/interrupts`, `/render`. Rail links keep `tabIndex={-1}` (the cues hide them by opacity; the same links are in the stills and the ledger). +- The proof line, monospace 12px green, derived from the recording (§4). + +### 3.3 The hold + +Within the approve hold range the beat block stays; the proof line is replaced by "Keep scrolling to approve." as a cue with the hold's window. No other hold copy exists. The "Open the live demo →" link under the frame stays where it is. + +### 3.4 The ending + +The last cue window (`cueFor('render')`'s closing hold) shows, in place of the render beat block once its check fills: + +- The ledger: four rows, each a filled check, the claim, and the docs link right-aligned. +- "Feature complete for the final mile." in the claim style. +- The install row: `npm i @threadplane/chat` in a code chip and "Spike it this week →" linking to the quickstart (`INSTALL_OPTIONS[0].quickstartHref`). +- The trust line from `positioning.ts` (`HERO_TRUST_LINE`) plus "LangGraph and AG-UI". + +The render beat block and the ledger are two cues whose windows meet at the render settle, so the swap is a crossfade, not a jump. The ending holds to the end of the act. + +## 4. Proof lines + +A build-time module `src/lib/stage-proof.ts` reads `examples/chat/angular/public/stage-replay.json` (already on disk in the monorepo; the website's `positioning.spec.ts` pattern of reading repo files applies) and exports `STAGE_PROOF: Record`. Derivations, all counted, none estimated: + +| Beat | Proof line | Derived from | +|---|---|---| +| Tools | `N events · 1 tool call · 3 sources` | run 0 event count; tool_call names in run 0; the citations array length in run 0's final state | +| Persist | `reloaded · N checkpoints · forked at step K` | the reload run exists; `histories` snapshot after run 3 length; the fork `checkpointIndex` mapped to the step label the devtools show (history is newest-first; step = length − index) | +| Approve | `1 interrupt pending · checkpoint N of N` | the interrupt in run 4's events; histories after run 4 (or the last snapshot before it) | +| Render | `1 surface · N components · no generated code ran` | the A2UI payload in run 6; component count from the surface's top-level children; the last clause is a stated property of `@threadplane/render`, not a count, and is the only non-derived phrase | + +If a derivation finds nothing (a re-record without citations, say), the segment is dropped from the line rather than defaulted. A unit spec pins every derivation against the committed recording and fails if the recording changes shape. The proof strings are passed to `StageAct` and `StageStills` as props from the server page, so the recording is never fetched by the browser. + +## 5. Copy + +In `positioning.ts`, `STAGE_RAIL` becomes: + +| beat | label | claim | docs | +|---|---|---|---| +| tools | Tools | Tool calls and citations as signals. | Tool calls → `/docs/chat/components/chat-tool-calls` | +| persist | Persist | Durable threads, no license. | Persistence → `/docs/langgraph/guides/persistence` | +| approve | Approve | Interrupts and approvals, built in. | Interrupts → `/docs/langgraph/guides/interrupts` | +| render | Render | Generative UI on A2UI and json-render. | @threadplane/render → `/render` | + +The beat key stays `stream` in `stage-beats.ts` and the recording (the timeline is unchanged); only the label and claim change. `STAGE_HOLD_LINES` becomes the single line "Keep scrolling to approve". New: `STAGE_CLOSE = { claim: 'Feature complete for the final mile.', install: 'npm i @threadplane/chat', cta: 'Spike it this week' }`. The still alt texts are kept. The removed body copy and rows are not moved anywhere: the docs pages they pointed at already carry them. + +## 6. Stills fallback + +Each still keeps its picture and gets the new beat block beneath it (check, claim, docs link, proof line). After the fourth still, the ledger and the install row render once. The stills are the page's no-JS and phone form, so the ledger there is the only place a phone visitor sees "feature complete". + +## 7. Verification + +- **Unit:** `stage-proof.spec.ts` pins each derivation; `positioning.spec.ts` asserts four claims under 40 characters each, one docs href each starting with `/`, and the single hold line; `Stage.spec.tsx` asserts four segments, four checks, the ledger present in the act's DOM, and rail links with `tabIndex=-1`; the style contract asserts the segment bar and check rules exist; the public-copy scan stays green. +- **e2e (`home-stage.spec.ts`):** at 5% the Tools segment is live and its check hollow; at 30% Tools is done and checked; at 68% the hold line is visible and no other hold copy exists; at 100% the ledger has four filled checks and the install row is visible; clicking the Persist segment scrolls the act to the persist beat (`--sc-p` within its window). The live-frame test is unchanged. +- **Harness:** `verify-home.mjs` at desktop must still report no dead scroll and all cues clearing contrast; the ending's ledger is a cue like the others so the harness sees it move. +- **Word count:** a spec asserts the rail's total visible words across the four beats plus the ending stay under 90. + +## 8. Out of scope + +A subagents beat (needs the demo graph and a re-record; its own spec). Any change to the frame, the beat map, the hold range, the threshold, the recording, analytics events, the stills images, or the mobile dock decision. Re-verifying the phone stills' crop. + +## 9. Open risks + +- **Segment click vs the engine.** Programmatic `scrollTo` moves the page; the engine reads scroll on its own frame, so the act follows. Smooth scrolling across two viewports may take ~600 ms during which the publisher posts intermediate `t` values and the frame rewinds or fast-forwards; that is the same as a fast wheel flick and within the rewind budget plan 2 measured. +- **Proof numbers drift on re-record.** By design they are recomputed; the unit spec fails on a shape change so the copy is reviewed, not silently changed.