diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fc598..430bb02 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 -- 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. +- Web: the five other marketing pages (Product, How it works, Trust, Pricing, Docs), a marketing e2e check (`pnpm --filter @wursor/e2e test:marketing`), and product components for setup, activity and search. 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/e2e/package.json b/e2e/package.json index d4d5473..6db014a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -6,7 +6,8 @@ "test": "vitest run", "golden": "tsx golden/src/run-golden.ts", "mirror:time": "tsx golden/src/run-mirror-timing.ts", - "agent:spike": "tsx agent/run-agent-spike.ts" + "agent:spike": "tsx agent/run-agent-spike.ts", + "test:marketing": "playwright test --config playwright.marketing.config.ts" }, "devDependencies": { "@playwright/test": "^1.62.1", diff --git a/e2e/playwright.marketing.config.ts b/e2e/playwright.marketing.config.ts new file mode 100644 index 0000000..7d1e296 --- /dev/null +++ b/e2e/playwright.marketing.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test'; + +/** Marketing-site checks only: needs the web app, not the API. Run with `pnpm --filter @wursor/e2e test:marketing`. */ +export default defineConfig({ + testDir: './tests', + testMatch: 'marketing.test.ts', + fullyParallel: true, + retries: 0, + use: { baseURL: 'http://localhost:5173', launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH ?? undefined } }, + webServer: { + command: 'pnpm --filter @wursor/web dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/e2e/tests/marketing.test.ts b/e2e/tests/marketing.test.ts new file mode 100644 index 0000000..5d21f7c --- /dev/null +++ b/e2e/tests/marketing.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; + +const PAGES = ['/', '/product', '/how-it-works', '/trust', '/pricing', '/docs']; + +for (const path of PAGES) { + test(`${path}: every Get started and Sign in goes to sign-up or sign-in (PX-13)`, async ({ page }) => { + await page.goto(path); + const links = page.getByRole('link', { name: /^(Get started|Sign in)$/ }); + const hrefs = await links.evaluateAll((els) => els.map((e) => e.getAttribute('href'))); + expect(hrefs.length).toBeGreaterThanOrEqual(2); + for (const href of hrefs) expect(['/signup', '/signin']).toContain(href); + }); + + test(`${path}: has one page heading and no horizontal scroll on a phone`, async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(path); + await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); + }); +} + +test('the navigation reaches every marketing page', async ({ page }) => { + await page.goto('/'); + for (const [name, path] of [['Product', '/product'], ['How it works', '/how-it-works'], ['Trust', '/trust'], ['Pricing', '/pricing'], ['Docs', '/docs']] as const) { + await page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name }).click(); + await expect(page).toHaveURL(new RegExp(`${path}$`)); + await expect(page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name })).toHaveAttribute('aria-current', 'page'); + } +}); + +test('the mobile menu opens and lists every page', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.getByRole('button', { name: 'Open menu' }).click(); + await expect(page.getByRole('navigation', { name: 'Mobile' }).getByRole('link')).toHaveCount(6); +}); diff --git a/web/__tests__/marketing/pages.test.tsx b/web/__tests__/marketing/pages.test.tsx new file mode 100644 index 0000000..aca5569 --- /dev/null +++ b/web/__tests__/marketing/pages.test.tsx @@ -0,0 +1,188 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import DocsPage from '../../app/(marketing)/docs/page.tsx'; +import HowItWorksPage from '../../app/(marketing)/how-it-works/page.tsx'; +import PricingPage from '../../app/(marketing)/pricing/page.tsx'; +import ProductPage from '../../app/(marketing)/product/page.tsx'; +import TrustPage from '../../app/(marketing)/trust/page.tsx'; +import { AlwaysNever } from '../../src/marketing/pages/AlwaysNever.tsx'; +import { ComparePlans } from '../../src/marketing/pages/ComparePlans.tsx'; +import { FeatureGrid } from '../../src/marketing/pages/FeatureGrid.tsx'; +import { FeatureRow } from '../../src/marketing/pages/FeatureRow.tsx'; +import { PageHero } from '../../src/marketing/pages/PageHero.tsx'; +import { PopularArticles } from '../../src/marketing/pages/PopularArticles.tsx'; +import { PricingContent } from '../../src/marketing/pages/PricingContent.tsx'; +import { Button } from '../../src/marketing/Button.tsx'; + +const PAGES = [ + ['Product', ProductPage, 'One workspace for every change.'], + ['How it works', HowItWorksPage, 'Four steps. You stay in charge.'], + ['Trust', TrustPage, 'Safe by design, not by promise.'], + ['Pricing', PricingPage, 'Simple plans. No surprises.'], + ['Docs', DocsPage, 'Find an answer fast.'], +] as const; + +describe.each(PAGES)('%s page', (_name, Page, heading) => { + it('has one page heading, the navigation and the footer', () => { + render(); + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); + expect(screen.getByRole('heading', { level: 1, name: heading })).toBeInTheDocument(); + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument(); + expect(screen.getByRole('contentinfo')).toBeInTheDocument(); + expect(screen.getByRole('main')).toBeInTheDocument(); + }); + + it('sends every Get started and Sign in to sign-up or sign-in (PX-13)', () => { + render(); + const ctas = screen.getAllByRole('link', { name: /^(Get started|Sign in)$/ }); + expect(ctas.length).toBeGreaterThanOrEqual(2); + for (const a of ctas) expect(['/signup', '/signin']).toContain(a.getAttribute('href')); + }); + + it('marks its own link in the navigation', () => { + render(); + const current = within(screen.getByRole('navigation', { name: 'Primary' })).queryAllByRole('link', { current: 'page' }); + expect(current.length).toBeLessThanOrEqual(1); + }); +}); + +describe('PageHero', () => { + it('has the page heading, a supporting sentence and its actions', () => { + render( + + + , + ); + expect(screen.getByRole('heading', { level: 1, name: 'One workspace for every change.' })).toBeInTheDocument(); + expect(screen.getByText('Product')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Get started' })).toBeInTheDocument(); + }); +}); + +describe('FeatureRow', () => { + it('has a heading, bullets and a picture that is hidden from assistive technology', () => { + const { container } = render( + +

card

+
, + ); + expect(screen.getByRole('heading', { level: 2, name: 'Start or connect your site' })).toBeInTheDocument(); + expect(screen.getAllByRole('listitem').map((i) => i.textContent)).toEqual(['No jargon', 'Undo any time']); + expect(container.querySelector('[aria-hidden="true"][inert]')).not.toBeNull(); + }); + + it('can put the picture first', () => { + const { container } = render( + +

card

+
, + ); + expect(container.querySelector('.m-row')).toHaveClass('m-row-reverse'); + }); +}); + +describe('FeatureGrid', () => { + it('shows a heading and one card per feature', () => { + render( + , + ); + expect(screen.getByRole('heading', { level: 2, name: 'Everything you need.' })).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent)).toEqual(['Ask in plain words', 'One-click undo']); + }); +}); + +describe('AlwaysNever', () => { + it('lists what Wursor always does and never does', () => { + render(); + expect(screen.getByRole('list', { name: 'Always' })).toBeInTheDocument(); + expect(screen.getByRole('list', { name: 'Never' })).toBeInTheDocument(); + expect(within(screen.getByRole('list', { name: 'Always' })).getAllByRole('listitem')).toHaveLength(5); + expect(within(screen.getByRole('list', { name: 'Never' })).getAllByRole('listitem')).toHaveLength(5); + expect(screen.getByText('Publishes without your approval')).toBeInTheDocument(); + }); +}); + +describe('ComparePlans', () => { + it('compares the three plans in a table', () => { + render(); + const table = screen.getByRole('table', { name: 'Compare plans' }); + expect(within(table).getAllByRole('columnheader').map((c) => c.textContent)).toEqual(['Feature', 'Starter', 'Studio', 'Agency']); + expect(within(table).getAllByRole('row').length).toBeGreaterThan(8); + }); +}); + +describe('PopularArticles', () => { + it('lists articles; only those with a page are links', () => { + render(); + expect(screen.getByRole('heading', { level: 2, name: 'Popular articles' })).toBeInTheDocument(); + expect(screen.getByText('Undo a published change')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'How approval works' })).toHaveAttribute('href', '/trust'); + expect(screen.queryByRole('link', { name: 'Invite your team' })).not.toBeInTheDocument(); + }); +}); + +describe('Pricing page content', () => { + it('does not show prices, allowances or the free trial until they are decided (PX-13)', () => { + render(); + expect(screen.queryByText(/\$\d/)).not.toBeInTheDocument(); + expect(screen.queryByText(/free for 14 days/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.getByText('Plans and prices will be published before launch.')).toBeInTheDocument(); + }); +}); + +describe('PricingContent when pricing is confirmed', () => { + it('shows the free trial, the plan cards and the comparison table', () => { + render(); + expect(screen.getByText(/Start free for 14 days/)).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { level: 3, name: /Starter|Studio|Agency/ })).toHaveLength(3); + expect(screen.getByRole('table', { name: 'Compare plans' })).toBeInTheDocument(); + expect(screen.getByText('Draft pricing. Final plans and prices will be confirmed before launch.')).toBeInTheDocument(); + }); +}); + +describe('Docs page content', () => { + it('has a search field, six topics and the popular articles', () => { + render(); + expect(screen.getByRole('searchbox')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'Browse by topic' })).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(6); + expect(screen.getByRole('heading', { level: 2, name: 'Popular articles' })).toBeInTheDocument(); + }); +}); + +describe('How it works page content', () => { + it('walks through the four steps', () => { + render(); + for (const step of ['Start or connect your site', 'Ask for a change', 'Review the proof', 'Publish, or undo']) { + expect(screen.getByRole('heading', { level: 2, name: step })).toBeInTheDocument(); + } + }); +}); + +describe('Trust page content', () => { + it('shows the four rules, the boundaries, the record and the controls', () => { + render(); + expect(screen.getByRole('heading', { level: 3, name: 'Works on a safe copy' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'What Wursor does, and what it never does.' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'A record you can read.' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'You decide who can do what.' })).toBeInTheDocument(); + }); +}); + +describe('Product page content', () => { + it('shows the workspace, six features and where the site lives', () => { + render(); + expect(screen.getByRole('tab', { name: 'Conversation' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'Everything you need to hand off the work.' })).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(6); + expect(screen.getByRole('heading', { level: 2, name: 'Choose where it lives. Keep your hands free.' })).toBeInTheDocument(); + }); +}); diff --git a/web/__tests__/product/onboarding.test.tsx b/web/__tests__/product/onboarding.test.tsx new file mode 100644 index 0000000..3f6147a --- /dev/null +++ b/web/__tests__/product/onboarding.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { ActivityCard } from '../../src/product/ActivityCard.tsx'; +import { Kbd } from '../../src/product/Kbd.tsx'; +import { SearchField } from '../../src/product/SearchField.tsx'; +import { SetupChecklist } from '../../src/product/SetupChecklist.tsx'; + +const STEPS = [ + { state: 'done', title: 'Site address added', detail: 'harborbakery.com' }, + { state: 'done', title: 'Connection verified', detail: 'Secure, read and write' }, + { state: 'next', title: 'Safe copy ready', detail: 'Wursor is creating it' }, + { state: 'todo', title: 'Make your first change', detail: 'Pick a suggestion' }, +] as const; + +describe('SetupChecklist', () => { + it('lists the steps in order, saying which is done, which is next and which is to do', () => { + render(); + const items = within(screen.getByRole('list', { name: 'Setup steps' })).getAllByRole('listitem'); + expect(items).toHaveLength(4); + expect(items.map((i) => i.getAttribute('data-state'))).toEqual(['done', 'done', 'next', 'todo']); + expect(within(items[0]!).getByText('Done')).toBeInTheDocument(); + expect(within(items[2]!).getByText('Next')).toBeInTheDocument(); + expect(within(items[3]!).getByText('To do')).toBeInTheDocument(); + }); + + it('marks the next step as the current one', () => { + render(); + expect(screen.getByText('Safe copy ready').closest('li')).toHaveAttribute('aria-current', 'step'); + }); + + it('offers Continue on the next step only', async () => { + const onContinue = vi.fn(); + render(); + expect(screen.getAllByRole('button', { name: 'Continue' })).toHaveLength(1); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(onContinue).toHaveBeenCalledOnce(); + }); +}); + +describe('ActivityCard', () => { + const rows = [ + { kind: 'published', text: 'Published the Contact page', time: 'Just now' }, + { kind: 'check', text: 'Checked 9 things, all passed', time: '2 min ago' }, + { kind: 'change', text: 'Changed the Contact page', time: '3 min ago' }, + { kind: 'safe-copy', text: 'Made a safe copy of your site', time: '5 min ago' }, + ] as const; + + it('shows what happened and when, newest first', () => { + render(); + const items = within(screen.getByRole('list', { name: 'Activity' })).getAllByRole('listitem'); + expect(items.map((i) => i.querySelector('.p-activity-text')?.textContent)).toEqual(rows.map((r) => r.text)); + expect(within(items[0]!).getByText('Just now')).toBeInTheDocument(); + }); + + it('gives each kind its own icon', () => { + render(); + const kinds = screen.getAllByRole('listitem').map((i) => i.getAttribute('data-kind')); + expect(kinds).toEqual(['published', 'check', 'change', 'safe-copy']); + }); +}); + +describe('Kbd and SearchField', () => { + it('shows a key cap', () => { + render(⌘K); + expect(screen.getByText('⌘K').tagName).toBe('KBD'); + }); + + it('is a labelled search box with a shortcut hint', async () => { + render(); + const box = screen.getByRole('searchbox', { name: 'Search the docs' }); + expect(box).toHaveAttribute('placeholder', 'Search or ask…'); + expect(screen.getByText('⌘K')).toBeInTheDocument(); + await userEvent.type(box, 'undo'); + expect(box).toHaveValue('undo'); + }); + + it('can be shown but not used until search exists', () => { + render(); + expect(screen.getByRole('searchbox', { name: 'Search the docs' })).toBeDisabled(); + }); +}); diff --git a/web/app/(marketing)/docs/page.tsx b/web/app/(marketing)/docs/page.tsx new file mode 100644 index 0000000..75ba274 --- /dev/null +++ b/web/app/(marketing)/docs/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from 'next'; +import { SearchField } from '../../../src/product/SearchField.tsx'; +import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx'; +import { FeatureGrid } from '../../../src/marketing/pages/FeatureGrid.tsx'; +import { PageHero } from '../../../src/marketing/pages/PageHero.tsx'; +import { PopularArticles } from '../../../src/marketing/pages/PopularArticles.tsx'; +import { Shell } from '../../../src/marketing/Shell.tsx'; + +export const metadata: Metadata = { title: 'Docs' }; + +export default function DocsPage() { + return ( + + +
+ {/* Search is shown as in the design but not usable until the docs exist. */} + +
+
+ + + +
+ ); +} diff --git a/web/app/(marketing)/how-it-works/page.tsx b/web/app/(marketing)/how-it-works/page.tsx new file mode 100644 index 0000000..ff2843d --- /dev/null +++ b/web/app/(marketing)/how-it-works/page.tsx @@ -0,0 +1,77 @@ +import type { Metadata } from 'next'; +import { ActivityCard } from '../../../src/product/ActivityCard.tsx'; +import { ApprovalCard } from '../../../src/product/ApprovalCard.tsx'; +import { HarborConversation } from '../../../src/product/HarborConversation.tsx'; +import { SetupChecklist } from '../../../src/product/SetupChecklist.tsx'; +import { Zoom } from '../../../src/ui/Zoom.tsx'; +import { Button } from '../../../src/marketing/Button.tsx'; +import { ROUTES } from '../../../src/marketing/content.ts'; +import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx'; +import { Faq } from '../../../src/marketing/home/Faq.tsx'; +import { FeatureRow } from '../../../src/marketing/pages/FeatureRow.tsx'; +import { PageHero } from '../../../src/marketing/pages/PageHero.tsx'; +import { ACTIVITY_ROWS, SETUP_STEPS } from '../../../src/marketing/pages/setup-steps.ts'; +import { Shell } from '../../../src/marketing/Shell.tsx'; + +export const metadata: Metadata = { title: 'How it works' }; + +export default function HowItWorksPage() { + return ( + + + + + + + + +
+ +
+ +
+
+
+
+ + + + + + + + +
+ ); +} diff --git a/web/app/(marketing)/page.tsx b/web/app/(marketing)/page.tsx index 0b5c641..29f8f8e 100644 --- a/web/app/(marketing)/page.tsx +++ b/web/app/(marketing)/page.tsx @@ -1,4 +1,4 @@ -import { Footer } from '../../src/marketing/Footer.tsx'; +import { Shell } from '../../src/marketing/Shell.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'; @@ -10,33 +10,28 @@ 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 ( - <> -