Skip to content

Commit 9a45849

Browse files
bloveclaude
andauthored
feat(website): the trust band keeps only third-party proof, and a No-runtime band lands after the architecture diagram (#1096)
* docs(specs): homepage trust band shrinks and a No-runtime band lands after the architecture diagram Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(plans): homepage trust band and No-runtime band implementation plan Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(website): the checklist data keeps only the five airworthiness rows Re-verified against live sources on 2026-09-11: framework rank moved from #8 of 119 to #11 of 122, and the OpenSSF Scorecard moved from 8.2 to 8.3. Updated both rather than rounding up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): the trust band renders the five airworthiness rows alone Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): the trust band's aside frames third-party proof Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): record the live rank and score read on 2026-09-11 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(website): the trust band CSS and guards keep only what the single list uses - landing.css: fold the .preflight-air response rules into the base .preflight-response rules (the base "done -> signal colour" rule was dead since every row is now inside .preflight-air), and fold is-ours into the base .preflight-col-head rule since it is the only heading left. Restore the blank line before the "For teams" block. - PreflightChecklist.tsx: drop the now-unused preflight-air/is-ours classes, check prefers-reduced-motion before the IntersectionObserver guard (guard matchMedia's existence too, since jsdom in this suite does not implement it), and soften the reduced-motion docstring line. - preflight-checklist.spec.ts: replace the "carries no self-reported rows" test with a property that actually distinguishes third-party rows (an absolute href whose host is not threadplane.ai), since the deleted Cloud/Signup/VC rows were self-reported by virtue of linking on-site pages. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): single-source the No-runtime band copy and register its CTA id Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(website): register the No-runtime CTA id and tighten its copy pins Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): the No-runtime band — two words over two flows Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): pin the No-runtime figure name and the direct arrow Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): the No-runtime band joins the homepage spine after the architecture diagram Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): measure the No-runtime flows at desktop and phone widths Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): the homepage FAQ answers whether there is a runtime to deploy Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): the flow guard measures a centre line, not a left edge Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): mark the preflight checklist spec superseded Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): the No-runtime band drops its runway so it does not merge with Fork us In the browser two runway stripes 450px apart made the two adjacent dark bands read as one tall block. The design spec's §5 fallback applies: the marking stays on Fork us alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(website): final-review polish for the trust and No-runtime bands Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 83432cd commit 9a45849

21 files changed

Lines changed: 2332 additions & 239 deletions
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
const BAND = '#no-runtime';
4+
5+
/**
6+
* The unit spec proves the flows are drawn from the copy module; this proves
7+
* the drawing holds together in a real layout. Measured, not eyeballed: every
8+
* node in a flow shares that flow's centre line, no node's text overflows its
9+
* box, and on a phone the flows stack instead of squeezing.
10+
*/
11+
async function nodeReport(page: Page) {
12+
return page.evaluate((sel) => {
13+
const flows = Array.from(document.querySelectorAll<HTMLElement>(`${sel} [data-flow]`));
14+
return flows.map((flow) => {
15+
const nodes = Array.from(flow.querySelectorAll<HTMLElement>('.no-runtime-node'));
16+
return {
17+
id: flow.dataset['flow'],
18+
top: flow.getBoundingClientRect().top,
19+
centres: nodes.map((n) => {
20+
const r = n.getBoundingClientRect();
21+
return Math.round((r.left + r.right) / 2);
22+
}),
23+
overflowing: nodes.filter((n) => n.scrollWidth > n.clientWidth).map((n) => n.textContent),
24+
};
25+
});
26+
}, BAND);
27+
}
28+
29+
test.describe('homepage No-runtime band', () => {
30+
test('aligns every node on its flow centre line and clips no text at 1440', async ({ page }) => {
31+
await page.setViewportSize({ width: 1440, height: 900 });
32+
await page.goto('/');
33+
await expect(page.locator('#no-runtime-heading')).toHaveText('No runtime.');
34+
await page.locator(BAND).scrollIntoViewIfNeeded();
35+
const flows = await nodeReport(page);
36+
expect(flows.map((f) => f.id)).toEqual(['usual', 'ours']);
37+
for (const f of flows) {
38+
expect(f.overflowing, f.id).toEqual([]);
39+
expect(new Set(f.centres).size, `${f.id} centres ${f.centres.join(',')}`).toBe(1);
40+
}
41+
// Side by side: the two flows share a top edge.
42+
expect(Math.abs(flows[0].top - flows[1].top)).toBeLessThanOrEqual(1);
43+
// The ghost hop exists exactly once and only in the usual flow.
44+
await expect(page.locator(`${BAND} .no-runtime-node.is-ghost`)).toHaveCount(1);
45+
await expect(page.locator(`${BAND} [data-flow="ours"] .is-ghost`)).toHaveCount(0);
46+
});
47+
48+
test('stacks the flows and still fits every node on a phone', async ({ page }) => {
49+
await page.setViewportSize({ width: 390, height: 844 });
50+
await page.goto('/');
51+
await page.locator(BAND).scrollIntoViewIfNeeded();
52+
const flows = await nodeReport(page);
53+
for (const f of flows) {
54+
expect(f.overflowing, f.id).toEqual([]);
55+
expect(new Set(f.centres).size).toBe(1);
56+
}
57+
// Stacked: the ours flow starts below the usual one.
58+
expect(flows[1].top).toBeGreaterThan(flows[0].top + 100);
59+
// The page never scrolls sideways.
60+
const overflow = await page.evaluate(
61+
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
62+
);
63+
expect(overflow).toBeLessThanOrEqual(0);
64+
});
65+
66+
test('carries one link, to the adapter guide, and no button', async ({ page }) => {
67+
await page.goto('/');
68+
const links = page.locator(`${BAND} a`);
69+
await expect(links).toHaveCount(1);
70+
await expect(links).toHaveAttribute('href', '/docs/choosing-an-adapter');
71+
await expect(page.locator(`${BAND} [data-ui="button"]`)).toHaveCount(0);
72+
});
73+
});

apps/website/e2e/website.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ test('landing page renders the spine in order (live-stage spec §3)', async ({ p
5757
'proof-heading',
5858
'compatibility-heading',
5959
'architecture-heading',
60+
'no-runtime-heading',
6061
'open-source-heading',
6162
'stage-heading',
6263
'field-report-heading',

apps/website/src/app/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Stage } from '../components/landing/Stage';
66
import { TeamsBlock } from '../components/landing/TeamsBlock';
77
import { HomeFAQ } from '../components/landing/HomeFAQ';
88
import { OpenSourceStrip } from '../components/landing/OpenSourceStrip';
9+
import { NoRuntimeBand } from '../components/landing/NoRuntimeBand';
910
import { RecentArticles } from '../components/landing/RecentArticles';
1011
// The homepage must stay statically rendered (no cookies()/headers()/dynamic):
1112
// the proof lines are read from the demo recording at build time, and the file
@@ -35,6 +36,12 @@ export default function HomePage() {
3536
<Compatibility />
3637
<EnterpriseArchitecture />
3738

39+
{/* No runtime: the diagram above shows Threadplane reaching your agent
40+
directly; this band says so in two words. Copy lives in
41+
NO_RUNTIME_BAND (positioning.ts). Deliberately dark-on-dark with the
42+
band below; each dark section paints its own seam (spec §5). */}
43+
<NoRuntimeBand />
44+
3845
{/* The open-source full stop: a loud dark band with one fork CTA. Copy
3946
lives in OPEN_SOURCE_STRIP (positioning.ts). */}
4047
<OpenSourceStrip />

apps/website/src/components/landing/HomeFAQ.spec.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,21 @@ describe('HomeFAQ', () => {
1212
'Can I use my existing Angular component library and design system?',
1313
'Does generated UI execute arbitrary code?',
1414
'Does Threadplane require a hosted service or an account?',
15+
'Does Threadplane have a runtime I need to deploy?',
1516
];
1617
for (const q of questions) expect(screen.getByText(q)).toBeTruthy();
1718
expect(screen.queryByText('Does Threadplane require LangGraph?')).toBeNull();
1819
expect(screen.queryByText(/raw streaming SDK/)).toBeNull();
19-
expect(container.querySelectorAll('summary')).toHaveLength(4);
20-
expect(container.querySelectorAll('a')).toHaveLength(4);
20+
expect(container.querySelectorAll('summary')).toHaveLength(5);
21+
expect(container.querySelectorAll('a')).toHaveLength(5);
22+
});
23+
24+
it('answers the runtime question in the band’s own words', () => {
25+
render(<HomeFAQ />);
26+
const answer = screen.getByText(/no Threadplane server in the request path/);
27+
expect(answer.textContent).toMatch(/no key/);
28+
expect(answer.textContent).toMatch(/no production tier/);
29+
expect(answer.textContent).not.toMatch(/proxy/i);
30+
expect(answer.querySelector('a')?.getAttribute('href')).toBe('/docs/choosing-an-adapter');
2131
});
2232
});

apps/website/src/components/landing/HomeFAQ.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { Section } from '../ui/Section';
33
import { Eyebrow } from '../ui/Eyebrow';
44
import { FAQ, type FAQItem } from '../ui/FAQ';
55

6-
// Four questions the page above does not answer (live-stage spec §3). Copy here
6+
// Five questions the page above does not answer (live-stage spec §3; the
7+
// runtime one from spec 2026-09-11). Copy here
78
// is scanned by lib/public-copy-contract.ts: absolute claims ("installation is
89
// inert") and retired routes (/docs/telemetry/**) are barred — a previous FAQ
910
// answer shipped both and only the production crawl caught it.
@@ -46,6 +47,16 @@ const ITEMS: FAQItem[] = [
4647
</>
4748
),
4849
},
50+
{
51+
q: 'Does Threadplane have a runtime I need to deploy?',
52+
a: (
53+
<>
54+
No. The adapters call your LangGraph or AG-UI server from the browser. There is no
55+
Threadplane server in the request path, no key, and no production tier.{' '}
56+
<a href="/docs/choosing-an-adapter">How it is wired</a>
57+
</>
58+
),
59+
},
4960
];
5061

