diff --git a/apps/website/content/docs/chat/getting-started/coding-agents.mdx b/apps/website/content/docs/chat/getting-started/coding-agents.mdx new file mode 100644 index 000000000..e04d9072d --- /dev/null +++ b/apps/website/content/docs/chat/getting-started/coding-agents.mdx @@ -0,0 +1,34 @@ +--- +title: Coding agents +description: A maintained setup prompt for Claude Code, Codex and other coding agents adding Threadplane to an Angular app, plus the references they should read first. +--- + +# Coding agents + +Threadplane publishes maintained, machine-readable setup context. Give your coding agent the prompt below; it starts with a fake agent, verifies the Angular surface, then connects LangGraph or AG-UI. + +The prompt starts from provideFakeAgent(), renders the smallest accessible chat surface, adds a focused test, and only then explains the LangGraph or AG-UI configuration. + +```text +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. +``` + +## What the agent should read + +- [AGENTS.md](/AGENTS.md), the repository-level playbook. +- [The full agent reference](/llms-full.txt), every docs page in one file. +- [Try without a backend](/docs/chat/getting-started/try-without-a-backend), the human quickstart the prompt starts from. diff --git a/apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx b/apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx index a5469d281..012a14ad1 100644 --- a/apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx +++ b/apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx @@ -94,6 +94,10 @@ The `detectChanges()` call after the input event matters: the send button stays +## Testing without a model + +`provideFakeAgent()` streams canned tokens in-process, and the mock transports script tool calls and interrupts, so component specs stay deterministic and fast. See the [testing guide](/docs/langgraph/guides/testing). + ## Connect a real adapter Replace `provideFakeAgent(...)` with one line and keep every component as it is: diff --git a/apps/website/content/docs/choosing-an-adapter/index.mdx b/apps/website/content/docs/choosing-an-adapter/index.mdx index 52d9bfa61..2f9f77ecf 100644 --- a/apps/website/content/docs/choosing-an-adapter/index.mdx +++ b/apps/website/content/docs/choosing-an-adapter/index.mdx @@ -164,6 +164,14 @@ The `Agent` contract from `@threadplane/chat` is intentionally runtime-neutral. Both adapters are MIT-licensed and live in the same monorepo. +## Frequently asked + +**Does Threadplane require LangGraph?** No. `@threadplane/ag-ui` connects any AG-UI-compatible backend, and `@threadplane/langgraph` is the direct LangGraph adapter. + +**What is the difference between the two adapters?** See [Why two adapters?](#why-two-adapters) above and the [measured runtime support](#measured-runtime-support) table. + +**How does Threadplane differ from a raw streaming SDK?** A streaming SDK gives you events. Threadplane gives you the Angular state model, chat UX, threads, approvals, generated UI, recovery, and tests on top of them. + ## Next steps - [LangGraph quickstart](/docs/langgraph/getting-started/quickstart) if your backend is LangGraph Platform and you want native streaming, checkpoints, threads, interrupts, and history APIs. diff --git a/apps/website/content/docs/langgraph/guides/persistence.mdx b/apps/website/content/docs/langgraph/guides/persistence.mdx index f3db078e8..acf97125e 100644 --- a/apps/website/content/docs/langgraph/guides/persistence.mdx +++ b/apps/website/content/docs/langgraph/guides/persistence.mdx @@ -397,6 +397,10 @@ Setting the `threadId` signal (or calling `switchThread()`) loads the target thr +## Where threads and checkpoints live + +In your backend's persistence layer. Threadplane exposes thread, history, and resume behavior in the UI; durability comes from the runtime you operate. + ## What's Next diff --git a/apps/website/e2e/demo-modal.spec.ts b/apps/website/e2e/demo-modal.spec.ts deleted file mode 100644 index 56ed975a6..000000000 --- a/apps/website/e2e/demo-modal.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('homepage demo: launch opens a modal, Esc closes it', async ({ page }) => { - await page.goto('/'); - const launch = page.getByRole('button', { name: /launch .* live demo/i }).first(); - await launch.scrollIntoViewIfNeeded(); - await launch.click(); - - const dialog = page.getByRole('dialog', { name: /live demo/i }); - await expect(dialog).toBeVisible(); - await expect(dialog.locator('iframe')).toBeVisible(); - - await page.keyboard.press('Escape'); - await expect(dialog).toBeHidden(); - await expect(launch).toBeFocused(); -}); - -test('homepage demo: in-modal tabs switch the runtime', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /launch .* live demo/i }).first().click(); - const dialog = page.getByRole('dialog', { name: /live demo/i }); - await expect(dialog).toBeVisible(); - await dialog.getByRole('tab', { name: 'AG-UI' }).click(); - await expect(dialog.getByText('ag-ui.threadplane.ai')).toBeVisible(); -}); diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index dcd275fcd..a2011277e 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -32,13 +32,33 @@ test('landing page renders the dark proof band', async ({ page }) => { await expect(page.locator('#proof[data-surface="dark"]')).toBeVisible(); }); -test('landing page renders feature blocks (Stream/Persist/Approve/Render/Test)', async ({ page }) => { +test('landing page renders the spine in order (live-stage spec §3)', async ({ page }) => { await page.goto('/'); - await expect(page.locator('#stream-heading')).toBeVisible(); - await expect(page.locator('#persist-heading')).toBeVisible(); - await expect(page.locator('#approve-heading')).toBeVisible(); - await expect(page.locator('#render-heading')).toBeVisible(); - await expect(page.locator('#test-heading')).toBeVisible(); + const ids = [ + 'hero-heading', + 'proof-heading', + 'why-heading', + 'stream-heading', + 'persist-heading', + 'approve-heading', + 'render-heading', + 'final-cta-heading', + 'pilot-heading', + 'faq-heading', + ]; + const tops: number[] = []; + for (const id of ids) { + const el = page.locator(`#${id}`); + await expect(el).toBeAttached(); + tops.push((await el.boundingBox())?.y ?? -1); + } + expect([...tops].sort((a, b) => a - b)).toEqual(tops); + await expect(page.locator('#test-heading')).toHaveCount(0); + await expect(page.locator('#parity-heading')).toHaveCount(0); + await expect(page.locator('#how-it-works-heading')).toHaveCount(0); + await expect(page.locator('#coding-agent-heading')).toHaveCount(0); + await expect(page.locator('#whitepaper-block')).toHaveCount(0); + await expect(page.locator('main form')).toHaveCount(1); }); test('landing page no longer carries the retired promises section', async ({ page }) => { @@ -165,7 +185,7 @@ test('footer newsletter form posts to /api/newsletter and renders success state' }); }); - await page.goto('/'); + await page.goto('/pricing'); const footer = page.locator('footer'); const input = footer.getByLabel('Email'); // Regression guard: the disclosure once sat inside the flex row and the input collapsed to 26px. @@ -519,7 +539,7 @@ test('representative docs pages do not create page-level horizontal overflow', a test('marketing pages link to downloadable whitepaper PDFs', async ({ page }) => { const expectedDownloads: Record = { - '/': '/whitepaper.pdf', + '/ag-ui': '/whitepaper.pdf', '/langgraph': '/whitepapers/angular.pdf', '/render': '/whitepapers/render.pdf', '/chat': '/whitepapers/chat.pdf', diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index b45091e52..2405665c5 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -1,22 +1,15 @@ import { Hero } from '../components/landing/Hero'; -import { LogoRibbon } from '../components/landing/LogoRibbon'; -import { ProofStrip } from '../components/landing/ProofStrip'; -import { RuntimeParity } from '../components/landing/RuntimeParity'; -import { ThreeSteps } from '../components/landing/ThreeSteps'; +import { Reliability } from '../components/landing/Reliability'; +import { ScopeTable } from '../components/landing/ScopeTable'; import { FeatureBlock } from '../components/landing/FeatureBlock'; -import { DemoShowcase } from '../components/landing/DemoShowcase'; import { MediumSwitcher } from '../components/landing/MediumSwitcher'; -import { CodingAgentQuickstart } from '../components/landing/CodingAgentQuickstart'; -import { ScopeTable } from '../components/landing/ScopeTable'; import { SECTION_MEDIA } from '../lib/section-media'; import { buildPanes } from '../lib/build-panes'; -import { PilotBlock } from '../components/landing/PilotBlock'; -import { WhitePaperBlock } from '../components/landing/WhitePaperBlock'; +import { TeamsBlock } from '../components/landing/TeamsBlock'; import { HomeFAQ } from '../components/landing/HomeFAQ'; import { FinalCTA } from '../components/landing/FinalCTA'; import { RecentArticles } from '../components/landing/RecentArticles'; -import { Section } from '../components/ui/Section'; -import { Container } from '../components/ui/Container'; +import { PROVE_IT_ROWS } from '../lib/positioning'; import { createPageMetadata, HERO_SECONDARY_HREF, @@ -35,8 +28,8 @@ export const metadata = createPageMetadata({ export default async function HomePage() { const formPolicy = getFormPolicy(); - const [streamPanes, persistPanes, approvePanes, renderPanes, testPanes] = await Promise.all( - (['stream', 'persist', 'approve', 'render', 'test'] as const).map((key) => + const [streamPanes, persistPanes, approvePanes, renderPanes] = await Promise.all( + (['stream', 'persist', 'approve', 'render'] as const).map((key) => buildPanes(SECTION_MEDIA[key], SECTION_MEDIA[key].video?.url ?? ''), ), ); @@ -44,11 +37,11 @@ export default async function HomePage() { return ( <> - - - - + + + {/* The four capability acts stay as FeatureBlocks until plan 3 replaces + them with the live stage (spec §4). */} {/* Stream */} } /> - {/* Test */} - - provideFakeAgent() streams canned tokens in-process; mock transports - script tool calls and interrupts. Your component specs stay deterministic and fast. - - } - 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' }, - ]} - cta={{ label: 'Try without a backend', href: INSTALL_OPTIONS[0].quickstartHref }} - visual={} - /> - - {/* Interactive demo showcase */} -
- - - -
- - - - - - + + ); diff --git a/apps/website/src/components/landing/AdapterGuideLink.tsx b/apps/website/src/components/landing/AdapterGuideLink.tsx index 48da48623..135d215b6 100644 --- a/apps/website/src/components/landing/AdapterGuideLink.tsx +++ b/apps/website/src/components/landing/AdapterGuideLink.tsx @@ -9,11 +9,11 @@ import { trackCtaClick } from '../../lib/analytics/client'; * `data-cta` attribute on a server-rendered `` has no handler wired to * it and never tracks. */ -export function AdapterGuideLink() { +export function AdapterGuideLink({ className = 'parity-cta' }: { className?: string }) { return ( trackCtaClick({ diff --git a/apps/website/src/components/landing/CodingAgentQuickstart.spec.tsx b/apps/website/src/components/landing/CodingAgentQuickstart.spec.tsx deleted file mode 100644 index 26aa410a1..000000000 --- a/apps/website/src/components/landing/CodingAgentQuickstart.spec.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// @vitest-environment jsdom -import React from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { CODING_AGENT_PROMPT } 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/Container', () => ({ - Container: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); -vi.mock('../ui/Section', () => ({ - Section: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); -vi.mock('../ui/SectionHeader', () => ({ - SectionHeader: ({ heading }: { heading: React.ReactNode }) =>

{heading}

, -})); -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('CodingAgentQuickstart', () => { - it('renders the maintained prompt verbatim and the four links', async () => { - const { CodingAgentQuickstart } = await import('./CodingAgentQuickstart'); - render(); - expect(screen.getByTestId('coding-agent-prompt').textContent).toBe(CODING_AGENT_PROMPT); - expect(screen.getByRole('link', { name: /Read AGENTS.md/ }).getAttribute('href')).toBe('/AGENTS.md'); - expect(screen.getByRole('link', { name: /full agent reference/ }).getAttribute('href')).toBe('/llms-full.txt'); - expect(screen.getByRole('link', { name: /human quickstart/ }).getAttribute('href')).toBe( - '/docs/chat/getting-started/try-without-a-backend', - ); - }); - - it('copy writes the prompt and tracks without sending the text', async () => { - const { CodingAgentQuickstart } = await import('./CodingAgentQuickstart'); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Copy setup prompt' })); - expect(writeTextMock).toHaveBeenCalledWith(CODING_AGENT_PROMPT); - const call = trackCtaClickMock.mock.calls.find((c) => c[0].cta_id === 'home_coding_agent_prompt')?.[0]; - expect(call).toBeTruthy(); - expect(JSON.stringify(call)).not.toContain('Add Threadplane to this Angular application'); - }); -}); diff --git a/apps/website/src/components/landing/CodingAgentQuickstart.tsx b/apps/website/src/components/landing/CodingAgentQuickstart.tsx deleted file mode 100644 index eb23e4f15..000000000 --- a/apps/website/src/components/landing/CodingAgentQuickstart.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Container } from '../ui/Container'; -import { Section } from '../ui/Section'; -import { SectionHeader } from '../ui/SectionHeader'; -import { Button } from '../ui/Button'; -import { trackCtaClick } from '../../lib/analytics/client'; -import { CODING_AGENT_PROMPT, INSTALL_OPTIONS } from '../../lib/positioning'; - -const LINKS = [ - { label: 'Read AGENTS.md', href: '/AGENTS.md' }, - { label: 'Open the full agent reference', href: '/llms-full.txt' }, - { label: 'Start the human quickstart', href: INSTALL_OPTIONS[0].quickstartHref }, -]; - -export function CodingAgentQuickstart() { - const [copied, setCopied] = useState(false); - - const copy = async () => { - trackCtaClick({ cta_id: 'home_coding_agent_prompt', track: 'developer', surface: 'home' }); - try { - await navigator.clipboard?.writeText(CODING_AGENT_PROMPT); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - /* the prompt is visible on the page; the reader can select it */ - } - }; - - return ( -
- - -
-          {CODING_AGENT_PROMPT}
-        
-
- - {LINKS.map((link) => ( - - ))} -
-
-
- ); -} diff --git a/apps/website/src/components/landing/DemoShowcase.spec.tsx b/apps/website/src/components/landing/DemoShowcase.spec.tsx deleted file mode 100644 index 04eb35376..000000000 --- a/apps/website/src/components/landing/DemoShowcase.spec.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// @vitest-environment jsdom -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { DemoShowcase } from './DemoShowcase'; - -const trackCtaClickMock = vi.hoisted(() => vi.fn()); -vi.mock('../../lib/analytics/client', () => ({ - trackCtaClick: trackCtaClickMock, - trackExternalLinkClick: vi.fn(), - track: vi.fn(), -})); - -vi.mock('../ui/Container', () => ({ - Container: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); -vi.mock('../ui/Section', () => ({ - Section: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); - -/** - * This section had no tests and an incomplete ARIA tabs pattern: it announced - * `role="tablist"` while offering no `aria-controls`, no roving tabindex, and no - * keyboard handling. These pin the behaviour the roles promise. - */ -describe('DemoShowcase', () => { - it('offers a tab per runtime', () => { - render(); - - const tabs = screen.getAllByRole('tab'); - expect(tabs.map((t) => t.textContent)).toEqual(['LangGraph', 'AG-UI']); - expect(tabs[0].getAttribute('aria-selected')).toBe('true'); - }); - - it('pairs each tab with the panel it controls', () => { - render(); - - const tab = screen.getAllByRole('tab')[0]; - const panel = screen.getByRole('tabpanel'); - expect(tab.getAttribute('aria-controls')).toBe(panel.getAttribute('id')); - expect(panel.getAttribute('aria-labelledby')).toBe(tab.getAttribute('id')); - }); - - it('moves focus with the selection on arrow keys', () => { - // The defect this section shipped with: selection moved, focus did not, so - // the next Tab press skipped the tablist entirely. - render(); - - fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' }); - - const tabs = screen.getAllByRole('tab'); - expect(tabs[1].getAttribute('aria-selected')).toBe('true'); - expect(document.activeElement).toBe(tabs[1]); - }); - - it('mounts only the active runtime clip', () => { - // Two autoplaying videos in one section would fetch both on load. - const { container } = render(); - - expect(container.querySelectorAll('video')).toHaveLength(1); - expect(container.querySelector('source')?.getAttribute('src')).toMatch(/langgraph-demo/); - }); - - it('swaps the clip when the other runtime is selected', () => { - const { container } = render(); - - fireEvent.click(screen.getAllByRole('tab')[1]); - - expect(container.querySelectorAll('video')).toHaveLength(1); - expect(container.querySelector('source')?.getAttribute('src')).toMatch(/ag-ui-demo/); - }); - - it('reports the runtime whose demo was launched', () => { - trackCtaClickMock.mockClear(); - render(); - - fireEvent.click(screen.getAllByRole('tab')[1]); - fireEvent.click(screen.getByRole('button', { name: 'Launch AG-UI live demo' })); - - expect(trackCtaClickMock).toHaveBeenCalledWith( - expect.objectContaining({ surface: 'home_demo', cta_id: 'home_demo_launch_ag_ui' }), - ); - }); - - it('links the feature CTA to the same-origin streaming workspace', () => { - render(); - - const link = screen.getByRole('link', { - name: 'See each feature in action →', - }); - expect(link.getAttribute('href')).toBe( - '/docs/langgraph/guides/streaming?mode=run' - ); - expect(link.getAttribute('target')).toBeNull(); - expect(link.getAttribute('rel')).toBeNull(); - }); -}); diff --git a/apps/website/src/components/landing/DemoShowcase.tsx b/apps/website/src/components/landing/DemoShowcase.tsx deleted file mode 100644 index 314db07a3..000000000 --- a/apps/website/src/components/landing/DemoShowcase.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; -import { useState } from 'react'; -import { ClipPlayer } from '../ui/ClipPlayer'; -import { TabGroup } from '../ui/TabGroup'; -import { Button } from '../ui/Button'; -import { DemoCtaPair } from './DemoCtaPair'; -import { DemoModal } from './DemoModal'; -import { trackCtaClick } from '../../lib/analytics/client'; -import { DEMOS } from '../../lib/demos'; -import { AG_UI_CLIP, LANGGRAPH_CLIP, type DemoClip } from '../../lib/demo-media'; - -type TabKey = (typeof DEMOS)[number]['key']; - -/** A runtime tab: the shared clip plus how this section labels and links it. */ -interface DemoMedia extends DemoClip { - key: TabKey; - tabLabel: string; - href: string; -} - - -const MEDIA: DemoMedia[] = [ - { ...LANGGRAPH_CLIP, key: 'langgraph', tabLabel: 'LangGraph', href: DEMOS.find((d) => d.key === 'langgraph')!.href }, - { ...AG_UI_CLIP, key: 'ag-ui', tabLabel: 'AG-UI', href: DEMOS.find((d) => d.key === 'ag-ui')!.href }, -]; - -export function DemoShowcase() { - const [active, setActive] = useState('langgraph'); - const [modalOpen, setModalOpen] = useState(false); - const launch = (media: DemoMedia) => { - setActive(media.key); - trackCtaClick({ surface: 'home_demo', destination_url: media.href, cta_id: `home_demo_launch_${media.key.replace(/-/g, '_')}`, cta_text: 'Launch live demo' }); - setModalOpen(true); - }; - - return ( -
-
-

See it running

-
-

- One chat UI. Two runtimes. Same code. -

-

- The identical Threadplane chat surface, running live against a LangGraph backend and an AG-UI backend. Switch tabs to compare — the front end never changes. -

- - {/* - Runtime tabs, not medium tabs — this section's whole argument is that the - SAME front end runs on two backends. `TabGroup` supplies the ARIA tabs - pattern (roving tabindex, arrow/Home/End keys, focus following - selection); previously this rendered tab roles with none of that - behaviour, which promised assistive tech a widget that did not respond. - */} - ({ - id: m.key, - label: m.tabLabel, - content: ( - launch(m)} aria-label={`Launch ${m.tabLabel} live demo`} - className="demo-showcase__launch-btn"> - - Launch live demo - - } - /> - ), - }))} - onSelect={(pane) => setActive(pane.id as TabKey)} - /> - -
- - -
-

