feat(web): Product, How it works, Trust, Pricing and Docs pages, with a marketing e2e check
Pricing shows no plans, prices or free trial until decided. Docs search and articles without pages are shown but not linked. Disables the agent file that next dev generates. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SdXCbrN5PtGExkpvMaMHx
@@ -8,7 +8,7 @@ The project is pre-1.0. Versions below `1.0.0` are internal milestones, not publ
|
|||||||
|
|
||||||
### Added
|
### 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.
|
- `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).
|
- 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)).
|
- **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)).
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"golden": "tsx golden/src/run-golden.ts",
|
"golden": "tsx golden/src/run-golden.ts",
|
||||||
"mirror:time": "tsx golden/src/run-mirror-timing.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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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(<Page />);
|
||||||
|
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(<Page />);
|
||||||
|
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(<Page />);
|
||||||
|
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(
|
||||||
|
<PageHero eyebrow="Product" title="One workspace for every change." subtitle="Talk to Wursor.">
|
||||||
|
<Button href="/signup" size="lg">Get started</Button>
|
||||||
|
</PageHero>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<FeatureRow eyebrow="Step 1" title="Start or connect your site" subtitle="Start on Cloud." bullets={['No jargon', 'Undo any time']}>
|
||||||
|
<p>card</p>
|
||||||
|
</FeatureRow>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<FeatureRow eyebrow="Step 2" title="Ask" subtitle="x" bullets={[]} reverse>
|
||||||
|
<p>card</p>
|
||||||
|
</FeatureRow>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.m-row')).toHaveClass('m-row-reverse');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FeatureGrid', () => {
|
||||||
|
it('shows a heading and one card per feature', () => {
|
||||||
|
render(
|
||||||
|
<FeatureGrid
|
||||||
|
eyebrow="What you get"
|
||||||
|
title="Everything you need."
|
||||||
|
cards={[
|
||||||
|
{ icon: 'sparkles', title: 'Ask in plain words', body: 'Describe the change.' },
|
||||||
|
{ icon: 'undo', title: 'One-click undo', body: 'Take it back.' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(<AlwaysNever />);
|
||||||
|
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(<ComparePlans />);
|
||||||
|
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(<PopularArticles />);
|
||||||
|
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(<PricingPage />);
|
||||||
|
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(<PricingContent confirmed />);
|
||||||
|
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(<DocsPage />);
|
||||||
|
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(<HowItWorksPage />);
|
||||||
|
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(<TrustPage />);
|
||||||
|
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(<ProductPage />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(<SetupChecklist steps={STEPS} />);
|
||||||
|
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(<SetupChecklist steps={STEPS} />);
|
||||||
|
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(<SetupChecklist steps={STEPS} onContinue={onContinue} />);
|
||||||
|
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(<ActivityCard rows={rows} />);
|
||||||
|
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(<ActivityCard rows={rows} />);
|
||||||
|
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(<Kbd>⌘K</Kbd>);
|
||||||
|
expect(screen.getByText('⌘K').tagName).toBe('KBD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a labelled search box with a shortcut hint', async () => {
|
||||||
|
render(<SearchField label="Search the docs" shortcut="⌘K" />);
|
||||||
|
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(<SearchField label="Search the docs" disabled />);
|
||||||
|
expect(screen.getByRole('searchbox', { name: 'Search the docs' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<Shell page>
|
||||||
|
<PageHero
|
||||||
|
eyebrow="Docs"
|
||||||
|
title="Find an answer fast."
|
||||||
|
subtitle="Guides for starting or connecting a site, moving between hosts, asking for changes and staying in control."
|
||||||
|
>
|
||||||
|
<div className="m-docs-search">
|
||||||
|
{/* Search is shown as in the design but not usable until the docs exist. */}
|
||||||
|
<SearchField label="Search the docs" shortcut="⌘K" disabled />
|
||||||
|
</div>
|
||||||
|
</PageHero>
|
||||||
|
<FeatureGrid
|
||||||
|
title="Browse by topic"
|
||||||
|
cards={[
|
||||||
|
{ icon: 'rocket', title: 'Getting started', body: 'Start or connect your site and make your first change.' },
|
||||||
|
{ icon: 'plug', title: 'Start or connect a site', body: 'Start on Wursor Cloud, link WordPress, move between hosts and manage access.' },
|
||||||
|
{ icon: 'sparkles', title: 'Asking for changes', body: 'Write a request, point at the page and follow the work.' },
|
||||||
|
{ icon: 'shield-check', title: 'Review and approve', body: 'Compare before and after, read the checks and publish.' },
|
||||||
|
{ icon: 'credit-card', title: 'Team and billing', body: 'Invite people, set roles and manage your plan.' },
|
||||||
|
{ icon: 'help', title: 'Troubleshooting', body: 'Fixes for the most common connection and preview problems.' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PopularArticles />
|
||||||
|
<ClosingCta />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Shell page>
|
||||||
|
<PageHero
|
||||||
|
eyebrow="How it works"
|
||||||
|
title="Four steps. You stay in charge."
|
||||||
|
subtitle="From “please change this” to a live, checked page, with your approval in the middle."
|
||||||
|
>
|
||||||
|
<Button href={ROUTES.signUp} size="lg">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</PageHero>
|
||||||
|
<FeatureRow
|
||||||
|
eyebrow="Step 1"
|
||||||
|
title="Start or connect your site"
|
||||||
|
subtitle="Start on Wursor Cloud or connect your WordPress. Wursor works on a safe copy."
|
||||||
|
bullets={['Standard themes and plugins just work, on either host', 'Access you can revoke at any time']}
|
||||||
|
>
|
||||||
|
<SetupChecklist steps={SETUP_STEPS} />
|
||||||
|
</FeatureRow>
|
||||||
|
<FeatureRow
|
||||||
|
reverse
|
||||||
|
eyebrow="Step 2"
|
||||||
|
title="Ask for a change"
|
||||||
|
subtitle="Type what you want in plain words, or point at the page. Wursor explains each step as it goes."
|
||||||
|
bullets={['No jargon, no tickets', 'Watch at the level of detail you like']}
|
||||||
|
card={{ left: 76, top: 30, width: 448, height: 424 }}
|
||||||
|
>
|
||||||
|
<div className="m-convowindow">
|
||||||
|
<Zoom ratio={448 / 440}>
|
||||||
|
<div style={{ width: 440, height: 852 }}>
|
||||||
|
<HarborConversation />
|
||||||
|
</div>
|
||||||
|
</Zoom>
|
||||||
|
</div>
|
||||||
|
</FeatureRow>
|
||||||
|
<FeatureRow
|
||||||
|
eyebrow="Step 3"
|
||||||
|
title="Review the proof"
|
||||||
|
subtitle="Drag to compare before and after, read what Wursor checked, then approve or ask for changes."
|
||||||
|
bullets={['Nine plain-language checks', 'Looks right on a phone, tablet and laptop']}
|
||||||
|
card={{ left: 60, top: 20, width: 480 }}
|
||||||
|
>
|
||||||
|
<ApprovalCard />
|
||||||
|
</FeatureRow>
|
||||||
|
<FeatureRow
|
||||||
|
reverse
|
||||||
|
eyebrow="Step 4"
|
||||||
|
title="Publish, or undo"
|
||||||
|
subtitle="One click puts it live. Changed your mind? One click takes it back, and the record shows what happened."
|
||||||
|
bullets={['Nothing goes live without you', 'Every step is recorded']}
|
||||||
|
card={{ left: 70, top: 119, width: 460 }}
|
||||||
|
>
|
||||||
|
<ActivityCard rows={ACTIVITY_ROWS} />
|
||||||
|
</FeatureRow>
|
||||||
|
<Faq />
|
||||||
|
<ClosingCta />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { ApproveWithProof } from '../../src/marketing/home/ApproveWithProof.tsx';
|
||||||
import { ClosingCta } from '../../src/marketing/home/ClosingCta.tsx';
|
import { ClosingCta } from '../../src/marketing/home/ClosingCta.tsx';
|
||||||
import { Examples } from '../../src/marketing/home/Examples.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 { SectionHeader } from '../../src/marketing/home/SectionHeader.tsx';
|
||||||
import { SurfacesShowcase } from '../../src/marketing/home/SurfacesShowcase.tsx';
|
import { SurfacesShowcase } from '../../src/marketing/home/SurfacesShowcase.tsx';
|
||||||
import { WatchTheWork } from '../../src/marketing/home/WatchTheWork.tsx';
|
import { WatchTheWork } from '../../src/marketing/home/WatchTheWork.tsx';
|
||||||
import { Nav } from '../../src/marketing/Nav.tsx';
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<Shell>
|
||||||
<Nav />
|
<Hero />
|
||||||
<main>
|
<ProofStrip />
|
||||||
<Hero />
|
<section className="m-section m-section-first">
|
||||||
<ProofStrip />
|
<SectionHeader
|
||||||
<section className="m-section m-section-first">
|
eyebrow="One screen"
|
||||||
<SectionHeader
|
title="Talk. Watch. Decide."
|
||||||
eyebrow="One screen"
|
subtitle="The workspace puts the conversation, your live site and the to-do board side by side, so you always know what Wursor is doing."
|
||||||
title="Talk. Watch. Decide."
|
mobileSubtitle="The workspace puts the conversation, your live site and the board side by side."
|
||||||
subtitle="The workspace puts the conversation, your live site and the to-do board side by side, so you always know what Wursor is doing."
|
/>
|
||||||
mobileSubtitle="The workspace puts the conversation, your live site and the board side by side."
|
<SurfacesShowcase />
|
||||||
/>
|
</section>
|
||||||
<SurfacesShowcase />
|
<WatchTheWork />
|
||||||
</section>
|
<ApproveWithProof />
|
||||||
<WatchTheWork />
|
<SafeByDesign />
|
||||||
<ApproveWithProof />
|
<Examples />
|
||||||
<SafeByDesign />
|
<PricingTeaser />
|
||||||
<Examples />
|
<Faq />
|
||||||
<PricingTeaser />
|
<ClosingCta />
|
||||||
<Faq />
|
</Shell>
|
||||||
<ClosingCta />
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Shell page>
|
||||||
|
<PricingContent />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Shell page>
|
||||||
|
<PageHero
|
||||||
|
eyebrow="Product"
|
||||||
|
title="One workspace for every change."
|
||||||
|
subtitle="Talk to Wursor, watch your site change on a safe copy, and approve what you like."
|
||||||
|
>
|
||||||
|
<Button href={ROUTES.signUp} size="lg">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
<Button href="/how-it-works" variant="ghost" size="lg" trailingIcon="arrow-right">
|
||||||
|
See how it works
|
||||||
|
</Button>
|
||||||
|
</PageHero>
|
||||||
|
<section className="m-grid-section m-grid-first">
|
||||||
|
<SurfacesShowcase />
|
||||||
|
</section>
|
||||||
|
<FeatureGrid
|
||||||
|
eyebrow="What you get"
|
||||||
|
title="Everything you need to hand off the work."
|
||||||
|
subtitle="Six things that make a change small, visible and reversible."
|
||||||
|
cards={[
|
||||||
|
{ icon: 'sparkles', title: 'Ask in plain words', body: 'Describe the change the way you would tell a colleague. Wursor works out the steps.' },
|
||||||
|
{ icon: 'monitor', title: 'Live preview on every device', body: 'See the real page on a phone, a tablet and a laptop before anything goes live.' },
|
||||||
|
{ icon: 'board', title: 'A board for every change', body: 'Ideas, work in progress and finished changes in one place, with what needs you on top.' },
|
||||||
|
{ icon: 'shield-check', title: 'Proof before you publish', body: 'Nine checks, a before-and-after slider and a plain summary of what changed.' },
|
||||||
|
{ icon: 'undo', title: 'One-click undo', body: 'Every published change can be taken back in one click, any time.' },
|
||||||
|
{ icon: 'user', title: 'Built for teams', body: 'Invite teammates, choose who can approve, and keep one shared record.' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
eyebrow="Start"
|
||||||
|
title="Choose where it lives. Keep your hands free."
|
||||||
|
subtitle="Start on Wursor Cloud, connect your WordPress, or build here and publish to yours. Wursor works on a safe copy and never changes your live site without you."
|
||||||
|
bullets={[
|
||||||
|
'Works with standard WordPress themes and plugins, hosted by us or by you',
|
||||||
|
'Access you can pause or revoke at any time',
|
||||||
|
'Nothing installed on your visitors’ side',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SetupChecklist steps={SETUP_STEPS} />
|
||||||
|
</FeatureRow>
|
||||||
|
<Faq />
|
||||||
|
<ClosingCta />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Shell page>
|
||||||
|
<PageHero eyebrow="Trust" title="Safe by design, not by promise." subtitle="Four rules Wursor can’t break, however you set it up.">
|
||||||
|
<Button href={ROUTES.signUp} size="lg">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</PageHero>
|
||||||
|
<section className="m-grid-section m-grid-tight">
|
||||||
|
<SafetyRules />
|
||||||
|
</section>
|
||||||
|
<AlwaysNever />
|
||||||
|
<FeatureRow
|
||||||
|
eyebrow="Everything is recorded"
|
||||||
|
title="A record you can read."
|
||||||
|
subtitle="Every request, check and decision is written down in plain words, so you can see what happened and why."
|
||||||
|
bullets={['Who asked, who approved, and when', 'Checks and results, kept with each change']}
|
||||||
|
card={{ left: 70, top: 119, width: 460 }}
|
||||||
|
>
|
||||||
|
<ActivityCard rows={ACTIVITY_ROWS} />
|
||||||
|
</FeatureRow>
|
||||||
|
<FeatureGrid
|
||||||
|
eyebrow="Your control"
|
||||||
|
title="You decide who can do what."
|
||||||
|
cards={[
|
||||||
|
{
|
||||||
|
icon: 'key',
|
||||||
|
title: 'Access you control',
|
||||||
|
body: 'Pause or revoke Wursor’s access at any time. Choose Full access or Content only, and move your site in or out whenever you like.',
|
||||||
|
},
|
||||||
|
{ icon: 'user', title: 'Roles for your team', body: 'Choose who can ask for changes, who can approve and who can publish.' },
|
||||||
|
{ icon: 'lock', title: 'Sensitive changes need more', body: 'Higher-risk changes ask for an extra confirmation before they go live.' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Faq />
|
||||||
|
<ClosingCta />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ const apiOrigin = process.env.WURSOR_API_URL ?? 'http://localhost:3000';
|
|||||||
|
|
||||||
const config: NextConfig = {
|
const config: NextConfig = {
|
||||||
reactStrictMode: true,
|
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.
|
// Interim: the Sprint 1 shell at /app still calls the API on the same origin.
|
||||||
// Replaced by server-held sessions (ADR 0018, step C).
|
// Replaced by server-held sessions (ADR 0018, step C).
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/camera">
|
||||||
|
<path id="Vector" d="M12.0833 3.33333H7.91667L5.83333 5.83333H3.33333C2.89131 5.83333 2.46738 6.00893 2.15482 6.32149C1.84226 6.63405 1.66667 7.05797 1.66667 7.5V15C1.66667 15.442 1.84226 15.866 2.15482 16.1785C2.46738 16.4911 2.89131 16.6667 3.33333 16.6667H16.6667C17.1087 16.6667 17.5326 16.4911 17.8452 16.1785C18.1577 15.866 18.3333 15.442 18.3333 15V7.5C18.3333 7.05797 18.1577 6.63405 17.8452 6.32149C17.5326 6.00893 17.1087 5.83333 16.6667 5.83333H14.1667L12.0833 3.33333Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M10 13.3333C11.3807 13.3333 12.5 12.214 12.5 10.8333C12.5 9.45262 11.3807 8.33333 10 8.33333C8.61929 8.33333 7.5 9.45262 7.5 10.8333C7.5 12.214 8.61929 13.3333 10 13.3333Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/credit-card">
|
||||||
|
<path id="Vector" d="M1.66667 8.33333H18.3333M3.33333 4.16667H16.6667C17.1087 4.16667 17.5326 4.34226 17.8452 4.65482C18.1577 4.96738 18.3333 5.39131 18.3333 5.83333V14.1667C18.3333 14.6087 18.1577 15.0326 17.8452 15.3452C17.5326 15.6577 17.1087 15.8333 16.6667 15.8333H3.33333C2.89131 15.8333 2.46738 15.6577 2.15482 15.3452C1.84226 15.0326 1.66667 14.6087 1.66667 14.1667V5.83333C1.66667 5.39131 1.84226 4.96738 2.15482 4.65482C2.46738 4.34226 2.89131 4.16667 3.33333 4.16667Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 769 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/help">
|
||||||
|
<path id="Vector" d="M10 18.3333C14.6024 18.3333 18.3333 14.6024 18.3333 10C18.3333 5.39763 14.6024 1.66667 10 1.66667C5.39763 1.66667 1.66667 5.39763 1.66667 10C1.66667 14.6024 5.39763 18.3333 10 18.3333Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M7.575 7.5C7.77092 6.94306 8.15763 6.47342 8.66663 6.17428C9.17563 5.87513 9.77408 5.76578 10.356 5.86559C10.9379 5.9654 11.4657 6.26794 11.8459 6.71961C12.2261 7.17128 12.4342 7.74294 12.4333 8.33333C12.4333 10 9.93333 10.8333 9.93333 10.8333M10 14.1667H10.0083" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 861 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/key">
|
||||||
|
<path id="Vector" d="M6.5 17C8.43 17 10 15.43 10 13.5C10 11.57 8.43 10 6.5 10C4.57 10 3 11.57 3 13.5C3 15.43 4.57 17 6.5 17Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M9 11L16.5 3.5" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_3" d="M14 6L16 8" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 651 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/plug">
|
||||||
|
<path id="Vector" d="M7 2.5V6M13 2.5V6" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M15 6H5V9.5C5 12.26 7.24 14.5 10 14.5C12.76 14.5 15 12.26 15 9.5V6Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_3" d="M10 14.5V17.5" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 622 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/rocket">
|
||||||
|
<path id="Vector" d="M6 9.5C6 15 10 17.5 10 17.5C10 17.5 14 15 14 9.5C14 6 12 3.5 10 2.5C8 3.5 6 6 6 9.5Z" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M6 12.5L3.5 14.5V11.5L6 9.5M14 12.5L16.5 14.5V11.5L14 9.5" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 558 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/user">
|
||||||
|
<path id="Vector" d="M15.8333 17.5V15.8333C15.8333 14.9493 15.4821 14.1014 14.857 13.4763C14.2319 12.8512 13.3841 12.5 12.5 12.5H7.5C6.61595 12.5 5.7681 12.8512 5.14298 13.4763C4.51786 14.1014 4.16667 14.9493 4.16667 15.8333V17.5" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M10 9.16667C11.8409 9.16667 13.3333 7.67428 13.3333 5.83333C13.3333 3.99238 11.8409 2.5 10 2.5C8.15905 2.5 6.66667 3.99238 6.66667 5.83333C6.66667 7.67428 8.15905 9.16667 10 9.16667Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 805 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/x">
|
||||||
|
<path id="Vector" d="M15 5L5 15M5 5L15 15" stroke="#A12A2A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { BrandMark } from './BrandMark.tsx';
|
import { BrandMark } from './BrandMark.tsx';
|
||||||
import { Button } from './Button.tsx';
|
import { Button } from './Button.tsx';
|
||||||
@@ -9,6 +10,7 @@ import { Icon } from '../ui/Icon.tsx';
|
|||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -29,7 +31,7 @@ export function Nav() {
|
|||||||
|
|
||||||
<nav aria-label="Primary" className="nav-links t-body-m">
|
<nav aria-label="Primary" className="nav-links t-body-m">
|
||||||
{NAV_LINKS.map((l) => (
|
{NAV_LINKS.map((l) => (
|
||||||
<Link key={l.href} href={l.href}>
|
<Link key={l.href} href={l.href} aria-current={pathname === l.href ? 'page' : undefined}>
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -58,7 +60,7 @@ export function Nav() {
|
|||||||
{open && (
|
{open && (
|
||||||
<nav id="mobile-menu" aria-label="Mobile" className="nav-menu">
|
<nav id="mobile-menu" aria-label="Mobile" className="nav-menu">
|
||||||
{NAV_LINKS.map((l) => (
|
{NAV_LINKS.map((l) => (
|
||||||
<Link key={l.href} href={l.href} className="t-title-m" onClick={() => setOpen(false)}>
|
<Link key={l.href} href={l.href} className="t-title-m" aria-current={pathname === l.href ? 'page' : undefined} onClick={() => setOpen(false)}>
|
||||||
{l.mobileLabel ?? l.label}
|
{l.mobileLabel ?? l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
<main className={page ? 'm-page' : undefined}>{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.',
|
'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' },
|
||||||
|
];
|
||||||
|
|||||||
@@ -3,6 +3,38 @@ import { Button } from '../Button.tsx';
|
|||||||
import { PLAN_FEATURES, PLANS, PRICING_CONFIRMED, ROUTES } from '../content.ts';
|
import { PLAN_FEATURES, PLANS, PRICING_CONFIRMED, ROUTES } from '../content.ts';
|
||||||
import { SectionHeader } from './SectionHeader.tsx';
|
import { SectionHeader } from './SectionHeader.tsx';
|
||||||
|
|
||||||
|
/** The three plan cards. Draft numbers: render only when pricing is confirmed. */
|
||||||
|
export function PlanCards() {
|
||||||
|
return (
|
||||||
|
<div className="m-plans">
|
||||||
|
{PLANS.map((p) => (
|
||||||
|
<div key={p.name} className={`m-plan${p.featured ? ' m-plan-featured' : ''}`}>
|
||||||
|
<div className="m-plan-head">
|
||||||
|
<h3 className="t-title-s">{p.name}</h3>
|
||||||
|
{p.featured && <span className="m-plan-tag t-label-m">Most popular</span>}
|
||||||
|
</div>
|
||||||
|
<p className="m-plan-price">
|
||||||
|
<span className="t-title-l">{p.price}</span>
|
||||||
|
<span className="t-caption">per month</span>
|
||||||
|
</p>
|
||||||
|
<p className="t-body-m m-section-sub">{p.allowance}</p>
|
||||||
|
<ul className="m-plan-features">
|
||||||
|
{PLAN_FEATURES.map((f) => (
|
||||||
|
<li key={f} className="t-body-m">
|
||||||
|
<Icon name="check" size={20} />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button href={ROUTES.signUp} variant={p.featured ? 'primary' : 'secondary'} className="m-plan-cta">
|
||||||
|
Choose plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plan names, prices and allowances show only once they are decided (PRD D-HL-8, PX-13). Until then
|
* 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.
|
* 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."
|
title="Simple plans. No surprises."
|
||||||
subtitle="Hosting on Wursor Cloud is included. Change or cancel any time."
|
subtitle="Hosting on Wursor Cloud is included. Change or cancel any time."
|
||||||
/>
|
/>
|
||||||
<div className="m-plans">
|
<PlanCards />
|
||||||
{PLANS.map((p) => (
|
|
||||||
<div key={p.name} className={`m-plan${p.featured ? ' m-plan-featured' : ''}`}>
|
|
||||||
<div className="m-plan-head">
|
|
||||||
<h3 className="t-title-s">{p.name}</h3>
|
|
||||||
{p.featured && <span className="m-plan-tag t-label-m">Most popular</span>}
|
|
||||||
</div>
|
|
||||||
<p className="m-plan-price">
|
|
||||||
<span className="t-title-l">{p.price}</span>
|
|
||||||
<span className="t-caption">per month</span>
|
|
||||||
</p>
|
|
||||||
<p className="t-body-m m-section-sub">{p.allowance}</p>
|
|
||||||
<ul className="m-plan-features">
|
|
||||||
{PLAN_FEATURES.map((f) => (
|
|
||||||
<li key={f} className="t-body-m">
|
|
||||||
<Icon name="check" size={20} />
|
|
||||||
{f}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<Button href={ROUTES.signUp} variant={p.featured ? 'primary' : 'secondary'} className="m-plan-cta">
|
|
||||||
Choose plan
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="m-rules">
|
||||||
|
{RULES.map((r) => (
|
||||||
|
<div key={r.title} className="m-rule">
|
||||||
|
<span className="m-rule-icon">
|
||||||
|
<Icon name={r.icon} size={20} />
|
||||||
|
</span>
|
||||||
|
<h3 className="t-title-s">{r.title}</h3>
|
||||||
|
<p className="t-body-m m-section-sub">{r.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SafeByDesign() {
|
export function SafeByDesign() {
|
||||||
return (
|
return (
|
||||||
<section className="m-section">
|
<section className="m-section">
|
||||||
@@ -33,17 +50,7 @@ export function SafeByDesign() {
|
|||||||
subtitle="Four rules Wursor can’t break, however you set it up."
|
subtitle="Four rules Wursor can’t break, however you set it up."
|
||||||
mobileSubtitle="Four rules Wursor can’t break."
|
mobileSubtitle="Four rules Wursor can’t break."
|
||||||
/>
|
/>
|
||||||
<div className="m-rules">
|
<SafetyRules />
|
||||||
{RULES.map((r) => (
|
|
||||||
<div key={r.title} className="m-rule">
|
|
||||||
<span className="m-rule-icon">
|
|
||||||
<Icon name={r.icon} size={20} />
|
|
||||||
</span>
|
|
||||||
<h3 className="t-title-s">{r.title}</h3>
|
|
||||||
<p className="t-body-m m-section-sub">{r.body}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,24 @@ export function Responsive({ desktop, mobile }: { desktop: ReactNode; mobile?: R
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
eyebrow: string;
|
eyebrow?: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
mobileTitle?: string;
|
mobileTitle?: string;
|
||||||
mobileSubtitle?: string;
|
mobileSubtitle?: string;
|
||||||
align?: 'center' | 'left';
|
align?: 'center' | 'left';
|
||||||
|
/** Use h1 for the page's first heading only. */
|
||||||
|
as?: 'h1' | 'h2';
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Eyebrow, headline and one supporting sentence. Centred by default. */
|
/** 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 (
|
return (
|
||||||
<div className={`m-section-header m-align-${align}`}>
|
<div className={`m-section-header m-align-${align}`}>
|
||||||
<p className="t-overline m-eyebrow">{eyebrow}</p>
|
{eyebrow && <p className="t-overline m-eyebrow">{eyebrow}</p>}
|
||||||
<h2 className="t-display-l m-section-title">
|
<Heading className="t-display-l m-section-title">
|
||||||
<Responsive desktop={title} mobile={mobileTitle} />
|
<Responsive desktop={title} mobile={mobileTitle} />
|
||||||
</h2>
|
</Heading>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="t-body-l m-section-sub">
|
<p className="t-body-l m-section-sub">
|
||||||
<Responsive desktop={subtitle} mobile={mobileSubtitle} />
|
<Responsive desktop={subtitle} mobile={mobileSubtitle} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="m-boundary">
|
||||||
|
<h3 className="t-title-m">{title}</h3>
|
||||||
|
<ul aria-label={title} className="m-points">
|
||||||
|
{items.map((i) => (
|
||||||
|
<li key={i} className="t-body-l" data-kind={kind}>
|
||||||
|
<Icon name={kind === 'always' ? 'check-circle' : 'x'} size={20} />
|
||||||
|
{i}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlwaysNever() {
|
||||||
|
return (
|
||||||
|
<section className="m-grid-section">
|
||||||
|
<SectionHeader eyebrow="Boundaries" title="What Wursor does, and what it never does." />
|
||||||
|
<div className="m-boundaries">
|
||||||
|
<Column title="Always" items={ALWAYS} kind="always" />
|
||||||
|
<Column title="Never" items={NEVER} kind="never" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="m-grid-section">
|
||||||
|
<SectionHeader eyebrow="Compare plans" title="Everything, side by side." />
|
||||||
|
<div className="m-compare">
|
||||||
|
<table aria-label="Compare plans">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="t-label-m">
|
||||||
|
<span className="sr-only">Feature</span>
|
||||||
|
</th>
|
||||||
|
{PLANS.map((p) => (
|
||||||
|
<th key={p.name} scope="col" className="t-label-m">
|
||||||
|
{p.name}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{COMPARE_ROWS.map((r) => (
|
||||||
|
<tr key={r.label}>
|
||||||
|
<th scope="row" className="t-body-m">
|
||||||
|
{r.label}
|
||||||
|
</th>
|
||||||
|
{r.values.map((v, i) => (
|
||||||
|
<td key={i} className="t-body-m">
|
||||||
|
{v}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="m-grid-section">
|
||||||
|
<SectionHeader {...(eyebrow ? { eyebrow } : {})} title={title} {...(subtitle ? { subtitle } : {})} />
|
||||||
|
<div className="m-featuregrid">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<div key={c.title} className="m-rule">
|
||||||
|
<span className="m-rule-icon">
|
||||||
|
<Icon name={c.icon} size={20} />
|
||||||
|
</span>
|
||||||
|
<h3 className="t-title-s">{c.title}</h3>
|
||||||
|
<p className="t-body-m m-section-sub">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className={`m-row${reverse ? ' m-row-reverse' : ''}`}>
|
||||||
|
<div className="m-row-text">
|
||||||
|
<SectionHeader align="left" eyebrow={eyebrow} title={title} subtitle={subtitle} />
|
||||||
|
{bullets.length > 0 && (
|
||||||
|
<ul className="m-points">
|
||||||
|
{bullets.map((b) => (
|
||||||
|
<li key={b} className="t-body-l">
|
||||||
|
<Icon name="check-circle" size={20} />
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<RowVisual {...(card ? { card } : {})}>{children}</RowVisual>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="m-pagehero">
|
||||||
|
<SectionHeader as="h1" eyebrow={eyebrow} title={title} {...(subtitle ? { subtitle } : {})} />
|
||||||
|
{children && <div className="m-hero-actions">{children}</div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="m-grid-section">
|
||||||
|
<SectionHeader title="Popular articles" />
|
||||||
|
<ul className="m-articles">
|
||||||
|
{ARTICLES.map((a) => (
|
||||||
|
<li key={a.title} className="t-body-m">
|
||||||
|
{a.href ? <Link href={a.href}>{a.title}</Link> : <span>{a.title}</span>}
|
||||||
|
<Icon name="chevron-right" size={20} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHero
|
||||||
|
eyebrow="Pricing"
|
||||||
|
title="Simple plans. No surprises."
|
||||||
|
subtitle={
|
||||||
|
confirmed
|
||||||
|
? 'Start free for 14 days. Hosting on Wursor Cloud is included. Change or cancel any time.'
|
||||||
|
: 'Plans and prices will be published before launch.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!confirmed && (
|
||||||
|
<Button href={ROUTES.signUp} size="lg">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</PageHero>
|
||||||
|
{confirmed && (
|
||||||
|
<>
|
||||||
|
<section className="m-grid-section">
|
||||||
|
<PlanCards />
|
||||||
|
<p className="t-caption m-draft-note">Draft pricing. Final plans and prices will be confirmed before launch.</p>
|
||||||
|
</section>
|
||||||
|
<ComparePlans />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Faq />
|
||||||
|
<ClosingCta />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="m-rowvisual" aria-hidden="true" inert>
|
||||||
|
<Fit designWidth={600} designHeight={480}>
|
||||||
|
<div className="m-rowscene">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="m-rowbackdrop" src="/marketing/backdrop.svg" alt="" />
|
||||||
|
<div className="m-rowcard" style={style}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fit>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<ActivityKind, IconName> = { 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 (
|
||||||
|
<div className="p-card">
|
||||||
|
<ul aria-label="Activity" className="p-activity">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.text} className="p-activityrow" data-kind={r.kind}>
|
||||||
|
<span className="p-activity-icon">
|
||||||
|
<Icon name={ICON[r.kind]} size={20} />
|
||||||
|
</span>
|
||||||
|
<span className="p-activity-text">{r.text}</span>
|
||||||
|
<span className="p-activity-time">{r.time}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/** Keyboard key cap for shortcuts. */
|
||||||
|
export function Kbd({ children }: { children: ReactNode }) {
|
||||||
|
return <kbd className="p-kbd">{children}</kbd>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={`p-search${disabled ? ' p-search-disabled' : ''}`}>
|
||||||
|
<Icon name="search" size={16} />
|
||||||
|
<input type="search" aria-label={label} placeholder={placeholder} disabled={disabled} className="p-search-input" />
|
||||||
|
{shortcut && <Kbd>{shortcut}</Kbd>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="p-card">
|
||||||
|
<ol aria-label="Setup steps" className="p-setup">
|
||||||
|
{steps.map((s) => (
|
||||||
|
<li key={s.title} className="p-setupstep" data-state={s.state} aria-current={s.state === 'next' ? 'step' : undefined}>
|
||||||
|
<span className="p-setup-marker" aria-hidden="true">
|
||||||
|
{s.state === 'done' && <Icon name="check" size={20} />}
|
||||||
|
</span>
|
||||||
|
<span className="p-setup-text">
|
||||||
|
<span className="sr-only">{STATE_LABEL[s.state]}</span>
|
||||||
|
<span className="p-setup-title">{s.title}</span>
|
||||||
|
<span className="p-setup-detail">{s.detail}</span>
|
||||||
|
</span>
|
||||||
|
{s.state === 'next' && (
|
||||||
|
<Button variant="primary" onClick={onContinue}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { AgentCursor } from './AgentCursor.tsx';
|
export { AgentCursor } from './AgentCursor.tsx';
|
||||||
|
export { ActivityCard, type ActivityRowData } from './ActivityCard.tsx';
|
||||||
export { ApprovalBar } from './ApprovalBar.tsx';
|
export { ApprovalBar } from './ApprovalBar.tsx';
|
||||||
export { ApprovalCard } from './ApprovalCard.tsx';
|
export { ApprovalCard } from './ApprovalCard.tsx';
|
||||||
export { Avatar } from './Avatar.tsx';
|
export { Avatar } from './Avatar.tsx';
|
||||||
@@ -15,6 +16,7 @@ export { DeviceFrame } from './DeviceFrame.tsx';
|
|||||||
export { HARBOR } from './harbor-bakery.ts';
|
export { HARBOR } from './harbor-bakery.ts';
|
||||||
export { HarborStream } from './HarborStream.tsx';
|
export { HarborStream } from './HarborStream.tsx';
|
||||||
export { IconButton } from './IconButton.tsx';
|
export { IconButton } from './IconButton.tsx';
|
||||||
|
export { Kbd } from './Kbd.tsx';
|
||||||
export { Message } from './Message.tsx';
|
export { Message } from './Message.tsx';
|
||||||
export { Milestone } from './Milestone.tsx';
|
export { Milestone } from './Milestone.tsx';
|
||||||
export { HarborConversation } from './HarborConversation.tsx';
|
export { HarborConversation } from './HarborConversation.tsx';
|
||||||
@@ -23,6 +25,8 @@ export { PreviewColumn } from './PreviewColumn.tsx';
|
|||||||
export { PreviewToolbar, type PreviewView } from './PreviewToolbar.tsx';
|
export { PreviewToolbar, type PreviewView } from './PreviewToolbar.tsx';
|
||||||
export { RiskChip } from './RiskChip.tsx';
|
export { RiskChip } from './RiskChip.tsx';
|
||||||
export { SegmentedControl } from './SegmentedControl.tsx';
|
export { SegmentedControl } from './SegmentedControl.tsx';
|
||||||
|
export { SearchField } from './SearchField.tsx';
|
||||||
|
export { SetupChecklist, type SetupStepData } from './SetupChecklist.tsx';
|
||||||
export { Sidebar } from './Sidebar.tsx';
|
export { Sidebar } from './Sidebar.tsx';
|
||||||
export { SiteMock } from './SiteMock.tsx';
|
export { SiteMock } from './SiteMock.tsx';
|
||||||
export { StatusPill, type Status } from './StatusPill.tsx';
|
export { StatusPill, type Status } from './StatusPill.tsx';
|
||||||
|
|||||||
@@ -298,3 +298,56 @@ main { display: block; }
|
|||||||
.m-cta-sub { display: none; }
|
.m-cta-sub { display: none; }
|
||||||
.footer { margin-top: 72px; }
|
.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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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-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-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); }
|
.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; }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
| 'activity' | 'arrow-right' | 'bell' | 'board' | 'check' | 'check-circle' | 'chevron-down' | 'chevron-right' | 'chevron-up' | 'clock' | 'close' | 'dots'
|
| 'activity' | 'arrow-right' | 'bell' | 'board' | 'check' | 'camera' | 'check-circle' | 'chevron-down' | 'chevron-right' | 'chevron-up' | 'clock' | 'close' | 'credit-card' | 'dots'
|
||||||
| 'eye' | 'globe' | 'home' | 'list' | 'loader' | 'lock' | 'menu' | 'mic' | 'monitor' | 'paperclip'
|
| 'eye' | 'globe' | 'help' | 'home' | 'key' | 'list' | 'loader' | 'lock' | 'menu' | 'mic' | 'monitor' | 'paperclip'
|
||||||
| 'pencil' | 'plus' | 'pointer' | 'refresh' | 'search' | 'shield-check' | 'sidebar' | 'sliders'
|
| 'pencil' | 'plug' | 'plus' | 'pointer' | 'refresh' | 'rocket' | 'search' | 'shield-check' | 'sidebar' | 'sliders'
|
||||||
| 'shield' | 'smartphone' | 'sparkles' | 'split' | 'stop' | 'undo';
|
| 'shield' | 'smartphone' | 'sparkles' | 'split' | 'stop' | 'undo' | 'user' | 'x';
|
||||||
|
|
||||||
type Props = { name: IconName; size?: number; className?: string };
|
type Props = { name: IconName; size?: number; className?: string };
|
||||||
|
|
||||||
|
|||||||