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
This commit is contained in:
Claude
2026-10-12 15:34:50 +00:00
parent 6947113027
commit 0cff3902b2
44 changed files with 1187 additions and 76 deletions
+1 -1
View File
@@ -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)).
+2 -1
View File
@@ -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",
+16
View File
@@ -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,
},
});
+37
View File
@@ -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);
});
+188
View File
@@ -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();
});
});
+82
View File
@@ -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();
});
});
+39
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
}
+3 -8
View File
@@ -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,13 +10,10 @@ 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 />
<main>
<Hero /> <Hero />
<ProofStrip /> <ProofStrip />
<section className="m-section m-section-first"> <section className="m-section m-section-first">
@@ -35,8 +32,6 @@ export default function HomePage() {
<PricingTeaser /> <PricingTeaser />
<Faq /> <Faq />
<ClosingCta /> <ClosingCta />
</main> </Shell>
<Footer />
</>
); );
} }
+13
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+2
View File
@@ -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() {
+6
View File
@@ -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

+5
View File
@@ -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

+6
View File
@@ -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

+7
View File
@@ -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

+7
View File
@@ -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

+6
View File
@@ -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

+6
View File
@@ -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

+5
View File
@@ -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

+4 -2
View File
@@ -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>
))} ))}
+14
View File
@@ -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 />
</>
);
}
+22
View File
@@ -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' },
];
+33 -26
View File
@@ -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>
); );
} }
+15 -8
View File
@@ -24,15 +24,9 @@ const RULES: ReadonlyArray<{ icon: IconName; title: string; body: string }> = [
}, },
]; ];
export function SafeByDesign() { /** The four rules as cards. Used on the home page and on the Trust page. */
export function SafetyRules() {
return ( return (
<section className="m-section">
<SectionHeader
eyebrow="Safe by design"
title="Nothing goes live until you say so."
subtitle="Four rules Wursor can’t break, however you set it up."
mobileSubtitle="Four rules Wursor can’t break."
/>
<div className="m-rules"> <div className="m-rules">
{RULES.map((r) => ( {RULES.map((r) => (
<div key={r.title} className="m-rule"> <div key={r.title} className="m-rule">
@@ -44,6 +38,19 @@ export function SafeByDesign() {
</div> </div>
))} ))}
</div> </div>
);
}
export function SafeByDesign() {
return (
<section className="m-section">
<SectionHeader
eyebrow="Safe by design"
title="Nothing goes live until you say so."
subtitle="Four rules Wursor can’t break, however you set it up."
mobileSubtitle="Four rules Wursor can’t break."
/>
<SafetyRules />
</section> </section>
); );
} }
+7 -5
View File
@@ -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} />
+46
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+14
View File
@@ -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 />
</>
);
}
+26
View File
@@ -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>
);
}
+16
View File
@@ -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;
+25
View File
@@ -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>
);
}
+6
View File
@@ -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>;
}
+21
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+4
View File
@@ -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';
+53
View File
@@ -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); }
}
+25
View File
@@ -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; }
+4 -4
View File
@@ -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 };