Skip to content

Commit f9c649b

Browse files
bloveclaude
andauthored
feat(website): homepage sequencing, plate trim, and diagram scaling (#1089)
* feat(website): replace the whitepaper disclosure and drop the direct links Requested change. The form now reads 'We will never spam you or share your email address.' in place of the three-email outreach disclosure, and the two 'Download the PDF directly' escapes are gone: the 'Already on the list?' line and the one in the failure block. Recorded because it is not a copy edit: whitepaper signups are still enrolled into the founder campaign (campaign/send.ts treats formKind 'whitepaper' as a campaign context), so the follow-up sequence still sends and is no longer disclosed at the point of capture. Raised before making the change and confirmed. GROWTH_FORM_POLICY_VERSION is bumped to growth_v1.2026-09-09 so consent captured under the old wording stays distinguishable from consent captured under this one, and the constant is renamed WHITEPAPER_PRIVACY_ASSURANCE because it no longer discloses outreach. The SUCCESS block keeps its direct link -- that reader has already given their address and the copy promises it ('the PDF is here too'). The failure block now says 'Try again in a moment.' rather than handing over the file. Also removes the .wp-already rules and the home_whitepaper_direct_inline analytics id, both of which nothing can reach any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): reorder the fork band, trim the plate, swap the briefing titles - OpenSourceStrip moves above Stage, so the fork band follows the architecture diagram. The e2e spine order moves with it. - The airport plate loses its N/S/E taxiways -- lines, letters, the TaxiwayLetter component, the TWY_* constants and the .ap-taxiway / .ap-twy-* rules -- and gains 44 viewBox units of margin below the southern runway (NEAT 444->488, VIEW 536->580, furniture shifted with it). - The off-airport strip now reads 'All AI Models supported'. - 'Compatibility, not endorsement' and 'Everything on the right is yours' are removed. - The briefing titles swap: the eyebrow carries the problem, the h2 carries the offer. '· free' leaves the eyebrow meta because the headline now opens with 'Free'. Two guards changed rather than deleted. Compatibility.spec kept its negative half -- nothing may read as an endorsement, and 'never sees' must still appear nowhere, that being the overclaim #1067 corrected -- while losing the positive never-TALKS-TO assertion, since that claim leaves the homepage with the old strip copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): the architecture diagram scales instead of scrolling It was never scaling at all. The docs kit floors every figure at a render width so shrinking the SVG cannot shrink its text into illegibility, and the marketing scale caps it at 860 -- but min-width beats max-width, so the two together pinned this diagram at exactly 1024px: ~96px of the container went unused on desktop, and between 768 and 1024 the figure scrolled sideways until the phone stack snapped in. Both constraints are dropped so width:100% governs. The max-width override needs .arch-figure .tp-diagram-figure[data-scale] .tp-diagram-svg -- the marketing cap is (0,3,0) and a plain .arch-figure descendant selector loses to it, which is how the first attempt at this silently stayed at 860px. Legibility now comes from the breakpoint rather than a floor: the phone stack takes over below 1024px, so the SVG never renders under ~944px and its 13.5px type never drops under ~10px. Measured at 1440px: 1120px wide, 11.8px type, no scroll. At 900px: stack, no page overflow. Also removes the toast's standalone 'or download directly' link and its now-dead .toast-download-link rule, and updates the airport e2e, which pinned the three taxiways this branch removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): the toast's failure block no longer hands over the PDF Aligns it with WhitePaperForm, whose failure block was changed the same way earlier on this branch: the guide is reachable only through a successful submission, so a failed send says to retry rather than releasing the file. trackWhitepaperDownloadClick and the toast_direct_download cta id go with it -- nothing in the toast can emit either any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): the e2e follows the policy bump and the PDF gate Both surfaced in CI, both real consequences of this branch. website.spec.ts hardcoded growth_v1.2026-09-01 rather than importing it (form-policy.ts is server-only and cannot load in a Playwright process), so bumping the policy version made the running app post a value the suite did not expect. Updated, with a comment saying it has to move with the constant. 'marketing pages link to downloadable whitepaper PDFs' failed because there are no such links any more: removing every direct-download escape from WhitePaperForm took the library pages' links with it. The test now asserts the gate instead -- the PDFs stay served, they are just not linked -- so re-adding a link is a deliberate act rather than a silent regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a956b25 commit f9c649b

19 files changed

Lines changed: 103 additions & 174 deletions

