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
+15 -2
View File
@@ -13,8 +13,21 @@ describe('Button', () => {
expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn', 'btn-primary', 'btn-md');
});
it('supports the ghost variant and the large size', () => {
render(<Button href="/product" variant="ghost" size="lg">See how it works</Button>);
it('supports the ghost and secondary variants and the large size', () => {
const { rerender } = render(<Button href="/product" variant="ghost" size="lg">See how it works</Button>);
expect(screen.getByRole('link', { name: 'See how it works' })).toHaveClass('btn-ghost', 'btn-lg');
rerender(<Button href="/trust" variant="secondary">How approval works</Button>);
expect(screen.getByRole('link', { name: 'How approval works' })).toHaveClass('btn-secondary');
});
it('supports a light variant for use on the dark panel', () => {
render(<Button href="/signup" variant="light" size="lg">Get started</Button>);
expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn-light');
});
it('can end with an arrow that is not announced', () => {
render(<Button href="/how-it-works" variant="ghost" size="lg" trailingIcon="arrow-right">See how it works</Button>);
const link = screen.getByRole('link', { name: 'See how it works' });
expect(link.querySelector('.icon')).toHaveAttribute('aria-hidden', 'true');
});
});
+213
View File
@@ -0,0 +1,213 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { ApproveWithProof } from '../../src/marketing/home/ApproveWithProof.tsx';
import { ClosingCta } from '../../src/marketing/home/ClosingCta.tsx';
import { Examples } from '../../src/marketing/home/Examples.tsx';
import { Faq } from '../../src/marketing/home/Faq.tsx';
import { Hero } from '../../src/marketing/home/Hero.tsx';
import { PricingTeaser } from '../../src/marketing/home/PricingTeaser.tsx';
import { ProofStrip } from '../../src/marketing/home/ProofStrip.tsx';
import { SafeByDesign } from '../../src/marketing/home/SafeByDesign.tsx';
import { SectionHeader } from '../../src/marketing/home/SectionHeader.tsx';
import { SurfacesShowcase } from '../../src/marketing/home/SurfacesShowcase.tsx';
import { WatchTheWork } from '../../src/marketing/home/WatchTheWork.tsx';
import HomePage from '../../app/(marketing)/page.tsx';
import { PRICING_CONFIRMED } from '../../src/marketing/content.ts';
describe('SectionHeader', () => {
it('has an eyebrow, a heading and a supporting sentence', () => {
render(<SectionHeader eyebrow="One screen" title="Talk. Watch. Decide." subtitle="All in one place." />);
expect(screen.getByRole('heading', { level: 2, name: 'Talk. Watch. Decide.' })).toBeInTheDocument();
expect(screen.getByText('One screen')).toBeInTheDocument();
expect(screen.getByText('All in one place.')).toBeInTheDocument();
});
});
describe('Hero', () => {
it('leads with the promise as the page heading', () => {
render(<Hero />);
expect(screen.getByRole('heading', { level: 1, name: 'Your website, handled.' })).toBeInTheDocument();
expect(screen.getByText('Tell Wursor what to change. Watch it work. Approve what you like.')).toBeInTheDocument();
});
it('sends Get started to sign-up and See how it works to the how-it-works page (PX-13)', () => {
render(<Hero />);
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
expect(screen.getByRole('link', { name: 'See how it works' })).toHaveAttribute('href', '/how-it-works');
});
it('announces the approval feature, linking to how approval works', () => {
render(<Hero />);
expect(screen.getByRole('link', { name: /Approve with proof: nine checks on every change/ })).toHaveAttribute('href', '/trust');
});
it('says who it is for', () => {
render(<Hero />);
expect(screen.getByText('For WordPress sites · We host it, or you do')).toBeInTheDocument();
});
it('shows the product as a picture, hidden from assistive technology and not focusable', () => {
const { container } = render(<Hero />);
const visual = container.querySelector('.m-hero-visual [aria-hidden="true"]');
expect(visual).not.toBeNull();
expect(visual).toHaveAttribute('inert');
});
});
describe('ProofStrip', () => {
it('shows the three proof points', () => {
render(<ProofStrip />);
expect(screen.getByText('9')).toBeInTheDocument();
expect(screen.getByText('checks before anything goes live')).toBeInTheDocument();
expect(screen.getByText('1 click')).toBeInTheDocument();
expect(screen.getByText('to undo any change, any time')).toBeInTheDocument();
expect(screen.getByText('Every step')).toBeInTheDocument();
expect(screen.getByText('on the record, in plain words')).toBeInTheDocument();
});
});
describe('SurfacesShowcase', () => {
it('starts on the Conversation tab with its caption', () => {
render(<SurfacesShowcase />);
expect(screen.getByRole('tab', { name: 'Conversation' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('Conversation: ask in plain words. Wursor explains each step as it goes.')).toBeInTheDocument();
});
it('switches to the Preview and Board views and their captions', async () => {
render(<SurfacesShowcase />);
await userEvent.click(screen.getByRole('tab', { name: 'Preview' }));
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('Preview: see the real page on a phone, tablet and laptop before anything goes live.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('tab', { name: 'Board' }));
expect(screen.getByText('Board: every change in one place, from idea to published.')).toBeInTheDocument();
});
it('moves between tabs with the arrow keys', async () => {
render(<SurfacesShowcase />);
screen.getByRole('tab', { name: 'Conversation' }).focus();
await userEvent.keyboard('{ArrowRight}');
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
});
});
describe('WatchTheWork', () => {
it('shows the same change at three levels of detail', () => {
render(<WatchTheWork />);
expect(screen.getByRole('heading', { level: 2, name: /Watch the work, your way\./ })).toBeInTheDocument();
// The design words the heading more briefly on a phone; both are in the page and CSS shows one.
expect(screen.getByText('Watch every step.')).toHaveClass('m-m');
for (const [name, text] of [
['Headline', 'Only what changed and what needs you.'],
['Steps', 'Each step, in a sentence. The default.'],
['Everything', 'Every look, edit, check and decision.'],
] as const) {
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument();
expect(screen.getByText(text)).toBeInTheDocument();
}
});
});
describe('ApproveWithProof', () => {
it('lists what the owner gets and links to how approval works', () => {
render(<ApproveWithProof />);
expect(screen.getByRole('heading', { level: 2, name: 'See it before it’s real.' })).toBeInTheDocument();
expect(screen.getByText('Nine checks, written in plain words')).toBeInTheDocument();
expect(screen.getByText('Looks right on a phone, a tablet and a laptop')).toBeInTheDocument();
expect(screen.getByText('One click to approve and publish, one click to undo')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'How approval works' })).toHaveAttribute('href', '/trust');
});
});
describe('SafeByDesign', () => {
it('states the four rules', () => {
render(<SafeByDesign />);
expect(screen.getByRole('heading', { level: 2, name: 'Nothing goes live until you say so.' })).toBeInTheDocument();
for (const rule of ['Works on a safe copy', 'Nothing goes live without you', 'Everything is recorded', 'Undo in one click']) {
expect(screen.getByRole('heading', { level: 3, name: rule })).toBeInTheDocument();
}
});
});
describe('Examples', () => {
it('shows five small jobs, each with a before and after', () => {
render(<Examples />);
const list = screen.getByRole('list', { name: 'Examples' });
const items = within(list).getAllByRole('listitem');
expect(items).toHaveLength(5);
expect(within(items[0]!).getByText('Update the holiday hours')).toBeInTheDocument();
expect(within(items[0]!).getByText('Page · 2 min')).toBeInTheDocument();
expect(within(items[0]!).getByText('Before')).toBeInTheDocument();
expect(within(items[0]!).getByText('After')).toBeInTheDocument();
});
});
describe('PricingTeaser', () => {
it('does not show prices or allowances until they are decided (PRD PX-13, D-HL-8)', () => {
expect(PRICING_CONFIRMED).toBe(false);
render(<PricingTeaser />);
expect(screen.getByRole('heading', { level: 2, name: 'Simple plans. No surprises.' })).toBeInTheDocument();
expect(screen.queryByText(/\$\d/)).not.toBeInTheDocument();
expect(screen.queryByText(/free for 14 days/i)).not.toBeInTheDocument();
expect(screen.queryByText(/sites ·/)).not.toBeInTheDocument();
expect(screen.getByText('Plans and prices will be published before launch.')).toBeInTheDocument();
});
it('still leads to sign-up and the pricing page', () => {
render(<PricingTeaser />);
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
expect(screen.getByRole('link', { name: 'See pricing' })).toHaveAttribute('href', '/pricing');
});
it('shows the plan cards when pricing is confirmed', () => {
render(<PricingTeaser confirmed />);
for (const plan of ['Starter', 'Studio', 'Agency']) {
expect(screen.getByRole('heading', { level: 3, name: plan })).toBeInTheDocument();
}
expect(screen.getAllByRole('link', { name: 'Choose plan' })).toHaveLength(3);
});
});
describe('Faq', () => {
it('lists the questions, with the first answered and open', () => {
render(<Faq />);
expect(screen.getByRole('heading', { level: 2, name: 'Questions, answered.' })).toBeInTheDocument();
const first = screen.getByText('Will Wursor change my live site without asking?').closest('details');
expect(first).toHaveAttribute('open');
expect(screen.getByText(/Nothing goes live until you review the preview and approve it\./)).toBeInTheDocument();
for (const q of ['Do I need my own hosting?', 'Can I undo a change?', 'Does it work with my theme and plugins?', 'How is my site kept safe?']) {
expect(screen.getByText(q).closest('details')).not.toHaveAttribute('open');
}
});
it('opens an item to show its answer', async () => {
render(<Faq />);
await userEvent.click(screen.getByText('Do I need my own hosting?'));
expect(screen.getByText('Do I need my own hosting?').closest('details')).toHaveAttribute('open');
});
});
describe('ClosingCta', () => {
it('ends with one clear action', () => {
render(<ClosingCta />);
expect(screen.getByRole('heading', { level: 2, name: 'Hand off the busywork. Keep the final say.' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
});
});
describe('HomePage', () => {
it('has one page heading, a nav and a footer', () => {
render(<HomePage />);
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument();
expect(screen.getByRole('contentinfo')).toBeInTheDocument();
expect(screen.getByRole('main')).toBeInTheDocument();
});
it('routes every call to action to sign-up or sign-in (PX-13)', () => {
render(<HomePage />);
const ctas = [...screen.getAllByRole('link', { name: /^(Get started|Sign in)$/ })];
expect(ctas.length).toBeGreaterThanOrEqual(3);
for (const a of ctas) expect(['/signup', '/signin']).toContain(a.getAttribute('href'));
});
});
+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();
});
});
+43 -1
View File
@@ -26,7 +26,7 @@ const COLOR_TOKENS = [
'--color-text-primary', '--color-text-secondary', '--color-text-tertiary', '--color-text-inverse',
'--color-text-on-accent', '--color-text-link',
'--color-border-subtle', '--color-border-default', '--color-border-strong',
'--color-accent-default', '--color-accent-text', '--color-accent-subtle',
'--color-accent-default', '--color-accent-fill', '--color-accent-text', '--color-accent-subtle',
'--color-status-working-dot', '--color-status-working-fg', '--color-status-working-bg',
'--color-status-needs-you-dot', '--color-status-needs-you-fg', '--color-status-needs-you-bg',
'--color-status-success-dot', '--color-status-success-fg', '--color-status-success-bg',
@@ -79,3 +79,45 @@ describe('design tokens', () => {
expect(css).toMatch(/\.t-display-xl\s*\{[^}]*letter-spacing:\s*-0\.03em/);
});
});
/** WCAG 2.x relative luminance and contrast ratio. */
function luminance(hex: string): number {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as [number, number, number];
const f = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
return (hi + 0.05) / (lo + 0.05);
}
/** Text token on the background token it appears on. All are small text, so AA is 4.5:1 (PX-10). */
const PAIRS: ReadonlyArray<[string, string]> = [
['--color-text-primary', '--color-bg-canvas'], ['--color-text-primary', '--color-bg-surface'],
['--color-text-primary', '--color-bg-sunken'], ['--color-text-primary', '--color-accent-subtle'],
['--color-text-secondary', '--color-bg-canvas'], ['--color-text-secondary', '--color-bg-surface'],
['--color-text-secondary', '--color-bg-sunken'], ['--color-text-secondary', '--color-accent-subtle'],
['--color-text-tertiary', '--color-bg-canvas'], ['--color-text-tertiary', '--color-bg-surface'],
['--color-text-tertiary', '--color-bg-sunken'],
['--color-text-inverse', '--color-bg-inverse'],
['--color-text-on-accent', '--color-accent-fill'],
['--color-accent-text', '--color-bg-canvas'], ['--color-accent-text', '--color-bg-surface'],
['--color-accent-text', '--color-accent-subtle'],
['--color-text-link', '--color-bg-canvas'], ['--color-text-link', '--color-bg-surface'],
['--color-text-link', '--color-bg-sunken'],
['--color-status-working-fg', '--color-status-working-bg'],
['--color-status-needs-you-fg', '--color-status-needs-you-bg'],
['--color-status-success-fg', '--color-status-success-bg'],
['--color-diff-add-fg', '--color-bg-surface'], ['--color-diff-remove-fg', '--color-bg-surface'],
];
describe('contrast (WCAG 2.2 AA, PX-10)', () => {
for (const [theme, tokens] of [['light', light], ['dark', dark]] as const) {
it(`every text pair is at least 4.5:1 in the ${theme} theme`, () => {
const failures = PAIRS.map(([fg, bg]) => ({ fg, bg, ratio: contrast(tokens[fg]!, tokens[bg]!) }))
.filter((p) => p.ratio < 4.5)
.map((p) => `${p.fg} on ${p.bg} = ${p.ratio.toFixed(2)}`);
expect(failures).toEqual([]);
});
}
});