5162
export function HomeFAQ() {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// @vitest-environment jsdom
2+
import { fireEvent, render, screen } from '@testing-library/react';
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { NoRuntimeBand } from './NoRuntimeBand';
5+
import { NO_RUNTIME_BAND } from '../../lib/positioning';
6+
7+
const trackCtaClickMock = vi.hoisted(() => vi.fn());
8+
vi.mock('../../lib/analytics/client', () => ({
9+
track: vi.fn(),
10+
trackCtaClick: trackCtaClickMock,
11+
trackExternalLinkClick: vi.fn(),
12+
}));
13+
14+
beforeEach(() => trackCtaClickMock.mockClear());
15+
16+
describe('NoRuntimeBand', () => {
17+
it('makes the two-word headline the section heading and names the section by it', () => {
18+
const { container } = render(<NoRuntimeBand />);
19+
const heading = screen.getByRole('heading', { level: 2 });
20+
expect(heading.textContent).toBe(NO_RUNTIME_BAND.headline);
21+
expect(heading.id).toBe('no-runtime-heading');
22+
const section = container.querySelector('[data-ui="section"]');
23+
expect(section?.getAttribute('aria-labelledby')).toBe('no-runtime-heading');
24+
expect(section?.getAttribute('id')).toBe('no-runtime');
25+
expect(container.querySelector('.no-runtime-eyebrow')?.textContent).toBe(NO_RUNTIME_BAND.eyebrow);
26+
expect(screen.getByText(NO_RUNTIME_BAND.body)).toBeTruthy();
27+
});
28+
29+
it('sits on the dark surface at the full rhythm, like Fork us', () => {
30+
const { container } = render(<NoRuntimeBand />);
31+
const section = container.querySelector('[data-ui="section"]');
32+
expect(section?.getAttribute('data-surface')).toBe('dark');
33+
expect(section?.getAttribute('data-tight')).toBeNull();
34+
expect(section?.classList.contains('no-runtime')).toBe(true);
35+
});
36+
37+
it('offers one text link and no button, and reports it as a homepage CTA', () => {
38+
const { container } = render(<NoRuntimeBand />);
39+
const links = container.querySelectorAll('a');
40+
expect(links).toHaveLength(1);
41+
const link = screen.getByRole('link', { name: NO_RUNTIME_BAND.link.label });
42+
expect(link.getAttribute('href')).toBe(NO_RUNTIME_BAND.link.href);
43+
expect(container.querySelector('[data-ui="button"]')).toBeNull();
44+
fireEvent.click(link);
45+
expect(trackCtaClickMock).toHaveBeenCalledWith({
46+
cta_id: 'home_no_runtime_docs',
47+
track: 'developer',
48+
surface: 'home',
49+
destination_url: NO_RUNTIME_BAND.link.href,
50+
});
51+
});
52+
53+
it('draws both flows from the copy module, with the ghost hop only in the usual one', () => {
54+
const { container } = render(<NoRuntimeBand />);
55+
const usual = container.querySelector('[data-flow="usual"]');
56+
const ours = container.querySelector('[data-flow="ours"]');
57+
expect(
58+
Array.from(usual!.querySelectorAll('.no-runtime-node')).map((n) => n.textContent),
59+
).toEqual([...NO_RUNTIME_BAND.flows.usual.nodes]);
60+
expect(
61+
Array.from(ours!.querySelectorAll('.no-runtime-node')).map((n) => n.textContent),
62+
).toEqual([...NO_RUNTIME_BAND.flows.ours.nodes]);
63+
expect(container.querySelectorAll('.no-runtime-node.is-ghost')).toHaveLength(1);
64+
expect(usual!.querySelector('.no-runtime-node.is-ghost')?.textContent).toBe(
65+
NO_RUNTIME_BAND.flows.usual.ghost,
66+
);
67+
expect(ours!.querySelector('.is-ghost')).toBeNull();
68+
// The direct arrow spans the hop that is not there: one in ours, none in usual.
69+
const oursArrows = ours!.querySelectorAll('.no-runtime-arrow');
70+
expect(oursArrows).toHaveLength(1);
71+
expect(oursArrows[0].classList.contains('is-direct')).toBe(true);
72+
const usualArrows = usual!.querySelectorAll('.no-runtime-arrow');
73+
expect(usualArrows).toHaveLength(2);
74+
expect(usual!.querySelector('.no-runtime-arrow.is-direct')).toBeNull();
75+
});
76+
77+
it('gives the flows a prose caption and hides the arrows from assistive tech', () => {
78+
const { container } = render(<NoRuntimeBand />);
79+
const figure = container.querySelector('figure.no-runtime-figure');
80+
expect(figure).toBeTruthy();
81+
expect(figure?.querySelector('figcaption')?.textContent).toBe(NO_RUNTIME_BAND.figureCaption);
82+
// getByRole skips hidden subtrees, so aria-hiding the figure fails here.
83+
// (jsdom's accessible-name computation gives a figure no name from its
84+
// figcaption, so the name is not queried; the caption is checked below.)
85+
expect(screen.getByRole('figure')).toBe(figure);
86+
expect(figure?.querySelector('figcaption')?.closest('[aria-hidden="true"]')).toBeNull();
87+
// The drawn flows duplicate the caption, so they are hidden as a unit.
88+
expect(figure?.querySelector('.no-runtime-flows')?.getAttribute('aria-hidden')).toBe('true');
89+
});
90+
91+
it('carries no runway of its own, so it does not merge with Fork us below it', () => {
92+
// Two stripes 450px apart read as one tall block in the browser; the
93+
// marking stays on Fork us alone (spec §5 fallback).
94+
const { container } = render(<NoRuntimeBand />);
95+
expect(container.querySelector('.no-runtime-runway')).toBeNull();
96+
// Structurally, not by class name: the section holds the container and
97+
// nothing else, so no stripe can come back under another name.
98+
const section = container.querySelector('[data-ui="section"]');
99+
expect(section?.children).toHaveLength(1);
100+
expect(section?.firstElementChild?.getAttribute('data-ui')).toBe('container');
101+
});
102+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use client';
2+
3+
import { Container } from '../ui/Container';
4+
import { Section } from '../ui/Section';
5+
import { trackCtaClick } from '../../lib/analytics/client';
6+
import { NO_RUNTIME_BAND } from '../../lib/positioning';
7+
8+
/**
9+
* The No-runtime band (spec 2026-09-11): a dark band between the architecture
10+
* diagram and Fork us, in the same loud register. Copy left, two vertical
11+
* flows right — the usual path with a vendor runtime in the middle, and ours
12+
* without it.
13+
*
14+
* No runway stripe here, on purpose. It was in the design; in the browser two
15+
* stripes 450px apart made this band and Fork us read as one tall block
16+
* (spec §5 fallback). Fork us keeps the marking, so the page still has one.
17+
*
18+
* Everything it says comes from NO_RUNTIME_BAND (positioning.ts). It names no
19+
* other product: the band states Threadplane's shape and leaves the
20+
* comparison to the reader.
21+
*
22+
* `'use client'` only for the analytics click handler; there is no state.
23+
*/
24+
export function NoRuntimeBand() {
25+
const { eyebrow, headline, body, link, flows, figureCaption } = NO_RUNTIME_BAND;
26+
27+
return (
28+
<Section surface="dark" id="no-runtime" ariaLabelledBy="no-runtime-heading" className="no-runtime">
29+
<Container>
30+
<div className="no-runtime-grid">
31+
<div className="no-runtime-copy">
32+
<p className="no-runtime-eyebrow">{eyebrow}</p>
33+
<h2 id="no-runtime-heading" className="no-runtime-headline">
34+
{headline}
35+
</h2>
36+
<p className="no-runtime-body">{body}</p>
37+
<a
38+
className="no-runtime-link"
39+
href={link.href}
40+
onClick={() =>
41+
trackCtaClick({
42+
cta_id: 'home_no_runtime_docs',
43+
track: 'developer',
44+
surface: 'home',
45+
destination_url: link.href,
46+
})
47+
}
48+
>
49+
{link.label}
50+
<span aria-hidden="true"></span>
51+
</a>
52+
</div>
53+
54+
<figure className="no-runtime-figure">
55+
{/* The drawn flows and the caption say the same thing; the drawing
56+
is hidden as a unit so a screen reader hears it once. */}
57+
<div className="no-runtime-flows" aria-hidden="true">
58+
<Flow id="usual" label={flows.usual.label} nodes={flows.usual.nodes} ghost={flows.usual.ghost} />
59+
<Flow id="ours" label={flows.ours.label} nodes={flows.ours.nodes} />
60+
</div>
61+
<figcaption className="no-runtime-caption">{figureCaption}</figcaption>
62+
</figure>
63+
</div>
64+
</Container>
65+
</Section>
66+
);
67+
}
68+
69+
function Flow({
70+
id,
71+
label,
72+
nodes,
73+
ghost,
74+
}: {
75+
id: 'usual' | 'ours';
76+
label: string;
77+
nodes: readonly string[];
78+
ghost?: string;
79+
}) {
80+
return (
81+
<div className="no-runtime-flow" data-flow={id}>
82+
<p className="no-runtime-flow-label">{label}</p>
83+
{nodes.map((node, i) => (
84+
<div className="no-runtime-hop" key={node}>
85+
{i > 0 ? <span className={`no-runtime-arrow${id === 'ours' ? ' is-direct' : ''}`} /> : null}
86+
<span
87+
className={`no-runtime-node${node === ghost ? ' is-ghost' : ''}${
88+
i === nodes.length - 1 ? ' is-agent' : ''
89+
}`}
90+
>
91+
{node}
92+
</span>
93+
</div>
94+
))}
95+
</div>
96+
);
97+
}

0 commit comments

Comments
 (0)