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
2 changes: 1 addition & 1 deletion apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
41 changes: 41 additions & 0 deletions apps/website/src/components/landing/FieldReportCover.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<FieldReportCover />);
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(<FieldReportCover />);
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 <ol> already numbers the list for assistive tech; the drawn 01/02
// markers are visual duplicates and must be hidden.
const { container } = render(<FieldReportCover />);
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(<FieldReportCover />);
expect(container.querySelector('.wp-paper.field-report-paper')).toBeTruthy();
});
});
41 changes: 41 additions & 0 deletions apps/website/src/components/landing/FieldReportCover.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="field-report-cover">
<div className="wp-paper field-report-paper">
<div>
<p className="field-report-kicker">{FIELD_REPORT.kicker}</p>
<h3 className="field-report-title">{FIELD_REPORT.title}</h3>
<p className="field-report-sub">{FIELD_REPORT.subtitle}</p>
<p className="field-report-toc-label">Contents</p>
<ol className="field-report-toc">
{FIELD_REPORT.chapters.map((chapter, i) => (
<li key={chapter}>
<span className="field-report-num" aria-hidden="true">
{String(i + 1).padStart(2, '0')}
</span>
{chapter}
</li>
))}
</ol>
</div>
<p className="field-report-foot">
<span>threadplane.ai</span>
<span>{FIELD_REPORT.year}</span>
</p>
</div>
</div>
);
}
61 changes: 49 additions & 12 deletions apps/website/src/components/landing/TeamsBlock.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(<TeamsBlock formPolicy={formPolicy} />);
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(<TeamsBlock formPolicy={formPolicy} />);
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(<TeamsBlock formPolicy={formPolicy} />);
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(<TeamsBlock formPolicy={formPolicy} />);
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(<TeamsBlock formPolicy={formPolicy} />);
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(<TeamsBlock formPolicy={formPolicy} />);
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(<TeamsBlock formPolicy={formPolicy} />);
const text = container.textContent ?? '';
for (const phrase of ['Error boundaries', 'fallbacks', 'observability', '18 pages']) {
expect(text).not.toContain(phrase);
}
});
});
115 changes: 51 additions & 64 deletions apps/website/src/components/landing/TeamsBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Section surface="tinted" id="teams" ariaLabelledBy="pilot-heading">
<Section surface="tinted" id="teams" ariaLabelledBy="field-report-heading">
<Container>
<div className="pilot-block-grid teams-block-grid">
<div className="teams-grid">
<div>
<div className="pilot-rail">
<Eyebrow tone="accent" className="pilot-eyebrow">For teams</Eyebrow>
<span className="pilot-rail-line" aria-hidden="true" />
</div>
<h2 id="pilot-heading" className="pilot-heading">Shipping inside a large Angular platform?</h2>
<p className="pilot-subhead">
Bring your backend, security model, and design system. Work directly with Threadplane
engineers on architecture, rollout, testing, and production hardening.
</p>
<div className="pilot-rows">
{OUTCOMES.map((o) => (
<div className="pilot-row" key={o.claim}>
<p className="pilot-row-claim">{o.claim}</p>
<p className="pilot-row-tail">{o.tail}</p>
</div>
))}
</div>
<div className="pilot-cta-row">
<Button variant="primary" size="lg" href="/contact?source=home_enterprise&track=enterprise"
onClick={() => trackCtaClick({ cta_id: 'hero_talk_to_engineers', track: 'enterprise', surface: 'home' })}>
Talk to an engineer
</Button>
<Button variant="secondary" size="lg" href="/pilot-to-prod">See the pilot program</Button>
</div>
<Eyebrow tone="accent" className="teams-eyebrow">
Preflight briefing
<span className="teams-eyebrow-meta">
{' '}· {FIELD_REPORT.pages} pages · free
</span>
</Eyebrow>
<h2 id="field-report-heading" className="teams-heading">
What breaks between a demo and production.
</h2>
<WhitePaperForm
paper="overview"
formPolicy={formPolicy}
surface="home_whitepaper"
sourceSection="teams-block"
idPrefix="teams-wp"
/>
</div>
<FieldReportCover />
</div>

