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( + + Get started + , + ); + 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 ( + + + + Get started + + + + + + + + + + + + + + + + + + + + + + + + ); +} 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 ( - <> - - - - - - - - - - - - - - - - - - > + + + + + + + + + + + + + + + ); } diff --git a/web/app/(marketing)/pricing/page.tsx b/web/app/(marketing)/pricing/page.tsx new file mode 100644 index 0000000..cdde042 --- /dev/null +++ b/web/app/(marketing)/pricing/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; +import { PricingContent } from '../../../src/marketing/pages/PricingContent.tsx'; +import { Shell } from '../../../src/marketing/Shell.tsx'; + +export const metadata: Metadata = { title: 'Pricing' }; + +export default function PricingPage() { + return ( + + + + ); +} diff --git a/web/app/(marketing)/product/page.tsx b/web/app/(marketing)/product/page.tsx new file mode 100644 index 0000000..88dcdd1 --- /dev/null +++ b/web/app/(marketing)/product/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from 'next'; +import { SetupChecklist } from '../../../src/product/SetupChecklist.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 { SurfacesShowcase } from '../../../src/marketing/home/SurfacesShowcase.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 { SETUP_STEPS } from '../../../src/marketing/pages/setup-steps.ts'; +import { Shell } from '../../../src/marketing/Shell.tsx'; + +export const metadata: Metadata = { title: 'Product' }; + +export default function ProductPage() { + return ( + + + + Get started + + + See how it works + + + + + + + + + + + + + ); +} diff --git a/web/app/(marketing)/trust/page.tsx b/web/app/(marketing)/trust/page.tsx new file mode 100644 index 0000000..b62e92a --- /dev/null +++ b/web/app/(marketing)/trust/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from 'next'; +import { ActivityCard } from '../../../src/product/ActivityCard.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 { SafetyRules } from '../../../src/marketing/home/SafeByDesign.tsx'; +import { AlwaysNever } from '../../../src/marketing/pages/AlwaysNever.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 { ACTIVITY_ROWS } from '../../../src/marketing/pages/setup-steps.ts'; +import { Shell } from '../../../src/marketing/Shell.tsx'; + +export const metadata: Metadata = { title: 'Trust and safety' }; + +export default function TrustPage() { + return ( + + + + Get started + + + + + + + + + + + + + + ); +} diff --git a/web/next.config.ts b/web/next.config.ts index 00bcb71..ca6fa93 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -4,6 +4,8 @@ const apiOrigin = process.env.WURSOR_API_URL ?? 'http://localhost:3000'; const config: NextConfig = { reactStrictMode: true, + // Do not let `next dev` write its own agent instructions into web/. The repository's AGENTS.md is the guide. + agentRules: false, // Interim: the Sprint 1 shell at /app still calls the API on the same origin. // Replaced by server-held sessions (ADR 0018, step C). async rewrites() { diff --git a/web/public/icons/camera.svg b/web/public/icons/camera.svg new file mode 100644 index 0000000..8cee1b9 --- /dev/null +++ b/web/public/icons/camera.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/icons/credit-card.svg b/web/public/icons/credit-card.svg new file mode 100644 index 0000000..7f061e1 --- /dev/null +++ b/web/public/icons/credit-card.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/public/icons/help.svg b/web/public/icons/help.svg new file mode 100644 index 0000000..b5fe606 --- /dev/null +++ b/web/public/icons/help.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/icons/key.svg b/web/public/icons/key.svg new file mode 100644 index 0000000..20332e6 --- /dev/null +++ b/web/public/icons/key.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/public/icons/plug.svg b/web/public/icons/plug.svg new file mode 100644 index 0000000..383a5da --- /dev/null +++ b/web/public/icons/plug.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/public/icons/rocket.svg b/web/public/icons/rocket.svg new file mode 100644 index 0000000..e221e79 --- /dev/null +++ b/web/public/icons/rocket.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/icons/user.svg b/web/public/icons/user.svg new file mode 100644 index 0000000..bf1f05f --- /dev/null +++ b/web/public/icons/user.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/icons/x.svg b/web/public/icons/x.svg new file mode 100644 index 0000000..9d6ee6c --- /dev/null +++ b/web/public/icons/x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/marketing/Nav.tsx b/web/src/marketing/Nav.tsx index a6d2133..a9df028 100644 --- a/web/src/marketing/Nav.tsx +++ b/web/src/marketing/Nav.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; import { BrandMark } from './BrandMark.tsx'; import { Button } from './Button.tsx'; @@ -9,6 +10,7 @@ import { Icon } from '../ui/Icon.tsx'; export function Nav() { const [open, setOpen] = useState(false); + const pathname = usePathname(); useEffect(() => { if (!open) return; @@ -29,7 +31,7 @@ export function Nav() { {NAV_LINKS.map((l) => ( - + {l.label} ))} @@ -58,7 +60,7 @@ export function Nav() { {open && ( {NAV_LINKS.map((l) => ( - setOpen(false)}> + setOpen(false)}> {l.mobileLabel ?? l.label} ))} diff --git a/web/src/marketing/Shell.tsx b/web/src/marketing/Shell.tsx new file mode 100644 index 0000000..6c3f241 --- /dev/null +++ b/web/src/marketing/Shell.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { Footer } from './Footer.tsx'; +import { Nav } from './Nav.tsx'; + +/** Navigation, the page, and the footer. Every marketing page is wrapped in this. */ +export function Shell({ children, page = false }: { children: ReactNode; /** Inner pages use a tighter rhythm than the home page. */ page?: boolean }) { + return ( + <> + + {children} + + > + ); +} diff --git a/web/src/marketing/content.ts b/web/src/marketing/content.ts index e15ae11..53c4898 100644 --- a/web/src/marketing/content.ts +++ b/web/src/marketing/content.ts @@ -110,3 +110,25 @@ export const FAQ: ReadonlyArray<{ question: string; answer: string; open?: boole '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.', }, ]; + +/** Rows of the plan comparison (draft numbers from the design; shown only when PRICING_CONFIRMED). */ +export const COMPARE_ROWS: ReadonlyArray<{ label: string; values: readonly [string, string, string] }> = [ + { label: 'Sites', values: ['1', '5', '25'] }, + { label: 'Changes per month', values: ['50', 'Unlimited', 'Unlimited'] }, + { label: 'Safe copy before every publish', values: ['Included', 'Included', 'Included'] }, + { label: 'Proof and nine checks', values: ['Included', 'Included', 'Included'] }, + { label: 'Undo history', values: ['30 days', '90 days', '1 year'] }, + { label: 'Team members', values: ['1', '5', 'Unlimited'] }, + { label: 'Client sharing', values: ['—', '—', 'Included'] }, + { label: 'Support', values: ['Email', 'Priority email', 'Priority and onboarding'] }, +]; + +/** Docs articles. An article without an href has no page yet. */ +export const ARTICLES: ReadonlyArray<{ title: string; href?: string }> = [ + { title: 'Start or connect a site' }, + { title: 'What happens when I ask for a change' }, + { title: 'How approval works', href: '/trust' }, + { title: 'Undo a published change' }, + { title: 'Invite your team' }, + { title: 'Change or cancel your plan' }, +]; diff --git a/web/src/marketing/home/PricingTeaser.tsx b/web/src/marketing/home/PricingTeaser.tsx index fad9b1c..1c9653f 100644 --- a/web/src/marketing/home/PricingTeaser.tsx +++ b/web/src/marketing/home/PricingTeaser.tsx @@ -3,6 +3,38 @@ import { Button } from '../Button.tsx'; import { PLAN_FEATURES, PLANS, PRICING_CONFIRMED, ROUTES } from '../content.ts'; import { SectionHeader } from './SectionHeader.tsx'; +/** The three plan cards. Draft numbers: render only when pricing is confirmed. */ +export function PlanCards() { + return ( + + {PLANS.map((p) => ( + + + {p.name} + {p.featured && Most popular} + + + {p.price} + per month + + {p.allowance} + + {PLAN_FEATURES.map((f) => ( + + + {f} + + ))} + + + Choose plan + + + ))} + + ); +} + /** * 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. @@ -30,32 +62,7 @@ export function PricingTeaser({ confirmed = PRICING_CONFIRMED }: { confirmed?: b title="Simple plans. No surprises." subtitle="Hosting on Wursor Cloud is included. Change or cancel any time." /> - - {PLANS.map((p) => ( - - - {p.name} - {p.featured && Most popular} - - - {p.price} - per month - - {p.allowance} - - {PLAN_FEATURES.map((f) => ( - - - {f} - - ))} - - - Choose plan - - - ))} - + ); } diff --git a/web/src/marketing/home/SafeByDesign.tsx b/web/src/marketing/home/SafeByDesign.tsx index a54da34..c21e4dd 100644 --- a/web/src/marketing/home/SafeByDesign.tsx +++ b/web/src/marketing/home/SafeByDesign.tsx @@ -24,6 +24,23 @@ const RULES: ReadonlyArray<{ icon: IconName; title: string; body: string }> = [ }, ]; +/** The four rules as cards. Used on the home page and on the Trust page. */ +export function SafetyRules() { + return ( + + {RULES.map((r) => ( + + + + + {r.title} + {r.body} + + ))} + + ); +} + export function SafeByDesign() { return ( @@ -33,17 +50,7 @@ export function SafeByDesign() { subtitle="Four rules Wursor can’t break, however you set it up." mobileSubtitle="Four rules Wursor can’t break." /> - - {RULES.map((r) => ( - - - - - {r.title} - {r.body} - - ))} - + ); } diff --git a/web/src/marketing/home/SectionHeader.tsx b/web/src/marketing/home/SectionHeader.tsx index b99c737..bb5b162 100644 --- a/web/src/marketing/home/SectionHeader.tsx +++ b/web/src/marketing/home/SectionHeader.tsx @@ -12,22 +12,24 @@ export function Responsive({ desktop, mobile }: { desktop: ReactNode; mobile?: R } type Props = { - eyebrow: string; + eyebrow?: string; title: string; subtitle?: string; mobileTitle?: string; mobileSubtitle?: string; align?: 'center' | 'left'; + /** Use h1 for the page's first heading only. */ + as?: 'h1' | 'h2'; }; /** Eyebrow, headline and one supporting sentence. Centred by default. */ -export function SectionHeader({ eyebrow, title, subtitle, mobileTitle, mobileSubtitle, align = 'center' }: Props) { +export function SectionHeader({ eyebrow, title, subtitle, mobileTitle, mobileSubtitle, align = 'center', as: Heading = 'h2' }: Props) { return ( - {eyebrow} - + {eyebrow && {eyebrow}} + - + {subtitle && ( diff --git a/web/src/marketing/pages/AlwaysNever.tsx b/web/src/marketing/pages/AlwaysNever.tsx new file mode 100644 index 0000000..77f332c --- /dev/null +++ b/web/src/marketing/pages/AlwaysNever.tsx @@ -0,0 +1,46 @@ +import { Icon } from '../../ui/Icon.tsx'; +import { SectionHeader } from '../home/SectionHeader.tsx'; + +const ALWAYS = [ + 'Works on a safe copy first', + 'Shows you the result before it goes live', + 'Runs checks on every change', + 'Asks before anything is published', + 'Records every step', +] as const; + +const NEVER = [ + 'Publishes without your approval', + 'Changes your live site while you are reviewing', + 'Deletes content without a way back', + 'Hides what it did or why', + 'Keeps working after you pause it', +] as const; + +function Column({ title, items, kind }: { title: string; items: readonly string[]; kind: 'always' | 'never' }) { + return ( + + {title} + + {items.map((i) => ( + + + {i} + + ))} + + + ); +} + +export function AlwaysNever() { + return ( + + + + + + + + ); +} diff --git a/web/src/marketing/pages/ComparePlans.tsx b/web/src/marketing/pages/ComparePlans.tsx new file mode 100644 index 0000000..9060eaa --- /dev/null +++ b/web/src/marketing/pages/ComparePlans.tsx @@ -0,0 +1,41 @@ +import { COMPARE_ROWS, PLANS } from '../content.ts'; +import { SectionHeader } from '../home/SectionHeader.tsx'; + +/** Plans side by side. Shown only once pricing is decided (PRD D-HL-8, PX-13). */ +export function ComparePlans() { + return ( + + + + + + + + Feature + + {PLANS.map((p) => ( + + {p.name} + + ))} + + + + {COMPARE_ROWS.map((r) => ( + + + {r.label} + + {r.values.map((v, i) => ( + + {v} + + ))} + + ))} + + + + + ); +} diff --git a/web/src/marketing/pages/FeatureGrid.tsx b/web/src/marketing/pages/FeatureGrid.tsx new file mode 100644 index 0000000..fc22db1 --- /dev/null +++ b/web/src/marketing/pages/FeatureGrid.tsx @@ -0,0 +1,26 @@ +import { Icon, type IconName } from '../../ui/Icon.tsx'; +import { SectionHeader } from '../home/SectionHeader.tsx'; + +export type FeatureCardData = { icon: IconName; title: string; body: string }; + +type Props = { eyebrow?: string; title: string; subtitle?: string; cards: readonly FeatureCardData[] }; + +/** A heading and a grid of icon cards, three across. */ +export function FeatureGrid({ eyebrow, title, subtitle, cards }: Props) { + return ( + + + + {cards.map((c) => ( + + + + + {c.title} + {c.body} + + ))} + + + ); +} diff --git a/web/src/marketing/pages/FeatureRow.tsx b/web/src/marketing/pages/FeatureRow.tsx new file mode 100644 index 0000000..066c9b3 --- /dev/null +++ b/web/src/marketing/pages/FeatureRow.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; +import { Icon } from '../../ui/Icon.tsx'; +import { SectionHeader } from '../home/SectionHeader.tsx'; +import { RowVisual } from './RowVisual.tsx'; + +type Props = { + eyebrow: string; + title: string; + subtitle: string; + bullets: readonly string[]; + /** The card shown on the backdrop. */ + children: ReactNode; + card?: { left: number; top: number; width: number; height?: number }; + /** Put the picture before the text. */ + reverse?: boolean; +}; + +/** Text with a short list on one side and a picture on the other. */ +export function FeatureRow({ eyebrow, title, subtitle, bullets, children, card, reverse = false }: Props) { + return ( + + + + {bullets.length > 0 && ( + + {bullets.map((b) => ( + + + {b} + + ))} + + )} + + {children} + + ); +} diff --git a/web/src/marketing/pages/PageHero.tsx b/web/src/marketing/pages/PageHero.tsx new file mode 100644 index 0000000..316eb25 --- /dev/null +++ b/web/src/marketing/pages/PageHero.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { SectionHeader } from '../home/SectionHeader.tsx'; + +type Props = { eyebrow: string; title: string; subtitle?: string; children?: ReactNode }; + +/** The top of a marketing page: eyebrow, the page heading, one supporting sentence and the page's actions. */ +export function PageHero({ eyebrow, title, subtitle, children }: Props) { + return ( + + + {children && {children}} + + ); +} diff --git a/web/src/marketing/pages/PopularArticles.tsx b/web/src/marketing/pages/PopularArticles.tsx new file mode 100644 index 0000000..f51bb43 --- /dev/null +++ b/web/src/marketing/pages/PopularArticles.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link'; +import { Icon } from '../../ui/Icon.tsx'; +import { ARTICLES } from '../content.ts'; +import { SectionHeader } from '../home/SectionHeader.tsx'; + +/** Docs articles. Only those that have a page are links; the others are shown as plain rows until they are written. */ +export function PopularArticles() { + return ( + + + + {ARTICLES.map((a) => ( + + {a.href ? {a.title} : {a.title}} + + + ))} + + + ); +} diff --git a/web/src/marketing/pages/PricingContent.tsx b/web/src/marketing/pages/PricingContent.tsx new file mode 100644 index 0000000..0212b7e --- /dev/null +++ b/web/src/marketing/pages/PricingContent.tsx @@ -0,0 +1,44 @@ +import { Button } from '../Button.tsx'; +import { PRICING_CONFIRMED, ROUTES } from '../content.ts'; +import { Faq } from '../home/Faq.tsx'; +import { ClosingCta } from '../home/ClosingCta.tsx'; +import { PlanCards } from '../home/PricingTeaser.tsx'; +import { ComparePlans } from './ComparePlans.tsx'; +import { PageHero } from './PageHero.tsx'; + +/** + * The pricing page. Plans, prices, allowances and the free trial are shown only once they are decided + * (PRD D-HL-8, PX-13). Until then the page says so and still leads to sign-up. + */ +export function PricingContent({ confirmed = PRICING_CONFIRMED }: { confirmed?: boolean }) { + return ( + <> + + {!confirmed && ( + + Get started + + )} + + {confirmed && ( + <> + + + Draft pricing. Final plans and prices will be confirmed before launch. + + + > + )} + + + > + ); +} diff --git a/web/src/marketing/pages/RowVisual.tsx b/web/src/marketing/pages/RowVisual.tsx new file mode 100644 index 0000000..c8706bd --- /dev/null +++ b/web/src/marketing/pages/RowVisual.tsx @@ -0,0 +1,26 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Fit } from '../../ui/Fit.tsx'; + +type Props = { + children: ReactNode; + /** Where the card sits in the 600 × 480 picture. */ + card?: { left: number; top: number; width: number; height?: number }; +}; + +/** A 600 × 480 backdrop with one card on it, scaled to the column. Decorative: hidden from assistive technology. */ +export function RowVisual({ children, card = { left: 70, top: 95, width: 460 } }: Props) { + const style: CSSProperties = { left: card.left, top: card.top, width: card.width, ...(card.height ? { height: card.height } : {}) }; + return ( + + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + {children} + + + + + ); +} diff --git a/web/src/marketing/pages/setup-steps.ts b/web/src/marketing/pages/setup-steps.ts new file mode 100644 index 0000000..b2d14b9 --- /dev/null +++ b/web/src/marketing/pages/setup-steps.ts @@ -0,0 +1,16 @@ +import type { SetupStepData } from '../../product/SetupChecklist.tsx'; + +/** The example first-run checklist shown on the marketing pages (Harbor Bakery). */ +export const SETUP_STEPS: readonly SetupStepData[] = [ + { 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' }, +]; + +export const ACTIVITY_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; diff --git a/web/src/product/ActivityCard.tsx b/web/src/product/ActivityCard.tsx new file mode 100644 index 0000000..7979fee --- /dev/null +++ b/web/src/product/ActivityCard.tsx @@ -0,0 +1,25 @@ +import { Icon, type IconName } from '../ui/Icon.tsx'; + +export type ActivityKind = 'published' | 'check' | 'change' | 'safe-copy'; +export type ActivityRowData = { kind: ActivityKind; text: string; time: string }; + +const ICON: Record = { published: 'rocket', check: 'shield-check', change: 'pencil', 'safe-copy': 'camera' }; + +/** A short, plain-words record of what happened to a site, newest first. */ +export function ActivityCard({ rows }: { rows: readonly ActivityRowData[] }) { + return ( + + + {rows.map((r) => ( + + + + + {r.text} + {r.time} + + ))} + + + ); +} diff --git a/web/src/product/Kbd.tsx b/web/src/product/Kbd.tsx new file mode 100644 index 0000000..f5b4f91 --- /dev/null +++ b/web/src/product/Kbd.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react'; + +/** Keyboard key cap for shortcuts. */ +export function Kbd({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/web/src/product/SearchField.tsx b/web/src/product/SearchField.tsx new file mode 100644 index 0000000..f3de514 --- /dev/null +++ b/web/src/product/SearchField.tsx @@ -0,0 +1,21 @@ +import { Icon } from '../ui/Icon.tsx'; +import { Kbd } from './Kbd.tsx'; + +type Props = { + /** The accessible name. The placeholder is not a label. */ + label: string; + shortcut?: string; + placeholder?: string; + disabled?: boolean; +}; + +/** Pill search field with a shortcut hint. Used in the top bar, the command palette and the docs. */ +export function SearchField({ label, shortcut, placeholder = 'Search or ask…', disabled = false }: Props) { + return ( + + + + {shortcut && {shortcut}} + + ); +} diff --git a/web/src/product/SetupChecklist.tsx b/web/src/product/SetupChecklist.tsx new file mode 100644 index 0000000..6ad5172 --- /dev/null +++ b/web/src/product/SetupChecklist.tsx @@ -0,0 +1,35 @@ +import { Icon } from '../ui/Icon.tsx'; +import { Button } from './Button.tsx'; + +export type SetupStepData = { state: 'done' | 'next' | 'todo'; title: string; detail: string }; + +const STATE_LABEL = { done: 'Done', next: 'Next', todo: 'To do' } as const; + +type Props = { steps: readonly SetupStepData[]; onContinue?: (() => void) | undefined }; + +/** The first-run checklist: what is done, what is next (with Continue), and what is still to do. */ +export function SetupChecklist({ steps, onContinue }: Props) { + return ( + + + {steps.map((s) => ( + + + {s.state === 'done' && } + + + {STATE_LABEL[s.state]} + {s.title} + {s.detail} + + {s.state === 'next' && ( + + Continue + + )} + + ))} + + + ); +} diff --git a/web/src/product/index.ts b/web/src/product/index.ts index 0c81ba4..96ba742 100644 --- a/web/src/product/index.ts +++ b/web/src/product/index.ts @@ -1,4 +1,5 @@ export { AgentCursor } from './AgentCursor.tsx'; +export { ActivityCard, type ActivityRowData } from './ActivityCard.tsx'; export { ApprovalBar } from './ApprovalBar.tsx'; export { ApprovalCard } from './ApprovalCard.tsx'; export { Avatar } from './Avatar.tsx'; @@ -15,6 +16,7 @@ export { DeviceFrame } from './DeviceFrame.tsx'; export { HARBOR } from './harbor-bakery.ts'; export { HarborStream } from './HarborStream.tsx'; export { IconButton } from './IconButton.tsx'; +export { Kbd } from './Kbd.tsx'; export { Message } from './Message.tsx'; export { Milestone } from './Milestone.tsx'; export { HarborConversation } from './HarborConversation.tsx'; @@ -23,6 +25,8 @@ export { PreviewColumn } from './PreviewColumn.tsx'; export { PreviewToolbar, type PreviewView } from './PreviewToolbar.tsx'; export { RiskChip } from './RiskChip.tsx'; export { SegmentedControl } from './SegmentedControl.tsx'; +export { SearchField } from './SearchField.tsx'; +export { SetupChecklist, type SetupStepData } from './SetupChecklist.tsx'; export { Sidebar } from './Sidebar.tsx'; export { SiteMock } from './SiteMock.tsx'; export { StatusPill, type Status } from './StatusPill.tsx'; diff --git a/web/src/styles/marketing.css b/web/src/styles/marketing.css index 30f7590..b5e494a 100644 --- a/web/src/styles/marketing.css +++ b/web/src/styles/marketing.css @@ -298,3 +298,56 @@ main { display: block; } .m-cta-sub { display: none; } .footer { margin-top: 72px; } } + +/* ---------- Inner pages ---------- */ +.m-pagehero { display: flex; flex-direction: column; align-items: center; gap: 28px; max-width: 1200px; margin: 0 auto; padding: 72px var(--space-6) 0; text-align: center; } +.m-pagehero .m-hero-actions { align-items: center; } +.m-page .m-section, .m-page .m-cta, .m-page .m-row, .m-page .m-grid-section { margin-top: 140px; } +.m-page .m-grid-first { margin-top: 64px; } +.m-page .m-grid-tight { margin-top: 72px; } +.m-page .footer { margin-top: 140px; } +.m-grid-section { display: flex; flex-direction: column; align-items: center; gap: 48px; width: 100%; max-width: 1248px; margin: 0 auto; padding: 0 var(--space-6); } +.m-featuregrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-6); width: 100%; } +.m-featuregrid .m-rule h3 { color: var(--color-text-primary); } +.m-docs-search { width: 560px; max-width: 100%; } + +.m-row { display: flex; align-items: center; gap: 96px; max-width: 1248px; margin: 0 auto; padding: 0 var(--space-6); } +.m-row-reverse { flex-direction: row-reverse; } +.m-row-text { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; align-items: flex-start; gap: 20px; } +.m-rowvisual { position: relative; flex: none; width: 600px; max-width: 100%; overflow: hidden; border-radius: var(--radius-xl); text-align: left; } +.m-rowscene { position: relative; width: 600px; height: 480px; overflow: hidden; } +.m-rowbackdrop { position: absolute; top: -120px; left: -372px; display: block; width: 1344px; height: 720px; max-width: none; } +.m-rowcard { position: absolute; } +.m-convowindow { width: 448px; height: 424px; overflow: hidden; border-radius: 12.8px; background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); box-shadow: var(--shadow-modal); } + +.m-boundaries { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-6); width: 100%; } +.m-boundary { display: flex; flex-direction: column; gap: 20px; padding: var(--space-8); border-radius: 20px; background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); } +.m-boundary h3 { margin: 0; color: var(--color-text-primary); } +.m-points li[data-kind="never"] .icon { color: var(--color-diff-remove-fg); } + +.m-compare { width: 100%; overflow-x: auto; border-radius: 20px; background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); } +.m-compare table { width: 100%; min-width: 720px; border-collapse: collapse; } +.m-compare th, .m-compare td { padding: var(--space-4) var(--space-6); text-align: center; border-top: 1px solid var(--color-border-subtle); } +.m-compare thead th { border-top: 0; color: var(--color-text-primary); } +.m-compare tbody th { text-align: left; font-weight: 400; color: var(--color-text-primary); } +.m-compare td { width: 190px; color: var(--color-text-secondary); } +.m-draft-note { margin: 0; color: var(--color-text-tertiary); } + +.m-articles { width: 760px; max-width: 100%; margin: 0; padding: 0; overflow: hidden; list-style: none; border-radius: 20px; background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); } +.m-articles li { display: flex; align-items: center; gap: var(--space-4); padding: 18px var(--space-6); color: var(--color-text-primary); } +.m-articles li + li { border-top: 1px solid var(--color-border-subtle); } +.m-articles li > :first-child { flex: 1 0 0; } +.m-articles .icon { color: var(--color-text-tertiary); } + +@media (max-width: 900px) { + .m-pagehero { padding-top: var(--space-8); gap: var(--space-5); } + .m-page .m-section, .m-page .m-cta, .m-page .m-row, .m-page .m-grid-section { margin-top: 72px; } + .m-page .m-grid-first, .m-page .m-grid-tight { margin-top: 48px; } + .m-page .footer { margin-top: 72px; } + .m-grid-section { gap: var(--space-8); padding: 0 var(--space-4); } + .m-featuregrid { grid-template-columns: 1fr; } + .m-row, .m-row-reverse { flex-direction: column; align-items: stretch; gap: var(--space-8); padding: 0 var(--space-4); } + .m-rowvisual { width: 100%; border-radius: 28px; } + .m-boundaries { grid-template-columns: 1fr; } + .m-boundary { padding: var(--space-6); } +} diff --git a/web/src/styles/product.css b/web/src/styles/product.css index 3796b56..f3471ef 100644 --- a/web/src/styles/product.css +++ b/web/src/styles/product.css @@ -241,3 +241,28 @@ button:where([class^="p-"]):focus-visible { outline: 2px solid var(--color-accen .p-approval-text { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; } .p-approval-title { font: 500 14px/20px var(--font-sans); color: var(--color-text-primary); } .p-approval-meta { font: 400 12px/16px var(--font-sans); color: var(--color-text-tertiary); } + +/* ---------- Cards, setup checklist, activity, search ---------- */ +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.p-card { display: flex; flex-direction: column; padding: var(--space-2); overflow: hidden; border-radius: 20px; background: var(--color-bg-surface); border: 1px solid var(--color-border-subtle); text-align: left; font-family: var(--font-sans); } +.p-setup, .p-activity { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; } +.p-setupstep { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3) var(--space-4); } +.p-setup-marker { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 24px; height: 24px; box-sizing: border-box; border-radius: var(--radius-full); } +.p-setupstep[data-state="done"] .p-setup-marker { background: var(--color-status-success-dot); color: var(--color-text-on-accent); } +.p-setupstep[data-state="next"] .p-setup-marker { border: 2px solid var(--color-accent-default); } +.p-setupstep[data-state="todo"] .p-setup-marker { border: 1.5px solid var(--color-border-default); } +.p-setup-text { display: flex; flex: 1 0 0; min-width: 0; flex-direction: column; gap: var(--space-1); font: 400 14px/20px var(--font-sans); } +.p-setup-title { font-weight: 500; color: var(--color-text-primary); } +.p-setup-detail { color: var(--color-text-secondary); } +.p-setupstep[data-state="todo"] .p-setup-title, .p-setupstep[data-state="todo"] .p-setup-detail { color: var(--color-text-secondary); } +.p-activityrow { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); } +.p-activity-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: var(--radius-full); background: var(--color-bg-sunken); color: var(--color-text-secondary); } +.p-activityrow[data-kind="published"] .p-activity-icon, .p-activityrow[data-kind="check"] .p-activity-icon { color: var(--color-status-success-fg); } +.p-activity-text { flex: 1 0 0; min-width: 0; font: 400 14px/20px var(--font-sans); color: var(--color-text-primary); } +.p-activity-time { flex: none; font: 400 12px/16px var(--font-sans); color: var(--color-text-tertiary); white-space: nowrap; } +.p-kbd { display: inline-flex; flex: none; align-items: center; justify-content: center; height: 20px; padding: 0 var(--space-1); border-radius: var(--radius-xs); background: var(--color-bg-sunken); border: 1px solid var(--color-border-default); font: 400 12px/16px var(--font-mono); color: var(--color-text-secondary); } +.p-search { display: flex; align-items: center; gap: var(--space-2); width: 100%; height: var(--size-control-md); padding: 0 var(--space-2) 0 var(--space-3); border-radius: var(--radius-full); background: var(--color-bg-sunken); border: 1px solid var(--color-border-subtle); color: var(--color-text-secondary); } +.p-search-input { flex: 1 0 0; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; color: var(--color-text-primary); font: 400 14px/20px var(--font-sans); } +.p-search-input::placeholder { color: var(--color-text-secondary); opacity: 1; } +.p-search:focus-within { outline: 2px solid var(--color-accent-default); outline-offset: 2px; } +.p-search-disabled { opacity: 0.7; } diff --git a/web/src/ui/Icon.tsx b/web/src/ui/Icon.tsx index 715981a..c5149dd 100644 --- a/web/src/ui/Icon.tsx +++ b/web/src/ui/Icon.tsx @@ -1,10 +1,10 @@ import type { CSSProperties } from 'react'; export type IconName = - | 'activity' | 'arrow-right' | 'bell' | 'board' | 'check' | 'check-circle' | 'chevron-down' | 'chevron-right' | 'chevron-up' | 'clock' | 'close' | 'dots' - | 'eye' | 'globe' | 'home' | 'list' | 'loader' | 'lock' | 'menu' | 'mic' | 'monitor' | 'paperclip' - | 'pencil' | 'plus' | 'pointer' | 'refresh' | 'search' | 'shield-check' | 'sidebar' | 'sliders' - | 'shield' | 'smartphone' | 'sparkles' | 'split' | 'stop' | 'undo'; + | 'activity' | 'arrow-right' | 'bell' | 'board' | 'check' | 'camera' | 'check-circle' | 'chevron-down' | 'chevron-right' | 'chevron-up' | 'clock' | 'close' | 'credit-card' | 'dots' + | 'eye' | 'globe' | 'help' | 'home' | 'key' | 'list' | 'loader' | 'lock' | 'menu' | 'mic' | 'monitor' | 'paperclip' + | 'pencil' | 'plug' | 'plus' | 'pointer' | 'refresh' | 'rocket' | 'search' | 'shield-check' | 'sidebar' | 'sliders' + | 'shield' | 'smartphone' | 'sparkles' | 'split' | 'stop' | 'undo' | 'user' | 'x'; type Props = { name: IconName; size?: number; className?: string };
card
+ {p.price} + per month +
{p.allowance}
- {p.price} - per month -
{r.body}
{eyebrow}
diff --git a/web/src/marketing/pages/AlwaysNever.tsx b/web/src/marketing/pages/AlwaysNever.tsx new file mode 100644 index 0000000..77f332c --- /dev/null +++ b/web/src/marketing/pages/AlwaysNever.tsx @@ -0,0 +1,46 @@ +import { Icon } from '../../ui/Icon.tsx'; +import { SectionHeader } from '../home/SectionHeader.tsx'; + +const ALWAYS = [ + 'Works on a safe copy first', + 'Shows you the result before it goes live', + 'Runs checks on every change', + 'Asks before anything is published', + 'Records every step', +] as const; + +const NEVER = [ + 'Publishes without your approval', + 'Changes your live site while you are reviewing', + 'Deletes content without a way back', + 'Hides what it did or why', + 'Keeps working after you pause it', +] as const; + +function Column({ title, items, kind }: { title: string; items: readonly string[]; kind: 'always' | 'never' }) { + return ( +
{c.body}
Draft pricing. Final plans and prices will be confirmed before launch.