From 96c400662a5eeebcae8ca9ddd39225faeeb41456 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 14:16:47 -0700 Subject: [PATCH 01/22] =?UTF-8?q?docs(specs):=20homepage=20live=20stage=20?= =?UTF-8?q?=E2=80=94=20one=20real=20run,=20scrubbed=20by=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../2026-09-05-homepage-live-stage-design.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md diff --git a/docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md b/docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md new file mode 100644 index 000000000..a07aeafa4 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md @@ -0,0 +1,173 @@ +# Homepage live stage: one real run, scrubbed by scroll + +**Date:** 2026-09-05 +**Status:** Design approved in brainstorming; awaiting spec review. +**Surface:** `apps/website` (homepage), `examples/chat/angular` (a new `/stage` route and a seekable replay), `libs/langgraph` (only if the seek needs an adapter hook). +**Supersedes:** the homepage section order in `2026-09-02-homepage-rebuild-design.md` §3 and §6–7. The hero, the install dialog, and the single-source content rules in that spec stand. + +## 1. Why + +The homepage has twenty blocks and no argument. "Two runtimes, same code" is told four times; the install path three times; "try without a backend" five times; nine of twelve FAQ answers restate a section above them. The five capability sections are the strongest content on the page and they are five identical layouts, which reads as a list. Nothing below the hero is alive. The reliability evidence, the page's best proof for a 0.0.x library, is a strip. The final-mile argument, the page's reason to exist, sits fourteenth. + +The audit that established this is in the brainstorming transcript; the numbers above were counted on the live page on 2026-09-05. + +## 2. Decisions + +| Decision | Choice | +|---|---| +| Page grammar | Live surface (scroll-craft's term): a real product surface rises into the frame and the argument stacks beneath it. Chaptered acts were the alternative and lost because four separate animations would still read as four sections. | +| The stage | The real Angular demo app in an iframe, real `` on the left and the real `` devtools on the right. Not a website-side facsimile. | +| What drives it | Scroll. The parent page maps scroll progress to recorded time and the iframe applies a recorded run up to that time. The wheel is a scrubber over a real run. | +| The recording | A live capture through the existing `HeroRecordingTransport`, one longer scripted run covering four beats. Not an aimock fixture: aimock replays are near-atomic and carry no token stream. | +| The one peak | The interrupt. The scrubber holds at the pause for a fixed stretch of scroll while only the copy rail advances; scrolling past the threshold is the approval. | +| Runtime parity | Dropped from the homepage as a section. It lives in the hero eyebrow, the works-with logos, the final CTA's swap line, and `/docs/choosing-an-adapter`. | +| Reliability | Kept as its own section directly under the hero (where today's logo ribbon and proof strip sit, merged): today's four sourced cells plus three receipts (signed provenance, three runtimes end to end, no content telemetry and no cloud). | +| Hero | Unchanged. It stays the autoplay teaser; the stage a screen below is "now you drive it". | +| Scroll engine | Vendor scroll-craft's `engine/scrollcraft.js` (MIT, Nate Herk, pinned at commit `0b81622`, 2026-09-04) as an unmodified client module loaded on the homepage only. Do not vendor its CSS; write the few device rules under the website's own style contract. | +| Copy rules | scroll-craft's hard rules that concern honesty are adopted (no invented numbers, no scroll cues, no counters, no dead scroll, one peak, a resolving ending). Its typographic taste rules are not: this site's voice doc governs, including its em dashes, and section eyebrows remain a website design element. | + +## 3. Page architecture + +Eight blocks between the header and the footer, in this order. + +1. **Hero.** As shipped in #1011. The replayed LangGraph run with live takeover. +2. **Reliability.** Heading and eyebrow as today ("Reliable to the core", "Audited, scored, published. Not self-reported — every number links to its source."). The four cells stay: rank, OpenSSF Scorecard, live HVTrust grade, Angular majors. A second line of three receipts in the same grammar: *Signed provenance on every release* (npm provenance attestations and the SLSA file on each GitHub release), *Three runtimes exercised end to end* (LangGraph, AG-UI, Mastra; links to the portability matrix), *No content telemetry, no cloud* (links to the telemetry README). The works-with logos become this section's footer line with a "Choose an adapter →" link. `LogoRibbon` and `ProofStrip` merge into one component. +3. **The final mile.** The launch narrative's line as the heading: "Angular teams are building agents. The last mile is still messy." The existing `ScopeTable` beneath it, unchanged. +4. **The stage.** One pinned act, about six viewport-heights of scroll, described in §4–6. +5. **Prove it without a backend.** The current `FinalCTA` absorbs the Test section's three rows (`provideFakeAgent()`, scripted tool calls and interrupts, same UI code in test and production) above its existing headline and buttons. One install moment on the page. +6. **For teams.** `PilotBlock` and `WhitePaperBlock` merge: the four phases, the field report, one email form. The footer's newsletter form is removed; the field report form is the page's one form. +7. **FAQ.** Four questions: is Threadplane a backend agent framework; can I use my existing component library; does generated UI execute arbitrary code; does Threadplane require a hosted service. The other eight move to `/docs` where they already have homes, and the adapter-difference answer moves to the adapter guide. +8. **Recent articles.** Unchanged. + +Removed: `RuntimeParity`, `ThreeSteps`, `DemoShowcase`, `CodingAgentQuickstart`, the standalone Test `FeatureBlock`, eight FAQ entries, the footer newsletter form. `CodingAgentQuickstart`'s content moves to a docs page linked from the final CTA caption; `AGENTS.md` and `llms-full.txt` stay where they are. + +The five `FeatureBlock`s and the `MediumSwitcher` remain as components for the solutions and docs pages; the homepage stops using them. + +## 4. The stage + +### 4.1 Layout + +A `
` whose sticky stage holds two columns on desktop: the iframe (about 60% width) and the narrative rail (about 40%). The rail's beats are `data-sc-cue` blocks keyed to the act's progress, using the greet form for the first beat and a closing two-value window on the last, per the engine's cue contract. The iframe is the ground the pinned stage needs before its progress leaves zero: it shows the recording's first frame the moment it is mounted. + +### 4.2 The `/stage` route + +A new route in `examples/chat/angular`, sibling to `/hero`. It renders `` and `` side by side, with the interrupt panel above the chat exactly as `/hero` does. The agent is a replay agent over a new `StageReplayTransport`. There is no live mode and no takeover; a "Open the live demo" link in the frame's bar goes to the demo. + +### 4.3 The protocol + +The parent already speaks to the hero iframe with `{ type: 'tplane-hero', visible }`. The stage adds one message each way. + +- Parent → iframe, on every animation frame while the act is on screen and the value changed: `{ type: 'tplane-stage', t }` where `t` is recorded milliseconds from the start of the run, computed from the act's `--sc-p` through the beat map (§5.2). Origin-checked both ways with the same allowlist the hero uses. +- Iframe → parent, whenever the applied state changes: `{ type: 'tplane-stage', applied: , phase: 'stream' | 'persist' | 'pause' | 'resume' | 'render' }`. The parent writes `applied` and `phase` into `data-sc-verify-state` on the stage element, which is what lets the verification harness see a bespoke stage (§8). + +### 4.4 The seekable replay + +`StageReplayTransport` loads the committed recording and exposes `seek(t)`. Forward seeks apply every event with `tMs <= t` that has not yet been applied, in order, in one synchronous batch per frame. Backward seeks reset the agent (`switchThread(null)` plus a transport reset) and fast-forward to `t` in the same batch. Rewinds are throttled to one per frame and coalesced, so a fast upward flick costs one reset, not one per wheel notch. + +The run is a single `stream()` per turn in the recording, exactly as the hero's recording is. The transport concatenates the runs into one timeline: run boundaries are the beat boundaries, and the resume turn begins where the pause ends. + +The Persist beat's leave-and-return is recorded, not simulated: the script reloads the thread mid-recording, so the recording carries a `getHistory` response and a fresh `values` event. On replay, that moment is a reset-and-restore from the recorded checkpoint values, which is what the product does on a real reload. + +### 4.5 The devtools pane + +``'s Timeline and State inspectors are the product's own. Nothing is added to them for the homepage. The State tab is what an engineer watches: `status()`, the tool calls and their states, the interrupt, the thread id and checkpoint count, `error()`. If the inspectors need a "read-only, driven by replay" affordance it is a prop on the existing component, not a fork. + +## 5. The recording + +### 5.1 The script + +One live capture via `record-hero-live.config.ts`'s pattern, driven by a stage script in the `/stage` route's record mode (`/stage?record=1`), against the real backend, written to `public/stage-replay.json`. Beats, in order, each a real turn: + +1. **Stream.** "Use the search tool to find authoritative information about Angular signals, then explain what they are and when to use them." Tokens stream, a `search_documents` call runs and completes, citations attach. +2. **Persist.** The script reloads the thread (the transport records the history fetch and the restored values), then sends "Shorter, please" so a new checkpoint is created, then forks from the previous checkpoint through the agent's branch API (the same call `chat-timeline-slider`'s Fork button makes) so the recording carries a branch. +3. **Approve.** "Clean up our old database backups, anything older than 90 days." `list_backups` renders its table; `delete_backups` pauses. The script accepts; the resume executes the deletion and the audit lands. This is the beat #1011 made real. +4. **Render.** "Show me a contact form with fields for name, email address, subject, and a multi-line message, plus a Send button." The A2UI surface streams and mounts. + +The recording is committed like `hero-replay.json`, validated by `stage-replay.fixture.spec.ts`: four beats in order, a `getHistory` in beat 2 with a branch, `list_backups` then `delete_backups` with the interrupt inside the tool in beat 3, a resume answer under 1,400 characters, an A2UI payload in beat 4, no API keys. Its prose is never edited; take several takes and commit a complete one. + +### 5.2 The beat map + +Scroll progress maps to recorded time per beat, not uniformly. Each beat owns a share of the act's span, with easing so a beat's copy lands on its settle. The map is data in one file, `stage-beats.ts`, and the recording's own timestamps define the boundaries, so the rail's cue windows are derived from the same numbers and cannot drift from the recording. + +| Beat | Share of span | Recorded time → scroll | Rail copy (from positioning.ts) | +|---|---|---|---| +| Stream | 1.3vh | linear | "The UI stays reactive through tokens, tools, errors, and state changes." + the three Stream rows | +| Persist | 1.2vh | linear, with the reload settled at the beat's midpoint | "A user can leave, return, inspect history, and continue." + the three Persist rows | +| Approve | 2.4vh | §6 | "Irreversible work pauses for a human decision." + the three Approve rows | +| Render | 1.1vh | linear, last 15% held on the mounted form | "Agent output becomes components from your design system." + the three Render rows | + +The act's last cue holds, so the stage ends on the mounted form and the completed devtools state rather than fading before the next section. + +### 5.3 The feeling curve + +Written per scroll-craft's method, before the beats were assigned. + +| Beat | Feeling | What causes it | +|---|---|---| +| Stream | Recognition | A chat they have built before, except the devtools beside it shows the state as signals, updating under their hand | +| Persist | Confidence | The transcript goes blank and comes back from a checkpoint; the timeline grows a branch | +| (silence) | Attention | The cleanup prompt is sent and the table lists what would go; the devtools shows `delete_backups` running. Nothing else moves. | +| Approve | Tension, then resolve | The panel appears and the wheel stops doing anything to the run. Then they scroll past the line, and it executes | +| Render | Delight | Structured output becomes a form made of the app's own components | + +**The peak, as a visitor would tell it:** "You scroll and the agent lists what it wants to delete, and then it just stops. Scrolling does nothing. You have to decide, and when you do, it actually deletes them." + +**Tell-someone sentence:** "It's the site where the agent won't delete anything until you scroll past the line." + +## 6. The peak + +The Approve beat's span is the largest on the page by a visible margin, and the silence before it is authored: the stretch from sending the cleanup prompt to the panel appearing carries no rail copy beyond the beat heading. + +Within the beat, scroll maps to time in three ranges: + +- **0–35%:** the prompt, the `list_backups` call, the table. Time advances linearly. +- **35–70%:** the hold. Recorded time is pinned at the interrupt. The panel is rendered by the real ``; its buttons are visible and inert (`pointer-events: none` on the panel inside `/stage`). The devtools State tab shows the interrupt. The rail advances through "The pause is a checkpoint, not a modal", "The run is frozen in durable state. Scroll all you like; nothing happens until someone decides", and "Keep scrolling to approve". The stage sets `data-sc-verify-hold="true"` for this range so the harness reads it as an authored hold rather than dead scroll. +- **70–100%:** the threshold and the resume. Crossing 70% dispatches the recorded resume; time advances through the deletion and the audit. Scrolling back above 70% rewinds to the hold: the recording rewinds, the checkpoint is still there, and the copy says so. + +The threshold is a scroll position, not a click. This was chosen over live buttons because it never breaks the scroll flow, needs no skip affordance, and does not leave a pin holding indefinitely for keyboard and screen-reader users. + +## 7. Smoothness + +- **One playhead.** The parent computes the target `t` from `--sc-p` each frame; the engine's lerp (0.18 per frame, 1.0 under reduced motion) is what turns wheel jitter into a glide. The stage applies `t` on its own animation frame inside the iframe; the parent never waits on it. +- **Batched application.** All events up to `t` apply in one synchronous pass before change detection runs once. The recording's ~2,000 events across four beats apply in well under a frame when batched; the hero's replay already proves the per-event cost. +- **No layout on the scroll thread.** The parent's scroll handler writes one custom property and posts one message. Everything that paints is inside the iframe or is a CSS cue driven by `--sc-p`. +- **Rewind budget.** A reset-and-fast-forward is bounded by the event count up to `t`; snapshot checkpoints every 200 events keep worst-case rewinds under a frame. Measured, not assumed: the stage spec asserts a full rewind from the end completes under 16 ms in the unit environment. +- **Monotonic mapping.** Time never moves backward while scroll moves forward. The beat map is a piecewise monotonic function and a spec pins that. + +## 8. Fallbacks and verification + +- **Below 1024px, or `prefers-reduced-motion: reduce`:** the act does not pin. The four beats render as four stacked stills (chat plus devtools), captured by a Playwright recorder from `/stage` at each beat's settle, the same way the hero posters are made. Each still carries its beat's rail copy beneath it. Under reduced motion on desktop the same stills are used; nothing scrubs. +- **No JavaScript, or the recording fails to load:** the stills. +- **The iframe never loads before hydration, never on mobile, and never under reduced motion** — the same rules the hero's spec set for its iframe, for the same performance reasons. +- **Verification harness.** scroll-craft's `shoot.mjs` runs against `next start` in the website e2e: six positions per act, dead-scroll detection through `data-sc-verify-state`, cues that never reach full opacity, and per-line contrast on the composited frame, at desktop, 390px, and reduced motion. Its contact sheet is an artifact of the run. The one authored hold is declared through `data-sc-verify-hold`, so a green run means every other screen of scroll changes something. +- **Accessibility.** The rail is real markup in reading order; the iframe has a title and the stills have alt text describing the beat's state. The stage is skippable with one Tab, and `--sc-p`-driven cues never hide content that is not also present in the rail's DOM. + +## 9. Analytics and success + +Events: `home_stage_enter`, `home_stage_beat` with the beat name on first entry to each beat, `home_stage_threshold` on the first crossing, `home_stage_complete` on reaching the end, plus the existing CTA click events. The win condition, read from the existing funnel: a higher share of homepage sessions reaching the final CTA and a higher install-click rate than the current page, compared over two weeks after shipping against the two weeks before. + +## 10. Testing + +- **Website (Vitest):** the beat map is monotonic and its boundaries equal the recording's run boundaries; the stage publisher posts `t` only when it changes and only while the act is on screen; the still fallback renders four beats with their copy; the merged Reliability section renders four cells and three receipts with hrefs; the FAQ has four entries; the public-copy contract scan passes with the new rail copy in `positioning.ts`. +- **Demo (Vitest):** `StageReplayTransport.seek` forward applies exactly the events up to `t`; backward resets and restores; a seek inside the hold range does not advance past the interrupt; the threshold dispatches the resume once; a full rewind completes under the budget. +- **Recording spec:** as in §5.1. +- **e2e:** the homepage stage reaches each beat under headless scrolling and the `data-sc-verify-state` signature changes between positions; `shoot.mjs` reports no dead scroll outside the declared hold and no contrast failure; the `/stage` route renders the interrupt panel inert. + +## 11. Out of scope + +Replacing the hero; any change to the demo's live mode or the live takeover; the docs pages that receive the moved FAQ entries and the coding-agent prompt (a follow-up with its own links); a website-side scroll engine of our own (the vendored engine is used as is; if it proves unfit, replacing it is a separate decision); the solutions pages, which keep `FeatureBlock` and `MediumSwitcher`. + +## 12. Open risks + +- **Iframe cost at 60 fps.** One `postMessage` per changed frame is cheap; the risk is change detection inside the iframe on a large batch. The mitigation is the batched apply and the snapshot ring. If measurement shows a frame budget miss on a mid-range laptop, the fallback is to apply on every second frame while the lerp keeps the motion smooth. +- **Recording fragility.** Four turns against a live model means more takes to get a complete recording. The fixture spec rejects incomplete takes; the script must never edit prose. +- **`chat-debug` layout inside a 60%-width frame.** The panel is designed to dock beside a full-width chat; a narrow stage may need it docked at the bottom on smaller desktop widths. Decide at implementation with a measurement, not a guess. + +## 13. Delivery + +Three plans, each shippable on its own and each leaving the homepage working. + +1. **Restructure.** The cuts and merges of §3 with the existing components: Reliability with its receipts and logo footer, the final mile promoted, the folded final CTA, the merged teams block, the four-question FAQ, the moved FAQ entries and coding-agent prompt landing in docs. No stage yet; the four capability `FeatureBlock`s stay in place until plan 3 replaces them. Measurable on its own against §9. +2. **The stage in the demo app.** The `/stage` route, `StageReplayTransport` with seek, the record mode and script, the committed `stage-replay.json` and its fixture spec, the still recorder. Verifiable in isolation by driving `/stage?t=` from a query parameter before the website exists. +3. **The stage on the homepage.** The vendored engine, the pinned act and rail, the protocol, the beat map, the fallbacks, `shoot.mjs` in e2e, analytics. Replaces the four `FeatureBlock`s. + From 0540d4fc33670145cc2bec15daa9f1355f9636ee Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 14:43:02 -0700 Subject: [PATCH 02/22] docs(plans): homepage restructure (live-stage plan 1 of 3) Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-09-05-homepage-restructure.md | 1382 +++++++++++++++++ 1 file changed, 1382 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-homepage-restructure.md diff --git a/docs/superpowers/plans/2026-09-05-homepage-restructure.md b/docs/superpowers/plans/2026-09-05-homepage-restructure.md new file mode 100644 index 000000000..ef88872e4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-homepage-restructure.md @@ -0,0 +1,1382 @@ +# Homepage Restructure Implementation Plan (live-stage plan 1 of 3) + +> **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:** Cut the homepage from twenty blocks to the eight-block spine in the live-stage spec §3, with Reliability merged and promoted, the final-mile argument promoted, one install moment, one teams block with one form, and a four-question FAQ, while the four capability `FeatureBlock`s stay in place until plan 3 replaces them with the stage. + +**Architecture:** Every change is a composition change in `apps/website/src/app/page.tsx` plus one new or merged component per block. New copy lives in `positioning.ts` (single-source rule), so the public-copy scan and the positioning spec cover it. Removed components are deleted with their specs; the e2e that pinned them is updated in the same task. Nothing in `examples/` or `libs/` changes. + +**Tech Stack:** Next.js app router (server components, `'use client'` where state is needed), Vitest with Testing Library (`// @vitest-environment jsdom`), Playwright website e2e, the website's unlayered CSS in `src/styles/landing.css`. + +**Spec:** `docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md` §3, §9 (analytics for this plan: no new events), §13 plan 1. + +--- + +## Conventions + +- Run website unit tests from the repo root: `npx nx test website` (the whole suite) or `cd apps/website && npx vitest run ` for one file. Never run the suite from inside `apps/website` for `cockpit-retirement.spec.ts`; it resolves paths from the repo root. +- Website e2e: `npx playwright test -c apps/website/e2e/playwright.config.ts `. Check the config path exists before the first run; if the config lives elsewhere, `grep -rn "testDir" apps/website/e2e/*.ts` finds it. +- Lint: `npx nx lint website 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -E "problems|error "`; zero errors, warnings tolerated. +- Commit after every task, message ending with `Co-Authored-By: Claude Fable 5.1 `. +- Branch off `origin/main` after `git fetch`: `git checkout -b blove/homepage-restructure origin/main`. + +## File structure + +| File | Responsibility | +|---|---| +| `apps/website/src/lib/positioning.ts` (modify) | New copy: final-mile heading and aside, reliability receipts, prove-it rows. | +| `apps/website/src/lib/positioning.spec.ts` (modify) | Pins the new copy and that every receipt links to a human page. | +| `apps/website/src/components/landing/Reliability.tsx` (new) | Merged proof band: four cells, three receipts, works-with logo footer with the adapter link. Replaces `ProofStrip.tsx` and `LogoRibbon.tsx`. | +| `apps/website/src/components/landing/Reliability.spec.tsx` (new) | Carries every guard from `ProofStrip.spec.tsx` and `LogoRibbon.spec.tsx`, plus the receipts. | +| `apps/website/src/components/landing/ScopeTable.tsx` (modify) | Becomes "The final mile": new heading and aside from positioning. | +| `apps/website/src/components/landing/FinalCTA.tsx` (modify) | Optional `rows` and `captionLinks` props. | +| `apps/website/src/components/landing/WhitePaperForm.tsx` (new) | The signup form and its states, extracted from `WhitePaperBlock.tsx` so two blocks can share it. | +| `apps/website/src/components/landing/TeamsBlock.tsx` (new) | Pilot copy, outcomes, four phases, and the field report form. Replaces `PilotBlock` on the homepage. | +| `apps/website/src/components/landing/HomeFAQ.tsx` (modify) | Four questions. | +| `apps/website/content/docs/chat/getting-started/coding-agents.mdx` (new) + `docs-config.ts` | The coding-agent prompt's new home. | +| `apps/website/content/docs/choosing-an-adapter/index.mdx`, `langgraph/guides/persistence.mdx`, `chat/getting-started/try-without-a-backend.mdx` (modify) | Receive the moved FAQ answers. | +| `apps/website/src/components/shared/SiteFooter.tsx`, `Footer.tsx` (modify) | Newsletter form hidden on `/`. | +| `apps/website/src/app/page.tsx` (modify) | The new composition. | +| Deleted | `ProofStrip.tsx` + spec, `LogoRibbon.tsx` + spec, `RuntimeParity.tsx`, `RuntimeParityToggle.tsx` + spec, `ThreeSteps.tsx`, `DemoShowcase.tsx` + spec, `CodingAgentQuickstart.tsx` + spec, `PilotBlock.tsx`. | +| `apps/website/e2e/website.spec.ts`, `demo-modal.spec.ts` (modify) | Section assertions and the demo modal's entry page. | +| `apps/website/src/styles/landing.css` (modify) | Receipts, logo footer, final-CTA rows, teams grid. | + +--- + +### Task 1: Copy constants + +**Files:** +- Modify: `apps/website/src/lib/positioning.ts` +- Test: `apps/website/src/lib/positioning.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `positioning.spec.ts` inside its top-level `describe` (or as a new `describe` at the end of the file): + +```ts +describe('homepage restructure copy (live-stage spec §3)', () => { + it('pins the final-mile heading and aside', async () => { + const { FINAL_MILE_HEADING, FINAL_MILE_ASIDE } = await import('./positioning'); + expect(FINAL_MILE_HEADING).toBe('Angular teams are building agents. The last mile is still messy.'); + expect(FINAL_MILE_ASIDE).toBe('What you start with, and what Threadplane adds.'); + }); + + it('carries three reliability receipts, each linking a human-readable page', async () => { + const { RELIABILITY_RECEIPTS } = await import('./positioning'); + expect(RELIABILITY_RECEIPTS.map((r) => r.claim)).toEqual([ + 'Signed provenance on every release', + 'Three runtimes exercised end to end', + 'No content telemetry, no cloud', + ]); + for (const r of RELIABILITY_RECEIPTS) { + expect(r.sourceLabel.length).toBeGreaterThan(0); + const { hostname, pathname } = new URL(r.sourceHref, 'https://threadplane.ai'); + expect(hostname.startsWith('api.'), r.sourceHref).toBe(false); + expect(pathname.startsWith('/api/'), r.sourceHref).toBe(false); + } + }); + + it('carries the three prove-it rows the final CTA absorbs from the Test section', async () => { + const { PROVE_IT_ROWS } = await import('./positioning'); + expect(PROVE_IT_ROWS).toEqual([ + { claim: 'No key, no server, no network', api: 'provideFakeAgent()' }, + { claim: 'Script tool calls and interrupts', api: 'mockLangGraphAgent()' }, + { claim: 'Same UI code in test and production', api: 'Agent' }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd apps/website && npx vitest run src/lib/positioning.spec.ts` +Expected: 3 FAIL, `FINAL_MILE_HEADING` undefined. + +- [ ] **Step 3: Add the constants** + +In `positioning.ts`, after the `HERO_TRUST_LINE` line, add: + +```ts +// ── The final mile (live-stage spec §3, block 3) ───────────────────────────── +export const FINAL_MILE_HEADING = 'Angular teams are building agents. The last mile is still messy.'; +export const FINAL_MILE_ASIDE = 'What you start with, and what Threadplane adds.'; + +// ── Reliability receipts (spec §3, block 2). Each links a page a human can read; +// the sourced numbers stay in Reliability.tsx beside them. ─────────────────── +export interface ReliabilityReceipt { + readonly claim: string; + readonly detail: string; + readonly sourceLabel: string; + readonly sourceHref: string; +} +export const RELIABILITY_RECEIPTS: readonly ReliabilityReceipt[] = [ + { + claim: 'Signed provenance on every release', + detail: 'npm provenance attestations from OIDC trusted publishing, and a SLSA provenance file on each GitHub release.', + sourceLabel: 'npmjs.com · provenance', + sourceHref: 'https://www.npmjs.com/package/@threadplane/chat#provenance', + }, + { + claim: 'Three runtimes exercised end to end', + detail: 'LangGraph, AG-UI and Mastra backends deployed and driven by browser tests on every merge, against one Angular contract.', + sourceLabel: 'runtime portability matrix', + sourceHref: '/docs/runtimes', + }, + { + claim: 'No content telemetry, no cloud', + detail: 'Operational facts about how the product runs, never prompts, messages or tool data. MIT, self-hosted, no account.', + sourceLabel: 'privacy policy', + sourceHref: '/privacy', + }, +]; + +// ── Prove it without a backend (spec §3, block 5): the Test rows the final CTA +// absorbs. ──────────────────────────────────────────────────────────────────── +export const PROVE_IT_ROWS = [ + { claim: 'No key, no server, no network', api: 'provideFakeAgent()' }, + { claim: 'Script tool calls and interrupts', api: 'mockLangGraphAgent()' }, + { claim: 'Same UI code in test and production', api: 'Agent' }, +] as const; +``` + +Before committing, confirm the two internal hrefs resolve: `ls apps/website/content/docs/runtimes` must list an index page (if the runtimes landing is at a different slug, use that slug and keep the label), and `/privacy` exists under `src/app/privacy`. + +- [ ] **Step 4: Run the tests and the public-copy scan** + +Run: `cd apps/website && npx vitest run src/lib/positioning.spec.ts src/lib/public-copy.spec.ts` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/lib/positioning.ts apps/website/src/lib/positioning.spec.ts +git commit -m "feat(website): single-source copy for the final mile, reliability receipts, and the prove-it rows + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: Reliability section (ProofStrip + LogoRibbon merged, receipts added) + +**Files:** +- Create: `apps/website/src/components/landing/Reliability.tsx` +- Create: `apps/website/src/components/landing/Reliability.spec.tsx` +- Modify: `apps/website/src/styles/landing.css` (after the `.proof-strip-source` rules, around line 1210) +- Delete: `ProofStrip.tsx`, `ProofStrip.spec.tsx`, `LogoRibbon.tsx`, `LogoRibbon.spec.tsx` (in Task 9, once `page.tsx` no longer imports them) + +- [ ] **Step 1: Write the failing spec** + +```tsx +// apps/website/src/components/landing/Reliability.spec.tsx +// @vitest-environment jsdom +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Reliability, PROOF_CELLS, RIBBON_ITEMS, RIBBON_MORE_COUNT } from './Reliability'; +import { RELIABILITY_RECEIPTS } from '../../lib/positioning'; + +describe('Reliability', () => { + it('renders four cells, each with a source link', () => { + render(); + expect(PROOF_CELLS).toHaveLength(4); + for (const cell of PROOF_CELLS) { + expect(screen.getByText(cell.caption)).toBeTruthy(); + expect(screen.getByRole('link', { name: cell.sourceLabel }).getAttribute('href')).toBe(cell.sourceHref); + } + }); + + it('renders the HVTrust grade as a live badge image, not text', () => { + render(); + expect(screen.getByAltText(/HVTrust grade/i).getAttribute('src')).toBe('https://hvtracker.net/badge/threadplane.svg'); + }); + + it('renders three receipts under the cells, each with a source link', () => { + render(); + const list = screen.getByRole('list', { name: 'Receipts' }); + expect(list.querySelectorAll('li')).toHaveLength(3); + for (const r of RELIABILITY_RECEIPTS) { + expect(screen.getByText(r.claim)).toBeTruthy(); + expect(screen.getByRole('link', { name: r.sourceLabel }).getAttribute('href')).toBe(r.sourceHref); + } + }); + + it('keeps the dark band, the id the e2e pins, the watermark, and the framing', () => { + const { container } = render(); + const section = container.querySelector('[data-ui="section"]'); + expect(section?.getAttribute('data-surface')).toBe('dark'); + expect(section?.getAttribute('id')).toBe('proof'); + const mark = container.querySelector('.proof-strip-watermark'); + expect(mark?.getAttribute('aria-hidden')).toBe('true'); + expect(mark?.textContent).toBe(''); + expect(screen.getByText('Reliable to the core')).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Audited, scored, published.' }).id).toBe('proof-heading'); + }); + + it('carries the works-with line as a compatibility claim with an adapter link', () => { + const { container } = render(); + expect(RIBBON_ITEMS).toHaveLength(8); + expect(screen.getByText('Works with')).toBeTruthy(); + for (const item of RIBBON_ITEMS) expect(screen.getByText(item.name)).toBeTruthy(); + expect(screen.getByText(`+ ${RIBBON_MORE_COUNT} more`)).toBeTruthy(); + for (const img of Array.from(container.querySelectorAll('img.reliability-logo'))) { + expect(img.getAttribute('aria-hidden')).toBe('true'); + expect(img.getAttribute('alt')).toBe(''); + } + expect(container.textContent).not.toMatch(/trusted by|customers|our clients|powered by/i); + expect(screen.getByRole('link', { name: 'Choose an adapter →' }).getAttribute('href')).toBe('/docs/choosing-an-adapter'); + }); + + it('links every number and receipt to a human-readable page, never a raw API', () => { + for (const href of [...PROOF_CELLS.map((c) => c.sourceHref), ...RELIABILITY_RECEIPTS.map((r) => r.sourceHref)]) { + const { hostname, pathname } = new URL(href, 'https://threadplane.ai'); + expect(hostname.startsWith('api.'), href).toBe(false); + expect(pathname.startsWith('/api/'), href).toBe(false); + } + }); +}); +``` + +- [ ] **Step 2: Run the spec to verify it fails** + +Run: `cd apps/website && npx vitest run src/components/landing/Reliability.spec.tsx` +Expected: FAIL, cannot resolve `./Reliability`. + +- [ ] **Step 3: Create the component** + +```tsx +// apps/website/src/components/landing/Reliability.tsx +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { SectionHeader } from '../ui/SectionHeader'; +import { WEBSITE_SUPPORTED_ANGULAR_MAJORS } from '../pricing/angular-support.mjs'; +import { RELIABILITY_RECEIPTS } from '../../lib/positioning'; + +interface ProofCell { + /** Big Garamond numeral, or null when the cell renders a live badge. */ + value: string | null; + suffix?: string; + caption: string; + sourceLabel: string; + sourceHref: string; +} + +/** + * Verified 2026-09-04 against live sources (homepage design spec, "Verification + * results"). The rank and score drift; re-verify on touch and never round up. + * The HVTrust grade is a LIVE badge on purpose: it sits near the A-band floor + * and has flipped several times in a month. + * + * Every href is a page a human can read. The Scorecard number comes from + * api.securityscorecards.dev, but the LINK goes to the scorecard.dev viewer. + */ +export const PROOF_CELLS: readonly ProofCell[] = [ + { value: '#8', suffix: 'of 119', caption: 'Of all agent frameworks ranked', sourceLabel: 'hvtracker.net', sourceHref: 'https://hvtracker.net/categories/agent-frameworks/' }, + { value: '8.2', suffix: '/10', caption: 'OpenSSF Scorecard, official scan', sourceLabel: 'scorecard.dev', sourceHref: 'https://scorecard.dev/viewer/?uri=github.com/cacheplane/angular-agent-framework' }, + { value: null, caption: 'HVTrust supply-chain grade, live', sourceLabel: 'hvtracker.net/agents', sourceHref: 'https://hvtracker.net/agents/threadplane/' }, + { + // Derived from the published peer range, never hardcoded. + value: `${WEBSITE_SUPPORTED_ANGULAR_MAJORS[0]}–${WEBSITE_SUPPORTED_ANGULAR_MAJORS.at(-1)}`, + caption: 'Angular majors supported, CI-tested', + sourceLabel: 'npmjs.com', + sourceHref: 'https://www.npmjs.com/package/@threadplane/langgraph', + }, +]; + +interface RibbonItem { name: string; logoSrc: string; } + +export const RIBBON_ITEMS: readonly RibbonItem[] = [ + { name: 'OpenAI', logoSrc: '/logos/providers/openai.svg' }, + { name: 'Anthropic', logoSrc: '/logos/providers/anthropic.svg' }, + { name: 'Gemini', logoSrc: '/logos/providers/google.svg' }, + { name: 'Bedrock', logoSrc: '/logos/providers/bedrock.svg' }, + { name: 'LangGraph', logoSrc: '/logos/langgraph.svg' }, + { name: 'AG-UI', logoSrc: '/logos/ag-ui.svg' }, + { name: 'CrewAI', logoSrc: '/logos/runtimes/crewai.svg' }, + { name: 'Mastra', logoSrc: '/logos/runtimes/mastra.svg' }, +]; +/** Azure OpenAI, Pydantic AI, Microsoft Agent Framework, AWS Strands. */ +export const RIBBON_MORE_COUNT = 4; + +/** + * The reliability section (live-stage spec §3, block 2): the sourced proof + * band, a second line of receipts in the same grammar, and the works-with line + * as its footer, so the two-runtime claim sits inside the trust argument. + * + * "Works with" is a compatibility claim, never a customer claim: logos are + * `alt="" aria-hidden` beside visible names, and no wording may imply these + * companies use Threadplane. Reliability.spec.tsx guards that. + */ +export function Reliability() { + return ( +
+ +
+ + +
+ ); +} +``` + +- [ ] **Step 4: Add the CSS** + +Append to `landing.css` directly after the `[data-ui="section"][data-surface="dark"] .proof-strip-source` rule: + +```css +/* Reliability receipts: a second line under the four cells, same grammar, + * smaller type. Reliability.tsx. */ +.reliability-receipts { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + list-style: none; + margin: 0; + padding: 24px 0 0; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} +.reliability-receipt { min-width: 0; } +.reliability-receipt-claim { + font-family: var(--font-inter); + font-size: 15px; + font-weight: 600; + line-height: 1.4; + color: var(--color-text-primary); + margin: 0; +} +.reliability-receipt-detail { + font-family: var(--font-inter); + font-size: 13px; + line-height: 1.5; + color: var(--color-text-secondary); + margin: 6px 0 0; +} +/* Works-with footer line, inside the dark band. */ +.reliability-works-with { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px 22px; + padding-top: 22px; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} +.reliability-works-with-label, +.reliability-works-with-more { + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-text-muted); +} +.reliability-works-with-item { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; } +.reliability-logo { width: 16px; height: 16px; object-fit: contain; } +.reliability-works-with-name { font-family: var(--font-inter); font-size: 13px; font-weight: 500; color: var(--color-text-secondary); } +.reliability-works-with-link { + margin-left: auto; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-accent); +} +@media (max-width: 860px) { + .reliability-receipts { grid-template-columns: 1fr; } + .reliability-works-with-link { margin-left: 0; } +} +``` + +Check the dark band's text tokens: if `--color-text-primary` is not light on `[data-surface="dark"]`, scope the two colour rules the way `.proof-strip-source` is scoped (`[data-ui="section"][data-surface="dark"] .reliability-receipt-claim`). + +- [ ] **Step 5: Run the spec and the style contract** + +Run: `cd apps/website && npx vitest run src/components/landing/Reliability.spec.tsx src/styles/style-contracts.spec.ts` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/components/landing/Reliability.tsx apps/website/src/components/landing/Reliability.spec.tsx apps/website/src/styles/landing.css +git commit -m "feat(website): Reliability section — proof cells, three receipts, works-with footer + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: The final mile (ScopeTable reframed) + +**Files:** +- Modify: `apps/website/src/components/landing/ScopeTable.tsx` +- Test: `apps/website/src/components/landing/ScopeTable.spec.tsx` (new) + +- [ ] **Step 1: Write the failing spec** + +```tsx +// apps/website/src/components/landing/ScopeTable.spec.tsx +// @vitest-environment jsdom +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { ScopeTable } from './ScopeTable'; +import { FINAL_MILE_ASIDE, FINAL_MILE_HEADING } from '../../lib/positioning'; + +describe('ScopeTable as the final-mile section', () => { + it('leads with the last-mile line and keeps the table and its anchor', () => { + const { container } = render(); + expect(screen.getByRole('heading', { name: FINAL_MILE_HEADING }).id).toBe('why-heading'); + expect(screen.getByText(FINAL_MILE_ASIDE)).toBeTruthy(); + expect(screen.getByText('The final mile')).toBeTruthy(); + expect(container.querySelector('[data-ui="section"]')?.getAttribute('id')).toBe('why'); + expect(screen.getAllByRole('row')).toHaveLength(5); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cd apps/website && npx vitest run src/components/landing/ScopeTable.spec.tsx` +Expected: FAIL on the heading name. + +- [ ] **Step 3: Reframe the header** + +In `ScopeTable.tsx`, add the import `import { FINAL_MILE_ASIDE, FINAL_MILE_HEADING } from '../../lib/positioning';` and replace the `SectionHeader` with: + +```tsx + +``` + +- [ ] **Step 4: Run and commit** + +Run: `cd apps/website && npx vitest run src/components/landing/ScopeTable.spec.tsx` +Expected: PASS. + +```bash +git add apps/website/src/components/landing/ScopeTable.tsx apps/website/src/components/landing/ScopeTable.spec.tsx +git commit -m "feat(website): the scope table leads with the last-mile line + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: FinalCTA absorbs the Test rows and a second caption link + +**Files:** +- Modify: `apps/website/src/components/landing/FinalCTA.tsx` +- Modify: `apps/website/src/components/landing/FinalCTA.spec.tsx` +- Modify: `apps/website/src/styles/landing.css` + +- [ ] **Step 1: Write the failing tests** + +Append inside `describe('FinalCTA', ...)`: + +```tsx + it('renders optional rows above the headline in the claim/api grammar', () => { + render( + , + ); + const list = screen.getByRole('list', { name: 'What you can prove first' }); + expect(list.querySelectorAll('li')).toHaveLength(2); + expect(screen.getByText('provideFakeAgent()')).toBeTruthy(); + }); + + it('renders no rows list when rows are omitted', () => { + render(); + expect(screen.queryByRole('list', { name: 'What you can prove first' })).toBeNull(); + }); + + it('renders extra caption links after the first, separated by " · "', () => { + render( + , + ); + expect(screen.getByText(/MIT · no account, no cloud/).textContent).toBe( + 'MIT · no account, no cloud · Talk to an engineer · Setup prompt for coding agents', + ); + expect(screen.getByRole('link', { name: 'Setup prompt for coding agents' }).getAttribute('href')).toBe( + '/docs/chat/getting-started/coding-agents', + ); + }); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd apps/website && npx vitest run src/components/landing/FinalCTA.spec.tsx` +Expected: 3 FAIL. + +- [ ] **Step 3: Implement** + +In `FinalCTA.tsx`: + +Add to `FinalCTAProps`: + +```ts + /** Optional claim/api rows rendered above the headline (the homepage's + * "prove it without a backend" moment). Omitted everywhere else. */ + rows?: readonly { claim: string; api: string }[]; + /** Further caption links, rendered after `captionLink`. */ + captionLinks?: readonly { label: string; href: string }[]; +``` + +Add `rows = [], captionLinks = []` to the destructured defaults. Render the rows immediately inside `.final-cta-inner`, before the mark: + +```tsx + {rows.length > 0 ? ( +
    + {rows.map((row) => ( +
  • + {row.claim} + {row.api} +
  • + ))} +
