diff --git a/apps/website/public/screenshots/hero-walkthrough-poster.webp b/apps/website/public/screenshots/hero-walkthrough-poster.webp new file mode 100644 index 000000000..ac4342fb9 Binary files /dev/null and b/apps/website/public/screenshots/hero-walkthrough-poster.webp differ diff --git a/docs/superpowers/plans/2026-09-02-hero-demo-route.md b/docs/superpowers/plans/2026-09-02-hero-demo-route.md new file mode 100644 index 000000000..37f3e6179 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-hero-demo-route.md @@ -0,0 +1,1844 @@ +# Hero Demo Route (`/hero`) 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:** Add a `/hero` route to the canonical demo (`examples/chat/angular`) that replays a recorded LangGraph run through the real `` components with a scripted cursor, and hands control to the live LangGraph agent when the visitor interacts. + +**Architecture:** A top-level lazy route with its own `HeroMode` component. Two agents are provided at component level: a replay agent backed by `HeroReplayTransport` (plays `public/hero-replay.json`) and a live agent backed by the adapter's default `FetchStreamTransport`. A pure `HeroScriptRunner` drives the walkthrough through a host interface; a `HeroBridge` talks to the embedding website over `postMessage`. Recording uses `HeroRecordingTransport` (a wrapper around `FetchStreamTransport`) driven by a Playwright `.record.ts` script against the aimock-backed backend, so no API key is needed. + +**Tech Stack:** Angular 20 (zoneless, signals, standalone), `@threadplane/chat`, `@threadplane/langgraph`, Vitest + Angular TestBed (`examples/chat/angular/vite.config.mts`), Playwright (`examples/chat/angular/e2e`), aimock replay. + +**Spec:** `docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md` §4.3. + +**Branch:** work on `blove/homepage-rebuild-spec` (cut from `origin/main`) or a branch from it. Run `npm ci` once in a fresh worktree before anything else. In a worktree the demo has no `.env`; symlink the main checkout's `.env` before any live serve. + +--- + +## File map + +| Path | Responsibility | +|---|---| +| `examples/chat/angular/src/app/hero/hero-recording.types.ts` | `RecordedEvent`, `RecordedRun`, `HeroRecording` types + `validateHeroRecording()` | +| `examples/chat/angular/src/app/hero/hero-replay.transport.ts` | `HeroReplayTransport` (injectable `AgentTransport`, paces recorded runs) | +| `examples/chat/angular/src/app/hero/hero-recording.transport.ts` | `HeroRecordingTransport` (wraps `FetchStreamTransport`, captures runs) | +| `examples/chat/angular/src/app/hero/hero-script.ts` | `HeroScriptRunner`, `HeroScriptHost`, `HeroScriptState`, `HERO_PROMPTS` | +| `examples/chat/angular/src/app/hero/hero-bridge.ts` | `HeroBridge` postMessage helpers with origin allowlist | +| `examples/chat/angular/src/app/hero/hero-cursor.component.ts` | `` SVG cursor positioned by signals | +| `examples/chat/angular/src/app/hero/hero-agent-refs.ts` | `HERO_REPLAY_REF`, `HERO_LIVE_REF` | +| `examples/chat/angular/src/app/hero/hero-mode.component.ts` | `HeroMode` route component: agents, chat, interrupt panel, pills, takeover | +| `examples/chat/angular/src/app/app.routes.ts` | add the `hero` route | +| `examples/chat/angular/public/hero-replay.json` | committed recording (three runs) | +| `examples/chat/angular/e2e/record-hero.config.ts` | Playwright config for `*.record.ts` without video | +| `examples/chat/angular/e2e/record-hero-fixture.record.ts` | drives `/hero?record=1`, writes the fixture | +| `examples/chat/angular/e2e/hero.spec.ts` | e2e: replay reaches the interrupt, takeover goes live, replay restarts | +| `apps/website/public/screenshots/hero-walkthrough-poster.webp` | poster for the website (captured in Task 13) | + +Every new `.ts` file starts with `// SPDX-License-Identifier: MIT` like its neighbors. + +--- + +### Task 1: Recording types and validator + +**Files:** +- Create: `examples/chat/angular/src/app/hero/hero-recording.types.ts` +- Test: `examples/chat/angular/src/app/hero/hero-recording.types.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { validateHeroRecording, type HeroRecording } from './hero-recording.types'; + +const good: HeroRecording = { + version: 1, + recordedAt: '2026-09-02T00:00:00.000Z', + runs: [ + { label: 'prompt', events: [{ tMs: 0, event: { type: 'messages', messages: [] } }] }, + { label: 'resume', events: [{ tMs: 0, event: { type: 'interrupt' } }] }, + { label: 'genui', events: [{ tMs: 12, event: { type: 'values' } }] }, + ], +}; + +describe('validateHeroRecording', () => { + it('accepts a three-run recording', () => { + expect(validateHeroRecording(good)).toEqual(good); + }); + + it('rejects a recording with fewer than three runs', () => { + expect(() => validateHeroRecording({ ...good, runs: good.runs.slice(0, 2) })).toThrow(/three runs/); + }); + + it('rejects an event without a numeric tMs', () => { + const bad = { ...good, runs: [{ label: 'x', events: [{ event: { type: 'values' } }] }, good.runs[1], good.runs[2]] }; + expect(() => validateHeroRecording(bad)).toThrow(/tMs/); + }); + + it('rejects non-objects', () => { + expect(() => validateHeroRecording(null)).toThrow(/object/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-recording.types.spec.ts` +Expected: FAIL, cannot resolve `./hero-recording.types`. + +- [ ] **Step 3: Write the types and validator** + +```ts +// SPDX-License-Identifier: MIT +import type { StreamEvent } from '@threadplane/langgraph'; + +/** One transport event with its offset from the start of its run. */ +export interface RecordedEvent { + readonly tMs: number; + readonly event: StreamEvent; +} + +/** One `AgentTransport.stream()` call, start to finish. */ +export interface RecordedRun { + readonly label: string; + readonly events: readonly RecordedEvent[]; +} + +/** The committed hero walkthrough: prompt → interrupt, resume, generative UI. */ +export interface HeroRecording { + readonly version: 1; + readonly recordedAt: string; + readonly runs: readonly RecordedRun[]; +} + +export const HERO_RECORDING_RUN_COUNT = 3; + +/** Throws a readable error when the fixture is not a usable recording. */ +export function validateHeroRecording(input: unknown): HeroRecording { + if (typeof input !== 'object' || input === null) throw new Error('hero recording must be an object'); + const rec = input as Partial; + if (rec.version !== 1) throw new Error('hero recording version must be 1'); + if (!Array.isArray(rec.runs) || rec.runs.length < HERO_RECORDING_RUN_COUNT) { + throw new Error(`hero recording needs at least three runs, got ${rec.runs?.length ?? 0}`); + } + rec.runs.forEach((run, i) => { + if (typeof run.label !== 'string') throw new Error(`run ${i} has no label`); + if (!Array.isArray(run.events)) throw new Error(`run ${i} has no events`); + run.events.forEach((e, j) => { + if (typeof e?.tMs !== 'number' || !Number.isFinite(e.tMs)) throw new Error(`run ${i} event ${j} has no numeric tMs`); + if (typeof e.event !== 'object' || e.event === null) throw new Error(`run ${i} event ${j} has no event`); + }); + }); + return rec as HeroRecording; +} +``` + +`StreamEvent` is exported from `@threadplane/langgraph` (`libs/langgraph/src/lib/agent.types.ts`). If the import fails, check `libs/langgraph/src/public-api.ts` for the export name and use it. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-recording.types.spec.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/hero/hero-recording.types.ts examples/chat/angular/src/app/hero/hero-recording.types.spec.ts +git commit -m "feat(examples/chat): hero recording types and validator" +``` + +--- + +### Task 2: HeroReplayTransport + +**Files:** +- Create: `examples/chat/angular/src/app/hero/hero-replay.transport.ts` +- Test: `examples/chat/angular/src/app/hero/hero-replay.transport.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi } from 'vitest'; +import { HeroReplayTransport, type ReplayClock } from './hero-replay.transport'; +import type { HeroRecording } from './hero-recording.types'; + +const recording: HeroRecording = { + version: 1, + recordedAt: '2026-09-02T00:00:00.000Z', + runs: [ + { + label: 'prompt', + events: [ + { tMs: 0, event: { type: 'messages', messages: [{ id: 'a', type: 'ai', content: 'He' }] } }, + { tMs: 5, event: { type: 'messages', messages: [{ id: 'a', type: 'ai', content: 'Hello' }] } }, + { tMs: 2000, event: { type: 'interrupt' } }, + ], + }, + { label: 'resume', events: [{ tMs: 0, event: { type: 'values' } }] }, + { label: 'genui', events: [{ tMs: 0, event: { type: 'values' } }] }, + ], +}; + +function fakeClock(): ReplayClock & { waits: number[] } { + const waits: number[] = []; + return { waits, sleep: async (ms) => { waits.push(ms); } }; +} + +async function collect(it: AsyncIterable): Promise { + const out: unknown[] = []; + for await (const e of it) out.push(e); + return out; +} + +describe('HeroReplayTransport', () => { + it('plays runs in order across successive stream() calls', async () => { + const clock = fakeClock(); + const t = new HeroReplayTransport(clock, async () => recording); + const ctl = new AbortController(); + const first = await collect(t.stream('hero', null, {}, ctl.signal)); + const second = await collect(t.stream('hero', null, {}, ctl.signal)); + expect(first).toHaveLength(3); + expect(second).toEqual([{ type: 'values' }]); + }); + + it('paces by recorded gaps clamped to [30, 600] ms', async () => { + const clock = fakeClock(); + const t = new HeroReplayTransport(clock, async () => recording); + await collect(t.stream('hero', null, {}, new AbortController().signal)); + // gaps: 0 → 30 (floor), 5 → 30 (floor), 1995 → 600 (ceiling) + expect(clock.waits).toEqual([30, 30, 600]); + }); + + it('stops when the signal aborts', async () => { + const clock = fakeClock(); + const t = new HeroReplayTransport(clock, async () => recording); + const ctl = new AbortController(); + const out: unknown[] = []; + for await (const e of t.stream('hero', null, {}, ctl.signal)) { + out.push(e); + ctl.abort(); + } + expect(out).toHaveLength(1); + }); + + it('reset() rewinds to the first run', async () => { + const clock = fakeClock(); + const t = new HeroReplayTransport(clock, async () => recording); + const sig = new AbortController().signal; + await collect(t.stream('hero', null, {}, sig)); + t.reset(); + const again = await collect(t.stream('hero', null, {}, sig)); + expect(again).toHaveLength(3); + }); + + it('yields nothing once every run is consumed', async () => { + const clock = fakeClock(); + const t = new HeroReplayTransport(clock, async () => recording); + const sig = new AbortController().signal; + for (let i = 0; i < 3; i++) await collect(t.stream('hero', null, {}, sig)); + expect(await collect(t.stream('hero', null, {}, sig))).toEqual([]); + }); + + it('loads the recording only once', async () => { + const load = vi.fn(async () => recording); + const t = new HeroReplayTransport(fakeClock(), load); + const sig = new AbortController().signal; + await collect(t.stream('hero', null, {}, sig)); + await collect(t.stream('hero', null, {}, sig)); + expect(load).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-replay.transport.spec.ts` +Expected: FAIL, cannot resolve `./hero-replay.transport`. + +- [ ] **Step 3: Write the transport** + +```ts +// SPDX-License-Identifier: MIT +import { Injectable } from '@angular/core'; +import type { + AgentQueueEntry, + AgentTransport, + LangGraphSubmitOptions, + StreamEvent, +} from '@threadplane/langgraph'; +import type { ThreadState } from '@langchain/langgraph-sdk'; +import { validateHeroRecording, type HeroRecording } from './hero-recording.types'; + +export interface ReplayClock { + sleep(ms: number): Promise; +} + +/** Floor keeps tokens visibly streaming even when the recording was near-atomic + * (aimock replay is); ceiling keeps long model pauses from stalling the hero. */ +export const REPLAY_MIN_GAP_MS = 30; +export const REPLAY_MAX_GAP_MS = 600; + +export const HERO_RECORDING_URL = '/hero-replay.json'; + +const realClock: ReplayClock = { + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +}; + +async function fetchRecording(): Promise { + const res = await fetch(HERO_RECORDING_URL); + if (!res.ok) throw new Error(`hero recording fetch failed: ${res.status}`); + return validateHeroRecording(await res.json()); +} + +/** + * AgentTransport that answers each `stream()` call with the next recorded run. + * Backs the hero's replay agent. No backend, no LLM. NOT for production apps. + */ +@Injectable() +export class HeroReplayTransport implements AgentTransport { + private recording: Promise | null = null; + private runIndex = 0; + + constructor( + private readonly clock: ReplayClock = realClock, + private readonly load: () => Promise = fetchRecording, + ) {} + + /** Resolves once the fixture is loaded; the hero posts `ready` after this. */ + ready(): Promise { + return this.getRecording().then(() => undefined); + } + + reset(): void { + this.runIndex = 0; + } + + async *stream( + _assistantId: string, + _threadId: string | null, + _payload: unknown, + signal: AbortSignal, + _options?: LangGraphSubmitOptions, + ): AsyncIterable { + const rec = await this.getRecording(); + const run = rec.runs[this.runIndex]; + if (!run) return; + this.runIndex += 1; + let last = 0; + for (const { tMs, event } of run.events) { + if (signal.aborted) return; + const gap = Math.min(REPLAY_MAX_GAP_MS, Math.max(REPLAY_MIN_GAP_MS, tMs - last)); + last = tMs; + await this.clock.sleep(gap); + if (signal.aborted) return; + yield event; + } + } + + async createQueuedRun( + _assistantId: string, + threadId: string, + payload: unknown, + _signal: AbortSignal, + options?: LangGraphSubmitOptions, + ): Promise { + return { + id: 'hero-replay-queued-run', + threadId, + values: payload, + options: { ...options, multitaskStrategy: 'enqueue' }, + createdAt: new Date(), + }; + } + + async cancelRun(): Promise { + return; + } + + async getHistory(): Promise { + return []; + } + + async *joinStream(): AsyncIterable { + yield* []; + } + + private getRecording(): Promise { + this.recording ??= this.load(); + return this.recording; + } +} +``` + +If `AgentQueueEntry` or `LangGraphSubmitOptions` are not exported from the package root, copy the import lines from `libs/langgraph/src/lib/testing/fake-stream.transport.ts` and adjust to the public path used there. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-replay.transport.spec.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/hero/hero-replay.transport.ts examples/chat/angular/src/app/hero/hero-replay.transport.spec.ts +git commit -m "feat(examples/chat): HeroReplayTransport plays recorded runs with clamped pacing" +``` + +--- + +### Task 3: HeroRecordingTransport + +**Files:** +- Create: `examples/chat/angular/src/app/hero/hero-recording.transport.ts` +- Test: `examples/chat/angular/src/app/hero/hero-recording.transport.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { HeroRecordingTransport } from './hero-recording.transport'; +import type { AgentTransport, StreamEvent } from '@threadplane/langgraph'; + +function innerWith(events: StreamEvent[]): AgentTransport { + return { + async *stream() { + for (const e of events) yield e; + }, + }; +} + +describe('HeroRecordingTransport', () => { + it('passes events through and records them with offsets', async () => { + let now = 1000; + const t = new HeroRecordingTransport(innerWith([{ type: 'values' }, { type: 'messages' }]), () => (now += 40)); + const out: StreamEvent[] = []; + for await (const e of t.stream('a', null, {}, new AbortController().signal)) out.push(e); + expect(out).toEqual([{ type: 'values' }, { type: 'messages' }]); + const rec = t.recording(); + expect(rec.runs).toHaveLength(1); + expect(rec.runs[0].events.map((e) => e.tMs)).toEqual([40, 80]); + }); + + it('labels runs in order: prompt, resume, genui, then run-N', async () => { + const t = new HeroRecordingTransport(innerWith([{ type: 'values' }]), () => 0); + const sig = new AbortController().signal; + for (let i = 0; i < 4; i++) for await (const _ of t.stream('a', null, {}, sig)) { /* drain */ } + expect(t.recording().runs.map((r) => r.label)).toEqual(['prompt', 'resume', 'genui', 'run-4']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-recording.transport.spec.ts` +Expected: FAIL, cannot resolve module. + +- [ ] **Step 3: Write the recording wrapper** + +```ts +// SPDX-License-Identifier: MIT +import type { + AgentQueueEntry, + AgentTransport, + LangGraphSubmitOptions, + StreamEvent, +} from '@threadplane/langgraph'; +import type { ThreadState } from '@langchain/langgraph-sdk'; +import type { HeroRecording, RecordedRun } from './hero-recording.types'; + +const RUN_LABELS = ['prompt', 'resume', 'genui'] as const; + +declare global { + interface Window { + /** Set by HeroRecordingTransport in record mode; read by the record script. */ + __heroRecording?: HeroRecording; + } +} + +/** + * Wraps the real transport, forwards everything, and keeps a copy of every + * `stream()` call's events with millisecond offsets. Only wired when + * `/hero?record=1` is opened in a non-production build. + */ +export class HeroRecordingTransport implements AgentTransport { + private readonly runs: RecordedRun[] = []; + + constructor( + private readonly inner: AgentTransport, + private readonly now: () => number = () => performance.now(), + ) {} + + recording(): HeroRecording { + return { version: 1, recordedAt: new Date().toISOString(), runs: [...this.runs] }; + } + + async *stream( + assistantId: string, + threadId: string | null, + payload: unknown, + signal: AbortSignal, + options?: LangGraphSubmitOptions, + ): AsyncIterable { + const start = this.now(); + const events: { tMs: number; event: StreamEvent }[] = []; + const index = this.runs.length; + const run: RecordedRun = { label: RUN_LABELS[index] ?? `run-${index + 1}`, events }; + this.runs.push(run); + for await (const event of this.inner.stream(assistantId, threadId, payload, signal, options)) { + events.push({ tMs: Math.round(this.now() - start), event }); + this.publish(); + yield event; + } + this.publish(); + } + + joinStream(threadId: string, runId: string, lastEventId: string | undefined, signal: AbortSignal): AsyncIterable { + return this.inner.joinStream ? this.inner.joinStream(threadId, runId, lastEventId, signal) : (async function* () {})(); + } + + createQueuedRun(assistantId: string, threadId: string, payload: unknown, signal: AbortSignal, options?: LangGraphSubmitOptions): Promise { + if (!this.inner.createQueuedRun) throw new Error('inner transport cannot queue runs'); + return this.inner.createQueuedRun(assistantId, threadId, payload, signal, options); + } + + cancelRun(threadId: string, runId: string, signal: AbortSignal): Promise { + return this.inner.cancelRun ? this.inner.cancelRun(threadId, runId, signal) : Promise.resolve(); + } + + getHistory(threadId: string, signal: AbortSignal): Promise { + return this.inner.getHistory ? this.inner.getHistory(threadId, signal) : Promise.resolve([]); + } + + updateState(threadId: string, values: Record, signal: AbortSignal, options?: { asNode?: string }): Promise { + return this.inner.updateState ? this.inner.updateState(threadId, values, signal, options) : Promise.resolve(); + } + + private publish(): void { + if (typeof window !== 'undefined') window.__heroRecording = this.recording(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-recording.transport.spec.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/hero/hero-recording.transport.ts examples/chat/angular/src/app/hero/hero-recording.transport.spec.ts +git commit -m "feat(examples/chat): HeroRecordingTransport captures live runs for the hero fixture" +``` + +--- + +### Task 4: HeroScriptRunner (pure, host-driven) + +**Files:** +- Create: `examples/chat/angular/src/app/hero/hero-script.ts` +- Test: `examples/chat/angular/src/app/hero/hero-script.spec.ts` + +The runner never touches the DOM. It calls a `HeroScriptHost`, which `HeroMode` implements in Task 8. + +- [ ] **Step 1: Write the failing test** + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { HeroScriptRunner, HERO_PROMPTS, type HeroScriptHost } from './hero-script'; + +interface FakeHost extends HeroScriptHost { + log: string[]; + interruptPresent: boolean; + running: boolean; +} + +function fakeHost(): FakeHost { + const host: FakeHost = { + log: [], + interruptPresent: false, + running: false, + reducedMotion: false, + typeInto: async (text) => { host.log.push(`type:${text}`); }, + send: async () => { host.log.push('send'); host.running = true; }, + acceptInterrupt: async () => { host.log.push('accept'); host.interruptPresent = false; host.running = true; }, + moveCursor: async (target) => { host.log.push(`cursor:${target}`); }, + hasInterrupt: () => host.interruptPresent, + isRunning: () => host.running, + restartReplay: async () => { host.log.push('restart'); }, + }; + return host; +} + +/** Zero-delay clock that lets waits resolve when the predicate flips. */ +const clock = { sleep: async () => {} }; + +describe('HeroScriptRunner', () => { + it('waits for visibility before typing', async () => { + const host = fakeHost(); + const r = new HeroScriptRunner(host, clock); + r.start(); + await Promise.resolve(); + expect(host.log).toEqual([]); + expect(r.state()).toBe('waiting'); + }); + + it('runs prompt → send → accept → prompt 2 → send → done', async () => { + const host = fakeHost(); + const r = new HeroScriptRunner(host, clock); + r.setVisible(true); + const done = r.start(); + // Let it type + send, then simulate the graph pausing. + await until(() => host.log.includes('send')); + host.running = false; host.interruptPresent = true; + await until(() => host.log.includes('accept')); + host.running = false; + await until(() => host.log.filter((l) => l === 'send').length === 2); + host.running = false; + await done; + expect(host.log).toEqual([ + 'cursor:composer', `type:${HERO_PROMPTS[0]}`, 'cursor:send', 'send', + 'cursor:accept', 'accept', + 'cursor:composer', `type:${HERO_PROMPTS[1]}`, 'cursor:send', 'send', + ]); + expect(r.state()).toBe('done'); + }); + + it('pauses when hidden and resumes where it stopped', async () => { + const host = fakeHost(); + const r = new HeroScriptRunner(host, clock); + r.setVisible(true); + const done = r.start(); + await until(() => host.log.includes('send')); + r.setVisible(false); + expect(r.state()).toBe('paused'); + host.running = false; host.interruptPresent = true; + await Promise.resolve(); + expect(host.log).not.toContain('accept'); + r.setVisible(true); + await until(() => host.log.includes('accept')); + r.stop(); + await done; + }); + + it('stop() ends the run without further host calls', async () => { + const host = fakeHost(); + const r = new HeroScriptRunner(host, clock); + r.setVisible(true); + const done = r.start(); + await until(() => host.log.includes('send')); + r.stop(); + await done; + expect(r.state()).toBe('stopped'); + const n = host.log.length; + host.running = false; host.interruptPresent = true; + await Promise.resolve(); + expect(host.log.length).toBe(n); + }); +}); + +async function until(pred: () => boolean, max = 200): Promise { + for (let i = 0; i < max; i++) { + if (pred()) return; + await new Promise((res) => setTimeout(res, 0)); + } + throw new Error('condition not met'); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-script.spec.ts` +Expected: FAIL, cannot resolve `./hero-script`. + +- [ ] **Step 3: Write the runner** + +```ts +// SPDX-License-Identifier: MIT +import { signal } from '@angular/core'; + +/** + * The two prompts must stay VERBATIM: aimock fixtures match on the exact user + * message (see e2e/fixtures/interrupt-approval.json and contact-form.json), so + * rewording either one breaks recording. + */ +export const HERO_PROMPTS = [ + 'I want to clean up old database backups older than 90 days. Walk me through ' + + 'what you would delete, and call request_approval before doing anything ' + + 'destructive so I can review your plan.', + 'Show me a contact form with fields for name, email address, subject, and a multi-line message, plus a Send button.', +] as const; + +export type CursorTarget = 'composer' | 'send' | 'accept'; + +export interface HeroScriptHost { + readonly reducedMotion: boolean; + typeInto(text: string): Promise; + send(): Promise; + acceptInterrupt(): Promise; + moveCursor(target: CursorTarget): Promise; + hasInterrupt(): boolean; + isRunning(): boolean; + restartReplay(): Promise; +} + +export interface ScriptClock { + sleep(ms: number): Promise; +} + +export type HeroScriptState = 'idle' | 'waiting' | 'running' | 'paused' | 'done' | 'stopped'; + +export const HOLD_AFTER_DONE_MS = 8000; +const POLL_MS = 50; +const SETTLE_MS = 400; + +const realClock: ScriptClock = { sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }; + +export class HeroScriptRunner { + readonly state = signal('idle'); + private visible = false; + private stopped = false; + private wake: (() => void) | null = null; + + constructor(private readonly host: HeroScriptHost, private readonly clock: ScriptClock = realClock) {} + + setVisible(v: boolean): void { + this.visible = v; + if (v) { + if (this.state() === 'paused') this.state.set('running'); + this.wake?.(); + } else if (this.state() === 'running') { + this.state.set('paused'); + } + } + + stop(): void { + this.stopped = true; + this.state.set('stopped'); + this.wake?.(); + } + + /** Runs one full walkthrough. Resolves when done or stopped. */ + async start(): Promise { + this.stopped = false; + this.state.set('waiting'); + await this.gate(); + if (this.stopped) return; + this.state.set('running'); + + await this.step(() => this.host.moveCursor('composer')); + await this.step(() => this.host.typeInto(HERO_PROMPTS[0])); + await this.step(() => this.host.moveCursor('send')); + await this.step(() => this.host.send()); + await this.waitFor(() => this.host.hasInterrupt()); + await this.step(() => this.host.moveCursor('accept')); + await this.step(() => this.host.acceptInterrupt()); + await this.waitFor(() => !this.host.isRunning() && !this.host.hasInterrupt()); + await this.step(() => this.clock.sleep(SETTLE_MS)); + await this.step(() => this.host.moveCursor('composer')); + await this.step(() => this.host.typeInto(HERO_PROMPTS[1])); + await this.step(() => this.host.moveCursor('send')); + await this.step(() => this.host.send()); + await this.waitFor(() => !this.host.isRunning()); + if (this.stopped) return; + this.state.set('done'); + } + + /** Loops start() with a hold and a fresh replay between passes until stopped. */ + async loop(): Promise { + while (!this.stopped) { + await this.start(); + if (this.stopped) return; + await this.clock.sleep(HOLD_AFTER_DONE_MS); + if (this.stopped) return; + await this.host.restartReplay(); + } + } + + private async step(fn: () => Promise): Promise { + if (this.stopped) return; + await this.gate(); + if (this.stopped) return; + await fn(); + } + + private async waitFor(pred: () => boolean): Promise { + while (!this.stopped) { + await this.gate(); + if (this.stopped) return; + if (pred()) return; + await this.clock.sleep(POLL_MS); + // Yield to the macrotask queue even with a zero-delay clock. + await new Promise((r) => setTimeout(r, 0)); + } + } + + /** Blocks while hidden. */ + private gate(): Promise { + if (this.visible || this.stopped) return Promise.resolve(); + return new Promise((resolve) => { + this.wake = () => { this.wake = null; resolve(); }; + }); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-script.spec.ts` +Expected: PASS (4 tests). If the "pauses when hidden" test is flaky, the `gate()` must be awaited before every host call (it is, via `step`); check that `waitFor` also gates. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/hero/hero-script.ts examples/chat/angular/src/app/hero/hero-script.spec.ts +git commit -m "feat(examples/chat): HeroScriptRunner drives the walkthrough through a host interface" +``` + +--- + +### Task 5: HeroBridge (postMessage with origin allowlist) + +**Files:** +- Create: `examples/chat/angular/src/app/hero/hero-bridge.ts` +- Test: `examples/chat/angular/src/app/hero/hero-bridge.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi } from 'vitest'; +import { HERO_PARENT_ORIGINS, createHeroBridge } from './hero-bridge'; + +describe('createHeroBridge', () => { + it('posts state to the parent when the referrer origin is allowlisted', () => { + const post = vi.fn(); + const b = createHeroBridge({ referrer: 'https://threadplane.ai/', parent: { postMessage: post } as unknown as Window, self: {} as Window }); + b.postState('ready'); + expect(post).toHaveBeenCalledWith({ type: 'tplane-hero', state: 'ready' }, 'https://threadplane.ai'); + }); + + it('does not post when the referrer is not allowlisted', () => { + const post = vi.fn(); + const b = createHeroBridge({ referrer: 'https://evil.example/', parent: { postMessage: post } as unknown as Window, self: {} as Window }); + b.postState('ready'); + expect(post).not.toHaveBeenCalled(); + }); + + it('delivers visibility only from an allowlisted origin', () => { + const listeners: ((e: MessageEvent) => void)[] = []; + const self = { addEventListener: (_: string, l: (e: MessageEvent) => void) => listeners.push(l), removeEventListener: vi.fn() } as unknown as Window; + const b = createHeroBridge({ referrer: '', parent: { postMessage: vi.fn() } as unknown as Window, self }); + const seen: boolean[] = []; + b.onVisibility((v) => seen.push(v)); + listeners[0]({ origin: 'https://threadplane.ai', data: { type: 'tplane-hero', visible: true } } as MessageEvent); + listeners[0]({ origin: 'https://evil.example', data: { type: 'tplane-hero', visible: false } } as MessageEvent); + listeners[0]({ origin: 'http://localhost:3000', data: { type: 'other' } } as MessageEvent); + expect(seen).toEqual([true]); + }); + + it('allowlist contains the production and local website origins', () => { + expect(HERO_PARENT_ORIGINS).toEqual([ + 'https://threadplane.ai', + 'https://www.threadplane.ai', + 'http://localhost:3000', + 'http://127.0.0.1:4308', + ]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-bridge.spec.ts` +Expected: FAIL, cannot resolve module. + +- [ ] **Step 3: Write the bridge** + +```ts +// SPDX-License-Identifier: MIT +export type HeroFrameState = 'ready' | 'scripted' | 'paused' | 'live' | 'replay'; + +export const HERO_MESSAGE_TYPE = 'tplane-hero'; + +/** Only these embedders receive frame state or can pause the script. */ +export const HERO_PARENT_ORIGINS: readonly string[] = [ + 'https://threadplane.ai', + 'https://www.threadplane.ai', + 'http://localhost:3000', + 'http://127.0.0.1:4308', +]; + +export interface HeroBridge { + postState(state: HeroFrameState): void; + onVisibility(cb: (visible: boolean) => void): () => void; +} + +interface BridgeEnv { + referrer: string; + parent: Window; + self: Window; +} + +function originOf(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +export function createHeroBridge(env: BridgeEnv): HeroBridge { + const parentOrigin = originOf(env.referrer); + const allowed = parentOrigin !== null && HERO_PARENT_ORIGINS.includes(parentOrigin); + return { + postState(state) { + if (!allowed || env.parent === env.self) return; + env.parent.postMessage({ type: HERO_MESSAGE_TYPE, state }, parentOrigin as string); + }, + onVisibility(cb) { + const handler = (e: MessageEvent) => { + if (!HERO_PARENT_ORIGINS.includes(e.origin)) return; + const d = e.data as { type?: string; visible?: unknown } | null; + if (!d || d.type !== HERO_MESSAGE_TYPE || typeof d.visible !== 'boolean') return; + cb(d.visible); + }; + env.self.addEventListener('message', handler); + return () => env.self.removeEventListener('message', handler); + }, + }; +} + +export function browserHeroBridge(): HeroBridge { + return createHeroBridge({ referrer: document.referrer, parent: window.parent, self: window }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-bridge.spec.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/hero/hero-bridge.ts examples/chat/angular/src/app/hero/hero-bridge.spec.ts +git commit -m "feat(examples/chat): hero postMessage bridge with parent-origin allowlist" +``` + +--- + +### Task 6: Cursor component + +**Files:** +- Create: `examples/chat/angular/src/app/hero/hero-cursor.component.ts` +- Test: `examples/chat/angular/src/app/hero/hero-cursor.component.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HeroCursorComponent } from './hero-cursor.component'; + +describe('HeroCursorComponent', () => { + it('is hidden until shown, then positions itself', () => { + TestBed.configureTestingModule({ imports: [HeroCursorComponent] }); + const fx = TestBed.createComponent(HeroCursorComponent); + fx.componentRef.setInput('x', 0); + fx.componentRef.setInput('y', 0); + fx.componentRef.setInput('visible', false); + fx.detectChanges(); + const el = fx.nativeElement as HTMLElement; + expect(el.getAttribute('aria-hidden')).toBe('true'); + expect(el.dataset['visible']).toBe('false'); + fx.componentRef.setInput('visible', true); + fx.componentRef.setInput('x', 120); + fx.componentRef.setInput('y', 48); + fx.detectChanges(); + expect(el.dataset['visible']).toBe('true'); + expect(el.style.transform).toBe('translate(120px, 48px)'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-cursor.component.spec.ts` +Expected: FAIL. + +- [ ] **Step 3: Write the component** + +```ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +/** + * The scripted pointer. Purely decorative: aria-hidden, pointer-events none, + * moved with a CSS transition on transform (disabled under reduced motion). + */ +@Component({ + selector: 'hero-cursor', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'aria-hidden': 'true', + '[attr.data-visible]': 'visible()', + '[attr.data-pressed]': 'pressed()', + '[style.transform]': '"translate(" + x() + "px, " + y() + "px)"', + }, + template: ` + + + + `, + styles: [` + :host { + position: absolute; + top: 0; + left: 0; + z-index: 20; + pointer-events: none; + opacity: 0; + transition: transform 600ms cubic-bezier(.2,.7,.2,1), opacity 200ms ease; + will-change: transform; + } + :host([data-visible="true"]) { opacity: 1; } + :host([data-pressed="true"]) svg { transform: scale(.85); } + @media (prefers-reduced-motion: reduce) { + :host { transition: opacity 200ms ease; } + } + `], +}) +export class HeroCursorComponent { + readonly x = input.required(); + readonly y = input.required(); + readonly visible = input(false); + readonly pressed = input(false); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-cursor.component.spec.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/hero/hero-cursor.component.ts examples/chat/angular/src/app/hero/hero-cursor.component.spec.ts +git commit -m "feat(examples/chat): hero cursor component" +``` + +--- + +### Task 7: Agent refs and route registration + +**Files:** +- Create: `examples/chat/angular/src/app/hero/hero-agent-refs.ts` +- Modify: `examples/chat/angular/src/app/app.routes.ts` +- Test: `examples/chat/angular/src/app/app.routes.spec.ts` (create if absent) + +- [ ] **Step 1: Write the refs** + +```ts +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; + +/** Replay agent: HeroReplayTransport, no backend. */ +export const HERO_REPLAY_REF = createAgentRef>('hero-replay'); +/** Live agent: the canonical demo's LangGraph backend, fresh thread. */ +export const HERO_LIVE_REF = createAgentRef>('hero-live'); +``` + +- [ ] **Step 2: Write the failing route test** + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { routes } from './app.routes'; + +describe('routes', () => { + it('registers /hero as a top-level lazy route before the shell', () => { + const heroIndex = routes.findIndex((r) => r.path === 'hero'); + const shellIndex = routes.findIndex((r) => r.path === '' && Array.isArray(r.children)); + expect(heroIndex).toBeGreaterThan(-1); + expect(heroIndex).toBeLessThan(shellIndex); + expect(typeof routes[heroIndex].loadComponent).toBe('function'); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/app.routes.spec.ts` +Expected: FAIL (`heroIndex` is -1). + +- [ ] **Step 4: Register the route** + +In `examples/chat/angular/src/app/app.routes.ts`, insert after the first redirect entry (`{ path: '', pathMatch: 'full', redirectTo: 'embed' }`): + +```ts + { + path: 'hero', + loadComponent: () => import('./hero/hero-mode.component').then((m) => m.HeroMode), + }, +``` + +The component file does not exist yet; the dynamic import is only resolved at runtime, so this test passes now and Task 8 supplies the module. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx nx test examples-chat-angular -- src/app/app.routes.spec.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/hero/hero-agent-refs.ts examples/chat/angular/src/app/app.routes.ts examples/chat/angular/src/app/app.routes.spec.ts +git commit -m "feat(examples/chat): register lazy /hero route and hero agent refs" +``` + +--- + +### Task 8: HeroMode component + +**Files:** +- Create: `examples/chat/angular/src/app/hero/hero-mode.component.ts` +- Test: `examples/chat/angular/src/app/hero/hero-mode.component.spec.ts` + +- [ ] **Step 1: Write the failing test** + +The test provides a tiny in-memory recording through `HeroReplayTransport`'s constructor override, so nothing is fetched. + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { HeroMode } from './hero-mode.component'; +import { HeroReplayTransport } from './hero-replay.transport'; +import type { HeroRecording } from './hero-recording.types'; + +const recording: HeroRecording = { + version: 1, + recordedAt: '2026-09-02T00:00:00.000Z', + runs: [ + { label: 'prompt', events: [{ tMs: 0, event: { type: 'messages', messages: [{ id: 'a', type: 'ai', content: 'Plan…' }] } }] }, + { label: 'resume', events: [{ tMs: 0, event: { type: 'messages', messages: [{ id: 'a', type: 'ai', content: 'Plan… done.' }] } }] }, + { label: 'genui', events: [{ tMs: 0, event: { type: 'messages', messages: [{ id: 'b', type: 'ai', content: 'Form' }] } }] }, + ], +}; + +describe('HeroMode', () => { + let fx: ComponentFixture; + + beforeEach(async () => { + TestBed.configureTestingModule({ imports: [HeroMode] }); + TestBed.overrideComponent(HeroMode, { + set: { + providers: HeroMode.providersForTest( + new HeroReplayTransport({ sleep: async () => {} }, async () => recording), + ), + }, + }); + fx = TestBed.createComponent(HeroMode); + fx.detectChanges(); + await fx.whenStable(); + }); + + it('starts in replay mode with the recorded pill and a Take control button', () => { + const el = fx.nativeElement as HTMLElement; + expect(fx.componentInstance.mode()).toBe('replay'); + expect(el.querySelector('[data-hero-pill]')?.textContent).toMatch(/recorded LangGraph run/i); + expect(el.querySelector('button[data-hero-take-control]')).toBeTruthy(); + expect(el.querySelector('chat')).toBeTruthy(); + }); + + it('pointerdown inside the surface takes over: live pill, banner, replay link', () => { + const el = fx.nativeElement as HTMLElement; + el.querySelector('[data-hero-surface]')!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + fx.detectChanges(); + expect(fx.componentInstance.mode()).toBe('live'); + expect(el.querySelector('[data-hero-pill]')?.textContent).toMatch(/Live · LangGraph/); + expect(el.querySelector('[data-hero-banner]')?.textContent).toMatch(/walkthrough was a recording/i); + expect(el.querySelector('button[data-hero-replay]')).toBeTruthy(); + expect(el.querySelector('button[data-hero-take-control]')).toBeNull(); + }); + + it('focusin inside the surface also takes over', () => { + const el = fx.nativeElement as HTMLElement; + el.querySelector('[data-hero-surface]')!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + fx.detectChanges(); + expect(fx.componentInstance.mode()).toBe('live'); + }); + + it('Replay walkthrough returns to replay mode', () => { + const el = fx.nativeElement as HTMLElement; + (el.querySelector('button[data-hero-take-control]') as HTMLButtonElement).click(); + fx.detectChanges(); + (el.querySelector('button[data-hero-replay]') as HTMLButtonElement).click(); + fx.detectChanges(); + expect(fx.componentInstance.mode()).toBe('replay'); + }); + + it('posts frame state through the bridge on mode changes', () => { + const states: string[] = []; + fx.componentInstance.bridge = { postState: (s) => states.push(s), onVisibility: () => () => {} }; + (fx.nativeElement as HTMLElement).querySelector('button[data-hero-take-control]')!.click(); + fx.detectChanges(); + expect(states).toContain('live'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-mode.component.spec.ts` +Expected: FAIL, cannot resolve `./hero-mode.component`. + +- [ ] **Step 3: Write the component** + +```ts +// SPDX-License-Identifier: MIT +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + afterNextRender, + computed, + inject, + signal, + type Provider, +} from '@angular/core'; +import { + ChatComponent, + ChatInterruptPanelComponent, + a2uiBasicCatalog, + type Agent, + type InterruptAction, +} from '@threadplane/chat'; +import { FetchStreamTransport, injectAgent, provideAgent, type LangGraphAgent } from '@threadplane/langgraph'; +import { environment } from '../../environments/environment'; +import { WelcomeSuggestionsComponent } from '../modes/welcome-suggestions.component'; +import { HERO_LIVE_REF, HERO_REPLAY_REF } from './hero-agent-refs'; +import { browserHeroBridge, type HeroBridge } from './hero-bridge'; +import { HeroCursorComponent } from './hero-cursor.component'; +import { HeroRecordingTransport } from './hero-recording.transport'; +import { HeroReplayTransport } from './hero-replay.transport'; +import { HeroScriptRunner, type CursorTarget, type HeroScriptHost } from './hero-script'; + +export type HeroModeKind = 'replay' | 'live'; + +const TYPE_DELAY_MS = 40; +const liveThreadId = signal(null); + +function isRecordMode(): boolean { + if (environment.production || typeof location === 'undefined') return false; + return new URLSearchParams(location.search).get('record') === '1'; +} + +function liveAgentProviders(): Provider[] { + return provideAgent(HERO_LIVE_REF, () => ({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.assistantId, + threadId: liveThreadId, + onThreadId: (id: string) => liveThreadId.set(id), + transport: isRecordMode() + ? new HeroRecordingTransport( + new FetchStreamTransport(environment.langGraphApiUrl, (id) => liveThreadId.set(id)), + ) + : undefined, + })); +} + +@Component({ + selector: 'hero-mode', + standalone: true, + imports: [ChatComponent, ChatInterruptPanelComponent, WelcomeSuggestionsComponent, HeroCursorComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: HeroMode.providersForTest(), + template: ` +
+
+ demo.threadplane.ai + + + @if (mode() === 'live') { Live · LangGraph · new thread } @else { Replaying a recorded LangGraph run } + +
+ +
+ @if (mode() === 'live') { +

+ You are live on a new LangGraph thread. The walkthrough was a recording. + +

+ } + @if (activeAgent().interrupt && activeAgent().interrupt!()) { +
+ +
+ } + + @if (mode() === 'live') { + + } + + +
+ + @if (mode() === 'replay') { + + } +
+ `, + styles: [` + :host { display: block; height: 100%; } + .hero { position: relative; display: flex; flex-direction: column; height: 100%; } + .hero__bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 6px 12px; font: 12px/1.3 system-ui, sans-serif; border-bottom: 1px solid rgba(128,128,128,.25); } + .hero__url { opacity: .6; } + .hero__pill { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 999px; + border: 1px solid #b5731a; color: #b5731a; } + .hero__pill[data-live="true"] { border-color: #2f6f4f; color: #2f6f4f; } + .hero__dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } + .hero__surface { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; } + .hero__surface > chat { flex: 1; min-height: 0; } + .hero__interrupt { padding: 8px 12px 0; } + .hero__banner { margin: 0; padding: 8px 12px; font: 13px/1.4 system-ui, sans-serif; + background: rgba(47,111,79,.08); border-bottom: 1px solid rgba(47,111,79,.3); } + .hero__link { margin-left: 8px; background: none; border: 0; padding: 0; color: inherit; + text-decoration: underline; cursor: pointer; font: inherit; } + .hero__take { position: absolute; left: 50%; bottom: 72px; transform: translateX(-50%); z-index: 10; + padding: 8px 14px; border-radius: 999px; border: 0; background: #111; color: #fff; + font: 600 13px/1 system-ui, sans-serif; cursor: pointer; box-shadow: 0 6px 18px rgba(0,0,0,.25); } + .hero__take:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } + `], +}) +export class HeroMode implements HeroScriptHost { + /** + * Static so the spec can substitute a preloaded replay transport. The + * decorator uses the no-arg form. + */ + static providersForTest(replay: HeroReplayTransport = new HeroReplayTransport()): Provider[] { + return [ + { provide: HeroReplayTransport, useValue: replay }, + ...provideAgent(HERO_REPLAY_REF, () => ({ assistantId: 'hero-replay', transport: inject(HeroReplayTransport) })), + ...liveAgentProviders(), + ]; + } + + private readonly host = inject(ElementRef); + private readonly destroyRef = inject(DestroyRef); + private readonly replayTransport = inject(HeroReplayTransport); + private readonly replayAgent = injectAgent(HERO_REPLAY_REF) as LangGraphAgent; + private readonly liveAgent = injectAgent(HERO_LIVE_REF) as LangGraphAgent; + + readonly mode = signal(isRecordMode() ? 'live' : 'replay'); + readonly activeAgent = computed(() => (this.mode() === 'live' ? this.liveAgent : this.replayAgent)); + protected readonly catalog = a2uiBasicCatalog(); + + readonly cursorX = signal(0); + readonly cursorY = signal(0); + readonly cursorVisible = signal(false); + readonly cursorPressed = signal(false); + + /** Replaced by the spec; browser bridge by default. */ + bridge: HeroBridge = typeof window === 'undefined' + ? { postState: () => undefined, onVisibility: () => () => undefined } + : browserHeroBridge(); + + readonly reducedMotion = + typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; + + private runner: HeroScriptRunner | null = null; + private visible = false; + + constructor() { + afterNextRender(() => void this.boot()); + this.destroyRef.onDestroy(() => this.runner?.stop()); + } + + private async boot(): Promise { + const off = this.bridge.onVisibility((v) => this.setVisible(v)); + this.destroyRef.onDestroy(off); + const onDocVis = () => this.setVisible(this.visible && !document.hidden); + document.addEventListener('visibilitychange', onDocVis); + this.destroyRef.onDestroy(() => document.removeEventListener('visibilitychange', onDocVis)); + + try { + await this.replayTransport.ready(); + } catch (err) { + console.error('hero recording unavailable; staying live', err); + this.mode.set('live'); + this.bridge.postState('ready'); + return; + } + this.bridge.postState('ready'); + // Not embedded (opened directly, or in record mode): run as if visible. + if (window.parent === window) this.setVisible(true); + this.startRunner(); + } + + private startRunner(): void { + this.runner?.stop(); + this.runner = new HeroScriptRunner(this); + this.runner.setVisible(this.visible); + this.bridge.postState('scripted'); + void this.runner.loop(); + } + + private setVisible(v: boolean): void { + this.visible = v; + this.runner?.setVisible(v); + if (this.mode() === 'replay' && this.runner) this.bridge.postState(v ? 'scripted' : 'paused'); + } + + // ── takeover / replay ───────────────────────────────────────────────── + + takeControl(): void { + if (this.mode() === 'live') return; + this.runner?.stop(); + this.runner = null; + this.cursorVisible.set(false); + this.mode.set('live'); + this.bridge.postState('live'); + } + + replay(event?: Event): void { + event?.stopPropagation(); + this.mode.set('replay'); + this.bridge.postState('replay'); + void this.restartReplay().then(() => this.startRunner()); + } + + protected sendLive(text: string): void { + void this.liveAgent.submit({ message: text }); + } + + protected async onInterruptAction(action: InterruptAction): Promise { + const agent = this.activeAgent(); + if (!agent.interrupt?.()) return; + const resume = action === 'ignore' ? 'denied' : 'approved'; + await agent.submit(null as never, { command: { resume } } as never); + } + + // ── HeroScriptHost ──────────────────────────────────────────────────── + + async typeInto(text: string): Promise { + const ta = this.textarea(); + if (!ta) return; + const set = (value: string) => { + ta.value = value; + ta.dispatchEvent(new Event('input', { bubbles: true })); + }; + if (this.reducedMotion) { set(text); return; } + for (let i = 1; i <= text.length; i++) { + set(text.slice(0, i)); + await sleep(TYPE_DELAY_MS); + } + } + + async send(): Promise { + await this.press(this.sendButton()); + } + + async acceptInterrupt(): Promise { + await this.press(this.acceptButton()); + } + + async moveCursor(target: CursorTarget): Promise { + const el = target === 'composer' ? this.textarea() : target === 'send' ? this.sendButton() : this.acceptButton(); + if (!el) return; + const surface = this.surface().getBoundingClientRect(); + const r = el.getBoundingClientRect(); + this.cursorX.set(Math.round(r.left - surface.left + Math.min(r.width / 2, 40))); + this.cursorY.set(Math.round(r.top - surface.top + r.height / 2)); + this.cursorVisible.set(true); + await sleep(this.reducedMotion ? 0 : 650); + } + + hasInterrupt(): boolean { + return !!this.activeAgent().interrupt?.(); + } + + isRunning(): boolean { + return this.activeAgent().isLoading(); + } + + async restartReplay(): Promise { + this.replayTransport.reset(); + this.replayAgent.switchThread(null); + } + + // ── DOM helpers (scoped to this component's own surface) ────────────── + + private surface(): HTMLElement { + return this.host.nativeElement.querySelector('[data-hero-surface]') as HTMLElement; + } + private textarea(): HTMLTextAreaElement | null { + return this.surface().querySelector('textarea[aria-label="Type a message"]'); + } + private sendButton(): HTMLButtonElement | null { + return this.surface().querySelector('button[aria-label="Send message"]'); + } + private acceptButton(): HTMLButtonElement | null { + const buttons = Array.from(this.surface().querySelectorAll('chat-interrupt-panel button')); + return buttons.find((b) => /accept/i.test(b.textContent ?? '')) ?? null; + } + private async press(el: HTMLButtonElement | null): Promise { + if (!el) return; + this.cursorPressed.set(true); + await sleep(this.reducedMotion ? 0 : 120); + el.click(); + this.cursorPressed.set(false); + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} +``` + +Notes for the implementer: +- `FetchStreamTransport` must be exported from `@threadplane/langgraph`; check `libs/langgraph/src/public-api.ts`. If it is not, export it there (one line) and regenerate api docs (`npx nx run website:generate-api-docs` or the target named in `apps/website/project.json`; memory says new exports need it). +- `providers: HeroMode.providersForTest()` in a decorator referencing the class works because decorators evaluate after the class body; if the TS config complains, hoist the provider array into a module-level `function heroProviders(replay?)` and reference that from both places. +- `switchThread(null)` resets the adapter's derived state and manager thread. If, when testing manually in Task 10, messages from the previous pass are still visible after Replay, wrap the `` in `@if (generation(); as g)` keyed by a counter incremented in `restartReplay()` so the composition remounts. +- The `(pointerdown)` on the surface fires before the click; a click on Accept during replay therefore lands on the live surface, which is acceptable. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-mode.component.spec.ts` +Expected: PASS (5 tests). If `afterNextRender` does not fire in TestBed, that is fine: the spec only exercises mode switching and the bridge. + +- [ ] **Step 5: Run the whole demo unit suite and lint** + +Run: `npx nx test examples-chat-angular && npx nx lint examples-chat-angular` +Expected: both green. Fix lint errors (not pre-existing warnings). + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/hero/hero-mode.component.ts examples/chat/angular/src/app/hero/hero-mode.component.spec.ts +git commit -m "feat(examples/chat): HeroMode route — replay agent, scripted cursor, takeover to live LangGraph" +``` + +--- + +### Task 9: Record the fixture with aimock (no API key) + +**Files:** +- Create: `examples/chat/angular/e2e/record-hero.config.ts` +- Create: `examples/chat/angular/e2e/record-hero-fixture.record.ts` +- Create: `examples/chat/angular/public/hero-replay.json` (generated) +- Test: `examples/chat/angular/src/app/hero/hero-replay.fixture.spec.ts` + +- [ ] **Step 1: Write the fixture spec (fails until the fixture exists)** + +```ts +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { validateHeroRecording } from './hero-recording.types'; + +const FIXTURE = resolve(__dirname, '../../../public/hero-replay.json'); + +describe('hero-replay.json', () => { + const rec = validateHeroRecording(JSON.parse(readFileSync(FIXTURE, 'utf8'))); + + it('has the prompt, resume and genui runs in order', () => { + expect(rec.runs.slice(0, 3).map((r) => r.label)).toEqual(['prompt', 'resume', 'genui']); + }); + + it('the prompt run pauses on an interrupt', () => { + const types = rec.runs[0].events.map((e) => String(e.event.type)); + expect(types.some((t) => t === 'interrupt' || t === 'interrupts' || t.startsWith('values'))).toBe(true); + expect(JSON.stringify(rec.runs[0].events)).toMatch(/approval_request/); + }); + + it('the genui run carries an A2UI payload', () => { + expect(JSON.stringify(rec.runs[2].events)).toMatch(/a2ui/i); + }); + + it('never contains an API key or bearer token', () => { + expect(JSON.stringify(rec)).not.toMatch(/sk-[A-Za-z0-9]{10,}|Bearer /); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-replay.fixture.spec.ts` +Expected: FAIL (ENOENT). + +- [ ] **Step 3: Write the Playwright record config** + +```ts +// SPDX-License-Identifier: MIT +/** + * Records the hero walkthrough fixture from `/hero?record=1` against the + * aimock-backed backend, so no API key is needed and the take is deterministic. + * `testMatch` picks up only `*.record.ts`, so CI never runs this. + * + * npx playwright test --config examples/chat/angular/e2e/record-hero.config.ts record-hero-fixture + */ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: '**/record-hero-fixture.record.ts', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: 'list', + timeout: 240_000, + use: { baseURL: 'http://localhost:4200', viewport: { width: 1200, height: 720 } }, + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + outputDir: './.record-output', +}); +``` + +- [ ] **Step 4: Write the record script** + +```ts +// SPDX-License-Identifier: MIT +/** + * NOT a test. Drives /hero?record=1 (the scripted walkthrough runs against the + * live agent wrapped in HeroRecordingTransport) and writes the captured runs + * to public/hero-replay.json. Run through record-hero.config.ts. + */ +import { test, expect } from '@playwright/test'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const OUT = resolve(__dirname, '../public/hero-replay.json'); + +test('record hero walkthrough fixture', async ({ page }) => { + await page.goto('/hero?record=1'); + // The script types, sends, accepts, sends again. Wait for three finished runs. + await expect + .poll(async () => page.evaluate(() => window.__heroRecording?.runs.length ?? 0), { timeout: 200_000 }) + .toBe(3); + // Let the last run drain. + await expect + .poll(async () => page.evaluate(() => { + const runs = window.__heroRecording?.runs ?? []; + const last = runs[runs.length - 1]; + return last?.events.length ?? 0; + }), { timeout: 60_000 }) + .toBeGreaterThan(0); + await page.waitForTimeout(3000); + const rec = await page.evaluate(() => window.__heroRecording); + expect(rec?.runs.map((r) => r.label)).toEqual(['prompt', 'resume', 'genui']); + writeFileSync(OUT, JSON.stringify(rec, null, 2) + '\n'); + console.log(`wrote ${OUT}`); +}); +``` + +Add the `Window.__heroRecording` type to the e2e tsconfig scope by importing the type: at the top add `import type {} from '../src/app/hero/hero-recording.transport';` (a side-effect-free type import that brings the `declare global` into scope). If the e2e tsconfig excludes `src`, instead declare locally: + +```ts +declare global { interface Window { __heroRecording?: { runs: { label: string; events: unknown[] }[] } } } +``` + +- [ ] **Step 5: Record** + +The aimock fixtures for both prompts already exist (`e2e/fixtures/interrupt-approval.json`, `e2e/fixtures/contact-form.json`) and the global setup loads every fixture in the directory. Free ports 2024 and 4200 first (memory: stale serves silently serve old bundles). + +```bash +npx playwright test --config examples/chat/angular/e2e/record-hero.config.ts record-hero-fixture +``` + +Expected: `wrote …/public/hero-replay.json`. Inspect the file: three runs, the first ending near an `approval_request`, the third containing `a2ui`. + +If the script never reaches three runs, open `http://localhost:4200/hero?record=1` in a browser while the global setup servers are running (run the command with `PWDEBUG=1`) and watch which step stalls; the usual causes are the Accept button text (Task 8 `acceptButton()`), or the prompt text drifting from the fixture. + +- [ ] **Step 6: Run the fixture spec** + +Run: `npx nx test examples-chat-angular -- src/app/hero/hero-replay.fixture.spec.ts` +Expected: PASS (4 tests). + +- [ ] **Step 7: Commit** + +```bash +git add examples/chat/angular/e2e/record-hero.config.ts examples/chat/angular/e2e/record-hero-fixture.record.ts examples/chat/angular/public/hero-replay.json examples/chat/angular/src/app/hero/hero-replay.fixture.spec.ts +git commit -m "feat(examples/chat): record and commit the hero walkthrough fixture" +``` + +--- + +### Task 10: Manual check of the replay in a browser + +- [ ] **Step 1: Serve the demo** + +Use the Browser pane (`preview_start` with a `.claude/launch.json` entry `examples-chat-angular` running `npx nx serve examples-chat-angular`, port 4200) and open `http://localhost:4200/hero`. + +- [ ] **Step 2: Verify, and fix anything that fails** + +Check, in order: +1. The pill reads "Replaying a recorded LangGraph run" and a cursor appears at the composer. +2. The first prompt types in, Send is pressed, tokens stream, tool progress appears, then the interrupt panel renders. +3. The cursor moves to Accept, presses it, the run resumes and finishes. +4. The second prompt types, sends, and an A2UI contact form renders. +5. After the hold, the transcript clears and the walkthrough restarts. If old messages remain, apply the `@if (generation())` remount described in Task 8. +6. Clicking anywhere, or tabbing into the composer, flips the pill to "Live · LangGraph · new thread", shows the banner and suggestion chips, and the cursor disappears. Typing a message sends to the live backend (requires the local LangGraph server on 2024 with an API key, or accept an error here and verify live on the deployed preview in Task 13). +7. "Replay walkthrough" returns to replay and restarts. +8. With the OS reduced-motion setting on, typing is instant and the cursor jumps. + +- [ ] **Step 3: Commit any fixes** + +```bash +git add -A examples/chat/angular/src/app/hero +git commit -m "fix(examples/chat): hero replay polish from manual check" +``` + +--- + +### Task 11: e2e for `/hero` + +**Files:** +- Create: `examples/chat/angular/e2e/hero.spec.ts` + +- [ ] **Step 1: Write the spec** + +```ts +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { attachBrowserHygiene } from './test-helpers'; + +test.describe('hero walkthrough', () => { + test('replays to the interrupt, takes over to live, and can replay again', async ({ page }) => { + attachBrowserHygiene(page); + await page.goto('/hero'); + + const pill = page.locator('[data-hero-pill]'); + await expect(pill).toContainText(/recorded LangGraph run/i); + + // The script types and sends; the replayed run pauses on the interrupt. + await expect(page.locator('chat-interrupt-panel')).toBeAttached({ timeout: 60_000 }); + + // Takeover via the pill. + await page.getByRole('button', { name: /take control/i }).click(); + await expect(pill).toContainText(/Live · LangGraph/); + await expect(page.locator('[data-hero-banner]')).toContainText(/walkthrough was a recording/i); + await expect(page.locator('hero-cursor')).toHaveAttribute('data-visible', 'false'); + + // Back to replay. + await page.getByRole('button', { name: /replay walkthrough/i }).click(); + await expect(pill).toContainText(/recorded LangGraph run/i); + }); + + test('focusing the composer takes over', async ({ page }) => { + attachBrowserHygiene(page); + await page.goto('/hero'); + await page.locator('[data-hero-surface] textarea').focus(); + await expect(page.locator('[data-hero-pill]')).toContainText(/Live · LangGraph/); + }); +}); +``` + +- [ ] **Step 2: Run it** + +Run: `npx nx e2e examples-chat-angular -- hero.spec.ts` +Expected: PASS (2 tests). The global setup still boots aimock and langgraph; the replay itself needs neither. + +- [ ] **Step 3: Commit** + +```bash +git add examples/chat/angular/e2e/hero.spec.ts +git commit -m "test(examples/chat): e2e for the hero walkthrough and takeover" +``` + +--- + +### Task 12: Production build and bundle budget + +- [ ] **Step 1: Build** + +Run: `npx nx build examples-chat-angular --configuration=production` +Expected: succeeds; the initial bundle stays under the 1.6 MB warning. `hero-mode.component` should appear as its own lazy chunk in the output listing. If the initial bundle grew, confirm nothing in `app.routes.ts` imports `./hero/*` eagerly. + +- [ ] **Step 2: Confirm the fixture ships as a static asset** + +Run: `ls dist/examples/chat/angular/browser/hero-replay.json` +Expected: the file exists (the `public/**` assets glob copies it). + +- [ ] **Step 3: Commit nothing; note the chunk size in the PR description** + +--- + +### Task 13: Poster capture and deployed check + +**Files:** +- Create: `examples/chat/angular/e2e/record-hero-poster.record.ts` +- Create: `apps/website/public/screenshots/hero-walkthrough-poster.webp` + +- [ ] **Step 1: Write the poster script** + +```ts +// SPDX-License-Identifier: MIT +/** + * NOT a test. Captures the hero's first replay frame as the website's + * server-rendered poster (1200x720, webp). Run through record-hero.config.ts + * with the file name as a filter. + */ +import { test } from '@playwright/test'; +import { resolve } from 'node:path'; +import sharp from 'sharp'; + +const OUT = resolve(__dirname, '../../../../apps/website/public/screenshots/hero-walkthrough-poster.webp'); + +test('capture hero poster', async ({ page }) => { + await page.goto('/hero'); + // First frame: prompt typed, first tokens streaming. + await page.waitForSelector('[data-hero-surface] .chat-message, [data-hero-surface] chat-message', { timeout: 60_000 }).catch(() => undefined); + await page.waitForTimeout(1500); + const png = await page.screenshot({ type: 'png', fullPage: false }); + await sharp(png).webp({ quality: 82 }).toFile(OUT); + console.log(`wrote ${OUT}`); +}); +``` + +Widen `testMatch` in `record-hero.config.ts` to `'**/record-hero-*.record.ts'` so both record scripts share it. `sharp` is present in `node_modules` (Next depends on it); if the import fails under the e2e tsconfig, use `await import('sharp')`. + +- [ ] **Step 2: Capture** + +```bash +npx playwright test --config examples/chat/angular/e2e/record-hero.config.ts record-hero-poster +``` + +Expected: `apps/website/public/screenshots/hero-walkthrough-poster.webp` exists, 1200×720, under 150 KB. Open it and confirm it shows the pill, a user message, and streaming text. + +- [ ] **Step 3: Commit** + +```bash +git add examples/chat/angular/e2e/record-hero-poster.record.ts examples/chat/angular/e2e/record-hero.config.ts apps/website/public/screenshots/hero-walkthrough-poster.webp +git commit -m "feat(website): hero walkthrough poster captured from /hero" +``` + +- [ ] **Step 4: After merge to main, verify the deployed route** + +The `demo-deploy` job in `.github/workflows/ci.yml` promotes `examples/chat` to demo.threadplane.ai on push to main. After it runs, open `https://demo.threadplane.ai/hero` and repeat Task 10 steps 1 to 7, including a real live message after takeover. Record the result in the PR that ships the website hero (Plan B, Task 14). + +--- + +## Self-review against spec §4.3 + +- Top-level route, own agents via two refs: Tasks 7, 8. +- HeroReplayTransport with `/hero-replay.json`, clamped pacing, `reset()`, no-op extras: Task 2. +- Three-run recording via `HeroRecordingTransport` and a `.record.ts` script, fixture spec with interrupt and A2UI assertions: Tasks 3, 9. +- Script runner with host interface, clock, pause/resume, reduced motion: Tasks 4, 8. +- Status pill, takeover on pill / pointerdown / focusin, banner, suggestion chips, replay link: Task 8, verified in Tasks 10, 11. +- Bridge with allowlist: Task 5. +- Poster for the website: Task 13. + +Not covered here by design: the website side (`HeroDemo`), which is Plan B. diff --git a/docs/superpowers/plans/2026-09-02-homepage-rebuild.md b/docs/superpowers/plans/2026-09-02-homepage-rebuild.md new file mode 100644 index 000000000..af7800056 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-homepage-rebuild.md @@ -0,0 +1,2604 @@ +# Homepage Rebuild Implementation Plan (website) + +> **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:** Rebuild the threadplane.ai homepage around the exact category, a live take-over-able hero demo, and an install dialog that leads to a no-backend first success, while keeping the lower sections' components, CSS, and analytics ids. + +**Architecture:** Copy and code snippets move into `positioning.ts` as the single source. New client components (`HeroDemo`, `InstallDialog`, `RuntimeParity` toggle, `CodingAgentQuickstart`, Yes Wall expander) sit on the existing `Section`/`Container`/`Button`/`BrowserFrame`/`TabGroup` primitives and the unlayered `landing.css`/`ui.css` conventions. Server components pre-highlight code with `HighlightedCode`. The hero iframe is the `/hero` route from the companion plan `2026-09-02-hero-demo-route.md`. + +**Tech Stack:** Next.js App Router (React, server + client components), Vitest + `@testing-library/react` (no jest-dom, raw DOM assertions, `vi.mock` UI primitives), Playwright (`apps/website/e2e`), shiki, PostHog via `track`/`trackCtaClick`. + +**Spec:** `docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md`. + +**Branch:** `blove/homepage-rebuild-spec` (from `origin/main`) or a branch from it. `npm ci` once per fresh worktree. If `apps/website/.next` exists from a dev run, `rm -rf apps/website/.next` before a production build. + +**Test conventions to copy in every new website spec** (from `Hero.spec.tsx`): `// @vitest-environment jsdom` at the top, `import React from 'react'`, `vi.mock('../../lib/analytics/client', () => ({ track: trackMock, trackCtaClick: trackCtaClickMock }))` with `vi.hoisted` mocks, and `vi.mock` for `../ui/Container`, `../ui/Section`, `../ui/Eyebrow`, `../ui/BrowserFrame`, `../ui/Button` exactly as `Hero.spec.tsx` lines 15–37 do. Assertions use `.textContent`, `.getAttribute()`, `toBeTruthy()`. + +--- + +## File map + +| Path | Change | Responsibility | +|---|---|---| +| `apps/website/src/lib/positioning.ts` | modify | hero copy, `INSTALL_OPTIONS`, `COMPONENT_SNIPPET`, parity snippets, `CODING_AGENT_PROMPT`, `HOME_TITLE`, `HOME_DESCRIPTION` | +| `apps/website/src/lib/positioning.spec.ts` | create | drift guards: packages exist, snippets parse, license word matches manifest | +| `apps/website/src/lib/site-metadata.ts` + `.spec.ts` | modify | re-export new constants; update assertions | +| `apps/website/src/lib/analytics/events.ts` | modify | new `CtaId` members | +| `docs/gtm/taxonomy.md` | modify | document new ids | +| `apps/website/src/components/ui/Modal.tsx` + `Modal.spec.tsx` | create | focus trap, Esc, scroll lock, backdrop close (extracted from DemoModal) | +| `apps/website/src/components/landing/DemoModal.tsx` | modify | use `Modal` | +| `apps/website/src/components/landing/InstallDialog.tsx` + spec | create | three-step install dialog | +| `apps/website/src/components/landing/HeroDemo.tsx` + spec | create | poster → iframe state machine, bridge, events | +| `apps/website/src/components/landing/Hero.tsx` + spec | rewrite | stacked hero | +| `apps/website/src/components/landing/LogoRibbon.tsx` + spec | rewrite | three labeled compatibility groups | +| `apps/website/src/components/landing/RuntimeParity.tsx`, `RuntimeParityToggle.tsx` + spec | create | parity section (server) + toggle (client) | +| `apps/website/src/components/landing/ThreeSteps.tsx` | create | three-step mechanism (server) | +| `apps/website/src/components/landing/CodingAgentQuickstart.tsx` + spec | create | prompt + links | +| `apps/website/src/components/landing/YesWall.tsx` + spec | modify | 8 shown, expand in place | +| `apps/website/src/components/landing/ScopeTable.tsx` | create | why-Threadplane table | +| `apps/website/src/components/landing/PilotBlock.tsx` | modify | heading, copy, tracked CTA | +| `apps/website/src/components/landing/HomeFAQ.tsx` | modify | twelve intent questions | +| `apps/website/src/lib/section-media.ts` | modify | `persist` and `test` keys | +| `apps/website/src/app/page.tsx` | modify | order + metadata | +| `apps/website/src/styles/landing.css`, `ui.css` | modify | new rules | +| `apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx` | create | no-backend quickstart | +| `apps/website/src/lib/docs-config.ts` | modify | nav entry | +| `apps/website/e2e/website.spec.ts`, `apps/website/e2e/home-hero.spec.ts` | modify/create | e2e | + +--- + +### Task 1: Positioning constants and drift guards + +**Files:** +- Modify: `apps/website/src/lib/positioning.ts` +- Create: `apps/website/src/lib/positioning.spec.ts` +- Modify: `apps/website/src/lib/site-metadata.ts`, `apps/website/src/lib/site-metadata.spec.ts` + +- [ ] **Step 1: Write the failing drift spec** + +```ts +// @vitest-environment node +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; +import { + CODING_AGENT_PROMPT, + COMPONENT_SNIPPET, + HERO_EYEBROW, + HERO_H1, + HERO_SUBHEAD, + HERO_TRUST_LINE, + HOME_DESCRIPTION, + HOME_TITLE, + INSTALL_OPTIONS, + PARITY_SNIPPETS, +} from './positioning'; +import { resolveWebsiteDir } from './website-dir'; + +const repoRoot = path.resolve(resolveWebsiteDir(), '..', '..'); +const libsDir = path.join(repoRoot, 'libs'); + +function readPkg(dir: string): { name: string; license?: string; peerDependencies?: Record } { + return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); +} + +const workspacePkgs = fs + .readdirSync(libsDir) + .filter((d) => fs.existsSync(path.join(libsDir, d, 'package.json'))) + .map((d) => readPkg(path.join(libsDir, d))); + +function parses(code: string): boolean { + const sf = ts.createSourceFile('x.ts', code, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + return (sf as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics.length === 0; +} + +describe('positioning: hero copy', () => { + it('names the exact category in eyebrow, H1, title and description', () => { + expect(HERO_EYEBROW).toBe('Open-source · Angular · LangGraph & AG-UI'); + expect(HERO_H1).toBe('The AI agent UI framework for Angular.'); + expect(HERO_SUBHEAD).toBe('Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.'); + expect(HOME_TITLE).toBe('Threadplane — Angular AI Agent UI Framework'); + expect(HOME_DESCRIPTION).toBe( + 'Open-source Angular AI agent UI framework for LangGraph and AG-UI: chat, durable threads, human approvals, and generative UI with Signals and DI.', + ); + expect(HOME_DESCRIPTION.length).toBeLessThanOrEqual(160); + }); + + it('trust line license word matches the chat package manifest', () => { + const chat = workspacePkgs.find((p) => p.name === '@threadplane/chat'); + expect(chat?.license).toBe('MIT'); + expect(HERO_TRUST_LINE).toContain(chat!.license!); + expect(HERO_TRUST_LINE).toBe('MIT · Angular 20–22 · no account, no cloud'); + }); +}); + +describe('positioning: install options', () => { + it('has fake, langgraph and ag_ui variants in that order', () => { + expect(INSTALL_OPTIONS.map((o) => o.key)).toEqual(['fake', 'langgraph', 'ag_ui']); + }); + + it('every @threadplane package in every command exists in libs/*', () => { + const names = new Set(workspacePkgs.map((p) => p.name)); + for (const opt of INSTALL_OPTIONS) { + const pkgs = opt.command.replace(/^npm install\s+/, '').split(/\s+/); + for (const pkg of pkgs.filter((p) => p.startsWith('@threadplane/'))) { + expect(names.has(pkg), `${opt.key}: ${pkg}`).toBe(true); + } + } + }); + + it('every non-Threadplane package in a command is a declared peer of a Threadplane package in it', () => { + for (const opt of INSTALL_OPTIONS) { + const pkgs = opt.command.replace(/^npm install\s+/, '').split(/\s+/); + const ours = pkgs.filter((p) => p.startsWith('@threadplane/')); + const peers = new Set( + ours.flatMap((n) => Object.keys(workspacePkgs.find((p) => p.name === n)?.peerDependencies ?? {})), + ); + for (const pkg of pkgs.filter((p) => !p.startsWith('@threadplane/'))) { + expect(peers.has(pkg), `${opt.key}: ${pkg} is not a peer of ${ours.join(', ')}`).toBe(true); + } + } + }); + + it('snippets parse as TypeScript', () => { + expect(parses(COMPONENT_SNIPPET)).toBe(true); + for (const opt of INSTALL_OPTIONS) expect(parses(opt.providerSnippet), opt.key).toBe(true); + for (const s of Object.values(PARITY_SNIPPETS)) expect(parses(s)).toBe(true); + }); + + it('quickstart hrefs point at docs routes', () => { + for (const opt of INSTALL_OPTIONS) expect(opt.quickstartHref).toMatch(/^\/docs\//); + }); +}); + +describe('positioning: coding-agent prompt', () => { + it('references the public agent context and the fake-agent path', () => { + expect(CODING_AGENT_PROMPT).toContain('https://threadplane.ai/AGENTS.md'); + expect(CODING_AGENT_PROMPT).toContain('provideFakeAgent()'); + expect(CODING_AGENT_PROMPT).not.toMatch(/api[_ -]?key/i); + }); +}); +``` + +`typescript` is a workspace dev dependency (Next and Angular both need it), so the import resolves. `resolveWebsiteDir` already exists (`site-metadata.spec.ts` imports it). + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx nx test website -- src/lib/positioning.spec.ts` +Expected: FAIL, named exports missing. + +- [ ] **Step 3: Rewrite `positioning.ts`** + +Replace the file's contents with: + +```ts +// ── Homepage copy (spec 2026-09-02-homepage-rebuild-design.md §4.1) ────────── +export const HERO_EYEBROW = 'Open-source · Angular · LangGraph & AG-UI'; +export const HERO_H1 = 'The AI agent UI framework for Angular.'; +export const HERO_SUBHEAD = + 'Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.'; +export const HERO_PRIMARY_LABEL = 'Install Threadplane'; +export const HERO_SECONDARY_LABEL = 'See it running in Cockpit →'; +export const HERO_SECONDARY_HREF = 'https://cockpit.threadplane.ai'; + +/** Kept for layout.tsx default title and the OG image alt. */ +export const PRIMARY_TAGLINE = 'Threadplane — Angular AI Agent UI Framework'; +export const HOME_TITLE = PRIMARY_TAGLINE; +export const HOME_DESCRIPTION = + 'Open-source Angular AI agent UI framework for LangGraph and AG-UI: chat, durable threads, human approvals, and generative UI with Signals and DI.'; +/** Longer form used by layout.tsx OG/Twitter defaults and the About page. */ +export const LONG_SUBHEAD = + 'Threadplane is the open-source Angular AI agent UI framework: signal-native chat, durable threads, human approvals, tool progress, subagents, and generative UI for LangGraph and AG-UI backends — without replacing your backend or design system.'; + +// ── Trust line (values verified by positioning.spec.ts + angular-support-copy.spec.ts) ── +import { WEBSITE_SUPPORTED_ANGULAR_MAJORS } from '../components/pricing/angular-support.mjs'; + +export function formatAngularRange(majors: readonly number[]): string { + const sorted = [...majors].sort((a, b) => a - b); + return sorted.length > 1 ? `Angular ${sorted[0]}–${sorted[sorted.length - 1]}` : `Angular ${sorted[0]}`; +} +export const HERO_TRUST_LINE = `MIT · ${formatAngularRange(WEBSITE_SUPPORTED_ANGULAR_MAJORS)} · no account, no cloud`; + +// ── Install variants: the ONE place install commands live on the website ───── +export type InstallVariant = 'fake' | 'langgraph' | 'ag_ui'; + +export interface InstallOption { + readonly key: InstallVariant; + readonly label: string; + readonly description: string; + readonly command: string; + readonly peersNote: string; + readonly providerSnippet: string; + readonly quickstartHref: string; +} + +export const COMPONENT_SNIPPET = `import { Component } from '@angular/core'; +import { injectAgent } from '@threadplane/langgraph'; +import { ChatComponent } from '@threadplane/chat'; + +@Component({ + imports: [ChatComponent], + template: \`\`, +}) +export class SupportAgentComponent { + protected readonly agent = injectAgent(); +}`; + +export const INSTALL_OPTIONS: readonly InstallOption[] = [ + { + key: 'fake', + label: 'Try without a backend', + description: 'Runs a fake agent in the browser. Swap in a real adapter when the UI works.', + command: 'npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked', + peersNote: 'Angular 20–22 · the LangGraph SDK and marked are peers of the adapter', + providerSnippet: `import { ApplicationConfig } from '@angular/core'; +import { provideFakeAgent } from '@threadplane/langgraph'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideFakeAgent({ tokens: ['Hello', ' from', ' Threadplane'] }), + ], +};`, + quickstartHref: '/docs/chat/getting-started/try-without-a-backend', + }, + { + key: 'langgraph', + label: 'LangGraph', + description: 'Connect a LangGraph Platform or langgraph dev server.', + command: 'npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked', + peersNote: 'Angular 20–22 · the LangGraph SDK and marked are peers of the adapter', + providerSnippet: `import { ApplicationConfig } from '@angular/core'; +import { provideAgent } from '@threadplane/langgraph'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgent({ apiUrl: 'http://localhost:2024', assistantId: 'agent' }), + ], +};`, + quickstartHref: '/docs/langgraph/getting-started/quickstart', + }, + { + key: 'ag_ui', + label: 'AG-UI', + description: 'Connect any AG-UI-compatible endpoint.', + command: 'npm install @threadplane/chat @threadplane/ag-ui @ag-ui/client @ag-ui/core marked', + peersNote: 'Angular 20–22 · the AG-UI client and marked are peers of the adapter', + providerSnippet: `import { ApplicationConfig } from '@angular/core'; +import { provideAgent } from '@threadplane/ag-ui'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgent({ url: 'http://localhost:8000/agent' }), + ], +};`, + quickstartHref: '/docs/ag-ui/getting-started/quickstart', + }, +]; + +/** Runtime-parity section: only the config pane differs. */ +export const PARITY_SNIPPETS = { + langgraph: INSTALL_OPTIONS[1].providerSnippet, + ag_ui: INSTALL_OPTIONS[2].providerSnippet, +} as const; + +// ── Coding-agent quickstart prompt ─────────────────────────────────────────── +export const CODING_AGENT_PROMPT = `Add Threadplane to this Angular application. + +1. Read https://threadplane.ai/AGENTS.md and the current Threadplane quickstart. +2. Inspect this repository's Angular version, application configuration, design + system, test runner, and existing agent/backend code. +3. Begin with Threadplane's provideFakeAgent() path so the UI can be verified + without a server or LLM. +4. Render the smallest accessible experience using the app's existing + layout and styles. +5. Add a focused test for the integration. +6. After the fake path passes, explain the exact configuration needed for + either LangGraph or AG-UI. Do not invent credentials, endpoint URLs, or + backend capabilities. +7. Run the repository's relevant lint, test, and build commands and report + every changed file.`; + +// ── OG image + keywords (unchanged) ───────────────────────────────────────── +export interface PositioningProofPoint { + readonly label: string; + readonly href: string; +} + +export const POSITIONING_PROOF_POINTS: readonly PositioningProofPoint[] = [ + { label: 'LangGraph + AG-UI', href: '/docs/choosing-an-adapter' }, + { label: 'Durable threads', href: '/docs/langgraph/guides/persistence' }, + { label: 'Interrupts', href: '/docs/langgraph/guides/interrupts' }, + { label: 'Subagents', href: '/docs/langgraph/guides/subgraphs' }, + { label: 'Planning + memory', href: '/docs/langgraph/guides/memory' }, + { label: 'json-render + A2UI', href: '/docs/render/concepts/json-render-vs-a2ui' }, +] as const; +export const SHORT_POSITIONING_DESCRIPTION = HOME_DESCRIPTION; +export const DEFAULT_META_DESCRIPTION = SHORT_POSITIONING_DESCRIPTION; +``` + +Then: +- Verify the AG-UI `provideAgent` option name (`url`) against `libs/ag-ui/src/lib/provide-agent.ts` and the AG-UI quickstart MDX; use whatever the real config key is. +- Verify the LangGraph install peers against `libs/langgraph/package.json` `peerDependencies` (the spec test enforces it). +- Delete `HERO_CAPABILITIES` and `HeroCapability` (Task 6 removes their only consumer; do the delete in Task 6 if the build complains before then). +- In `site-metadata.ts`, add the new names to the re-export list: `HERO_EYEBROW, HERO_H1, HERO_SUBHEAD, HERO_TRUST_LINE, HOME_TITLE, HOME_DESCRIPTION, INSTALL_OPTIONS, COMPONENT_SNIPPET, PARITY_SNIPPETS, CODING_AGENT_PROMPT`. +- In `site-metadata.spec.ts`, replace the body of `'exports the approved primary tagline and supporting copy'` with: + +```ts + expect(PRIMARY_TAGLINE).toBe('Threadplane — Angular AI Agent UI Framework'); + expect(LONG_SUBHEAD).toContain('open-source Angular AI agent UI framework'); + expect(LONG_SUBHEAD).toContain('LangGraph and AG-UI'); + expect(HERO_SUBHEAD).toBe('Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.'); + expect(POSITIONING_PROOF_POINTS.map((p) => p.label)).toEqual([ /* unchanged list */ ]); + expect(POSITIONING_PROOF_POINTS.map((p) => p.href)).toEqual([ /* unchanged list */ ]); + expect(DEFAULT_META_DESCRIPTION).toBe(SHORT_POSITIONING_DESCRIPTION); +``` + +and add a test that asserts the real homepage metadata: + +```ts + it('homepage metadata uses the category title and an un-clamped description', () => { + const metadata = createPageMetadata({ title: HOME_TITLE, description: HOME_DESCRIPTION, pathname: '/', type: 'website' }); + expect(metadata.title).toBe('Threadplane — Angular AI Agent UI Framework'); + expect(metadata.description).toBe(HOME_DESCRIPTION); + }); +``` + +Importing `angular-support.mjs` from a `.ts` file: `ProofStrip.tsx` already does this, so the path and module settings work. If Vitest complains under `@vitest-environment node`, drop that pragma line (jsdom is the default). + +- [ ] **Step 4: Run the specs** + +Run: `npx nx test website -- src/lib/positioning.spec.ts src/lib/site-metadata.spec.ts` +Expected: PASS. If the brand-name spelling scan in `site-metadata.spec.ts` fails, the failing string is in your new copy; fix the spelling it demands. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/lib/positioning.ts apps/website/src/lib/positioning.spec.ts apps/website/src/lib/site-metadata.ts apps/website/src/lib/site-metadata.spec.ts +git commit -m "feat(website): single-source homepage copy, install variants and snippets in positioning.ts" +``` + +--- + +### Task 2: Analytics ids and taxonomy + +**Files:** +- Modify: `apps/website/src/lib/analytics/events.ts:62-98` +- Modify: `docs/gtm/taxonomy.md` (CTA ids section, "Hero" block) + +- [ ] **Step 1: Add the ids to the `CtaId` union** + +Replace the `// Hero (Spec 2)` block with: + +```ts + // Hero (spec 2026-09-02 homepage rebuild) + | 'hero_install' + | 'hero_install_open' + | 'hero_quickstart' + | 'hero_live_demo' + | 'hero_github' + | 'hero_demo_takeover' + | 'hero_demo_replay' + | 'hero_demo_play' + | 'hero_demo_fallback_open' + | 'hero_talk_to_engineers' + // Homepage sections + | 'home_runtime_parity_toggle' + | 'home_adapter_guide' + | 'home_coding_agent_prompt' + | 'home_coding_agent_link' + | 'home_production_readiness_expand' +``` + +Keep the existing `hero_demo_open_workspace`, `hero_demo_open_workspace_caption`, `hero_proof_pill` members for one release so historical dashboards still type-check; add a comment `// retired 2026-09-02, remove after 90 days`. + +- [ ] **Step 2: Add an `adapter` property type** + +In `AnalyticsProperties` (around line 108), add: + +```ts + /** Install/parity variant. */ + adapter?: 'fake' | 'langgraph' | 'ag_ui'; +``` + +- [ ] **Step 3: Document in taxonomy.md** + +Replace the `**Hero**` bullet list with: + +``` +**Hero** + +- `hero_install_open` — primary button opens the install dialog +- `hero_install` — copy in the dialog; property `adapter: fake | langgraph | ag_ui` +- `hero_quickstart` — dialog footer link and final CTA primary; property `adapter` +- `hero_live_demo` — hero text link → cockpit; final CTA secondary +- `hero_demo_play` — "Play walkthrough" pressed (mobile / reduced motion) +- `hero_demo_takeover` — visitor took control of the hero demo (frame reported `live`) +- `hero_demo_replay` — visitor restarted the walkthrough +- `hero_demo_fallback_open` — poster fallback link → demo.threadplane.ai +- `hero_talk_to_engineers` — enterprise section CTA (moved from the hero 2026-09-02) +- retired 2026-09-02: `hero_demo_open_workspace`, `hero_demo_open_workspace_caption`, `hero_proof_pill` + +**Homepage sections** + +- `home_runtime_parity_toggle` — property `adapter` +- `home_adapter_guide` — parity CTA → `/docs/choosing-an-adapter` +- `home_coding_agent_prompt` — prompt copied (prompt text is never sent) +- `home_coding_agent_link` — property `cta_text` names which link +- `home_production_readiness_expand` — Yes Wall expanded +- `home_yes_wall_docs` — Yes Wall footer link +``` + +- [ ] **Step 4: Type-check** + +Run: `npx tsc -p apps/website/tsconfig.json --noEmit` +Expected: no errors (or only pre-existing ones; compare with `git stash`-free baseline by running the same command on a clean checkout if unsure). + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/lib/analytics/events.ts docs/gtm/taxonomy.md +git commit -m "feat(website): analytics ids for the homepage rebuild" +``` + +--- + +### Task 3: Extract a `Modal` primitive from DemoModal + +**Files:** +- Create: `apps/website/src/components/ui/Modal.tsx` +- Create: `apps/website/src/components/ui/Modal.spec.tsx` +- Modify: `apps/website/src/components/landing/DemoModal.tsx` +- Modify: `apps/website/src/styles/ui.css` + +- [ ] **Step 1: Write the failing spec** + +```tsx +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Modal } from './Modal'; + +describe('Modal', () => { + it('renders nothing when closed', () => { + const { container } = render( {}} label="x">

hi

); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it('renders a labelled modal dialog with its children and focuses the close button', () => { + render( {}} label="Install Threadplane">

hi

); + const dialog = screen.getByRole('dialog', { name: 'Install Threadplane' }); + expect(dialog.getAttribute('aria-modal')).toBe('true'); + expect(screen.getByText('hi')).toBeTruthy(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' })); + }); + + it('closes on Escape and on backdrop mousedown, not on frame mousedown', () => { + const onClose = vi.fn(); + render(

hi

); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.mouseDown(screen.getByRole('dialog')); + expect(onClose).toHaveBeenCalledTimes(2); + fireEvent.mouseDown(screen.getByText('hi')); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('locks body scroll while open and restores it', () => { + const { unmount } = render( {}} label="x">

hi

); + expect(document.body.style.overflow).toBe('hidden'); + unmount(); + expect(document.body.style.overflow).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx nx test website -- src/components/ui/Modal.spec.tsx` +Expected: FAIL. + +- [ ] **Step 3: Write `Modal.tsx`** + +```tsx +'use client'; +import { useEffect, useRef, type ReactNode } from 'react'; + +interface ModalProps { + open: boolean; + onClose: () => void; + /** Accessible name for the dialog. */ + label: string; + children: ReactNode; + /** Optional class for the inner frame (size). */ + frameClassName?: string; +} + +const FOCUSABLE = 'a[href], button:not([disabled]), input, select, textarea, iframe, [tabindex]:not([tabindex="-1"])'; + +/** + * Minimal modal: role=dialog, focus trap, Esc, backdrop click, body scroll + * lock, focus restore. Extracted from DemoModal (2026-09-02). + */ +export function Modal({ open, onClose, label, children, frameClassName }: ModalProps) { + const frameRef = useRef(null); + const closeBtnRef = useRef(null); + + useEffect(() => { + if (!open) return; + const prevFocus = document.activeElement as HTMLElement | null; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + closeBtnRef.current?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { onClose(); return; } + if (e.key !== 'Tab') return; + const f = frameRef.current?.querySelectorAll(FOCUSABLE); + if (!f || f.length === 0) return; + const first = f[0]; + const last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + }; + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + prevFocus?.focus?.(); + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ + {children} +
+
+ ); +} +``` + +- [ ] **Step 4: Add styles to `ui.css`** (append; keep the file's data-attribute convention) + +```css +[data-ui="modal"] { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, 0.45); +} +[data-ui="modal-frame"] { + position: relative; + width: min(100%, 560px); + max-height: calc(100vh - 48px); + overflow: auto; + border-radius: 12px; + background: var(--color-surface-white, #fff); + color: var(--color-text, #111); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35); +} +[data-ui="modal-close"] { + position: absolute; + top: 10px; + right: 10px; + width: 36px; + height: 36px; + border: 0; + border-radius: 8px; + background: transparent; + font-size: 22px; + line-height: 1; + cursor: pointer; + color: var(--color-text-muted); +} +[data-ui="modal-close"]:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} +``` + +Check the variable names against the top of `ui.css` and `landing.css` (`--color-text-muted`, `--color-accent` are used in `landing.css`; use the same surface variable the `.demo-modal__frame` rule uses for its background). + +- [ ] **Step 5: Refactor `DemoModal.tsx` onto `Modal`** + +Replace its `useEffect`, the outer `
` and the close button with `` around the titlebar/body/footer. Remove the now-unused `closeBtnRef` and the `demo-modal__close` button (the `Modal` close button replaces it); keep `demo-modal__titlebar`, tabs, iframe, footer as they are. In `landing.css`, delete the `.demo-modal` backdrop rule (lines around 781–792) since `[data-ui="modal"]` now provides it, and keep `.demo-modal__frame` for sizing. Run `npx nx test website -- src/components/landing/DemoShowcase.spec.tsx` and `npx nx e2e website -- demo-modal.spec.ts` to confirm nothing regressed (the e2e expects `getByRole('dialog', { name: /live demo/i })` and the launch button to be refocused on Escape; both are preserved). + +- [ ] **Step 6: Run the specs** + +Run: `npx nx test website -- src/components/ui/Modal.spec.tsx src/components/landing/DemoShowcase.spec.tsx` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/website/src/components/ui/Modal.tsx apps/website/src/components/ui/Modal.spec.tsx apps/website/src/components/landing/DemoModal.tsx apps/website/src/styles/ui.css apps/website/src/styles/landing.css +git commit -m "refactor(website): extract Modal primitive from DemoModal" +``` + +--- + +### Task 4: InstallDialog + +**Files:** +- Create: `apps/website/src/components/landing/InstallDialog.tsx` +- Create: `apps/website/src/components/landing/InstallDialog.spec.tsx` +- Modify: `apps/website/src/styles/landing.css` + +- [ ] **Step 1: Write the failing spec** + +```tsx +// @vitest-environment jsdom +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { INSTALL_OPTIONS } from '../../lib/positioning'; + +const trackCtaClickMock = vi.hoisted(() => vi.fn()); +const writeTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: trackCtaClickMock, track: vi.fn() })); +vi.mock('../ui/Button', () => ({ + Button: ({ children, href, onClick }: { children: React.ReactNode; href?: string; onClick?: () => void }) => + href ? {children} : , +})); + +beforeEach(() => { + trackCtaClickMock.mockClear(); + writeTextMock.mockClear(); + Object.assign(navigator, { clipboard: { writeText: writeTextMock } }); +}); + +describe('InstallDialog', () => { + it('opens on the fake-agent variant and shows its command and snippet', async () => { + const { InstallDialog } = await import('./InstallDialog'); + render( {}} />); + const dialog = screen.getByRole('dialog', { name: 'Install Threadplane' }); + const radios = within(dialog).getAllByRole('radio'); + expect(radios.map((r) => r.getAttribute('aria-checked'))).toEqual(['true', 'false', 'false']); + expect(within(dialog).getByTestId('install-command').textContent).toBe(INSTALL_OPTIONS[0].command); + expect(within(dialog).getByTestId('install-snippet').textContent).toContain('provideFakeAgent'); + expect(within(dialog).getByRole('link', { name: /Open the full quickstart/ }).getAttribute('href')).toBe(INSTALL_OPTIONS[0].quickstartHref); + }); + + it('switching to AG-UI swaps command, snippet and quickstart link, and tracks the toggle', async () => { + const { InstallDialog } = await import('./InstallDialog'); + render( {}} />); + fireEvent.click(screen.getByRole('radio', { name: 'AG-UI' })); + expect(screen.getByTestId('install-command').textContent).toBe(INSTALL_OPTIONS[2].command); + expect(screen.getByTestId('install-snippet').textContent).toContain("@threadplane/ag-ui"); + expect(screen.getByRole('link', { name: /Open the full quickstart/ }).getAttribute('href')).toBe(INSTALL_OPTIONS[2].quickstartHref); + }); + + it('arrow keys move the radio selection', async () => { + const { InstallDialog } = await import('./InstallDialog'); + render( {}} />); + const first = screen.getByRole('radio', { name: 'Try without a backend' }); + first.focus(); + fireEvent.keyDown(first, { key: 'ArrowRight' }); + expect(screen.getByRole('radio', { name: 'LangGraph' }).getAttribute('aria-checked')).toBe('true'); + }); + + it('copy writes the visible command and fires hero_install with the adapter', async () => { + const { InstallDialog } = await import('./InstallDialog'); + render( {}} />); + fireEvent.click(screen.getByRole('radio', { name: 'LangGraph' })); + fireEvent.click(screen.getByRole('button', { name: 'Copy install command' })); + expect(writeTextMock).toHaveBeenCalledWith(INSTALL_OPTIONS[1].command); + expect(trackCtaClickMock).toHaveBeenCalledWith(expect.objectContaining({ cta_id: 'hero_install', adapter: 'langgraph', surface: 'home', track: 'developer' })); + expect(await screen.findByText(/Copied/)).toBeTruthy(); + }); + + it('quickstart link fires hero_quickstart with the adapter', async () => { + const { InstallDialog } = await import('./InstallDialog'); + render( {}} />); + fireEvent.click(screen.getByRole('link', { name: /Open the full quickstart/ })); + expect(trackCtaClickMock).toHaveBeenCalledWith(expect.objectContaining({ cta_id: 'hero_quickstart', adapter: 'fake' })); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx nx test website -- src/components/landing/InstallDialog.spec.tsx` +Expected: FAIL. + +- [ ] **Step 3: Write `InstallDialog.tsx`** + +```tsx +'use client'; +import { useCallback, useRef, useState, type KeyboardEvent } from 'react'; +import { Modal } from '../ui/Modal'; +import { Button } from '../ui/Button'; +import { trackCtaClick } from '../../lib/analytics/client'; +import { COMPONENT_SNIPPET, HERO_TRUST_LINE, INSTALL_OPTIONS, type InstallVariant } from '../../lib/positioning'; + +interface InstallDialogProps { + open: boolean; + onClose: () => void; +} + +const COPY_FEEDBACK_MS = 1500; + +export function InstallDialog({ open, onClose }: InstallDialogProps) { + const [variant, setVariant] = useState('fake'); + const [copied, setCopied] = useState(false); + const radioRefs = useRef<(HTMLButtonElement | null)[]>([]); + const option = INSTALL_OPTIONS.find((o) => o.key === variant) ?? INSTALL_OPTIONS[0]; + + const onRadioKey = (e: KeyboardEvent, index: number) => { + const delta = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0; + if (!delta) return; + e.preventDefault(); + const next = (index + delta + INSTALL_OPTIONS.length) % INSTALL_OPTIONS.length; + setVariant(INSTALL_OPTIONS[next].key); + radioRefs.current[next]?.focus(); + }; + + const copy = useCallback(async () => { + trackCtaClick({ cta_id: 'hero_install', adapter: option.key, track: 'developer', surface: 'home' }); + try { + await navigator.clipboard?.writeText(option.command); + setCopied(true); + setTimeout(() => setCopied(false), COPY_FEEDBACK_MS); + } catch { + // Clipboard blocked: the command is visible, the user can select it. + } + }, [option]); + + return ( + +

Install Threadplane

+

+ Three steps to a running <chat> in your Angular app. No account, no key. +

+ +
    +
  1. +

    Pick how you want to start

    +
    + {INSTALL_OPTIONS.map((o, i) => ( + + ))} +
    +

    {option.description}

    +
  2. + +
  3. +

    Run this in your Angular project

    +
    {option.command}
    +

    {option.peersNote}

    +
  4. + +
  5. +

    Add the provider and the component

    +
    {option.providerSnippet}
    +
    {COMPONENT_SNIPPET}
    +
  6. +
+ +
+ + +
+

{HERO_TRUST_LINE}

+
+ ); +} +``` + +- [ ] **Step 4: Add `landing.css` rules** (append, flat kebab like the rest of the file) + +```css +.install-dialog { padding: 28px 28px 20px; } +.install-dialog-title { margin: 0 32px 4px 0; font-size: 22px; line-height: 1.2; } +.install-dialog-lede { margin: 0 0 16px; color: var(--color-text-muted); font-size: 14px; } +.install-dialog-steps { list-style: none; margin: 0; padding: 0; counter-reset: step; } +.install-dialog-step { position: relative; padding-left: 34px; margin-bottom: 18px; counter-increment: step; } +.install-dialog-step::before { + content: counter(step); position: absolute; left: 0; top: 0; width: 22px; height: 22px; border-radius: 50%; + background: var(--color-text); color: var(--color-surface-white, #fff); font-size: 12px; font-weight: 700; + display: grid; place-items: center; +} +.install-dialog-step-title { margin: 0 0 8px; font-size: 15px; line-height: 22px; } +.install-dialog-step-note { margin: 6px 0 0; font-size: 12.5px; color: var(--color-text-muted); } +.install-dialog-seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden; } +.install-dialog-seg-btn { padding: 7px 12px; border: 0; background: transparent; font: inherit; font-size: 13px; cursor: pointer; } +.install-dialog-seg-btn[aria-checked="true"] { background: var(--color-text); color: var(--color-surface-white, #fff); } +.install-dialog-seg-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; } +.install-dialog-code { + margin: 0 0 8px; padding: 10px 12px; border-radius: 8px; overflow-x: auto; + background: #1c1c1e; color: #e8e8e8; font-family: var(--font-mono, ui-monospace, Menlo, monospace); font-size: 12.5px; line-height: 1.5; +} +.install-dialog-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; } +.install-dialog-trust { margin: 12px 0 0; font-size: 12px; color: var(--color-text-muted); } +``` + +Use the border and mono-font variables that `landing.css` already uses (grep `--color-border` and `--font-` at the top of the file) and adjust the names. + +- [ ] **Step 5: Run the spec** + +Run: `npx nx test website -- src/components/landing/InstallDialog.spec.tsx` +Expected: PASS (5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/components/landing/InstallDialog.tsx apps/website/src/components/landing/InstallDialog.spec.tsx apps/website/src/styles/landing.css +git commit -m "feat(website): InstallDialog with fake/LangGraph/AG-UI variants" +``` + +--- + +### Task 5: HeroDemo (poster → iframe state machine) + +**Files:** +- Create: `apps/website/src/components/landing/HeroDemo.tsx` +- Create: `apps/website/src/components/landing/HeroDemo.spec.tsx` +- Modify: `apps/website/src/styles/landing.css` + +- [ ] **Step 1: Write the failing spec** + +```tsx +// @vitest-environment jsdom +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +const trackCtaClickMock = vi.hoisted(() => vi.fn()); +vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: trackCtaClickMock, track: vi.fn() })); +vi.mock('../ui/BrowserFrame', () => ({ + BrowserFrame: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +type IOCallback = (entries: { isIntersecting: boolean }[]) => void; +let ioCallback: IOCallback | null = null; + +function installEnv({ width = 1280, reduced = false }: { width?: number; reduced?: boolean } = {}) { + Object.defineProperty(window, 'innerWidth', { value: width, configurable: true }); + window.matchMedia = vi.fn().mockImplementation((q: string) => ({ + matches: q.includes('reduce') ? reduced : false, + addEventListener: vi.fn(), removeEventListener: vi.fn(), media: q, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), + })); + class IO { + constructor(cb: IOCallback) { ioCallback = cb; } + observe() {} unobserve() {} disconnect() {} + } + (window as unknown as { IntersectionObserver: unknown }).IntersectionObserver = IO; +} + +function frameReady(origin = 'https://demo.threadplane.ai') { + fireEvent(window, new MessageEvent('message', { origin, data: { type: 'tplane-hero', state: 'ready' } })); +} + +beforeEach(() => { vi.useFakeTimers(); ioCallback = null; trackCtaClickMock.mockClear(); }); +afterEach(() => { vi.useRealTimers(); }); + +describe('HeroDemo', () => { + it('server-renders the poster eagerly with explicit dimensions and no iframe', async () => { + installEnv(); + const { HeroDemo } = await import('./HeroDemo'); + const { container } = render(); + const img = container.querySelector('img') as HTMLImageElement; + expect(img.getAttribute('src')).toBe('/screenshots/hero-walkthrough-poster.webp'); + expect(img.getAttribute('width')).toBe('1200'); + expect(img.getAttribute('height')).toBe('720'); + expect(img.getAttribute('loading')).toBe('eager'); + expect(img.getAttribute('fetchpriority')).toBe('high'); + expect(container.querySelector('iframe')).toBeNull(); + }); + + it('mounts the iframe when visible on desktop and reveals it on ready from the demo origin', async () => { + installEnv(); + const { HeroDemo } = await import('./HeroDemo'); + const { container } = render(); + act(() => { ioCallback?.([{ isIntersecting: true }]); }); + const iframe = container.querySelector('iframe') as HTMLIFrameElement; + expect(iframe.getAttribute('src')).toBe('https://demo.threadplane.ai/hero'); + expect(iframe.getAttribute('title')).toBe('Threadplane live demo'); + expect(container.querySelector('[data-hero-demo]')?.getAttribute('data-state')).toBe('mounting'); + act(() => { frameReady(); }); + expect(container.querySelector('[data-hero-demo]')?.getAttribute('data-state')).toBe('ready'); + }); + + it('ignores ready from a foreign origin and falls back after the timeout', async () => { + installEnv(); + const { HeroDemo } = await import('./HeroDemo'); + const { container } = render(); + act(() => { ioCallback?.([{ isIntersecting: true }]); }); + act(() => { frameReady('https://evil.example'); }); + expect(container.querySelector('[data-hero-demo]')?.getAttribute('data-state')).toBe('mounting'); + act(() => { vi.advanceTimersByTime(8000); }); + expect(container.querySelector('[data-hero-demo]')?.getAttribute('data-state')).toBe('fallback'); + expect(screen.getByRole('link', { name: /Open the live demo/ }).getAttribute('href')).toBe('https://demo.threadplane.ai'); + }); + + it('shows Play walkthrough instead of mounting on narrow viewports, and mounts on click', async () => { + installEnv({ width: 390 }); + const { HeroDemo } = await import('./HeroDemo'); + const { container } = render(); + act(() => { ioCallback?.([{ isIntersecting: true }]); }); + expect(container.querySelector('iframe')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Play walkthrough' })); + expect(container.querySelector('iframe')).toBeTruthy(); + expect(trackCtaClickMock).toHaveBeenCalledWith(expect.objectContaining({ cta_id: 'hero_demo_play' })); + }); + + it('shows Play walkthrough under reduced motion', async () => { + installEnv({ reduced: true }); + const { HeroDemo } = await import('./HeroDemo'); + render(); + act(() => { ioCallback?.([{ isIntersecting: true }]); }); + expect(screen.getByRole('button', { name: 'Play walkthrough' })).toBeTruthy(); + }); + + it('tracks takeover and replay once per frame state message', async () => { + installEnv(); + const { HeroDemo } = await import('./HeroDemo'); + render(); + act(() => { ioCallback?.([{ isIntersecting: true }]); }); + act(() => { frameReady(); }); + act(() => { fireEvent(window, new MessageEvent('message', { origin: 'https://demo.threadplane.ai', data: { type: 'tplane-hero', state: 'live' } })); }); + act(() => { fireEvent(window, new MessageEvent('message', { origin: 'https://demo.threadplane.ai', data: { type: 'tplane-hero', state: 'replay' } })); }); + expect(trackCtaClickMock).toHaveBeenCalledWith(expect.objectContaining({ cta_id: 'hero_demo_takeover' })); + expect(trackCtaClickMock).toHaveBeenCalledWith(expect.objectContaining({ cta_id: 'hero_demo_replay' })); + }); + + it('forwards visibility to the frame with the demo origin', async () => { + installEnv(); + const { HeroDemo } = await import('./HeroDemo'); + const { container } = render(); + act(() => { ioCallback?.([{ isIntersecting: true }]); }); + const iframe = container.querySelector('iframe') as HTMLIFrameElement; + const post = vi.fn(); + Object.defineProperty(iframe, 'contentWindow', { value: { postMessage: post }, configurable: true }); + act(() => { frameReady(); }); + act(() => { ioCallback?.([{ isIntersecting: false }]); }); + expect(post).toHaveBeenCalledWith({ type: 'tplane-hero', visible: false }, 'https://demo.threadplane.ai'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx nx test website -- src/components/landing/HeroDemo.spec.tsx` +Expected: FAIL. + +- [ ] **Step 3: Write `HeroDemo.tsx`** + +```tsx +'use client'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { BrowserFrame } from '../ui/BrowserFrame'; +import { trackCtaClick } from '../../lib/analytics/client'; + +export const HERO_DEMO_ORIGIN = 'https://demo.threadplane.ai'; +export const HERO_DEMO_URL = `${HERO_DEMO_ORIGIN}/hero`; +export const HERO_POSTER = '/screenshots/hero-walkthrough-poster.webp'; +const POSTER_W = 1200; +const POSTER_H = 720; +const READY_TIMEOUT_MS = 8000; +const MIN_AUTOPLAY_WIDTH = 768; +const MESSAGE_TYPE = 'tplane-hero'; + +type State = 'poster' | 'playRequested' | 'mounting' | 'ready' | 'fallback'; + +function autoplayAllowed(): boolean { + if (typeof window === 'undefined') return false; + if (window.innerWidth < MIN_AUTOPLAY_WIDTH) return false; + return !window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** + * Hero demo: server-rendered poster (the LCP), iframe mounted after hydration + * when the hero is visible on a wide, motion-tolerant viewport, crossfaded in + * when the frame reports ready. See spec §4.2. + */ +export function HeroDemo() { + const [state, setState] = useState('poster'); + const [visible, setVisible] = useState(false); + const [needsClick, setNeedsClick] = useState(false); + const rootRef = useRef(null); + const iframeRef = useRef(null); + const lastFrameState = useRef(null); + + // Visibility. + useEffect(() => { + const el = rootRef.current; + if (!el || typeof IntersectionObserver === 'undefined') return; + const io = new IntersectionObserver((entries) => setVisible(entries.some((e) => e.isIntersecting)), { threshold: 0.25 }); + io.observe(el); + return () => io.disconnect(); + }, []); + + // Decide whether to mount. + useEffect(() => { + if (!visible) return; + if (state !== 'poster' && state !== 'playRequested') return; + if (state === 'playRequested' || autoplayAllowed()) setState('mounting'); + else setNeedsClick(true); + }, [visible, state]); + + // Ready timeout → fallback. + useEffect(() => { + if (state !== 'mounting') return; + const t = setTimeout(() => setState((s) => (s === 'mounting' ? 'fallback' : s)), READY_TIMEOUT_MS); + return () => clearTimeout(t); + }, [state]); + + // Frame → website messages. + useEffect(() => { + const onMessage = (e: MessageEvent) => { + if (e.origin !== HERO_DEMO_ORIGIN) return; + const d = e.data as { type?: string; state?: string } | null; + if (!d || d.type !== MESSAGE_TYPE || typeof d.state !== 'string') return; + if (d.state === 'ready') setState((s) => (s === 'mounting' ? 'ready' : s)); + if (d.state === lastFrameState.current) return; + lastFrameState.current = d.state; + if (d.state === 'live') trackCtaClick({ cta_id: 'hero_demo_takeover', track: 'developer', surface: 'home' }); + if (d.state === 'replay') trackCtaClick({ cta_id: 'hero_demo_replay', track: 'developer', surface: 'home' }); + }; + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, []); + + // Website → frame visibility. Posted while mounting too (on the iframe's + // load event) so a frame whose referrer was stripped can learn our origin + // from this message and replay its `ready` state to us. + useEffect(() => { + if (state !== 'mounting' && state !== 'ready') return; + iframeRef.current?.contentWindow?.postMessage({ type: MESSAGE_TYPE, visible }, HERO_DEMO_ORIGIN); + }, [visible, state]); + + const play = useCallback(() => { + trackCtaClick({ cta_id: 'hero_demo_play', track: 'developer', surface: 'home' }); + setNeedsClick(false); + setState('playRequested'); + }, []); + + const mounted = state === 'mounting' || state === 'ready'; + + return ( +
+ +
+ Threadplane chat replaying a recorded LangGraph run: a user prompt, a request_approval tool call, and the streamed three-step cleanup plan + {mounted ? ( +