- Video loops instantly · click Launch to open the live, interactive demo · no signup -

- setModalOpen(false)} - tabs={MEDIA} - active={active} - onActive={setActive} - /> -
- ); -} diff --git a/apps/website/src/components/landing/FinalCTA.spec.tsx b/apps/website/src/components/landing/FinalCTA.spec.tsx index b0a4b4779..f44ec944c 100644 --- a/apps/website/src/components/landing/FinalCTA.spec.tsx +++ b/apps/website/src/components/landing/FinalCTA.spec.tsx @@ -90,6 +90,59 @@ describe('FinalCTA', () => { expect(caption.textContent).toBe('MIT · no account, no cloud · Talk to an engineer'); expect(screen.getByRole('link', { name: 'Talk to an engineer' }).getAttribute('href')).toBe('/contact'); }); + + 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(); + const heading = screen.getByRole('heading', { level: 2 }); + expect(list.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(list.querySelector('.final-cta-prove-row-claim')?.textContent).toBe('No key, no server, no network'); + expect(list.querySelector('.final-cta-prove-row-api')?.textContent).toBe('provideFakeAgent()'); + }); + + 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', + ); + }); + + it('does not lead with a separator when captionLinks is the only caption content', () => { + const { container } = render( + , + ); + expect(container.querySelector('.final-cta-caption')?.textContent).toBe('A · B'); + }); }); describe('FinalCTA caption surface', () => { diff --git a/apps/website/src/components/landing/FinalCTA.tsx b/apps/website/src/components/landing/FinalCTA.tsx index 7399ede33..9de46361a 100644 --- a/apps/website/src/components/landing/FinalCTA.tsx +++ b/apps/website/src/components/landing/FinalCTA.tsx @@ -24,6 +24,11 @@ interface FinalCTAProps { caption?: string | null; /** Optional link rendered after the caption text (e.g. "Talk to an engineer"). */ captionLink?: { label: string; href: string } | null; + /** Optional claim/api rows rendered above the headline (the homepage's + * "prove it without a backend" moment). Omitted everywhere else. */ + rows?: readonly { readonly claim: string; readonly api: string }[]; + /** Further caption links, rendered after `captionLink`. */ + captionLinks?: readonly { label: string; href: string }[]; /** * 'dark' renders on the dark band. Rule (amended 2026-08-31): dark closes * PRODUCT pages — the homepage and the four library pages. Commerce pages @@ -44,6 +49,8 @@ export function FinalCTA({ secondary = DEFAULT_SECONDARY, caption = null, captionLink = null, + rows = [], + captionLinks = [], variant = 'default', }: FinalCTAProps = {}) { return ( @@ -59,6 +66,16 @@ export function FinalCTA({ → ) : null} + {rows.length > 0 ? ( +
    + {rows.map((row) => ( +
  • + {row.claim} + {row.api} +
  • + ))} +
+ ) : null}

{headline}

@@ -111,7 +128,7 @@ export function FinalCTA({ ) : null} - {caption || captionLink ? ( + {caption || captionLink || captionLinks.length > 0 ? (

{caption} {captionLink ? ( @@ -120,6 +137,12 @@ export function FinalCTA({ {captionLink.label} ) : null} + {captionLinks.map((link, i) => ( + + {caption || captionLink || i > 0 ? ' · ' : null} + {link.label} + + ))}

) : null} diff --git a/apps/website/src/components/landing/HomeFAQ.spec.tsx b/apps/website/src/components/landing/HomeFAQ.spec.tsx new file mode 100644 index 000000000..aad0ba16b --- /dev/null +++ b/apps/website/src/components/landing/HomeFAQ.spec.tsx @@ -0,0 +1,22 @@ +// @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', () => { + const { container } = 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(container.querySelectorAll('summary')).toHaveLength(4); + expect(container.querySelectorAll('a')).toHaveLength(4); + }); +}); diff --git a/apps/website/src/components/landing/HomeFAQ.tsx b/apps/website/src/components/landing/HomeFAQ.tsx index 7fdacd822..d6e6b2001 100644 --- a/apps/website/src/components/landing/HomeFAQ.tsx +++ b/apps/website/src/components/landing/HomeFAQ.tsx @@ -2,9 +2,11 @@ import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; import { Eyebrow } from '../ui/Eyebrow'; import { FAQ, type FAQItem } from '../ui/FAQ'; -import { formatAngularRange } from '../../lib/positioning'; -import { WEBSITE_SUPPORTED_ANGULAR_MAJORS } from '../pricing/angular-support.mjs'; +// Four questions the page above does not answer (live-stage spec §3). Copy here +// is scanned by lib/public-copy-contract.ts: absolute claims ("installation is +// inert") and retired routes (/docs/telemetry/**) are barred — a previous FAQ +// answer shipped both and only the production crawl caught it. const ITEMS: FAQItem[] = [ { q: 'Is Threadplane a backend agent framework?', @@ -16,36 +18,6 @@ const ITEMS: FAQItem[] = [ ), }, - { - q: 'Does Threadplane require LangGraph?', - a: ( - <> - No. @threadplane/ag-ui connects any AG-UI-compatible backend, and{' '} - @threadplane/langgraph is the direct LangGraph adapter.{' '} - AG-UI on Threadplane - - ), - }, - { - q: 'What is the difference between the LangGraph and AG-UI adapters?', - a: ( - <> - Both implement the same Agent contract. LangGraph adds native threads, - checkpoints, history, and branch mapping; AG-UI maps the protocol's events and depends - on what the backend emits. Choosing an adapter - - ), - }, - { - q: 'Where are threads and checkpoints stored?', - a: ( - <> - In your backend's persistence layer. Threadplane exposes thread, history, and resume - behavior in the UI; durability comes from the runtime you operate.{' '} - Persistence guide - - ), - }, { q: 'Can I use my existing Angular component library and design system?', a: ( @@ -65,26 +37,6 @@ const ITEMS: FAQItem[] = [ ), }, - { - q: 'Can I test the UI without a model or a live backend?', - a: ( - <> - Yes. provideFakeAgent() streams canned tokens in-process, and mock transports - script tool calls and interrupts.{' '} - Try it without a backend - - ), - }, - { - q: 'Which Angular versions are supported?', - a: ( - <> - Threadplane supports {formatAngularRange(WEBSITE_SUPPORTED_ANGULAR_MAJORS)}. The installation - guide lists the peer ranges for every package.{' '} - Installation - - ), - }, { q: 'Does Threadplane require a hosted service or an account?', a: ( @@ -94,40 +46,6 @@ const ITEMS: FAQItem[] = [ ), }, - { - // The absolute framing this question used to carry ("installation is - // inert", linking the retired telemetry docs library) is barred copy — - // see lib/public-copy-contract.ts. /privacy is the canonical answer. - q: 'What does Threadplane report about my application?', - a: ( - <> - Operational facts about how the product is running — activity, not content. Prompts, - messages, tool inputs and outputs, application state, and source code are outside what - that reporting is designed to carry. Privacy policy - - ), - }, - { - q: 'How does Threadplane differ from a raw streaming SDK?', - a: ( - <> - A streaming SDK gives you events. Threadplane gives you the Angular state model, chat UX, - threads, approvals, generated UI, recovery, and tests on top of them.{' '} - Chat - - ), - }, - { - q: 'How does Threadplane compare with other Angular agent UI libraries?', - a: ( - <> - Threadplane is the runtime-neutral Angular UI layer: direct LangGraph and AG-UI adapters, a - fake-agent test path, design-system-owned generated UI, and no hosted layer in the loop. A - dated, sourced comparison page is planned.{' '} - Choosing an adapter - - ), - }, ]; export function HomeFAQ() { diff --git a/apps/website/src/components/landing/LogoRibbon.spec.tsx b/apps/website/src/components/landing/LogoRibbon.spec.tsx deleted file mode 100644 index 05a230c6d..000000000 --- a/apps/website/src/components/landing/LogoRibbon.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// @vitest-environment jsdom -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; -import { LogoRibbon, RIBBON_ITEMS, RIBBON_MORE_COUNT } from './LogoRibbon'; - -describe('LogoRibbon', () => { - it('renders eight named items and the more-count', () => { - render(); - expect(RIBBON_ITEMS).toHaveLength(8); - for (const item of RIBBON_ITEMS) { - expect(screen.getByText(item.name)).toBeTruthy(); - } - expect(screen.getByText(`+ ${RIBBON_MORE_COUNT} more`)).toBeTruthy(); - }); - - it('is a labelled landmark with no links', () => { - const { container } = render(); - const section = container.querySelector('section'); - expect(section?.getAttribute('aria-label')).toBe('Works with your agent stack'); - expect(container.querySelectorAll('a')).toHaveLength(0); - }); - - it('reads as compatibility, not customers: hidden logos, visible names, no endorsement wording', () => { - const { container } = render(); - expect(screen.getByText('Works with')).toBeTruthy(); - const imgs = Array.from(container.querySelectorAll('img')); - expect(imgs).toHaveLength(RIBBON_ITEMS.length); - for (const img of imgs) { - 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); - }); -}); diff --git a/apps/website/src/components/landing/LogoRibbon.tsx b/apps/website/src/components/landing/LogoRibbon.tsx deleted file mode 100644 index ed9e86863..000000000 --- a/apps/website/src/components/landing/LogoRibbon.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Container } from '../ui/Container'; - -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 "works with" recognition line. Deliberately not a Section: no heading, - * no subhead — the portability argument lives in the proof band below; this - * line carries recognition only. No links, no hover states. - * - * "Works with" is a compatibility claim, never a customer claim: the logos are - * `alt="" aria-hidden` decoration beside the visible names, and no wording here - * may imply these companies are Threadplane users. LogoRibbon.spec.tsx guards - * that. The labelled three-group variant was tried and rolled back — see - * `git show aaf8dea1:apps/website/src/components/landing/LogoRibbon.tsx`. - */ -export function LogoRibbon() { - return ( -
- -
- Works with - {RIBBON_ITEMS.map((item) => ( - - - {item.name} - - ))} - + {RIBBON_MORE_COUNT} more -
-
-
- ); -} diff --git a/apps/website/src/components/landing/ProofStrip.spec.tsx b/apps/website/src/components/landing/ProofStrip.spec.tsx deleted file mode 100644 index ac71144b3..000000000 --- a/apps/website/src/components/landing/ProofStrip.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; -import { ProofStrip, PROOF_CELLS } from './ProofStrip'; - -describe('ProofStrip', () => { - 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(); - const link = screen.getByRole('link', { name: cell.sourceLabel }); - expect(link.getAttribute('href')).toBe(cell.sourceHref); - } - }); - - it('renders the HVTrust grade as a live badge image, not text', () => { - render(); - const badge = screen.getByAltText(/HVTrust grade/i); - expect(badge.getAttribute('src')).toBe('https://hvtracker.net/badge/threadplane.svg'); - }); - - it('renders on the dark contrast band, with a watermark carrying no text', () => { - 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?.getAttribute('data-watermark-text')).toBe('Proof'); - expect(mark?.textContent).toBe(''); - }); - - it('keeps the "Reliable to the core" framing on the band', () => { - render(); - expect(screen.getByText('Reliable to the core')).toBeTruthy(); - const heading = screen.getByRole('heading', { name: 'Audited, scored, published.' }); - expect(heading.id).toBe('proof-heading'); - }); - - it('links every number to a human-readable page, never a raw API', () => { - for (const cell of PROOF_CELLS) { - const { hostname, pathname } = new URL(cell.sourceHref); - expect(hostname.startsWith('api.'), cell.sourceHref).toBe(false); - expect(pathname.startsWith('/api/'), cell.sourceHref).toBe(false); - } - }); -}); diff --git a/apps/website/src/components/landing/Reliability.spec.tsx b/apps/website/src/components/landing/Reliability.spec.tsx new file mode 100644 index 000000000..9ca70838d --- /dev/null +++ b/apps/website/src/components/landing/Reliability.spec.tsx @@ -0,0 +1,83 @@ +// @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.getByText(r.detail)).toBeTruthy(); + const link = screen.getByRole('link', { name: r.sourceLabel }); + expect(link.getAttribute('href')).toBe(r.sourceHref); + const external = r.sourceHref.startsWith('http'); + expect(link.getAttribute('target')).toBe(external ? '_blank' : null); + expect(link.getAttribute('rel')).toBe(external ? 'noopener noreferrer' : null); + } + }); + + 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?.getAttribute('data-watermark-text')).toBe('Proof'); + 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(); + const logos = container.querySelectorAll('img.reliability-logo'); + expect(logos).toHaveLength(RIBBON_ITEMS.length); + for (const img of Array.from(logos)) { + 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'); + expect(screen.getByRole('list', { name: 'Works with' })).toBeTruthy(); + }); + + it('orders cells, receipts, then the works-with line', () => { + const { container } = render(); + const children = container.querySelector('.proof-strip-grid')!.children; + expect(children[1].className).toBe('proof-strip-cells'); + expect(children[2].className).toBe('reliability-receipts'); + expect(children[3].className).toBe('reliability-works-with'); + }); + + 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); + } + }); +}); diff --git a/apps/website/src/components/landing/ProofStrip.tsx b/apps/website/src/components/landing/Reliability.tsx similarity index 54% rename from apps/website/src/components/landing/ProofStrip.tsx rename to apps/website/src/components/landing/Reliability.tsx index 7e2e0a12e..9460ca0d4 100644 --- a/apps/website/src/components/landing/ProofStrip.tsx +++ b/apps/website/src/components/landing/Reliability.tsx @@ -2,6 +2,8 @@ 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'; +import { AdapterGuideLink } from './AdapterGuideLink'; interface ProofCell { /** Big Garamond numeral, or null when the cell renders a live badge. */ @@ -47,8 +49,7 @@ export const PROOF_CELLS: readonly ProofCell[] = [ sourceHref: 'https://hvtracker.net/agents/threadplane/', }, { - // Revived per the homepage design spec's condition: npm @latest publishes - // ^20 || ^21 || ^22 (verified 2026-08-31). Derived, never hardcoded. + // 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', @@ -56,7 +57,38 @@ export const PROOF_CELLS: readonly ProofCell[] = [ }, ]; -export function ProofStrip() { +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 (homepage design 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 multi-runtime claim sits inside the + * trust argument instead of floating above it as a separate strip. + * + * Replaces ProofStrip and LogoRibbon, both deleted with the homepage + * restructure. + * + * "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 (
@@ -109,6 +141,50 @@ export function ProofStrip() { ))} +
    + {RELIABILITY_RECEIPTS.map((r) => { + const external = r.sourceHref.startsWith('http'); + return ( +
  • +

    {r.claim}

    +

    {r.detail}

    + + {r.sourceLabel} + +
  • + ); + })} +
