From 9679409a84bd4aab76df93d43ab71beed246839a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 20:20:31 -0700 Subject: [PATCH 01/22] =?UTF-8?q?docs:=20plan=203=20=E2=80=94=20the=20stag?= =?UTF-8?q?e=20on=20the=20homepage=20(live-stage=20plan=203=20of=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven tasks: extend the frame's ready message with the hold and reload boundary; vendor the scroll-craft engine hash-pinned at 0b81622; the beat map (shares, the 35–70% hold, the threshold, the render tail, cue windows); rail copy in positioning.ts; the stills fallback; a DOM-only publisher; the pinned act with engine mount and analytics; the page swap; e2e; the scroll-craft harness in CI; verification and PR. Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-09-06-stage-homepage.md | 1424 +++++++++++++++++ 1 file changed, 1424 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-stage-homepage.md diff --git a/docs/superpowers/plans/2026-09-06-stage-homepage.md b/docs/superpowers/plans/2026-09-06-stage-homepage.md new file mode 100644 index 000000000..e3b16dad9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-stage-homepage.md @@ -0,0 +1,1424 @@ +# Stage on the Homepage 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's four capability `FeatureBlock`s with one pinned, scroll-scrubbed act that drives the demo app's `/stage` route (plan 2, PR #1030) through the `tplane-stage` protocol, with stacked stills as the fallback, the scroll-craft verification harness in e2e, and the stage analytics events. + +**Architecture:** The website vendors scroll-craft's engine unmodified and mounts it on one `
`. Each frame, a publisher reads the act's `--sc-p`, maps it to recorded milliseconds through a pure beat map (`stage-beats.ts`), and posts `{ type: 'tplane-stage', t }` to the iframe; the iframe answers `ready` (timeline numbers) and `{ applied, phase, t }`, which the publisher writes into `data-sc-verify-state` so the harness can see a bespoke stage. The rail's cue windows are derived from the same beat shares, so copy and time cannot drift. Below 1024px, under reduced motion, without JavaScript, or when the frame never reports ready, the section renders four stacked stills with the same copy. + +**Tech Stack:** Next.js (apps/website, React 19, Vitest, Playwright), scroll-craft engine (MIT, commit `0b81622`), the demo app's `/stage` route (Angular, `examples/chat/angular`), posthog-js analytics. + +**Spec:** `docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md` §4.1, §4.3, §5.2, §6, §7, §8, §9, §10. Plan 2 delivered §4.2, §4.4, §4.5, §5.1 and the stills recorder. + +--- + +## Conventions + +- Branch: `blove/stage-homepage`, created from `blove/stage-route` (this plan needs plan 2's code). Rebase onto `origin/main` once #1030 merges; never `git stash` (shared stack). +- Run every command from the repo root unless a step says otherwise. Website unit tests: `npx nx test website` (must run from the root, see memory). Only `npx nx build website` catches website type errors; run it before claiming green. +- Public copy: the rail copy lands in `positioning.ts` and is scanned by `src/lib/public-copy.spec.ts` (unit) and crawled by `e2e/public-copy.spec.ts` in production mode. Never write the banned phrasings in `src/lib/public-copy-contract.ts` (`BANNED_CLAIMS`), and no competitor names. +- Voice: `docs/gtm/voice.md`. Rail copy is taken verbatim from the existing FeatureBlocks in `app/page.tsx` and the spec §6; do not rewrite it. +- The demo origin is `https://demo.threadplane.ai`; the website's origins are already allowlisted in `examples/chat/angular/src/app/hero/hero-bridge.ts` (`HERO_PARENT_ORIGINS`, plus Vercel previews). The website e2e server is `http://127.0.0.1:4308`, which is in that list. +- The `/stage` route on the deployed demo exists only after #1030 merges AND the canonical demo deploy promotes. Tasks 1–8 need no network. Task 9's live-frame assertions are gated by an env variable so CI stays green before the deploy; run them locally against production afterwards. + +## File structure + +**Demo app (`examples/chat/angular/src/app/stage/`)** +- Modify `stage-bridge.ts` — `StageReady` gains `hold` and `reloadEndMs` (the parent needs the hold's numbers for the approve mapping and the reload boundary for the persist midpoint). +- Modify `stage-mode.component.ts` — posts the two new fields. +- Modify `stage-bridge.spec.ts`, `stage-mode.component.spec.ts` — assert the new payload. + +**Website (`apps/website/src/`)** +- Create `vendor/scrollcraft/scrollcraft.js` — the engine, byte-identical to the pinned upstream file; `vendor/scrollcraft/LICENSE`; `vendor/scrollcraft/README.md`; `vendor/scrollcraft/scrollcraft.d.ts` (the `window.ScrollCraft` shape); `vendor/scrollcraft/scrollcraft.spec.ts` (hash pin). +- Create `lib/stage-beats.ts` + `lib/stage-beats.spec.ts` — shares, windows, the piecewise monotonic time map, the hold range, the threshold, cue strings. +- Modify `lib/positioning.ts` + `lib/positioning.spec.ts` — `STAGE_RAIL` (per-beat eyebrow, headline, body, rows, cta, still alt text) and `STAGE_HOLD_LINES`. +- Modify `lib/analytics/events.ts`, `lib/analytics/client.ts` + spec — `marketing:stage_progress`, surface `home_stage`, `trackStageProgress`. +- Create `components/landing/StageStills.tsx` + spec — the server-rendered fallback (four stills + copy). +- Create `components/landing/StageAct.tsx` — the pinned act: frame + rail + publisher (client). +- Create `components/landing/Stage.tsx` + spec — the mode switch (stills by default, act when allowed). +- Create `components/landing/use-stage-publisher.ts` + spec — the rAF publisher and message handler, DOM-only, no React state per frame. +- Modify `app/page.tsx` — `` replaces the four `FeatureBlock`s. +- Modify `styles/landing.css` + `styles/style-contracts.spec.ts` — `.stage-*` rules; the sticky contract. +- Modify `eslint.config.mjs` (website) — ignore the vendored engine. + +**Website e2e (`apps/website/e2e/`)** +- Create `home-stage.spec.ts`. +- Create `scroll-craft/shoot.mjs` (vendored harness, unmodified), `scroll-craft/verify-home.mjs` (runs the three modes against a running server and fails on dead scroll / cues that never peak), `scroll-craft/README.md`. +- Modify `.github/workflows/ci.yml` — one step in `website-e2e` that builds, starts `next start`, runs `verify-home.mjs`, uploads the contact sheets. + +--- + +### Task 1: The frame tells the parent where the hold and the reload are + +**Files:** +- Modify: `examples/chat/angular/src/app/stage/stage-bridge.ts` +- Modify: `examples/chat/angular/src/app/stage/stage-mode.component.ts` (the `postReady` call, ~line 368) +- Test: `examples/chat/angular/src/app/stage/stage-bridge.spec.ts`, `stage-mode.component.spec.ts` + +- [ ] **Step 1: Failing test — the bridge posts `hold` and `reloadEndMs`** + +Add to `stage-bridge.spec.ts`, next to the existing `postReady` case: + +```ts +it('postReady carries the hold and the reload boundary the parent maps scroll through', () => { + const posted: unknown[] = []; + const parent = { postMessage: (m: unknown) => posted.push(m) } as unknown as Window; + const self = { addEventListener: () => undefined, removeEventListener: () => undefined } as unknown as Window; + const bridge = createStageBridge({ referrer: 'https://threadplane.ai/', parent, self }); + bridge.postReady({ + totalMs: 9000, + beats: [{ beat: 'stream', startMs: 0, endMs: 1000 }], + hold: { startMs: 5000, endMs: 8000 }, + reloadEndMs: 1600, + }); + expect(posted[0]).toMatchObject({ type: STAGE_MESSAGE_TYPE, ready: true, hold: { startMs: 5000, endMs: 8000 }, reloadEndMs: 1600 }); +}); +``` + +- [ ] **Step 2: Run** `cd examples/chat/angular && npx vitest run src/app/stage/stage-bridge` — FAIL (type error / missing fields). + +- [ ] **Step 3: Implement** + +In `stage-bridge.ts`: + +```ts +export interface StageReady { + totalMs: number; + beats: readonly TimelineBeat[]; + /** The authored hold at the interrupt (timeline ms). */ + hold: { readonly startMs: number; readonly endMs: number }; + /** End of the persist beat's reload run, or null when the recording has none. */ + reloadEndMs: number | null; +} +``` +and in `postReady`: `post({ ready: true, totalMs: ready.totalMs, beats: ready.beats, hold: ready.hold, reloadEndMs: ready.reloadEndMs });` + +In `stage-mode.component.ts`, where `postReady` is called: + +```ts +const reload = tl.runs.find((r) => r.run.action.kind === 'reload'); +this.bridge.postReady({ + totalMs: tl.totalMs, + beats: tl.beats, + hold: tl.hold, + reloadEndMs: reload ? reload.endMs : null, +}); +``` + +- [ ] **Step 4: Component spec** — find the existing `postReady` assertion in `stage-mode.component.spec.ts` and extend it: `expect(ready.hold).toEqual(timeline.hold)` and `expect(ready.reloadEndMs).toBe(timeline.runs[1].endMs)` (the fixture's run 1 is the reload; check `stage-recording.fixtures.ts`'s `MINIMAL`). + +- [ ] **Step 5: Run** `npx vitest run src/app/stage` — PASS. `npx eslint examples/chat/angular/src/app/stage` — clean. + +- [ ] **Step 6: Commit** `feat(examples/chat): stage ready message carries the hold and the reload boundary` + +--- + +### Task 2: Vendor the engine + +**Files:** +- Create: `apps/website/src/vendor/scrollcraft/scrollcraft.js`, `LICENSE`, `README.md`, `scrollcraft.d.ts`, `scrollcraft.spec.ts` +- Modify: `apps/website/eslint.config.mjs` + +- [ ] **Step 1: Copy the pinned files** + +```bash +git clone --quiet https://github.com/nateherkai/scroll-craft /tmp/scroll-craft-pin && git -C /tmp/scroll-craft-pin checkout --quiet 0b81622 +mkdir -p apps/website/src/vendor/scrollcraft +cp /tmp/scroll-craft-pin/plugins/nateherk-design/skills/scroll-craft/engine/scrollcraft.js apps/website/src/vendor/scrollcraft/scrollcraft.js +cp /tmp/scroll-craft-pin/LICENSE apps/website/src/vendor/scrollcraft/LICENSE +shasum -a 256 apps/website/src/vendor/scrollcraft/scrollcraft.js +``` +(Use the scratchpad directory instead of `/tmp` when one is listed in your environment.) Record the printed hash; it goes into the spec below. + +- [ ] **Step 2: README** + +`apps/website/src/vendor/scrollcraft/README.md`: + +```md +# scroll-craft engine (vendored) + +Source: https://github.com/nateherkai/scroll-craft — `plugins/nateherk-design/skills/scroll-craft/engine/scrollcraft.js` +Commit: 0b81622 (2026-09-04). Licence: MIT (LICENSE beside this file). + +`scrollcraft.js` is byte-identical to upstream and MUST stay that way — `scrollcraft.spec.ts` pins its SHA-256. Upstream's CSS is deliberately not vendored: the few rules the homepage needs (`.stage-pin` sticky, `[data-sc-cue]` initial opacity) live in `src/styles/landing.css` under the website's own style contract. + +To update: copy the new file, re-run `shasum -a 256`, update the hash in the spec and the commit here. +``` + +- [ ] **Step 3: Type declaration** `scrollcraft.d.ts`: + +```ts +/** The globals `scrollcraft.js` installs. Only what the homepage uses. */ +export interface ScrollCraftAct { + el: HTMLElement; + device: 'scrub' | 'pin' | 'pan' | 'flow'; + p: number; +} +export interface ScrollCraftInstance { + layout(): void; + acts: ScrollCraftAct[]; + lerp: number; +} +export interface ScrollCraftGlobal { + mount(root?: Element | Document | string, opts?: { lerp?: number }): ScrollCraftInstance; + reduce: boolean; + instances: ScrollCraftInstance[]; +} +declare global { + interface Window { ScrollCraft?: ScrollCraftGlobal } +} +``` + +- [ ] **Step 4: Hash pin spec** `scrollcraft.spec.ts`: + +```ts +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** Upstream commit 0b81622. See README.md beside this file before changing. */ +const PINNED_SHA256 = ''; + +describe('vendored scroll-craft engine', () => { + it('is byte-identical to the pinned upstream file', () => { + const src = readFileSync(join(__dirname, 'scrollcraft.js')); + expect(createHash('sha256').update(src).digest('hex')).toBe(PINNED_SHA256); + }); + it('exposes mount and never auto-mounts', () => { + const src = readFileSync(join(__dirname, 'scrollcraft.js'), 'utf8'); + expect(src).toMatch(/global\.ScrollCraft = \{ mount: mount/); + expect(src).not.toMatch(/DOMContentLoaded/); + }); +}); +``` + +- [ ] **Step 5: ESLint ignore** — in `apps/website/eslint.config.mjs` add `'src/vendor/scrollcraft/scrollcraft.js'` to the `ignores` array (create the `{ ignores: [...] }` entry if the config has none). Confirm with `npx eslint apps/website/src/vendor/scrollcraft` → no output. + +- [ ] **Step 6: Run** `npx nx test website -- src/vendor/scrollcraft` — PASS (check the Vitest include glob in `apps/website/vitest.config.*` covers `src/**/*.spec.ts`; it does for `src/lib` and `src/components`; if `src/vendor` is excluded, add it). + +- [ ] **Step 7: Commit** `chore(website): vendor the scroll-craft engine, pinned at 0b81622` + +--- + +### Task 3: The beat map + +**Files:** +- Create: `apps/website/src/lib/stage-beats.ts`, `apps/website/src/lib/stage-beats.spec.ts` + +- [ ] **Step 1: Failing tests** + +```ts +// apps/website/src/lib/stage-beats.spec.ts +import { describe, expect, it } from 'vitest'; +import { + APPROVE_HOLD, STAGE_BEATS, STAGE_SPAN, beatAt, beatWindows, crossedThreshold, cueFor, inHold, timeAt, + type StageReadyMessage, +} from './stage-beats'; + +const READY: StageReadyMessage = { + totalMs: 40_000, + beats: [ + { beat: 'stream', startMs: 0, endMs: 12_000 }, + { beat: 'persist', startMs: 12_000, endMs: 20_000 }, + { beat: 'approve', startMs: 20_000, endMs: 32_000 }, + { beat: 'render', startMs: 32_000, endMs: 40_000 }, + ], + hold: { startMs: 27_000, endMs: 30_000 }, + reloadEndMs: 12_600, +}; + +describe('beatWindows', () => { + it('partitions the act by the shares, in beat order, summing to the span', () => { + const w = beatWindows(); + expect(w.map((x) => x.beat)).toEqual([...STAGE_BEATS]); + expect(w[0].from).toBe(0); + expect(w[w.length - 1].to).toBe(1); + w.slice(1).forEach((x, i) => expect(x.from).toBe(w[i].to)); + expect(STAGE_SPAN).toBe(6); + }); +}); + +describe('timeAt', () => { + it('is monotonic non-decreasing across the whole act', () => { + let last = -1; + for (let i = 0; i <= 2000; i++) { + const t = timeAt(i / 2000, READY); + expect(t).toBeGreaterThanOrEqual(last); + last = t; + } + }); + it('lands each beat boundary on the recording boundary', () => { + const w = beatWindows(); + w.forEach((x, i) => expect(timeAt(x.from, READY)).toBe(READY.beats[i].startMs)); + expect(timeAt(1, READY)).toBe(READY.totalMs); + }); + it('settles the reload at the persist midpoint', () => { + const persist = beatWindows()[1]; + expect(timeAt((persist.from + persist.to) / 2, READY)).toBe(READY.reloadEndMs); + }); + it('pins time at the interrupt through the hold and resumes past the threshold', () => { + const a = beatWindows()[2]; + const at = (f: number) => timeAt(a.from + (a.to - a.from) * f, READY); + expect(at(APPROVE_HOLD.from)).toBe(READY.hold.startMs); + expect(at(0.5)).toBe(READY.hold.startMs); + expect(at(APPROVE_HOLD.to - 1e-6)).toBe(READY.hold.startMs); + expect(at(APPROVE_HOLD.to)).toBe(READY.hold.endMs); + expect(at(1)).toBe(READY.beats[2].endMs); + }); + it('holds the mounted form through the render tail', () => { + const r = beatWindows()[3]; + expect(timeAt(r.from + (r.to - r.from) * 0.9, READY)).toBe(READY.totalMs); + }); + it('clamps outside 0..1 and falls back to linear persist without a reload', () => { + expect(timeAt(-1, READY)).toBe(0); + expect(timeAt(2, READY)).toBe(READY.totalMs); + const noReload = { ...READY, reloadEndMs: null }; + const persist = beatWindows()[1]; + expect(timeAt((persist.from + persist.to) / 2, noReload)).toBe(16_000); + }); +}); + +describe('inHold / beatAt / crossedThreshold', () => { + it('reports the hold only inside the approve hold range', () => { + const a = beatWindows()[2]; + expect(inHold(a.from + (a.to - a.from) * 0.5)).toBe(true); + expect(inHold(a.from + (a.to - a.from) * 0.2)).toBe(false); + expect(inHold(0)).toBe(false); + }); + it('names the beat at a progress', () => { + expect(beatAt(0)).toBe('stream'); + expect(beatAt(0.999)).toBe('render'); + }); + it('fires the threshold exactly once per crossing, forwards only', () => { + const a = beatWindows()[2]; + const th = a.from + (a.to - a.from) * APPROVE_HOLD.to; + expect(crossedThreshold(th - 0.01, th + 0.01)).toBe(true); + expect(crossedThreshold(th + 0.01, th - 0.01)).toBe(false); + expect(crossedThreshold(th + 0.01, th + 0.02)).toBe(false); + }); +}); + +describe('cueFor', () => { + it('greets on the first beat, holds on the last, and fades the middle ones', () => { + expect(cueFor('stream')).toMatch(/^0 0\.21\d+ 0 0\.3$/); + expect(cueFor('render')).toMatch(/ 1 0\.3 0$/); + expect(cueFor('persist').split(' ')).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run** `npx nx test website -- src/lib/stage-beats` — FAIL (module missing). + +- [ ] **Step 3: Implement** + +```ts +// apps/website/src/lib/stage-beats.ts +/** + * Scroll → recorded time for the homepage stage (spec §5.2, §6). + * + * The shares are authored; the times come from the frame's `ready` message, + * so the rail's cue windows (derived from the shares) and the seek targets + * (derived from the recording) are two views of one table. + */ +export const STAGE_BEATS = ['stream', 'persist', 'approve', 'render'] as const; +export type StageBeat = (typeof STAGE_BEATS)[number]; + +/** Viewport-heights of scroll each beat owns. Approve is the peak by a visible margin. */ +export const STAGE_SHARES: Readonly> = { stream: 1.3, persist: 1.2, approve: 2.4, render: 1.1 }; +export const STAGE_SPAN = Object.values(STAGE_SHARES).reduce((a, b) => a + b, 0); +/** Fractions of the approve beat: linear → hold → threshold and resume. */ +export const APPROVE_HOLD = { from: 0.35, to: 0.7 } as const; +/** The last slice of the render beat holds on the mounted form. */ +export const RENDER_TAIL = 0.15; + +export interface StageReadyMessage { + totalMs: number; + beats: readonly { beat: StageBeat; startMs: number; endMs: number }[]; + hold: { startMs: number; endMs: number }; + reloadEndMs: number | null; +} + +export interface BeatWindow { beat: StageBeat; from: number; to: number } + +export function beatWindows(): BeatWindow[] { + let acc = 0; + return STAGE_BEATS.map((beat) => { + const from = acc / STAGE_SPAN; + acc += STAGE_SHARES[beat]; + return { beat, from, to: acc / STAGE_SPAN }; + }); +} + +const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x); +const lerp = (a: number, b: number, f: number) => a + (b - a) * f; + +export function beatAt(p: number): StageBeat { + const q = clamp01(p); + const w = beatWindows().find((x) => q < x.to) ?? beatWindows()[STAGE_BEATS.length - 1]; + return w.beat; +} + +/** Progress within the beat that owns `p`, 0..1. */ +function local(p: number): { beat: StageBeat; f: number } { + const q = clamp01(p); + const w = beatWindows().find((x) => q < x.to) ?? beatWindows()[STAGE_BEATS.length - 1]; + return { beat: w.beat, f: clamp01((q - w.from) / (w.to - w.from)) }; +} + +export function inHold(p: number): boolean { + const { beat, f } = local(p); + return beat === 'approve' && f >= APPROVE_HOLD.from && f < APPROVE_HOLD.to; +} + +/** True when scroll moved forward across the approve threshold between two frames. */ +export function crossedThreshold(prevP: number, nextP: number): boolean { + const a = beatWindows()[2]; + const th = a.from + (a.to - a.from) * APPROVE_HOLD.to; + return prevP < th && nextP >= th; +} + +/** Piecewise monotonic: recorded milliseconds at act progress `p`. */ +export function timeAt(p: number, ready: StageReadyMessage): number { + if (p >= 1) return ready.totalMs; + const { beat, f } = local(p); + const b = ready.beats.find((x) => x.beat === beat); + if (!b) return 0; + switch (beat) { + case 'persist': { + const mid = ready.reloadEndMs; + if (mid === null) return Math.round(lerp(b.startMs, b.endMs, f)); + return f < 0.5 ? Math.round(lerp(b.startMs, mid, f / 0.5)) : Math.round(lerp(mid, b.endMs, (f - 0.5) / 0.5)); + } + case 'approve': { + if (f < APPROVE_HOLD.from) return Math.round(lerp(b.startMs, ready.hold.startMs, f / APPROVE_HOLD.from)); + if (f < APPROVE_HOLD.to) return ready.hold.startMs; + return Math.round(lerp(ready.hold.endMs, b.endMs, (f - APPROVE_HOLD.to) / (1 - APPROVE_HOLD.to))); + } + case 'render': { + const live = 1 - RENDER_TAIL; + return f >= live ? ready.totalMs : Math.round(lerp(b.startMs, ready.totalMs, f / live)); + } + default: + return Math.round(lerp(b.startMs, b.endMs, f)); + } +} + +/** + * `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. + */ +export function cueFor(beat: StageBeat): string { + const w = beatWindows().find((x) => x.beat === 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(w.to)}`; +} + +/** Cue windows for the three hold lines inside the approve beat, spread across the hold range. */ +export function holdLineCues(count: number): string[] { + const a = beatWindows()[2]; + const span = a.to - a.from; + const start = a.from + span * APPROVE_HOLD.from; + const end = a.from + span * APPROVE_HOLD.to; + 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)}`; + }); +} +``` + +Note on `timeAt` inside the approve hold: the recording's timeline has a 3,000 ms authored hold (`HOLD_MS`) between the approve run and the resume run; the parent pins `t` at `hold.startMs` for the whole 35–70% range and jumps to `hold.endMs` at 70%. The frame's `phaseAt` reports `pause` for any `t` inside `[hold.startMs, hold.endMs)`, so the devtools show the interrupt through the hold, and `resume` from the threshold. + +- [ ] **Step 4: Run** the spec — PASS. Add a `holdLineCues` case: three cues, each `from` strictly increasing, all inside `[approve.from, 1]`. + +- [ ] **Step 5: Commit** `feat(website): stage beat map — shares, the hold, the threshold, cue windows` + +--- + +### Task 4: Rail copy in positioning.ts + +**Files:** +- Modify: `apps/website/src/lib/positioning.ts`, `apps/website/src/lib/positioning.spec.ts` + +- [ ] **Step 1: Failing test** + +```ts +describe('STAGE_RAIL', () => { + it('has one entry per beat in beat order, three rows each, a cta, and still alt text', () => { + expect(STAGE_RAIL.map((b) => b.beat)).toEqual(['stream', 'persist', 'approve', 'render']); + for (const b of STAGE_RAIL) { + expect(b.rows).toHaveLength(3); + expect(b.cta.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).toHaveLength(3); + expect(STAGE_HOLD_LINES[2]).toBe('Keep scrolling to approve'); + }); +}); +``` + +- [ ] **Step 2: Implement** — add to `positioning.ts` (copy the strings verbatim from the four `FeatureBlock`s in `app/page.tsx`; the `body` strings that contain `` become plain text with the API names inline): + +```ts +export type StageBeatKey = 'stream' | 'persist' | 'approve' | 'render'; +export interface StageRailBeat { + beat: StageBeatKey; + eyebrow: string; + headline: string; + body: string; + rows: readonly { claim: string; api: string }[]; + cta: { label: string; href: string }; + /** Alt text for the fallback still: what the frame shows at this beat's settle. */ + stillAlt: string; +} + +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' }, + stillAlt: 'Threadplane chat beside its devtools: a streamed answer about Angular signals with a Sources row of 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' }, + stillAlt: 'The thread restored after a reload and forked from an earlier checkpoint: a "Make it a haiku instead." turn, with the devtools Timeline showing ten checkpoints and the fork', + }, + { + 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' }, + stillAlt: 'The agent paused inside delete_backups: the interrupt panel with Accept, Edit, Respond and Ignore above a five-row table of the backups it would delete, and the devtools State tab showing the interrupt', + }, + { + 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' }, + stillAlt: 'A generated contact form — Name, Email, 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', +]; +``` + +- [ ] **Step 3: Run** `npx nx test website -- src/lib/positioning src/lib/public-copy` — PASS (the public-copy unit scan must stay green with the new strings). + +- [ ] **Step 4: Commit** `feat(website): stage rail copy single-sourced in positioning.ts` + +--- + +### Task 5: The stills fallback + +**Files:** +- Create: `apps/website/src/components/landing/StageStills.tsx`, `StageStills.spec.tsx` +- Modify: `apps/website/src/styles/landing.css` + +- [ ] **Step 1: Failing test** + +```tsx +// StageStills.spec.tsx +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { StageStills } from './StageStills'; +import { STAGE_RAIL } from '../../lib/positioning'; + +describe('StageStills', () => { + it('renders four beats in order, each with its still, phone source, copy rows and cta', () => { + render(); + const beats = screen.getAllByTestId('stage-still-beat'); + expect(beats.map((b) => b.getAttribute('data-beat'))).toEqual(['stream', 'persist', 'approve', 'render']); + for (const [i, b] of beats.entries()) { + const img = b.querySelector('img')!; + expect(img.getAttribute('src')).toBe(`/screenshots/stage-${STAGE_RAIL[i].beat}.webp`); + expect(img.getAttribute('alt')).toBe(STAGE_RAIL[i].stillAlt); + expect(img.getAttribute('loading')).toBe('lazy'); + expect(b.querySelector('source')!.getAttribute('srcset')).toBe(`/screenshots/stage-${STAGE_RAIL[i].beat}-mobile.webp`); + expect(b.querySelectorAll('.feature-block-row')).toHaveLength(3); + expect(b.querySelector('a.feature-block-cta')!.getAttribute('href')).toBe(STAGE_RAIL[i].cta.href); + } + }); + it('is the section the act replaces, with its anchors', () => { + render(); + expect(document.querySelector('section#stage')).not.toBeNull(); + expect(screen.getByRole('heading', { level: 2, name: STAGE_RAIL[2].headline })).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Implement** + +```tsx +// StageStills.tsx (server component — no 'use client') +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'; + +export const STAGE_STILL_MOBILE_MEDIA = '(max-width: 767px)'; +const STILL_W = 1200, STILL_H = 720, STILL_MOBILE_W = 585, STILL_MOBILE_H = 975; + +/** + * 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. + */ +export function StageStills() { + return ( +
+ +

