From 694711302746e58dadf4aa0411d4daefe2340e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 15:25:50 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_014SdXCbrN5PtGExkpvMaMHx --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- .../0023-product-components-and-contrast.md | 35 +++ docs/decisions/README.md | 1 + web/__tests__/marketing/Button.test.tsx | 17 +- web/__tests__/marketing/home.test.tsx | 213 +++++++++++++++ web/__tests__/product/conversation.test.tsx | 158 ++++++++++++ web/__tests__/product/primitives.test.tsx | 141 ++++++++++ web/__tests__/product/workspace.test.tsx | 166 ++++++++++++ web/__tests__/tokens.test.ts | 44 +++- web/app/(marketing)/layout.tsx | 1 + web/app/(marketing)/page.tsx | 32 ++- web/public/icons/README.md | 3 + web/public/icons/activity.svg | 5 + web/public/icons/arrow-right.svg | 5 + web/public/icons/bell.svg | 5 + web/public/icons/board.svg | 5 + web/public/icons/check-circle.svg | 5 + web/public/icons/check.svg | 5 + web/public/icons/chevron-down.svg | 5 + .../{marketing => }/icons/chevron-right.svg | 0 web/public/icons/chevron-up.svg | 5 + web/public/icons/clock.svg | 6 + web/public/icons/close.svg | 5 + web/public/icons/dots.svg | 7 + web/public/icons/eye.svg | 6 + web/public/icons/globe.svg | 6 + web/public/icons/home.svg | 6 + web/public/icons/list.svg | 5 + web/public/icons/loader.svg | 5 + web/public/icons/lock.svg | 5 + web/public/{marketing => }/icons/menu.svg | 0 web/public/icons/mic.svg | 5 + web/public/icons/monitor.svg | 5 + web/public/icons/paperclip.svg | 5 + web/public/icons/pencil.svg | 5 + web/public/icons/plus.svg | 5 + web/public/icons/pointer.svg | 5 + web/public/icons/refresh.svg | 5 + web/public/icons/search.svg | 6 + web/public/icons/shield-check.svg | 6 + web/public/icons/shield.svg | 5 + web/public/icons/sidebar.svg | 5 + web/public/icons/sliders.svg | 5 + web/public/icons/smartphone.svg | 5 + web/public/icons/sparkles.svg | 6 + web/public/icons/split.svg | 5 + web/public/icons/stop.svg | 5 + web/public/icons/undo.svg | 6 + web/public/marketing/README.md | 9 +- web/public/marketing/avatar-mark-dark.svg | 6 + web/public/marketing/avatar-mark.svg | 6 + web/public/marketing/backdrop.svg | 48 ++++ web/src/marketing/Button.tsx | 10 +- web/src/marketing/Icon.tsx | 16 -- web/src/marketing/Nav.tsx | 2 +- web/src/marketing/content.ts | 67 +++++ web/src/marketing/home/ApproveWithProof.tsx | 47 ++++ web/src/marketing/home/ClosingCta.tsx | 16 ++ web/src/marketing/home/Examples.tsx | 43 ++++ web/src/marketing/home/Faq.tsx | 22 ++ web/src/marketing/home/Hero.tsx | 40 +++ web/src/marketing/home/PricingTeaser.tsx | 61 +++++ web/src/marketing/home/ProofStrip.tsx | 18 ++ web/src/marketing/home/SafeByDesign.tsx | 49 ++++ web/src/marketing/home/SectionHeader.tsx | 38 +++ web/src/marketing/home/SurfacesShowcase.tsx | 95 +++++++ web/src/marketing/home/WatchTheWork.tsx | 43 ++++ web/src/product/AgentCursor.tsx | 13 + web/src/product/ApprovalBar.tsx | 22 ++ web/src/product/ApprovalCard.tsx | 20 ++ web/src/product/Avatar.tsx | 23 ++ web/src/product/BoardCard.tsx | 38 +++ web/src/product/BoardStrip.tsx | 31 +++ web/src/product/Button.tsx | 23 ++ web/src/product/ChangeEvent.tsx | 38 +++ web/src/product/CheckEvent.tsx | 19 ++ web/src/product/CompareSlider.tsx | 68 +++++ web/src/product/Composer.tsx | 32 +++ web/src/product/ConversationPanel.tsx | 35 +++ web/src/product/Counter.tsx | 4 + web/src/product/DeviceFrame.tsx | 21 ++ web/src/product/HarborConversation.tsx | 15 ++ web/src/product/HarborStream.tsx | 58 +++++ web/src/product/IconButton.tsx | 18 ++ web/src/product/Message.tsx | 19 ++ web/src/product/Milestone.tsx | 17 ++ web/src/product/PointHighlight.tsx | 14 + web/src/product/PreviewColumn.tsx | 28 ++ web/src/product/PreviewToolbar.tsx | 28 ++ web/src/product/RiskChip.tsx | 13 + web/src/product/SegmentedControl.tsx | 27 ++ web/src/product/Sidebar.tsx | 55 ++++ web/src/product/SiteMock.tsx | 52 ++++ web/src/product/StatusPill.tsx | 18 ++ web/src/product/StepGroup.tsx | 22 ++ web/src/product/StepLine.tsx | 25 ++ web/src/product/Tab.tsx | 16 ++ web/src/product/Tag.tsx | 17 ++ web/src/product/Thought.tsx | 25 ++ web/src/product/TopBar.tsx | 42 +++ web/src/product/Workspace.tsx | 33 +++ web/src/product/harbor-bakery.ts | 50 ++++ web/src/product/index.ts | 35 +++ web/src/styles/marketing.css | 183 +++++++++++++ web/src/styles/product.css | 243 ++++++++++++++++++ web/src/styles/tokens.css | 17 +- web/src/ui/Fit.tsx | 53 ++++ web/src/ui/Icon.tsx | 18 ++ web/src/ui/Zoom.tsx | 13 + 110 files changed, 3135 insertions(+), 37 deletions(-) create mode 100644 docs/decisions/0023-product-components-and-contrast.md create mode 100644 web/__tests__/marketing/home.test.tsx create mode 100644 web/__tests__/product/conversation.test.tsx create mode 100644 web/__tests__/product/primitives.test.tsx create mode 100644 web/__tests__/product/workspace.test.tsx create mode 100644 web/public/icons/README.md create mode 100644 web/public/icons/activity.svg create mode 100644 web/public/icons/arrow-right.svg create mode 100644 web/public/icons/bell.svg create mode 100644 web/public/icons/board.svg create mode 100644 web/public/icons/check-circle.svg create mode 100644 web/public/icons/check.svg create mode 100644 web/public/icons/chevron-down.svg rename web/public/{marketing => }/icons/chevron-right.svg (100%) create mode 100644 web/public/icons/chevron-up.svg create mode 100644 web/public/icons/clock.svg create mode 100644 web/public/icons/close.svg create mode 100644 web/public/icons/dots.svg create mode 100644 web/public/icons/eye.svg create mode 100644 web/public/icons/globe.svg create mode 100644 web/public/icons/home.svg create mode 100644 web/public/icons/list.svg create mode 100644 web/public/icons/loader.svg create mode 100644 web/public/icons/lock.svg rename web/public/{marketing => }/icons/menu.svg (100%) create mode 100644 web/public/icons/mic.svg create mode 100644 web/public/icons/monitor.svg create mode 100644 web/public/icons/paperclip.svg create mode 100644 web/public/icons/pencil.svg create mode 100644 web/public/icons/plus.svg create mode 100644 web/public/icons/pointer.svg create mode 100644 web/public/icons/refresh.svg create mode 100644 web/public/icons/search.svg create mode 100644 web/public/icons/shield-check.svg create mode 100644 web/public/icons/shield.svg create mode 100644 web/public/icons/sidebar.svg create mode 100644 web/public/icons/sliders.svg create mode 100644 web/public/icons/smartphone.svg create mode 100644 web/public/icons/sparkles.svg create mode 100644 web/public/icons/split.svg create mode 100644 web/public/icons/stop.svg create mode 100644 web/public/icons/undo.svg create mode 100644 web/public/marketing/avatar-mark-dark.svg create mode 100644 web/public/marketing/avatar-mark.svg create mode 100644 web/public/marketing/backdrop.svg delete mode 100644 web/src/marketing/Icon.tsx create mode 100644 web/src/marketing/home/ApproveWithProof.tsx create mode 100644 web/src/marketing/home/ClosingCta.tsx create mode 100644 web/src/marketing/home/Examples.tsx create mode 100644 web/src/marketing/home/Faq.tsx create mode 100644 web/src/marketing/home/Hero.tsx create mode 100644 web/src/marketing/home/PricingTeaser.tsx create mode 100644 web/src/marketing/home/ProofStrip.tsx create mode 100644 web/src/marketing/home/SafeByDesign.tsx create mode 100644 web/src/marketing/home/SectionHeader.tsx create mode 100644 web/src/marketing/home/SurfacesShowcase.tsx create mode 100644 web/src/marketing/home/WatchTheWork.tsx create mode 100644 web/src/product/AgentCursor.tsx create mode 100644 web/src/product/ApprovalBar.tsx create mode 100644 web/src/product/ApprovalCard.tsx create mode 100644 web/src/product/Avatar.tsx create mode 100644 web/src/product/BoardCard.tsx create mode 100644 web/src/product/BoardStrip.tsx create mode 100644 web/src/product/Button.tsx create mode 100644 web/src/product/ChangeEvent.tsx create mode 100644 web/src/product/CheckEvent.tsx create mode 100644 web/src/product/CompareSlider.tsx create mode 100644 web/src/product/Composer.tsx create mode 100644 web/src/product/ConversationPanel.tsx create mode 100644 web/src/product/Counter.tsx create mode 100644 web/src/product/DeviceFrame.tsx create mode 100644 web/src/product/HarborConversation.tsx create mode 100644 web/src/product/HarborStream.tsx create mode 100644 web/src/product/IconButton.tsx create mode 100644 web/src/product/Message.tsx create mode 100644 web/src/product/Milestone.tsx create mode 100644 web/src/product/PointHighlight.tsx create mode 100644 web/src/product/PreviewColumn.tsx create mode 100644 web/src/product/PreviewToolbar.tsx create mode 100644 web/src/product/RiskChip.tsx create mode 100644 web/src/product/SegmentedControl.tsx create mode 100644 web/src/product/Sidebar.tsx create mode 100644 web/src/product/SiteMock.tsx create mode 100644 web/src/product/StatusPill.tsx create mode 100644 web/src/product/StepGroup.tsx create mode 100644 web/src/product/StepLine.tsx create mode 100644 web/src/product/Tab.tsx create mode 100644 web/src/product/Tag.tsx create mode 100644 web/src/product/Thought.tsx create mode 100644 web/src/product/TopBar.tsx create mode 100644 web/src/product/Workspace.tsx create mode 100644 web/src/product/harbor-bakery.ts create mode 100644 web/src/product/index.ts create mode 100644 web/src/styles/product.css create mode 100644 web/src/ui/Fit.tsx create mode 100644 web/src/ui/Icon.tsx create mode 100644 web/src/ui/Zoom.tsx 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 ( <>