+
    +
  • + Works with +
  • + {RIBBON_ITEMS.map((item) => ( +
  • + + {item.name} +
  • + ))} +
  • + {RIBBON_MORE_COUNT} more
  • +
  • + +
  • +
diff --git a/apps/website/src/components/landing/RuntimeParity.tsx b/apps/website/src/components/landing/RuntimeParity.tsx deleted file mode 100644 index ffdf081fe..000000000 --- a/apps/website/src/components/landing/RuntimeParity.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Container } from '../ui/Container'; -import { Section } from '../ui/Section'; -import { SectionHeader } from '../ui/SectionHeader'; -import { HighlightedCode } from './HighlightedCode'; -import { RuntimeParityToggle } from './RuntimeParityToggle'; -import { AdapterGuideLink } from './AdapterGuideLink'; -import { PARITY_SNIPPETS, PINNED_COMPONENT_SNIPPET } from '../../lib/positioning'; - -/** - * Runtime parity (spec §6). Both config panes and the pinned component pane - * are highlighted on the server and passed to the client toggle as elements, - * so the toggle ships no shiki and never re-highlights on switch. - */ -export function RuntimeParity() { - return ( -
- - - , - ag_ui: , - }} - componentPane={} - /> -

- Not every backend emits every capability. Interrupts, subagents and checkpoints depend on - what the runtime sends.{' '} - -

