From 5ce110c19c864270371b8d8a80fa3e4bfb5f2e98 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 20:35:27 -0700 Subject: [PATCH 01/17] =?UTF-8?q?docs(specs):=20lead=20forms=20system=20de?= =?UTF-8?q?sign=20=E2=80=94=20one=20kit,=20four=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved brainstorm outcome for rebuilding the website lead forms: a shared form kit and submission hook, bordered tokenized fields, the enterprise form merged into /contact as an intent variant, the pricing page reduced to a CTA band, whitepaper block and toast rebuilt on the kit, and the footer newsletter's collapsed input fixed structurally. Company domain inference is deferred pending Dawn changes. Co-Authored-By: Claude Fable 5.1 --- .../2026-09-03-lead-forms-system-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-03-lead-forms-system-design.md diff --git a/docs/superpowers/specs/2026-09-03-lead-forms-system-design.md b/docs/superpowers/specs/2026-09-03-lead-forms-system-design.md new file mode 100644 index 000000000..584af3d87 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-lead-forms-system-design.md @@ -0,0 +1,130 @@ +# Lead forms system: one kit, four surfaces + +**Date:** 2026-09-03 +**Status:** Approved design, awaiting implementation plan +**Surface:** `apps/website` — contact page, pricing page, homepage whitepaper block, announcement toast, footer newsletter +**Depends on:** the growth hard cutover (#968) and its follow-ups; every form already posts the growth envelope to Neon-backed routes + +## 1. Goal + +Every lead capture on threadplane.ai should look like one product, behave the same way, and be accessible by construction. Today five surfaces use four stylesheets, no shared input component, two label strategies, and three radii; only two have a focus state; success and error colors are hardcoded hex values; the footer newsletter input renders 26 px wide because its disclosure sits inside the flex row; and the whitepaper offer appears three times with three treatments. + +The redesign is structural. Consistency comes from a shared form kit and one submission hook, not from a review checklist. + +## 2. Decisions locked during brainstorming + +| Decision | Choice | +|---|---| +| Scope | One form system composed into every surface. | +| Field style | Bordered, tokenized: one 44 px height, one 8 px radius, one focus ring, visible labels above the control. | +| Enterprise form | Trimmed to work email, company, timeline, message. Team size and Pilot-to-Prod interest move to the conversation. | +| Where the enterprise form lives | Merged into the contact page as an intent variant at `/contact?intent=enterprise`. The pricing page keeps a short CTA band, no form. | +| Whitepaper offer | Homepage block and toast both stay, both built on the kit. Footer stays newsletter-only. | +| Contact composition | Heading and lede on the left of a tinted band, the form in an elevated white card on the right, direct channels as chips under the heading. | +| Company domain inference | Deferred. Waits on changes landing in Dawn and possibly this repository. Not part of this spec. | +| Dark mode | Out of scope; the marketing site is light-only. | + +Evidence behind the trim and merge: over the 90 days to 2026-09-03, PostHog recorded 11 contact successes, 8 whitepaper-block successes, 2 toast successes, 2 footer newsletter successes, and 0 enterprise-form submissions. + +## 3. The form kit + +New directory `apps/website/src/components/form/`. + +| Piece | Responsibility | +|---|---| +| `Field` | Visible label, optional "(optional)" marker, help text, error line. Generates ids and wires `aria-describedby` and `aria-invalid` on its child control. Label: Inter 13 px, weight 500, above the control. | +| `TextInput`, `TextArea`, `Select` | Thin wrappers applying the shared control class and forwarding all native props, including `type`, `autoComplete`, `required`, `inputMode`. | +| `FormCard` | The elevated white card used on a tinted band. `compact` prop for the toast and the footer. | +| `SubmitButton` | The existing `Button` primitive with a `pending` prop that swaps the label and disables the control without changing its width. | +| `FormStatus` | Success and failure blocks with an icon, specific copy, and a next step. `role="status"` for success, `role="alert"` for failure and stale. | + +One hook, `useGrowthForm`, replaces the per-surface copies of the submission flow. It owns the request snapshot (`growthFormRequestSnapshot`), the POST of the growth envelope, the stale-policy branch, the analytics events, and a status machine: `idle`, `pending`, `sent`, `failed`, `stale`. Surfaces pass the route, the facts, the surface and section names, and an optional `entry_point`. + +### Styling + +New file `apps/website/src/styles/forms.css` in the style substrate. It introduces tokens, defined once in the substrate's token file and consumed here: + +| Token | Purpose | +|---|---| +| `--form-control-height` | 44 px standard, 36 px compact | +| `--form-control-radius` | 8 px | +| `--form-focus-ring` | accent border plus 3 px soft accent shadow | +| `--form-error-ring` | error border plus 3 px soft error shadow | +| `--color-status-success`, `--color-status-error` | replace the hardcoded `#1a7a40`, `#c00`, and the Angular red used for form errors | + +The old form rules are deleted: `.contact-form-*` and `.lead-form-*` from `marketing.css`, the form parts of `.wp-*` from `landing.css`, `.footer-newsletter-*` and `.toast-input`-family rules from `chrome.css`, and the footer's Tailwind utility classes. `forms.css` gets style-contract entries for the load-bearing declarations: control height, focus ring, error ring, and the footer row layout. + +## 4. Surfaces + +### Contact page, `/contact` + +Tinted band. Left column: eyebrow "Contact", heading "Talk to an engineer.", lede "Tell us what you are shipping. We reply within one business day, usually with code, not a calendar invite.", then direct-channel chips: the founder address, GitHub issues, Discord. Right column: `FormCard` with the form. + +Fields: Work email (required), Name (optional), Company (optional), "What are you shipping?" (optional textarea). Disclosure paragraph above the button, unchanged in substance. Button: "Send to Brian". Success replaces the card body with "Sent. Expect a reply within one business day." and leaves the chips visible. + +**Enterprise variant**, `/contact?intent=enterprise`, read on the server so it renders without a flash: + +- eyebrow "Enterprise"; heading unchanged; +- a required Timeline select after Company: This quarter, Next quarter, 6+ months, Just exploring; +- textarea prompt "Tell us about your use case"; +- button "Request a conversation"; +- posts to `/api/leads` with `form_kind: 'pricing'` and `timeline`, which the route already accepts; analytics surface `pricing`, plus `entry_point` naming the pricing button that linked here. + +### Pricing page, `/pricing` + +The lead-form section is replaced by a CTA band: eyebrow "Enterprise", heading "Choose the support. Add delivery if you need it.", one sentence, one button to `/contact?intent=enterprise`. The three plan buttons and the Pilot-to-Prod link point to the same URL with distinct `entry_point` values. `LeadForm.tsx`, its CSS, and its spec are deleted. + +### Whitepaper block + +Composition unchanged: rail, three rows, cover art. The input becomes a kit `Field` with the visible label "Work email"; the button becomes `SubmitButton` with "Get the field report" and pending "Sending the guide…". Success is a `FormStatus` block: "Check your inbox. The guide is on its way, and the PDF is here too." with the direct PDF link. Failure offers the direct PDF link. + +### Announcement toast + +Trigger, dismissal, and copy unchanged. Shell becomes `FormCard compact`; input and button become compact kit pieces; the three ad-hoc font sizes go. Success uses the whitepaper `FormStatus` copy and keeps the auto-dismiss. + +### Footer newsletter + +Disclosure moves below the input row, which fixes the collapsed input. A visible compact label "Email" above the row, input and button at compact size, button "Subscribe", pending "Subscribing…". Success: "Subscribed. The first note from Brian arrives within a day." + +## 5. Behavior and copy + +- **Validation** runs on blur, never per keystroke; an error clears as soon as the value is valid; submit runs a final pass and moves focus to the first invalid field. +- **Error copy names the fix**: "Enter a full address, like jordan@acme.dev." and "Choose a timeline so we can route this." Errors are text plus an icon beside the field, linked by `aria-describedby`, never color alone. +- **Work email** is the label on contact, enterprise, and whitepaper; any address is accepted. The newsletter label is "Email". +- **Pending buttons** keep their width. Labels: "Sending…", "Sending…", "Sending the guide…", "Subscribing…". +- **Failure copy** says "That did not send." and offers the surface's fallback: the founder address, the direct PDF, or "try again". +- **Stale policy** keeps its refresh path, rendered through `FormStatus`. +- **Disclosures** keep their text, since they are the stored consent record, and render as one muted paragraph above the button on every surface. +- **Voice**: no contractions, declarative sentences. Replaces "We'll be in touch" and "You're subscribed". + +## 6. Accessibility + +Every control has a visible label; placeholders are examples only. Focus ring satisfies WCAG 2.2 focus appearance. Every target, including the toast's "Not now" and the footer button, is at least 24 px. `autocomplete` values: `email`, `name`, `organization`. No entrance transition under `prefers-reduced-motion`. Success uses `role="status"`; failure and stale use `role="alert"`. + +## 7. Server and analytics + +No change to route validation, the growth envelope, the policy version check, the acquisition session id, the disclosure text, or the lifecycle service. + +Analytics event names and surface values are unchanged (`contact`, `pricing`, `home_whitepaper`, `toast`, `footer`). The enterprise variant reports surface `pricing` with `entry_point`. Firing submit, success, and fail from the hook fixes the current gap where some success events arrive without a surface. + +## 8. Testing + +- Component specs for each kit piece; hook spec covering every state and the analytics calls. +- Existing surface specs updated for the new markup, plus the enterprise variant and the pricing CTA band. +- Style-contract entries for control height, focus ring, error ring, and the footer row layout. +- Website e2e: the four form-flow cases rewritten; an enterprise-intent case added; a footer case asserting the input width exceeds a minimum. +- Manual gate after each deploy: one real submission per surface, checked in Neon and in PostHog by surface. + +## 9. Rollout + +Three PRs, each leaving every surface working because each merge auto-promotes: + +1. Form kit, `forms.css`, tokens, style-contract entries, and the footer newsletter on the kit. Repairs the live defect. +2. Contact page with the enterprise variant, pricing CTA band, deletion of `LeadForm`. +3. Whitepaper block and announcement toast on the kit; deletion of the remaining old form CSS. + +## 10. Deferred + +- **Company domain inference** from the email address. Waits on changes landing in Dawn and possibly this repository. When it resumes, the intended shape is: derive `work` classification and `company_domain` for non-free-mail domains in the shared accept path, tag the activity with the source, and leave enrichment unchanged. +- An explicit "company website" override for agencies, if they become a real share of leads. +- Dark mode for the marketing site. From ead1ecb09d49fe2d83c654f16a30488ae8a347a6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 07:56:22 -0700 Subject: [PATCH 02/17] docs(plans): lead forms system implementation plan Sixteen tasks in three shippable PRs: the form kit, forms.css with style contracts, and useGrowthForm with the footer newsletter fix; the contact page band with the enterprise intent and the pricing CTA band; the whitepaper block and toast on the kit with the old form CSS removed. Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-09-04-lead-forms-system.md | 2594 +++++++++++++++++ 1 file changed, 2594 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-lead-forms-system.md 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..2b74b83d2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-lead-forms-system.md @@ -0,0 +1,2594 @@ +# 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); + --form-error-ring: 0 0 0 3px rgba(221, 0, 49, 0.18); + --color-status-success: #1a7a40; + --color-status-error: var(--color-angular-red); +} + +/* 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"] { + flex: 1 1 200px; + min-width: 0; +} +[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="submit"] > span:first-child { + display: inline-grid; +} +[data-ui="submit"] [data-slot] { + grid-area: 1 / 1; + white-space: nowrap; +} +[data-ui="submit"]:not([data-pending]) [data-slot="pending"], +[data-ui="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: rgba(221, 0, 49, 0.25); + background: rgba(221, 0, 49, 0.05); +} +[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( + <> + +