Skip to content

Commit 4c808e5

Browse files
bloveclaude
andauthored
feat(website): lead-form kit and footer newsletter fix (#994)
* docs(specs): lead forms system design — one kit, four surfaces 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat(website): add the lead-form stylesheet and its style contracts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(plans): submit button uses a data-submit marker; error tints via color-mix * fix(website): key submit rules off a marker attribute; derive error tints from the token Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): add the form Field primitive with wired accessibility Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): add TextInput, TextArea, and Select form controls Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): merge caller aria-describedby with the field's; pin the id override Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): add FormCard, SubmitButton, and FormStatus Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): add form validators with fix-naming error copy Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): add useGrowthForm, the shared lead-form submission hook Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): style the stale form status; cover stale tone and submit passthrough Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): rebuild the footer newsletter on the form kit; the input no longer collapses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): guard the footer newsletter input width end to end Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): useGrowthForm — in-flight guard, stable submit, 4xx coverage; mark kit files client Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: lead forms spec/plan — footer disclosure placement, focus on invalid everywhere, compact row rules * fix(website): footer form — compact row heights, focus on invalid, success and clearing coverage Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 11a4ff7 commit 4c808e5

26 files changed

Lines changed: 3814 additions & 104 deletions

apps/website/e2e/website.spec.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,13 @@ test('footer newsletter form posts to /api/newsletter and renders success state'
171171

172172
await page.goto('/');
173173
const footer = page.locator('footer');
174-
await footer.getByLabel('Email address').fill('reader@acme.com');
174+
const input = footer.getByLabel('Email');
175+
// Regression guard: the disclosure once sat inside the flex row and the input collapsed to 26px.
176+
expect((await input.boundingBox())?.width ?? 0).toBeGreaterThan(160);
177+
await input.fill('reader@acme.com');
175178
await footer.getByRole('button', { name: 'Subscribe' }).click();
176179

177-
await expect(page.getByText("✓ You're subscribed!")).toBeVisible();
180+
await expect(footer.getByRole('status')).toContainText('Subscribed.');
178181
expect(payload).toMatchObject({
179182
email: 'reader@acme.com',
180183
policy_version: GROWTH_FORM_POLICY_VERSION,

apps/website/src/app/global.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
@import "../styles/docs.css";
1515
@import "../styles/landing.css";
1616
@import "../styles/marketing.css";
17+
@import "../styles/forms.css";
1718
@import "../styles/pages.css";
1819

1920
/* Shared workspace components live outside this app's automatic content
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// @vitest-environment jsdom
2+
import React, { useContext } from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
import { render, screen } from '@testing-library/react';
5+
import { Field } from './Field';
6+
import { FieldContext } from './field-context';
7+
8+
function Probe() {
9+
const ctx = useContext(FieldContext);
10+
return <input data-testid="probe" id={ctx?.id} aria-describedby={ctx?.describedBy} aria-invalid={ctx?.invalid || undefined} />;
11+
}
12+
13+
describe('Field', () => {
14+
it('labels the control by id and marks optional fields', () => {
15+
render(
16+
<Field id="f-email" label="Work email" optional>
17+
<Probe />
18+
</Field>
19+
);
20+
const label = screen.getByText('Work email', { selector: 'label' });
21+
expect(label.getAttribute('for')).toBe('f-email');
22+
expect(screen.getByText('(optional)')).toBeTruthy();
23+
expect(screen.getByTestId('probe').id).toBe('f-email');
24+
});
25+
26+
it('wires help and error text through aria-describedby and sets aria-invalid', () => {
27+
render(
28+
<Field id="f-email" label="Work email" help="We reply from a real inbox." error="Enter a full address, like jordan@acme.dev.">
29+
<Probe />
30+
</Field>
31+
);
32+
const probe = screen.getByTestId('probe');
33+
expect(probe.getAttribute('aria-describedby')).toBe('f-email-help f-email-error');
34+
expect(probe.getAttribute('aria-invalid')).toBe('true');
35+
expect(screen.getByText('Enter a full address, like jordan@acme.dev.').id).toBe('f-email-error');
36+
expect(screen.getByText('We reply from a real inbox.').id).toBe('f-email-help');
37+
});
38+
39+
it('omits aria-describedby when there is nothing to describe', () => {
40+
render(
41+
<Field id="f-name" label="Name">
42+
<Probe />
43+
</Field>
44+
);
45+
expect(screen.getByTestId('probe').getAttribute('aria-describedby')).toBeNull();
46+
expect(screen.getByTestId('probe').getAttribute('aria-invalid')).toBeNull();
47+
});
48+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use client';
2+
import type { ReactNode } from 'react';
3+
import { FieldContext } from './field-context';
4+
5+
interface FieldProps {
6+
/** Control id. The label's `for` and the control's `id` both use it. */
7+
id: string;
8+
label: ReactNode;
9+
optional?: boolean;
10+
help?: ReactNode;
11+
/** Error copy. Present means the field is invalid. */
12+
error?: string | null;
13+
children: ReactNode;
14+
}
15+
16+
export function Field({ id, label, optional = false, help, error, children }: FieldProps) {
17+
const helpId = help ? `${id}-help` : undefined;
18+
const errorId = error ? `${id}-error` : undefined;
19+
const describedBy = [helpId, errorId].filter(Boolean).join(' ') || undefined;
20+
return (
21+
<div data-ui="field">
22+
<label data-ui="field-label" htmlFor={id}>
23+
{label}
24+
{optional ? <> <span data-ui="field-optional">(optional)</span></> : null}
25+
</label>
26+
<FieldContext.Provider value={{ id, describedBy, invalid: Boolean(error) }}>
27+
{children}
28+
</FieldContext.Provider>
29+
{help ? (
30+
<p data-ui="field-help" id={helpId}>
31+
{help}
32+
</p>
33+
) : null}
34+
{error ? (
35+
<p data-ui="field-error" id={errorId}>
36+
{error}
37+
</p>
38+
) : null}
39+
</div>
40+
);
41+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// @vitest-environment jsdom
2+
import React from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
import { render } from '@testing-library/react';
5+
import { FormCard } from './FormCard';
6+
7+
describe('FormCard', () => {
8+
it('renders the card shell and forwards the compact flag', () => {
9+
const { container, rerender } = render(<FormCard>body</FormCard>);
10+
const card = container.querySelector('[data-ui="form-card"]');
11+
expect(card?.textContent).toBe('body');
12+
expect(card?.getAttribute('data-compact')).toBeNull();
13+
rerender(<FormCard compact>body</FormCard>);
14+
expect(container.querySelector('[data-ui="form-card"]')?.getAttribute('data-compact')).toBe('');
15+
});
16+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { HTMLAttributes, ReactNode } from 'react';
2+
3+
interface FormCardProps extends HTMLAttributes<HTMLDivElement> {
4+
children: ReactNode;
5+
compact?: boolean;
6+
}
7+
8+
export function FormCard({ children, compact = false, ...rest }: FormCardProps) {
9+
return (
10+
<div data-ui="form-card" data-compact={compact ? '' : undefined} {...rest}>
11+
{children}
12+
</div>
13+
);
14+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// @vitest-environment jsdom
2+
import React from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
import { render, screen } from '@testing-library/react';
5+
import { FormStatus } from './FormStatus';
6+
7+
describe('FormStatus', () => {
8+
it('announces success politely', () => {
9+
render(<FormStatus tone="success" title="Sent." detail="Expect a reply within one business day." />);
10+
const status = screen.getByRole('status');
11+
expect(status.getAttribute('data-tone')).toBe('success');
12+
expect(status.textContent).toContain('Sent.');
13+
expect(status.textContent).toContain('Expect a reply within one business day.');
14+
});
15+
16+
it('announces failure as an alert and renders an action', () => {
17+
render(
18+
<FormStatus tone="failure" title="That did not send." detail="Email brian@threadplane.ai instead.">
19+
<a href="/whitepaper.pdf">Download the PDF directly</a>
20+
</FormStatus>
21+
);
22+
const alert = screen.getByRole('alert');
23+
expect(alert.getAttribute('data-tone')).toBe('failure');
24+
expect(screen.getByRole('link', { name: 'Download the PDF directly' })).toBeTruthy();
25+
});
26+
27+
it('announces the stale tone as an alert', () => {
28+
render(<FormStatus tone="stale" title="This page is out of date." detail="Refresh to continue." />);
29+
const alert = screen.getByRole('alert');
30+
expect(alert.getAttribute('data-tone')).toBe('stale');
31+
expect(alert.textContent).toContain('This page is out of date.');
32+
});
33+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ReactNode } from 'react';
2+
3+
type Tone = 'success' | 'failure' | 'stale';
4+
5+
interface FormStatusProps {
6+
tone: Tone;
7+
title: string;
8+
detail?: ReactNode;
9+
/** Optional follow-up: a link, a retry button, a refresh button. */
10+
children?: ReactNode;
11+
}
12+
13+
const ICON: Record<Tone, string> = { success: '✓', failure: '!', stale: '↻' };
14+
15+
export function FormStatus({ tone, title, detail, children }: FormStatusProps) {
16+
const role = tone === 'success' ? 'status' : 'alert';
17+
return (
18+
<div data-ui="form-status" data-tone={tone} role={role}>
19+
<span data-ui="form-status-icon" aria-hidden="true">{ICON[tone]}</span>
20+
<div data-ui="form-status-body">
21+
<p>
22+
<strong>{title}</strong>
23+
{detail ? <> {detail}</> : null}
24+
</p>
25+
{children ? <div data-ui="form-status-action">{children}</div> : null}
26+
</div>
27+
</div>
28+
);
29+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// @vitest-environment jsdom
2+
import React from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
import { render, screen } from '@testing-library/react';
5+
import { SubmitButton } from './SubmitButton';
6+
7+
describe('SubmitButton', () => {
8+
it('renders both labels so width is stable, exposes only the active one, and disables while pending', () => {
9+
const { rerender } = render(<SubmitButton pendingLabel="Sending…">Send to Brian</SubmitButton>);
10+
const button = screen.getByRole('button', { name: 'Send to Brian' }) as HTMLButtonElement;
11+
expect(button.type).toBe('submit');
12+
expect(button.disabled).toBe(false);
13+
expect(button.getAttribute('data-pending')).toBeNull();
14+
expect(button.getAttribute('data-submit')).toBe('');
15+
expect(button.getAttribute('data-ui')).toBe('button');
16+
expect(button.querySelector('[data-slot="pending"]')?.textContent).toBe('Sending…');
17+
18+
rerender(<SubmitButton pending pendingLabel="Sending…">Send to Brian</SubmitButton>);
19+
const pending = screen.getByRole('button', { name: 'Sending…' }) as HTMLButtonElement;
20+
expect(pending.disabled).toBe(true);
21+
expect(pending.getAttribute('data-pending')).toBe('');
22+
expect(pending.getAttribute('aria-busy')).toBe('true');
23+
});
24+
25+
it('forwards button props such as variant, size, and aria-describedby', () => {
26+
render(
27+
<SubmitButton pendingLabel="Sending…" variant="secondary" size="lg" aria-describedby="disc">
28+
Subscribe
29+
</SubmitButton>
30+
);
31+
const button = screen.getByRole('button', { name: 'Subscribe' });
32+
expect(button.getAttribute('data-variant')).toBe('secondary');
33+
expect(button.getAttribute('data-size')).toBe('lg');
34+
expect(button.getAttribute('aria-describedby')).toBe('disc');
35+
});
36+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ReactNode } from 'react';
2+
import { Button, type ButtonProps } from '../ui/Button';
3+
4+
type SubmitButtonProps = Omit<Extract<ButtonProps, { href?: undefined }>, 'type' | 'children'> & {
5+
children: ReactNode;
6+
pending?: boolean;
7+
pendingLabel: string;
8+
};
9+
10+
/**
11+
* Both labels render in the same grid cell (see forms.css) so the button
12+
* keeps its width when the label swaps. The inactive label is hidden from
13+
* layout by visibility and from assistive tech by aria-hidden.
14+
*/
15+
export function SubmitButton({ children, pending = false, pendingLabel, disabled, ...rest }: SubmitButtonProps) {
16+
return (
17+
<Button
18+
{...rest}
19+
type="submit"
20+
data-submit=""
21+
data-pending={pending ? '' : undefined}
22+
aria-busy={pending || undefined}
23+
disabled={pending || disabled}
24+
>
25+
<span data-slot="label" aria-hidden={pending || undefined}>{children}</span>
26+
<span data-slot="pending" aria-hidden={!pending || undefined}>{pendingLabel}</span>
27+
</Button>
28+
);
29+
}

0 commit comments

Comments
 (0)