+ ) : null} +``` + +Change the caption condition to `caption || captionLink || captionLinks.length > 0` and append after the `captionLink` fragment: + +```tsx + {captionLinks.map((link) => ( + + {' · '} + {link.label} + + ))} +``` + +Append to `landing.css` after the existing `.final-cta-caption` rules (search for `.final-cta-caption`): + +```css +/* Prove-it rows above the closing headline. FinalCTA.tsx `rows`. */ +.final-cta-rows { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px 24px; + list-style: none; + margin: 0 0 28px; + padding: 0; + text-align: left; +} +.final-cta-row { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.final-cta-row-claim { font-family: var(--font-inter); font-size: 14px; color: var(--color-text-secondary); } +.final-cta-row-api { font-family: var(--font-mono); font-size: 12px; color: var(--color-accent); overflow-wrap: anywhere; } +@media (max-width: 860px) { .final-cta-rows { grid-template-columns: 1fr; } } +``` + +- [ ] **Step 4: Run and commit** + +Run: `cd apps/website && npx vitest run src/components/landing/FinalCTA.spec.tsx` +Expected: all PASS. + +```bash +git add apps/website/src/components/landing/FinalCTA.tsx apps/website/src/components/landing/FinalCTA.spec.tsx apps/website/src/styles/landing.css +git commit -m "feat(website): FinalCTA takes prove-it rows and extra caption links + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: WhitePaperForm extracted; TeamsBlock merges pilot and field report + +**Files:** +- Create: `apps/website/src/components/landing/WhitePaperForm.tsx` +- Modify: `apps/website/src/components/landing/WhitePaperBlock.tsx` +- Create: `apps/website/src/components/landing/TeamsBlock.tsx` +- Create: `apps/website/src/components/landing/TeamsBlock.spec.tsx` +- Modify: `apps/website/src/styles/landing.css` + +- [ ] **Step 1: Write the failing spec** + +```tsx +// apps/website/src/components/landing/TeamsBlock.spec.tsx +// @vitest-environment jsdom +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { TeamsBlock } from './TeamsBlock'; + +vi.mock('../../lib/analytics/client', () => ({ + track: vi.fn(), + trackCtaClick: vi.fn(), + trackWhitepaperDownloadClick: vi.fn(), +})); + +const formPolicy = { + version: 'test', + disclosures: { whitepaper: 'Whitepaper disclosure', newsletter: 'Newsletter disclosure', contact: 'Contact disclosure' }, +} as never; + +describe('TeamsBlock', () => { + it('renders the pilot heading, four outcomes, four phases, both CTAs, and one email form', () => { + const { container } = render(); + expect(screen.getByRole('heading', { name: 'Shipping inside a large Angular platform?' }).id).toBe('pilot-heading'); + expect(container.querySelectorAll('.pilot-row')).toHaveLength(4); + expect(container.querySelectorAll('.pilot-step')).toHaveLength(4); + expect(screen.getByRole('link', { name: 'Talk to an engineer' }).getAttribute('href')).toBe('/contact?source=home_enterprise&track=enterprise'); + expect(screen.getByRole('link', { name: 'See the pilot program' }).getAttribute('href')).toBe('/pilot-to-prod'); + expect(container.querySelectorAll('form')).toHaveLength(1); + expect(screen.getByLabelText('Email address')).toBeTruthy(); + expect(screen.getByText('Whitepaper disclosure')).toBeTruthy(); + }); + + it('frames the field report as the takeaway, not a second section', () => { + render(); + expect(screen.getByText('Field report')).toBeTruthy(); + expect(screen.getByText('From Prototype to Production')).toBeTruthy(); + expect(screen.queryByRole('heading', { name: 'The last-mile gap in Angular AI.' })).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/website && npx vitest run src/components/landing/TeamsBlock.spec.tsx` +Expected: FAIL, cannot resolve `./TeamsBlock`. + +- [ ] **Step 3: Extract the form** + +Create `WhitePaperForm.tsx` by moving everything in `WhitePaperBlock.tsx` from `type WhitepaperId` through the end of the `submit` function, plus the JSX from the `state === 'done'` ternary through the `wp-already` paragraph, into a client component: + +```tsx +// apps/website/src/components/landing/WhitePaperForm.tsx +'use client'; +import { useRef, useState } from 'react'; +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { FORM_POLICY_REFRESH_MESSAGE, growthFormRequestSnapshot, type GrowthFormRequestSnapshot } from '../../lib/growth/form-client'; +import { Button } from '../ui/Button'; +import { analyticsEvents } from '../../lib/analytics/events'; +import { track, trackWhitepaperDownloadClick } from '../../lib/analytics/client'; + +export type WhitepaperId = 'overview' | 'angular' | 'render' | 'chat'; + +export const PDF_PATHS: Record = { + overview: { href: '/whitepaper.pdf', download: 'angular-agent-readiness-guide.pdf' }, + angular: { href: '/whitepapers/angular.pdf', download: 'angular-streaming-guide.pdf' }, + render: { href: '/whitepapers/render.pdf', download: 'angular-genui-guide.pdf' }, + chat: { href: '/whitepapers/chat.pdf', download: 'angular-chat-guide.pdf' }, +}; + +interface WhitePaperFormProps { + paper: WhitepaperId; + formPolicy: PublicFormPolicy; + /** Analytics surface + section, so the teams block and the library pages report separately. */ + surface: string; + sourceSection: string; + /** Ids must be unique per page; two forms on one page would collide. */ + idPrefix: string; +} + +export function WhitePaperForm({ paper, formPolicy, surface, sourceSection, idPrefix }: WhitePaperFormProps) { + const pdf = PDF_PATHS[paper]; + const [email, setEmail] = useState(''); + const [state, setState] = useState<'idle' | 'submitting' | 'done' | 'error' | 'stale'>('idle'); + const submissionSnapshot = useRef | null>(null); + const disclosureId = `${idPrefix}-growth-disclosure`; + const inputId = `${idPrefix}-email`; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) return; + setState('submitting'); + track(analyticsEvents.marketingWhitepaperSignupSubmit, { surface, source_section: sourceSection, paper }); + try { + const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, { email, paper }); + submissionSnapshot.current = snapshot; + const res = await fetch('/api/whitepaper-signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...snapshot.facts, + acquisition_session_id: snapshot.acquisition_session_id, + submission_id: snapshot.submission_id, + policy_version: formPolicy.version, + }), + }); + if (res.status === 409) { submissionSnapshot.current = null; setState('stale'); return; } + if (res.status >= 400 && res.status < 500) submissionSnapshot.current = null; + if (!res.ok) throw new Error('whitepaper_signup_failed'); + submissionSnapshot.current = null; + track(analyticsEvents.marketingWhitepaperSignupSuccess, { surface, source_section: sourceSection, paper }); + setState('done'); + } catch { + track(analyticsEvents.marketingWhitepaperSignupFail, { surface, source_section: sourceSection, paper, error_reason: 'api_error' }); + setState('error'); + } + }; + + const download = (ctaId: 'home_whitepaper_direct' | 'home_whitepaper_direct_inline') => () => + trackWhitepaperDownloadClick(paper, { surface, source_section: sourceSection, cta_id: ctaId }); + + return ( + <> + {state === 'done' ? ( +
+ ✓ Check your inbox — the guide is on its way.{' '} + Or download directly. +
+ ) : state === 'stale' ? ( +
+

{FORM_POLICY_REFRESH_MESSAGE}

+ +
+ ) : ( +
+ + setEmail(e.target.value)} required disabled={state === 'submitting'} className="wp-email-input" /> +