-
-
- ); -} diff --git a/apps/website/src/components/landing/RuntimeParityToggle.spec.tsx b/apps/website/src/components/landing/RuntimeParityToggle.spec.tsx deleted file mode 100644 index 9d1260e99..000000000 --- a/apps/website/src/components/landing/RuntimeParityToggle.spec.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom -import React from 'react'; -import { 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() })); - -beforeEach(() => trackCtaClickMock.mockClear()); - -describe('RuntimeParityToggle', () => { - it('shows the LangGraph config pane by default and the pinned component pane', async () => { - const { RuntimeParityToggle } = await import('./RuntimeParityToggle'); - render( - LG CONFIG, ag_ui:
AGUI CONFIG
}} - componentPane={
COMPONENT
} - />, - ); - expect(screen.getByText('LG CONFIG')).toBeTruthy(); - expect(screen.queryByText('AGUI CONFIG')).toBeNull(); - expect(screen.getByText('COMPONENT')).toBeTruthy(); - expect(screen.getByText('same in both')).toBeTruthy(); - }); - - it('switches to AG-UI and tracks the toggle with adapter', async () => { - const { RuntimeParityToggle } = await import('./RuntimeParityToggle'); - render( - LG CONFIG, ag_ui:
AGUI CONFIG
}} - componentPane={
COMPONENT
} - />, - ); - fireEvent.click(screen.getByRole('radio', { name: 'AG-UI' })); - expect(screen.getByText('AGUI CONFIG')).toBeTruthy(); - expect(screen.queryByText('LG CONFIG')).toBeNull(); - expect(screen.getByText('COMPONENT')).toBeTruthy(); - expect(trackCtaClickMock).toHaveBeenCalledWith( - expect.objectContaining({ cta_id: 'home_runtime_parity_toggle', adapter: 'ag_ui' }), - ); - }); - - it('moves DOM focus to the newly checked radio on ArrowRight', async () => { - const { RuntimeParityToggle } = await import('./RuntimeParityToggle'); - render( - LG CONFIG, ag_ui:
AGUI CONFIG
}} - componentPane={
COMPONENT
} - />, - ); - const langgraphRadio = screen.getByRole('radio', { name: 'LangGraph' }); - const agUiRadio = screen.getByRole('radio', { name: 'AG-UI' }); - langgraphRadio.focus(); - fireEvent.keyDown(langgraphRadio, { key: 'ArrowRight' }); - expect(document.activeElement).toBe(agUiRadio); - }); -}); diff --git a/apps/website/src/components/landing/RuntimeParityToggle.tsx b/apps/website/src/components/landing/RuntimeParityToggle.tsx deleted file mode 100644 index a233c8a38..000000000 --- a/apps/website/src/components/landing/RuntimeParityToggle.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; - -import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; -import { trackCtaClick } from '../../lib/analytics/client'; - -type Adapter = 'langgraph' | 'ag_ui'; - -const ADAPTERS: { key: Adapter; label: string }[] = [ - { key: 'langgraph', label: 'LangGraph' }, - { key: 'ag_ui', label: 'AG-UI' }, -]; - -interface RuntimeParityToggleProps { - /** - * Both panes are highlighted on the server and handed down as elements, so - * switching never round-trips or re-highlights. Only the selected one is - * mounted — `hidden` would keep the inactive copy in the accessibility tree - * for `getByText`/screen readers, which is the wrong reading of "what - * changes". - */ - configPanes: Record; - componentPane: ReactNode; -} - -/** - * Runtime parity toggle (spec §6): the left pane swaps with the adapter, the - * right pane is pinned to make the point that the component tree does not. - */ -export function RuntimeParityToggle({ configPanes, componentPane }: RuntimeParityToggleProps) { - const [adapter, setAdapter] = useState('langgraph'); - const radioRefs = useRef>({ langgraph: null, ag_ui: null }); - - const select = (key: Adapter) => { - if (key === adapter) return; - setAdapter(key); - trackCtaClick({ - cta_id: 'home_runtime_parity_toggle', - adapter: key, - track: 'developer', - surface: 'home', - }); - }; - - const onKey = (event: KeyboardEvent) => { - if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return; - event.preventDefault(); - const next = adapter === 'langgraph' ? 'ag_ui' : 'langgraph'; - select(next); - // APG roving radiogroup: DOM focus follows the newly checked radio. - radioRefs.current[next]?.focus(); - }; - - return ( -
-
- {ADAPTERS.map((a) => ( - - ))} -
-
-
-

- What changes app.config.ts -

- {configPanes[adapter]} -
-
-

- What does not same in both -

- {componentPane} -
-
-
- ); -} diff --git a/apps/website/src/components/landing/ScopeTable.spec.tsx b/apps/website/src/components/landing/ScopeTable.spec.tsx new file mode 100644 index 000000000..b84de63b8 --- /dev/null +++ b/apps/website/src/components/landing/ScopeTable.spec.tsx @@ -0,0 +1,18 @@ +// 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_EYEBROW, 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(FINAL_MILE_EYEBROW)).toBeTruthy(); + expect(container.querySelector('[data-ui="section"]')?.getAttribute('id')).toBe('why'); + expect(screen.getAllByRole('row')).toHaveLength(5); + }); +}); diff --git a/apps/website/src/components/landing/ScopeTable.tsx b/apps/website/src/components/landing/ScopeTable.tsx index 344258e16..dacc0780f 100644 --- a/apps/website/src/components/landing/ScopeTable.tsx +++ b/apps/website/src/components/landing/ScopeTable.tsx @@ -1,6 +1,7 @@ import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; import { SectionHeader } from '../ui/SectionHeader'; +import { FINAL_MILE_ASIDE, FINAL_MILE_EYEBROW, FINAL_MILE_HEADING } from '../../lib/positioning'; const ROWS = [ { @@ -31,9 +32,10 @@ export function ScopeTable() {
diff --git a/apps/website/src/components/landing/TeamsBlock.spec.tsx b/apps/website/src/components/landing/TeamsBlock.spec.tsx new file mode 100644 index 000000000..f0232ecab --- /dev/null +++ b/apps/website/src/components/landing/TeamsBlock.spec.tsx @@ -0,0 +1,37 @@ +// @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(); + }); +}); diff --git a/apps/website/src/components/landing/PilotBlock.tsx b/apps/website/src/components/landing/TeamsBlock.tsx similarity index 51% rename from apps/website/src/components/landing/PilotBlock.tsx rename to apps/website/src/components/landing/TeamsBlock.tsx index a53638930..4e14a5225 100644 --- a/apps/website/src/components/landing/PilotBlock.tsx +++ b/apps/website/src/components/landing/TeamsBlock.tsx @@ -1,10 +1,12 @@ '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.' }, @@ -20,19 +22,22 @@ const OUTCOMES = [ { claim: 'Team trained on the framework', tail: 'runbook, yours' }, ]; -export function PilotBlock() { +/** + * 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? -