One real run: stream, persist, approve, render

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

{b.headline}

+

{b.body}

+
+ {b.rows.map((row) => ( +
+ {row.claim} + {row.api} +
+ ))} +
+ {b.cta.label} → +
+
+ ))} +
+
+
+ ); +} +``` + +The second spec case uses `getByRole('heading', { level: 2 ...})` — change it to level 3 to match the markup above (the h2 is the visually hidden section heading; check the site has an `.sr-only` utility in `ui.css`; if not, use the existing visually-hidden class the header uses, found with `grep -rn "clip: rect" apps/website/src/styles`). + +- [ ] **Step 3: CSS** — append to `landing.css`, after the `.reliability-*` block: + +```css +/* Stage — components/landing/Stage.tsx, StageStills.tsx, StageAct.tsx + * The stills are the section's default form; the pinned act replaces them on + * wide, motion-tolerant viewports after hydration. The stills reuse the + * feature-block row grammar so the two forms read as one section. */ +.stage-stills { display: grid; gap: 96px; } +.stage-still { display: grid; grid-template-columns: 3fr 2fr; gap: 48px; align-items: center; } +.stage-still:nth-child(even) .stage-still-visual { order: 2; } +.stage-still-visual { border-radius: 12px; overflow: hidden; background: #0f1116; } +.stage-still-visual picture { display: block; } +.stage-still-img { display: block; width: 100%; height: auto; } +@media (max-width: 900px) { + .stage-still { grid-template-columns: 1fr; gap: 24px; } + .stage-still:nth-child(even) .stage-still-visual { order: 0; } + .stage-stills { gap: 64px; } +} +``` + +- [ ] **Step 4: Run** `npx nx test website -- StageStills` — PASS. `npx eslint apps/website/src/components/landing/StageStills.tsx` — clean. + +- [ ] **Step 5: Commit** `feat(website): stage stills — the section's server-rendered form` + +--- + +### Task 6: The publisher + +**Files:** +- Create: `apps/website/src/components/landing/use-stage-publisher.ts`, `use-stage-publisher.spec.ts` + +The publisher is DOM-only: it never sets React state per frame. It owns the rAF loop, the `--sc-p` read, the beat map, the postMessage, the verify attributes, and the analytics milestones. + +- [ ] **Step 1: Failing tests** + +```ts +// use-stage-publisher.spec.ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStagePublisher, STAGE_DEMO_ORIGIN, STAGE_MESSAGE_TYPE } from './use-stage-publisher'; +import { beatWindows, APPROVE_HOLD } from '../../lib/stage-beats'; + +const READY = { + type: STAGE_MESSAGE_TYPE, ready: true, totalMs: 40_000, + beats: [ + { beat: 'stream', startMs: 0, endMs: 12_000 }, { beat: 'persist', startMs: 12_000, endMs: 20_000 }, + { beat: 'approve', startMs: 20_000, endMs: 32_000 }, { beat: 'render', startMs: 32_000, endMs: 40_000 }, + ], + hold: { startMs: 27_000, endMs: 30_000 }, reloadEndMs: 12_600, +}; + +function setup() { + const section = document.createElement('section'); + document.body.appendChild(section); + const posted: unknown[] = []; + const frame = { postMessage: (m: unknown, origin: string) => posted.push({ m, origin }) } as unknown as Window; + const track = vi.fn(); + const pub = createStagePublisher({ section, frameWindow: () => frame, track }); + // Simulate the frame's ready message + window.dispatchEvent(new MessageEvent('message', { origin: STAGE_DEMO_ORIGIN, data: READY })); + return { section, posted, track, pub }; +} + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); document.body.innerHTML = ''; }); + +describe('stage publisher', () => { + it('posts t only when it changes, to the demo origin', () => { + const { section, posted, pub } = setup(); + section.style.setProperty('--sc-p', '0.1'); + pub.tick(); pub.tick(); + expect(posted).toHaveLength(1); + expect(posted[0]).toMatchObject({ origin: STAGE_DEMO_ORIGIN, m: { type: STAGE_MESSAGE_TYPE, t: expect.any(Number) } }); + section.style.setProperty('--sc-p', '0.2'); + pub.tick(); + expect(posted).toHaveLength(2); + }); + it('posts nothing before ready', () => { + const section = document.createElement('section'); + const posted: unknown[] = []; + const pub = createStagePublisher({ section, frameWindow: () => ({ postMessage: (m: unknown) => posted.push(m) } as unknown as Window), track: vi.fn() }); + section.style.setProperty('--sc-p', '0.3'); + pub.tick(); + expect(posted).toHaveLength(0); + }); + it('ignores messages from other origins', () => { + const { section, pub } = setup(); + window.dispatchEvent(new MessageEvent('message', { origin: 'https://evil.example', data: { type: STAGE_MESSAGE_TYPE, applied: 9, phase: 'render', t: 1 } })); + expect(section.getAttribute('data-sc-verify-state')).toBeNull(); + pub.dispose(); + }); + it('mirrors the frame\'s applied state into data-sc-verify-state and the hold into data-sc-verify-hold', () => { + const { section, pub } = setup(); + window.dispatchEvent(new MessageEvent('message', { origin: STAGE_DEMO_ORIGIN, data: { type: STAGE_MESSAGE_TYPE, applied: 42, phase: 'stream', t: 900 } })); + expect(section.getAttribute('data-sc-verify-state')).toBe('stream:42'); + const a = beatWindows()[2]; + section.style.setProperty('--sc-p', String(a.from + (a.to - a.from) * 0.5)); + pub.tick(); + expect(section.getAttribute('data-sc-verify-hold')).toBe('true'); + section.style.setProperty('--sc-p', String(a.from + (a.to - a.from) * 0.9)); + pub.tick(); + expect(section.getAttribute('data-sc-verify-hold')).toBeNull(); + }); + it('tracks enter once, each beat once, the threshold once, complete once', () => { + const { section, track, pub } = setup(); + const a = beatWindows()[2]; + const th = a.from + (a.to - a.from) * APPROVE_HOLD.to; + for (const p of [0.01, 0.02, beatWindows()[1].from + 0.01, th - 0.01, th + 0.01, th + 0.02, 0.999, 1]) { + section.style.setProperty('--sc-p', String(p)); + pub.tick(); + } + const events = track.mock.calls.map((c) => `${c[0]}${c[1] ? ':' + c[1] : ''}`); + expect(events).toEqual(['enter', 'beat:stream', 'beat:persist', 'beat:approve', 'threshold', 'beat:render', 'complete']); + }); + it('dispose removes the listener and stops posting', () => { + const { section, posted, pub } = setup(); + pub.dispose(); + section.style.setProperty('--sc-p', '0.5'); + pub.tick(); + expect(posted).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run** — FAIL (module missing). + +- [ ] **Step 3: Implement** + +```ts +// use-stage-publisher.ts +import { useEffect } from 'react'; +import { beatAt, crossedThreshold, inHold, timeAt, type StageBeat, type StageReadyMessage } from '../../lib/stage-beats'; + +export const STAGE_DEMO_ORIGIN = 'https://demo.threadplane.ai'; +export const STAGE_DEMO_URL = `${STAGE_DEMO_ORIGIN}/stage?t=0`; +export const STAGE_MESSAGE_TYPE = 'tplane-stage'; + +export type StageMilestone = 'enter' | 'beat' | 'threshold' | 'complete'; + +export interface StagePublisherDeps { + section: HTMLElement; + /** The iframe's window, or null while it is not mounted / not navigated. */ + frameWindow: () => Window | null; + track: (milestone: StageMilestone, beat?: StageBeat) => void; + /** Called with the frame's first `ready`; the act uses it to crossfade the poster. */ + onReady?: () => void; +} + +export interface StagePublisher { + /** One frame: read --sc-p, post t if it changed, update verify attributes and milestones. */ + tick(): void; + dispose(): void; +} + +function readProgress(el: HTMLElement): number { + const v = parseFloat(el.style.getPropertyValue('--sc-p')); + return Number.isFinite(v) ? v : 0; +} + +export function createStagePublisher(deps: StagePublisherDeps): StagePublisher { + let ready: StageReadyMessage | null = null; + let lastT = -1; + let lastP = -1; + let hold = false; + let entered = false; + let completed = false; + let thresholdSeen = false; + const beatsSeen = new Set(); + let disposed = false; + + const onMessage = (e: MessageEvent) => { + if (e.origin !== STAGE_DEMO_ORIGIN) return; + const d = e.data as Record | null; + if (!d || d['type'] !== STAGE_MESSAGE_TYPE) return; + if (d['ready'] === true && Array.isArray(d['beats']) && typeof d['totalMs'] === 'number') { + const first = ready === null; + ready = d as unknown as StageReadyMessage; + lastT = -1; // re-post the current t after a (re)ready + if (first) deps.onReady?.(); + return; + } + if (typeof d['applied'] === 'number' && typeof d['phase'] === 'string') { + deps.section.setAttribute('data-sc-verify-state', `${d['phase']}:${d['applied']}`); + } + }; + window.addEventListener('message', onMessage); + + return { + tick() { + if (disposed) return; + const p = readProgress(deps.section); + // Verify hold, from scroll alone: the harness must see the authored hold even before the frame answers. + const h = inHold(p); + if (h !== hold) { + hold = h; + if (h) deps.section.setAttribute('data-sc-verify-hold', 'true'); + else deps.section.removeAttribute('data-sc-verify-hold'); + } + // Milestones. + if (!entered && p > 0) { entered = true; deps.track('enter'); } + const beat = beatAt(p); + if (p > 0 && !beatsSeen.has(beat)) { beatsSeen.add(beat); deps.track('beat', beat); } + if (lastP >= 0 && !thresholdSeen && crossedThreshold(lastP, p)) { thresholdSeen = true; deps.track('threshold'); } + if (!completed && p >= 0.999) { completed = true; deps.track('complete'); } + lastP = p; + // Seek. + if (!ready) return; + const t = timeAt(p, ready); + if (t === lastT) return; + const w = deps.frameWindow(); + if (!w) return; + w.postMessage({ type: STAGE_MESSAGE_TYPE, t }, STAGE_DEMO_ORIGIN); + lastT = t; + }, + dispose() { + disposed = true; + window.removeEventListener('message', onMessage); + }, + }; +} + +/** + * Runs the publisher on animation frames while `active`, and only while the + * section intersects the viewport (a pinned act six viewports tall is on screen + * for a while; nothing is posted before it arrives or after it leaves). + */ +export function useStagePublisher( + sectionRef: React.RefObject, + active: boolean, + deps: Omit, +): void { + useEffect(() => { + const section = sectionRef.current; + if (!active || !section) return; + const pub = createStagePublisher({ section, ...deps }); + let onScreen = false; + let frame = 0; + const loop = () => { + pub.tick(); + frame = onScreen ? requestAnimationFrame(loop) : 0; + }; + const io = new IntersectionObserver((entries) => { + onScreen = entries.some((e) => e.isIntersecting); + if (onScreen && frame === 0) frame = requestAnimationFrame(loop); + }); + io.observe(section); + return () => { + io.disconnect(); + onScreen = false; + if (frame) cancelAnimationFrame(frame); + pub.dispose(); + }; + // deps are stable callbacks from the act; see StageAct. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sectionRef, active]); +} +``` + +- [ ] **Step 4: Run** the spec — PASS. Mutation check: comment out `lastT = t;` → the first case fails (posts twice). Restore. + +- [ ] **Step 5: Commit** `feat(website): stage publisher — scroll to recorded time, verify attributes, milestones` + +--- + +### Task 7: The act, the frame, the mode switch + +**Files:** +- Create: `apps/website/src/components/landing/StageAct.tsx`, `Stage.tsx`, `Stage.spec.tsx` +- Modify: `apps/website/src/styles/landing.css`, `apps/website/src/styles/style-contracts.spec.ts` +- Modify: `apps/website/src/lib/analytics/events.ts`, `client.ts`, `client.spec.ts` (or `server.spec.ts`'s sibling — put the new case where `trackCtaClick` is tested) + +- [ ] **Step 1: Analytics (failing test first)** + +In the analytics spec that covers `trackCtaClick`, add: + +```ts +it('trackStageProgress captures marketing:stage_progress with the milestone and beat', () => { + trackStageProgress('beat', 'approve'); + expect(captureSpy).toHaveBeenCalledWith('marketing:stage_progress', expect.objectContaining({ surface: 'home_stage', stage_event: 'beat', beat: 'approve' })); +}); +``` +(Use whatever posthog capture spy the file already sets up.) Then in `events.ts`: add `marketingStageProgress: 'marketing:stage_progress'` to `analyticsEvents`, `'home_stage'` to `AnalyticsSurface`, and to `AnalyticsProperties`: `stage_event?: 'enter' | 'beat' | 'threshold' | 'complete'; beat?: 'stream' | 'persist' | 'approve' | 'render';`. In `client.ts`: + +```ts +export function trackStageProgress(stage_event: AnalyticsProperties['stage_event'], beat?: AnalyticsProperties['beat']) { + track(analyticsEvents.marketingStageProgress, { surface: 'home_stage', stage_event, ...(beat ? { beat } : {}) }); +} +``` + +- [ ] **Step 2: Failing tests for Stage** + +```tsx +// Stage.spec.tsx +import { act, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Stage } from './Stage'; + +function mockViewport(width: number, reducedMotion: boolean) { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); + window.matchMedia = vi.fn().mockImplementation((q: string) => ({ + matches: q.includes('reduce') ? reducedMotion : false, media: q, addEventListener: vi.fn(), removeEventListener: vi.fn(), + })) as unknown as typeof window.matchMedia; +} +afterEach(() => { vi.restoreAllMocks(); }); + +describe('Stage', () => { + it('renders the stills on the server and keeps them on a narrow viewport', async () => { + mockViewport(390, false); + render(); + expect(screen.getAllByTestId('stage-still-beat')).toHaveLength(4); + expect(document.querySelector('[data-stage-act]')).toBeNull(); + }); + it('keeps the stills under reduced motion on a wide viewport', () => { + mockViewport(1440, true); + render(); + expect(document.querySelector('[data-stage-act]')).toBeNull(); + }); + it('upgrades to the pinned act on a wide, motion-tolerant viewport', async () => { + mockViewport(1440, false); + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = class { observe() {} disconnect() {} } as never; + render(); + await act(async () => {}); + const actEl = document.querySelector('[data-stage-act]')!; + expect(actEl.getAttribute('data-sc-act')).toBe('pin'); + expect(actEl.getAttribute('data-sc-span')).toBe('6'); + expect(actEl.querySelector('[data-sc-stage]')).not.toBeNull(); + expect(actEl.querySelectorAll('[data-sc-cue]').length).toBeGreaterThanOrEqual(7); // 4 beats + 3 hold lines + expect(screen.queryAllByTestId('stage-still-beat')).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 3: Implement `Stage.tsx`** + +```tsx +'use client'; +import { useEffect, useState } from 'react'; +import { StageStills } from './StageStills'; +import { StageAct } from './StageAct'; + +export const STAGE_MIN_WIDTH = 1024; +type Mode = 'stills' | 'act'; + +function actAllowed(): boolean { + if (typeof window === 'undefined') return false; + if (window.innerWidth < STAGE_MIN_WIDTH) return false; + return !window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** + * 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 + * and the first client render agree. + */ +export function Stage() { + const [mode, setMode] = useState('stills'); + useEffect(() => { if (actAllowed()) setMode('act'); }, []); + if (mode === 'stills') return ; + return setMode('stills')} />; +} +``` + +- [ ] **Step 4: Implement `StageAct.tsx`** + +```tsx +'use client'; +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 { STAGE_SPAN, cueFor, holdLineCues } from '../../lib/stage-beats'; +import { trackStageProgress } from '../../lib/analytics/client'; +import { STAGE_DEMO_URL, STAGE_MESSAGE_TYPE, STAGE_DEMO_ORIGIN, useStagePublisher, type StageMilestone } from './use-stage-publisher'; +import type { StageBeat } from '../../lib/stage-beats'; + +const READY_TIMEOUT_MS = 8000; +const POSTER = '/screenshots/stage-stream.webp'; + +interface Props { onFallback: () => void } + +/** + * The pinned act (spec §4.1, §6, §7). The engine owns scroll and `--sc-p`; + * 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) { + const sectionRef = useRef(null); + const iframeRef = useRef(null); + const [frameLoaded, setFrameLoaded] = useState(false); + const [ready, setReady] = useState(false); + + // Mount the engine once the act is in the DOM. The engine is an IIFE that + // touches window at load, so it is imported on the client only. + useEffect(() => { + let cancelled = false; + void import('../../vendor/scrollcraft/scrollcraft.js').then(() => { + if (cancelled || !sectionRef.current || !window.ScrollCraft) return; + window.ScrollCraft.mount(sectionRef.current); + }); + return () => { cancelled = true; }; + }, []); + + // Ready timeout → the stills. + useEffect(() => { + if (ready) return; + const t = setTimeout(onFallback, READY_TIMEOUT_MS); + return () => clearTimeout(t); + }, [ready, onFallback]); + + const track = useCallback((m: StageMilestone, beat?: StageBeat) => trackStageProgress(m, beat), []); + const frameWindow = useCallback(() => (frameLoaded ? iframeRef.current?.contentWindow ?? null : null), [frameLoaded]); + const onReady = useCallback(() => setReady(true), []); + useStagePublisher(sectionRef, true, { frameWindow, track, onReady }); + + const holdCues = holdLineCues(STAGE_HOLD_LINES.length); + + return ( +
+
+ +
+ +
+ +