diff --git a/AGENTS.md b/AGENTS.md
index faa96fd..949ccd2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@ Non-technical WordPress site owners describe what they want; Wursor makes it hap
```
api/ Node.js + TypeScript API server (empty until Phase 0 gate)
-web/ Next.js (App Router) + TypeScript frontend (ADR 0018)
+web/ Next.js (App Router) + TypeScript frontend (ADR 0018): app/ routes, src/product (app UI components), src/marketing, src/ui, src/styles (tokens)
plugin/ WordPress plugin (PHP) — empty until Phase 0 gate
infrastructure/ Docker images, warm pool, GC, deploy scripts
e2e/ Playwright + e2e/golden/ harness
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7464e71..27fc598 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ The project is pre-1.0. Versions below `1.0.0` are internal milestones, not publ
### Added
-- ADRs 0018 to 0022 (0022: hosting on DigitalOcean): Next.js web app (partly supersedes ADR 0011), sign-in by code, link and passkeys, Cloud provisioning on WeOwn Cloud, and keeping the "never sees a diff" rule wording. PRD, IMPLEMENTATION and AGENTS.md updated to match.
+- Web: Next.js app with design tokens (light and dark, AA contrast), the marketing Home page (desktop and phone), and a product component library built at design size (conversation, workspace, board, compare and approval) that the marketing mockups use and the app will reuse (ADR 0023). ADRs 0018 to 0022 (0022: hosting on DigitalOcean): Next.js web app (partly supersedes ADR 0011), sign-in by code, link and passkeys, Cloud provisioning on WeOwn Cloud, and keeping the "never sees a diff" rule wording. PRD, IMPLEMENTATION and AGENTS.md updated to match.
- `docs/prd-assets/headless-architecture.png`, the architecture diagram used in the PRD.
- Design reference: Figma file "Wursor - Experiment" (Design system, Product Design, Marketing site and Brand Guideline pages).
- **Monorepo workspace** — `pnpm` workspace (`api`, `web`, `e2e`), root `package.json` / `pnpm-workspace.yaml` / `tsconfig.base.json`, and a PHP `plugin` package (`composer.json`). Empty packages by design (see [ADR 8](docs/decisions/0008-empty-packages-not-stubs.md)).
diff --git a/docs/decisions/0023-product-components-and-contrast.md b/docs/decisions/0023-product-components-and-contrast.md
new file mode 100644
index 0000000..925d179
--- /dev/null
+++ b/docs/decisions/0023-product-components-and-contrast.md
@@ -0,0 +1,35 @@
+# 23. Product UI is built once at design size and reused; text contrast tokens meet AA
+
+- **Status:** Accepted
+- **Date:** 2026-10-12
+
+## Context
+
+The marketing site shows the product (conversation, preview, board, approval) in four large mockups. The same screens become the app (PRD sections 15 and 26, PX-11: one component library, no one-off controls). Figma's generated code is absolutely positioned and uses Tailwind classes, and the mockups are drawn at several scales (the hero at 0.76, the Surfaces windows at 1.27, 1.08 and 1.39). Separately, two pairs of Figma tokens fail WCAG AA for small text, which PX-10 requires: secondary and tertiary text, and white text on the accent orange.
+
+## Decision
+
+1. **Build each product component once, at its design size (1x),** in `web/src/product/`, with plain CSS in `src/styles/product.css` that uses only the design tokens. No Tailwind. Marketing scales whole components with `Fit` (follows the container width) or `Zoom` (fixed ratio), in `src/ui/`. The app uses them unscaled.
+2. **Icons** are the exact Figma SVG exports in `public/icons`, drawn with a CSS mask so their colour comes from the theme token (`src/ui/Icon.tsx`). The brand mark and the Wursor avatar need separate light and dark files because they have two colours.
+3. **Sample content** is one fictional site, Harbor Bakery (`harbor-bakery.ts`), as in the design. Real previews replace it in the app.
+4. **Contrast.** `--color-text-secondary` and `--color-text-tertiary` are darker in light and lighter in dark than the Figma values, so every text pair is at least 4.5:1 (a test checks 24 pairs per theme). Chips and counters that carry text on the accent use a new `--color-accent-fill` (`#c24a08` in light) instead of `--color-accent-default` (`#e8590c`, white on it is 3.6:1). The dark link blue is lightened. The design file should adopt these values.
+5. **Claims.** Plan names, prices and allowances stay out of the marketing site until decided (PRD D-HL-8, PX-13): `PRICING_CONFIRMED` is false and the pricing section says so. FAQ answers other than the first are drafted from the PRD and flagged in `content.ts` for review.
+
+### Options considered
+
+- **One component set at 1x, scaled where needed (chosen).** One source of truth for the marketing mockups and the app.
+- **Convert Figma's generated code per mockup.** Fast, but each mockup becomes its own fixed-pixel copy, and the app would need a second build of every screen.
+- **Export the mockups as images.** Smallest, and not reusable. Rejected because the components are needed for the app.
+- **Keep Figma's contrast values.** Faithful to the file, but fails PX-10.
+
+### Rejected
+
+- Tailwind: not in the stack, and the Figma output depends on it only as a convenience.
+- Recolouring icons by editing the SVG files: they must stay exactly as exported.
+
+## Consequences
+
+- The product components are a library for the app screens in `IMPLEMENTATION.md` step H, and need tests as they grow (about 90 exist).
+- Only the states that are designed exist so far. Not built: Composer states other than working, risk levels other than Everyday, check results other than passed, approval states other than ready, the phone device frame. The components say so where it applies.
+- `Fit` measures the container in the browser, so the mockups reflow once after load.
+- The design file should adopt the contrast values, and the dark link blue, so design and code agree.
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 86c035a..e7d7f98 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -36,6 +36,7 @@ Each ADR is a single file following the [Nygard format](https://cognitect.com/bl
| [0020](0020-weown-cloud-provisioning.md) | Cloud sites are provisioned on WeOwn Cloud | Accepted |
| [0021](0021-keep-no-diff-rule-wording.md) | Keep the "never sees a diff" rule in AGENTS.md as written | Accepted |
| [0022](0022-digitalocean-hosting.md) | Wursor and WeOwn Cloud run on DigitalOcean | Accepted (service picks to confirm) |
+| [0023](0023-product-components-and-contrast.md) | Product UI is built once at design size and reused; text contrast tokens meet AA | Accepted |
## How to add one
diff --git a/web/__tests__/marketing/Button.test.tsx b/web/__tests__/marketing/Button.test.tsx
index 6ce9fed..46f0213 100644
--- a/web/__tests__/marketing/Button.test.tsx
+++ b/web/__tests__/marketing/Button.test.tsx
@@ -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();
+ it('supports the ghost and secondary variants and the large size', () => {
+ const { rerender } = render();
expect(screen.getByRole('link', { name: 'See how it works' })).toHaveClass('btn-ghost', 'btn-lg');
+ rerender();
+ expect(screen.getByRole('link', { name: 'How approval works' })).toHaveClass('btn-secondary');
+ });
+
+ it('supports a light variant for use on the dark panel', () => {
+ render();
+ expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn-light');
+ });
+
+ it('can end with an arrow that is not announced', () => {
+ render();
+ const link = screen.getByRole('link', { name: 'See how it works' });
+ expect(link.querySelector('.icon')).toHaveAttribute('aria-hidden', 'true');
});
});
diff --git a/web/__tests__/marketing/home.test.tsx b/web/__tests__/marketing/home.test.tsx
new file mode 100644
index 0000000..8a563f9
--- /dev/null
+++ b/web/__tests__/marketing/home.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ expect(screen.getByRole('link', { name: /Approve with proof: nine checks on every change/ })).toHaveAttribute('href', '/trust');
+ });
+
+ it('says who it is for', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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'));
+ });
+});
diff --git a/web/__tests__/product/conversation.test.tsx b/web/__tests__/product/conversation.test.tsx
new file mode 100644
index 0000000..ddb519e
--- /dev/null
+++ b/web/__tests__/product/conversation.test.tsx
@@ -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(Make the phone number tappable.);
+ expect(screen.getByText('Make the phone number tappable.').closest('.p-message')).toHaveClass('p-message-user');
+ });
+
+ it('shows Wursor replies beside the Wursor avatar', () => {
+ render(On it.);
+ 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(Home, Menu, Contact, About);
+ 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();
+ expect(screen.getByText('4')).toHaveClass('p-counter');
+ });
+});
+
+describe('Thought', () => {
+ it('is collapsed by default, with the reasoning hidden', () => {
+ render(The number is plain text.);
+ 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(The number is plain text.);
+ 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(The number is plain text.);
+ expect(screen.getByText('The number is plain text.')).toBeInTheDocument();
+ });
+});
+
+describe('StepLine', () => {
+ it('shows what Wursor did and how long it took', () => {
+ render(Made the Contact page number a tap-to-call link);
+ 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(Taking a photo of the page on a phone);
+ 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();
+ 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();
+ await userEvent.click(screen.getByRole('button', { name: 'See what changed' }));
+ expect(onSeeChanges).toHaveBeenCalledOnce();
+ });
+});
+
+describe('CheckEvent', () => {
+ it('reports a passed check quietly', () => {
+ render();
+ 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(Plan ready);
+ 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();
+ 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();
+ 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();
+ 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(
+ {}}>
+ Hello
+ ,
+ );
+ 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();
+ await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
+ expect(onDensityChange).toHaveBeenCalledWith('everything');
+ });
+});
diff --git a/web/__tests__/product/primitives.test.tsx b/web/__tests__/product/primitives.test.tsx
new file mode 100644
index 0000000..57225e4
--- /dev/null
+++ b/web/__tests__/product/primitives.test.tsx
@@ -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();
+ expect(screen.getByRole('img', { name: 'Amara Okoye' })).toHaveTextContent('AO');
+ });
+
+ it('shows the brand mark for Wursor', () => {
+ render();
+ expect(screen.getByRole('img', { name: 'Wursor' })).toHaveClass('p-avatar-wursor');
+ });
+
+ it('has a small and a medium size', () => {
+ render();
+ expect(screen.getByRole('img', { name: 'A' })).toHaveClass('p-avatar-md');
+ });
+});
+
+describe('Counter', () => {
+ it('shows the count', () => {
+ render();
+ 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();
+ expect(screen.getByText(label).closest('.p-status')).toHaveAttribute('data-status', status);
+ });
+});
+
+describe('RiskChip', () => {
+ it('shows the plain-word risk level', () => {
+ render();
+ expect(screen.getByText('Everyday')).toBeInTheDocument();
+ });
+});
+
+describe('IconButton', () => {
+ it('is named by its label and calls onClick', async () => {
+ const onClick = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Search' }));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it('supports the inverse variant', () => {
+ render();
+ 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();
+ 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();
+ expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-secondary', 'p-btn-sm');
+ rerender();
+ expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-primary', 'p-btn-md');
+ rerender();
+ 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( {}} />);
+ 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();
+ await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
+ expect(onChange).toHaveBeenCalledWith('everything');
+ });
+});
+
+describe('Tab', () => {
+ it('marks the selected tab', () => {
+ render(
+
+
+
+
,
+ );
+ 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();
+ expect(screen.getByRole('tab', { name: /Proof/ })).toHaveTextContent('3');
+ });
+});
+
+describe('Tag', () => {
+ it('shows its label', () => {
+ render();
+ expect(screen.getByText('Draft copy')).toBeInTheDocument();
+ });
+
+ it('can be removed when closable', async () => {
+ const onClose = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Remove Draft copy' }));
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+});
diff --git a/web/__tests__/product/workspace.test.tsx b/web/__tests__/product/workspace.test.tsx
new file mode 100644
index 0000000..e468a22
--- /dev/null
+++ b/web/__tests__/product/workspace.test.tsx
@@ -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();
+ 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();
+ 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();
+ expect(screen.getByText('Make phone number tappable').closest('[aria-current]')).toHaveAttribute('aria-current', 'true');
+ });
+
+ it('shows the account', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ expect(screen.queryByRole('link', { name: /Call \(555\) 014-2290/ })).not.toBeInTheDocument();
+ rerender();
+ 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();
+ expect(screen.getByText('harborbakery.com/contact')).toBeInTheDocument();
+ });
+});
+
+describe('BoardCard and BoardStrip', () => {
+ it('asks for the owner when a change needs them', () => {
+ render();
+ 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();
+ 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();
+ 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(old} after={new} 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(old} after={new} />);
+ 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();
+ 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();
+ 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();
+ 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();
+ expect(screen.getByRole('separator', { name: 'Plan ready' })).toBeInTheDocument();
+ expect(screen.queryByText('Looked at home page')).not.toBeInTheDocument();
+ unmount();
+ render();
+ expect(screen.getByText('Looked at home page')).toBeInTheDocument();
+ });
+});
diff --git a/web/__tests__/tokens.test.ts b/web/__tests__/tokens.test.ts
index 40623d8..9c0fc1a 100644
--- a/web/__tests__/tokens.test.ts
+++ b/web/__tests__/tokens.test.ts
@@ -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([]);
+ });
+ }
+});
diff --git a/web/app/(marketing)/layout.tsx b/web/app/(marketing)/layout.tsx
index 58102b8..2e9c2c4 100644
--- a/web/app/(marketing)/layout.tsx
+++ b/web/app/(marketing)/layout.tsx
@@ -4,6 +4,7 @@ import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import '../../src/styles/tokens.css';
import '../../src/styles/marketing.css';
+import '../../src/styles/product.css';
export const metadata: Metadata = {
title: { default: 'Wursor', template: '%s · Wursor' },
diff --git a/web/app/(marketing)/page.tsx b/web/app/(marketing)/page.tsx
index 8a269f7..0b5c641 100644
--- a/web/app/(marketing)/page.tsx
+++ b/web/app/(marketing)/page.tsx
@@ -1,11 +1,41 @@
import { Footer } from '../../src/marketing/Footer.tsx';
+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 { Nav } from '../../src/marketing/Nav.tsx';
export default function HomePage() {
return (
<>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
);
diff --git a/web/public/icons/README.md b/web/public/icons/README.md
new file mode 100644
index 0000000..1f1ede8
--- /dev/null
+++ b/web/public/icons/README.md
@@ -0,0 +1,3 @@
+Single-colour line icons exported from the Figma file "Wursor - weown.net" (Icon/* components).
+Used through the `Icon` component (src/ui/Icon.tsx), which paints them with CSS masks so the colour comes from the
+current theme. Do not edit the files; re-export from Figma if an icon changes.
diff --git a/web/public/icons/activity.svg b/web/public/icons/activity.svg
new file mode 100644
index 0000000..911daa1
--- /dev/null
+++ b/web/public/icons/activity.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/arrow-right.svg b/web/public/icons/arrow-right.svg
new file mode 100644
index 0000000..27f8426
--- /dev/null
+++ b/web/public/icons/arrow-right.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/bell.svg b/web/public/icons/bell.svg
new file mode 100644
index 0000000..e50534e
--- /dev/null
+++ b/web/public/icons/bell.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/board.svg b/web/public/icons/board.svg
new file mode 100644
index 0000000..d4b102a
--- /dev/null
+++ b/web/public/icons/board.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/check-circle.svg b/web/public/icons/check-circle.svg
new file mode 100644
index 0000000..148700f
--- /dev/null
+++ b/web/public/icons/check-circle.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/check.svg b/web/public/icons/check.svg
new file mode 100644
index 0000000..fdf9b49
--- /dev/null
+++ b/web/public/icons/check.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/chevron-down.svg b/web/public/icons/chevron-down.svg
new file mode 100644
index 0000000..a8aa0ef
--- /dev/null
+++ b/web/public/icons/chevron-down.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/marketing/icons/chevron-right.svg b/web/public/icons/chevron-right.svg
similarity index 100%
rename from web/public/marketing/icons/chevron-right.svg
rename to web/public/icons/chevron-right.svg
diff --git a/web/public/icons/chevron-up.svg b/web/public/icons/chevron-up.svg
new file mode 100644
index 0000000..5532d51
--- /dev/null
+++ b/web/public/icons/chevron-up.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/clock.svg b/web/public/icons/clock.svg
new file mode 100644
index 0000000..58bf780
--- /dev/null
+++ b/web/public/icons/clock.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/icons/close.svg b/web/public/icons/close.svg
new file mode 100644
index 0000000..810f522
--- /dev/null
+++ b/web/public/icons/close.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/dots.svg b/web/public/icons/dots.svg
new file mode 100644
index 0000000..da307f6
--- /dev/null
+++ b/web/public/icons/dots.svg
@@ -0,0 +1,7 @@
+
diff --git a/web/public/icons/eye.svg b/web/public/icons/eye.svg
new file mode 100644
index 0000000..d1089d1
--- /dev/null
+++ b/web/public/icons/eye.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/icons/globe.svg b/web/public/icons/globe.svg
new file mode 100644
index 0000000..571ac28
--- /dev/null
+++ b/web/public/icons/globe.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/icons/home.svg b/web/public/icons/home.svg
new file mode 100644
index 0000000..b36fb42
--- /dev/null
+++ b/web/public/icons/home.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/icons/list.svg b/web/public/icons/list.svg
new file mode 100644
index 0000000..c5a7be4
--- /dev/null
+++ b/web/public/icons/list.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/loader.svg b/web/public/icons/loader.svg
new file mode 100644
index 0000000..16dbf03
--- /dev/null
+++ b/web/public/icons/loader.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/lock.svg b/web/public/icons/lock.svg
new file mode 100644
index 0000000..b02e49a
--- /dev/null
+++ b/web/public/icons/lock.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/marketing/icons/menu.svg b/web/public/icons/menu.svg
similarity index 100%
rename from web/public/marketing/icons/menu.svg
rename to web/public/icons/menu.svg
diff --git a/web/public/icons/mic.svg b/web/public/icons/mic.svg
new file mode 100644
index 0000000..a7a66fc
--- /dev/null
+++ b/web/public/icons/mic.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/monitor.svg b/web/public/icons/monitor.svg
new file mode 100644
index 0000000..8acba12
--- /dev/null
+++ b/web/public/icons/monitor.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/paperclip.svg b/web/public/icons/paperclip.svg
new file mode 100644
index 0000000..afa029b
--- /dev/null
+++ b/web/public/icons/paperclip.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/pencil.svg b/web/public/icons/pencil.svg
new file mode 100644
index 0000000..9b99282
--- /dev/null
+++ b/web/public/icons/pencil.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/plus.svg b/web/public/icons/plus.svg
new file mode 100644
index 0000000..dbe14ee
--- /dev/null
+++ b/web/public/icons/plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/pointer.svg b/web/public/icons/pointer.svg
new file mode 100644
index 0000000..487b3b4
--- /dev/null
+++ b/web/public/icons/pointer.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/refresh.svg b/web/public/icons/refresh.svg
new file mode 100644
index 0000000..097d6e1
--- /dev/null
+++ b/web/public/icons/refresh.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/search.svg b/web/public/icons/search.svg
new file mode 100644
index 0000000..eb49cdb
--- /dev/null
+++ b/web/public/icons/search.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/icons/shield-check.svg b/web/public/icons/shield-check.svg
new file mode 100644
index 0000000..5c406ba
--- /dev/null
+++ b/web/public/icons/shield-check.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/icons/shield.svg b/web/public/icons/shield.svg
new file mode 100644
index 0000000..77fc0a4
--- /dev/null
+++ b/web/public/icons/shield.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/sidebar.svg b/web/public/icons/sidebar.svg
new file mode 100644
index 0000000..bdfc469
--- /dev/null
+++ b/web/public/icons/sidebar.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/sliders.svg b/web/public/icons/sliders.svg
new file mode 100644
index 0000000..a9a2164
--- /dev/null
+++ b/web/public/icons/sliders.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/smartphone.svg b/web/public/icons/smartphone.svg
new file mode 100644
index 0000000..f8c0cfe
--- /dev/null
+++ b/web/public/icons/smartphone.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/sparkles.svg b/web/public/icons/sparkles.svg
new file mode 100644
index 0000000..cf65168
--- /dev/null
+++ b/web/public/icons/sparkles.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/icons/split.svg b/web/public/icons/split.svg
new file mode 100644
index 0000000..64d81f5
--- /dev/null
+++ b/web/public/icons/split.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/stop.svg b/web/public/icons/stop.svg
new file mode 100644
index 0000000..09a7e38
--- /dev/null
+++ b/web/public/icons/stop.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/icons/undo.svg b/web/public/icons/undo.svg
new file mode 100644
index 0000000..d9537f2
--- /dev/null
+++ b/web/public/icons/undo.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/marketing/README.md b/web/public/marketing/README.md
index 9e273f0..b8c5fdd 100644
--- a/web/public/marketing/README.md
+++ b/web/public/marketing/README.md
@@ -3,9 +3,8 @@ Do not redraw or edit them. Re-export from Figma if the design changes.
| File | Figma node |
|---|---|
-| brand-mark-light.svg | Brand/Mark (28:30), light |
-| brand-mark-dark.svg | Brand/Mark in Nav Bar, dark (136:72493) |
-| icons/menu.svg | Icon in Menu · Mobile (143:77164) |
-| icons/chevron-right.svg | Icon/chevron-right in Hero Announcement (64:1451) |
+| brand-mark-light.svg / brand-mark-dark.svg | Brand/Mark (28:30), Nav Bar light (64:1426) and dark (136:72493) |
+| avatar-mark.svg / avatar-mark-dark.svg | Mark inside the Wursor Avatar (28:98), light and dark |
+| backdrop.svg | Backdrop "Size=Wide" (63:1707); the same warm backdrop is used in light and dark |
-Single-colour icons are drawn with CSS masks so they take the token colour for the current theme.
+Icons are in ../icons. They are drawn with CSS masks so they take the token colour for the current theme.
diff --git a/web/public/marketing/avatar-mark-dark.svg b/web/public/marketing/avatar-mark-dark.svg
new file mode 100644
index 0000000..a7e966a
--- /dev/null
+++ b/web/public/marketing/avatar-mark-dark.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/marketing/avatar-mark.svg b/web/public/marketing/avatar-mark.svg
new file mode 100644
index 0000000..163adf6
--- /dev/null
+++ b/web/public/marketing/avatar-mark.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/public/marketing/backdrop.svg b/web/public/marketing/backdrop.svg
new file mode 100644
index 0000000..354cfe1
--- /dev/null
+++ b/web/public/marketing/backdrop.svg
@@ -0,0 +1,48 @@
+
diff --git a/web/src/marketing/Button.tsx b/web/src/marketing/Button.tsx
index 901fa0c..aea1726 100644
--- a/web/src/marketing/Button.tsx
+++ b/web/src/marketing/Button.tsx
@@ -1,20 +1,24 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
+import { Icon, type IconName } from '../ui/Icon.tsx';
type Props = {
href: string;
children: ReactNode;
- variant?: 'primary' | 'ghost';
+ /** `light` is for use on the dark closing panel. */
+ variant?: 'primary' | 'secondary' | 'ghost' | 'light';
size?: 'sm' | 'md' | 'lg';
+ trailingIcon?: IconName;
className?: string;
};
-/** Figma "Button": primary is the one main action on a screen; secondary and danger are added when a screen needs them. Heights 28, 36, 44. */
-export function Button({ href, children, variant = 'primary', size = 'md', className }: Props) {
+/** Figma "Button": primary is the one main action on a screen. Heights 28, 36, 44. */
+export function Button({ href, children, variant = 'primary', size = 'md', trailingIcon, className }: Props) {
const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
return (
{children}
+ {trailingIcon && }
);
}
diff --git a/web/src/marketing/Icon.tsx b/web/src/marketing/Icon.tsx
deleted file mode 100644
index d953bd2..0000000
--- a/web/src/marketing/Icon.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { CSSProperties } from 'react';
-
-type Props = { name: 'menu' | 'chevron-right'; size?: number; className?: string };
-
-/**
- * Draws a single-colour Figma icon with a CSS mask so it takes the text colour
- * of the current theme. The SVG file is the exact export from Figma.
- */
-export function Icon({ name, size = 16, className }: Props) {
- const style = {
- '--icon-src': `url(/marketing/icons/${name}.svg)`,
- width: size,
- height: size,
- } as CSSProperties;
- return ;
-}
diff --git a/web/src/marketing/Nav.tsx b/web/src/marketing/Nav.tsx
index d3f5db1..a6d2133 100644
--- a/web/src/marketing/Nav.tsx
+++ b/web/src/marketing/Nav.tsx
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react';
import { BrandMark } from './BrandMark.tsx';
import { Button } from './Button.tsx';
import { NAV_LINKS, ROUTES } from './content.ts';
-import { Icon } from './Icon.tsx';
+import { Icon } from '../ui/Icon.tsx';
export function Nav() {
const [open, setOpen] = useState(false);
diff --git a/web/src/marketing/content.ts b/web/src/marketing/content.ts
index 4614586..e15ae11 100644
--- a/web/src/marketing/content.ts
+++ b/web/src/marketing/content.ts
@@ -43,3 +43,70 @@ export const FOOTER_COLUMNS: readonly FooterColumn[] = [
];
export const TAGLINE = 'Your website, handled.';
+
+/**
+ * Plan names, prices and allowances are placeholders until decided (PRD D-HL-8, D-HL-14).
+ * PX-13: the marketing site shows them only once they are approved. Flip this when they are.
+ */
+export const PRICING_CONFIRMED = false;
+
+export type Plan = {
+ name: string;
+ price: string;
+ allowance: string;
+ featured?: boolean;
+};
+
+/** Draft numbers from the Figma design. Not shown while PRICING_CONFIRMED is false. */
+export const PLANS: readonly Plan[] = [
+ { name: 'Starter', price: '$19', allowance: '1 site · 50 changes a month' },
+ { name: 'Studio', price: '$49', allowance: '5 sites · unlimited changes', featured: true },
+ { name: 'Agency', price: '$149', allowance: '25 sites · team seats and client sharing' },
+];
+
+export const PLAN_FEATURES = [
+ 'Unlimited changes with approval',
+ 'Safe copy before every publish',
+ 'Proof for every change',
+ 'Undo for 30 days',
+] as const;
+
+export const EXAMPLES = [
+ { title: 'Update the holiday hours', meta: 'Page · 2 min' },
+ { title: 'Add a gluten-free menu', meta: 'Menu · 4 min' },
+ { title: 'Fix the contact form', meta: 'Form · 3 min' },
+ { title: 'Swap the homepage photo', meta: 'Media · 1 min' },
+ { title: 'Publish a new blog post', meta: 'Post · 6 min' },
+] as const;
+
+/**
+ * FAQ. The first answer is from the design. The rest are drafted from the PRD (sections 1.5, 2.2, 11.8 and 16)
+ * for the product owner to review; they are not in the design yet.
+ */
+export const FAQ: ReadonlyArray<{ question: string; answer: string; open?: boolean }> = [
+ {
+ question: 'Will Wursor change my live site without asking?',
+ answer: 'No. Every change is made on a safe copy first. Nothing goes live until you review the preview and approve it.',
+ open: true,
+ },
+ {
+ question: 'Do I need my own hosting?',
+ answer:
+ 'No. You can start a new site on Wursor Cloud, connect a WordPress site you already have, or build here and publish to your own WordPress. You can move between them later.',
+ },
+ {
+ question: 'Can I undo a change?',
+ answer:
+ 'Yes. Every change has a way back, and the undo button stays in view after you publish. If a change cannot be fully undone, Wursor says so before you approve it.',
+ },
+ {
+ question: 'Does it work with my theme and plugins?',
+ answer:
+ 'Wursor works on a copy of your site, so anything unusual shows up in the preview and the checks, not on your live pages. It tells you what it could not check.',
+ },
+ {
+ question: 'How is my site kept safe?',
+ answer:
+ 'Changes are made on a copy, a backup is taken before anything is applied, and each approval is tied to the exact checks you saw. You can disconnect Wursor from your site at any time.',
+ },
+];
diff --git a/web/src/marketing/home/ApproveWithProof.tsx b/web/src/marketing/home/ApproveWithProof.tsx
new file mode 100644
index 0000000..e45ba2a
--- /dev/null
+++ b/web/src/marketing/home/ApproveWithProof.tsx
@@ -0,0 +1,47 @@
+import { ApprovalCard } from '../../product/ApprovalCard.tsx';
+import { Fit } from '../../ui/Fit.tsx';
+import { Icon } from '../../ui/Icon.tsx';
+import { Button } from '../Button.tsx';
+import { SectionHeader } from './SectionHeader.tsx';
+
+const POINTS = [
+ 'Nine checks, written in plain words',
+ 'Looks right on a phone, a tablet and a laptop',
+ 'One click to approve and publish, one click to undo',
+] as const;
+
+export function ApproveWithProof() {
+ return (
+
+
+
+ );
+}
diff --git a/web/src/marketing/home/PricingTeaser.tsx b/web/src/marketing/home/PricingTeaser.tsx
new file mode 100644
index 0000000..fad9b1c
--- /dev/null
+++ b/web/src/marketing/home/PricingTeaser.tsx
@@ -0,0 +1,61 @@
+import { Icon } from '../../ui/Icon.tsx';
+import { Button } from '../Button.tsx';
+import { PLAN_FEATURES, PLANS, PRICING_CONFIRMED, ROUTES } from '../content.ts';
+import { SectionHeader } from './SectionHeader.tsx';
+
+/**
+ * Plan names, prices and allowances show only once they are decided (PRD D-HL-8, PX-13). Until then
+ * this section says so and sends people to sign up or to the pricing page.
+ */
+export function PricingTeaser({ confirmed = PRICING_CONFIRMED }: { confirmed?: boolean }) {
+ if (!confirmed) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+ {PLANS.map((p) => (
+
+
+
{p.name}
+ {p.featured && Most popular}
+
+
+ {p.price}
+ per month
+
+
{p.allowance}
+
+ {PLAN_FEATURES.map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/marketing/home/ProofStrip.tsx b/web/src/marketing/home/ProofStrip.tsx
new file mode 100644
index 0000000..3eaa6ef
--- /dev/null
+++ b/web/src/marketing/home/ProofStrip.tsx
@@ -0,0 +1,18 @@
+const STATS = [
+ { value: '9', label: 'checks before anything goes live' },
+ { value: '1 click', label: 'to undo any change, any time' },
+ { value: 'Every step', label: 'on the record, in plain words' },
+] as const;
+
+export function ProofStrip() {
+ return (
+
+ {STATS.map((s) => (
+
+
{s.value}
+
{s.label}
+
+ ))}
+
+ );
+}
diff --git a/web/src/marketing/home/SafeByDesign.tsx b/web/src/marketing/home/SafeByDesign.tsx
new file mode 100644
index 0000000..a54da34
--- /dev/null
+++ b/web/src/marketing/home/SafeByDesign.tsx
@@ -0,0 +1,49 @@
+import { Icon, type IconName } from '../../ui/Icon.tsx';
+import { SectionHeader } from './SectionHeader.tsx';
+
+const RULES: ReadonlyArray<{ icon: IconName; title: string; body: string }> = [
+ {
+ icon: 'shield',
+ title: 'Works on a safe copy',
+ body: 'Every change is made on a copy of your site first, so your live pages stay untouched until you say so.',
+ },
+ {
+ icon: 'check-circle',
+ title: 'Nothing goes live without you',
+ body: 'Wursor shows you the result and waits. Nothing is published without your approval.',
+ },
+ {
+ icon: 'list',
+ title: 'Everything is recorded',
+ body: 'Every step, check and decision is written down, so you can see exactly what happened and why.',
+ },
+ {
+ icon: 'undo',
+ title: 'Undo in one click',
+ body: 'If you change your mind, one click puts your site back the way it was.',
+ },
+];
+
+export function SafeByDesign() {
+ return (
+
+
+
+ {RULES.map((r) => (
+
+
+
+
+
{r.title}
+
{r.body}
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/marketing/home/SectionHeader.tsx b/web/src/marketing/home/SectionHeader.tsx
new file mode 100644
index 0000000..b99c737
--- /dev/null
+++ b/web/src/marketing/home/SectionHeader.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from 'react';
+
+/** Shows `desktop` on wide screens and `mobile` on narrow ones. The design words a few lines more briefly on a phone. */
+export function Responsive({ desktop, mobile }: { desktop: ReactNode; mobile?: ReactNode }) {
+ if (mobile === undefined) return <>{desktop}>;
+ return (
+ <>
+ {desktop}
+ {mobile}
+ >
+ );
+}
+
+type Props = {
+ eyebrow: string;
+ title: string;
+ subtitle?: string;
+ mobileTitle?: string;
+ mobileSubtitle?: string;
+ align?: 'center' | 'left';
+};
+
+/** Eyebrow, headline and one supporting sentence. Centred by default. */
+export function SectionHeader({ eyebrow, title, subtitle, mobileTitle, mobileSubtitle, align = 'center' }: Props) {
+ return (
+
+
{eyebrow}
+
+
+
+ {subtitle && (
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/marketing/home/SurfacesShowcase.tsx b/web/src/marketing/home/SurfacesShowcase.tsx
new file mode 100644
index 0000000..f215804
--- /dev/null
+++ b/web/src/marketing/home/SurfacesShowcase.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { useId, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
+import { BoardStrip } from '../../product/BoardStrip.tsx';
+import { HARBOR } from '../../product/harbor-bakery.ts';
+import { HarborConversation } from '../../product/HarborConversation.tsx';
+import { PreviewColumn } from '../../product/PreviewColumn.tsx';
+import { Fit } from '../../ui/Fit.tsx';
+import { Icon, type IconName } from '../../ui/Icon.tsx';
+import { Responsive } from './SectionHeader.tsx';
+
+type Surface = 'conversation' | 'preview' | 'board';
+
+const SURFACES: ReadonlyArray<{ value: Surface; label: string; icon: IconName; caption: string; mobileCaption?: string }> = [
+ { value: 'conversation', label: 'Conversation', icon: 'sparkles', caption: 'Conversation: ask in plain words. Wursor explains each step as it goes.', mobileCaption: 'Ask in plain words. Wursor explains each step.' },
+ { value: 'preview', label: 'Preview', icon: 'eye', caption: 'Preview: see the real page on a phone, tablet and laptop before anything goes live.' },
+ { value: 'board', label: 'Board', icon: 'board', caption: 'Board: every change in one place, from idea to published.' },
+];
+
+/**
+ * Each window is a share of the stage width with the design's aspect ratio, and its content is scaled to the
+ * window's own width, so the three views stay in proportion on any screen. Sizes are from the design (1344 × 720 stage).
+ */
+const WINDOWS: Record = {
+ conversation: { width: '41.667%', aspect: '560 / 620', contentWidth: 440, contentHeight: 852, initialScale: 560 / 440 },
+ preview: { width: '60.863%', aspect: '818 / 600', contentWidth: 760, contentHeight: 852, initialScale: 818 / 760 },
+ board: { width: '78.571%', aspect: '1056 / 341', contentWidth: 760, contentHeight: 246, initialScale: 1056 / 760 },
+};
+
+function SurfaceWindow({ surface }: { surface: Surface }) {
+ const w = WINDOWS[surface];
+ const style = { '--w': w.width, aspectRatio: w.aspect } as CSSProperties;
+ return (
+
+ );
+}
diff --git a/web/src/marketing/home/WatchTheWork.tsx b/web/src/marketing/home/WatchTheWork.tsx
new file mode 100644
index 0000000..5986781
--- /dev/null
+++ b/web/src/marketing/home/WatchTheWork.tsx
@@ -0,0 +1,43 @@
+import type { Density } from '../../product/ConversationPanel.tsx';
+import { Workspace } from '../../product/Workspace.tsx';
+import { Fit } from '../../ui/Fit.tsx';
+import { SectionHeader } from './SectionHeader.tsx';
+
+const LEVELS: ReadonlyArray<{ density: Density; title: string; description: string }> = [
+ { density: 'headline', title: 'Headline', description: 'Only what changed and what needs you.' },
+ { density: 'steps', title: 'Steps', description: 'Each step, in a sentence. The default.' },
+ { density: 'everything', title: 'Everything', description: 'Every look, edit, check and decision.' },
+];
+
+export function WatchTheWork() {
+ return (
+
+
+
+ {LEVELS.map((l) => (
+
+
+
{l.title}
+
{l.description}
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/product/AgentCursor.tsx b/web/src/product/AgentCursor.tsx
new file mode 100644
index 0000000..b461e3e
--- /dev/null
+++ b/web/src/product/AgentCursor.tsx
@@ -0,0 +1,13 @@
+import type { CSSProperties } from 'react';
+import { Icon } from '../ui/Icon.tsx';
+
+/** Wursor's pointer on the preview, with its name. Position is in preview-stage pixels. */
+export function AgentCursor({ left, top, name = 'Wursor' }: { left: number; top: number; name?: string }) {
+ const style: CSSProperties = { left, top };
+ return (
+
+
+ {name}
+
+ );
+}
diff --git a/web/src/product/ApprovalBar.tsx b/web/src/product/ApprovalBar.tsx
new file mode 100644
index 0000000..d549413
--- /dev/null
+++ b/web/src/product/ApprovalBar.tsx
@@ -0,0 +1,22 @@
+import { Icon } from '../ui/Icon.tsx';
+import { Button } from './Button.tsx';
+
+type Props = { title: string; meta: string; onApprove?: () => void };
+
+/** The decision point. Ready state only: approve, or (later) ask for changes. Publishing and Published are not designed here yet. */
+export function ApprovalBar({ title, meta, onApprove }: Props) {
+ return (
+
+
+
+
+
+
{title}
+
{meta}
+
+
+
+ );
+}
diff --git a/web/src/product/ApprovalCard.tsx b/web/src/product/ApprovalCard.tsx
new file mode 100644
index 0000000..44cd328
--- /dev/null
+++ b/web/src/product/ApprovalCard.tsx
@@ -0,0 +1,20 @@
+import { ApprovalBar } from './ApprovalBar.tsx';
+import { CheckEvent } from './CheckEvent.tsx';
+import { CompareSlider } from './CompareSlider.tsx';
+import { Zoom } from '../ui/Zoom.tsx';
+import { SiteMock } from './SiteMock.tsx';
+
+/** Compare slider, the checks and the approval bar together: what the owner sees before anything goes live. */
+export function ApprovalCard() {
+ return (
+
+
+
+ } after={} />
+
+
+
+
+
+ );
+}
diff --git a/web/src/product/Avatar.tsx b/web/src/product/Avatar.tsx
new file mode 100644
index 0000000..bb686a6
--- /dev/null
+++ b/web/src/product/Avatar.tsx
@@ -0,0 +1,23 @@
+type Props =
+ | { kind: 'wursor'; size?: 'sm' | 'md' }
+ | { kind: 'initials'; initials: string; name: string; size?: 'sm' | 'md' };
+
+/** Person or Wursor agent identity: initials, or the brand mark on the inverse colour. */
+export function Avatar(props: Props) {
+ const size = props.size ?? 'sm';
+ if (props.kind === 'wursor') {
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ );
+ }
+ return (
+
+ {props.initials}
+
+ );
+}
diff --git a/web/src/product/BoardCard.tsx b/web/src/product/BoardCard.tsx
new file mode 100644
index 0000000..4dc541b
--- /dev/null
+++ b/web/src/product/BoardCard.tsx
@@ -0,0 +1,38 @@
+import { RiskChip } from './RiskChip.tsx';
+import { StatusPill, type Status } from './StatusPill.tsx';
+
+type Props = {
+ status: Status;
+ title: string;
+ meta: string;
+ risk?: 'everyday';
+ /** 0 to 1. Shown on a working change. */
+ progress?: number;
+};
+
+/** One change on the board. A change that needs the owner is outlined in the accent colour and says why. */
+export function BoardCard({ status, title, meta, risk, progress }: Props) {
+ return (
+
+ );
+}
diff --git a/web/src/product/BoardStrip.tsx b/web/src/product/BoardStrip.tsx
new file mode 100644
index 0000000..eb102d7
--- /dev/null
+++ b/web/src/product/BoardStrip.tsx
@@ -0,0 +1,31 @@
+import { BoardCard } from './BoardCard.tsx';
+import { Counter } from './Counter.tsx';
+import type { BoardLaneData } from './harbor-bakery.ts';
+
+/** The board as a strip under the preview: four lanes, each with its count. The header count is what is not done yet. */
+export function BoardStrip({ lanes }: { lanes: readonly BoardLaneData[] }) {
+ const open = lanes.filter((l) => l.status !== 'done').reduce((n, l) => n + l.cards.length, 0);
+ return (
+
+
+
Board
+
+
+ Open board
+
+
+ {lanes.map((lane) => (
+
+
+ {lane.title}
+
+
+ {lane.cards.map((c) => (
+
+ ))}
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/product/Button.tsx b/web/src/product/Button.tsx
new file mode 100644
index 0000000..b460dd9
--- /dev/null
+++ b/web/src/product/Button.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from 'react';
+import { Icon, type IconName } from '../ui/Icon.tsx';
+
+type Props = {
+ children: ReactNode;
+ variant?: 'primary' | 'secondary' | 'ghost';
+ size?: 'sm' | 'md';
+ leadingIcon?: IconName;
+ trailingIcon?: IconName;
+ onClick?: (() => void) | undefined;
+};
+
+/** Product button. Primary (black pill) is the one main action on a screen; secondary is the alternative; ghost is tertiary. */
+export function Button({ children, variant = 'secondary', size = 'sm', leadingIcon, trailingIcon, onClick }: Props) {
+ const iconSize = 16;
+ return (
+
+ );
+}
diff --git a/web/src/product/ChangeEvent.tsx b/web/src/product/ChangeEvent.tsx
new file mode 100644
index 0000000..dae682f
--- /dev/null
+++ b/web/src/product/ChangeEvent.tsx
@@ -0,0 +1,38 @@
+import { Icon } from '../ui/Icon.tsx';
+import { Button } from './Button.tsx';
+import { RiskChip } from './RiskChip.tsx';
+
+type Props = {
+ title: string;
+ meta: string;
+ risk: 'everyday';
+ added: number;
+ removed: number;
+ onSeeChanges?: () => void;
+};
+
+/** A change Wursor made to the draft copy. Always shows the risk, how much changed, and a route to the full comparison. */
+export function ChangeEvent({ title, meta, risk, added, removed, onSeeChanges }: Props) {
+ return (
+
+
+
+
+
+
+
{title}
+
{meta}
+
+
+
+
+ +{added} lines
+ −{removed} lines
+
+
+
+
+ );
+}
diff --git a/web/src/product/CheckEvent.tsx b/web/src/product/CheckEvent.tsx
new file mode 100644
index 0000000..47bce3b
--- /dev/null
+++ b/web/src/product/CheckEvent.tsx
@@ -0,0 +1,19 @@
+import { Icon } from '../ui/Icon.tsx';
+
+type Props = { result: 'passed'; title: string; detail: string };
+
+/** Wursor's own checks after a change. Passed is quiet and green. "Needs attention" is not designed yet. */
+export function CheckEvent({ result, title, detail }: Props) {
+ return (
+
+
+
+
+
+
{title}
+
{detail}
+
+
+
+ );
+}
diff --git a/web/src/product/CompareSlider.tsx b/web/src/product/CompareSlider.tsx
new file mode 100644
index 0000000..2812666
--- /dev/null
+++ b/web/src/product/CompareSlider.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
+import { Icon } from '../ui/Icon.tsx';
+
+type Props = { before: ReactNode; after: ReactNode; defaultPosition?: number };
+
+const STEP = 5;
+const clamp = (n: number) => Math.min(100, Math.max(0, n));
+
+/** Drag the divider (or use the arrow keys) to compare the live site on the left with the draft on the right. */
+export function CompareSlider({ before, after, defaultPosition = 50 }: Props) {
+ const [position, setPosition] = useState(clamp(defaultPosition));
+ const box = useRef(null);
+
+ const moveTo = (clientX: number) => {
+ const rect = box.current?.getBoundingClientRect();
+ if (!rect || rect.width === 0) return;
+ setPosition(clamp(((clientX - rect.left) / rect.width) * 100));
+ };
+ const onPointerDown = (e: PointerEvent) => {
+ e.currentTarget.setPointerCapture?.(e.pointerId);
+ moveTo(e.clientX);
+ };
+ const onPointerMove = (e: PointerEvent) => {
+ if (e.buttons === 1) moveTo(e.clientX);
+ };
+ const onKeyDown = (e: KeyboardEvent) => {
+ const next: Record = {
+ ArrowLeft: position - STEP,
+ ArrowRight: position + STEP,
+ Home: 0,
+ End: 100,
+ };
+ const value = next[e.key];
+ if (value === undefined) return;
+ e.preventDefault();
+ setPosition(clamp(value));
+ };
+
+ return (
+
+
{after}
+
+ {before}
+
+
+ Before
+ After
+
+
+ );
+}
diff --git a/web/src/product/Composer.tsx b/web/src/product/Composer.tsx
new file mode 100644
index 0000000..6cf5a52
--- /dev/null
+++ b/web/src/product/Composer.tsx
@@ -0,0 +1,32 @@
+import { Button } from './Button.tsx';
+import { IconButton } from './IconButton.tsx';
+
+type Props = {
+ /** Only the working state is designed so far. Empty, typing and pointing come with the app screens. */
+ state: 'working';
+ autonomy: string;
+ placeholder?: string;
+ onStop?: () => void;
+};
+
+/** Where the owner talks to Wursor. While Wursor works, stop is offered and typing still works. */
+export function Composer({ autonomy, placeholder = 'Wursor is working. Add a note, or stop.', onStop }: Props) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/product/ConversationPanel.tsx b/web/src/product/ConversationPanel.tsx
new file mode 100644
index 0000000..b5c7874
--- /dev/null
+++ b/web/src/product/ConversationPanel.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from 'react';
+import { Composer } from './Composer.tsx';
+import { SegmentedControl } from './SegmentedControl.tsx';
+
+export type Density = 'headline' | 'steps' | 'everything';
+
+const DENSITIES = [
+ { value: 'headline', label: 'Headline' },
+ { value: 'steps', label: 'Steps' },
+ { value: 'everything', label: 'Everything' },
+] as const;
+
+type Props = {
+ density: Density;
+ onDensityChange: (density: Density) => void;
+ composer?: ReactNode;
+ children?: ReactNode;
+};
+
+/** The conversation: header with the detail level, the stream, and the composer. */
+export function ConversationPanel({ density, onDensityChange, composer, children }: Props) {
+ return (
+
+
+
Conversation
+
+
+
+
+ {children}
+
+
{composer ?? }
+
+ );
+}
diff --git a/web/src/product/Counter.tsx b/web/src/product/Counter.tsx
new file mode 100644
index 0000000..4c6e7ec
--- /dev/null
+++ b/web/src/product/Counter.tsx
@@ -0,0 +1,4 @@
+/** Small count badge on the accent colour. */
+export function Counter({ count }: { count: number }) {
+ return {count};
+}
diff --git a/web/src/product/DeviceFrame.tsx b/web/src/product/DeviceFrame.tsx
new file mode 100644
index 0000000..58f19e4
--- /dev/null
+++ b/web/src/product/DeviceFrame.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from 'react';
+import { Icon } from '../ui/Icon.tsx';
+
+/** A browser window around a page. Drawn at 760 px wide and shown at 712 px, the width of the preview stage. */
+export function DeviceFrame({ url, children }: { url: string; children: ReactNode }) {
+ return (
+
+
+
+
+
+
+ {url}
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/web/src/product/HarborConversation.tsx b/web/src/product/HarborConversation.tsx
new file mode 100644
index 0000000..c7f4529
--- /dev/null
+++ b/web/src/product/HarborConversation.tsx
@@ -0,0 +1,15 @@
+'use client';
+
+import { useState } from 'react';
+import { ConversationPanel, type Density } from './ConversationPanel.tsx';
+import { HarborStream } from './HarborStream.tsx';
+
+/** The Harbor Bakery conversation on its own, with its own detail level. */
+export function HarborConversation({ defaultDensity = 'steps' }: { defaultDensity?: Density }) {
+ const [density, setDensity] = useState(defaultDensity);
+ return (
+
+
+
+ );
+}
diff --git a/web/src/product/HarborStream.tsx b/web/src/product/HarborStream.tsx
new file mode 100644
index 0000000..b40e9de
--- /dev/null
+++ b/web/src/product/HarborStream.tsx
@@ -0,0 +1,58 @@
+import { ChangeEvent } from './ChangeEvent.tsx';
+import type { Density } from './ConversationPanel.tsx';
+import { Message } from './Message.tsx';
+import { Milestone } from './Milestone.tsx';
+import { StepGroup } from './StepGroup.tsx';
+import { StepLine } from './StepLine.tsx';
+import { Thought } from './Thought.tsx';
+
+const REASONING =
+ 'The number is plain text on the Contact page and again in the footer. On a phone it needs to be a tel: link, and the tap target should be at least 44 px tall. I’ll check the header too, because it repeats the number.';
+
+const CHANGE = { title: 'Changed the Contact page', meta: '1 section · 2 places', risk: 'everyday', added: 8, removed: 2 } as const;
+
+/** The example conversation for a Harbor Bakery change, at each detail level. */
+export function HarborStream({ density }: { density: Density }) {
+ if (density === 'headline') {
+ return (
+ <>
+ Make the phone number tappable on mobile, and the same in the footer.
+
+ Two places show your phone number as plain text: the Contact page and the footer. Here is my plan. Nothing goes live until you approve.
+
+ Plan ready
+ I’ve changed the Contact page and the footer, and I’m checking them on a phone now.
+ >
+ );
+ }
+ if (density === 'steps') {
+ return (
+ <>
+ Make the phone number tappable on mobile, and the same in the footer.
+ On it. I’ll show you each step as I go.
+
+ Looked at the home page
+
+ {REASONING}
+ Made the Contact page number a tap-to-call link
+
+ Did the same in the footer
+ Taking a photo of the page on a phone
+ >
+ );
+ }
+ return (
+ <>
+ On it. I’ll show you each step as I go.
+ {REASONING}
+ Looked at home page
+ Looked at contact page
+ Looked at footer
+ Searched your pages for the phone number
+ Made the Contact page number a tap-to-call link
+
+ Did the same in the footer
+ Taking a photo of the page on a phone
+ >
+ );
+}
diff --git a/web/src/product/IconButton.tsx b/web/src/product/IconButton.tsx
new file mode 100644
index 0000000..6c0a328
--- /dev/null
+++ b/web/src/product/IconButton.tsx
@@ -0,0 +1,18 @@
+import { Icon, type IconName } from '../ui/Icon.tsx';
+
+type Props = {
+ icon: IconName;
+ label: string;
+ variant?: 'ghost' | 'inverse';
+ size?: 'sm' | 'md';
+ onClick?: (() => void) | undefined;
+};
+
+/** Round button with an icon, for toolbars and the composer. `label` is its accessible name. */
+export function IconButton({ icon, label, variant = 'ghost', size = 'sm', onClick }: Props) {
+ return (
+
+ );
+}
diff --git a/web/src/product/Message.tsx b/web/src/product/Message.tsx
new file mode 100644
index 0000000..fa68b78
--- /dev/null
+++ b/web/src/product/Message.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from 'react';
+import { Avatar } from './Avatar.tsx';
+
+/** A message in the conversation. The owner's sit in a soft box; Wursor's are plain text beside its avatar. */
+export function Message({ role, children }: { role: 'user' | 'wursor'; children: ReactNode }) {
+ if (role === 'user') {
+ return (
+
+
{children}
+
+ );
+ }
+ return (
+
+
+
{children}
+
+ );
+}
diff --git a/web/src/product/Milestone.tsx b/web/src/product/Milestone.tsx
new file mode 100644
index 0000000..0b783a2
--- /dev/null
+++ b/web/src/product/Milestone.tsx
@@ -0,0 +1,17 @@
+import type { ReactNode } from 'react';
+import { Icon } from '../ui/Icon.tsx';
+
+/** A divider in the stream that marks a stage in the life of a change. Always visible, even at the Headline level. */
+export function Milestone({ children }: { children: string | ReactNode }) {
+ const label = typeof children === 'string' ? children : undefined;
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/web/src/product/PointHighlight.tsx b/web/src/product/PointHighlight.tsx
new file mode 100644
index 0000000..3c8fb2a
--- /dev/null
+++ b/web/src/product/PointHighlight.tsx
@@ -0,0 +1,14 @@
+import type { CSSProperties } from 'react';
+
+type Props = { left: number; top: number; width: number; height: number; label: string };
+
+/** A dashed accent box over the spot Wursor is editing. Position is in preview-stage pixels. */
+export function PointHighlight({ left, top, width, height, label }: Props) {
+ const style: CSSProperties = { left, top, width, height };
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/web/src/product/PreviewColumn.tsx b/web/src/product/PreviewColumn.tsx
new file mode 100644
index 0000000..e7a96dc
--- /dev/null
+++ b/web/src/product/PreviewColumn.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { useState } from 'react';
+import { AgentCursor } from './AgentCursor.tsx';
+import { BoardStrip } from './BoardStrip.tsx';
+import { DeviceFrame } from './DeviceFrame.tsx';
+import { HARBOR } from './harbor-bakery.ts';
+import { PointHighlight } from './PointHighlight.tsx';
+import { PreviewToolbar, type PreviewView } from './PreviewToolbar.tsx';
+import { SiteMock } from './SiteMock.tsx';
+
+/** The right-hand column of the workspace: view tabs, the live preview of the draft, and the board strip. */
+export function PreviewColumn({ defaultView = 'preview' }: { defaultView?: PreviewView }) {
+ const [view, setView] = useState(defaultView);
+ return (
+