diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 4216f0b2f..bb512e596 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -54,7 +54,7 @@ test('landing page renders the spine in order (live-stage spec §3)', async ({ p 'architecture-heading', 'stage-heading', 'open-source-heading', - 'pilot-heading', + 'field-report-heading', 'faq-heading', ]; const tops: number[] = []; diff --git a/apps/website/src/components/landing/FieldReportCover.spec.tsx b/apps/website/src/components/landing/FieldReportCover.spec.tsx new file mode 100644 index 000000000..105013109 --- /dev/null +++ b/apps/website/src/components/landing/FieldReportCover.spec.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { FieldReportCover } from './FieldReportCover'; +import { FIELD_REPORT } from '../../lib/field-report'; + +describe('FieldReportCover', () => { + it('shows every chapter, in order', () => { + const { container } = render(); + const items = Array.from(container.querySelectorAll('.field-report-toc li')); + expect(items.map((li) => li.textContent?.replace(/^\d+/, '').trim())).toEqual([ + ...FIELD_REPORT.chapters, + ]); + }); + + it('is readable rather than decorative', () => { + // The old .wp-cover-wrap is aria-hidden because it is artwork. This one + // carries the table of contents, which is the reason to download — hiding + // it would withhold the substance from screen reader users. + const { container } = render(); + expect(container.querySelector('[aria-hidden="true"].field-report-paper')).toBeNull(); + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(FIELD_REPORT.title); + expect(container.querySelector('ol.field-report-toc')).toBeTruthy(); + }); + + it('does not read the numbers twice', () => { + // The
    already numbers the list for assistive tech; the drawn 01/02 + // markers are visual duplicates and must be hidden. + const { container } = render(); + const nums = Array.from(container.querySelectorAll('.field-report-num')); + expect(nums).toHaveLength(FIELD_REPORT.chapters.length); + for (const n of nums) expect(n.getAttribute('aria-hidden')).toBe('true'); + }); + + it('borrows the library pages’ paper styling', () => { + // Same object the four library pages already show, so it is not a second + // visual language for the same artifact. + const { container } = render(); + expect(container.querySelector('.wp-paper.field-report-paper')).toBeTruthy(); + }); +}); diff --git a/apps/website/src/components/landing/FieldReportCover.tsx b/apps/website/src/components/landing/FieldReportCover.tsx new file mode 100644 index 000000000..2fa5392fd --- /dev/null +++ b/apps/website/src/components/landing/FieldReportCover.tsx @@ -0,0 +1,41 @@ +import { FIELD_REPORT } from '../../lib/field-report'; + +/** + * The field report rendered as the object it is, contents included. + * + * It reuses `.wp-paper` — the tilted card the library pages already show — + * so the same artifact does not get two visual languages, and adds its own + * classes for the content the library version does not have. + * + * Deliberately NOT aria-hidden. The library page's cover is artwork and hides + * itself; this one prints the table of contents, which is the reason anyone + * gives up an email address for it. + */ +export function FieldReportCover() { + return ( +
    +
    +
    +

    {FIELD_REPORT.kicker}

    +

    {FIELD_REPORT.title}

    +

    {FIELD_REPORT.subtitle}

    +

    Contents

    +
      + {FIELD_REPORT.chapters.map((chapter, i) => ( +
    1. + + {chapter} +
    2. + ))} +
    +
    +

    + threadplane.ai + {FIELD_REPORT.year} +

    +
    +
    + ); +} diff --git a/apps/website/src/components/landing/TeamsBlock.spec.tsx b/apps/website/src/components/landing/TeamsBlock.spec.tsx index 44fa93846..616de39d2 100644 --- a/apps/website/src/components/landing/TeamsBlock.spec.tsx +++ b/apps/website/src/components/landing/TeamsBlock.spec.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { TeamsBlock } from './TeamsBlock'; +import { FIELD_REPORT } from '../../lib/field-report'; vi.mock('../../lib/analytics/client', () => ({ track: vi.fn(), @@ -16,22 +17,58 @@ const formPolicy = { } as never; describe('TeamsBlock', () => { - it('renders the pilot heading, four outcomes, four phases, both CTAs, and one email form', () => { + it('leads with the ask: eyebrow, heading, then the form', () => { const { container } = render(); - expect(screen.getByRole('heading', { name: 'Shipping inside a large Angular platform?' }).id).toBe('pilot-heading'); - expect(container.querySelectorAll('.pilot-row')).toHaveLength(4); - expect(container.querySelectorAll('.pilot-step')).toHaveLength(4); - expect(screen.getByRole('link', { name: 'Talk to an engineer' }).getAttribute('href')).toBe('/contact?source=home_enterprise&track=enterprise'); - expect(screen.getByRole('link', { name: 'See the pilot program' }).getAttribute('href')).toBe('/pilot-to-prod'); + const heading = screen.getByRole('heading', { level: 2 }); + expect(heading.textContent).toBe('What breaks between a demo and production.'); + expect(heading.id).toBe('field-report-heading'); expect(container.querySelectorAll('form')).toHaveLength(1); expect(screen.getByLabelText('Work email')).toBeTruthy(); - expect(screen.getByText('Whitepaper disclosure')).toBeTruthy(); + expect(screen.getByText(formPolicy.disclosures.whitepaper)).toBeTruthy(); }); - it('frames the field report as the takeaway, not a second section', () => { - render(); - expect(screen.getByText('Field report')).toBeTruthy(); - expect(screen.getByText('From Prototype to Production')).toBeTruthy(); - expect(screen.queryByRole('heading', { name: 'The last-mile gap in Angular AI.' })).toBeNull(); + it('derives the page count rather than typing it', () => { + // The section claimed "18 pages" for a 17-page document. Reading it from + // FIELD_REPORT means the guard against the PDF covers this string too. + const { container } = render(); + const eyebrow = container.querySelector('[data-ui="eyebrow"]'); + expect(eyebrow?.textContent).toContain(`${FIELD_REPORT.pages} pages`); + expect(eyebrow?.textContent).toContain('Preflight briefing'); + }); + + it('shows the briefing beside the ask', () => { + const { container } = render(); + expect(container.querySelector('.field-report-paper')).toBeTruthy(); + }); + + it('makes contact the secondary ask, not a rival button', () => { + // Two same-weight buttons is what stopped the old section saying which + // ask mattered. The arrow is decorative so the accessible name stays clean. + const { container } = render(); + const link = screen.getByRole('link', { name: 'Talk to an engineer' }); + expect(link.getAttribute('href')).toBe('/contact?source=home_enterprise&track=enterprise'); + expect(link.getAttribute('data-ui')).not.toBe('button'); + // Exactly one button in the whole section, and it belongs to the form. + // (SubmitButton wraps Button, so the form's submit carries data-ui too.) + const buttons = container.querySelectorAll('[data-ui="button"]'); + expect(buttons).toHaveLength(1); + expect(buttons[0].closest('form')).toBeTruthy(); + }); + + it('no longer repeats the pilot programme the dedicated page owns', () => { + const { container } = render(); + expect(container.querySelectorAll('.pilot-step')).toHaveLength(0); + expect(container.querySelectorAll('.pilot-row')).toHaveLength(0); + expect(screen.queryByRole('link', { name: 'See the pilot program' })).toBeNull(); + }); + + it('never advertises topics the document does not contain', () => { + // "Error boundaries", "fallbacks" and "observability" each return zero + // matches in whitepaper.pdf. They were on this page for months. + const { container } = render(); + const text = container.textContent ?? ''; + for (const phrase of ['Error boundaries', 'fallbacks', 'observability', '18 pages']) { + expect(text).not.toContain(phrase); + } }); }); diff --git a/apps/website/src/components/landing/TeamsBlock.tsx b/apps/website/src/components/landing/TeamsBlock.tsx index 4e14a5225..50e3101d6 100644 --- a/apps/website/src/components/landing/TeamsBlock.tsx +++ b/apps/website/src/components/landing/TeamsBlock.tsx @@ -4,81 +4,68 @@ import type { PublicFormPolicy } from '../../lib/growth/form-policy'; import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; import { Eyebrow } from '../ui/Eyebrow'; -import { Button } from '../ui/Button'; import { trackCtaClick } from '../../lib/analytics/client'; import { WhitePaperForm } from './WhitePaperForm'; - -const TIMELINE = [ - { phase: '01', title: 'Discover', body: 'Map your stack, surfaces, and the agentic work that earns its keep.' }, - { phase: '02', title: 'Build', body: 'A working demo on your real data, in your real app.' }, - { phase: '03', title: 'Harden', body: 'Observability, error boundaries, deploy paths, on-call patterns.' }, - { phase: '04', title: 'Train', body: 'Your team owns the stack. We leave you with a runbook, not a black box.' }, -]; - -const OUTCOMES = [ - { claim: 'A working agent demo on your domain', tail: 'your data' }, - { claim: 'Hardened error, fallback, observability patterns', tail: 'production-ready' }, - { claim: 'Deploy-ready integration', tail: 'your CI/CD' }, - { claim: 'Team trained on the framework', tail: 'runbook, yours' }, -]; +import { FieldReportCover } from './FieldReportCover'; +import { FIELD_REPORT } from '../../lib/field-report'; /** - * For teams (live-stage spec §3, block 6): the pilot program and the field - * report in one block, with the page's one email form. Replaces PilotBlock + - * WhitePaperBlock on the homepage; the library pages keep WhitePaperBlock. + * For teams: the field report is the ask, contact is the follow-up. + * + * The previous version inverted its own goals — the form sat at y=618 in a + * 916px section, below a four-step pilot timeline that `/pilot-to-prod` + * already covers in full, while a same-weight amber button sent people to + * contact instead. The timeline and the outcome rows are gone, the form sits + * beside a preview of the actual document, and contact is a link. + * + * Everything the section says about the report reads from FIELD_REPORT, which + * is pinned to the PDF — this block previously advertised a page count and a + * contents list that the file did not match. */ export function TeamsBlock({ formPolicy }: { formPolicy: PublicFormPolicy }) { return ( -
    +
    -
    +
    -
    - For teams -
    -

    Shipping inside a large Angular platform?

    -

    - Bring your backend, security model, and design system. Work directly with Threadplane - engineers on architecture, rollout, testing, and production hardening. -

    -
    - {OUTCOMES.map((o) => ( -
    -

    {o.claim}

    -

    {o.tail}

    -
    - ))} -
    -
    - - -
    + + Preflight briefing + + {' '}· {FIELD_REPORT.pages} pages · free + + +

    + What breaks between a demo and production. +

    +
    + +
    -
    -
    - {TIMELINE.map((t) => ( -
    - -
    -
    {t.title}
    -
    {t.body}
    -
    -
    - ))} -
    -
    - Field report -
    18 pages · free
    -

    From Prototype to Production

    -

    Six production-readiness dimensions for Angular AI teams. Error boundaries, fallbacks, observability, deploy. Free.

    - -
    -
    +
    +

    + Shipping inside a large Angular platform? Bring your backend, security + model, and design system. +

    + + trackCtaClick({ + cta_id: 'hero_talk_to_engineers', + track: 'enterprise', + surface: 'home', + }) + } + > + Talk to an engineer +
    diff --git a/apps/website/src/components/landing/WhitePaperBlock.tsx b/apps/website/src/components/landing/WhitePaperBlock.tsx index 4502d5e13..be6922196 100644 --- a/apps/website/src/components/landing/WhitePaperBlock.tsx +++ b/apps/website/src/components/landing/WhitePaperBlock.tsx @@ -3,10 +3,14 @@ import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; import { Eyebrow } from '../ui/Eyebrow'; import { WhitePaperForm, type WhitepaperId } from './WhitePaperForm'; +import { FIELD_REPORT } from '../../lib/field-report'; const ROWS = [ - { claim: 'Six production-readiness dimensions', tail: '18 pages' }, - { claim: 'Error boundaries, fallbacks, observability, deploy', tail: 'concrete patterns' }, + { claim: 'Six production-readiness dimensions', tail: `${FIELD_REPORT.pages} pages` }, + // Was "Error boundaries, fallbacks, observability, deploy" — three phrases + // with zero matches in whitepaper.pdf. These are the document's actual + // chapters. + { claim: 'Streaming, persistence, tool calls, approvals, generative UI, testing', tail: 'the six chapters' }, { claim: 'No vendor pitch — what we learned shipping it', tail: 'free' }, ]; @@ -54,7 +58,7 @@ export function WhitePaperBlock({