{formPolicy.disclosures.whitepaper}

+ +
+ )} + {state === 'error' && ( +

+ Something went wrong — please try again or{' '} + download directly. +

+ )} + {state !== 'done' && ( +

+ Already on the list?{' '} + Download the PDF directly. +

+ )} + + ); +} +``` + +Then reduce `WhitePaperBlock.tsx` to the section chrome: keep `ROWS`, the header, the tilted cover, and replace the form/state JSX with ``. Remove the now-unused imports (`useRef`, `useState`, `growthFormRequestSnapshot`, `track`, `trackWhitepaperDownloadClick`, `analyticsEvents`, `FORM_POLICY_REFRESH_MESSAGE`, `Button` if unused). Keep the `'use client'` directive only if something else in the file needs it; the block itself is now static. + +Run `cd apps/website && npx vitest run src/components/landing/WhitePaperBlock.spec.tsx` and confirm every existing case still passes (the input id was `wp-email`; the spec uses labels and roles, so the `wp-overview-email` id is fine. If a case queries `#wp-email`, change the query to `getByLabelText('Email address')`). + +- [ ] **Step 4: Create TeamsBlock** + +```tsx +// apps/website/src/components/landing/TeamsBlock.tsx +'use client'; + +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { Eyebrow } from '../ui/Eyebrow'; +import { Button } from '../ui/Button'; +import { trackCtaClick } from '../../lib/analytics/client'; +import { WhitePaperForm } from './WhitePaperForm'; + +const TIMELINE = [ + { phase: '01', title: 'Discover', body: 'Map your stack, surfaces, and the agentic work that earns its keep.' }, + { phase: '02', title: 'Build', body: 'A working demo on your real data, in your real app.' }, + { phase: '03', title: 'Harden', body: 'Observability, error boundaries, deploy paths, on-call patterns.' }, + { phase: '04', title: 'Train', body: 'Your team owns the stack. We leave you with a runbook, not a black box.' }, +]; + +const OUTCOMES = [ + { claim: 'A working agent demo on your domain', tail: 'your data' }, + { claim: 'Hardened error, fallback, observability patterns', tail: 'production-ready' }, + { claim: 'Deploy-ready integration', tail: 'your CI/CD' }, + { claim: 'Team trained on the framework', tail: 'runbook, yours' }, +]; + +/** + * For teams (live-stage spec §3, block 6): the pilot program and the field + * report in one block, with the page's one email form. Replaces PilotBlock + + * WhitePaperBlock on the homepage; the library pages keep WhitePaperBlock. + */ +export function TeamsBlock({ formPolicy }: { formPolicy: PublicFormPolicy }) { + return ( +
+ +
+
+
+ For teams +
+