<div className="teams-aside">
<div className="pilot-steps">
{TIMELINE.map((t) => (
<div className="pilot-step" key={t.phase}>
<span className="pilot-step-num" aria-hidden="true">{t.phase}</span>
<div>
<div className="pilot-step-title">{t.title}</div>
<div className="pilot-step-body">{t.body}</div>
</div>
</div>
))}
</div>
<div className="teams-report">
<Eyebrow tone="accent" className="wp-eyebrow">Field report</Eyebrow>
<div className="teams-report-badge">18 pages · free</div>
<h3 id="teams-report-heading" className="teams-report-title">From Prototype to Production</h3>
<p className="teams-report-desc">Six production-readiness dimensions for Angular AI teams. Error boundaries, fallbacks, observability, deploy. Free.</p>
<WhitePaperForm paper="overview" formPolicy={formPolicy} surface="home_whitepaper" sourceSection="teams-block" idPrefix="teams-wp" />
</div>
</div>
<div className="teams-contact">
<p className="teams-contact-copy">
Shipping inside a large Angular platform? Bring your backend, security
model, and design system.
</p>
<a
className="teams-contact-link"
href="/contact?source=home_enterprise&track=enterprise"
onClick={() =>
trackCtaClick({
cta_id: 'hero_talk_to_engineers',
track: 'enterprise',
surface: 'home',
})
}
>
Talk to an engineer <span aria-hidden="true">→</span>
</a>
</div>
</Container>
</Section>
Expand Down
10 changes: 7 additions & 3 deletions apps/website/src/components/landing/WhitePaperBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];

Expand Down Expand Up @@ -54,7 +58,7 @@ export function WhitePaperBlock({
<div className="wp-cover-wrap" aria-hidden="true">
<div className="wp-paper">
<div>
<div className="wp-cover-badge">Field report · 18 pages</div>
<div className="wp-cover-badge">Field report · {FIELD_REPORT.pages} pages</div>
<div className="wp-cover-title">From Prototype to Production</div>
<div className="wp-cover-desc">Six production-readiness dimensions for Angular AI teams.</div>
</div>
Expand Down
53 changes: 53 additions & 0 deletions apps/website/src/lib/field-report.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
import { FIELD_REPORT } from './field-report';

// Anchored to the workspace root the way the other file-reading specs here
// are, rather than to a bare relative path: both `nx test website` and
// `vitest --root apps/website` run with the repo root as the working
// directory, and jsdom makes `import.meta.url` an http: URL, so neither a
// cwd-relative path nor a module-relative one resolves the file.
const findWorkspaceRoot = (): string => {
let directory = process.cwd();
while (directory !== resolve(directory, '..')) {
if (existsSync(join(directory, 'nx.json'))) return directory;
directory = resolve(directory, '..');
}
throw new Error('workspace root (nx.json) not found');
};

const PDF = join(findWorkspaceRoot(), 'apps/website/public/whitepaper.pdf');

describe('FIELD_REPORT', () => {
it('declares the page count the PDF actually has', () => {
// The homepage advertised "18 pages" for a 17-page document, in exchange
// for an email address. Counting the page objects in the file itself is
// dependency-free and means regenerating the PDF at a different length
// fails here instead of silently making the page lie.
const pdf = readFileSync(PDF, 'latin1');
const pages = (pdf.match(/\/Type\s*\/Page[^s]/g) || []).length;
expect(pages).toBeGreaterThan(0);
expect(FIELD_REPORT.pages).toBe(pages);
});

it('lists the six chapters the document actually contains', () => {
// Not machine-checkable: the text lives in compressed streams. Verify by
// hand with the command in the module's docblock when the PDF changes.
expect(FIELD_REPORT.chapters).toEqual([
'Streaming State Management',
'Thread Persistence',
'Tool-Call Rendering',
'Human Approval Flows',
'Generative UI',
'Deterministic Testing',
]);
});

it('carries the cover strings the document prints', () => {
expect(FIELD_REPORT.title).toBe('From Prototype to Production');
expect(FIELD_REPORT.kicker).toBe('Threadplane · Open source · Angular');
expect(FIELD_REPORT.subtitle).toBeTruthy();
expect(FIELD_REPORT.year).toBe('2026');
});
});
Loading
Loading