From c77c235e8b0c0c23fd6c4131caf6d944da3e4645 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 20:34:20 -0700 Subject: [PATCH 1/5] 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 --- .../components/landing/WhitePaperBlock.spec.tsx | 11 ++++++++--- .../src/components/landing/WhitePaperForm.tsx | 17 ++++++++++------- .../shared/AnnouncementToast.spec.tsx | 2 +- apps/website/src/lib/analytics/events.ts | 1 - apps/website/src/lib/growth/form-policy.spec.ts | 6 +++--- apps/website/src/lib/growth/form-policy.ts | 16 ++++++++++++---- apps/website/src/styles/landing.css | 10 ---------- 7 files changed, 34 insertions(+), 29 deletions(-) diff --git a/apps/website/src/components/landing/WhitePaperBlock.spec.tsx b/apps/website/src/components/landing/WhitePaperBlock.spec.tsx index cb1a3c5a7..d7689ccd9 100644 --- a/apps/website/src/components/landing/WhitePaperBlock.spec.tsx +++ b/apps/website/src/components/landing/WhitePaperBlock.spec.tsx @@ -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.', }, }; @@ -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(); 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', () => { diff --git a/apps/website/src/components/landing/WhitePaperForm.tsx b/apps/website/src/components/landing/WhitePaperForm.tsx index 55a92809b..15b49d0df 100644 --- a/apps/website/src/components/landing/WhitePaperForm.tsx +++ b/apps/website/src/components/landing/WhitePaperForm.tsx @@ -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) => ( trackWhitepaperDownloadClick(paper, { surface, source_section: sourceSection, cta_id: ctaId })} + onClick={() => + trackWhitepaperDownloadClick(paper, { + surface, + source_section: sourceSection, + cta_id: 'home_whitepaper_direct', + }) + } > {label} @@ -79,7 +85,7 @@ export function WhitePaperForm({ if (form.status === 'sent') { return ( - {directLink('home_whitepaper_direct', 'Download the PDF directly')} + {directLink('Download the PDF directly')} ); } @@ -118,11 +124,8 @@ export function WhitePaperForm({ {formPolicy.disclosures.whitepaper}

{form.status === 'failed' ? ( - - {directLink('home_whitepaper_direct', 'Download the PDF directly')} - + ) : null} -

Already on the list? {directLink('home_whitepaper_direct_inline', 'Download the PDF directly.')}

); } diff --git a/apps/website/src/components/shared/AnnouncementToast.spec.tsx b/apps/website/src/components/shared/AnnouncementToast.spec.tsx index 35eb23bcd..4b31d6823 100644 --- a/apps/website/src/components/shared/AnnouncementToast.spec.tsx +++ b/apps/website/src/components/shared/AnnouncementToast.spec.tsx @@ -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.', }, }; diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts index f1fe19b5d..abb7c48b6 100644 --- a/apps/website/src/lib/analytics/events.ts +++ b/apps/website/src/lib/analytics/events.ts @@ -82,7 +82,6 @@ export type CtaId = | 'hero_proof_pill' // Whitepaper block on home | 'home_whitepaper_direct' - | 'home_whitepaper_direct_inline' // Why this exists section | 'home_why_pilot_to_prod' // Pricing tier CTAs diff --git a/apps/website/src/lib/growth/form-policy.spec.ts b/apps/website/src/lib/growth/form-policy.spec.ts index 25e8a4e4c..1617cfd98 100644 --- a/apps/website/src/lib/growth/form-policy.spec.ts +++ b/apps/website/src/lib/growth/form-policy.spec.ts @@ -6,7 +6,7 @@ import { CONTACT_OUTREACH_DISCLOSURE, GROWTH_FORM_POLICY_VERSION, NEWSLETTER_OUTREACH_DISCLOSURE, - WHITEPAPER_OUTREACH_DISCLOSURE, + WHITEPAPER_PRIVACY_ASSURANCE, getFormPolicy, matchesSubmittedFormPolicy, } from './form-policy'; @@ -28,8 +28,8 @@ describe('server form policy', () => { contact: expect.any(String), }), }); - expect(WHITEPAPER_OUTREACH_DISCLOSURE).toBe( - 'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.' + expect(WHITEPAPER_PRIVACY_ASSURANCE).toBe( + 'We will never spam you or share your email address.' ); expect(CONTACT_OUTREACH_DISCLOSURE).toBe( 'By sending, you agree Brian may follow up by email about your request.' diff --git a/apps/website/src/lib/growth/form-policy.ts b/apps/website/src/lib/growth/form-policy.ts index 9233ab6f0..1c0af0f1b 100644 --- a/apps/website/src/lib/growth/form-policy.ts +++ b/apps/website/src/lib/growth/form-policy.ts @@ -1,9 +1,17 @@ import 'server-only'; -export const GROWTH_FORM_POLICY_VERSION = 'growth_v1.2026-09-01'; +export const GROWTH_FORM_POLICY_VERSION = 'growth_v1.2026-09-09'; -export const WHITEPAPER_OUTREACH_DISCLOSURE = - 'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.'; +/** + * Deliberately no longer an outreach disclosure, hence the rename: it makes no + * statement about the follow-up sequence, which whitepaper signups are still + * enrolled into (`apps/lifecycle/src/campaign/send.ts` treats formKind + * 'whitepaper' as a campaign context). Changed on request; the policy version + * is bumped alongside it so consent captured under the previous wording stays + * distinguishable from consent captured under this one. + */ +export const WHITEPAPER_PRIVACY_ASSURANCE = + 'We will never spam you or share your email address.'; export const CONTACT_OUTREACH_DISCLOSURE = 'By sending, you agree Brian may follow up by email about your request.'; export const NEWSLETTER_OUTREACH_DISCLOSURE = @@ -25,7 +33,7 @@ const GROWTH_V1_POLICY: PublicFormPolicy = Object.freeze({ disclosures: Object.freeze({ contact: CONTACT_OUTREACH_DISCLOSURE, newsletter: NEWSLETTER_OUTREACH_DISCLOSURE, - whitepaper: WHITEPAPER_OUTREACH_DISCLOSURE, + whitepaper: WHITEPAPER_PRIVACY_ASSURANCE, }), }); diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index b7c640d3d..78cfce921 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -287,16 +287,6 @@ .wp-form { max-width: 520px; } -.wp-already { - margin-top: 4px; - font-size: 13px; - color: var(--color-text-muted); - font-family: var(--font-sans); -} -.wp-already a { - color: var(--color-accent); - text-decoration: underline; -} .wp-cover-wrap { display: flex; justify-content: center; From c2f95c2790ea03dd64a99ff34f2cbe442dbad60c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 21:17:43 -0700 Subject: [PATCH 2/5] feat(website): reorder the fork band, trim the plate, swap the briefing titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/website/e2e/website.spec.ts | 2 +- apps/website/src/app/page.tsx | 7 +++-- .../components/landing/Compatibility.spec.tsx | 28 +++++++++---------- .../src/components/landing/Compatibility.tsx | 22 --------------- .../landing/EnterpriseArchitecture.tsx | 2 +- .../components/landing/TeamsBlock.spec.tsx | 4 +-- .../src/components/landing/TeamsBlock.tsx | 6 ++-- apps/website/src/lib/airport-diagram.spec.ts | 12 -------- apps/website/src/lib/airport-diagram.ts | 18 ++++-------- apps/website/src/styles/landing.css | 20 ------------- 10 files changed, 31 insertions(+), 90 deletions(-) diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index bb512e596..b339bd1e3 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -52,8 +52,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', ]; diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 4c9b7d56f..cd315dd81 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -35,14 +35,15 @@ export default function HomePage() { + {/* The open-source full stop: a loud dark band with one fork CTA. Copy + lives in OPEN_SOURCE_STRIP (positioning.ts). */} + + {/* 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). */} - {/* 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/Compatibility.spec.tsx b/apps/website/src/components/landing/Compatibility.spec.tsx index 0053ae388..cbf9617b8 100644 --- a/apps/website/src/components/landing/Compatibility.spec.tsx +++ b/apps/website/src/components/landing/Compatibility.spec.tsx @@ -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(); - 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 , 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(); - 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); }); diff --git a/apps/website/src/components/landing/Compatibility.tsx b/apps/website/src/components/landing/Compatibility.tsx index aa0f1894f..6d40caaac 100644 --- a/apps/website/src/components/landing/Compatibility.tsx +++ b/apps/website/src/components/landing/Compatibility.tsx @@ -4,7 +4,6 @@ import { AdapterGuideLink } from './AdapterGuideLink'; import { CHART_ID, CONCOURSES, - DISCLAIMER, EYEBROW, FIELD, HEADLINE, @@ -23,9 +22,6 @@ import { STAND, TICK_X, TICK_Y, - TWY_E, - TWY_N, - TWY_S, VIEW, type Gate, type Row, @@ -48,17 +44,6 @@ function Runway({ y, h, left, right }: RunwayGeometry) { ); } -function TaxiwayLetter({ x, y, ch }: { x: number; y: number; ch: string }) { - return ( - - - - {ch} - - - ); -} - /** 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; @@ -168,12 +153,6 @@ function Plate() { - - - - - - {/* The one structure that IS Threadplane: solid ink. Partner stands are white, so the two values carry the meaning with no legend. */} @@ -359,7 +338,6 @@ export function Compatibility() {
-

{DISCLAIMER}

diff --git a/apps/website/src/components/landing/EnterpriseArchitecture.tsx b/apps/website/src/components/landing/EnterpriseArchitecture.tsx index 1a623d811..934311ca6 100644 --- a/apps/website/src/components/landing/EnterpriseArchitecture.tsx +++ b/apps/website/src/components/landing/EnterpriseArchitecture.tsx @@ -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.'; diff --git a/apps/website/src/components/landing/TeamsBlock.spec.tsx b/apps/website/src/components/landing/TeamsBlock.spec.tsx index 616de39d2..33e716760 100644 --- a/apps/website/src/components/landing/TeamsBlock.spec.tsx +++ b/apps/website/src/components/landing/TeamsBlock.spec.tsx @@ -20,7 +20,7 @@ describe('TeamsBlock', () => { it('leads with the ask: eyebrow, heading, then the form', () => { const { container } = render(); 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(); @@ -33,7 +33,7 @@ describe('TeamsBlock', () => { const { container } = render(); 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', () => { diff --git a/apps/website/src/components/landing/TeamsBlock.tsx b/apps/website/src/components/landing/TeamsBlock.tsx index 50e3101d6..071e66278 100644 --- a/apps/website/src/components/landing/TeamsBlock.tsx +++ b/apps/website/src/components/landing/TeamsBlock.tsx @@ -29,13 +29,13 @@ export function TeamsBlock({ formPolicy }: { formPolicy: PublicFormPolicy }) {
- Preflight briefing + What breaks between a demo and production - {' '}· {FIELD_REPORT.pages} pages · free + {' '}· {FIELD_REPORT.pages} pages

- What breaks between a demo and production. + Free preflight briefing for agentic UI

{ 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( diff --git a/apps/website/src/lib/airport-diagram.ts b/apps/website/src/lib/airport-diagram.ts index 19ab18ba1..38b190f6a 100644 --- a/apps/website/src/lib/airport-diagram.ts +++ b/apps/website/src/lib/airport-diagram.ts @@ -16,10 +16,10 @@ * WHITE, the one structure that is Threadplane is solid INK. */ -export const VIEW = { width: 1000, height: 536 } as const; +export const VIEW = { width: 1000, height: 580 } as const; /** The chart frame. It does NOT rotate — only the airfield inside it does. */ -export const NEAT = { x: 8, y: 8, width: 984, height: 444 } as const; +export const NEAT = { x: 8, y: 8, width: 984, height: 488 } as const; export const TICK_X = 88; export const TICK_Y = 84; @@ -54,9 +54,6 @@ export interface Runway { export const RWY_N = { y: 58, h: 11, left: '09L', right: '27R' } as const; export const RWY_S = { y: 408, h: 11, left: '09R', right: '27L' } as const; -export const TWY_N = 100; -export const TWY_S = 386; -export const TWY_E = 930; export const MAIN = { x0: 56, x1: 188, y0: 190, y1: 288 } as const; export const CONC_A = { x0: 204, x1: 432, y0: 190, y1: 224 } as const; @@ -203,8 +200,7 @@ export interface Provider { * These five marks knowingly repeat MODEL_STRIP in architecture-diagram.ts one * screen below. Accepted trade. Do not "fix" it there. */ -export const OFF_AIRPORT_LABEL = - 'OFF AIRPORT — BEHIND YOUR BACKEND. THREADPLANE NEVER TALKS TO THEM.'; +export const OFF_AIRPORT_LABEL = 'All AI Models supported'; export const PROVIDERS: readonly Provider[] = [ { src: '/logos/providers/openai.svg', name: 'OpenAI' }, { src: '/logos/providers/anthropic.svg', name: 'Anthropic' }, @@ -213,11 +209,11 @@ export const PROVIDERS: readonly Provider[] = [ { src: '/logos/providers/bedrock.svg', name: 'Amazon Bedrock', w: 33 }, ]; /** `y` is the marks' centre line; each is `size` tall and `w` wide if it is a wordmark. */ -export const PROVIDER_ROW = { y: 518, size: 20, x0: 30, step: 76, labelY: 492 } as const; +export const PROVIDER_ROW = { y: 562, size: 20, x0: 30, step: 76, labelY: 536 } as const; /** Chart furniture lives in the margin, never on the field. */ -export const SCALE_BAR = { x0: 742, x1: 842, y: 512 } as const; -export const NORTH = { x: 960, y: 498 } as const; +export const SCALE_BAR = { x0: 742, x1: 842, y: 556 } as const; +export const NORTH = { x: 960, y: 542 } as const; /** The Threadplane glyph, identical to the path in ui/PlaneMark.tsx (64x64). */ export const PLANE_PATH = 'M4 34.5 58 6 40 58l-11.5-16.5L36 22 20 37.5z'; @@ -225,5 +221,3 @@ export const PLANE_PATH = 'M4 34.5 58 6 40 58l-11.5-16.5L36 22 20 37.5z'; export const EYEBROW = 'AIRPORT DIAGRAM'; export const HEADLINE = 'Every stack has a gate.'; export const CHART_ID = ['THREADPLANE INTL (TPL)', 'ANGULAR · LANGGRAPH & AG-UI'] as const; -export const DISCLAIMER = - 'Compatibility, not endorsement — no company here is claimed as a customer.'; diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 78cfce921..53f72a141 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1907,32 +1907,17 @@ letter-spacing: 0.08em; fill: var(--color-signal); } -.ap-taxiway, .ap-stub, .ap-link { fill: none; stroke: var(--color-ink); } -.ap-taxiway { - stroke-width: 1.6; -} .ap-stub { stroke-width: 1.4; } .ap-link { stroke-width: 3.5; } -.ap-twy-disc { - fill: var(--color-signal); - stroke: var(--color-ink); - stroke-width: 1.2; -} -.ap-twy-letter { - font-family: var(--font-mono); - font-size: 8.5px; - font-weight: 700; - fill: var(--color-ink); -} .ap-apron { fill: none; stroke: var(--color-ink); @@ -2076,11 +2061,6 @@ .compatibility-link:focus-visible { color: var(--color-accent-hover); } -.compatibility-disclaimer { - font-size: 12.5px; - color: var(--color-text-muted); - margin: 0; -} /* Narrow form: the same gates as an HTML list, driven by the same data. * The plate is hidden here instead of scrolled sideways — the .arch-stack From d11d0fe9fa750a6ea55c8612ce3d8f0ff1dd644c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 21:26:44 -0700 Subject: [PATCH 3/5] 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 --- apps/website/e2e/home-airport.spec.ts | 4 +++- .../components/shared/AnnouncementToast.tsx | 15 -------------- apps/website/src/styles/chrome.css | 10 ---------- apps/website/src/styles/landing.css | 20 +++++++++++++++++-- 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/apps/website/e2e/home-airport.spec.ts b/apps/website/e2e/home-airport.spec.ts index 9069f8327..9b1956679 100644 --- a/apps/website/e2e/home-airport.spec.ts +++ b/apps/website/e2e/home-airport.spec.ts @@ -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 diff --git a/apps/website/src/components/shared/AnnouncementToast.tsx b/apps/website/src/components/shared/AnnouncementToast.tsx index dc1f042ae..86d5b524e 100644 --- a/apps/website/src/components/shared/AnnouncementToast.tsx +++ b/apps/website/src/components/shared/AnnouncementToast.tsx @@ -255,21 +255,6 @@ export function AnnouncementToast({ ) : null} - { - trackWhitepaperDownloadClick('overview', { - surface: 'toast', - source_section: 'announcement-toast', - cta_id: 'toast_direct_download', - }); - dismiss(); - }} - className="toast-download-link" - > - or download directly - )} diff --git a/apps/website/src/styles/chrome.css b/apps/website/src/styles/chrome.css index ca0261ff0..2871c4523 100644 --- a/apps/website/src/styles/chrome.css +++ b/apps/website/src/styles/chrome.css @@ -383,16 +383,6 @@ .toast-mt-section { margin-top: 8px; } -.toast-download-link { - display: inline-flex; - align-items: center; - min-height: 24px; - margin-top: 0; - font-size: 12px; - color: var(--color-text-muted); - text-decoration: underline; - font-family: var(--font-sans); -} /* Nav panels * diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 53f72a141..0f464c2e7 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1315,7 +1315,23 @@ width: 100%; } .arch-figure .tp-diagram-svg { - min-width: 1024px; + /* Scale to the container instead of scrolling. The docs kit floors every + * figure at a render width so shrinking the SVG cannot shrink its text into + * illegibility, and marketing caps it at 860 -- but min-width beats + * max-width, so those two pinned this diagram at exactly 1024px: it never + * scaled, wasted ~96px of the container on desktop, and scrolled sideways + * below that. Both constraints are dropped here, and the legibility the + * floor was protecting is handled by the breakpoint instead: below 1024px + * the phone stack takes over, so the SVG never renders under ~944px + * (13.5px type at ~10px, its smallest legible size). */ + min-width: 0; +} +/* Specificity, not preference: the marketing cap is + * `.tp-diagram-figure[data-scale] .tp-diagram-svg` (0,3,0), so a plain + * `.arch-figure .tp-diagram-svg` (0,2,0) loses to it and the diagram stays + * pinned at 860px. This selector has to out-rank it to let the figure fill. */ +.arch-figure .tp-diagram-figure[data-scale] .tp-diagram-svg { + max-width: none; } .arch-figure text { font-family: var(--font-diagram); @@ -1474,7 +1490,7 @@ .arch-stack { display: none; } -@media (max-width: 767px) { +@media (max-width: 1023px) { .arch-figure .tp-diagram-figure { display: none; } From 88a3a1864859b431972f476042f1e12742f25380 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 9 Sep 2026 07:29:49 -0700 Subject: [PATCH 4/5] 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 --- .../components/shared/AnnouncementToast.tsx | 24 +++---------------- apps/website/src/lib/analytics/events.ts | 1 - 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/apps/website/src/components/shared/AnnouncementToast.tsx b/apps/website/src/components/shared/AnnouncementToast.tsx index 86d5b524e..5a417c452 100644 --- a/apps/website/src/components/shared/AnnouncementToast.tsx +++ b/apps/website/src/components/shared/AnnouncementToast.tsx @@ -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, @@ -237,23 +234,8 @@ export function AnnouncementToast({ - { - trackWhitepaperDownloadClick('overview', { - surface: 'toast', - source_section: 'announcement-toast', - cta_id: 'toast_direct_download', - }); - dismiss(); - }} - > - Download the PDF directly - - + detail="Try again in a moment." + /> ) : null} )} diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts index abb7c48b6..9e188a187 100644 --- a/apps/website/src/lib/analytics/events.ts +++ b/apps/website/src/lib/analytics/events.ts @@ -94,7 +94,6 @@ export type CtaId = | 'footer_ag_ui' // Announcement toast | 'toast_get_guide' - | 'toast_direct_download' // Docs surfaces — copy buttons take a dynamic label fallback | 'copy_code' | 'copy_prompt' From 29b164212ec7ea9ffef6c545a71c5d7b235aeb94 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 9 Sep 2026 07:42:48 -0700 Subject: [PATCH 5/5] 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 --- apps/website/e2e/website.spec.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index b339bd1e3..907ef414b 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -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; @@ -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 = { +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 = { '/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); } });