Shipping inside a large Angular platform?

+

+ Bring your backend, security model, and design system. Work directly with Threadplane + engineers on architecture, rollout, testing, and production hardening. +

+
+ {OUTCOMES.map((o) => ( +
+

{o.claim}

+

{o.tail}

+
+ ))} +
+
+ + +
+
+ +
+
+ {TIMELINE.map((t) => ( +
+ +
+
{t.title}
+
{t.body}
+
+
+ ))} +
+
+
Field report · 18 pages
+

From Prototype to Production

+

Six production-readiness dimensions for Angular AI teams. Error boundaries, fallbacks, observability, deploy. Free.

+ +
+
+
+
+
+ ); +} +``` + +Append to `landing.css` after the `.pilot-step-body` rule: + +```css +/* TeamsBlock: phases stacked over the field report card. */ +.teams-aside { display: flex; flex-direction: column; gap: 28px; } +.teams-report { + padding: 22px 24px; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-canvas); +} +.teams-report-title { font-family: var(--font-garamond); font-size: 26px; font-weight: 700; line-height: 1.1; margin: 10px 0 6px; color: var(--color-text-primary); } +.teams-report-desc { font-family: var(--font-inter); font-size: 14px; line-height: 1.5; color: var(--color-text-secondary); margin: 0 0 16px; } +``` + +- [ ] **Step 5: Run the specs** + +Run: `cd apps/website && npx vitest run src/components/landing/TeamsBlock.spec.tsx src/components/landing/WhitePaperBlock.spec.tsx` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/components/landing/WhitePaperForm.tsx apps/website/src/components/landing/WhitePaperBlock.tsx apps/website/src/components/landing/TeamsBlock.tsx apps/website/src/components/landing/TeamsBlock.spec.tsx apps/website/src/styles/landing.css +git commit -m "feat(website): TeamsBlock merges the pilot program and the field report around one form + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 6: FAQ trimmed to four; the moved answers land in docs + +**Files:** +- Modify: `apps/website/src/components/landing/HomeFAQ.tsx` +- Create: `apps/website/src/components/landing/HomeFAQ.spec.tsx` +- Modify: `apps/website/content/docs/choosing-an-adapter/index.mdx`, `apps/website/content/docs/langgraph/guides/persistence.mdx`, `apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx` + +- [ ] **Step 1: Write the failing spec** + +```tsx +// apps/website/src/components/landing/HomeFAQ.spec.tsx +// @vitest-environment jsdom +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { HomeFAQ } from './HomeFAQ'; + +describe('HomeFAQ', () => { + it('asks only what the page above did not answer', () => { + render(); + const questions = [ + 'Is Threadplane a backend agent framework?', + 'Can I use my existing Angular component library and design system?', + 'Does generated UI execute arbitrary code?', + 'Does Threadplane require a hosted service or an account?', + ]; + for (const q of questions) expect(screen.getByText(q)).toBeTruthy(); + expect(screen.queryByText('Does Threadplane require LangGraph?')).toBeNull(); + expect(screen.queryByText(/raw streaming SDK/)).toBeNull(); + expect(screen.getAllByRole('heading', { level: 3 }).length + 0).toBeLessThanOrEqual(4); + }); +}); +``` + +If the `FAQ` primitive renders questions as `