Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/website/e2e/home-airport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,9 @@ test.describe('homepage airport diagram', () => {
CONCOURSES.length
);
await expect(page.locator(`${PLATE} [data-main-terminal]`)).toHaveCount(1);
await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(3);
// The N/S/E taxiways were removed on request. Asserted as absent rather
// than dropped, so re-adding them is a deliberate act and not a drift.
await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(0);
await expect(page.locator(`${PLATE} .ap-furniture`)).toHaveCount(1);
// The off-airport row is the section's central argument rendered as
// geometry — the five providers sit OUTSIDE the neat line because
Expand Down
21 changes: 15 additions & 6 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { test, expect } from '@playwright/test';

// Mirrored from apps/website/src/lib/growth/form-policy.ts, which is server-only
// and therefore cannot be imported into a Playwright spec.
const GROWTH_FORM_POLICY_VERSION = 'growth_v1.2026-09-01';
// Hardcoded rather than imported: lib/growth/form-policy.ts is `server-only`
// and cannot be pulled into a Playwright process. It must be updated whenever
// GROWTH_FORM_POLICY_VERSION is bumped -- this literal is compared against what
// the running app actually posts, so a stale copy fails the suite rather than
// passing silently.
const GROWTH_FORM_POLICY_VERSION = 'growth_v1.2026-09-09';
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;

Expand Down Expand Up @@ -52,8 +57,8 @@ test('landing page renders the spine in order (live-stage spec §3)', async ({ p
'proof-heading',
'compatibility-heading',
'architecture-heading',
'stage-heading',
'open-source-heading',
'stage-heading',
'field-report-heading',
'faq-heading',
];
Expand Down Expand Up @@ -548,17 +553,21 @@ 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<string, string> = {
test('marketing pages gate the whitepaper PDFs behind the form', async ({ page }) => {
// These pages used to link their PDF directly. Every direct-download escape
// was removed on request, so the guide is now reachable only by submitting
// the form -- the files stay served (see the next test), they are just not
// linked. Asserted as absent so re-adding a link is a deliberate act.
const gated: Record<string, string> = {
'/ag-ui': '/whitepaper.pdf',
'/langgraph': '/whitepapers/angular.pdf',
'/render': '/whitepapers/render.pdf',
'/chat': '/whitepapers/chat.pdf',
};

for (const [route, href] of Object.entries(expectedDownloads)) {
for (const [route, href] of Object.entries(gated)) {
await page.goto(route);
await expect(page.locator(`a[href="${href}"]`).first(), `${route} links ${href}`).toBeVisible();
await expect(page.locator(`a[href="${href}"]`), `${route} still links ${href}`).toHaveCount(0);
}
});

Expand Down
7 changes: 4 additions & 3 deletions apps/website/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ export default function HomePage() {
<Compatibility />
<EnterpriseArchitecture />

{/* The open-source full stop: a loud dark band with one fork CTA. Copy
lives in OPEN_SOURCE_STRIP (positioning.ts). */}
<OpenSourceStrip />

{/* The four capability beats (stream, persist, approve, render): stills
by default, the pinned live act on wide, motion-tolerant viewports
(live-stage spec §3, §8). Copy lives in STAGE_RAIL (positioning.ts). */}
<Stage proof={STAGE_PROOF} />

{/* The open-source full stop: a loud dark band with one fork CTA. Copy
lives in OPEN_SOURCE_STRIP (positioning.ts). */}
<OpenSourceStrip />
<TeamsBlock formPolicy={formPolicy} />
<HomeFAQ />
<RecentArticles />
Expand Down
28 changes: 14 additions & 14 deletions apps/website/src/components/landing/Compatibility.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,26 +103,26 @@ describe('Compatibility', () => {
}
});

it('states compatibility in words and never implies a customer', () => {
it('never implies a customer, and no longer needs a disclaimer to say so', () => {
// The "Compatibility, not endorsement" line was removed on request. The
// claim it guarded against still matters, so the negative assertion stays:
// nothing in the band may read as an endorsement in the first place.
const { container } = render(<Compatibility />);
expect(screen.getByText(/Compatibility, not endorsement/)).toBeTruthy();
expect(screen.queryByText(/Compatibility, not endorsement/)).toBeNull();
expect(container.textContent).not.toMatch(/trusted by|customers|our clients|powered by/i);
});

it('says Threadplane never talks to model providers, not that it never sees them', () => {
// never-SEES is a data claim the docs do not support; never-TALKS-TO is
// structural. This is the same failure mode #1067 had to correct.
it('never claims Threadplane cannot see model providers', () => {
// The strip used to read "OFF AIRPORT - BEHIND YOUR BACKEND. THREADPLANE
// NEVER TALKS TO THEM." and this guard held the positive half of that
// claim in place. The label is now "All AI Models supported" on request,
// so the structural never-TALKS-TO claim is off the homepage entirely.
//
// The positive half is scoped to .airport-stack, for the same reason as
// the gate-name test above: the plate carries this sentence too, as an
// aria-hidden <text>, so an unscoped read of container.textContent stays
// green while the claim disappears from the phone form and from every
// accessible surface the band has. The negative half stays unscoped —
// "never sees" must not appear anywhere in the section, drawn or spoken.
// The negative half stays, and matters more: never-SEES is a data claim
// the docs do not support, and it is the overclaim #1067 had to correct.
// It must not appear anywhere in the band, drawn or spoken.
const { container } = render(<Compatibility />);
const stack = container.querySelector('.airport-stack');
expect(stack, 'the accessible stack is gone').toBeTruthy();
expect(within(stack as HTMLElement).getByText(/never talks to them/i)).toBeTruthy();
expect(container.querySelector('.airport-stack'), 'the accessible stack is gone').toBeTruthy();
expect(container.textContent).not.toMatch(/never sees/i);
});

Expand Down
22 changes: 0 additions & 22 deletions apps/website/src/components/landing/Compatibility.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { AdapterGuideLink } from './AdapterGuideLink';
import {
CHART_ID,
CONCOURSES,
DISCLAIMER,
EYEBROW,
FIELD,
HEADLINE,
Expand All @@ -23,9 +22,6 @@ import {
STAND,
TICK_X,
TICK_Y,
TWY_E,
TWY_N,
TWY_S,
VIEW,
type Gate,
type Row,
Expand All @@ -48,17 +44,6 @@ function Runway({ y, h, left, right }: RunwayGeometry) {
);
}

function TaxiwayLetter({ x, y, ch }: { x: number; y: number; ch: string }) {
return (
<g>
<circle className="ap-twy-disc" cx={x} cy={y} r={7.5} />
<text className="ap-twy-letter" x={x} y={y + 3.4} textAnchor="middle">
{ch}
</text>
</g>
);
}

/** A stand: the stub off the concourse, the white box, the mark, the callsign. */
function Stand({ gate, row, above }: { gate: Gate; row: Row; above: boolean }) {
const cy = row.standCy;
Expand Down Expand Up @@ -168,12 +153,6 @@ function Plate() {
<Runway {...RWY_N} />
<Runway {...RWY_S} />

<path className="ap-taxiway" d={`M${FIELD.x0} ${TWY_N} H${FIELD.x1}`} />
<path className="ap-taxiway" d={`M${FIELD.x0} ${TWY_S} H${FIELD.x1}`} />
<path className="ap-taxiway" d={`M${TWY_E} ${TWY_N} V${TWY_S}`} />
<TaxiwayLetter x={PIVOT.x} y={TWY_N} ch="N" />
<TaxiwayLetter x={PIVOT.x} y={TWY_S} ch="S" />
<TaxiwayLetter x={TWY_E} y={PIVOT.y} ch="E" />

{/* The one structure that IS Threadplane: solid ink. Partner stands are
white, so the two values carry the meaning with no legend. */}
Expand Down Expand Up @@ -359,7 +338,6 @@ export function Compatibility() {

<div className="airport-footer">
<AdapterGuideLink className="compatibility-link" />
<p className="compatibility-disclaimer">{DISCLAIMER}</p>
</div>
</Container>
</Section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const ARCHITECTURE_EYEBROW = 'Architecture';
export const ARCHITECTURE_HEADLINE =
'The UI layer between your users and your agents.';
export const ARCHITECTURE_BODY =
'Threadplane lives inside your Angular application and talks to your agents through the LangGraph SDK or AG-UI. Everything on the right is yours.';
'Threadplane lives inside your Angular application and talks to your agents through the LangGraph SDK or AG-UI.';
export const ARCHITECTURE_LABEL =
'Threadplane is the UI layer between your users and your agents: it lives inside your Angular application, reaches LangGraph agents first-class through the LangGraph SDK and any AG-UI server through the AG-UI protocol, and leaves the model choice to your runtime.';

Expand Down
4 changes: 2 additions & 2 deletions apps/website/src/components/landing/TeamsBlock.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('TeamsBlock', () => {
it('leads with the ask: eyebrow, heading, then the form', () => {
const { container } = render(<TeamsBlock formPolicy={formPolicy} />);
const heading = screen.getByRole('heading', { level: 2 });
expect(heading.textContent).toBe('What breaks between a demo and production.');
expect(heading.textContent).toBe('Free preflight briefing for agentic UI');
expect(heading.id).toBe('field-report-heading');
expect(container.querySelectorAll('form')).toHaveLength(1);
expect(screen.getByLabelText('Work email')).toBeTruthy();
Expand All @@ -33,7 +33,7 @@ describe('TeamsBlock', () => {
const { container } = render(<TeamsBlock formPolicy={formPolicy} />);
const eyebrow = container.querySelector('[data-ui="eyebrow"]');
expect(eyebrow?.textContent).toContain(`${FIELD_REPORT.pages} pages`);
expect(eyebrow?.textContent).toContain('Preflight briefing');
expect(eyebrow?.textContent).toContain('What breaks between a demo and production');
});

it('shows the briefing beside the ask', () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/website/src/components/landing/TeamsBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ export function TeamsBlock({ formPolicy }: { formPolicy: PublicFormPolicy }) {
<div className="teams-grid">
<div>
<Eyebrow tone="accent" className="teams-eyebrow">
Preflight briefing
What breaks between a demo and production
<span className="teams-eyebrow-meta">
{' '}· {FIELD_REPORT.pages} pages · free
{' '}· {FIELD_REPORT.pages} pages
</span>
</Eyebrow>
<h2 id="field-report-heading" className="teams-heading">
What breaks between a demo and production.
Free preflight briefing for agentic UI
</h2>
<WhitePaperForm
paper="overview"
Expand Down
11 changes: 8 additions & 3 deletions apps/website/src/components/landing/WhitePaperBlock.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const formPolicy: PublicFormPolicy = {
contact: 'Contact disclosure',
newsletter: 'Newsletter disclosure',
whitepaper:
'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.',
'We will never spam you or share your email address.',
},
};

Expand Down Expand Up @@ -64,13 +64,18 @@ describe('WhitePaperBlock', () => {
expect(events).toContain('marketing:whitepaper_signup_success');
});

it('shows the direct PDF link in the failure block', async () => {
it('offers no direct download from the failure block', async () => {
// Removed on request: the PDF is reachable only through a successful
// submission now, so a failed send tells the reader to retry rather than
// handing over the file. The success block still links it, because that
// reader has already given their address and the copy promises it.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
render(<WhitePaperBlock formPolicy={formPolicy} paper="chat" />);
fireEvent.change(screen.getByLabelText('Work email'), { target: { value: 'reader@acme.com' } });
fireEvent.click(screen.getByRole('button', { name: 'Get the field report' }));
await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy());
expect(screen.getByRole('link', { name: 'Download the PDF directly' }).getAttribute('href')).toBe('/whitepapers/chat.pdf');
expect(screen.getByText('Try again in a moment.')).toBeTruthy();
expect(screen.queryByRole('link', { name: /Download the PDF/ })).toBeNull();
});

it('validates on blur, focuses the field on an invalid submit, and clears once valid', () => {
Expand Down
17 changes: 10 additions & 7 deletions apps/website/src/components/landing/WhitePaperForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,17 @@ export function WhitePaperForm({
const inputId = `${idPrefix}-email`;
const disclosureId = `${idPrefix}-growth-disclosure`;

const directLink = (ctaId: 'home_whitepaper_direct' | 'home_whitepaper_direct_inline', label: string) => (
const directLink = (label: string) => (
<a
href={pdf.href}
download={pdf.download}
onClick={() => trackWhitepaperDownloadClick(paper, { surface, source_section: sourceSection, cta_id: ctaId })}
onClick={() =>
trackWhitepaperDownloadClick(paper, {
surface,
source_section: sourceSection,
cta_id: 'home_whitepaper_direct',
})
}
>
{label}
</a>
Expand All @@ -79,7 +85,7 @@ export function WhitePaperForm({
if (form.status === 'sent') {
return (
<FormStatus tone="success" title="Check your inbox." detail="The guide is on its way, and the PDF is here too.">
{directLink('home_whitepaper_direct', 'Download the PDF directly')}
{directLink('Download the PDF directly')}
</FormStatus>
);
}
Expand Down Expand Up @@ -118,11 +124,8 @@ export function WhitePaperForm({
{formPolicy.disclosures.whitepaper}
</p>
{form.status === 'failed' ? (
<FormStatus tone="failure" title="That did not send." detail="You can still get the guide.">
{directLink('home_whitepaper_direct', 'Download the PDF directly')}
</FormStatus>
<FormStatus tone="failure" title="That did not send." detail="Try again in a moment." />
) : null}
<p className="wp-already">Already on the list? {directLink('home_whitepaper_direct_inline', 'Download the PDF directly.')}</p>
</form>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const formPolicy: PublicFormPolicy = {
contact: 'Contact disclosure',
newsletter: 'Newsletter disclosure',
whitepaper:
'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.',
'We will never spam you or share your email address.',
},
};

Expand Down
39 changes: 3 additions & 36 deletions apps/website/src/components/shared/AnnouncementToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import { useState, useEffect } from 'react';
import type { PublicFormPolicy } from '../../lib/growth/form-policy';
import { FORM_POLICY_REFRESH_MESSAGE } from '../../lib/growth/form-client';
import { analyticsEvents } from '../../lib/analytics/events';
import {
track,
trackWhitepaperDownloadClick,
} from '../../lib/analytics/client';
import { track } from '../../lib/analytics/client';
import { Button } from '../ui/Button';
import {
Field,
Expand Down Expand Up @@ -237,39 +234,9 @@ export function AnnouncementToast({
<FormStatus
tone="failure"
title="That did not send."
detail="You can still get the guide."
>
<a
href="/whitepaper.pdf"
download="angular-agent-readiness-guide.pdf"
onClick={() => {
trackWhitepaperDownloadClick('overview', {
surface: 'toast',
source_section: 'announcement-toast',
cta_id: 'toast_direct_download',
});
dismiss();
}}
>
Download the PDF directly
</a>
</FormStatus>
detail="Try again in a moment."
/>
) : null}
<a
href="/whitepaper.pdf"
download="angular-agent-readiness-guide.pdf"
onClick={() => {
trackWhitepaperDownloadClick('overview', {
surface: 'toast',
source_section: 'announcement-toast',
cta_id: 'toast_direct_download',
});
dismiss();
}}
className="toast-download-link"
>
or download directly
</a>
</form>
)}

Expand Down
12 changes: 0 additions & 12 deletions apps/website/src/lib/airport-diagram.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ import {
RWY_S,
SCALE_BAR,
STAND,
TWY_E,
TWY_N,
TWY_S,
VIEW,
WIDE_RATIO,
rotate,
Expand Down Expand Up @@ -98,15 +95,6 @@ describe('airport diagram geometry', () => {
inside(label, FIELD.x0, FIELD.x1, r.y, r.y + r.h);
}

// The two parallel taxiways run the full width; the east one connects them.
for (const [label, y] of [
['TWY_N', TWY_N],
['TWY_S', TWY_S],
] as const) {
inside(label, FIELD.x0, FIELD.x1, y, y);
}
inside('TWY_E', TWY_E, TWY_E, TWY_N, TWY_S);

for (const c of CONCOURSES) {
for (const g of c.gates) {
inside(
Expand Down
Loading
Loading