+

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. @@ -46,31 +51,33 @@ export function PilotBlock() { ))}

-
- {/* Timeline */} -
- {TIMELINE.map((t) => ( -
- -
-
{t.title}
-
{t.body}
+
+
+ {TIMELINE.map((t) => ( +
+ +
+
{t.title}
+
{t.body}
+
-
- ))} + ))} +
+
+ Field report +
18 pages · free
+

From Prototype to Production

+

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

+ +
diff --git a/apps/website/src/components/landing/ThreeSteps.tsx b/apps/website/src/components/landing/ThreeSteps.tsx deleted file mode 100644 index 2194ba9ba..000000000 --- a/apps/website/src/components/landing/ThreeSteps.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Container } from '../ui/Container'; -import { Section } from '../ui/Section'; -import { SectionHeader } from '../ui/SectionHeader'; -import { HighlightedCode } from './HighlightedCode'; -import { COMPONENT_SNIPPET, INSTALL_OPTIONS, RENDER_SNIPPET } from '../../lib/positioning'; - -const STEPS = [ - { - title: 'Choose an adapter', - body: 'Connect LangGraph or an AG-UI endpoint, or start with a fake agent. This is the only file that knows which runtime you run.', - code: INSTALL_OPTIONS[1].providerSnippet, - }, - { - title: 'Inject signal-shaped state', - body: 'provideAgent() once, injectAgent() where the UI needs messages, status, errors, tool progress and thread actions.', - code: COMPONENT_SNIPPET, - }, - { - title: 'Render the experience you own', - body: 'Use the chat compositions, the headless primitives, or register your own design-system components for generated UI.', - code: RENDER_SNIPPET, - }, -] as const; - -/** - * The mechanism, in three steps (spec §7). Every snippet comes from - * `positioning.ts`, where a parse test keeps it real TypeScript. - */ -export function ThreeSteps() { - return ( -
- - -
    - {STEPS.map((step) => ( -
  1. -

    {step.title}

    -

    {step.body}

    -
    - -
    -
  2. - ))} -
-
-
- ); -} diff --git a/apps/website/src/components/landing/WhitePaperBlock.tsx b/apps/website/src/components/landing/WhitePaperBlock.tsx index d8756446e..4502d5e13 100644 --- a/apps/website/src/components/landing/WhitePaperBlock.tsx +++ b/apps/website/src/components/landing/WhitePaperBlock.tsx @@ -1,17 +1,8 @@ -'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 { Container } from '../ui/Container'; import { Section } from '../ui/Section'; import { Eyebrow } from '../ui/Eyebrow'; -import { Button } from '../ui/Button'; -import { analyticsEvents } from '../../lib/analytics/events'; -import { track, trackWhitepaperDownloadClick } from '../../lib/analytics/client'; +import { WhitePaperForm, type WhitepaperId } from './WhitePaperForm'; const ROWS = [ { claim: 'Six production-readiness dimensions', tail: '18 pages' }, @@ -19,88 +10,16 @@ const ROWS = [ { claim: 'No vendor pitch — what we learned shipping it', tail: 'free' }, ]; -type WhitepaperId = 'overview' | 'angular' | 'render' | 'chat'; - interface WhitePaperBlockProps { /** Whitepaper variant. Determines PDF path + analytics tag. */ paper?: WhitepaperId; formPolicy: PublicFormPolicy; } -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' }, -}; - export function WhitePaperBlock({ formPolicy, paper = 'overview', }: WhitePaperBlockProps) { - 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 = `wp-${paper}-growth-disclosure`; - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!email) return; - setState('submitting'); - track(analyticsEvents.marketingWhitepaperSignupSubmit, { - surface: 'home_whitepaper', - source_section: 'whitepaper-block', - 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: 'home_whitepaper', - source_section: 'whitepaper-block', - paper, - }); - setState('done'); - } catch { - track(analyticsEvents.marketingWhitepaperSignupFail, { - surface: 'home_whitepaper', - source_section: 'whitepaper-block', - paper, - error_reason: 'api_error', - }); - setState('error'); - } - }; - return (
@@ -122,92 +41,13 @@ export function WhitePaperBlock({ ))}
- {state === 'done' ? ( - - ) : 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?{' '} - - trackWhitepaperDownloadClick(paper, { - surface: 'home_whitepaper', - source_section: 'whitepaper-block', - cta_id: 'home_whitepaper_direct_inline', - }) - } - className="wp-already-link" - > - Download the PDF directly. - -

