From 86a10c4ed40975da33fe3645e04b77a0aa66a320 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 16:45:23 -0700 Subject: [PATCH 01/23] =?UTF-8?q?docs(specs):=20homepage=20rebuild=20desig?= =?UTF-8?q?n=20=E2=80=94=20category=20hero,=20live=20take-over=20demo,=20i?= =?UTF-8?q?nstall=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../2026-09-02-homepage-rebuild-design.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md diff --git a/docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md b/docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md new file mode 100644 index 000000000..69f058b24 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md @@ -0,0 +1,255 @@ +# Homepage rebuild: category, live hero demo, install dialog + +**Date:** 2026-09-02 +**Status:** Approved design, awaiting implementation plan +**Surface:** `apps/website` homepage (`/`), plus a new `/hero` route in `examples/chat` +**Brief:** "Rebuild threadplane.ai for Developer Awareness and Activation" (September 2, 2026). This spec is the first of five sub-projects; comparison pages, README and npm alignment, the measurement baseline program, and the content program are deferred to their own specs. + +## 1. Goal + +A senior Angular engineer landing on threadplane.ai should, without interpretation: + +- read the exact category (open-source Angular AI agent UI framework) in the first viewport; +- see the real product running, not a screenshot, and be able to take control of it; +- understand from one button what "install" means and what happens after they click it; +- reach a first success (a rendered ``) with no server, no LLM, and no account; +- find the LangGraph and AG-UI paths as the second step, not the first. + +The page optimizes for comprehension, quickstart entry, and activation. Not for GitHub stars, decorative novelty, or enterprise lead capture. + +## 2. Decisions locked during brainstorming + +| Decision | Choice | +|---|---| +| Scope | Rebuild the top of the page; refine and reorder the bottom. FeatureBlocks, DemoShowcase, YesWall, ProofStrip, PilotBlock, HomeFAQ, FinalCTA keep their components, CSS, specs, and analytics ids. | +| Hero layout | Stacked and centered: eyebrow, H1, one sentence, one primary button, one text link, then the demo at full width. Nothing else in the first viewport. | +| H1 | "The AI agent UI framework for Angular." | +| Hero demo | A recorded real LangGraph run replayed through the real Threadplane components with a scripted cursor. Takeover swaps to the live LangGraph backend on a new thread. | +| Takeover gesture | Visible "Take control" pill, plus any pointer-down or focus inside the surface. | +| Primary CTA | "Install Threadplane" opens an install dialog. The dialog's quickstart link and the final CTA land on a new "Try without a backend" docs page. | +| No-backend path | Shipped in this release. A new docs page, verified from a clean `ng new` app before launch. | +| Yes Wall | Eight questions shown (two per group), in-place expand to all seventeen. No new route. | +| Runtime parity | Its own section below the hero, not merged into it. | +| Branch base | `origin/main`. The old brainstorm branch carries one stray, superseded commit and is not used. | + +## 3. Page architecture + +Order is fixed. Component names are indicative; reuse existing primitives (`Section`, `Container`, `Eyebrow`, `Button`, `BrowserFrame`, `DemoModal`, `FeatureBlock`, `MediumSwitcher`, `HighlightedCode`) before adding any. + +| # | Section | Component | Change | +|---|---|---|---| +| 1 | Hero | `Hero` + new `HeroDemo` + new `InstallDialog` | Rewrite | +| 2 | Compatibility boundary | `LogoRibbon` | Rewrite into three labeled groups | +| 3 | Runtime parity | new `RuntimeParity` | New; replaces `StackDiagramSection` on the homepage (the component stays for other pages) | +| 4 | Three-step mechanism | new `ThreeSteps` | New; replaces `HomeConceptGrid` on the homepage | +| 5 | Capability proof | `FeatureBlock` x5 | Re-copy Stream, Persist (was Ship), Approve, Render; add Test | +| 6 | Live Cockpit proof | `DemoShowcase` | Move down; one scenario | +| 7 | Coding-agent quickstart | new `CodingAgentQuickstart` | New | +| 8 | Production readiness | `YesWall` | Move down; compress with expand | +| 9 | Why Threadplane | new `ScopeTable` | New, static | +| 10 | Trust | `ProofStrip`, `Promises`, `WhitePaperBlock` | Keep | +| 11 | Enterprise | `PilotBlock` | Keep, new heading and copy | +| 12 | FAQ | `HomeFAQ` | Rewrite questions | +| 13 | Final CTA, articles | `FinalCTA`, `RecentArticles` | Rewrite copy | + +`StackDiagramSection` and `HomeConceptGrid` are unmounted from `page.tsx` only. They are not deleted in this release. + +## 4. Hero + +### 4.1 Copy + +All strings live in `apps/website/src/lib/positioning.ts` (see section 9). + +- Eyebrow: `Open-source · Angular · LangGraph & AG-UI` +- H1: `The AI agent UI framework for Angular.` +- Subhead: `Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.` +- Primary button: `Install Threadplane` (opens the install dialog) +- Text link: `See it running in Cockpit →` (cockpit.threadplane.ai) +- Small line under the demo: `MIT · Angular 20–22 · no account, no cloud` (values derived, see 9.2) + +The "six months" line, the capability chip row, the enterprise button, the hidden clipboard action, and the screenshot are removed from the hero. The enterprise CTA and its `hero_talk_to_engineers` id move unchanged to the enterprise section. + +### 4.2 HeroDemo (website side) + +A client component that owns a small state machine: + +``` +poster → mounting → ready → (takeover | replay)* +poster → fallback (frame never reports ready within 8s, or postMessage origin mismatch) +poster → playRequested (mobile or reduced-motion: user pressed "Play walkthrough") → mounting +``` + +- **Poster** is server-rendered: a webp of the first replay frame inside `BrowserFrame`, explicit width and height, `fetchpriority="high"`, not lazy. It is the LCP element. +- **Mounting** happens after hydration when all hold: the hero is intersecting, viewport width ≥ 768px, `prefers-reduced-motion` is not `reduce`. Otherwise the poster shows a "Play walkthrough" button and mounting waits for the click. +- The iframe (`https://demo.threadplane.ai/hero`) is absolutely positioned over the poster with `opacity: 0` until the frame posts `ready`, then crossfades. `title` is "Threadplane live demo". No layout shift: the poster's box is the iframe's box. +- **Fallback** keeps the poster and shows "Open the live demo →" linking to demo.threadplane.ai. A backend or CDN outage never blanks the hero. +- The website sends `{ type: 'tplane-hero', visible: boolean }` on intersection changes so the frame can pause and resume the script. The frame sends `{ type: 'tplane-hero', state: 'ready' | 'scripted' | 'paused' | 'live' | 'replay' }`. Both sides check `event.origin` against an allowlist; nothing else crosses the boundary. +- Analytics: `hero_demo_takeover` and `hero_demo_replay` fire once per state transition from the frame's messages. `hero_live_demo` fires on the Cockpit link. + +**Documented exception to the brief:** an iframe exists in the first viewport. It is never the LCP, never loads before hydration, never loads on mobile or under reduced motion without a click, and has a poster fallback. + +### 4.3 The `/hero` route (examples/chat side) + +A new `HeroMode` component registered beside `embed`, `popup`, and `sidebar`, hosted under `DemoShell` so it inherits client tools, the A2UI catalog, and model options. + +- **Agent as a signal.** `HeroMode` binds ``. It starts on a replay agent created with `provideAgent`-equivalent wiring around a `ReplayTransport`; takeover sets the signal to the shell's live `DEMO_AGENT`. Verify during implementation that `` re-binds cleanly when its `agent` input changes; if it does not, wrap the composition in `@if` keyed on the agent so it remounts. +- **ReplayTransport** implements the LangGraph adapter's `AgentTransport`. `stream()` yields the events of a committed fixture (`examples/chat/angular/src/assets/hero-replay.json`) honoring each event's relative timestamp, scaled by a speed factor, and aborts on the signal. Two recorded runs are stored: the initial prompt run, and the resume run after approval. `joinStream` and `createQueuedRun` are not implemented. +- **Recording** is a one-off `RecordingTransport` wrapper around the real transport, enabled by a query flag in dev only, that captures `{ tMs, event }` pairs to the console for pasting into the fixture. The fixture is regenerated when the demo backend's prompt or tools change; a spec asserts the fixture parses and contains a tool call, an interrupt, and a generative payload. +- **Script runner** is a small pure state machine with an injected clock so it is testable: + 1. wait for `visible`; + 2. show the cursor SVG at the composer, type the prompt character by character at 40ms; + 3. submit through the agent API (`agent.submit`), not by dispatching DOM events; + 4. wait for the interrupt to appear, move the cursor to the Approve control over 600ms, press it visually, then call the agent's resume path with the same payload the panel would send; + 5. wait for the run to end, hold 4s, then loop with a Replay affordance visible. + It pauses on `visible: false` and on `document.hidden`, and resumes where it left off. Under `prefers-reduced-motion` typing is instant and the cursor still moves but does not animate. +- **Status pill** in the frame's top bar: `● Replaying a recorded LangGraph run` while scripted; `● Live · LangGraph · new thread` after takeover. This is the honesty marker and must never be hidden. +- **Takeover.** Triggered by the "Take control" pill, `pointerdown` anywhere on the surface, or `focusin` on any control inside it. It stops the runner, hides the cursor, sets the agent signal to the live agent on a fresh thread, dims the replayed transcript to 45% opacity, inserts the divider "You are live. The walkthrough above was a recording; this thread is new.", and shows the existing welcome suggestion chips. A "Replay walkthrough" link returns to the scripted state on a fresh replay agent. +- The live agent uses the shell's existing LangGraph config and telemetry. Nothing new is configured for the backend. + +### 4.4 InstallDialog + +Opened by "Install Threadplane". Reuses `DemoModal`'s focus trap, escape handling, and scroll lock through a shared modal primitive extracted from `DemoModal` (the extraction is the only refactor permitted in this release). + +Content, top to bottom: + +1. Title `Install Threadplane`, subtitle `Three steps to a running in your Angular app. No account, no key.` +2. Step 1 `Pick how you want to start`: segmented control `Try without a backend` (default) / `LangGraph` / `AG-UI`, each with a one-line description. +3. Step 2 `Run this in your Angular project`: the install command for the selected variant in a code block with a copy button and visible "Copied" feedback. A caption names the peer dependencies the command adds. +4. Step 3 `Add the provider and the component`: a six-line snippet for the selected variant (provider line plus `injectAgent()` and ``). +5. Footer: `Open the full quickstart →` (the variant's docs page) and `Copy install command`. + +Analytics: `hero_install` keeps its id and gains `adapter: 'fake' | 'langgraph' | 'ag_ui'`; `hero_install_open` fires when the dialog opens; `hero_quickstart` fires on the footer link with the same `adapter` property. No copied text is sent. + +Keyboard: the segmented control is a `radiogroup` operable with arrows; the dialog is `aria-modal` with `aria-labelledby`. + +## 5. Compatibility boundary + +`LogoRibbon` becomes three rows under the heading `Keep your agent stack. Standardize the Angular surface.` and the line `Threadplane adapts LangGraph and AG-UI into one signal-shaped Agent contract. Your model provider stays behind the backend you already operate.` + +| Row label | Entries | Source of truth | +|---|---|---| +| Direct Threadplane adapters | LangGraph, AG-UI | `libs/langgraph`, `libs/ag-ui` | +| Backends reachable through AG-UI | Only runtimes with a cockpit or `runtimes/` example on main at implementation time | `cockpit/ag-ui/*`, runtime manifest | +| Model providers, behind your backend | OpenAI, Anthropic, Gemini, Bedrock | existing logo assets | + +Logos are `aria-hidden`; names are visible text. No row is titled or styled as customer proof. + +## 6. Runtime parity, three steps, capability proof + +**RuntimeParity.** Heading `One Angular UI. Two runtime adapters. The same contract.` A LangGraph / AG-UI toggle (`radiogroup`) above two panes. Left pane, "What changes": the app config with the adapter import and provider line highlighted; only these lines differ between toggles. Right pane, "What does not": the component with `injectAgent()` and ``, rendered once and visually pinned with a "same in both" badge. Below: `@threadplane/chat consumes Agent, not LangGraphAgent or an AG-UI client. Swap the adapter without rewriting the Angular component tree.` and the qualification `Not every backend emits every capability; see the adapter guide.` CTA `Choose an adapter` → `/docs/choosing-an-adapter`. Both panes are server-highlighted with `HighlightedCode`; the toggle switches pre-rendered panes, so no client highlighting. Event `home_runtime_parity_toggle` with `adapter`. + +**ThreeSteps.** Heading `From agent endpoint to Angular UI in three steps.` Steps: Choose an adapter, Inject signal-shaped state, Render the experience you own. Each has one sentence and one code fragment taken from the same `positioning.ts` snippets the dialog and parity section use. + +**Capability proof.** Five `FeatureBlock`s, existing component and existing `MediumSwitcher` panes: + +| Block | Outcome | Mechanism | Qualification | +|---|---|---|---| +| Stream | The UI stays reactive through tokens, tools, errors, and state changes. | `messages()`, `status()`, `error()`, `isLoading()`, tool progress | | +| Persist | A user can leave, return, inspect history, and continue. | thread selection, history, branch/replay, backend checkpoints | Durability comes from the connected runtime and persistence layer. | +| Approve | Irreversible work pauses for a human decision. | `interrupt()`, ``, `submit({ resume })`, checkpoint | | +| Render | Agent output becomes components from your design system. | registry, schema validation, readiness gate, per-component fallback, json-render and A2UI | Constrained structured output; no arbitrary generated code runs. | +| Test | Verify UI behavior without a model or backend. | `provideFakeAgent()`, `mockLangGraphAgent`, fixtures | | + +Persist reuses the Ship block's panes. Test has a code pane only. Each block keeps one guide link and one live or inspectable example. + +## 7. Coding-agent quickstart, Yes Wall, scope table, enterprise, FAQ, final CTA + +**CodingAgentQuickstart.** Heading `Give your coding agent the Angular agent UI playbook.` The brief's seven-step prompt rendered from `positioning.ts` in a code block with copy. Links: `Copy setup prompt`, `Read AGENTS.md` (`/AGENTS.md`), `Open the full agent reference` (`/llms-full.txt`), `Start the human quickstart`. Event `home_coding_agent_prompt`; prompt text never enters analytics. + +**YesWall.** Data unchanged. Renders the first two questions of each group; a button `See all 17 production-readiness questions` (count computed from data) expands the rest in place with `aria-expanded` and `aria-controls`. Event `home_production_readiness_expand`. The aside copy is regenerated from the count. Questions whose answer depends on the backend keep their existing qualification in the API pairing. + +**ScopeTable.** Heading `Why Threadplane`. The brief's four-row table (raw stream SDK, backend agent framework, generative-UI renderer, React-first agent UI) as a static, responsive table that scrolls horizontally inside its own container on narrow widths. No competitor is named. + +**PilotBlock.** Heading `Shipping inside a large Angular platform?`, the brief's copy, CTA `Talk to an engineer` with the existing `hero_talk_to_engineers` id and the existing direct-response promise only if `docs/gtm` still states it. + +**HomeFAQ.** The brief's twelve questions. Each answer is one to three sentences, literal, with one docs link, and qualified where a capability depends on the backend. No FAQ structured data. + +**FinalCTA.** Heading `Prove the Angular UI before you connect the backend.` Subhead `Start with a fake agent, render a real Threadplane surface, then swap in LangGraph or AG-UI when the integration is ready.` Primary `Start the quickstart` → the no-backend page; secondary `Run live examples`. A small `Talk to an engineer` link stays adjacent. + +## 8. The "Try without a backend" docs page + +New MDX page `apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx`, linked from the chat and both adapter getting-started sidebars. + +Sections: what you get (a real ``, no server, no key); install (`@threadplane/chat` plus one adapter, since `provideFakeAgent` ships in the adapter packages); provide `provideFakeAgent({ tokens })` in `app.config.ts`; the component with `injectAgent()`; run it; a focused test with `provideFakeAgent` in `TestBed`; "Connect a real adapter" with links to both adapter quickstarts and the runtime-parity explanation. + +Verification gate before launch: the page is followed verbatim on a clean `ng new` project with the published packages, and the run is recorded in the implementation plan with package versions. + +## 9. Single-source content + +### 9.1 `positioning.ts` + +Owns: hero strings, install variants, code snippets, coding-agent prompt, trust facts. + +```ts +export type InstallVariant = 'fake' | 'langgraph' | 'ag_ui'; +export interface InstallOption { + readonly key: InstallVariant; + readonly label: string; + readonly description: string; + readonly command: string; // full npm install line + readonly providerSnippet: string; // app.config.ts fragment + readonly quickstartHref: string; +} +export const INSTALL_OPTIONS: readonly InstallOption[]; +export const COMPONENT_SNIPPET: string; // injectAgent + , shared by dialog, parity, three steps +export const CODING_AGENT_PROMPT: string; +``` + +`PRIMARY_TAGLINE`, `LONG_SUBHEAD`, and `HERO_SUBHEAD` are replaced by the new strings; `HERO_CAPABILITIES` is removed once nothing imports it. `POSITIONING_PROOF_POINTS` stays for the OG image and keywords. + +### 9.2 Derived trust facts + +`apps/website/src/lib/compat-facts.ts` reads `libs/chat/package.json` at build time and exports `{ license, angularRange }`, formatting the peer range as `Angular 20–22`. A spec asserts the formatted string against the manifest. No component types a version. + +### 9.3 Drift guards + +- A spec asserts every `@threadplane/*` package in `INSTALL_OPTIONS[*].command` exists under `libs/*/package.json` and every other package is a declared peer of one of them. +- A spec asserts `COMPONENT_SNIPPET` and each `providerSnippet` compile-parse as TypeScript. +- The docs page's install command is the same `INSTALL_OPTIONS` entry rendered through an existing MDX component, not retyped. + +## 10. Metadata and structured data + +- Title `Threadplane — Angular AI Agent UI Framework`; 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.`; OG title aligned; canonical unchanged. +- Existing structured data (Organization, WebSite, SoftwareSourceCode, breadcrumbs) keeps its ids; only the description string changes. No FAQ, rating, offer, or review markup is added. +- `llms.txt`, `llms-full.txt`, `AGENTS.md`, and the sitemap are untouched except that the new docs page appears in the docs sitemap through the existing generator. + +## 11. Analytics + +Stable family `marketing:cta_click`. Ids added to the events constants, their tests, and `docs/gtm/taxonomy.md`: + +| cta_id | Properties | Fires on | +|---|---|---| +| `hero_install` (kept) | `adapter` | copy in the dialog | +| `hero_install_open` | | dialog open | +| `hero_quickstart` | `adapter` | dialog footer link, final CTA primary | +| `hero_live_demo` | | hero text link, final CTA secondary | +| `hero_demo_takeover` | | frame reports `live` | +| `hero_demo_replay` | | frame reports `replay` | +| `hero_demo_play` | | "Play walkthrough" pressed | +| `home_runtime_parity_toggle` | `adapter` | parity toggle | +| `home_coding_agent_prompt` | | prompt copied | +| `home_production_readiness_expand` | | Yes Wall expanded | +| `home_adapter_guide` | | parity CTA | + +Never sent: copied commands, prompt text, chat input, endpoint URLs. + +## 12. Performance and accessibility + +- LCP is the server-rendered poster; hero copy and the dialog's initial markup are server-rendered. The iframe, syntax-highlighted panes below the fold, and DemoShowcase media load lazily. +- No layout shift on tab or toggle changes: parity and dialog panes are pre-rendered and shown with the same box; the poster and iframe share a box. +- One `h1`; sections keep `aria-labelledby`; all toggles are `radiogroup`s; the dialog traps focus; the Yes Wall expander is a real button; reduced motion disables the cursor animation and typing delay; logos are `aria-hidden`. +- The examples/chat production bundle budget and the website bundle budget gates are unchanged and must pass. + +## 13. Testing + +**Website (Vitest):** `HeroDemo` state machine (poster, mount gating by width and reduced motion, ready crossfade, fallback timeout, origin check, play button), `InstallDialog` (variant switch, copy, events with `adapter`), `RuntimeParity` (toggle switches only the config pane), `YesWall` (8 shown, expand to all, count from data), `LogoRibbon` (three labeled groups, logos hidden), drift specs from section 9. + +**examples/chat (Vitest):** `ReplayTransport` (timing with fake clock, abort), script runner (visible gating, pause and resume, takeover stops it), `HeroMode` (agent signal swap, divider and status pill, replay resets), fixture shape spec. + +**E2E:** website Playwright: hero CTAs reach their destinations, dialog is keyboard-operable and the copied text equals the visible command, poster renders before the frame, mobile shows the play button. examples/chat Playwright: `/hero` runs the replay to the interrupt, takeover shows the live pill and a new thread, replay restarts. Both twin suites (chat and ag-ui) are updated where shared copy changed. + +**Manual gate before merge:** clean-app run of the no-backend page; Lighthouse desktop and mobile on the homepage compared with a baseline captured from production before the branch is deployed; screenshots at 1440, 768, and 390; claim audit table (license, Angular range, package names, adapter list, AG-UI backend names, persistence wording, telemetry defaults, self-hosting, no-account statements) with a source file for each. + +## 14. Out of scope and deferred + +Comparison pages, product-page title changes, README and npm description alignment, OG image redesign, a public skill file, the content program, GSC and PostHog baseline reviews, and deleting `StackDiagramSection` and `HomeConceptGrid`. Each is a separate spec. From a367f4712e3361ff34def15ddafb0b6eae712a09 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 17:03:42 -0700 Subject: [PATCH 02/23] =?UTF-8?q?docs(specs):=20homepage=20rebuild=20?= =?UTF-8?q?=E2=80=94=20pin=20/hero=20route=20design=20to=20verified=20adap?= =?UTF-8?q?ter=20and=20demo=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../2026-09-02-homepage-rebuild-design.md | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md b/docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md index 69f058b24..dbdfe985c 100644 --- a/docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md +++ b/docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md @@ -28,7 +28,7 @@ The page optimizes for comprehension, quickstart entry, and activation. Not for | Takeover gesture | Visible "Take control" pill, plus any pointer-down or focus inside the surface. | | Primary CTA | "Install Threadplane" opens an install dialog. The dialog's quickstart link and the final CTA land on a new "Try without a backend" docs page. | | No-backend path | Shipped in this release. A new docs page, verified from a clean `ng new` app before launch. | -| Yes Wall | Eight questions shown (two per group), in-place expand to all seventeen. No new route. | +| Yes Wall | Eight questions shown (two per group), in-place expand to all sixteen. No new route. | | Runtime parity | Its own section below the hero, not merged into it. | | Branch base | `origin/main`. The old brainstorm branch carries one stray, superseded commit and is not used. | @@ -90,21 +90,20 @@ poster → playRequested (mobile or reduced-motion: user pressed "Play walkthrou ### 4.3 The `/hero` route (examples/chat side) -A new `HeroMode` component registered beside `embed`, `popup`, and `sidebar`, hosted under `DemoShell` so it inherits client tools, the A2UI catalog, and model options. - -- **Agent as a signal.** `HeroMode` binds ``. It starts on a replay agent created with `provideAgent`-equivalent wiring around a `ReplayTransport`; takeover sets the signal to the shell's live `DEMO_AGENT`. Verify during implementation that `` re-binds cleanly when its `agent` input changes; if it does not, wrap the composition in `@if` keyed on the agent so it remounts. -- **ReplayTransport** implements the LangGraph adapter's `AgentTransport`. `stream()` yields the events of a committed fixture (`examples/chat/angular/src/assets/hero-replay.json`) honoring each event's relative timestamp, scaled by a speed factor, and aborts on the signal. Two recorded runs are stored: the initial prompt run, and the resume run after approval. `joinStream` and `createQueuedRun` are not implemented. -- **Recording** is a one-off `RecordingTransport` wrapper around the real transport, enabled by a query flag in dev only, that captures `{ tMs, event }` pairs to the console for pasting into the fixture. The fixture is regenerated when the demo backend's prompt or tools change; a spec asserts the fixture parses and contains a tool call, an interrupt, and a generative payload. -- **Script runner** is a small pure state machine with an injected clock so it is testable: - 1. wait for `visible`; - 2. show the cursor SVG at the composer, type the prompt character by character at 40ms; - 3. submit through the agent API (`agent.submit`), not by dispatching DOM events; - 4. wait for the interrupt to appear, move the cursor to the Approve control over 600ms, press it visually, then call the agent's resume path with the same payload the panel would send; - 5. wait for the run to end, hold 4s, then loop with a Replay affordance visible. - It pauses on `visible: false` and on `document.hidden`, and resumes where it left off. Under `prefers-reduced-motion` typing is instant and the cursor still moves but does not animate. -- **Status pill** in the frame's top bar: `● Replaying a recorded LangGraph run` while scripted; `● Live · LangGraph · new thread` after takeover. This is the honesty marker and must never be hidden. -- **Takeover.** Triggered by the "Take control" pill, `pointerdown` anywhere on the surface, or `focusin` on any control inside it. It stops the runner, hides the cursor, sets the agent signal to the live agent on a fresh thread, dims the replayed transcript to 45% opacity, inserts the divider "You are live. The walkthrough above was a recording; this thread is new.", and shows the existing welcome suggestion chips. A "Replay walkthrough" link returns to the scripted state on a fresh replay agent. -- The live agent uses the shell's existing LangGraph config and telemetry. Nothing new is configured for the backend. +A new top-level lazy route `/hero` with its own `HeroMode` component. It is deliberately **not** hosted under `DemoShell`: the shell's `provideAgent` uses module-scope thread state and a palette, neither of which the hero wants. `HeroMode` provides two agents at component level through two `AgentRef`s: + +- **Replay agent**: `provideAgent(HERO_REPLAY_REF, () => ({ assistantId: 'hero-replay', transport: inject(HeroReplayTransport) }))`. +- **Live agent**: `provideAgent(HERO_LIVE_REF, () => ({ apiUrl: environment.langGraphApiUrl, assistantId: environment.assistantId, threadId, onThreadId }))`, the same backend the canonical demo uses. + +`HeroMode` binds `` and ``; `activeAgent` is a signal that starts on the replay agent and switches to the live agent on takeover. `ChatComponent.agent` is a signal input and every internal read goes through it, so the swap is a plain input change. + +- **HeroReplayTransport** (injectable) implements the LangGraph adapter's `AgentTransport`. It loads `public/hero-replay.json` once (served at `/hero-replay.json`, so the recording never enters the initial bundle), then answers each `stream()` call with the next recorded run in order, pacing events by their recorded gaps clamped to a 30 ms floor and a 600 ms ceiling, and stopping on the abort signal. `reset()` rewinds to the first run. `createQueuedRun`, `cancelRun`, `getHistory` are no-ops like `FakeStreamTransport`; `joinStream` yields nothing. +- **The recording** is three runs from one scripted session against the aimock-backed backend (deterministic, no API key): the destructive-cleanup prompt that pauses on `request_approval`, the resume run after Accept, and the contact-form prompt that renders an A2UI surface. A `HeroRecordingTransport` wraps `FetchStreamTransport`, stamps each event with milliseconds since the run started, and exposes the runs on `window.__heroRecording`. It is active only when `/hero?record=1` is opened in a non-production build. A Playwright `.record.ts` script drives it and writes the fixture. A spec asserts the fixture has three runs and contains an interrupt event and an A2UI payload. +- **Script runner** is a small class with an injected host interface and clock so it is unit-testable: wait for visible, show the cursor at the composer, type the first prompt character by character (40 ms), click Send, wait for `interrupt()` to be set, move the cursor to Accept (600 ms) and press it, wait for the run to finish, type the second prompt, send, wait for the run to finish, hold, then restart on a fresh replay (`switchThread(null)` on the replay agent plus `reset()` on the transport). It pauses on `visible: false` or `document.hidden` and resumes where it stopped. Under `prefers-reduced-motion` typing is instant and the cursor jumps. Typing and sending go through the real composer DOM (`textarea[aria-label="Type a message"]` and the Send button) because the composer has no public draft API; Accept goes through the same `onInterruptAction('accept')` path a user click takes. +- **Status pill** in the frame's top bar: `Replaying a recorded LangGraph run` while scripted; `Live · LangGraph · new thread` after takeover. This is the honesty marker and is never hidden. +- **Takeover.** Triggered by the "Take control" pill, `pointerdown` anywhere on the surface, or `focusin` on any control inside it. It stops the runner, hides the cursor, and swaps `activeAgent` to the live agent, whose thread is new and empty. A banner above the composer reads "You are live on a new LangGraph thread. The walkthrough was a recording." and the existing welcome suggestion chips render. A "Replay walkthrough" link returns to the replay agent and restarts the script. (Brainstorm mockups showed the replayed transcript dimmed under the live one; `` renders one agent's transcript, so the banner replaces the dimmed transcript.) +- **Bridge.** The frame posts `{ type: 'tplane-hero', state }` to `window.parent` and accepts `{ type: 'tplane-hero', visible }` only from an allowlisted parent origin (threadplane.ai, www.threadplane.ai, localhost:3000, 127.0.0.1:4308). +- The live agent uses the demo's existing LangGraph config and telemetry. Nothing new is configured for the backend. ### 4.4 InstallDialog @@ -156,7 +155,7 @@ Persist reuses the Ship block's panes. Test has a code pane only. Each block kee **CodingAgentQuickstart.** Heading `Give your coding agent the Angular agent UI playbook.` The brief's seven-step prompt rendered from `positioning.ts` in a code block with copy. Links: `Copy setup prompt`, `Read AGENTS.md` (`/AGENTS.md`), `Open the full agent reference` (`/llms-full.txt`), `Start the human quickstart`. Event `home_coding_agent_prompt`; prompt text never enters analytics. -**YesWall.** Data unchanged. Renders the first two questions of each group; a button `See all 17 production-readiness questions` (count computed from data) expands the rest in place with `aria-expanded` and `aria-controls`. Event `home_production_readiness_expand`. The aside copy is regenerated from the count. Questions whose answer depends on the backend keep their existing qualification in the API pairing. +**YesWall.** Data unchanged. Renders the first two questions of each group; a button `See all 16 production-readiness questions` (count computed from data) expands the rest in place with `aria-expanded` and `aria-controls`. Event `home_production_readiness_expand`. The aside copy is regenerated from the count. Questions whose answer depends on the backend keep their existing qualification in the API pairing. **ScopeTable.** Heading `Why Threadplane`. The brief's four-row table (raw stream SDK, backend agent framework, generative-UI renderer, React-first agent UI) as a static, responsive table that scrolls horizontally inside its own container on narrow widths. No competitor is named. @@ -199,7 +198,7 @@ export const CODING_AGENT_PROMPT: string; ### 9.2 Derived trust facts -`apps/website/src/lib/compat-facts.ts` reads `libs/chat/package.json` at build time and exports `{ license, angularRange }`, formatting the peer range as `Angular 20–22`. A spec asserts the formatted string against the manifest. No component types a version. +The Angular range comes from the existing `WEBSITE_SUPPORTED_ANGULAR_MAJORS` in `apps/website/src/components/pricing/angular-support.mjs`, which `angular-support-copy.spec.ts` already verifies against `libs/langgraph/package.json`. A `formatAngularRange()` helper renders it as `Angular 20–22`. The license word in the trust line is asserted by a spec against `libs/chat/package.json`'s `license` field. No component types a version. ### 9.3 Drift guards From 3677c715140c608139d7fbb8befcd5dc2946e05b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 17:17:31 -0700 Subject: [PATCH 03/23] =?UTF-8?q?docs(plans):=20homepage=20rebuild=20?= =?UTF-8?q?=E2=80=94=20hero=20demo=20route=20and=20website=20implementatio?= =?UTF-8?q?n=20plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-09-02-hero-demo-route.md | 1844 ++++++++++++ .../plans/2026-09-02-homepage-rebuild.md | 2601 +++++++++++++++++ 2 files changed, 4445 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-02-hero-demo-route.md create mode 100644 docs/superpowers/plans/2026-09-02-homepage-rebuild.md 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..17090be86 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-homepage-rebuild.md @@ -0,0 +1,2601 @@ +# 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. + useEffect(() => { + if (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, streamed reply, tool progress and an approval panel + {mounted ? ( +