-
- {FORM_POLICY_REFRESH_MESSAGE}
-
-
+
+
+
+
);
}
return (
-
);
}
diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts
index c2ab97327..30b465f5e 100644
--- a/apps/website/src/lib/analytics/events.ts
+++ b/apps/website/src/lib/analytics/events.ts
@@ -120,6 +120,8 @@ export type AnalyticsProperties = {
result_count?: number;
query_length?: number;
error_reason?: string;
+ /** Which CTA carried the visitor to a form; e.g. `pricing_tier_enterprise`. */
+ entry_point?: string;
ai_crawler?: string;
ai_source?: string;
user_agent?: string;
diff --git a/apps/website/src/styles/chrome.css b/apps/website/src/styles/chrome.css
index fe50696a6..87e1e3bfc 100644
--- a/apps/website/src/styles/chrome.css
+++ b/apps/website/src/styles/chrome.css
@@ -50,14 +50,9 @@
max-width: 36ch;
line-height: 1.6;
}
-.footer-newsletter-success {
- color: #1a7a40;
-}
-.footer-newsletter-input {
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- color: var(--color-text-primary);
- outline: none;
+.footer-newsletter {
+ max-width: 320px;
+ margin-bottom: 16px;
}
.footer-column-eyebrow {
margin-bottom: 4px;
diff --git a/apps/website/src/styles/forms.css b/apps/website/src/styles/forms.css
new file mode 100644
index 000000000..d3086bf4b
--- /dev/null
+++ b/apps/website/src/styles/forms.css
@@ -0,0 +1,226 @@
+/*
+ * Lead-form kit. Every form on the marketing site composes these rules.
+ * Spec: docs/superpowers/specs/2026-09-03-lead-forms-system-design.md
+ *
+ * Tokens below are website-scoped and derive from @threadplane/design-tokens.
+ * They are the only place a form size, ring, or status color is defined.
+ */
+:root {
+ --form-control-height: 44px;
+ --form-control-height-compact: 36px;
+ --form-control-radius: 8px;
+ --form-focus-ring: 0 0 0 3px var(--color-accent-glow);
+ --color-status-success: #1a7a40;
+ --color-status-error: var(--color-angular-red);
+ --form-error-ring: 0 0 0 3px color-mix(in srgb, var(--color-status-error) 18%, transparent);
+}
+
+/* Field: label, control, help, error */
+[data-ui="field"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+[data-ui="field-label"] {
+ font-family: var(--font-inter);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+[data-ui="field-optional"] {
+ font-weight: 400;
+ color: var(--color-text-muted);
+}
+[data-ui="field-help"] {
+ margin: 0;
+ font-family: var(--font-inter);
+ font-size: 12px;
+ color: var(--color-text-muted);
+}
+[data-ui="field-error"] {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ font-family: var(--font-inter);
+ font-size: 12px;
+ color: var(--color-status-error);
+}
+[data-ui="field-error"]::before {
+ content: "!";
+ display: inline-grid;
+ place-items: center;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--color-status-error);
+ color: var(--color-text-inverted);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+/* Controls */
+[data-ui="form-control"] {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ height: var(--form-control-height);
+ padding: 0 14px;
+ font-family: var(--font-inter);
+ font-size: var(--text-body);
+ color: var(--color-text-primary);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--form-control-radius);
+ outline: none;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+}
+[data-ui="form-control"]::placeholder {
+ color: var(--color-text-muted);
+}
+[data-ui="form-control"][data-multiline] {
+ height: auto;
+ min-height: calc(var(--form-control-height) * 2.4);
+ padding: 11px 14px;
+ resize: vertical;
+ line-height: var(--text-body--line-height);
+}
+[data-ui="form-control"][data-compact] {
+ height: var(--form-control-height-compact);
+ padding: 0 12px;
+ font-size: 14px;
+}
+[data-ui="form-control"]:focus-visible {
+ border-color: var(--color-accent);
+ box-shadow: var(--form-focus-ring);
+}
+[data-ui="form-control"][aria-invalid="true"] {
+ border-color: var(--color-status-error);
+ box-shadow: var(--form-error-ring);
+}
+[data-ui="form-control"]:disabled {
+ color: var(--color-text-muted);
+ background: var(--color-surface-dim);
+}
+/* Chevron stroke '%23737373' mirrors --color-text-muted; update both together. */
+select[data-ui="form-control"] {
+ appearance: none;
+ padding-right: 36px;
+ background-image: url("data:image/svg+xml;utf8,
");
+ background-repeat: no-repeat;
+ background-position: right 14px center;
+}
+
+/* Layout helpers */
+[data-ui="form"] {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+[data-ui="form"][data-compact] {
+ gap: 10px;
+}
+[data-ui="form-row"] {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+}
+[data-ui="form-row"] > [data-ui="field"],
+[data-ui="form-row"] > [data-ui="form-control"] {
+ flex: 1 1 160px;
+ min-width: 0;
+}
+/* A compact form's row button matches the compact control height. */
+[data-ui="form"][data-compact] [data-ui="form-row"] [data-ui="button"] {
+ height: var(--form-control-height-compact);
+}
+[data-ui="form-disclosure"] {
+ margin: 0;
+ font-family: var(--font-inter);
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--color-text-muted);
+}
+
+/* Card */
+[data-ui="form-card"] {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 28px;
+ box-shadow: var(--shadow-lg);
+}
+[data-ui="form-card"][data-compact] {
+ padding: 18px;
+ box-shadow: var(--shadow-md);
+}
+
+/* Submit: both labels occupy the same grid cell so the width never changes */
+[data-ui="button"][data-submit] > span:first-child {
+ display: inline-grid;
+}
+[data-ui="button"][data-submit] [data-slot] {
+ grid-area: 1 / 1;
+ white-space: nowrap;
+}
+[data-ui="button"][data-submit]:not([data-pending]) [data-slot="pending"],
+[data-ui="button"][data-submit][data-pending] [data-slot="label"] {
+ visibility: hidden;
+}
+
+/* Status blocks */
+[data-ui="form-status"] {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ padding: 14px 16px;
+ border-radius: var(--form-control-radius);
+ font-family: var(--font-inter);
+ font-size: var(--text-body);
+ line-height: var(--text-body--line-height);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ background: var(--color-surface-tinted);
+}
+[data-ui="form-status"][data-tone="success"] {
+ border-color: var(--color-accent-border);
+ background: var(--color-accent-surface);
+}
+[data-ui="form-status"][data-tone="failure"] {
+ border-color: color-mix(in srgb, var(--color-status-error) 25%, transparent);
+ background: color-mix(in srgb, var(--color-status-error) 5%, transparent);
+}
+[data-ui="form-status"][data-tone="stale"] {
+ border-color: var(--color-border-strong);
+ background: var(--color-surface-dim);
+}
+[data-ui="form-status"][data-tone="stale"] [data-ui="form-status-icon"] {
+ color: var(--color-text-secondary);
+}
+[data-ui="form-status-icon"] {
+ flex: 0 0 auto;
+ font-weight: 700;
+}
+[data-ui="form-status"][data-tone="success"] [data-ui="form-status-icon"] {
+ color: var(--color-status-success);
+}
+[data-ui="form-status"][data-tone="failure"] [data-ui="form-status-icon"] {
+ color: var(--color-status-error);
+}
+[data-ui="form-status-body"] {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+[data-ui="form-status-body"] p {
+ margin: 0;
+}
+[data-ui="form-status"] a {
+ color: var(--color-accent);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-ui="form-control"] {
+ transition: none;
+ }
+}
diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts
index 2ddd5cc7f..a3519d075 100644
--- a/apps/website/src/styles/style-contracts.spec.ts
+++ b/apps/website/src/styles/style-contracts.spec.ts
@@ -172,6 +172,39 @@ const CONTRACTS: StyleContract[] = [
'max-width': /max-width:\s*420px/,
},
},
+ {
+ file: 'forms.css',
+ selector: '[data-ui="form-control"]',
+ why: 'Every form control shares one height and one focus ring. If either goes, inputs silently drift back to five sizes and lose keyboard visibility.',
+ requires: {
+ height: /height:\s*var\(--form-control-height\)/,
+ },
+ },
+ {
+ file: 'forms.css',
+ selector: '[data-ui="form-control"]:focus-visible',
+ why: 'WCAG 2.2 focus appearance. Without this ring keyboard users cannot see which field is active.',
+ requires: {
+ 'box-shadow': /box-shadow:\s*var\(--form-focus-ring\)/,
+ },
+ },
+ {
+ file: 'forms.css',
+ selector: '[data-ui="form-control"][aria-invalid="true"]',
+ why: 'Errors are text plus a ring. Losing the ring leaves the icon-and-text line as the only cue, which reads as help text at a glance.',
+ requires: {
+ 'box-shadow': /box-shadow:\s*var\(--form-error-ring\)/,
+ },
+ },
+ {
+ file: 'forms.css',
+ selector: '[data-ui="form-row"]',
+ why: 'The footer newsletter row once put its disclosure inside the flex row and the input collapsed to 26px. The row must only ever hold controls.',
+ requires: {
+ display: /display:\s*flex/,
+ gap: /gap:/,
+ },
+ },
];
function baseDeclarationsFor(css: string, selector: string): string {
diff --git a/docs/superpowers/plans/2026-09-04-lead-forms-system.md b/docs/superpowers/plans/2026-09-04-lead-forms-system.md
new file mode 100644
index 000000000..b11f8b16f
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-04-lead-forms-system.md
@@ -0,0 +1,2610 @@
+# Lead Forms System Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the five hand-rolled lead forms on threadplane.ai with one form kit and one submission hook, composed into four surfaces, so every form looks, behaves, and reads the same and the footer newsletter's collapsed input is fixed structurally.
+
+**Architecture:** A `components/form/` kit (Field, TextInput, TextArea, Select, FormCard, SubmitButton, FormStatus) plus a `useGrowthForm` hook that owns the growth envelope, the stale-policy branch, analytics, and the status machine. Styling lives in one new `styles/forms.css` guarded by the style-contract test. The enterprise form merges into `/contact?intent=enterprise`; `/pricing` keeps a CTA band. Whitepaper block and toast keep their compositions and swap their inputs for kit pieces.
+
+**Tech Stack:** Next.js 16 App Router, React, TypeScript, Vitest + Testing Library (jsdom), Playwright, CSS custom properties from `@threadplane/design-tokens`, Nx.
+
+**Spec:** `docs/superpowers/specs/2026-09-03-lead-forms-system-design.md`
+
+**Branch:** create from `origin/main` with `git fetch origin main && git checkout -b blove/lead-forms-kit origin/main` (see memory: never branch from stale local main). Run `npm ci` once in a fresh worktree before anything else.
+
+**Ships as three PRs**, each leaving every surface working because each merge auto-promotes production:
+
+| PR | Tasks | Delivers |
+|---|---|---|
+| 1 | 1–9 | Form kit, `forms.css`, hook, footer newsletter on the kit (fixes the live bug) |
+| 2 | 10–13 | Contact page with the enterprise variant, pricing CTA band, `LeadForm` deleted |
+| 3 | 14–16 | Whitepaper block and toast on the kit, old form CSS deleted |
+
+**Commands used throughout** (run from the repo root):
+
+```bash
+# unit tests for one file
+npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/Field.spec.tsx
+# whole website unit suite
+npx nx test website --skip-nx-cache
+# lint
+npx nx lint website --skip-nx-cache
+# production build (catches type errors the tests do not)
+npx nx build website --skip-nx-cache
+```
+
+---
+
+## File structure
+
+**Create**
+
+| File | Responsibility |
+|---|---|
+| `apps/website/src/styles/forms.css` | Every form-kit rule: tokens, controls, card, submit, status, layout helpers. Imported once from `global.css`. |
+| `apps/website/src/components/form/field-context.ts` | Context carrying the control id, described-by ids, and invalid flag from `Field` to its control. |
+| `apps/website/src/components/form/Field.tsx` (+ `.spec.tsx`) | Label, optional marker, help, error; wires `aria-describedby` and `aria-invalid`. |
+| `apps/website/src/components/form/controls.tsx` (+ `controls.spec.tsx`) | `TextInput`, `TextArea`, `Select`: thin wrappers reading the field context. |
+| `apps/website/src/components/form/FormCard.tsx` (+ `.spec.tsx`) | The elevated card; `compact` prop. |
+| `apps/website/src/components/form/SubmitButton.tsx` (+ `.spec.tsx`) | `Button` with a width-stable `pending` state. |
+| `apps/website/src/components/form/FormStatus.tsx` (+ `.spec.tsx`) | Success / failure / stale blocks with the right ARIA role. |
+| `apps/website/src/components/form/validators.ts` (+ `.spec.ts`) | `emailError`, `requiredError`: pure functions returning error copy or `null`. |
+| `apps/website/src/components/form/use-growth-form.ts` (+ `.spec.tsx`) | The shared submission hook. |
+| `apps/website/src/components/form/index.ts` | Barrel. |
+| `apps/website/src/components/pricing/EnterpriseCtaBand.tsx` (+ `.spec.tsx`) | Replaces `LeadForm` on `/pricing`. |
+
+**Modify**
+
+| File | Change |
+|---|---|
+| `apps/website/src/app/global.css` | `@import "../styles/forms.css";` after `marketing.css`. |
+| `apps/website/src/styles/style-contracts.spec.ts` | Four contracts for `forms.css`. |
+| `apps/website/src/lib/analytics/events.ts` | Add `entry_point?: string` to `AnalyticsProperties` (documented; the index signature already admits it). |
+| `apps/website/src/components/shared/Footer.tsx` (+ `.spec.tsx`) | `NewsletterForm` on the kit. |
+| `apps/website/src/components/contact/ContactForm.tsx` (+ `.spec.tsx`) | Rebuilt on the kit with `intent` prop. |
+| `apps/website/src/app/contact/page.tsx` | Band layout; reads `intent` from `searchParams`. |
+| `apps/website/src/components/pricing/CompareTable.tsx` | CTA hrefs to `/contact?intent=enterprise&entry=…`. |
+| `apps/website/src/app/pricing/page.tsx` | `EnterpriseCtaBand` replaces `LeadForm`. |
+| `apps/website/src/components/landing/WhitePaperBlock.tsx` (+ `.spec.tsx`) | Form parts on the kit. |
+| `apps/website/src/components/shared/AnnouncementToast.tsx` (+ `.spec.tsx`) | Form parts on the kit. |
+| `apps/website/src/styles/marketing.css`, `landing.css`, `chrome.css`, `pages.css` | Old form rules removed; contact band rules added to `pages.css`. |
+| `apps/website/e2e/website.spec.ts` | Form-flow cases rewritten; enterprise-intent and footer-width cases added. |
+
+**Delete**
+
+`apps/website/src/components/pricing/LeadForm.tsx`, `LeadForm.spec.tsx`, `apps/website/src/components/contact/SlaCard.tsx` (its copy moves into the band), and the `.lead-form-*`, `.contact-form-*`, `.wp-form`/`.wp-email-input`/`.wp-disclosure`/`.wp-error*`/`.wp-success*`/`.wp-already*`, `.footer-newsletter-*`, `.toast-input`/`.toast-disclosure`/`.toast-download-link`/`.toast-success-text` rules.
+
+---
+
+## Task 1: `forms.css`, tokens, import, and style contracts
+
+**Files:**
+- Create: `apps/website/src/styles/forms.css`
+- Modify: `apps/website/src/app/global.css:16`
+- Modify: `apps/website/src/styles/style-contracts.spec.ts`
+
+- [ ] **Step 1: Write the failing style-contract tests**
+
+Append these entries to the `CONTRACTS` array in `apps/website/src/styles/style-contracts.spec.ts`, before the closing `];`:
+
+```ts
+ {
+ file: 'forms.css',
+ selector: '[data-ui="form-control"]',
+ why: 'Every form control shares one height and one focus ring. If either goes, inputs silently drift back to five sizes and lose keyboard visibility.',
+ requires: {
+ height: /height:\s*var\(--form-control-height\)/,
+ },
+ },
+ {
+ file: 'forms.css',
+ selector: '[data-ui="form-control"]:focus-visible',
+ why: 'WCAG 2.2 focus appearance. Without this ring keyboard users cannot see which field is active.',
+ requires: {
+ 'box-shadow': /box-shadow:\s*var\(--form-focus-ring\)/,
+ },
+ },
+ {
+ file: 'forms.css',
+ selector: '[data-ui="form-control"][aria-invalid="true"]',
+ why: 'Errors are text plus a ring. Losing the ring leaves the icon-and-text line as the only cue, which reads as help text at a glance.',
+ requires: {
+ 'box-shadow': /box-shadow:\s*var\(--form-error-ring\)/,
+ },
+ },
+ {
+ file: 'forms.css',
+ selector: '[data-ui="form-row"]',
+ why: 'The footer newsletter row once put its disclosure inside the flex row and the input collapsed to 26px. The row must only ever hold controls.',
+ requires: {
+ display: /display:\s*flex/,
+ gap: /gap:/,
+ },
+ },
+```
+
+- [ ] **Step 2: Run the contract spec to verify it fails**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/styles/style-contracts.spec.ts`
+Expected: FAIL, four new cases, each with "ENOENT" or "missing declaration" for `forms.css`.
+
+- [ ] **Step 3: Create `forms.css`**
+
+```css
+/*
+ * Lead-form kit. Every form on the marketing site composes these rules.
+ * Spec: docs/superpowers/specs/2026-09-03-lead-forms-system-design.md
+ *
+ * Tokens below are website-scoped and derive from @threadplane/design-tokens.
+ * They are the only place a form size, ring, or status color is defined.
+ */
+:root {
+ --form-control-height: 44px;
+ --form-control-height-compact: 36px;
+ --form-control-radius: 8px;
+ --form-focus-ring: 0 0 0 3px var(--color-accent-glow);
+ --color-status-success: #1a7a40;
+ --color-status-error: var(--color-angular-red);
+ --form-error-ring: 0 0 0 3px color-mix(in srgb, var(--color-status-error) 18%, transparent);
+}
+
+/* Field: label, control, help, error */
+[data-ui="field"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+[data-ui="field-label"] {
+ font-family: var(--font-inter);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+[data-ui="field-optional"] {
+ font-weight: 400;
+ color: var(--color-text-muted);
+}
+[data-ui="field-help"] {
+ margin: 0;
+ font-family: var(--font-inter);
+ font-size: 12px;
+ color: var(--color-text-muted);
+}
+[data-ui="field-error"] {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ font-family: var(--font-inter);
+ font-size: 12px;
+ color: var(--color-status-error);
+}
+[data-ui="field-error"]::before {
+ content: "!";
+ display: inline-grid;
+ place-items: center;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--color-status-error);
+ color: var(--color-text-inverted);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+/* Controls */
+[data-ui="form-control"] {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ height: var(--form-control-height);
+ padding: 0 14px;
+ font-family: var(--font-inter);
+ font-size: var(--text-body);
+ color: var(--color-text-primary);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--form-control-radius);
+ outline: none;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+}
+[data-ui="form-control"]::placeholder {
+ color: var(--color-text-muted);
+}
+[data-ui="form-control"][data-multiline] {
+ height: auto;
+ min-height: calc(var(--form-control-height) * 2.4);
+ padding: 11px 14px;
+ resize: vertical;
+ line-height: var(--text-body--line-height);
+}
+[data-ui="form-control"][data-compact] {
+ height: var(--form-control-height-compact);
+ padding: 0 12px;
+ font-size: 14px;
+}
+[data-ui="form-control"]:focus-visible {
+ border-color: var(--color-accent);
+ box-shadow: var(--form-focus-ring);
+}
+[data-ui="form-control"][aria-invalid="true"] {
+ border-color: var(--color-status-error);
+ box-shadow: var(--form-error-ring);
+}
+[data-ui="form-control"]:disabled {
+ color: var(--color-text-muted);
+ background: var(--color-surface-dim);
+}
+select[data-ui="form-control"] {
+ appearance: none;
+ padding-right: 36px;
+ background-image: url("data:image/svg+xml;utf8,
");
+ background-repeat: no-repeat;
+ background-position: right 14px center;
+}
+
+/* Layout helpers */
+[data-ui="form"] {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+[data-ui="form"][data-compact] {
+ gap: 10px;
+}
+[data-ui="form-row"] {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+}
+[data-ui="form-row"] > [data-ui="field"],
+[data-ui="form-row"] > [data-ui="form-control"] {
+ flex: 1 1 160px;
+ min-width: 0;
+}
+/* A compact form's row button matches the compact control height. */
+[data-ui="form"][data-compact] [data-ui="form-row"] [data-ui="button"] {
+ height: var(--form-control-height-compact);
+}
+[data-ui="form-disclosure"] {
+ margin: 0;
+ font-family: var(--font-inter);
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--color-text-muted);
+}
+
+/* Card */
+[data-ui="form-card"] {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 28px;
+ box-shadow: var(--shadow-lg);
+}
+[data-ui="form-card"][data-compact] {
+ padding: 18px;
+ box-shadow: var(--shadow-md);
+}
+
+/* Submit: both labels occupy the same grid cell so the width never changes */
+[data-ui="button"][data-submit] > span:first-child {
+ display: inline-grid;
+}
+[data-ui="button"][data-submit] [data-slot] {
+ grid-area: 1 / 1;
+ white-space: nowrap;
+}
+[data-ui="button"][data-submit]:not([data-pending]) [data-slot="pending"],
+[data-ui="button"][data-submit][data-pending] [data-slot="label"] {
+ visibility: hidden;
+}
+
+/* Status blocks */
+[data-ui="form-status"] {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ padding: 14px 16px;
+ border-radius: var(--form-control-radius);
+ font-family: var(--font-inter);
+ font-size: var(--text-body);
+ line-height: var(--text-body--line-height);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ background: var(--color-surface-tinted);
+}
+[data-ui="form-status"][data-tone="success"] {
+ border-color: var(--color-accent-border);
+ background: var(--color-accent-surface);
+}
+[data-ui="form-status"][data-tone="failure"] {
+ border-color: color-mix(in srgb, var(--color-status-error) 25%, transparent);
+ background: color-mix(in srgb, var(--color-status-error) 5%, transparent);
+}
+[data-ui="form-status-icon"] {
+ flex: 0 0 auto;
+ font-weight: 700;
+}
+[data-ui="form-status"][data-tone="success"] [data-ui="form-status-icon"] {
+ color: var(--color-status-success);
+}
+[data-ui="form-status"][data-tone="failure"] [data-ui="form-status-icon"] {
+ color: var(--color-status-error);
+}
+[data-ui="form-status-body"] {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+[data-ui="form-status-body"] p {
+ margin: 0;
+}
+[data-ui="form-status"] a {
+ color: var(--color-accent);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-ui="form-control"] {
+ transition: none;
+ }
+}
+```
+
+- [ ] **Step 4: Import it**
+
+In `apps/website/src/app/global.css`, after line 16 (`@import "../styles/marketing.css";`) add:
+
+```css
+@import "../styles/forms.css";
+```
+
+- [ ] **Step 5: Run the contract spec to verify it passes**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/styles/style-contracts.spec.ts`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/website/src/styles/forms.css apps/website/src/app/global.css apps/website/src/styles/style-contracts.spec.ts
+git commit -m "feat(website): add the lead-form stylesheet and its style contracts"
+```
+
+---
+
+## Task 2: `Field` and the field context
+
+**Files:**
+- Create: `apps/website/src/components/form/field-context.ts`
+- Create: `apps/website/src/components/form/Field.tsx`
+- Test: `apps/website/src/components/form/Field.spec.tsx`
+
+- [ ] **Step 1: Write the failing test**
+
+```tsx
+// @vitest-environment jsdom
+import React, { useContext } from 'react';
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Field } from './Field';
+import { FieldContext } from './field-context';
+
+function Probe() {
+ const ctx = useContext(FieldContext);
+ return
;
+}
+
+describe('Field', () => {
+ it('labels the control by id and marks optional fields', () => {
+ render(
+
+
+
+ );
+ const label = screen.getByText('Work email', { selector: 'label' });
+ expect(label.getAttribute('for')).toBe('f-email');
+ expect(screen.getByText('(optional)')).toBeTruthy();
+ expect(screen.getByTestId('probe').id).toBe('f-email');
+ });
+
+ it('wires help and error text through aria-describedby and sets aria-invalid', () => {
+ render(
+
+
+
+ );
+ const probe = screen.getByTestId('probe');
+ expect(probe.getAttribute('aria-describedby')).toBe('f-email-help f-email-error');
+ expect(probe.getAttribute('aria-invalid')).toBe('true');
+ expect(screen.getByText('Enter a full address, like jordan@acme.dev.').id).toBe('f-email-error');
+ expect(screen.getByText('We reply from a real inbox.').id).toBe('f-email-help');
+ });
+
+ it('omits aria-describedby when there is nothing to describe', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('probe').getAttribute('aria-describedby')).toBeNull();
+ expect(screen.getByTestId('probe').getAttribute('aria-invalid')).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/Field.spec.tsx`
+Expected: FAIL, "Cannot find module './Field'".
+
+- [ ] **Step 3: Write the context**
+
+`apps/website/src/components/form/field-context.ts`:
+
+```ts
+import { createContext } from 'react';
+
+export interface FieldControlContext {
+ /** id the label points at; the control must render it as its id. */
+ id: string;
+ /** Space-separated ids of help and error text, or undefined when neither exists. */
+ describedBy: string | undefined;
+ /** True while the field shows an error. */
+ invalid: boolean;
+}
+
+export const FieldContext = createContext
(null);
+```
+
+- [ ] **Step 4: Write `Field`**
+
+`apps/website/src/components/form/Field.tsx`:
+
+```tsx
+import type { ReactNode } from 'react';
+import { FieldContext } from './field-context';
+
+interface FieldProps {
+ /** Control id. The label's `for` and the control's `id` both use it. */
+ id: string;
+ label: ReactNode;
+ optional?: boolean;
+ help?: ReactNode;
+ /** Error copy. Present means the field is invalid. */
+ error?: string | null;
+ children: ReactNode;
+}
+
+export function Field({ id, label, optional = false, help, error, children }: FieldProps) {
+ const helpId = help ? `${id}-help` : undefined;
+ const errorId = error ? `${id}-error` : undefined;
+ const describedBy = [helpId, errorId].filter(Boolean).join(' ') || undefined;
+ return (
+
+
+
+ {children}
+
+ {help ? (
+
+ {help}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/Field.spec.tsx`
+Expected: PASS, 3 tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/website/src/components/form/field-context.ts apps/website/src/components/form/Field.tsx apps/website/src/components/form/Field.spec.tsx
+git commit -m "feat(website): add the form Field primitive with wired accessibility"
+```
+
+---
+
+## Task 3: `TextInput`, `TextArea`, `Select`
+
+**Files:**
+- Create: `apps/website/src/components/form/controls.tsx`
+- Test: `apps/website/src/components/form/controls.spec.tsx`
+
+- [ ] **Step 1: Write the failing test**
+
+```tsx
+// @vitest-environment jsdom
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Field } from './Field';
+import { Select, TextArea, TextInput } from './controls';
+
+describe('form controls', () => {
+ it('TextInput takes id, described-by, and invalid from the surrounding Field', () => {
+ render(
+
+
+
+ );
+ const input = screen.getByLabelText('Work email') as HTMLInputElement;
+ expect(input.id).toBe('c-email');
+ expect(input.getAttribute('data-ui')).toBe('form-control');
+ expect(input.getAttribute('aria-describedby')).toBe('c-email-error');
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ expect(input.type).toBe('email');
+ expect(input.autocomplete).toBe('email');
+ });
+
+ it('TextArea marks itself multiline and Select renders its options', () => {
+ render(
+ <>
+
+
+
+
+
+
+ >
+ );
+ expect(screen.getByLabelText('Message').getAttribute('data-multiline')).toBe('');
+ expect(screen.getByLabelText('Timeline').tagName).toBe('SELECT');
+ expect(screen.getByRole('option', { name: 'This quarter' })).toBeTruthy();
+ });
+
+ it('accepts a compact size', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByLabelText('Email').getAttribute('data-compact')).toBe('');
+ });
+
+ it('works outside a Field when given an explicit id', () => {
+ render();
+ expect(screen.getByLabelText('Lone').id).toBe('lone');
+ });
+});
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/controls.spec.tsx`
+Expected: FAIL, "Cannot find module './controls'".
+
+- [ ] **Step 3: Write the controls**
+
+`apps/website/src/components/form/controls.tsx`:
+
+```tsx
+import { useContext } from 'react';
+import type { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
+import { FieldContext } from './field-context';
+
+interface ControlExtras {
+ /** Shorter control for the toast and the footer. */
+ compact?: boolean;
+}
+
+function useControlAttributes(compact: boolean, explicitId: string | undefined) {
+ const field = useContext(FieldContext);
+ return {
+ id: explicitId ?? field?.id,
+ 'aria-describedby': field?.describedBy,
+ 'aria-invalid': field?.invalid ? true : undefined,
+ 'data-ui': 'form-control' as const,
+ 'data-compact': compact ? '' : undefined,
+ };
+}
+
+export function TextInput({ compact = false, id, ...rest }: InputHTMLAttributes & ControlExtras) {
+ return ;
+}
+
+export function TextArea({ compact = false, id, ...rest }: TextareaHTMLAttributes & ControlExtras) {
+ return ;
+}
+
+export function Select({ compact = false, id, children, ...rest }: SelectHTMLAttributes & ControlExtras) {
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/controls.spec.tsx`
+Expected: PASS, 4 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/website/src/components/form/controls.tsx apps/website/src/components/form/controls.spec.tsx
+git commit -m "feat(website): add TextInput, TextArea, and Select form controls"
+```
+
+---
+
+## Task 4: `FormCard`, `SubmitButton`, `FormStatus`
+
+**Files:**
+- Create: `apps/website/src/components/form/FormCard.tsx`
+- Create: `apps/website/src/components/form/SubmitButton.tsx`
+- Create: `apps/website/src/components/form/FormStatus.tsx`
+- Test: `apps/website/src/components/form/FormCard.spec.tsx`, `SubmitButton.spec.tsx`, `FormStatus.spec.tsx`
+
+- [ ] **Step 1: Write the failing tests**
+
+`FormCard.spec.tsx`:
+
+```tsx
+// @vitest-environment jsdom
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+import { render } from '@testing-library/react';
+import { FormCard } from './FormCard';
+
+describe('FormCard', () => {
+ it('renders the card shell and forwards the compact flag', () => {
+ const { container, rerender } = render(body);
+ const card = container.querySelector('[data-ui="form-card"]');
+ expect(card?.textContent).toBe('body');
+ expect(card?.getAttribute('data-compact')).toBeNull();
+ rerender(body);
+ expect(container.querySelector('[data-ui="form-card"]')?.getAttribute('data-compact')).toBe('');
+ });
+});
+```
+
+`SubmitButton.spec.tsx`:
+
+```tsx
+// @vitest-environment jsdom
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SubmitButton } from './SubmitButton';
+
+describe('SubmitButton', () => {
+ it('renders both labels so width is stable, exposes only the active one, and disables while pending', () => {
+ const { rerender } = render(Send to Brian);
+ const button = screen.getByRole('button', { name: 'Send to Brian' }) as HTMLButtonElement;
+ expect(button.type).toBe('submit');
+ expect(button.disabled).toBe(false);
+ expect(button.getAttribute('data-pending')).toBeNull();
+ expect(button.getAttribute('data-submit')).toBe('');
+ expect(button.getAttribute('data-ui')).toBe('button');
+ expect(button.querySelector('[data-slot="pending"]')?.textContent).toBe('Sending…');
+
+ rerender(Send to Brian);
+ const pending = screen.getByRole('button', { name: 'Sending…' }) as HTMLButtonElement;
+ expect(pending.disabled).toBe(true);
+ expect(pending.getAttribute('data-pending')).toBe('');
+ expect(pending.getAttribute('aria-busy')).toBe('true');
+ });
+});
+```
+
+`FormStatus.spec.tsx`:
+
+```tsx
+// @vitest-environment jsdom
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { FormStatus } from './FormStatus';
+
+describe('FormStatus', () => {
+ it('announces success politely', () => {
+ render();
+ const status = screen.getByRole('status');
+ expect(status.getAttribute('data-tone')).toBe('success');
+ expect(status.textContent).toContain('Sent.');
+ expect(status.textContent).toContain('Expect a reply within one business day.');
+ });
+
+ it('announces failure and stale as alerts and renders an action', () => {
+ render(
+
+ Download the PDF directly
+
+ );
+ const alert = screen.getByRole('alert');
+ expect(alert.getAttribute('data-tone')).toBe('failure');
+ expect(screen.getByRole('link', { name: 'Download the PDF directly' })).toBeTruthy();
+ });
+});
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/FormCard.spec.tsx apps/website/src/components/form/SubmitButton.spec.tsx apps/website/src/components/form/FormStatus.spec.tsx`
+Expected: FAIL, three "Cannot find module" errors.
+
+- [ ] **Step 3: Write the three components**
+
+`FormCard.tsx`:
+
+```tsx
+import type { HTMLAttributes, ReactNode } from 'react';
+
+interface FormCardProps extends HTMLAttributes {
+ children: ReactNode;
+ compact?: boolean;
+}
+
+export function FormCard({ children, compact = false, ...rest }: FormCardProps) {
+ return (
+
+ {children}
+
+ );
+}
+```
+
+`SubmitButton.tsx`:
+
+```tsx
+import type { ReactNode } from 'react';
+import { Button, type ButtonProps } from '../ui/Button';
+
+type SubmitButtonProps = Omit, 'type' | 'children'> & {
+ children: ReactNode;
+ pending?: boolean;
+ pendingLabel: string;
+};
+
+/**
+ * Both labels render in the same grid cell (see forms.css) so the button
+ * keeps its width when the label swaps. The inactive label is hidden from
+ * layout by visibility and from assistive tech by aria-hidden.
+ */
+export function SubmitButton({ children, pending = false, pendingLabel, disabled, ...rest }: SubmitButtonProps) {
+ return (
+
+ );
+}
+```
+
+Note: `Button` sets `type="button"` before spreading `buttonAttrs`, so the explicit `type="submit"` here wins. The marker is a separate `data-submit` attribute so `data-ui="button"` and every base button rule in `ui.css` survive. Verify by reading `apps/website/src/components/ui/Button.tsx:78-90`.
+
+`FormStatus.tsx`:
+
+```tsx
+import type { ReactNode } from 'react';
+
+type Tone = 'success' | 'failure' | 'stale';
+
+interface FormStatusProps {
+ tone: Tone;
+ title: string;
+ detail?: ReactNode;
+ /** Optional follow-up: a link, a retry button, a refresh button. */
+ children?: ReactNode;
+}
+
+const ICON: Record = { success: '✓', failure: '!', stale: '↻' };
+
+export function FormStatus({ tone, title, detail, children }: FormStatusProps) {
+ const role = tone === 'success' ? 'status' : 'alert';
+ return (
+
+
{ICON[tone]}
+
+
+ {title}
+ {detail ? <> {detail}> : null}
+
+ {children ?
{children}
: null}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/`
+Expected: PASS, all form specs so far.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/website/src/components/form/FormCard.tsx apps/website/src/components/form/SubmitButton.tsx apps/website/src/components/form/FormStatus.tsx apps/website/src/components/form/FormCard.spec.tsx apps/website/src/components/form/SubmitButton.spec.tsx apps/website/src/components/form/FormStatus.spec.tsx
+git commit -m "feat(website): add FormCard, SubmitButton, and FormStatus"
+```
+
+---
+
+## Task 5: Validators
+
+**Files:**
+- Create: `apps/website/src/components/form/validators.ts`
+- Test: `apps/website/src/components/form/validators.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it } from 'vitest';
+import { emailError, requiredError } from './validators';
+
+describe('validators', () => {
+ it('emailError names the fix and accepts a full address', () => {
+ expect(emailError('')).toBe('Enter your email address.');
+ expect(emailError('jordan@acme')).toBe('Enter a full address, like jordan@acme.dev.');
+ expect(emailError('jordan@acme.dev')).toBeNull();
+ expect(emailError(' jordan@acme.dev ')).toBeNull();
+ });
+
+ it('requiredError uses the supplied message only when the value is blank', () => {
+ expect(requiredError('', 'Choose a timeline so we can route this.')).toBe('Choose a timeline so we can route this.');
+ expect(requiredError(' ', 'Choose a timeline so we can route this.')).toBe('Choose a timeline so we can route this.');
+ expect(requiredError('this_quarter', 'Choose a timeline so we can route this.')).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/validators.spec.ts`
+Expected: FAIL, "Cannot find module './validators'".
+
+- [ ] **Step 3: Write the validators**
+
+```ts
+/** Loose shape check: something@something.tld. The server normalizes for real. */
+const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u;
+
+export function emailError(value: string): string | null {
+ const trimmed = value.trim();
+ if (trimmed.length === 0) return 'Enter your email address.';
+ if (!EMAIL_SHAPE.test(trimmed)) return 'Enter a full address, like jordan@acme.dev.';
+ return null;
+}
+
+export function requiredError(value: string, message: string): string | null {
+ return value.trim().length === 0 ? message : null;
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/validators.spec.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/website/src/components/form/validators.ts apps/website/src/components/form/validators.spec.ts
+git commit -m "feat(website): add form validators with fix-naming error copy"
+```
+
+---
+
+## Task 6: `useGrowthForm`
+
+**Files:**
+- Create: `apps/website/src/components/form/use-growth-form.ts`
+- Modify: `apps/website/src/lib/analytics/events.ts` (add `entry_point?: string;` to `AnalyticsProperties`, after `error_reason?: string;`)
+- Test: `apps/website/src/components/form/use-growth-form.spec.tsx`
+
+The hook reproduces exactly the flow every surface copies today (compare `apps/website/src/components/contact/ContactForm.tsx:29-88`): snapshot reuse across retries, POST of the envelope, 409 = stale, any 4xx clears the snapshot, ok = success, other = fail `api_error`, thrown = fail `network_error`.
+
+- [ ] **Step 1: Write the failing test**
+
+```tsx
+// @vitest-environment jsdom
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, renderHook, waitFor } from '@testing-library/react';
+
+const trackMock = vi.hoisted(() => vi.fn());
+vi.mock('../../lib/analytics/client', () => ({ track: trackMock }));
+
+import { analyticsEvents } from '../../lib/analytics/events';
+import type { PublicFormPolicy } from '../../lib/growth/form-policy';
+import { useGrowthForm } from './use-growth-form';
+
+const formPolicy: PublicFormPolicy = {
+ mode: 'growth_v1',
+ version: 'growth_v1.2026-09-01',
+ disclosures: { contact: 'c', newsletter: 'n', whitepaper: 'w' },
+};
+
+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;
+
+function options() {
+ return {
+ route: '/api/leads' as const,
+ formPolicy,
+ events: {
+ submit: analyticsEvents.marketingLeadFormSubmit,
+ success: analyticsEvents.marketingLeadFormSuccess,
+ fail: analyticsEvents.marketingLeadFormFail,
+ },
+ analytics: { surface: 'contact' as const, source_section: 'contact-form' },
+ };
+}
+
+function body(fetchMock: ReturnType, call: number) {
+ return JSON.parse(fetchMock.mock.calls[call][1].body as string);
+}
+
+beforeEach(() => {
+ trackMock.mockClear();
+ sessionStorage.clear();
+});
+afterEach(() => vi.unstubAllGlobals());
+
+describe('useGrowthForm', () => {
+ it('posts the growth envelope, fires submit then success, and lands on sent', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ vi.stubGlobal('fetch', fetchMock);
+ const { result } = renderHook(() => useGrowthForm(options()));
+ expect(result.current.status).toBe('idle');
+
+ await act(() => result.current.submit({ form_kind: 'contact', email: 'jane@acme.com' }));
+
+ expect(fetchMock).toHaveBeenCalledWith('/api/leads', expect.objectContaining({ method: 'POST' }));
+ const sent = body(fetchMock, 0);
+ expect(sent).toMatchObject({ form_kind: 'contact', email: 'jane@acme.com', policy_version: 'growth_v1.2026-09-01' });
+ expect(sent.submission_id).toMatch(UUID_V4);
+ expect(trackMock).toHaveBeenNthCalledWith(1, 'marketing:lead_form_submit', { surface: 'contact', source_section: 'contact-form' });
+ expect(trackMock).toHaveBeenNthCalledWith(2, 'marketing:lead_form_success', { surface: 'contact', source_section: 'contact-form' });
+ expect(result.current.status).toBe('sent');
+ });
+
+ it('reuses the submission id when the same facts are retried after a server error, then fails with api_error', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ vi.stubGlobal('fetch', fetchMock);
+ const { result } = renderHook(() => useGrowthForm(options()));
+
+ await act(() => result.current.submit({ form_kind: 'contact', email: 'jane@acme.com' }));
+ await act(() => result.current.submit({ form_kind: 'contact', email: 'jane@acme.com' }));
+
+ expect(body(fetchMock, 0).submission_id).toBe(body(fetchMock, 1).submission_id);
+ expect(result.current.status).toBe('failed');
+ expect(trackMock).toHaveBeenLastCalledWith('marketing:lead_form_fail', expect.objectContaining({ error_reason: 'api_error' }));
+ });
+
+ it('mints a new submission id when the facts change', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ vi.stubGlobal('fetch', fetchMock);
+ const { result } = renderHook(() => useGrowthForm(options()));
+
+ await act(() => result.current.submit({ form_kind: 'contact', email: 'jane@acme.com' }));
+ await act(() => result.current.submit({ form_kind: 'contact', email: 'jane@acme.dev' }));
+
+ expect(body(fetchMock, 0).submission_id).not.toBe(body(fetchMock, 1).submission_id);
+ });
+
+ it('goes stale on 409 and reports no success', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 409 }));
+ const { result } = renderHook(() => useGrowthForm(options()));
+
+ await act(() => result.current.submit({ form_kind: 'contact', email: 'jane@acme.com' }));
+
+ expect(result.current.status).toBe('stale');
+ expect(trackMock).not.toHaveBeenCalledWith('marketing:lead_form_success', expect.anything());
+ });
+
+ it('fails with network_error when fetch throws', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
+ const { result } = renderHook(() => useGrowthForm(options()));
+
+ await act(() => result.current.submit({ form_kind: 'contact', email: 'jane@acme.com' }));
+
+ expect(result.current.status).toBe('failed');
+ expect(trackMock).toHaveBeenLastCalledWith('marketing:lead_form_fail', expect.objectContaining({ error_reason: 'network_error' }));
+ });
+
+ it('is pending while the request is in flight and can be reset', async () => {
+ let resolve: (value: unknown) => void = () => undefined;
+ vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise((r) => { resolve = r; })));
+ const { result } = renderHook(() => useGrowthForm(options()));
+
+ let done: Promise = Promise.resolve();
+ act(() => { done = result.current.submit({ form_kind: 'contact', email: 'jane@acme.com' }); });
+ await waitFor(() => expect(result.current.status).toBe('pending'));
+
+ await act(async () => { resolve({ ok: true, status: 200 }); await done; });
+ expect(result.current.status).toBe('sent');
+
+ act(() => result.current.reset());
+ expect(result.current.status).toBe('idle');
+ });
+});
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/use-growth-form.spec.tsx`
+Expected: FAIL, "Cannot find module './use-growth-form'".
+
+- [ ] **Step 3: Add `entry_point` to the analytics property type**
+
+In `apps/website/src/lib/analytics/events.ts`, inside `AnalyticsProperties`, after `error_reason?: string;` add:
+
+```ts
+ /** Which CTA carried the visitor to a form; e.g. `pricing_tier_enterprise`. */
+ entry_point?: string;
+```
+
+- [ ] **Step 4: Write the hook**
+
+`apps/website/src/components/form/use-growth-form.ts`:
+
+```ts
+'use client';
+import { useCallback, useRef, useState } from 'react';
+import { track } from '../../lib/analytics/client';
+import type { AnalyticsEventName, AnalyticsProperties } from '../../lib/analytics/events';
+import type { PublicFormPolicy } from '../../lib/growth/form-policy';
+import {
+ growthFormRequestSnapshot,
+ type GrowthFormFacts,
+ type GrowthFormRequestSnapshot,
+} from '../../lib/growth/form-client';
+
+export type GrowthFormStatus = 'idle' | 'pending' | 'sent' | 'failed' | 'stale';
+
+export type GrowthFormRoute = '/api/leads' | '/api/newsletter' | '/api/whitepaper-signup';
+
+export interface UseGrowthFormOptions {
+ route: GrowthFormRoute;
+ formPolicy: PublicFormPolicy;
+ events: { submit: AnalyticsEventName; success: AnalyticsEventName; fail: AnalyticsEventName };
+ /** Sent with every event: surface, source_section, paper, entry_point. */
+ analytics: AnalyticsProperties;
+}
+
+export interface GrowthFormController {
+ status: GrowthFormStatus;
+ /** Posts the facts. Resolves after the status has settled; never throws. */
+ submit: (facts: Facts) => Promise;
+ /** Back to idle; keeps the snapshot so a retry after reset still reuses its id. */
+ reset: () => void;
+}
+
+/**
+ * One implementation of the flow every lead surface used to copy:
+ * immutable request snapshot, growth envelope, stale-policy branch, analytics.
+ * 409 means the visitor's page holds an old policy version; any 4xx discards
+ * the snapshot so the next attempt is a fresh submission.
+ */
+export function useGrowthForm(
+ options: UseGrowthFormOptions
+): GrowthFormController {
+ const { route, formPolicy, events, analytics } = options;
+ const [status, setStatus] = useState('idle');
+ const snapshotRef = useRef | null>(null);
+
+ const submit = useCallback(
+ async (facts: Facts) => {
+ setStatus('pending');
+ track(events.submit, analytics);
+ try {
+ const snapshot = growthFormRequestSnapshot(snapshotRef.current, facts);
+ snapshotRef.current = snapshot;
+ const response = await fetch(route, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ...snapshot.facts,
+ acquisition_session_id: snapshot.acquisition_session_id,
+ submission_id: snapshot.submission_id,
+ policy_version: formPolicy.version,
+ }),
+ });
+ if (response.status === 409) {
+ snapshotRef.current = null;
+ setStatus('stale');
+ return;
+ }
+ if (response.status >= 400 && response.status < 500) {
+ snapshotRef.current = null;
+ }
+ if (response.ok) {
+ snapshotRef.current = null;
+ track(events.success, analytics);
+ setStatus('sent');
+ return;
+ }
+ track(events.fail, { ...analytics, error_reason: 'api_error' });
+ setStatus('failed');
+ } catch {
+ track(events.fail, { ...analytics, error_reason: 'network_error' });
+ setStatus('failed');
+ }
+ },
+ [route, formPolicy.version, events, analytics]
+ );
+
+ const reset = useCallback(() => setStatus('idle'), []);
+
+ return { status, submit, reset };
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/form/use-growth-form.spec.tsx`
+Expected: PASS, 6 tests.
+
+- [ ] **Step 6: Add the barrel**
+
+`apps/website/src/components/form/index.ts`:
+
+```ts
+export { Field } from './Field';
+export { TextInput, TextArea, Select } from './controls';
+export { FormCard } from './FormCard';
+export { SubmitButton } from './SubmitButton';
+export { FormStatus } from './FormStatus';
+export { emailError, requiredError } from './validators';
+export { useGrowthForm } from './use-growth-form';
+export type { GrowthFormStatus, GrowthFormController, UseGrowthFormOptions } from './use-growth-form';
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/website/src/components/form/use-growth-form.ts apps/website/src/components/form/use-growth-form.spec.tsx apps/website/src/components/form/index.ts apps/website/src/lib/analytics/events.ts
+git commit -m "feat(website): add useGrowthForm, the shared lead-form submission hook"
+```
+
+---
+
+## Task 7: Footer newsletter on the kit
+
+**Files:**
+- Modify: `apps/website/src/components/shared/Footer.tsx:44-146` (the `NewsletterForm` function)
+- Modify: `apps/website/src/components/shared/Footer.spec.tsx`
+- Modify: `apps/website/src/styles/chrome.css` (delete `.footer-newsletter-success` and `.footer-newsletter-input`; add the layout rule below)
+
+- [ ] **Step 1: Read the existing footer spec**
+
+Open `apps/website/src/components/shared/Footer.spec.tsx`. Its five newsletter cases assert: the disclosure text and `aria-describedby` on the submit control; the envelope facts; submission-id reuse; a new id on changed facts; the stale refresh path. Keep every assertion. Change only the selectors listed in Step 2.
+
+- [ ] **Step 2: Update the failing tests**
+
+In `Footer.spec.tsx`, make these replacements:
+
+- `screen.getByLabelText('Email address')` → `screen.getByLabelText('Email')`
+- `screen.getByRole('button', { name: 'Subscribe' })` stays.
+- Any assertion on `"✓ You're subscribed!"` → `screen.getByRole('status')` containing `'Subscribed.'`
+- Add one new case inside `describe('Footer newsletter growth policy')`:
+
+```tsx
+ it('keeps the disclosure out of the control row and shows a fix-naming email error on blur', () => {
+ render();
+ const input = screen.getByLabelText('Email');
+ const row = input.closest('[data-ui="form-row"]');
+ expect(row).toBeTruthy();
+ expect(row?.querySelector('[data-ui="form-disclosure"]')).toBeNull();
+ fireEvent.change(input, { target: { value: 'reader@acme' } });
+ fireEvent.blur(input);
+ expect(screen.getByText('Enter a full address, like jordan@acme.dev.')).toBeTruthy();
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ });
+```
+
+- [ ] **Step 3: Run the spec to verify the changed cases fail**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/shared/Footer.spec.tsx`
+Expected: FAIL on the label lookup and the new case.
+
+- [ ] **Step 4: Rewrite `NewsletterForm`**
+
+Replace the whole `NewsletterForm` function in `Footer.tsx` with:
+
+```tsx
+function NewsletterForm({ formPolicy }: { formPolicy: PublicFormPolicy }) {
+ const [email, setEmail] = useState('');
+ const [emailMessage, setEmailMessage] = useState(null);
+ const form = useGrowthForm<{ email: string }>({
+ route: '/api/newsletter',
+ formPolicy,
+ events: {
+ submit: analyticsEvents.marketingNewsletterSignupSubmit,
+ success: analyticsEvents.marketingNewsletterSignupSuccess,
+ fail: analyticsEvents.marketingNewsletterSignupFail,
+ },
+ analytics: { surface: 'footer', source_section: 'newsletter-form' },
+ });
+ const disclosureId = 'footer-newsletter-growth-disclosure';
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const problem = emailError(email);
+ setEmailMessage(problem);
+ if (problem) {
+ document.getElementById('footer-email')?.focus();
+ return;
+ }
+ void form.submit({ email: email.trim() });
+ };
+
+ if (form.status === 'sent') {
+ return (
+
+
+
+ );
+ }
+
+ if (form.status === 'stale') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+```
+
+Update the imports at the top of `Footer.tsx`: remove `growthFormRequestSnapshot` and `GrowthFormRequestSnapshot` from the `form-client` import (keep `FORM_POLICY_REFRESH_MESSAGE`), remove `useRef` from the React import, remove `track` from the analytics import if nothing else in the file uses it (check with `grep -n "track(" apps/website/src/components/shared/Footer.tsx`; `trackCtaClick` and `trackExternalLinkClick` stay), and add:
+
+```tsx
+import { Field, FormStatus, SubmitButton, TextInput, emailError, useGrowthForm } from '../form';
+```
+
+- [ ] **Step 5: Replace the footer newsletter CSS**
+
+In `apps/website/src/styles/chrome.css`, delete the `.footer-newsletter-success` and `.footer-newsletter-input` rules and add:
+
+```css
+.footer-newsletter {
+ max-width: 320px;
+ margin-bottom: 16px;
+}
+```
+
+- [ ] **Step 6: Run the spec to verify it passes**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/shared/Footer.spec.tsx`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/website/src/components/shared/Footer.tsx apps/website/src/components/shared/Footer.spec.tsx apps/website/src/styles/chrome.css
+git commit -m "fix(website): rebuild the footer newsletter on the form kit; the input no longer collapses"
+```
+
+---
+
+## Task 8: E2E footer case and gate run for PR 1
+
+**Files:**
+- Modify: `apps/website/e2e/website.spec.ts:171-192` (footer newsletter case)
+
+- [ ] **Step 1: Rewrite the footer e2e case**
+
+Replace the `footer newsletter form posts to /api/newsletter and renders success state` test with:
+
+```ts
+test('footer newsletter form posts to /api/newsletter and renders success state', async ({ page }) => {
+ let payload: Record | undefined;
+ await page.route('**/api/newsletter', async (route) => {
+ payload = route.request().postDataJSON();
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
+ });
+
+ await page.goto('/');
+ const footer = page.locator('footer');
+ const input = footer.getByLabel('Email');
+ // Regression guard: the disclosure once sat inside the flex row and the input collapsed to 26px.
+ expect((await input.boundingBox())?.width ?? 0).toBeGreaterThan(160);
+ await input.fill('reader@acme.com');
+ await footer.getByRole('button', { name: 'Subscribe' }).click();
+
+ await expect(footer.getByRole('status')).toContainText('Subscribed.');
+ expect(payload).toMatchObject({ email: 'reader@acme.com', policy_version: GROWTH_FORM_POLICY_VERSION });
+ expect(payload?.['submission_id']).toMatch(UUID_V4);
+});
+```
+
+- [ ] **Step 2: Run the gates**
+
+```bash
+npx nx lint website --skip-nx-cache
+npx nx test website --skip-nx-cache
+npx nx build website --skip-nx-cache
+npx nx e2e website --skip-nx-cache --grep "footer newsletter"
+```
+
+Expected: all pass. If the build fails on the `SubmitButton` prop type, the `Extract` narrowing is the place to look.
+
+- [ ] **Step 3: Visual check**
+
+Run: `npx nx serve website` and open `http://localhost:3000/`. Scroll to the footer. The email input must be wider than the button and the disclosure must sit under the row. Tab into the input: a blue ring appears. Type `reader@acme` and tab out: the red ring and the "Enter a full address" line appear.
+
+- [ ] **Step 4: Commit and open PR 1**
+
+```bash
+git add apps/website/e2e/website.spec.ts
+git commit -m "test(website): guard the footer newsletter input width end to end"
+git push -u origin HEAD
+gh pr create --title "feat(website): lead-form kit and footer newsletter fix" --body "PR 1 of 3 for docs/superpowers/specs/2026-09-03-lead-forms-system-design.md. Adds the form kit, forms.css with style contracts, useGrowthForm, and rebuilds the footer newsletter on it, which fixes the input that rendered 26px wide in production."
+```
+
+---
+
+## Task 9: Contact page band layout and CSS
+
+**Files:**
+- Modify: `apps/website/src/styles/pages.css` (contact page rules)
+- Modify: `apps/website/src/styles/marketing.css` (delete `.contact-form*` rules at the block starting `.contact-form-sent`)
+- Modify: `apps/website/src/styles/style-contracts.spec.ts`
+
+- [ ] **Step 1: Add a style contract for the band**
+
+Append to `CONTRACTS`:
+
+```ts
+ {
+ file: 'pages.css',
+ selector: '.contact-band',
+ why: 'Heading column and form card are grid siblings; without the grid the card drops below the heading and the page reads as the old single column.',
+ requires: {
+ display: /display:\s*grid/,
+ 'grid-template-columns': /grid-template-columns:/,
+ },
+ },
+```
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/styles/style-contracts.spec.ts`
+Expected: FAIL on `.contact-band`.
+
+- [ ] **Step 2: Replace the contact page CSS**
+
+In `apps/website/src/styles/pages.css`, delete `.contact-page-inner`, `.contact-page-eyebrow-spaced`, `.contact-page-subtitle`, `.contact-page-sla-wrap`, `.contact-page-links-row`, and `.sla-card`, `.sla-card-text` (the last two may live in `marketing.css`; delete wherever `grep -n "sla-card" apps/website/src/styles/*.css` finds them). Keep `.contact-alt-row`, `.contact-alt-link`, `.contact-alt-sep`. Add:
+
+```css
+.contact-band {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
+ gap: 48px;
+ align-items: start;
+}
+.contact-band-eyebrow {
+ margin-bottom: 16px;
+}
+.contact-band-lede {
+ font-family: var(--font-inter);
+ font-size: var(--text-body-lg);
+ line-height: var(--text-body-lg--line-height);
+ color: var(--color-text-secondary);
+ margin: 0 0 24px;
+ max-width: 48ch;
+}
+.contact-band-note {
+ font-family: var(--font-inter);
+ font-size: var(--text-body);
+ line-height: var(--text-body--line-height);
+ color: var(--color-text-primary);
+ border-left: 3px solid var(--color-accent);
+ padding: 6px 0 6px 14px;
+ margin: 0 0 24px;
+}
+.contact-band-channels {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.contact-band-channels-label {
+ font-family: var(--font-mono);
+ font-size: var(--text-eyebrow);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+ margin: 0;
+}
+.contact-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.contact-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 32px;
+ padding: 0 12px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-full);
+ font-family: var(--font-inter);
+ font-size: 13px;
+ color: var(--color-text-primary);
+ text-decoration: none;
+ background: var(--color-surface);
+}
+.contact-chip:hover {
+ border-color: var(--color-accent-border-hover);
+ color: var(--color-accent);
+}
+@media (max-width: 900px) {
+ .contact-band {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 28px;
+ }
+}
+```
+
+In `apps/website/src/styles/marketing.css`, delete the rules `.contact-form-sent`, `.contact-form`, `.contact-form-label`, `.contact-form-optional`, `.contact-form-input`, `.contact-form-textarea`, `.contact-form-error`.
+
+- [ ] **Step 3: Run the contract spec to verify it passes**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/styles/style-contracts.spec.ts`
+Expected: PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add apps/website/src/styles/pages.css apps/website/src/styles/marketing.css apps/website/src/styles/style-contracts.spec.ts
+git commit -m "feat(website): contact page band layout styles; drop the old contact-form rules"
+```
+
+---
+
+## Task 10: `ContactForm` rebuilt with the enterprise intent
+
+**Files:**
+- Modify: `apps/website/src/components/contact/ContactForm.tsx` (full rewrite)
+- Modify: `apps/website/src/components/contact/ContactForm.spec.tsx`
+
+- [ ] **Step 1: Update the failing tests**
+
+In `ContactForm.spec.tsx`:
+
+- Change `fill()` to look up `/work email/i` instead of `/email/i`, and `/what are you shipping/i` instead of `/message/i`.
+- Change `send()` to `screen.getByRole('button', { name: /send to brian/i })`.
+- Replace the success assertions (`Thanks. We'll be in touch…`) with `expect(screen.getByRole('status').textContent).toContain('Sent.')`.
+- Keep every growth-policy case; they assert the envelope, not the markup.
+- Add, inside `describe('ContactForm')`:
+
+```tsx
+ it('validates the email on blur, names the fix, and blocks submit until fixed', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ render();
+ const email = screen.getByLabelText(/work email/i);
+ fireEvent.change(email, { target: { value: 'jane@acme' } });
+ fireEvent.blur(email);
+ expect(screen.getByText('Enter a full address, like jordan@acme.dev.')).toBeTruthy();
+ send();
+ expect(fetchMock).not.toHaveBeenCalled();
+ fireEvent.change(email, { target: { value: 'jane@acme.com' } });
+ expect(screen.queryByText('Enter a full address, like jordan@acme.dev.')).toBeNull();
+ });
+
+ it('in enterprise intent posts the pricing form kind with the timeline and entry point', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ vi.stubGlobal('fetch', fetchMock);
+ render();
+
+ fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'jane@acme.com' } });
+ fireEvent.change(screen.getByLabelText(/company/i), { target: { value: 'Acme' } });
+ fireEvent.change(screen.getByLabelText(/timeline/i), { target: { value: 'this_quarter' } });
+ fireEvent.change(screen.getByLabelText(/tell us about your use case/i), { target: { value: 'Volume seats.' } });
+ fireEvent.click(screen.getByRole('button', { name: /request a conversation/i }));
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ expect(sentBody(fetchMock, 0)).toMatchObject({
+ form_kind: 'pricing',
+ email: 'jane@acme.com',
+ company: 'Acme',
+ timeline: 'this_quarter',
+ message: 'Volume seats.',
+ });
+ expect(trackMock).toHaveBeenCalledWith(
+ 'marketing:lead_form_submit',
+ expect.objectContaining({ surface: 'pricing', entry_point: 'pricing_tier_enterprise' })
+ );
+ });
+
+ it('in enterprise intent requires a timeline and says so', () => {
+ vi.stubGlobal('fetch', vi.fn());
+ render();
+ fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'jane@acme.com' } });
+ fireEvent.click(screen.getByRole('button', { name: /request a conversation/i }));
+ expect(screen.getByText('Choose a timeline so we can route this.')).toBeTruthy();
+ });
+```
+
+- [ ] **Step 2: Run the spec to verify it fails**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/contact/ContactForm.spec.tsx`
+Expected: FAIL on the new labels and the `intent` prop.
+
+- [ ] **Step 3: Rewrite `ContactForm`**
+
+```tsx
+'use client';
+
+import React, { useState } from 'react';
+import { Button } from '../ui/Button';
+import { analyticsEvents } from '../../lib/analytics/events';
+import type { PublicFormPolicy } from '../../lib/growth/form-policy';
+import { FORM_POLICY_REFRESH_MESSAGE } from '../../lib/growth/form-client';
+import {
+ Field,
+ FormStatus,
+ Select,
+ SubmitButton,
+ TextArea,
+ TextInput,
+ emailError,
+ requiredError,
+ useGrowthForm,
+} from '../form';
+
+export type ContactIntent = 'contact' | 'enterprise';
+
+const TIMELINES = [
+ ['this_quarter', 'This quarter'],
+ ['next_quarter', 'Next quarter'],
+ ['6_plus_months', '6+ months'],
+ ['exploring', 'Just exploring'],
+] as const;
+
+type Timeline = (typeof TIMELINES)[number][0];
+
+interface ContactFormProps {
+ formPolicy: PublicFormPolicy;
+ /** `enterprise` adds the timeline field and posts the pricing form kind. */
+ intent?: ContactIntent;
+ /** The CTA that brought the visitor here; reported to analytics. */
+ entryPoint?: string;
+}
+
+const FOUNDER_EMAIL = 'brian@threadplane.ai';
+
+export function ContactForm({ formPolicy, intent = 'contact', entryPoint }: ContactFormProps) {
+ const enterprise = intent === 'enterprise';
+ const [email, setEmail] = useState('');
+ const [name, setName] = useState('');
+ const [company, setCompany] = useState('');
+ const [timeline, setTimeline] = useState('');
+ const [message, setMessage] = useState('');
+ const [emailMessage, setEmailMessage] = useState(null);
+ const [timelineMessage, setTimelineMessage] = useState(null);
+
+ const form = useGrowthForm({
+ route: '/api/leads',
+ formPolicy,
+ events: {
+ submit: analyticsEvents.marketingLeadFormSubmit,
+ success: analyticsEvents.marketingLeadFormSuccess,
+ fail: analyticsEvents.marketingLeadFormFail,
+ },
+ analytics: {
+ surface: enterprise ? 'pricing' : 'contact',
+ source_section: 'contact-form',
+ ...(entryPoint ? { entry_point: entryPoint } : {}),
+ },
+ });
+ const disclosureId = 'contact-form-growth-disclosure';
+ const timelineError = 'Choose a timeline so we can route this.';
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const emailProblem = emailError(email);
+ const timelineProblem = enterprise ? requiredError(timeline, timelineError) : null;
+ setEmailMessage(emailProblem);
+ setTimelineMessage(timelineProblem);
+ if (emailProblem) {
+ document.getElementById('contact-email')?.focus();
+ return;
+ }
+ if (timelineProblem) {
+ document.getElementById('contact-timeline')?.focus();
+ return;
+ }
+ void form.submit({
+ form_kind: enterprise ? 'pricing' : 'contact',
+ email: email.trim(),
+ ...(name ? { name } : {}),
+ ...(company ? { company } : {}),
+ ...(enterprise && timeline ? { timeline } : {}),
+ ...(message ? { message } : {}),
+ });
+ }
+
+ if (form.status === 'stale') {
+ return (
+
+
+
+ );
+ }
+
+ if (form.status === 'sent') {
+ return ;
+ }
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 4: Run the spec to verify it passes**
+
+Run: `npx vitest run --config apps/website/vite.config.mts apps/website/src/components/contact/ContactForm.spec.tsx`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/website/src/components/contact/ContactForm.tsx apps/website/src/components/contact/ContactForm.spec.tsx
+git commit -m "feat(website): rebuild ContactForm on the kit with an enterprise intent"
+```
+
+---
+
+## Task 11: Contact page composition
+
+**Files:**
+- Modify: `apps/website/src/app/contact/page.tsx` (full rewrite)
+- Delete: `apps/website/src/components/contact/SlaCard.tsx`
+- Create: `apps/website/src/app/contact/page.spec.tsx`
+
+- [ ] **Step 1: Write the failing page test**
+
+`apps/website/src/app/contact/page.spec.tsx`:
+
+```tsx
+// @vitest-environment jsdom
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+vi.mock('../../lib/analytics/client', () => ({ track: vi.fn(), trackCtaClick: vi.fn(), trackExternalLinkClick: vi.fn() }));
+
+import ContactPage from './page';
+
+describe('ContactPage', () => {
+ it('renders the contact variant by default', async () => {
+ render(await ContactPage({ searchParams: Promise.resolve({}) }));
+ expect(screen.getByText('Contact', { selector: '[data-ui="eyebrow"]' })).toBeTruthy();
+ expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('Talk to an engineer.');
+ expect(screen.getByRole('button', { name: 'Send to Brian' })).toBeTruthy();
+ expect(screen.queryByLabelText('Timeline')).toBeNull();
+ expect(screen.getByRole('link', { name: 'brian@threadplane.ai' })).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'GitHub issues' })).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Discord' })).toBeTruthy();
+ });
+
+ it('renders the enterprise variant from the intent query and passes the entry point through', async () => {
+ render(await ContactPage({ searchParams: Promise.resolve({ intent: 'enterprise', entry: 'pricing_tier_enterprise' }) }));
+ expect(screen.getByText('Enterprise', { selector: '[data-ui="eyebrow"]' })).toBeTruthy();
+ expect(screen.getByLabelText('Timeline')).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Request a conversation' })).toBeTruthy();
+ });
+
+ it('ignores unknown intents and unsafe entry values', async () => {
+ render(await ContactPage({ searchParams: Promise.resolve({ intent: 'weird', entry: '