diff --git a/apps/website/e2e/home-no-runtime.spec.ts b/apps/website/e2e/home-no-runtime.spec.ts new file mode 100644 index 000000000..2a7a7f799 --- /dev/null +++ b/apps/website/e2e/home-no-runtime.spec.ts @@ -0,0 +1,73 @@ +import { test, expect, type Page } from '@playwright/test'; + +const BAND = '#no-runtime'; + +/** + * The unit spec proves the flows are drawn from the copy module; this proves + * the drawing holds together in a real layout. Measured, not eyeballed: every + * node in a flow shares that flow's centre line, no node's text overflows its + * box, and on a phone the flows stack instead of squeezing. + */ +async function nodeReport(page: Page) { + return page.evaluate((sel) => { + const flows = Array.from(document.querySelectorAll(`${sel} [data-flow]`)); + return flows.map((flow) => { + const nodes = Array.from(flow.querySelectorAll('.no-runtime-node')); + return { + id: flow.dataset['flow'], + top: flow.getBoundingClientRect().top, + centres: nodes.map((n) => { + const r = n.getBoundingClientRect(); + return Math.round((r.left + r.right) / 2); + }), + overflowing: nodes.filter((n) => n.scrollWidth > n.clientWidth).map((n) => n.textContent), + }; + }); + }, BAND); +} + +test.describe('homepage No-runtime band', () => { + test('aligns every node on its flow centre line and clips no text at 1440', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + await expect(page.locator('#no-runtime-heading')).toHaveText('No runtime.'); + await page.locator(BAND).scrollIntoViewIfNeeded(); + const flows = await nodeReport(page); + expect(flows.map((f) => f.id)).toEqual(['usual', 'ours']); + for (const f of flows) { + expect(f.overflowing, f.id).toEqual([]); + expect(new Set(f.centres).size, `${f.id} centres ${f.centres.join(',')}`).toBe(1); + } + // Side by side: the two flows share a top edge. + expect(Math.abs(flows[0].top - flows[1].top)).toBeLessThanOrEqual(1); + // The ghost hop exists exactly once and only in the usual flow. + await expect(page.locator(`${BAND} .no-runtime-node.is-ghost`)).toHaveCount(1); + await expect(page.locator(`${BAND} [data-flow="ours"] .is-ghost`)).toHaveCount(0); + }); + + test('stacks the flows and still fits every node on a phone', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.locator(BAND).scrollIntoViewIfNeeded(); + const flows = await nodeReport(page); + for (const f of flows) { + expect(f.overflowing, f.id).toEqual([]); + expect(new Set(f.centres).size).toBe(1); + } + // Stacked: the ours flow starts below the usual one. + expect(flows[1].top).toBeGreaterThan(flows[0].top + 100); + // The page never scrolls sideways. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + + test('carries one link, to the adapter guide, and no button', async ({ page }) => { + await page.goto('/'); + const links = page.locator(`${BAND} a`); + await expect(links).toHaveCount(1); + await expect(links).toHaveAttribute('href', '/docs/choosing-an-adapter'); + await expect(page.locator(`${BAND} [data-ui="button"]`)).toHaveCount(0); + }); +}); diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 907ef414b..5bd65110c 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -57,6 +57,7 @@ test('landing page renders the spine in order (live-stage spec §3)', async ({ p 'proof-heading', 'compatibility-heading', 'architecture-heading', + 'no-runtime-heading', 'open-source-heading', 'stage-heading', 'field-report-heading', diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index cd315dd81..5a5e8572f 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -6,6 +6,7 @@ import { Stage } from '../components/landing/Stage'; import { TeamsBlock } from '../components/landing/TeamsBlock'; import { HomeFAQ } from '../components/landing/HomeFAQ'; import { OpenSourceStrip } from '../components/landing/OpenSourceStrip'; +import { NoRuntimeBand } from '../components/landing/NoRuntimeBand'; import { RecentArticles } from '../components/landing/RecentArticles'; // The homepage must stay statically rendered (no cookies()/headers()/dynamic): // the proof lines are read from the demo recording at build time, and the file @@ -35,6 +36,12 @@ export default function HomePage() { + {/* No runtime: the diagram above shows Threadplane reaching your agent + directly; this band says so in two words. Copy lives in + NO_RUNTIME_BAND (positioning.ts). Deliberately dark-on-dark with the + band below; each dark section paints its own seam (spec §5). */} + + {/* The open-source full stop: a loud dark band with one fork CTA. Copy lives in OPEN_SOURCE_STRIP (positioning.ts). */} diff --git a/apps/website/src/components/landing/HomeFAQ.spec.tsx b/apps/website/src/components/landing/HomeFAQ.spec.tsx index aad0ba16b..7e770f5ee 100644 --- a/apps/website/src/components/landing/HomeFAQ.spec.tsx +++ b/apps/website/src/components/landing/HomeFAQ.spec.tsx @@ -12,11 +12,21 @@ describe('HomeFAQ', () => { '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?', + 'Does Threadplane have a runtime I need to deploy?', ]; 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); + expect(container.querySelectorAll('summary')).toHaveLength(5); + expect(container.querySelectorAll('a')).toHaveLength(5); + }); + + it('answers the runtime question in the band’s own words', () => { + render(); + const answer = screen.getByText(/no Threadplane server in the request path/); + expect(answer.textContent).toMatch(/no key/); + expect(answer.textContent).toMatch(/no production tier/); + expect(answer.textContent).not.toMatch(/proxy/i); + expect(answer.querySelector('a')?.getAttribute('href')).toBe('/docs/choosing-an-adapter'); }); }); diff --git a/apps/website/src/components/landing/HomeFAQ.tsx b/apps/website/src/components/landing/HomeFAQ.tsx index d6e6b2001..a4ccf1002 100644 --- a/apps/website/src/components/landing/HomeFAQ.tsx +++ b/apps/website/src/components/landing/HomeFAQ.tsx @@ -3,7 +3,8 @@ import { Section } from '../ui/Section'; import { Eyebrow } from '../ui/Eyebrow'; import { FAQ, type FAQItem } from '../ui/FAQ'; -// Four questions the page above does not answer (live-stage spec §3). Copy here +// Five questions the page above does not answer (live-stage spec §3; the +// runtime one from spec 2026-09-11). 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. @@ -46,6 +47,16 @@ const ITEMS: FAQItem[] = [ ), }, + { + q: 'Does Threadplane have a runtime I need to deploy?', + a: ( + <> + No. The adapters call your LangGraph or AG-UI server from the browser. There is no + Threadplane server in the request path, no key, and no production tier.{' '} + How it is wired + + ), + }, ]; export function HomeFAQ() { diff --git a/apps/website/src/components/landing/NoRuntimeBand.spec.tsx b/apps/website/src/components/landing/NoRuntimeBand.spec.tsx new file mode 100644 index 000000000..602a8229b --- /dev/null +++ b/apps/website/src/components/landing/NoRuntimeBand.spec.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NoRuntimeBand } from './NoRuntimeBand'; +import { NO_RUNTIME_BAND } from '../../lib/positioning'; + +const trackCtaClickMock = vi.hoisted(() => vi.fn()); +vi.mock('../../lib/analytics/client', () => ({ + track: vi.fn(), + trackCtaClick: trackCtaClickMock, + trackExternalLinkClick: vi.fn(), +})); + +beforeEach(() => trackCtaClickMock.mockClear()); + +describe('NoRuntimeBand', () => { + it('makes the two-word headline the section heading and names the section by it', () => { + const { container } = render(); + const heading = screen.getByRole('heading', { level: 2 }); + expect(heading.textContent).toBe(NO_RUNTIME_BAND.headline); + expect(heading.id).toBe('no-runtime-heading'); + const section = container.querySelector('[data-ui="section"]'); + expect(section?.getAttribute('aria-labelledby')).toBe('no-runtime-heading'); + expect(section?.getAttribute('id')).toBe('no-runtime'); + expect(container.querySelector('.no-runtime-eyebrow')?.textContent).toBe(NO_RUNTIME_BAND.eyebrow); + expect(screen.getByText(NO_RUNTIME_BAND.body)).toBeTruthy(); + }); + + it('sits on the dark surface at the full rhythm, like Fork us', () => { + const { container } = render(); + const section = container.querySelector('[data-ui="section"]'); + expect(section?.getAttribute('data-surface')).toBe('dark'); + expect(section?.getAttribute('data-tight')).toBeNull(); + expect(section?.classList.contains('no-runtime')).toBe(true); + }); + + it('offers one text link and no button, and reports it as a homepage CTA', () => { + const { container } = render(); + const links = container.querySelectorAll('a'); + expect(links).toHaveLength(1); + const link = screen.getByRole('link', { name: NO_RUNTIME_BAND.link.label }); + expect(link.getAttribute('href')).toBe(NO_RUNTIME_BAND.link.href); + expect(container.querySelector('[data-ui="button"]')).toBeNull(); + fireEvent.click(link); + expect(trackCtaClickMock).toHaveBeenCalledWith({ + cta_id: 'home_no_runtime_docs', + track: 'developer', + surface: 'home', + destination_url: NO_RUNTIME_BAND.link.href, + }); + }); + + it('draws both flows from the copy module, with the ghost hop only in the usual one', () => { + const { container } = render(); + const usual = container.querySelector('[data-flow="usual"]'); + const ours = container.querySelector('[data-flow="ours"]'); + expect( + Array.from(usual!.querySelectorAll('.no-runtime-node')).map((n) => n.textContent), + ).toEqual([...NO_RUNTIME_BAND.flows.usual.nodes]); + expect( + Array.from(ours!.querySelectorAll('.no-runtime-node')).map((n) => n.textContent), + ).toEqual([...NO_RUNTIME_BAND.flows.ours.nodes]); + expect(container.querySelectorAll('.no-runtime-node.is-ghost')).toHaveLength(1); + expect(usual!.querySelector('.no-runtime-node.is-ghost')?.textContent).toBe( + NO_RUNTIME_BAND.flows.usual.ghost, + ); + expect(ours!.querySelector('.is-ghost')).toBeNull(); + // The direct arrow spans the hop that is not there: one in ours, none in usual. + const oursArrows = ours!.querySelectorAll('.no-runtime-arrow'); + expect(oursArrows).toHaveLength(1); + expect(oursArrows[0].classList.contains('is-direct')).toBe(true); + const usualArrows = usual!.querySelectorAll('.no-runtime-arrow'); + expect(usualArrows).toHaveLength(2); + expect(usual!.querySelector('.no-runtime-arrow.is-direct')).toBeNull(); + }); + + it('gives the flows a prose caption and hides the arrows from assistive tech', () => { + const { container } = render(); + const figure = container.querySelector('figure.no-runtime-figure'); + expect(figure).toBeTruthy(); + expect(figure?.querySelector('figcaption')?.textContent).toBe(NO_RUNTIME_BAND.figureCaption); + // getByRole skips hidden subtrees, so aria-hiding the figure fails here. + // (jsdom's accessible-name computation gives a figure no name from its + // figcaption, so the name is not queried; the caption is checked below.) + expect(screen.getByRole('figure')).toBe(figure); + expect(figure?.querySelector('figcaption')?.closest('[aria-hidden="true"]')).toBeNull(); + // The drawn flows duplicate the caption, so they are hidden as a unit. + expect(figure?.querySelector('.no-runtime-flows')?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('carries no runway of its own, so it does not merge with Fork us below it', () => { + // Two stripes 450px apart read as one tall block in the browser; the + // marking stays on Fork us alone (spec §5 fallback). + const { container } = render(); + expect(container.querySelector('.no-runtime-runway')).toBeNull(); + // Structurally, not by class name: the section holds the container and + // nothing else, so no stripe can come back under another name. + const section = container.querySelector('[data-ui="section"]'); + expect(section?.children).toHaveLength(1); + expect(section?.firstElementChild?.getAttribute('data-ui')).toBe('container'); + }); +}); diff --git a/apps/website/src/components/landing/NoRuntimeBand.tsx b/apps/website/src/components/landing/NoRuntimeBand.tsx new file mode 100644 index 000000000..8919e1661 --- /dev/null +++ b/apps/website/src/components/landing/NoRuntimeBand.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { trackCtaClick } from '../../lib/analytics/client'; +import { NO_RUNTIME_BAND } from '../../lib/positioning'; + +/** + * The No-runtime band (spec 2026-09-11): a dark band between the architecture + * diagram and Fork us, in the same loud register. Copy left, two vertical + * flows right — the usual path with a vendor runtime in the middle, and ours + * without it. + * + * No runway stripe here, on purpose. It was in the design; in the browser two + * stripes 450px apart made this band and Fork us read as one tall block + * (spec §5 fallback). Fork us keeps the marking, so the page still has one. + * + * Everything it says comes from NO_RUNTIME_BAND (positioning.ts). It names no + * other product: the band states Threadplane's shape and leaves the + * comparison to the reader. + * + * `'use client'` only for the analytics click handler; there is no state. + */ +export function NoRuntimeBand() { + const { eyebrow, headline, body, link, flows, figureCaption } = NO_RUNTIME_BAND; + + return ( +
+ +
+ + +
+ {/* The drawn flows and the caption say the same thing; the drawing + is hidden as a unit so a screen reader hears it once. */} + +
{figureCaption}
+
+
+
+
+ ); +} + +function Flow({ + id, + label, + nodes, + ghost, +}: { + id: 'usual' | 'ours'; + label: string; + nodes: readonly string[]; + ghost?: string; +}) { + return ( +
+

{label}

+ {nodes.map((node, i) => ( +
+ {i > 0 ? : null} + + {node} + +
+ ))} +
+ ); +} diff --git a/apps/website/src/components/landing/PreflightChecklist.spec.tsx b/apps/website/src/components/landing/PreflightChecklist.spec.tsx index 4418563cc..8626d29a1 100644 --- a/apps/website/src/components/landing/PreflightChecklist.spec.tsx +++ b/apps/website/src/components/landing/PreflightChecklist.spec.tsx @@ -1,21 +1,20 @@ import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { PreflightChecklist } from './PreflightChecklist'; -import { PREFLIGHT_OURS, PREFLIGHT_YOURS, AIRWORTHINESS } from '../../lib/preflight-checklist'; +import { AIRWORTHINESS } from '../../lib/preflight-checklist'; describe('PreflightChecklist', () => { - it('renders one row per data row', () => { + it('renders one row per data row and nothing else', () => { const { container } = render(); - expect(container.querySelectorAll('.preflight-row')).toHaveLength( - PREFLIGHT_OURS.length + PREFLIGHT_YOURS.length + AIRWORTHINESS.length, - ); + expect(container.querySelectorAll('.preflight-row')).toHaveLength(AIRWORTHINESS.length); + // The two-column checklist is gone; nothing may bring its grid back. + expect(container.querySelector('.preflight-cols')).toBeNull(); + expect(container.querySelector('.preflight-yours')).toBeNull(); }); - it('links every ticked row and none of the Yours rows', () => { + it('links every row', () => { const { container } = render(); - expect(container.querySelectorAll('a.preflight-row')).toHaveLength( - PREFLIGHT_OURS.length + AIRWORTHINESS.length, - ); + expect(container.querySelectorAll('a.preflight-row')).toHaveLength(AIRWORTHINESS.length); for (const a of Array.from(container.querySelectorAll('a.preflight-row'))) { expect(a.getAttribute('href')).toBeTruthy(); } @@ -26,19 +25,15 @@ describe('PreflightChecklist', () => { // screen reader it can be toggled, which is a lie. const { container } = render(); expect(container.querySelectorAll('input')).toHaveLength(0); - expect(container.querySelectorAll('.preflight-box')).toHaveLength( - PREFLIGHT_OURS.length + PREFLIGHT_YOURS.length + AIRWORTHINESS.length, - ); + expect(container.querySelectorAll('.preflight-box')).toHaveLength(AIRWORTHINESS.length); }); - it('names each column list so two adjacent lists are distinguishable', () => { + it('names the list', () => { const { container } = render(); const lists = Array.from(container.querySelectorAll('ul[aria-labelledby]')); - expect(lists).toHaveLength(3); - for (const ul of lists) { - const label = container.querySelector(`#${ul.getAttribute('aria-labelledby')}`); - expect(label?.textContent).toBeTruthy(); - } + expect(lists).toHaveLength(1); + const label = container.querySelector(`#${lists[0].getAttribute('aria-labelledby')}`); + expect(label?.textContent).toBe('Airworthiness'); }); it('keeps the HVTrust grade a live badge with real alt text', () => { diff --git a/apps/website/src/components/landing/PreflightChecklist.tsx b/apps/website/src/components/landing/PreflightChecklist.tsx index 8833aba71..aa5df3742 100644 --- a/apps/website/src/components/landing/PreflightChecklist.tsx +++ b/apps/website/src/components/landing/PreflightChecklist.tsx @@ -1,12 +1,7 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import { - AIRWORTHINESS, - PREFLIGHT_OURS, - PREFLIGHT_YOURS, - type ChecklistRow, -} from '../../lib/preflight-checklist'; +import { AIRWORTHINESS, type ChecklistRow } from '../../lib/preflight-checklist'; /** Milliseconds between ticks. Fast enough not to read as a loading bar. */ const TICK_MS = 180; @@ -22,67 +17,64 @@ function Box() { } function Row({ row, done }: { row: ChecklistRow; done: boolean }) { - const body = ( - <> - - {row.challenge} -