From d340edd5d96765fe98187db0cc2e9ffb90b05c34 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 14:55:59 -0700 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20spec=20=E2=80=94=20stage=20rail?= =?UTF-8?q?=20redesign=20as=20a=20completeness=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../2026-09-06-stage-rail-redesign-design.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-06-stage-rail-redesign-design.md 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. From 5dcb305651ff14bf0bea1e91be99e219c0f209ca Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:04:56 -0700 Subject: [PATCH 02/13] =?UTF-8?q?docs:=20plan=20=E2=80=94=20stage=20rail?= =?UTF-8?q?=20redesign=20as=20a=20completeness=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-09-06-stage-rail-redesign.md | 553 ++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-stage-rail-redesign.md 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). From fc6ae079276ca7e8dbeb89cf6e3ac0a347547ff8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:22:15 -0700 Subject: [PATCH 03/13] feat(website): stage rail copy becomes a four-claim ledger Co-Authored-By: Claude Fable 5.1 --- apps/website/src/lib/positioning.spec.ts | 46 +++++++------ apps/website/src/lib/positioning.ts | 82 ++++++++++-------------- 2 files changed, 59 insertions(+), 69 deletions(-) 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. From 13a56d176626e753e8ae64213f7daa0ef2e01da3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:25:12 -0700 Subject: [PATCH 04/13] feat(website): stage proof lines derived from the recording Co-Authored-By: Claude Fable 5.1 --- apps/website/src/lib/stage-proof.spec.ts | 89 +++++++++++ apps/website/src/lib/stage-proof.ts | 193 +++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 apps/website/src/lib/stage-proof.spec.ts create mode 100644 apps/website/src/lib/stage-proof.ts 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..33cc09282 --- /dev/null +++ b/apps/website/src/lib/stage-proof.spec.ts @@ -0,0 +1,89 @@ +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' +); + +interface Run { + beat: string; + events: unknown[]; +} + +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('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 devtools step label', () => { + // history() is newest-first: index 9 of 10 states is step 1. + expect(proof.persist).toBe('reloaded · 10 checkpoints · forked at step 1'); + }); + + 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('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', () => { + const noCitations = { + ...rec, + runs: rec.runs.map((r: Run, 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('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..05e819924 --- /dev/null +++ b/apps/website/src/lib/stage-proof.ts @@ -0,0 +1,193 @@ +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". + * + * 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 }[]; +} + +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; +} + +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); + const histAfter = (i: number): number | null => + rec.histories.filter((h) => h.afterRun === i).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; + const checkpoints = fork ? histAfter(forkIdx + 1) : null; + // history() is newest-first: index i of n is step n - i. + const forkStep = + fork && checkpoints ? checkpoints - (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 interrupted = + r4?.events.some(({ event }) => { + const ev = event as Dict; + return ( + Array.isArray(ev['__interrupt__']) || + Array.isArray((ev['data'] as Dict | undefined)?.['__interrupt__']) + ); + }) ?? false; + const r4i = r4 ? rec.runs.indexOf(r4) : -1; + const last = r4 ? histAfter(r4i) ?? histAfter(r4i + 1) : null; + const approve = join([ + interrupted ? '1 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 cwd differs between `nx build website` (repo root) and +// `cd apps/website && vitest`; the two candidates cover both. +const RECORDING_CANDIDATES = [ + resolve(process.cwd(), 'examples/chat/angular/public/stage-replay.json'), + resolve( + process.cwd(), + '../../examples/chat/angular/public/stage-replay.json' + ), +]; + +function readRecording(): Recording { + for (const p of RECORDING_CANDIDATES) { + try { + return JSON.parse(readFileSync(p, 'utf8')) as Recording; + } catch { + /* next candidate */ + } + } + 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() +); From ba032a25fc21ac64ae72273d8d916548d59ec57a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:27:39 -0700 Subject: [PATCH 05/13] feat(website): beat settle points, segment states, and the hold and closing cues Co-Authored-By: Claude Fable 5.1 --- apps/website/src/lib/stage-beats.spec.ts | 90 +++++++++++++++++++----- apps/website/src/lib/stage-beats.ts | 58 ++++++++++----- 2 files changed, 112 insertions(+), 36 deletions(-) diff --git a/apps/website/src/lib/stage-beats.spec.ts b/apps/website/src/lib/stage-beats.spec.ts index c3bf5a303..8501f0840 100644 --- a/apps/website/src/lib/stage-beats.spec.ts +++ b/apps/website/src/lib/stage-beats.spec.ts @@ -2,14 +2,19 @@ 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, inHold, + isChecked, + segmentState, + settleAt, timeAt, type StageReadyMessage, } from './stage-beats'; @@ -150,29 +155,76 @@ 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'))}` + ); 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); + }); }); -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(2); + const [from, to] = cue.split(' ').map(Number); const a = beatWindows()[2]; - const [, to] = holdLineCues(3)[2].split(' ').map(Number); + // 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).toBeCloseTo(edge, 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) * 0.12, 4); }); + 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.3 0'); + expect(closeCue()).toMatch(/ 1 0\.3 0$/); + }); + 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..aa22a441e 100644 --- a/apps/website/src/lib/stage-beats.ts +++ b/apps/website/src/lib/stage-beats.ts @@ -136,35 +136,59 @@ 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; +} + +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'; +} + +export function isChecked(beat: StageBeat, p: number): boolean { + return p >= settleAt(beat); +} + +const fmt = (n: number) => String(+n.toFixed(4)); + /** * `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 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`). */ 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))}`; return `${fmt(w.from)} ${fmt(w.to)}`; } /** - * 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: the hold range, overshooting + * the threshold by 12% of the approve span so "Keep scrolling to approve" + * lingers and is still readable as the resume begins. */ -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 * 0.12, 1); + return `${fmt(from)} ${fmt(to)}`; +} + +/** Cue for the closing ledger: fades in at the render settle and holds to the end. */ +export function closeCue(): string { + return `${fmt(settleAt('render'))} 1 0.3 0`; } From 5c6fedacc33f5db40037bd266cd055db73c9f1ce Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:29:42 -0700 Subject: [PATCH 06/13] feat(website): stage publisher drives the segment bar and the checks Co-Authored-By: Claude Fable 5.1 --- .../landing/use-stage-publisher.spec.ts | 100 +++++++++++++++++- .../components/landing/use-stage-publisher.ts | 39 +++++++ 2 files changed, 137 insertions(+), 2 deletions(-) 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..2762e1c9f 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,12 @@ import { useStagePublisher, type StagePublisher, } from './use-stage-publisher'; -import { APPROVE_HOLD, beatWindows, timeAt } from '../../lib/stage-beats'; +import { + APPROVE_HOLD, + beatWindows, + STAGE_BEATS, + timeAt, +} from '../../lib/stage-beats'; const READY = { type: STAGE_MESSAGE_TYPE, @@ -36,10 +41,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 +69,18 @@ function setup( return { section, posted, track, onReady, pub, frame }; } +/** One segment and one check per beat, 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 chk = document.createElement('span'); + chk.setAttribute('data-stage-check', b); + section.appendChild(chk); + } +} + afterEach(() => { current?.dispose(); current = null; @@ -187,6 +210,79 @@ 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('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-check]'), + ]; + expect(els).toHaveLength(8); + 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..058a58890 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-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,8 @@ export function createStagePublisher(deps: StagePublisherDeps): StagePublisher { const beatsSeen = new Set(); let disposed = false; let lastHello = -Infinity; + const segments = railElements(deps.section, 'data-stage-segment'); + const checks = railElements(deps.section, 'data-stage-check'); const onMessage = (e: MessageEvent) => { if (e.origin !== STAGE_DEMO_ORIGIN) return; @@ -116,6 +141,20 @@ 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'); + } + } // Milestones. if (!entered && p > 0) { entered = true; From 4f88a00dafbfe9a36a608c6676fbd561993cbd90 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:34:26 -0700 Subject: [PATCH 07/13] =?UTF-8?q?feat(website):=20the=20stage=20rail=20is?= =?UTF-8?q?=20a=20completeness=20ledger=20=E2=80=94=20segments,=20checks,?= =?UTF-8?q?=20one=20claim,=20one=20link,=20one=20proof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- apps/website/src/app/page.tsx | 22 ++- .../src/components/landing/Stage.spec.tsx | 116 +++++++++++-- apps/website/src/components/landing/Stage.tsx | 10 +- .../src/components/landing/StageAct.tsx | 147 +++++++++++----- apps/website/src/styles/landing.css | 162 +++++++++++++++++- .../src/styles/style-contracts.spec.ts | 15 ++ 6 files changed, 411 insertions(+), 61 deletions(-) diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index bb8569bd0..1d9e40674 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -7,6 +7,7 @@ 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'; +import { STAGE_PROOF } from '../lib/stage-proof'; import { createPageMetadata, HERO_SECONDARY_HREF, @@ -35,18 +36,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..dd479b54a 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. @@ -12,6 +25,20 @@ vi.mock('../../lib/analytics/client', () => ({ trackStageProgress: vi.fn(), track: vi.fn(), })); +// The stills are the server default and have their own spec; here they only +// need to be tellable apart from the act. +vi.mock('./StageStills', async () => { + const { STAGE_RAIL } = await import('../../lib/positioning'); + return { + StageStills: () => ( +
+ {STAGE_RAIL.map((b) => ( +
+ ))} +
+ ), + }; +}); vi.mock('../ui/BrowserFrame', () => ({ BrowserFrame: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -64,7 +91,7 @@ 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(); @@ -72,7 +99,7 @@ describe('Stage', () => { 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 +120,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 +128,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 @@ -120,13 +170,59 @@ describe('Stage', () => { 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 railLinks = actEl?.querySelectorAll('.stage-rail a'); + expect(railLinks?.length).toBeGreaterThanOrEqual(4 + 4 + 4 + 1); + railLinks?.forEach((a) => expect(a.getAttribute('tabindex')).toBe('-1')); 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..0e3ccab09 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,7 +30,7 @@ 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'); @@ -32,7 +38,7 @@ export function Stage() { 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..3385e5fea 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 (stage-rail spec §3): the segment bar, one beat block per + beat stacked in a single cell so the cues crossfade in place, the + hold line, and the closing ledger. 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} - -
-

{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/styles/landing.css b/apps/website/src/styles/landing.css index aa5baf8bc..686b58221 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1394,22 +1394,172 @@ 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, the beat blocks + * stacked in one cell so cues crossfade in place, the hold line in a cell + * beneath, and the closing ledger across both of those cells. 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; + 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-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; + 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); + 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: 34px; + 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.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-render-green); } .stage-rail-hold { - grid-area: 2 / 1; - margin: 24px 0 0; + grid-area: 3 / 1; + margin: 0; font-size: 15px; line-height: 1.5; + 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; +} +/* 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; + word-break: break-word; +} +.stage-install-cta { + font-size: 13px; + font-weight: 600; + text-decoration: none; +} +.stage-trust { + margin: 12px 0 0; + font-size: 12px; + opacity: 0.5; } /* Cues start hidden; the engine drives opacity and transform per frame. */ .stage-act [data-sc-cue] { diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts index 6689d31fa..893f1b6ff 100644 --- a/apps/website/src/styles/style-contracts.spec.ts +++ b/apps/website/src/styles/style-contracts.spec.ts @@ -484,5 +484,20 @@ 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 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 (which shares the beat blocks' cell and the hold line's) + * would overlap them unless it spans both rows. + */ + it('the segment bar and the checks have a visible state, and the ledger spans both rows', () => { + 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-close')).toMatch(/grid-area:\s*2\s*\/\s*1\s*\/\s*span 2/); + }); }); }); From 570e3c88484a24ac991cedb0979962803be2e4fd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:37:57 -0700 Subject: [PATCH 08/13] fix(website): stage proof counts the interrupts, pins every line, and traces the recording Co-Authored-By: Claude Fable 5.1 --- apps/website/next.config.ts | 15 +++- apps/website/src/app/page.tsx | 3 + apps/website/src/lib/stage-proof.spec.ts | 74 +++++++++++++--- apps/website/src/lib/stage-proof.ts | 105 ++++++++++++++++------- 4 files changed, 151 insertions(+), 46 deletions(-) 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 1d9e40674..d85b5e4c1 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -7,6 +7,9 @@ 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, diff --git a/apps/website/src/lib/stage-proof.spec.ts b/apps/website/src/lib/stage-proof.spec.ts index 33cc09282..4bd907509 100644 --- a/apps/website/src/lib/stage-proof.spec.ts +++ b/apps/website/src/lib/stage-proof.spec.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from 'vitest'; +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( @@ -10,15 +13,20 @@ const REC = resolve( interface Run { beat: string; - events: unknown[]; + 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).toMatch(/^\d{3,} events · 1 tool call · \d sources$/); + expect(proof.stream).toBe('586 events · 1 tool call · 3 sources'); expect(proof.stream.startsWith(`${rec.runs[0].events.length} events`)).toBe( true ); @@ -36,14 +44,42 @@ describe('stage proof', () => { ); }); - it('maps the fork checkpointIndex onto the devtools step label', () => { - // history() is newest-first: index 9 of 10 states is step 1. + 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', () => { - expect(proof.approve).toMatch( - /^1 interrupt pending · checkpoint \d+ of \d+$/ + // 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' ); }); @@ -61,15 +97,29 @@ describe('stage proof', () => { }); 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) => - i === 0 ? { ...r, events: [] } : r - ), + 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).not.toMatch(/sources/); - expect(p.stream).not.toMatch(/NaN|undefined/); + expect(p.stream).toBe('586 events · 1 tool call'); }); it('drops the surface clauses when the render run has no surface', () => { diff --git a/apps/website/src/lib/stage-proof.ts b/apps/website/src/lib/stage-proof.ts index 05e819924..f7109db92 100644 --- a/apps/website/src/lib/stage-proof.ts +++ b/apps/website/src/lib/stage-proof.ts @@ -1,6 +1,8 @@ +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 @@ -17,6 +19,13 @@ interface RecordedRun { 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[] }[]; @@ -88,6 +97,21 @@ function citationCount(run: RecordedRun): number | null { 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') { @@ -107,8 +131,12 @@ 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); - const histAfter = (i: number): number | null => - rec.histories.filter((h) => h.afterRun === i).at(-1)?.states.length ?? null; + /** 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([ @@ -122,10 +150,17 @@ export function deriveStageProof(rec: Recording): Record { (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; - // history() is newest-first: index i of n is step n - i. + // `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 && checkpoints ? checkpoints - (fork.action.checkpointIndex ?? 0) : 0; + fork && forkFrom ? forkFrom - (fork.action.checkpointIndex ?? 0) : 0; const persist = join([ reload ? 'reloaded' : null, counted(checkpoints, 'checkpoint'), @@ -133,18 +168,14 @@ export function deriveStageProof(rec: Recording): Record { ]); const r4 = run('approve', 'submit'); - const interrupted = - r4?.events.some(({ event }) => { - const ev = event as Dict; - return ( - Array.isArray(ev['__interrupt__']) || - Array.isArray((ev['data'] as Dict | undefined)?.['__interrupt__']) - ); - }) ?? false; + const interrupts = r4 ? interruptCount(r4) : 0; const r4i = r4 ? rec.runs.indexOf(r4) : -1; - const last = r4 ? histAfter(r4i) ?? histAfter(r4i + 1) : null; + // 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([ - interrupted ? '1 interrupt pending' : null, + interrupts > 0 ? `${plural(interrupts, 'interrupt')} pending` : null, last ? `checkpoint ${last} of ${last}` : null, ]); @@ -165,27 +196,37 @@ export function deriveStageProof(rec: Recording): Record { return { stream, persist, approve, render }; } -// The cwd differs between `nx build website` (repo root) and -// `cd apps/website && vitest`; the two candidates cover both. -const RECORDING_CANDIDATES = [ - resolve(process.cwd(), 'examples/chat/angular/public/stage-replay.json'), - resolve( - process.cwd(), - '../../examples/chat/angular/public/stage-replay.json' - ), -]; +/** + * 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 { - for (const p of RECORDING_CANDIDATES) { - try { - return JSON.parse(readFileSync(p, 'utf8')) as Recording; - } catch { - /* next candidate */ - } + 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)}` + ); } - 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( From 3b40b244b25808a8987abd96ff53c7e719e1314d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:38:27 -0700 Subject: [PATCH 09/13] refactor(website): beat map polish and a rewind test for the rail state Co-Authored-By: Claude Fable 5.1 --- .../landing/use-stage-publisher.spec.ts | 19 +++++++++++++++++++ apps/website/src/lib/stage-beats.spec.ts | 3 ++- apps/website/src/lib/stage-beats.ts | 15 ++++++++++++--- 3 files changed, 33 insertions(+), 4 deletions(-) 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 2762e1c9f..d9b04a7b8 100644 --- a/apps/website/src/components/landing/use-stage-publisher.spec.ts +++ b/apps/website/src/components/landing/use-stage-publisher.spec.ts @@ -236,6 +236,25 @@ describe('stage publisher', () => { ).toHaveLength(4); }); + 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) => { diff --git a/apps/website/src/lib/stage-beats.spec.ts b/apps/website/src/lib/stage-beats.spec.ts index 8501f0840..b8e06e96e 100644 --- a/apps/website/src/lib/stage-beats.spec.ts +++ b/apps/website/src/lib/stage-beats.spec.ts @@ -164,6 +164,7 @@ describe('cueFor', () => { 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]); }); }); @@ -202,7 +203,7 @@ describe('holdCue / closeCue', () => { // 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).toBeCloseTo(edge, 4); + 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); diff --git a/apps/website/src/lib/stage-beats.ts b/apps/website/src/lib/stage-beats.ts index aa22a441e..2ff2e313a 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 { @@ -144,6 +150,7 @@ export function settleAt(beat: StageBeat): number { 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. */ @@ -155,12 +162,14 @@ export function segmentState(beat: StageBeat, p: number): SegmentState { : '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); } -const fmt = (n: number) => String(+n.toFixed(4)); - /** * `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 middle ones fade From f2ba1001eb80d0f3883335af2339d59345d1cb5d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:38:48 -0700 Subject: [PATCH 10/13] feat(website): stage stills carry the ledger Co-Authored-By: Claude Fable 5.1 --- .../src/components/landing/Stage.spec.tsx | 21 ++--- apps/website/src/components/landing/Stage.tsx | 2 +- .../components/landing/StageStills.spec.tsx | 69 ++++++++++++--- .../src/components/landing/StageStills.tsx | 86 ++++++++++++------- apps/website/src/styles/landing.css | 13 +++ 5 files changed, 134 insertions(+), 57 deletions(-) diff --git a/apps/website/src/components/landing/Stage.spec.tsx b/apps/website/src/components/landing/Stage.spec.tsx index dd479b54a..5b30f3a2d 100644 --- a/apps/website/src/components/landing/Stage.spec.tsx +++ b/apps/website/src/components/landing/Stage.spec.tsx @@ -25,20 +25,6 @@ vi.mock('../../lib/analytics/client', () => ({ trackStageProgress: vi.fn(), track: vi.fn(), })); -// The stills are the server default and have their own spec; here they only -// need to be tellable apart from the act. -vi.mock('./StageStills', async () => { - const { STAGE_RAIL } = await import('../../lib/positioning'); - return { - StageStills: () => ( -
- {STAGE_RAIL.map((b) => ( -
- ))} -
- ), - }; -}); vi.mock('../ui/BrowserFrame', () => ({ BrowserFrame: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -95,6 +81,13 @@ describe('Stage', () => { 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 () => { diff --git a/apps/website/src/components/landing/Stage.tsx b/apps/website/src/components/landing/Stage.tsx index 0e3ccab09..a968bd0fa 100644 --- a/apps/website/src/components/landing/Stage.tsx +++ b/apps/website/src/components/landing/Stage.tsx @@ -35,7 +35,7 @@ export function Stage({ proof }: Props) { useEffect(() => { if (actAllowed()) setMode('act'); }, []); - if (mode === 'stills') return ; + if (mode === 'stills') return ; return ( <> setMode('stills')} proof={proof} /> diff --git a/apps/website/src/components/landing/StageStills.spec.tsx b/apps/website/src/components/landing/StageStills.spec.tsx index 28acbe1fe..86737a28c 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,64 @@ 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. + document + .querySelectorAll('section#stage a') + .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..70286b9ac 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/styles/landing.css b/apps/website/src/styles/landing.css index 686b58221..9d93fcae5 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1898,3 +1898,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; +} From 6cd68b2e9a9a4f382a569bc0a1b2a5eabf26c6cb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 6 Sep 2026 15:50:03 -0700 Subject: [PATCH 11/13] =?UTF-8?q?fix(website):=20stage=20rail=20=E2=80=94?= =?UTF-8?q?=20visible=20cue=20owns=20the=20pointer,=20segments=20are=20key?= =?UTF-8?q?boardable,=20anchors=20resolve,=20hold=20and=20ledger=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../src/components/landing/Stage.spec.tsx | 28 ++++-- .../src/components/landing/StageAct.tsx | 87 +++++++++++-------- .../components/landing/StageStills.spec.tsx | 9 +- .../src/components/landing/StageStills.tsx | 2 +- .../landing/use-stage-publisher.spec.ts | 49 ++++++++++- .../components/landing/use-stage-publisher.ts | 20 ++++- apps/website/src/lib/stage-beats.spec.ts | 4 +- apps/website/src/lib/stage-beats.ts | 8 +- apps/website/src/styles/landing.css | 64 ++++++++++---- .../src/styles/style-contracts.spec.ts | 26 +++++- 10 files changed, 220 insertions(+), 77 deletions(-) diff --git a/apps/website/src/components/landing/Stage.spec.tsx b/apps/website/src/components/landing/Stage.spec.tsx index 5b30f3a2d..5237224b4 100644 --- a/apps/website/src/components/landing/Stage.spec.tsx +++ b/apps/website/src/components/landing/Stage.spec.tsx @@ -158,14 +158,32 @@ 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 railLinks = actEl?.querySelectorAll('.stage-rail a'); - expect(railLinks?.length).toBeGreaterThanOrEqual(4 + 4 + 4 + 1); - railLinks?.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' diff --git a/apps/website/src/components/landing/StageAct.tsx b/apps/website/src/components/landing/StageAct.tsx index 3385e5fea..40d604b9f 100644 --- a/apps/website/src/components/landing/StageAct.tsx +++ b/apps/website/src/components/landing/StageAct.tsx @@ -184,10 +184,13 @@ export function StageAct({ onFallback, proof }: Props) { Open the live demo → - {/* The rail (stage-rail spec §3): the segment bar, one beat block per - beat stacked in a single cell so the cues crossfade in place, the - hold line, and the closing ledger. Segment and check state is - written by the publisher, not React. */} + {/* 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: tools, persist, approve, render @@ -200,7 +203,6 @@ export function StageAct({ onFallback, proof }: Props) { className="stage-seg" data-stage-segment={b.beat} data-beat-state="todo" - tabIndex={-1} onClick={(e) => { e.preventDefault(); scrollToBeat(b.beat); @@ -210,40 +212,53 @@ export function StageAct({ onFallback, proof }: Props) { ))} - {STAGE_RAIL.map((b) => ( -
-