feat(web): marketing Home page and the product component library (ADR 0023)

Product UI components are built once at design size from the Figma file and reused by the marketing mockups and, later, the app. Text contrast tokens now meet WCAG AA in both themes, with a test. Pricing stays hidden until decided.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014SdXCbrN5PtGExkpvMaMHx
This commit is contained in:
Claude
2026-10-12 15:25:50 +00:00
parent 913d7a8832
commit 6947113027
110 changed files with 3135 additions and 37 deletions
+158
View File
@@ -0,0 +1,158 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ChangeEvent } from '../../src/product/ChangeEvent.tsx';
import { CheckEvent } from '../../src/product/CheckEvent.tsx';
import { Composer } from '../../src/product/Composer.tsx';
import { ConversationPanel } from '../../src/product/ConversationPanel.tsx';
import { Message } from '../../src/product/Message.tsx';
import { Milestone } from '../../src/product/Milestone.tsx';
import { StepGroup } from '../../src/product/StepGroup.tsx';
import { StepLine } from '../../src/product/StepLine.tsx';
import { Thought } from '../../src/product/Thought.tsx';
describe('Message', () => {
it('puts what the owner said in a soft box', () => {
render(<Message role="user">Make the phone number tappable.</Message>);
expect(screen.getByText('Make the phone number tappable.').closest('.p-message')).toHaveClass('p-message-user');
});
it('shows Wursor replies beside the Wursor avatar', () => {
render(<Message role="wursor">On it.</Message>);
expect(screen.getByRole('img', { name: 'Wursor' })).toBeInTheDocument();
expect(screen.getByText('On it.')).toBeInTheDocument();
});
});
describe('StepGroup', () => {
it('is collapsed by default and opens on press', async () => {
render(<StepGroup title="Looked at 4 pages" count={4}>Home, Menu, Contact, About</StepGroup>);
const toggle = screen.getByRole('button', { name: /Looked at 4 pages/ });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('Home, Menu, Contact, About')).not.toBeInTheDocument();
await userEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Home, Menu, Contact, About')).toBeInTheDocument();
});
it('shows the count', () => {
render(<StepGroup title="Looked at 4 pages" count={4} />);
expect(screen.getByText('4')).toHaveClass('p-counter');
});
});
describe('Thought', () => {
it('is collapsed by default, with the reasoning hidden', () => {
render(<Thought seconds={6}>The number is plain text.</Thought>);
expect(screen.getByRole('button', { name: 'Thought for 6 seconds' })).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('The number is plain text.')).not.toBeInTheDocument();
});
it('shows the reasoning in plain words when expanded', async () => {
render(<Thought seconds={6}>The number is plain text.</Thought>);
await userEvent.click(screen.getByRole('button', { name: 'Thought for 6 seconds' }));
expect(screen.getByText('The number is plain text.')).toBeInTheDocument();
});
it('can start expanded', () => {
render(<Thought seconds={6} defaultExpanded>The number is plain text.</Thought>);
expect(screen.getByText('The number is plain text.')).toBeInTheDocument();
});
});
describe('StepLine', () => {
it('shows what Wursor did and how long it took', () => {
render(<StepLine kind="edit" time="4s">Made the Contact page number a tap-to-call link</StepLine>);
expect(screen.getByText('Made the Contact page number a tap-to-call link')).toBeInTheDocument();
expect(screen.getByText('4s')).toBeInTheDocument();
});
it('marks a running step so it is announced', () => {
render(<StepLine kind="running" time="now">Taking a photo of the page on a phone</StepLine>);
expect(screen.getByRole('status', { name: 'In progress' })).toBeInTheDocument();
});
});
describe('ChangeEvent', () => {
const props = {
title: 'Changed the Contact page',
meta: '1 section · 2 places',
risk: 'everyday',
added: 8,
removed: 2,
} as const;
it('shows the change, its risk and how much changed', () => {
render(<ChangeEvent {...props} />);
expect(screen.getByText('Changed the Contact page')).toBeInTheDocument();
expect(screen.getByText('Everyday')).toBeInTheDocument();
expect(screen.getByText('+8 lines')).toBeInTheDocument();
expect(screen.getByText('−2 lines')).toBeInTheDocument();
});
it('offers the full comparison', async () => {
const onSeeChanges = vi.fn();
render(<ChangeEvent {...props} onSeeChanges={onSeeChanges} />);
await userEvent.click(screen.getByRole('button', { name: 'See what changed' }));
expect(onSeeChanges).toHaveBeenCalledOnce();
});
});
describe('CheckEvent', () => {
it('reports a passed check quietly', () => {
render(<CheckEvent result="passed" title="Checked 9 things. All passed." detail="Phone link works, 3 screen sizes." />);
expect(screen.getByText('Checked 9 things. All passed.')).toBeInTheDocument();
expect(screen.getByText('Phone link works, 3 screen sizes.')).toBeInTheDocument();
});
});
describe('Milestone', () => {
it('marks a stage in the life of a change', () => {
render(<Milestone>Plan ready</Milestone>);
expect(screen.getByRole('separator', { name: 'Plan ready' })).toBeInTheDocument();
});
});
describe('Composer', () => {
it('invites a note while Wursor is working, and offers to stop', async () => {
const onStop = vi.fn();
render(<Composer state="working" autonomy="Ask first" onStop={onStop} />);
expect(screen.getByPlaceholderText('Wursor is working. Add a note, or stop.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Stop' }));
expect(onStop).toHaveBeenCalledOnce();
});
it('shows the autonomy level and the toolbar actions', () => {
render(<Composer state="working" autonomy="Ask first" />);
expect(screen.getByRole('button', { name: 'Ask first' })).toBeInTheDocument();
for (const name of ['Attach a file', 'Point at something on the page', 'Dictate']) {
expect(screen.getByRole('button', { name })).toBeInTheDocument();
}
});
it('lets the owner keep typing while it works', async () => {
render(<Composer state="working" autonomy="Ask first" />);
await userEvent.type(screen.getByRole('textbox', { name: 'Message to Wursor' }), 'Also the header');
expect(screen.getByRole('textbox', { name: 'Message to Wursor' })).toHaveValue('Also the header');
});
});
describe('ConversationPanel', () => {
it('has a header with the three detail levels, and a stream', () => {
render(
<ConversationPanel density="steps" onDensityChange={() => {}}>
<Message role="user">Hello</Message>
</ConversationPanel>,
);
expect(screen.getByRole('heading', { name: 'Conversation' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Steps' })).toBeChecked();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('reports a change of detail level', async () => {
const onDensityChange = vi.fn();
render(<ConversationPanel density="steps" onDensityChange={onDensityChange} />);
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
expect(onDensityChange).toHaveBeenCalledWith('everything');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Avatar } from '../../src/product/Avatar.tsx';
import { Button } from '../../src/product/Button.tsx';
import { Counter } from '../../src/product/Counter.tsx';
import { IconButton } from '../../src/product/IconButton.tsx';
import { RiskChip } from '../../src/product/RiskChip.tsx';
import { SegmentedControl } from '../../src/product/SegmentedControl.tsx';
import { StatusPill } from '../../src/product/StatusPill.tsx';
import { Tab } from '../../src/product/Tab.tsx';
import { Tag } from '../../src/product/Tag.tsx';
describe('Avatar', () => {
it('shows initials for a person', () => {
render(<Avatar kind="initials" initials="AO" name="Amara Okoye" />);
expect(screen.getByRole('img', { name: 'Amara Okoye' })).toHaveTextContent('AO');
});
it('shows the brand mark for Wursor', () => {
render(<Avatar kind="wursor" />);
expect(screen.getByRole('img', { name: 'Wursor' })).toHaveClass('p-avatar-wursor');
});
it('has a small and a medium size', () => {
render(<Avatar kind="initials" initials="AO" name="A" size="md" />);
expect(screen.getByRole('img', { name: 'A' })).toHaveClass('p-avatar-md');
});
});
describe('Counter', () => {
it('shows the count', () => {
render(<Counter count={3} />);
expect(screen.getByText('3')).toHaveClass('p-counter');
});
});
describe('StatusPill', () => {
it.each([
['idea', 'Idea'],
['working', 'Working'],
['needs-you', 'Needs you'],
['done', 'Done'],
] as const)('labels the %s status in plain words', (status, label) => {
render(<StatusPill status={status} />);
expect(screen.getByText(label).closest('.p-status')).toHaveAttribute('data-status', status);
});
});
describe('RiskChip', () => {
it('shows the plain-word risk level', () => {
render(<RiskChip level="everyday" />);
expect(screen.getByText('Everyday')).toBeInTheDocument();
});
});
describe('IconButton', () => {
it('is named by its label and calls onClick', async () => {
const onClick = vi.fn();
render(<IconButton icon="search" label="Search" onClick={onClick} />);
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
expect(onClick).toHaveBeenCalledOnce();
});
it('supports the inverse variant', () => {
render(<IconButton icon="stop" label="Stop" variant="inverse" />);
expect(screen.getByRole('button', { name: 'Stop' })).toHaveClass('p-iconbtn-inverse');
});
});
describe('Button', () => {
it('renders its label and calls onClick', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>See what changed</Button>);
await userEvent.click(screen.getByRole('button', { name: 'See what changed' }));
expect(onClick).toHaveBeenCalledOnce();
});
it('defaults to secondary and small, and supports primary and ghost', () => {
const { rerender } = render(<Button>A</Button>);
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-secondary', 'p-btn-sm');
rerender(<Button variant="primary" size="md">A</Button>);
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-primary', 'p-btn-md');
rerender(<Button variant="ghost">A</Button>);
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-ghost');
});
});
describe('SegmentedControl', () => {
const options = [
{ value: 'headline', label: 'Headline' },
{ value: 'steps', label: 'Steps' },
{ value: 'everything', label: 'Everything' },
];
it('is a radio group with the current choice checked', () => {
render(<SegmentedControl label="Detail" options={options} value="steps" onChange={() => {}} />);
expect(screen.getByRole('radiogroup', { name: 'Detail' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Steps' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Headline' })).not.toBeChecked();
});
it('reports the chosen value', async () => {
const onChange = vi.fn();
render(<SegmentedControl label="Detail" options={options} value="steps" onChange={onChange} />);
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
expect(onChange).toHaveBeenCalledWith('everything');
});
});
describe('Tab', () => {
it('marks the selected tab', () => {
render(
<div role="tablist">
<Tab label="Preview" selected />
<Tab label="Compare" />
</div>,
);
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Compare' })).toHaveAttribute('aria-selected', 'false');
});
it('can show a count', () => {
render(<Tab label="Proof" count={3} />);
expect(screen.getByRole('tab', { name: /Proof/ })).toHaveTextContent('3');
});
});
describe('Tag', () => {
it('shows its label', () => {
render(<Tag label="Draft copy" />);
expect(screen.getByText('Draft copy')).toBeInTheDocument();
});
it('can be removed when closable', async () => {
const onClose = vi.fn();
render(<Tag label="Draft copy" closable onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: 'Remove Draft copy' }));
expect(onClose).toHaveBeenCalledOnce();
});
});
+166
View File
@@ -0,0 +1,166 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ApprovalBar } from '../../src/product/ApprovalBar.tsx';
import { BoardCard } from '../../src/product/BoardCard.tsx';
import { BoardStrip } from '../../src/product/BoardStrip.tsx';
import { CompareSlider } from '../../src/product/CompareSlider.tsx';
import { DeviceFrame } from '../../src/product/DeviceFrame.tsx';
import { PreviewToolbar } from '../../src/product/PreviewToolbar.tsx';
import { Sidebar } from '../../src/product/Sidebar.tsx';
import { SiteMock } from '../../src/product/SiteMock.tsx';
import { TopBar } from '../../src/product/TopBar.tsx';
import { Workspace } from '../../src/product/Workspace.tsx';
import { HARBOR } from '../../src/product/harbor-bakery.ts';
describe('TopBar', () => {
it('shows the site, the current change and its status', () => {
render(<TopBar site="Harbor Bakery" siteInitial="H" change="Make phone number tappable" status="working" autonomy="Ask first" account={{ initials: 'AO', name: 'Amara Okoye' }} />);
expect(screen.getByRole('button', { name: /Harbor Bakery/ })).toBeInTheDocument();
expect(screen.getByText('Make phone number tappable')).toBeInTheDocument();
expect(screen.getByText('Working')).toBeInTheDocument();
for (const name of ['Toggle sidebar', 'Search', 'Notifications']) {
expect(screen.getByRole('button', { name })).toBeInTheDocument();
}
});
});
describe('Sidebar', () => {
const changes = [
{ title: 'Make phone number tappable', status: 'working' as const, active: true },
{ title: 'Update opening hours', status: 'needs-you' as const },
{ title: 'Describe 3 images', status: 'idea' as const },
{ title: 'Refresh footer links', status: 'done' as const },
];
it('has the four places and the list of changes', () => {
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
const nav = screen.getByRole('navigation', { name: 'Workspace' });
expect(within(nav).getAllByRole('link').map((a) => a.textContent)).toEqual(['Home', 'Board', 'Activity', 'Sites']);
expect(screen.getByRole('list', { name: 'Changes' })).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(4);
});
it('marks the open change', () => {
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
expect(screen.getByText('Make phone number tappable').closest('[aria-current]')).toHaveAttribute('aria-current', 'true');
});
it('shows the account', () => {
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
expect(screen.getByText('Amara Okoye')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
});
});
describe('PreviewToolbar', () => {
it('has Preview, Compare and Proof tabs with the draft tag and device buttons', () => {
render(<PreviewToolbar selected="preview" tag="Draft copy" />);
expect(screen.getByRole('tablist', { name: 'View' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Proof' })).toBeInTheDocument();
expect(screen.getByText('Draft copy')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Phone' })).toBeInTheDocument();
});
});
describe('SiteMock and DeviceFrame', () => {
it('shows the Harbor Bakery contact page', () => {
render(<SiteMock state="after" />);
expect(screen.getByText('Find us on Quay Street')).toBeInTheDocument();
expect(screen.getByText('Fresh bread from 6:30, every morning.')).toBeInTheDocument();
});
it('before the change the number is plain text, after it is a tap-to-call link', () => {
const { rerender } = render(<SiteMock state="before" />);
expect(screen.queryByRole('link', { name: /Call \(555\) 014-2290/ })).not.toBeInTheDocument();
rerender(<SiteMock state="after" />);
expect(screen.getByRole('link', { name: /Call \(555\) 014-2290/ })).toHaveAttribute('href', 'tel:+55550142290');
});
it('frames the page in a browser bar with its address', () => {
render(<DeviceFrame url="harborbakery.com/contact"><SiteMock state="after" /></DeviceFrame>);
expect(screen.getByText('harborbakery.com/contact')).toBeInTheDocument();
});
});
describe('BoardCard and BoardStrip', () => {
it('asks for the owner when a change needs them', () => {
render(<BoardCard status="needs-you" title="Update opening hours" meta="Home page · 1 hour ago" />);
expect(screen.getByText('Wursor is waiting for your answer')).toBeInTheDocument();
expect(screen.getByText('Needs you')).toBeInTheDocument();
});
it('shows risk and progress on a working change', () => {
render(<BoardCard status="working" title="Make phone number tappable" meta="Contact page · 1 min ago" risk="everyday" progress={0.62} />);
expect(screen.getByText('Everyday')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Make phone number tappable' })).toHaveAttribute('aria-valuenow', '62');
});
it('groups cards into the four lanes with counts', () => {
render(<BoardStrip lanes={HARBOR.board} />);
for (const lane of ['Needs you', 'Working', 'Idea', 'Done']) {
expect(screen.getByRole('group', { name: lane })).toBeInTheDocument();
}
expect(screen.getByText('Open board')).toBeInTheDocument();
});
});
describe('CompareSlider', () => {
it('is a slider the keyboard can move, labelled in plain words', async () => {
render(<CompareSlider before={<span>old</span>} after={<span>new</span>} defaultPosition={50} />);
const slider = screen.getByRole('slider', { name: 'Compare before and after' });
expect(slider).toHaveAttribute('aria-valuenow', '50');
slider.focus();
await userEvent.keyboard('{ArrowRight}');
expect(slider).toHaveAttribute('aria-valuenow', '55');
await userEvent.keyboard('{ArrowLeft}{ArrowLeft}');
expect(slider).toHaveAttribute('aria-valuenow', '45');
});
it('labels both sides', () => {
render(<CompareSlider before={<span>old</span>} after={<span>new</span>} />);
expect(screen.getByText('Before')).toBeInTheDocument();
expect(screen.getByText('After')).toBeInTheDocument();
});
});
describe('ApprovalBar', () => {
it('says what is ready and offers one clear action', async () => {
const onApprove = vi.fn();
render(<ApprovalBar title="Ready for you" meta="2 changes · Everyday risk" onApprove={onApprove} />);
expect(screen.getByText('Ready for you')).toBeInTheDocument();
expect(screen.getByText('2 changes · Everyday risk')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Approve and publish' }));
expect(onApprove).toHaveBeenCalledOnce();
});
});
describe('Workspace', () => {
it('assembles the top bar, sidebar, conversation, preview and board', () => {
render(<Workspace defaultDensity="steps" />);
expect(screen.getByRole('banner')).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: 'Workspace' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Conversation' })).toBeInTheDocument();
expect(screen.getByRole('tablist', { name: 'View' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Board' })).toBeInTheDocument();
});
it('changes what it shows when the owner picks another detail level', async () => {
render(<Workspace defaultDensity="steps" />);
expect(screen.queryByText('Looked at home page')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
expect(screen.getByText('Looked at home page')).toBeInTheDocument();
});
it('shows less at the headline level and more at everything', () => {
const { unmount } = render(<Workspace defaultDensity="headline" />);
expect(screen.getByRole('separator', { name: 'Plan ready' })).toBeInTheDocument();
expect(screen.queryByText('Looked at home page')).not.toBeInTheDocument();
unmount();
render(<Workspace defaultDensity="everything" />);
expect(screen.getByText('Looked at home page')).toBeInTheDocument();
});
});