Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5ce110c
docs(specs): lead forms system design — one kit, four surfaces
blove Sep 4, 2026
ead1ecb
docs(plans): lead forms system implementation plan
blove Sep 4, 2026
b7455f9
feat(website): add the lead-form stylesheet and its style contracts
blove Sep 4, 2026
4b6c47f
docs(plans): submit button uses a data-submit marker; error tints via…
blove Sep 4, 2026
d907c6a
fix(website): key submit rules off a marker attribute; derive error t…
blove Sep 4, 2026
798bf7a
feat(website): add the form Field primitive with wired accessibility
blove Sep 4, 2026
25907f1
feat(website): add TextInput, TextArea, and Select form controls
blove Sep 4, 2026
bb2561e
fix(website): merge caller aria-describedby with the field's; pin the…
blove Sep 4, 2026
1b26409
feat(website): add FormCard, SubmitButton, and FormStatus
blove Sep 4, 2026
e0e365f
feat(website): add form validators with fix-naming error copy
blove Sep 4, 2026
67aeb62
feat(website): add useGrowthForm, the shared lead-form submission hook
blove Sep 4, 2026
af58cf4
fix(website): style the stale form status; cover stale tone and submi…
blove Sep 4, 2026
96b639e
fix(website): rebuild the footer newsletter on the form kit; the inpu…
blove Sep 4, 2026
4a4d635
test(website): guard the footer newsletter input width end to end
blove Sep 4, 2026
7ba9f84
fix(website): useGrowthForm — in-flight guard, stable submit, 4xx cov…
blove Sep 4, 2026
5c24571
docs: lead forms spec/plan — footer disclosure placement, focus on in…
blove Sep 4, 2026
67195d8
fix(website): footer form — compact row heights, focus on invalid, su…
blove Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,13 @@ test('footer newsletter form posts to /api/newsletter and renders success state'

await page.goto('/');
const footer = page.locator('footer');
await footer.getByLabel('Email address').fill('reader@acme.com');
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(page.getByText("✓ You're subscribed!")).toBeVisible();
await expect(footer.getByRole('status')).toContainText('Subscribed.');
expect(payload).toMatchObject({
email: 'reader@acme.com',
policy_version: GROWTH_FORM_POLICY_VERSION,
Expand Down
1 change: 1 addition & 0 deletions apps/website/src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
@import "../styles/docs.css";
@import "../styles/landing.css";
@import "../styles/marketing.css";
@import "../styles/forms.css";
@import "../styles/pages.css";

/* Shared workspace components live outside this app's automatic content
Expand Down
48 changes: 48 additions & 0 deletions apps/website/src/components/form/Field.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// @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 <input data-testid="probe" id={ctx?.id} aria-describedby={ctx?.describedBy} aria-invalid={ctx?.invalid || undefined} />;
}

describe('Field', () => {
it('labels the control by id and marks optional fields', () => {
render(
<Field id="f-email" label="Work email" optional>
<Probe />
</Field>
);
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(
<Field id="f-email" label="Work email" help="We reply from a real inbox." error="Enter a full address, like jordan@acme.dev.">
<Probe />
</Field>
);
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(
<Field id="f-name" label="Name">
<Probe />
</Field>
);
expect(screen.getByTestId('probe').getAttribute('aria-describedby')).toBeNull();
expect(screen.getByTestId('probe').getAttribute('aria-invalid')).toBeNull();
});
});
41 changes: 41 additions & 0 deletions apps/website/src/components/form/Field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client';
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 (
<div data-ui="field">
<label data-ui="field-label" htmlFor={id}>
{label}
{optional ? <> <span data-ui="field-optional">(optional)</span></> : null}
</label>
<FieldContext.Provider value={{ id, describedBy, invalid: Boolean(error) }}>
{children}
</FieldContext.Provider>
{help ? (
<p data-ui="field-help" id={helpId}>
{help}
</p>
) : null}
{error ? (
<p data-ui="field-error" id={errorId}>
{error}
</p>
) : null}
</div>
);
}
16 changes: 16 additions & 0 deletions apps/website/src/components/form/FormCard.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// @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(<FormCard>body</FormCard>);
const card = container.querySelector('[data-ui="form-card"]');
expect(card?.textContent).toBe('body');
expect(card?.getAttribute('data-compact')).toBeNull();
rerender(<FormCard compact>body</FormCard>);
expect(container.querySelector('[data-ui="form-card"]')?.getAttribute('data-compact')).toBe('');
});
});
14 changes: 14 additions & 0 deletions apps/website/src/components/form/FormCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { HTMLAttributes, ReactNode } from 'react';

interface FormCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
compact?: boolean;
}

export function FormCard({ children, compact = false, ...rest }: FormCardProps) {
return (
<div data-ui="form-card" data-compact={compact ? '' : undefined} {...rest}>
{children}
</div>
);
}
33 changes: 33 additions & 0 deletions apps/website/src/components/form/FormStatus.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// @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(<FormStatus tone="success" title="Sent." detail="Expect a reply within one business day." />);
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 as an alert and renders an action', () => {
render(
<FormStatus tone="failure" title="That did not send." detail="Email brian@threadplane.ai instead.">
<a href="/whitepaper.pdf">Download the PDF directly</a>
</FormStatus>
);
const alert = screen.getByRole('alert');
expect(alert.getAttribute('data-tone')).toBe('failure');
expect(screen.getByRole('link', { name: 'Download the PDF directly' })).toBeTruthy();
});

it('announces the stale tone as an alert', () => {
render(<FormStatus tone="stale" title="This page is out of date." detail="Refresh to continue." />);
const alert = screen.getByRole('alert');
expect(alert.getAttribute('data-tone')).toBe('stale');
expect(alert.textContent).toContain('This page is out of date.');
});
});
29 changes: 29 additions & 0 deletions apps/website/src/components/form/FormStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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<Tone, string> = { success: '✓', failure: '!', stale: '↻' };

export function FormStatus({ tone, title, detail, children }: FormStatusProps) {
const role = tone === 'success' ? 'status' : 'alert';
return (
<div data-ui="form-status" data-tone={tone} role={role}>
<span data-ui="form-status-icon" aria-hidden="true">{ICON[tone]}</span>
<div data-ui="form-status-body">
<p>
<strong>{title}</strong>
{detail ? <> {detail}</> : null}
</p>
{children ? <div data-ui="form-status-action">{children}</div> : null}
</div>
</div>
);
}
36 changes: 36 additions & 0 deletions apps/website/src/components/form/SubmitButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @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(<SubmitButton pendingLabel="Sending…">Send to Brian</SubmitButton>);
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(<SubmitButton pending pendingLabel="Sending…">Send to Brian</SubmitButton>);
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');
});

it('forwards button props such as variant, size, and aria-describedby', () => {
render(
<SubmitButton pendingLabel="Sending…" variant="secondary" size="lg" aria-describedby="disc">
Subscribe
</SubmitButton>
);
const button = screen.getByRole('button', { name: 'Subscribe' });
expect(button.getAttribute('data-variant')).toBe('secondary');
expect(button.getAttribute('data-size')).toBe('lg');
expect(button.getAttribute('aria-describedby')).toBe('disc');
});
});
29 changes: 29 additions & 0 deletions apps/website/src/components/form/SubmitButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
import { Button, type ButtonProps } from '../ui/Button';

type SubmitButtonProps = Omit<Extract<ButtonProps, { href?: undefined }>, '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 (
<Button
{...rest}
type="submit"
data-submit=""
data-pending={pending ? '' : undefined}
aria-busy={pending || undefined}
disabled={pending || disabled}
>
<span data-slot="label" aria-hidden={pending || undefined}>{children}</span>
<span data-slot="pending" aria-hidden={!pending || undefined}>{pendingLabel}</span>
</Button>
);
}
76 changes: 76 additions & 0 deletions apps/website/src/components/form/controls.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @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(
<Field id="c-email" label="Work email" error="Enter a full address, like jordan@acme.dev.">
<TextInput type="email" autoComplete="email" />
</Field>
);
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(
<>
<Field id="c-msg" label="Message">
<TextArea rows={3} />
</Field>
<Field id="c-when" label="Timeline">
<Select defaultValue="">
<option value="" disabled>Select…</option>
<option value="this_quarter">This quarter</option>
</Select>
</Field>
</>
);
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(
<Field id="c-nl" label="Email">
<TextInput compact />
</Field>
);
expect(screen.getByLabelText('Email').getAttribute('data-compact')).toBe('');
});

it('works outside a Field when given an explicit id', () => {
render(<TextInput id="lone" aria-label="Lone" />);
expect(screen.getByLabelText('Lone').id).toBe('lone');
});

it('lets an explicit id win over the Field id while keeping the field description', () => {
render(
<Field id="c-email" label="Work email" help="We reply from a real inbox.">
<TextInput id="explicit" />
</Field>
);
const input = screen.getByRole('textbox');
expect(input.id).toBe('explicit');
expect(input.getAttribute('aria-describedby')).toBe('c-email-help');
});

it('merges a caller aria-describedby after the field ids', () => {
render(
<Field id="c-email" label="Work email" error="Enter a full address, like jordan@acme.dev.">
<TextInput aria-describedby="extra-note" />
</Field>
);
expect(screen.getByLabelText('Work email').getAttribute('aria-describedby')).toBe('c-email-error extra-note');
});
});
Loading
Loading