- )} +
{/* Tilted whitepaper cover */} diff --git a/apps/website/src/components/landing/WhitePaperForm.tsx b/apps/website/src/components/landing/WhitePaperForm.tsx new file mode 100644 index 000000000..92e94b4a7 --- /dev/null +++ b/apps/website/src/components/landing/WhitePaperForm.tsx @@ -0,0 +1,197 @@ +'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, + type AnalyticsSurface, + type WhitepaperId, +} from '../../lib/analytics/events'; +import { track, trackWhitepaperDownloadClick } from '../../lib/analytics/client'; + +export type { WhitepaperId }; + +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: AnalyticsSurface; + 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 inputId = `${idPrefix}-email`; + const disclosureId = `${idPrefix}-growth-disclosure`; + + 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'); + } + }; + + return ( + <> + {state === 'done' ? ( + + ) : 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?{' '} + + trackWhitepaperDownloadClick(paper, { + surface, + source_section: sourceSection, + cta_id: 'home_whitepaper_direct_inline', + }) + } + className="wp-already-link" + > + Download the PDF directly. + +

+ )} + + ); +} diff --git a/apps/website/src/components/shared/Footer.tsx b/apps/website/src/components/shared/Footer.tsx index 20648fc1c..83e752fdc 100644 --- a/apps/website/src/components/shared/Footer.tsx +++ b/apps/website/src/components/shared/Footer.tsx @@ -106,7 +106,13 @@ function NewsletterForm({ formPolicy }: { formPolicy: PublicFormPolicy }) { ); } -export function Footer({ formPolicy }: { formPolicy: PublicFormPolicy }) { +export function Footer({ + formPolicy, + showNewsletter = true, +}: { + formPolicy: PublicFormPolicy; + showNewsletter?: boolean; +}) { /** * `ctaId` defaults to a slug of the label. Pass it explicitly when the visible * text changes but the analytics series should stay continuous — renaming @@ -137,7 +143,7 @@ export function Footer({ formPolicy }: { formPolicy: PublicFormPolicy }) {

The Angular UI layer for production agents.

- + {showNewsletter ? : null} {/* Social links */}
{ ); it('hands the server-owned policy through to the footer newsletter form', () => { + pathname.current = '/pricing'; + render(); const disclosure = screen.getByText(formPolicy.disclosures.newsletter); @@ -121,4 +123,12 @@ describe('SiteFooter', () => { .getAttribute('aria-describedby') ).toBe(disclosure.id); }); + + it('hides the newsletter form on the homepage, whose field report form is the one form', () => { + pathname.current = '/'; + render(); + expect(screen.queryByRole('button', { name: /subscribe/i })).toBeNull(); + expect(screen.queryByText(formPolicy.disclosures.newsletter)).toBeNull(); + expect(document.querySelector('footer')).not.toBeNull(); + }); }); diff --git a/apps/website/src/components/shared/SiteFooter.tsx b/apps/website/src/components/shared/SiteFooter.tsx index bfc157c1d..3f5cd2d92 100644 --- a/apps/website/src/components/shared/SiteFooter.tsx +++ b/apps/website/src/components/shared/SiteFooter.tsx @@ -10,9 +10,12 @@ import { Footer } from './Footer'; * something opts out here. The docs tree opts out: every /docs route renders * the sidebar control plane and ends at its own prev/next rail, and a marketing * footer bolted under that column breaks the single-pane reading experience. + * The homepage keeps the footer but drops its newsletter form: the field + * report form is the homepage's one form (spec §3, block 6), so a second + * capture form in the footer would just duplicate it. */ export function SiteFooter({ formPolicy }: { formPolicy: PublicFormPolicy }) { const pathname = usePathname(); if (pathname === '/docs' || pathname?.startsWith('/docs/')) return null; - return