apps/website/e2e/home-airport.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,9 @@ test.describe('homepage airport diagram', () => {
289289
CONCOURSES.length
290290
);
291291
await expect(page.locator(`${PLATE} [data-main-terminal]`)).toHaveCount(1);
292-
await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(3);
292+
// The N/S/E taxiways were removed on request. Asserted as absent rather
293+
// than dropped, so re-adding them is a deliberate act and not a drift.
294+
await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(0);
293295
await expect(page.locator(`${PLATE} .ap-furniture`)).toHaveCount(1);
294296
// The off-airport row is the section's central argument rendered as
295297
// geometry — the five providers sit OUTSIDE the neat line because

apps/website/e2e/website.spec.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { test, expect } from '@playwright/test';
22

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

@@ -52,8 +57,8 @@ test('landing page renders the spine in order (live-stage spec §3)', async ({ p
5257
'proof-heading',
5358
'compatibility-heading',
5459
'architecture-heading',
55-
'stage-heading',
5660
'open-source-heading',
61+
'stage-heading',
5762
'field-report-heading',
5863
'faq-heading',
5964
];
@@ -548,17 +553,21 @@ test('representative docs pages do not create page-level horizontal overflow', a
548553
}
549554
});
550555

551-
test('marketing pages link to downloadable whitepaper PDFs', async ({ page }) => {
552-
const expectedDownloads: Record<string, string> = {
556+
test('marketing pages gate the whitepaper PDFs behind the form', async ({ page }) => {
557+
// These pages used to link their PDF directly. Every direct-download escape
558+
// was removed on request, so the guide is now reachable only by submitting
559+
// the form -- the files stay served (see the next test), they are just not
560+
// linked. Asserted as absent so re-adding a link is a deliberate act.
561+
const gated: Record<string, string> = {
553562
'/ag-ui': '/whitepaper.pdf',
554563
'/langgraph': '/whitepapers/angular.pdf',
555564
'/render': '/whitepapers/render.pdf',
556565
'/chat': '/whitepapers/chat.pdf',
557566
};
558567

559-
for (const [route, href] of Object.entries(expectedDownloads)) {
568+
for (const [route, href] of Object.entries(gated)) {
560569
await page.goto(route);
561-
await expect(page.locator(`a[href="${href}"]`).first(), `${route} links ${href}`).toBeVisible();
570+
await expect(page.locator(`a[href="${href}"]`), `${route} still links ${href}`).toHaveCount(0);
562571
}
563572
});
564573

apps/website/src/app/page.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,15 @@ export default function HomePage() {
3535
<Compatibility />
3636
<EnterpriseArchitecture />
3737

38+
{/* The open-source full stop: a loud dark band with one fork CTA. Copy
39+
lives in OPEN_SOURCE_STRIP (positioning.ts). */}
40+
<OpenSourceStrip />
41+
3842
{/* The four capability beats (stream, persist, approve, render): stills
3943
by default, the pinned live act on wide, motion-tolerant viewports
4044
(live-stage spec §3, §8). Copy lives in STAGE_RAIL (positioning.ts). */}
4145
<Stage proof={STAGE_PROOF} />
4246

43-
{/* The open-source full stop: a loud dark band with one fork CTA. Copy
44-
lives in OPEN_SOURCE_STRIP (positioning.ts). */}
45-
<OpenSourceStrip />
4647
<TeamsBlock formPolicy={formPolicy} />
4748
<HomeFAQ />
4849
<RecentArticles />

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

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -103,26 +103,26 @@ describe('Compatibility', () => {
103103
}
104104
});
105105

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

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

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

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { AdapterGuideLink } from './AdapterGuideLink';
44
import {
55
CHART_ID,
66
CONCOURSES,
7-
DISCLAIMER,
87
EYEBROW,
98
FIELD,
109
HEADLINE,
@@ -23,9 +22,6 @@ import {
2322
STAND,
2423
TICK_X,
2524
TICK_Y,
26-
TWY_E,
27-
TWY_N,
28-
TWY_S,
2925
VIEW,
3026
type Gate,
3127
type Row,
@@ -48,17 +44,6 @@ function Runway({ y, h, left, right }: RunwayGeometry) {
4844
);
4945
}
5046

51-
function TaxiwayLetter({ x, y, ch }: { x: number; y: number; ch: string }) {
52-
return (
53-
<g>
54-
<circle className="ap-twy-disc" cx={x} cy={y} r={7.5} />
55-
<text className="ap-twy-letter" x={x} y={y + 3.4} textAnchor="middle">
56-
{ch}
57-
</text>
58-
</g>
59-
);
60-
}
61-
6247
/** A stand: the stub off the concourse, the white box, the mark, the callsign. */
6348
function Stand({ gate, row, above }: { gate: Gate; row: Row; above: boolean }) {
6449
const cy = row.standCy;
@@ -168,12 +153,6 @@ function Plate() {
168153
<Runway {...RWY_N} />
169154
<Runway {...RWY_S} />
170155

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

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

360339
<div className="airport-footer">
361340
<AdapterGuideLink className="compatibility-link" />
362-
<p className="compatibility-disclaimer">{DISCLAIMER}</p>
363341
</div>
364342
</Container>
365343
</Section>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const ARCHITECTURE_EYEBROW = 'Architecture';
2626
export const ARCHITECTURE_HEADLINE =
2727
'The UI layer between your users and your agents.';
2828
export const ARCHITECTURE_BODY =
29-
'Threadplane lives inside your Angular application and talks to your agents through the LangGraph SDK or AG-UI. Everything on the right is yours.';
29+
'Threadplane lives inside your Angular application and talks to your agents through the LangGraph SDK or AG-UI.';
3030
export const ARCHITECTURE_LABEL =
3131
'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.';
3232

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('TeamsBlock', () => {
2020
it('leads with the ask: eyebrow, heading, then the form', () => {
2121
const { container } = render(<TeamsBlock formPolicy={formPolicy} />);
2222
const heading = screen.getByRole('heading', { level: 2 });
23-
expect(heading.textContent).toBe('What breaks between a demo and production.');
23+
expect(heading.textContent).toBe('Free preflight briefing for agentic UI');
2424
expect(heading.id).toBe('field-report-heading');
2525
expect(container.querySelectorAll('form')).toHaveLength(1);
2626
expect(screen.getByLabelText('Work email')).toBeTruthy();
@@ -33,7 +33,7 @@ describe('TeamsBlock', () => {
3333
const { container } = render(<TeamsBlock formPolicy={formPolicy} />);
3434
const eyebrow = container.querySelector('[data-ui="eyebrow"]');
3535
expect(eyebrow?.textContent).toContain(`${FIELD_REPORT.pages} pages`);
36-
expect(eyebrow?.textContent).toContain('Preflight briefing');
36+
expect(eyebrow?.textContent).toContain('What breaks between a demo and production');
3737
});
3838

3939
it('shows the briefing beside the ask', () => {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ export function TeamsBlock({ formPolicy }: { formPolicy: PublicFormPolicy }) {
2929
<div className="teams-grid">
3030
<div>
3131
<Eyebrow tone="accent" className="teams-eyebrow">
32-
Preflight briefing
32+
What breaks between a demo and production
3333
<span className="teams-eyebrow-meta">
34-
{' '}· {FIELD_REPORT.pages} pages · free
34+
{' '}· {FIELD_REPORT.pages} pages
3535
</span>
3636
</Eyebrow>
3737
<h2 id="field-report-heading" className="teams-heading">
38-
What breaks between a demo and production.
38+
Free preflight briefing for agentic UI
3939
</h2>
4040
<WhitePaperForm
4141
paper="overview"

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const formPolicy: PublicFormPolicy = {
2020
contact: 'Contact disclosure',
2121
newsletter: 'Newsletter disclosure',
2222
whitepaper:
23-
'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.',
23+
'We will never spam you or share your email address.',
2424
},
2525
};
2626

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

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

7681
it('validates on blur, focuses the field on an invalid submit, and clears once valid', () => {

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,17 @@ export function WhitePaperForm({
5555
const inputId = `${idPrefix}-email`;
5656
const disclosureId = `${idPrefix}-growth-disclosure`;
5757

58-
const directLink = (ctaId: 'home_whitepaper_direct' | 'home_whitepaper_direct_inline', label: string) => (
58+
const directLink = (label: string) => (
5959
<a
6060
href={pdf.href}
6161
download={pdf.download}
62-
onClick={() => trackWhitepaperDownloadClick(paper, { surface, source_section: sourceSection, cta_id: ctaId })}
62+
onClick={() =>
63+
trackWhitepaperDownloadClick(paper, {
64+
surface,
65+
source_section: sourceSection,
66+
cta_id: 'home_whitepaper_direct',
67+
})
68+
}
6369
>
6470
{label}
6571
</a>
@@ -79,7 +85,7 @@ export function WhitePaperForm({
7985
if (form.status === 'sent') {
8086
return (
8187
<FormStatus tone="success" title="Check your inbox." detail="The guide is on its way, and the PDF is here too.">
82-
{directLink('home_whitepaper_direct', 'Download the PDF directly')}
88+
{directLink('Download the PDF directly')}
8389
</FormStatus>
8490
);
8591
}
@@ -118,11 +124,8 @@ export function WhitePaperForm({
118124
{formPolicy.disclosures.whitepaper}
119125
</p>
120126
{form.status === 'failed' ? (
121-
<FormStatus tone="failure" title="That did not send." detail="You can still get the guide.">
122-
{directLink('home_whitepaper_direct', 'Download the PDF directly')}
123-
</FormStatus>
127+
<FormStatus tone="failure" title="That did not send." detail="Try again in a moment." />
124128
) : null}
125-
<p className="wp-already">Already on the list? {directLink('home_whitepaper_direct_inline', 'Download the PDF directly.')}</p>
126129
</form>
127130
);
128131
}

0 commit comments

Comments
 (0)