feat(web): marketing Home page and the product component library (ADR 0023)
Product UI components are built once at design size from the Figma file and reused by the marketing mockups and, later, the app. Text contrast tokens now meet WCAG AA in both themes, with a test. Pricing stays hidden until decided. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SdXCbrN5PtGExkpvMaMHx
@@ -10,7 +10,7 @@ Non-technical WordPress site owners describe what they want; Wursor makes it hap
|
|||||||
|
|
||||||
```
|
```
|
||||||
api/ Node.js + TypeScript API server (empty until Phase 0 gate)
|
api/ Node.js + TypeScript API server (empty until Phase 0 gate)
|
||||||
web/ Next.js (App Router) + TypeScript frontend (ADR 0018)
|
web/ Next.js (App Router) + TypeScript frontend (ADR 0018): app/ routes, src/product (app UI components), src/marketing, src/ui, src/styles (tokens)
|
||||||
plugin/ WordPress plugin (PHP) — empty until Phase 0 gate
|
plugin/ WordPress plugin (PHP) — empty until Phase 0 gate
|
||||||
infrastructure/ Docker images, warm pool, GC, deploy scripts
|
infrastructure/ Docker images, warm pool, GC, deploy scripts
|
||||||
e2e/ Playwright + e2e/golden/ harness
|
e2e/ Playwright + e2e/golden/ harness
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The project is pre-1.0. Versions below `1.0.0` are internal milestones, not publ
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ADRs 0018 to 0022 (0022: hosting on DigitalOcean): Next.js web app (partly supersedes ADR 0011), sign-in by code, link and passkeys, Cloud provisioning on WeOwn Cloud, and keeping the "never sees a diff" rule wording. PRD, IMPLEMENTATION and AGENTS.md updated to match.
|
- Web: Next.js app with design tokens (light and dark, AA contrast), the marketing Home page (desktop and phone), and a product component library built at design size (conversation, workspace, board, compare and approval) that the marketing mockups use and the app will reuse (ADR 0023). ADRs 0018 to 0022 (0022: hosting on DigitalOcean): Next.js web app (partly supersedes ADR 0011), sign-in by code, link and passkeys, Cloud provisioning on WeOwn Cloud, and keeping the "never sees a diff" rule wording. PRD, IMPLEMENTATION and AGENTS.md updated to match.
|
||||||
- `docs/prd-assets/headless-architecture.png`, the architecture diagram used in the PRD.
|
- `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)).
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 23. Product UI is built once at design size and reused; text contrast tokens meet AA
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-10-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The marketing site shows the product (conversation, preview, board, approval) in four large mockups. The same screens become the app (PRD sections 15 and 26, PX-11: one component library, no one-off controls). Figma's generated code is absolutely positioned and uses Tailwind classes, and the mockups are drawn at several scales (the hero at 0.76, the Surfaces windows at 1.27, 1.08 and 1.39). Separately, two pairs of Figma tokens fail WCAG AA for small text, which PX-10 requires: secondary and tertiary text, and white text on the accent orange.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **Build each product component once, at its design size (1x),** in `web/src/product/`, with plain CSS in `src/styles/product.css` that uses only the design tokens. No Tailwind. Marketing scales whole components with `Fit` (follows the container width) or `Zoom` (fixed ratio), in `src/ui/`. The app uses them unscaled.
|
||||||
|
2. **Icons** are the exact Figma SVG exports in `public/icons`, drawn with a CSS mask so their colour comes from the theme token (`src/ui/Icon.tsx`). The brand mark and the Wursor avatar need separate light and dark files because they have two colours.
|
||||||
|
3. **Sample content** is one fictional site, Harbor Bakery (`harbor-bakery.ts`), as in the design. Real previews replace it in the app.
|
||||||
|
4. **Contrast.** `--color-text-secondary` and `--color-text-tertiary` are darker in light and lighter in dark than the Figma values, so every text pair is at least 4.5:1 (a test checks 24 pairs per theme). Chips and counters that carry text on the accent use a new `--color-accent-fill` (`#c24a08` in light) instead of `--color-accent-default` (`#e8590c`, white on it is 3.6:1). The dark link blue is lightened. The design file should adopt these values.
|
||||||
|
5. **Claims.** Plan names, prices and allowances stay out of the marketing site until decided (PRD D-HL-8, PX-13): `PRICING_CONFIRMED` is false and the pricing section says so. FAQ answers other than the first are drafted from the PRD and flagged in `content.ts` for review.
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- **One component set at 1x, scaled where needed (chosen).** One source of truth for the marketing mockups and the app.
|
||||||
|
- **Convert Figma's generated code per mockup.** Fast, but each mockup becomes its own fixed-pixel copy, and the app would need a second build of every screen.
|
||||||
|
- **Export the mockups as images.** Smallest, and not reusable. Rejected because the components are needed for the app.
|
||||||
|
- **Keep Figma's contrast values.** Faithful to the file, but fails PX-10.
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- Tailwind: not in the stack, and the Figma output depends on it only as a convenience.
|
||||||
|
- Recolouring icons by editing the SVG files: they must stay exactly as exported.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The product components are a library for the app screens in `IMPLEMENTATION.md` step H, and need tests as they grow (about 90 exist).
|
||||||
|
- Only the states that are designed exist so far. Not built: Composer states other than working, risk levels other than Everyday, check results other than passed, approval states other than ready, the phone device frame. The components say so where it applies.
|
||||||
|
- `Fit` measures the container in the browser, so the mockups reflow once after load.
|
||||||
|
- The design file should adopt the contrast values, and the dark link blue, so design and code agree.
|
||||||
@@ -36,6 +36,7 @@ Each ADR is a single file following the [Nygard format](https://cognitect.com/bl
|
|||||||
| [0020](0020-weown-cloud-provisioning.md) | Cloud sites are provisioned on WeOwn Cloud | Accepted |
|
| [0020](0020-weown-cloud-provisioning.md) | Cloud sites are provisioned on WeOwn Cloud | Accepted |
|
||||||
| [0021](0021-keep-no-diff-rule-wording.md) | Keep the "never sees a diff" rule in AGENTS.md as written | Accepted |
|
| [0021](0021-keep-no-diff-rule-wording.md) | Keep the "never sees a diff" rule in AGENTS.md as written | Accepted |
|
||||||
| [0022](0022-digitalocean-hosting.md) | Wursor and WeOwn Cloud run on DigitalOcean | Accepted (service picks to confirm) |
|
| [0022](0022-digitalocean-hosting.md) | Wursor and WeOwn Cloud run on DigitalOcean | Accepted (service picks to confirm) |
|
||||||
|
| [0023](0023-product-components-and-contrast.md) | Product UI is built once at design size and reused; text contrast tokens meet AA | Accepted |
|
||||||
|
|
||||||
## How to add one
|
## How to add one
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,21 @@ describe('Button', () => {
|
|||||||
expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn', 'btn-primary', 'btn-md');
|
expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn', 'btn-primary', 'btn-md');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports the ghost variant and the large size', () => {
|
it('supports the ghost and secondary variants and the large size', () => {
|
||||||
render(<Button href="/product" variant="ghost" size="lg">See how it works</Button>);
|
const { rerender } = render(<Button href="/product" variant="ghost" size="lg">See how it works</Button>);
|
||||||
expect(screen.getByRole('link', { name: 'See how it works' })).toHaveClass('btn-ghost', 'btn-lg');
|
expect(screen.getByRole('link', { name: 'See how it works' })).toHaveClass('btn-ghost', 'btn-lg');
|
||||||
|
rerender(<Button href="/trust" variant="secondary">How approval works</Button>);
|
||||||
|
expect(screen.getByRole('link', { name: 'How approval works' })).toHaveClass('btn-secondary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports a light variant for use on the dark panel', () => {
|
||||||
|
render(<Button href="/signup" variant="light" size="lg">Get started</Button>);
|
||||||
|
expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn-light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can end with an arrow that is not announced', () => {
|
||||||
|
render(<Button href="/how-it-works" variant="ghost" size="lg" trailingIcon="arrow-right">See how it works</Button>);
|
||||||
|
const link = screen.getByRole('link', { name: 'See how it works' });
|
||||||
|
expect(link.querySelector('.icon')).toHaveAttribute('aria-hidden', 'true');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ApproveWithProof } from '../../src/marketing/home/ApproveWithProof.tsx';
|
||||||
|
import { ClosingCta } from '../../src/marketing/home/ClosingCta.tsx';
|
||||||
|
import { Examples } from '../../src/marketing/home/Examples.tsx';
|
||||||
|
import { Faq } from '../../src/marketing/home/Faq.tsx';
|
||||||
|
import { Hero } from '../../src/marketing/home/Hero.tsx';
|
||||||
|
import { PricingTeaser } from '../../src/marketing/home/PricingTeaser.tsx';
|
||||||
|
import { ProofStrip } from '../../src/marketing/home/ProofStrip.tsx';
|
||||||
|
import { SafeByDesign } from '../../src/marketing/home/SafeByDesign.tsx';
|
||||||
|
import { SectionHeader } from '../../src/marketing/home/SectionHeader.tsx';
|
||||||
|
import { SurfacesShowcase } from '../../src/marketing/home/SurfacesShowcase.tsx';
|
||||||
|
import { WatchTheWork } from '../../src/marketing/home/WatchTheWork.tsx';
|
||||||
|
import HomePage from '../../app/(marketing)/page.tsx';
|
||||||
|
import { PRICING_CONFIRMED } from '../../src/marketing/content.ts';
|
||||||
|
|
||||||
|
describe('SectionHeader', () => {
|
||||||
|
it('has an eyebrow, a heading and a supporting sentence', () => {
|
||||||
|
render(<SectionHeader eyebrow="One screen" title="Talk. Watch. Decide." subtitle="All in one place." />);
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'Talk. Watch. Decide.' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One screen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('All in one place.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hero', () => {
|
||||||
|
it('leads with the promise as the page heading', () => {
|
||||||
|
render(<Hero />);
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Your website, handled.' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tell Wursor what to change. Watch it work. Approve what you like.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends Get started to sign-up and See how it works to the how-it-works page (PX-13)', () => {
|
||||||
|
render(<Hero />);
|
||||||
|
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
|
||||||
|
expect(screen.getByRole('link', { name: 'See how it works' })).toHaveAttribute('href', '/how-it-works');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces the approval feature, linking to how approval works', () => {
|
||||||
|
render(<Hero />);
|
||||||
|
expect(screen.getByRole('link', { name: /Approve with proof: nine checks on every change/ })).toHaveAttribute('href', '/trust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says who it is for', () => {
|
||||||
|
render(<Hero />);
|
||||||
|
expect(screen.getByText('For WordPress sites · We host it, or you do')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the product as a picture, hidden from assistive technology and not focusable', () => {
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
const visual = container.querySelector('.m-hero-visual [aria-hidden="true"]');
|
||||||
|
expect(visual).not.toBeNull();
|
||||||
|
expect(visual).toHaveAttribute('inert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ProofStrip', () => {
|
||||||
|
it('shows the three proof points', () => {
|
||||||
|
render(<ProofStrip />);
|
||||||
|
expect(screen.getByText('9')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('checks before anything goes live')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1 click')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('to undo any change, any time')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Every step')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('on the record, in plain words')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SurfacesShowcase', () => {
|
||||||
|
it('starts on the Conversation tab with its caption', () => {
|
||||||
|
render(<SurfacesShowcase />);
|
||||||
|
expect(screen.getByRole('tab', { name: 'Conversation' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(screen.getByText('Conversation: ask in plain words. Wursor explains each step as it goes.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to the Preview and Board views and their captions', async () => {
|
||||||
|
render(<SurfacesShowcase />);
|
||||||
|
await userEvent.click(screen.getByRole('tab', { name: 'Preview' }));
|
||||||
|
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(screen.getByText('Preview: see the real page on a phone, tablet and laptop before anything goes live.')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('tab', { name: 'Board' }));
|
||||||
|
expect(screen.getByText('Board: every change in one place, from idea to published.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves between tabs with the arrow keys', async () => {
|
||||||
|
render(<SurfacesShowcase />);
|
||||||
|
screen.getByRole('tab', { name: 'Conversation' }).focus();
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WatchTheWork', () => {
|
||||||
|
it('shows the same change at three levels of detail', () => {
|
||||||
|
render(<WatchTheWork />);
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: /Watch the work, your way\./ })).toBeInTheDocument();
|
||||||
|
// The design words the heading more briefly on a phone; both are in the page and CSS shows one.
|
||||||
|
expect(screen.getByText('Watch every step.')).toHaveClass('m-m');
|
||||||
|
for (const [name, text] of [
|
||||||
|
['Headline', 'Only what changed and what needs you.'],
|
||||||
|
['Steps', 'Each step, in a sentence. The default.'],
|
||||||
|
['Everything', 'Every look, edit, check and decision.'],
|
||||||
|
] as const) {
|
||||||
|
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(text)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ApproveWithProof', () => {
|
||||||
|
it('lists what the owner gets and links to how approval works', () => {
|
||||||
|
render(<ApproveWithProof />);
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'See it before it’s real.' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Nine checks, written in plain words')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Looks right on a phone, a tablet and a laptop')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('One click to approve and publish, one click to undo')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'How approval works' })).toHaveAttribute('href', '/trust');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SafeByDesign', () => {
|
||||||
|
it('states the four rules', () => {
|
||||||
|
render(<SafeByDesign />);
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'Nothing goes live until you say so.' })).toBeInTheDocument();
|
||||||
|
for (const rule of ['Works on a safe copy', 'Nothing goes live without you', 'Everything is recorded', 'Undo in one click']) {
|
||||||
|
expect(screen.getByRole('heading', { level: 3, name: rule })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Examples', () => {
|
||||||
|
it('shows five small jobs, each with a before and after', () => {
|
||||||
|
render(<Examples />);
|
||||||
|
const list = screen.getByRole('list', { name: 'Examples' });
|
||||||
|
const items = within(list).getAllByRole('listitem');
|
||||||
|
expect(items).toHaveLength(5);
|
||||||
|
expect(within(items[0]!).getByText('Update the holiday hours')).toBeInTheDocument();
|
||||||
|
expect(within(items[0]!).getByText('Page · 2 min')).toBeInTheDocument();
|
||||||
|
expect(within(items[0]!).getByText('Before')).toBeInTheDocument();
|
||||||
|
expect(within(items[0]!).getByText('After')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PricingTeaser', () => {
|
||||||
|
it('does not show prices or allowances until they are decided (PRD PX-13, D-HL-8)', () => {
|
||||||
|
expect(PRICING_CONFIRMED).toBe(false);
|
||||||
|
render(<PricingTeaser />);
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'Simple plans. No surprises.' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/\$\d/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/free for 14 days/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/sites ·/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Plans and prices will be published before launch.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still leads to sign-up and the pricing page', () => {
|
||||||
|
render(<PricingTeaser />);
|
||||||
|
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
|
||||||
|
expect(screen.getByRole('link', { name: 'See pricing' })).toHaveAttribute('href', '/pricing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the plan cards when pricing is confirmed', () => {
|
||||||
|
render(<PricingTeaser confirmed />);
|
||||||
|
for (const plan of ['Starter', 'Studio', 'Agency']) {
|
||||||
|
expect(screen.getByRole('heading', { level: 3, name: plan })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
expect(screen.getAllByRole('link', { name: 'Choose plan' })).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Faq', () => {
|
||||||
|
it('lists the questions, with the first answered and open', () => {
|
||||||
|
render(<Faq />);
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'Questions, answered.' })).toBeInTheDocument();
|
||||||
|
const first = screen.getByText('Will Wursor change my live site without asking?').closest('details');
|
||||||
|
expect(first).toHaveAttribute('open');
|
||||||
|
expect(screen.getByText(/Nothing goes live until you review the preview and approve it\./)).toBeInTheDocument();
|
||||||
|
for (const q of ['Do I need my own hosting?', 'Can I undo a change?', 'Does it work with my theme and plugins?', 'How is my site kept safe?']) {
|
||||||
|
expect(screen.getByText(q).closest('details')).not.toHaveAttribute('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens an item to show its answer', async () => {
|
||||||
|
render(<Faq />);
|
||||||
|
await userEvent.click(screen.getByText('Do I need my own hosting?'));
|
||||||
|
expect(screen.getByText('Do I need my own hosting?').closest('details')).toHaveAttribute('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ClosingCta', () => {
|
||||||
|
it('ends with one clear action', () => {
|
||||||
|
render(<ClosingCta />);
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'Hand off the busywork. Keep the final say.' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HomePage', () => {
|
||||||
|
it('has one page heading, a nav and a footer', () => {
|
||||||
|
render(<HomePage />);
|
||||||
|
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||||
|
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('contentinfo')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes every call to action to sign-up or sign-in (PX-13)', () => {
|
||||||
|
render(<HomePage />);
|
||||||
|
const ctas = [...screen.getAllByRole('link', { name: /^(Get started|Sign in)$/ })];
|
||||||
|
expect(ctas.length).toBeGreaterThanOrEqual(3);
|
||||||
|
for (const a of ctas) expect(['/signup', '/signin']).toContain(a.getAttribute('href'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ChangeEvent } from '../../src/product/ChangeEvent.tsx';
|
||||||
|
import { CheckEvent } from '../../src/product/CheckEvent.tsx';
|
||||||
|
import { Composer } from '../../src/product/Composer.tsx';
|
||||||
|
import { ConversationPanel } from '../../src/product/ConversationPanel.tsx';
|
||||||
|
import { Message } from '../../src/product/Message.tsx';
|
||||||
|
import { Milestone } from '../../src/product/Milestone.tsx';
|
||||||
|
import { StepGroup } from '../../src/product/StepGroup.tsx';
|
||||||
|
import { StepLine } from '../../src/product/StepLine.tsx';
|
||||||
|
import { Thought } from '../../src/product/Thought.tsx';
|
||||||
|
|
||||||
|
describe('Message', () => {
|
||||||
|
it('puts what the owner said in a soft box', () => {
|
||||||
|
render(<Message role="user">Make the phone number tappable.</Message>);
|
||||||
|
expect(screen.getByText('Make the phone number tappable.').closest('.p-message')).toHaveClass('p-message-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Wursor replies beside the Wursor avatar', () => {
|
||||||
|
render(<Message role="wursor">On it.</Message>);
|
||||||
|
expect(screen.getByRole('img', { name: 'Wursor' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('On it.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StepGroup', () => {
|
||||||
|
it('is collapsed by default and opens on press', async () => {
|
||||||
|
render(<StepGroup title="Looked at 4 pages" count={4}>Home, Menu, Contact, About</StepGroup>);
|
||||||
|
const toggle = screen.getByRole('button', { name: /Looked at 4 pages/ });
|
||||||
|
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(screen.queryByText('Home, Menu, Contact, About')).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
expect(screen.getByText('Home, Menu, Contact, About')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the count', () => {
|
||||||
|
render(<StepGroup title="Looked at 4 pages" count={4} />);
|
||||||
|
expect(screen.getByText('4')).toHaveClass('p-counter');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Thought', () => {
|
||||||
|
it('is collapsed by default, with the reasoning hidden', () => {
|
||||||
|
render(<Thought seconds={6}>The number is plain text.</Thought>);
|
||||||
|
expect(screen.getByRole('button', { name: 'Thought for 6 seconds' })).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(screen.queryByText('The number is plain text.')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the reasoning in plain words when expanded', async () => {
|
||||||
|
render(<Thought seconds={6}>The number is plain text.</Thought>);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Thought for 6 seconds' }));
|
||||||
|
expect(screen.getByText('The number is plain text.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can start expanded', () => {
|
||||||
|
render(<Thought seconds={6} defaultExpanded>The number is plain text.</Thought>);
|
||||||
|
expect(screen.getByText('The number is plain text.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StepLine', () => {
|
||||||
|
it('shows what Wursor did and how long it took', () => {
|
||||||
|
render(<StepLine kind="edit" time="4s">Made the Contact page number a tap-to-call link</StepLine>);
|
||||||
|
expect(screen.getByText('Made the Contact page number a tap-to-call link')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('4s')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a running step so it is announced', () => {
|
||||||
|
render(<StepLine kind="running" time="now">Taking a photo of the page on a phone</StepLine>);
|
||||||
|
expect(screen.getByRole('status', { name: 'In progress' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChangeEvent', () => {
|
||||||
|
const props = {
|
||||||
|
title: 'Changed the Contact page',
|
||||||
|
meta: '1 section · 2 places',
|
||||||
|
risk: 'everyday',
|
||||||
|
added: 8,
|
||||||
|
removed: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
it('shows the change, its risk and how much changed', () => {
|
||||||
|
render(<ChangeEvent {...props} />);
|
||||||
|
expect(screen.getByText('Changed the Contact page')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Everyday')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('+8 lines')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('−2 lines')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the full comparison', async () => {
|
||||||
|
const onSeeChanges = vi.fn();
|
||||||
|
render(<ChangeEvent {...props} onSeeChanges={onSeeChanges} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'See what changed' }));
|
||||||
|
expect(onSeeChanges).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CheckEvent', () => {
|
||||||
|
it('reports a passed check quietly', () => {
|
||||||
|
render(<CheckEvent result="passed" title="Checked 9 things. All passed." detail="Phone link works, 3 screen sizes." />);
|
||||||
|
expect(screen.getByText('Checked 9 things. All passed.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Phone link works, 3 screen sizes.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Milestone', () => {
|
||||||
|
it('marks a stage in the life of a change', () => {
|
||||||
|
render(<Milestone>Plan ready</Milestone>);
|
||||||
|
expect(screen.getByRole('separator', { name: 'Plan ready' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Composer', () => {
|
||||||
|
it('invites a note while Wursor is working, and offers to stop', async () => {
|
||||||
|
const onStop = vi.fn();
|
||||||
|
render(<Composer state="working" autonomy="Ask first" onStop={onStop} />);
|
||||||
|
expect(screen.getByPlaceholderText('Wursor is working. Add a note, or stop.')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Stop' }));
|
||||||
|
expect(onStop).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the autonomy level and the toolbar actions', () => {
|
||||||
|
render(<Composer state="working" autonomy="Ask first" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Ask first' })).toBeInTheDocument();
|
||||||
|
for (const name of ['Attach a file', 'Point at something on the page', 'Dictate']) {
|
||||||
|
expect(screen.getByRole('button', { name })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the owner keep typing while it works', async () => {
|
||||||
|
render(<Composer state="working" autonomy="Ask first" />);
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: 'Message to Wursor' }), 'Also the header');
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Message to Wursor' })).toHaveValue('Also the header');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConversationPanel', () => {
|
||||||
|
it('has a header with the three detail levels, and a stream', () => {
|
||||||
|
render(
|
||||||
|
<ConversationPanel density="steps" onDensityChange={() => {}}>
|
||||||
|
<Message role="user">Hello</Message>
|
||||||
|
</ConversationPanel>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('heading', { name: 'Conversation' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('radio', { name: 'Steps' })).toBeChecked();
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a change of detail level', async () => {
|
||||||
|
const onDensityChange = vi.fn();
|
||||||
|
render(<ConversationPanel density="steps" onDensityChange={onDensityChange} />);
|
||||||
|
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
|
||||||
|
expect(onDensityChange).toHaveBeenCalledWith('everything');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Avatar } from '../../src/product/Avatar.tsx';
|
||||||
|
import { Button } from '../../src/product/Button.tsx';
|
||||||
|
import { Counter } from '../../src/product/Counter.tsx';
|
||||||
|
import { IconButton } from '../../src/product/IconButton.tsx';
|
||||||
|
import { RiskChip } from '../../src/product/RiskChip.tsx';
|
||||||
|
import { SegmentedControl } from '../../src/product/SegmentedControl.tsx';
|
||||||
|
import { StatusPill } from '../../src/product/StatusPill.tsx';
|
||||||
|
import { Tab } from '../../src/product/Tab.tsx';
|
||||||
|
import { Tag } from '../../src/product/Tag.tsx';
|
||||||
|
|
||||||
|
describe('Avatar', () => {
|
||||||
|
it('shows initials for a person', () => {
|
||||||
|
render(<Avatar kind="initials" initials="AO" name="Amara Okoye" />);
|
||||||
|
expect(screen.getByRole('img', { name: 'Amara Okoye' })).toHaveTextContent('AO');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the brand mark for Wursor', () => {
|
||||||
|
render(<Avatar kind="wursor" />);
|
||||||
|
expect(screen.getByRole('img', { name: 'Wursor' })).toHaveClass('p-avatar-wursor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a small and a medium size', () => {
|
||||||
|
render(<Avatar kind="initials" initials="AO" name="A" size="md" />);
|
||||||
|
expect(screen.getByRole('img', { name: 'A' })).toHaveClass('p-avatar-md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Counter', () => {
|
||||||
|
it('shows the count', () => {
|
||||||
|
render(<Counter count={3} />);
|
||||||
|
expect(screen.getByText('3')).toHaveClass('p-counter');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StatusPill', () => {
|
||||||
|
it.each([
|
||||||
|
['idea', 'Idea'],
|
||||||
|
['working', 'Working'],
|
||||||
|
['needs-you', 'Needs you'],
|
||||||
|
['done', 'Done'],
|
||||||
|
] as const)('labels the %s status in plain words', (status, label) => {
|
||||||
|
render(<StatusPill status={status} />);
|
||||||
|
expect(screen.getByText(label).closest('.p-status')).toHaveAttribute('data-status', status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RiskChip', () => {
|
||||||
|
it('shows the plain-word risk level', () => {
|
||||||
|
render(<RiskChip level="everyday" />);
|
||||||
|
expect(screen.getByText('Everyday')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IconButton', () => {
|
||||||
|
it('is named by its label and calls onClick', async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<IconButton icon="search" label="Search" onClick={onClick} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
|
||||||
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports the inverse variant', () => {
|
||||||
|
render(<IconButton icon="stop" label="Stop" variant="inverse" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Stop' })).toHaveClass('p-iconbtn-inverse');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
it('renders its label and calls onClick', async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<Button onClick={onClick}>See what changed</Button>);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'See what changed' }));
|
||||||
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to secondary and small, and supports primary and ghost', () => {
|
||||||
|
const { rerender } = render(<Button>A</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-secondary', 'p-btn-sm');
|
||||||
|
rerender(<Button variant="primary" size="md">A</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-primary', 'p-btn-md');
|
||||||
|
rerender(<Button variant="ghost">A</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-ghost');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SegmentedControl', () => {
|
||||||
|
const options = [
|
||||||
|
{ value: 'headline', label: 'Headline' },
|
||||||
|
{ value: 'steps', label: 'Steps' },
|
||||||
|
{ value: 'everything', label: 'Everything' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('is a radio group with the current choice checked', () => {
|
||||||
|
render(<SegmentedControl label="Detail" options={options} value="steps" onChange={() => {}} />);
|
||||||
|
expect(screen.getByRole('radiogroup', { name: 'Detail' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('radio', { name: 'Steps' })).toBeChecked();
|
||||||
|
expect(screen.getByRole('radio', { name: 'Headline' })).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the chosen value', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<SegmentedControl label="Detail" options={options} value="steps" onChange={onChange} />);
|
||||||
|
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith('everything');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tab', () => {
|
||||||
|
it('marks the selected tab', () => {
|
||||||
|
render(
|
||||||
|
<div role="tablist">
|
||||||
|
<Tab label="Preview" selected />
|
||||||
|
<Tab label="Compare" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(screen.getByRole('tab', { name: 'Compare' })).toHaveAttribute('aria-selected', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can show a count', () => {
|
||||||
|
render(<Tab label="Proof" count={3} />);
|
||||||
|
expect(screen.getByRole('tab', { name: /Proof/ })).toHaveTextContent('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tag', () => {
|
||||||
|
it('shows its label', () => {
|
||||||
|
render(<Tag label="Draft copy" />);
|
||||||
|
expect(screen.getByText('Draft copy')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be removed when closable', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<Tag label="Draft copy" closable onClose={onClose} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Remove Draft copy' }));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ApprovalBar } from '../../src/product/ApprovalBar.tsx';
|
||||||
|
import { BoardCard } from '../../src/product/BoardCard.tsx';
|
||||||
|
import { BoardStrip } from '../../src/product/BoardStrip.tsx';
|
||||||
|
import { CompareSlider } from '../../src/product/CompareSlider.tsx';
|
||||||
|
import { DeviceFrame } from '../../src/product/DeviceFrame.tsx';
|
||||||
|
import { PreviewToolbar } from '../../src/product/PreviewToolbar.tsx';
|
||||||
|
import { Sidebar } from '../../src/product/Sidebar.tsx';
|
||||||
|
import { SiteMock } from '../../src/product/SiteMock.tsx';
|
||||||
|
import { TopBar } from '../../src/product/TopBar.tsx';
|
||||||
|
import { Workspace } from '../../src/product/Workspace.tsx';
|
||||||
|
import { HARBOR } from '../../src/product/harbor-bakery.ts';
|
||||||
|
|
||||||
|
describe('TopBar', () => {
|
||||||
|
it('shows the site, the current change and its status', () => {
|
||||||
|
render(<TopBar site="Harbor Bakery" siteInitial="H" change="Make phone number tappable" status="working" autonomy="Ask first" account={{ initials: 'AO', name: 'Amara Okoye' }} />);
|
||||||
|
expect(screen.getByRole('button', { name: /Harbor Bakery/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Make phone number tappable')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Working')).toBeInTheDocument();
|
||||||
|
for (const name of ['Toggle sidebar', 'Search', 'Notifications']) {
|
||||||
|
expect(screen.getByRole('button', { name })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
const changes = [
|
||||||
|
{ title: 'Make phone number tappable', status: 'working' as const, active: true },
|
||||||
|
{ title: 'Update opening hours', status: 'needs-you' as const },
|
||||||
|
{ title: 'Describe 3 images', status: 'idea' as const },
|
||||||
|
{ title: 'Refresh footer links', status: 'done' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('has the four places and the list of changes', () => {
|
||||||
|
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
|
||||||
|
const nav = screen.getByRole('navigation', { name: 'Workspace' });
|
||||||
|
expect(within(nav).getAllByRole('link').map((a) => a.textContent)).toEqual(['Home', 'Board', 'Activity', 'Sites']);
|
||||||
|
expect(screen.getByRole('list', { name: 'Changes' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('listitem')).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the open change', () => {
|
||||||
|
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
|
||||||
|
expect(screen.getByText('Make phone number tappable').closest('[aria-current]')).toHaveAttribute('aria-current', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the account', () => {
|
||||||
|
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
|
||||||
|
expect(screen.getByText('Amara Okoye')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PreviewToolbar', () => {
|
||||||
|
it('has Preview, Compare and Proof tabs with the draft tag and device buttons', () => {
|
||||||
|
render(<PreviewToolbar selected="preview" tag="Draft copy" />);
|
||||||
|
expect(screen.getByRole('tablist', { name: 'View' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: 'Proof' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Draft copy')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Phone' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SiteMock and DeviceFrame', () => {
|
||||||
|
it('shows the Harbor Bakery contact page', () => {
|
||||||
|
render(<SiteMock state="after" />);
|
||||||
|
expect(screen.getByText('Find us on Quay Street')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Fresh bread from 6:30, every morning.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('before the change the number is plain text, after it is a tap-to-call link', () => {
|
||||||
|
const { rerender } = render(<SiteMock state="before" />);
|
||||||
|
expect(screen.queryByRole('link', { name: /Call \(555\) 014-2290/ })).not.toBeInTheDocument();
|
||||||
|
rerender(<SiteMock state="after" />);
|
||||||
|
expect(screen.getByRole('link', { name: /Call \(555\) 014-2290/ })).toHaveAttribute('href', 'tel:+55550142290');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frames the page in a browser bar with its address', () => {
|
||||||
|
render(<DeviceFrame url="harborbakery.com/contact"><SiteMock state="after" /></DeviceFrame>);
|
||||||
|
expect(screen.getByText('harborbakery.com/contact')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BoardCard and BoardStrip', () => {
|
||||||
|
it('asks for the owner when a change needs them', () => {
|
||||||
|
render(<BoardCard status="needs-you" title="Update opening hours" meta="Home page · 1 hour ago" />);
|
||||||
|
expect(screen.getByText('Wursor is waiting for your answer')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Needs you')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows risk and progress on a working change', () => {
|
||||||
|
render(<BoardCard status="working" title="Make phone number tappable" meta="Contact page · 1 min ago" risk="everyday" progress={0.62} />);
|
||||||
|
expect(screen.getByText('Everyday')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('progressbar', { name: 'Make phone number tappable' })).toHaveAttribute('aria-valuenow', '62');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups cards into the four lanes with counts', () => {
|
||||||
|
render(<BoardStrip lanes={HARBOR.board} />);
|
||||||
|
for (const lane of ['Needs you', 'Working', 'Idea', 'Done']) {
|
||||||
|
expect(screen.getByRole('group', { name: lane })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
expect(screen.getByText('Open board')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CompareSlider', () => {
|
||||||
|
it('is a slider the keyboard can move, labelled in plain words', async () => {
|
||||||
|
render(<CompareSlider before={<span>old</span>} after={<span>new</span>} defaultPosition={50} />);
|
||||||
|
const slider = screen.getByRole('slider', { name: 'Compare before and after' });
|
||||||
|
expect(slider).toHaveAttribute('aria-valuenow', '50');
|
||||||
|
slider.focus();
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
expect(slider).toHaveAttribute('aria-valuenow', '55');
|
||||||
|
await userEvent.keyboard('{ArrowLeft}{ArrowLeft}');
|
||||||
|
expect(slider).toHaveAttribute('aria-valuenow', '45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels both sides', () => {
|
||||||
|
render(<CompareSlider before={<span>old</span>} after={<span>new</span>} />);
|
||||||
|
expect(screen.getByText('Before')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('After')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ApprovalBar', () => {
|
||||||
|
it('says what is ready and offers one clear action', async () => {
|
||||||
|
const onApprove = vi.fn();
|
||||||
|
render(<ApprovalBar title="Ready for you" meta="2 changes · Everyday risk" onApprove={onApprove} />);
|
||||||
|
expect(screen.getByText('Ready for you')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2 changes · Everyday risk')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Approve and publish' }));
|
||||||
|
expect(onApprove).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Workspace', () => {
|
||||||
|
it('assembles the top bar, sidebar, conversation, preview and board', () => {
|
||||||
|
render(<Workspace defaultDensity="steps" />);
|
||||||
|
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('navigation', { name: 'Workspace' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('heading', { name: 'Conversation' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tablist', { name: 'View' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('region', { name: 'Board' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes what it shows when the owner picks another detail level', async () => {
|
||||||
|
render(<Workspace defaultDensity="steps" />);
|
||||||
|
expect(screen.queryByText('Looked at home page')).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
|
||||||
|
expect(screen.getByText('Looked at home page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows less at the headline level and more at everything', () => {
|
||||||
|
const { unmount } = render(<Workspace defaultDensity="headline" />);
|
||||||
|
expect(screen.getByRole('separator', { name: 'Plan ready' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Looked at home page')).not.toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
render(<Workspace defaultDensity="everything" />);
|
||||||
|
expect(screen.getByText('Looked at home page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ const COLOR_TOKENS = [
|
|||||||
'--color-text-primary', '--color-text-secondary', '--color-text-tertiary', '--color-text-inverse',
|
'--color-text-primary', '--color-text-secondary', '--color-text-tertiary', '--color-text-inverse',
|
||||||
'--color-text-on-accent', '--color-text-link',
|
'--color-text-on-accent', '--color-text-link',
|
||||||
'--color-border-subtle', '--color-border-default', '--color-border-strong',
|
'--color-border-subtle', '--color-border-default', '--color-border-strong',
|
||||||
'--color-accent-default', '--color-accent-text', '--color-accent-subtle',
|
'--color-accent-default', '--color-accent-fill', '--color-accent-text', '--color-accent-subtle',
|
||||||
'--color-status-working-dot', '--color-status-working-fg', '--color-status-working-bg',
|
'--color-status-working-dot', '--color-status-working-fg', '--color-status-working-bg',
|
||||||
'--color-status-needs-you-dot', '--color-status-needs-you-fg', '--color-status-needs-you-bg',
|
'--color-status-needs-you-dot', '--color-status-needs-you-fg', '--color-status-needs-you-bg',
|
||||||
'--color-status-success-dot', '--color-status-success-fg', '--color-status-success-bg',
|
'--color-status-success-dot', '--color-status-success-fg', '--color-status-success-bg',
|
||||||
@@ -79,3 +79,45 @@ describe('design tokens', () => {
|
|||||||
expect(css).toMatch(/\.t-display-xl\s*\{[^}]*letter-spacing:\s*-0\.03em/);
|
expect(css).toMatch(/\.t-display-xl\s*\{[^}]*letter-spacing:\s*-0\.03em/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** WCAG 2.x relative luminance and contrast ratio. */
|
||||||
|
function luminance(hex: string): number {
|
||||||
|
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as [number, number, number];
|
||||||
|
const f = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||||
|
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||||
|
}
|
||||||
|
function contrast(a: string, b: string): number {
|
||||||
|
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
||||||
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text token on the background token it appears on. All are small text, so AA is 4.5:1 (PX-10). */
|
||||||
|
const PAIRS: ReadonlyArray<[string, string]> = [
|
||||||
|
['--color-text-primary', '--color-bg-canvas'], ['--color-text-primary', '--color-bg-surface'],
|
||||||
|
['--color-text-primary', '--color-bg-sunken'], ['--color-text-primary', '--color-accent-subtle'],
|
||||||
|
['--color-text-secondary', '--color-bg-canvas'], ['--color-text-secondary', '--color-bg-surface'],
|
||||||
|
['--color-text-secondary', '--color-bg-sunken'], ['--color-text-secondary', '--color-accent-subtle'],
|
||||||
|
['--color-text-tertiary', '--color-bg-canvas'], ['--color-text-tertiary', '--color-bg-surface'],
|
||||||
|
['--color-text-tertiary', '--color-bg-sunken'],
|
||||||
|
['--color-text-inverse', '--color-bg-inverse'],
|
||||||
|
['--color-text-on-accent', '--color-accent-fill'],
|
||||||
|
['--color-accent-text', '--color-bg-canvas'], ['--color-accent-text', '--color-bg-surface'],
|
||||||
|
['--color-accent-text', '--color-accent-subtle'],
|
||||||
|
['--color-text-link', '--color-bg-canvas'], ['--color-text-link', '--color-bg-surface'],
|
||||||
|
['--color-text-link', '--color-bg-sunken'],
|
||||||
|
['--color-status-working-fg', '--color-status-working-bg'],
|
||||||
|
['--color-status-needs-you-fg', '--color-status-needs-you-bg'],
|
||||||
|
['--color-status-success-fg', '--color-status-success-bg'],
|
||||||
|
['--color-diff-add-fg', '--color-bg-surface'], ['--color-diff-remove-fg', '--color-bg-surface'],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('contrast (WCAG 2.2 AA, PX-10)', () => {
|
||||||
|
for (const [theme, tokens] of [['light', light], ['dark', dark]] as const) {
|
||||||
|
it(`every text pair is at least 4.5:1 in the ${theme} theme`, () => {
|
||||||
|
const failures = PAIRS.map(([fg, bg]) => ({ fg, bg, ratio: contrast(tokens[fg]!, tokens[bg]!) }))
|
||||||
|
.filter((p) => p.ratio < 4.5)
|
||||||
|
.map((p) => `${p.fg} on ${p.bg} = ${p.ratio.toFixed(2)}`);
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Metadata } from 'next';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import '../../src/styles/tokens.css';
|
import '../../src/styles/tokens.css';
|
||||||
import '../../src/styles/marketing.css';
|
import '../../src/styles/marketing.css';
|
||||||
|
import '../../src/styles/product.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: { default: 'Wursor', template: '%s · Wursor' },
|
title: { default: 'Wursor', template: '%s · Wursor' },
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
import { Footer } from '../../src/marketing/Footer.tsx';
|
import { Footer } from '../../src/marketing/Footer.tsx';
|
||||||
|
import { ApproveWithProof } from '../../src/marketing/home/ApproveWithProof.tsx';
|
||||||
|
import { ClosingCta } from '../../src/marketing/home/ClosingCta.tsx';
|
||||||
|
import { Examples } from '../../src/marketing/home/Examples.tsx';
|
||||||
|
import { Faq } from '../../src/marketing/home/Faq.tsx';
|
||||||
|
import { Hero } from '../../src/marketing/home/Hero.tsx';
|
||||||
|
import { PricingTeaser } from '../../src/marketing/home/PricingTeaser.tsx';
|
||||||
|
import { ProofStrip } from '../../src/marketing/home/ProofStrip.tsx';
|
||||||
|
import { SafeByDesign } from '../../src/marketing/home/SafeByDesign.tsx';
|
||||||
|
import { SectionHeader } from '../../src/marketing/home/SectionHeader.tsx';
|
||||||
|
import { SurfacesShowcase } from '../../src/marketing/home/SurfacesShowcase.tsx';
|
||||||
|
import { WatchTheWork } from '../../src/marketing/home/WatchTheWork.tsx';
|
||||||
import { Nav } from '../../src/marketing/Nav.tsx';
|
import { Nav } from '../../src/marketing/Nav.tsx';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
<main />
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<ProofStrip />
|
||||||
|
<section className="m-section m-section-first">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="One screen"
|
||||||
|
title="Talk. Watch. Decide."
|
||||||
|
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>
|
||||||
|
<WatchTheWork />
|
||||||
|
<ApproveWithProof />
|
||||||
|
<SafeByDesign />
|
||||||
|
<Examples />
|
||||||
|
<PricingTeaser />
|
||||||
|
<Faq />
|
||||||
|
<ClosingCta />
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Single-colour line icons exported from the Figma file "Wursor - weown.net" (Icon/* components).
|
||||||
|
Used through the `Icon` component (src/ui/Icon.tsx), which paints them with CSS masks so the colour comes from the
|
||||||
|
current theme. Do not edit the files; re-export from Figma if an icon changes.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M16.5 9H13.5L11.25 15.75L6.75 2.25L4.5 9H1.5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 344 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="Trailing icon">
|
||||||
|
<path id="Vector" d="M4.16667 10H15.8333M10 15.8333L15.8333 10L10 4.16667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M6.86667 14C6.97826 14.203 7.1423 14.3722 7.34166 14.4901C7.54102 14.608 7.76839 14.6702 8 14.6702C8.23161 14.6702 8.45898 14.608 8.65834 14.4901C8.8577 14.3722 9.02175 14.203 9.13333 14M4 5.33333C4 4.27247 4.42143 3.25505 5.17157 2.50491C5.92172 1.75476 6.93913 1.33333 8 1.33333C9.06087 1.33333 10.0783 1.75476 10.8284 2.50491C11.5786 3.25505 12 4.27247 12 5.33333C12 10 14 11.3333 14 11.3333H2C2 11.3333 4 10 4 5.33333Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 722 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M4.5 3.75V12M9 3.75V8.25M13.5 3.75V14.25" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 340 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/check-circle">
|
||||||
|
<path id="Vector" d="M18.3333 9.23333V10C18.3323 11.797 17.7504 13.5456 16.6744 14.9849C15.5985 16.4241 14.0861 17.4771 12.3628 17.9866C10.6395 18.4961 8.79771 18.4349 7.11205 17.8122C5.4264 17.1894 3.98721 16.0384 3.00913 14.5309C2.03105 13.0234 1.56649 11.2401 1.68473 9.44693C1.80296 7.6538 2.49767 5.94694 3.66523 4.58089C4.83279 3.21485 6.41065 2.26282 8.16349 1.86679C9.91632 1.47076 11.7502 1.65195 13.3917 2.38333M18.3333 3.33333L10 11.675L7.5 9.175" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 749 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/check">
|
||||||
|
<path id="Vector" d="M16.6667 5L7.5 14.1667L3.33333 10" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Trailing icon">
|
||||||
|
<path id="Vector" d="M4 6L8 10L12 6" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 323 B |
|
Before Width: | Height: | Size: 335 B After Width: | Height: | Size: 335 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/chevron-up">
|
||||||
|
<path id="Vector" d="M15 12.5L10 7.5L5 12.5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 333 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/clock">
|
||||||
|
<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="#8F8B80" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M10 5V10L13.3333 11.6667" stroke="#8F8B80" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 624 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Close">
|
||||||
|
<path id="Vector" d="M10.5 3.5L3.5 10.5M3.5 3.5L10.5 10.5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 337 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="37.4737" height="7.49474" viewBox="0 0 37.4737 7.49474" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Dots">
|
||||||
|
<circle id="Ellipse" cx="3.74737" cy="3.74737" r="3.74737" fill="#C4C2B8"/>
|
||||||
|
<circle id="Ellipse_2" cx="18.7368" cy="3.74737" r="3.74737" fill="#C4C2B8"/>
|
||||||
|
<circle id="Ellipse_3" cx="33.7263" cy="3.74737" r="3.74737" fill="#C4C2B8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 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/eye">
|
||||||
|
<path id="Vector" d="M1.66667 10C1.66667 10 4.16667 4.16667 10 4.16667C15.8333 4.16667 18.3333 10 18.3333 10C18.3333 10 15.8333 15.8333 10 15.8333C4.16667 15.8333 1.66667 10 1.66667 10Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M10 12.5C11.3807 12.5 12.5 11.3807 12.5 10C12.5 8.61929 11.3807 7.5 10 7.5C8.61929 7.5 7.5 8.61929 7.5 10C7.5 11.3807 8.61929 12.5 10 12.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 717 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M9 16.5C13.1421 16.5 16.5 13.1421 16.5 9C16.5 4.85786 13.1421 1.5 9 1.5C4.85786 1.5 1.5 4.85786 1.5 9C1.5 13.1421 4.85786 16.5 9 16.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M1.5 9H16.5M9 1.5C10.876 3.55376 11.9421 6.21903 12 9C11.9421 11.781 10.876 14.4462 9 16.5C7.12404 14.4462 6.05794 11.781 6 9C6.05794 6.21903 7.12404 3.55376 9 1.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 708 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/home">
|
||||||
|
<path id="Vector" d="M2.25 6.75L9 1.5L15.75 6.75V15C15.75 15.3978 15.592 15.7794 15.3107 16.0607C15.0294 16.342 14.6478 16.5 14.25 16.5H3.75C3.35218 16.5 2.97064 16.342 2.68934 16.0607C2.40804 15.7794 2.25 15.3978 2.25 15V6.75Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M6.75 16.5V9H11.25V16.5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 644 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/list">
|
||||||
|
<path id="Vector" d="M6.66667 5H17.5M6.66667 10H17.5M6.66667 15H17.5M2.5 5H2.50833M2.5 10H2.50833M2.5 15H2.50833" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 396 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/loader">
|
||||||
|
<path id="Vector" d="M10 2.5V5.5M10 14.5V17.5M2.5 10H5.5M14.5 10H17.5M4.7 4.7L6.8 6.8M13.2 13.2L15.3 15.3M4.7 15.3L6.8 13.2M13.2 6.8L15.3 4.7" stroke="#2A52B0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18.7368" height="18.7368" viewBox="0 0 18.7368 18.7368" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/lock">
|
||||||
|
<path id="Vector" d="M5.46491 8.58772V5.46491C5.46491 4.42964 5.87617 3.43676 6.60822 2.70472C7.34027 1.97267 8.33315 1.5614 9.36842 1.5614C10.4037 1.5614 11.3966 1.97267 12.1286 2.70472C12.8607 3.43676 13.2719 4.42964 13.2719 5.46491V8.58772M3.90351 8.58772H14.8333C15.2474 8.58772 15.6446 8.75222 15.9374 9.04504C16.2302 9.33786 16.3947 9.73501 16.3947 10.1491V15.614C16.3947 16.0281 16.2302 16.4253 15.9374 16.7181C15.6446 17.0109 15.2474 17.1754 14.8333 17.1754H3.90351C3.4894 17.1754 3.09225 17.0109 2.79943 16.7181C2.50661 16.4253 2.34211 16.0281 2.34211 15.614V10.1491C2.34211 9.73501 2.50661 9.33786 2.79943 9.04504C3.09225 8.75222 3.4894 8.58772 3.90351 8.58772Z" stroke="#8F8B80" stroke-width="1.40526" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 377 B After Width: | Height: | Size: 377 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M3.33333 6.66667V8C3.33333 9.23768 3.825 10.4247 4.70017 11.2998C5.57534 12.175 6.76232 12.6667 8 12.6667C9.23768 12.6667 10.4247 12.175 11.2998 11.2998C12.175 10.4247 12.6667 9.23768 12.6667 8V6.66667M8 12.6667V14.6667M8 1.33333C7.46957 1.33333 6.96086 1.54405 6.58579 1.91912C6.21071 2.29419 6 2.8029 6 3.33333V8C6 8.53043 6.21071 9.03914 6.58579 9.41421C6.96086 9.78929 7.46957 10 8 10C8.53043 10 9.03914 9.78929 9.41421 9.41421C9.78929 9.03914 10 8.53043 10 8V3.33333C10 2.8029 9.78929 2.29419 9.41421 1.91912C9.03914 1.54405 8.53043 1.33333 8 1.33333Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 856 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M5.33333 14H10.6667M8 11.3333V14M2.66667 2.66667H13.3333C13.687 2.66667 14.0261 2.80714 14.2761 3.05719C14.5262 3.30724 14.6667 3.64638 14.6667 4V10C14.6667 10.3536 14.5262 10.6928 14.2761 10.9428C14.0261 11.1929 13.687 11.3333 13.3333 11.3333H2.66667C2.31304 11.3333 1.97391 11.1929 1.72386 10.9428C1.47381 10.6928 1.33333 10.3536 1.33333 10V4C1.33333 3.64638 1.47381 3.30724 1.72386 3.05719C1.97391 2.80714 2.31304 2.66667 2.66667 2.66667Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M14.2933 7.36667L8.16667 13.4933C7.4161 14.2439 6.39812 14.6656 5.33667 14.6656C4.27521 14.6656 3.25723 14.2439 2.50667 13.4933C1.7561 12.7428 1.33444 11.7248 1.33444 10.6633C1.33444 9.60188 1.7561 8.5839 2.50667 7.83333L8.22 2.12C8.72037 1.61874 9.39938 1.33679 10.1076 1.33616C10.8159 1.33553 11.4954 1.61629 11.9967 2.11667C12.4979 2.61704 12.7799 3.29605 12.7805 4.00431C12.7811 4.71257 12.5004 5.39207 12 5.89333L6.27333 11.6067C6.02315 11.8569 5.68382 11.9974 5.33 11.9974C4.97618 11.9974 4.63685 11.8569 4.38667 11.6067C4.13648 11.3565 3.99593 11.0172 3.99593 10.6633C3.99593 10.3095 4.13648 9.97019 4.38667 9.72L10.0467 4.06667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 935 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/pencil">
|
||||||
|
<path id="Vector" d="M14.1667 2.5C14.3789 2.2492 14.6414 2.04505 14.9375 1.90057C15.2336 1.75609 15.5568 1.67445 15.8864 1.66085C16.216 1.64725 16.5449 1.70198 16.852 1.82157C17.1591 1.94116 17.4378 2.12298 17.6703 2.35543C17.9027 2.58789 18.0838 2.86589 18.2021 3.1717C18.3204 3.47752 18.3732 3.80445 18.3572 4.13165C18.3412 4.45885 18.2567 4.77915 18.1091 5.07214C17.9615 5.36512 17.7541 5.62436 17.5 5.83333L6.25 17.0833L1.66667 18.3333L2.91667 13.75L14.1667 2.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 752 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/plus">
|
||||||
|
<path id="Vector" d="M4.16667 10H15.8333M10 4.16667V15.8333" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M8.66667 8.66667L12.6667 12.6667M2 2L6.71333 13.3133L8.38667 8.38667L13.3133 6.71333L2 2Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18.7368" height="18.7368" viewBox="0 0 18.7368 18.7368" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/refresh">
|
||||||
|
<path id="Vector" d="M2.34211 9.36842C2.34211 7.50493 3.08238 5.71776 4.40007 4.40007C5.71776 3.08238 7.50493 2.34211 9.36842 2.34211C11.3327 2.3495 13.2181 3.11596 14.6304 4.48123L16.3947 6.24561M12.4912 6.24561H16.3947V2.34211M16.3947 9.36842C16.3947 11.2319 15.6545 13.0191 14.3368 14.3368C13.0191 15.6545 11.2319 16.3947 9.36842 16.3947C7.40414 16.3873 5.51876 15.6209 4.10649 14.2556L2.34211 12.4912M2.34211 16.3947V12.4912H6.24561" stroke="#6B675E" stroke-width="1.40526" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 747 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M14 14L11.1333 11.1333" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 605 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon/shield-check">
|
||||||
|
<path id="Vector" d="M11.6667 7.58333C11.6667 10.5 9.625 11.9583 7.19833 12.8042C7.07126 12.8472 6.93323 12.8452 6.8075 12.7983C4.375 11.9583 2.33333 10.5 2.33333 7.58333V3.5C2.33333 3.34529 2.39479 3.19692 2.50419 3.08752C2.61358 2.97813 2.76196 2.91667 2.91667 2.91667C4.08333 2.91667 5.54167 2.21667 6.55667 1.33C6.68025 1.22442 6.83746 1.1664 7 1.1664C7.16254 1.1664 7.31975 1.22442 7.44333 1.33C8.46417 2.2225 9.91667 2.91667 11.0833 2.91667C11.238 2.91667 11.3864 2.97813 11.4958 3.08752C11.6052 3.19692 11.6667 3.34529 11.6667 3.5V7.58333Z" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M5.25 7L6.41667 8.16667L8.75 5.83333" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 984 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/shield">
|
||||||
|
<path id="Vector" d="M16.6667 10.8333C16.6667 15 13.75 17.0833 10.2833 18.2917C10.1018 18.3532 9.90461 18.3502 9.725 18.2833C6.25 17.0833 3.33333 15 3.33333 10.8333V5C3.33333 4.77899 3.42113 4.56703 3.57741 4.41074C3.73369 4.25446 3.94565 4.16667 4.16667 4.16667C5.83333 4.16667 7.91667 3.16667 9.36667 1.9C9.54321 1.74917 9.76779 1.66629 10 1.66629C10.2322 1.66629 10.4568 1.74917 10.6333 1.9C12.0917 3.175 14.1667 4.16667 15.8333 4.16667C16.0543 4.16667 16.2663 4.25446 16.4226 4.41074C16.5789 4.56703 16.6667 4.77899 16.6667 5V10.8333Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 824 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M6 2V14M3.33333 2H12.6667C13.0203 2 13.3594 2.14048 13.6095 2.39052C13.8595 2.64057 14 2.97971 14 3.33333V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V3.33333C2 2.97971 2.14048 2.64057 2.39052 2.39052C2.64057 2.14048 2.97971 2 3.33333 2Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 668 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16.1667" height="16" viewBox="0 0 16.1667 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M2.75 14V9.33333M2.75 6.66667V2M8.08333 14V8M8.08333 5.33333V2M13.4167 14V10.6667M13.4167 8V2M0.75 9.33333H4.75M6.08333 5.33333H10.0833M11.4167 10.6667H15.4167" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 469 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M8 12H8.00667M4.66667 1.33333H11.3333C11.687 1.33333 12.0261 1.47381 12.2761 1.72386C12.5262 1.97391 12.6667 2.31304 12.6667 2.66667V13.3333C12.6667 13.687 12.5262 14.0261 12.2761 14.2761C12.0261 14.5262 11.687 14.6667 11.3333 14.6667H4.66667C4.31304 14.6667 3.97391 14.5262 3.72386 14.2761C3.47381 14.0261 3.33333 13.687 3.33333 13.3333V2.66667C3.33333 2.31304 3.47381 1.97391 3.72386 1.72386C3.97391 1.47381 4.31304 1.33333 4.66667 1.33333Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 742 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/sparkles">
|
||||||
|
<path id="Vector" d="M10 2.5L11.5833 6.75L15.8333 8.33333L11.5833 9.91667L10 14.1667L8.41667 9.91667L4.16667 8.33333L8.41667 6.75L10 2.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M15.8333 12.5L16.5 14.3333L18.3333 15L16.5 15.6667L15.8333 17.5L15.1667 15.6667L13.3333 15L15.1667 14.3333L15.8333 12.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 655 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Leading icon">
|
||||||
|
<path id="Vector" d="M14 5.33333V2H10.6667M14 2L10 6M2 5.33333V2H5.33333M2 2L7.22 7.22C7.47076 7.471 7.66891 7.76951 7.80284 8.09805C7.93678 8.42659 8.00381 8.77856 8 9.13333V14.6667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 469 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Icon">
|
||||||
|
<path id="Vector" d="M4.66667 3.33333H11.3333C11.687 3.33333 12.0261 3.47381 12.2761 3.72386C12.5262 3.97391 12.6667 4.31304 12.6667 4.66667V11.3333C12.6667 11.687 12.5262 12.0261 12.2761 12.2761C12.0261 12.5262 11.687 12.6667 11.3333 12.6667H4.66667C4.31304 12.6667 3.97391 12.5262 3.72386 12.2761C3.47381 12.0261 3.33333 11.687 3.33333 11.3333V4.66667C3.33333 4.31304 3.47381 3.97391 3.72386 3.72386C3.97391 3.47381 4.31304 3.33333 4.66667 3.33333Z" stroke="#F2F1EC" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 729 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/undo">
|
||||||
|
<path id="Vector" d="M2.5 5.83333V10.8333H7.5" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M17.5 14.1667C17.5 12.1775 16.7098 10.2699 15.3033 8.86337C13.8968 7.45684 11.9891 6.66667 10 6.66667C8.15427 6.66855 6.37404 7.35097 5 8.58333L2.5 10.8333" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
@@ -3,9 +3,8 @@ Do not redraw or edit them. Re-export from Figma if the design changes.
|
|||||||
|
|
||||||
| File | Figma node |
|
| File | Figma node |
|
||||||
|---|---|
|
|---|---|
|
||||||
| brand-mark-light.svg | Brand/Mark (28:30), light |
|
| brand-mark-light.svg / brand-mark-dark.svg | Brand/Mark (28:30), Nav Bar light (64:1426) and dark (136:72493) |
|
||||||
| brand-mark-dark.svg | Brand/Mark in Nav Bar, dark (136:72493) |
|
| avatar-mark.svg / avatar-mark-dark.svg | Mark inside the Wursor Avatar (28:98), light and dark |
|
||||||
| icons/menu.svg | Icon in Menu · Mobile (143:77164) |
|
| backdrop.svg | Backdrop "Size=Wide" (63:1707); the same warm backdrop is used in light and dark |
|
||||||
| icons/chevron-right.svg | Icon/chevron-right in Hero Announcement (64:1451) |
|
|
||||||
|
|
||||||
Single-colour icons are drawn with CSS masks so they take the token colour for the current theme.
|
Icons are in ../icons. They are drawn with CSS masks so they take the token colour for the current theme.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="10.6944" height="10.6944" viewBox="0 0 10.6944 10.6944" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Mark">
|
||||||
|
<path id="Vector" d="M2.00521 6.90683C2.00521 5.06115 3.50154 3.56481 5.34722 3.56481C7.19291 3.56481 8.68924 5.06115 8.68924 6.90683" stroke="#14120B" stroke-width="2.48264" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle id="ember" cx="5.34722" cy="6.90683" r="0.891204" fill="#FF7A33"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 511 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Mark">
|
||||||
|
<path id="Vector" d="M2.625 9.04167C2.625 6.6255 4.58383 4.66667 7 4.66667C9.41617 4.66667 11.375 6.6255 11.375 9.04167" stroke="#F2F1EC" stroke-width="3.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle id="ember" cx="7" cy="9.04167" r="1.16667" fill="#E8590C"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="1344" height="720" viewBox="0 0 1344 720" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Backdrop">
|
||||||
|
<g clip-path="url(#clip0_0_834)">
|
||||||
|
<rect id="Base" width="1344" height="720" fill="url(#paint0_linear_0_834)"/>
|
||||||
|
<g id="Blob 1" filter="url(#filter0_f_0_834)">
|
||||||
|
<ellipse cx="201.6" cy="540" rx="369.6" ry="324" fill="#F28C54" fill-opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
<g id="Blob 2" filter="url(#filter1_f_0_834)">
|
||||||
|
<ellipse cx="1048.32" cy="144" rx="336" ry="288" fill="#FFEDCC" fill-opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
<g id="Blob 3" filter="url(#filter2_f_0_834)">
|
||||||
|
<ellipse cx="739.2" cy="684" rx="302.4" ry="216" fill="#FCCCA8" fill-opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
<g id="Blob 4" filter="url(#filter3_f_0_834)">
|
||||||
|
<ellipse cx="67.2" cy="72" rx="235.2" ry="180" fill="#FFE0C2" fill-opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_f_0_834" x="-337" y="47" width="1077.2" height="986" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="84.5" result="effect1_foregroundBlur_0_834"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_f_0_834" x="543.32" y="-313" width="1010" height="914" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="84.5" result="effect1_foregroundBlur_0_834"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="filter2_f_0_834" x="267.8" y="299" width="942.8" height="770" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="84.5" result="effect1_foregroundBlur_0_834"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="filter3_f_0_834" x="-337" y="-277" width="808.4" height="698" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="84.5" result="effect1_foregroundBlur_0_834"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_0_834" x1="672" y1="0" x2="672" y2="720" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FAEDE0"/>
|
||||||
|
<stop offset="1" stop-color="#F5D6BD"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_0_834">
|
||||||
|
<path d="M0 24C0 10.7452 10.7452 0 24 0H1320C1333.25 0 1344 10.7452 1344 24V696C1344 709.255 1333.25 720 1320 720H24C10.7452 720 0 709.255 0 696V24Z" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -1,20 +1,24 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
href: string;
|
href: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
variant?: 'primary' | 'ghost';
|
/** `light` is for use on the dark closing panel. */
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'light';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
trailingIcon?: IconName;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Figma "Button": primary is the one main action on a screen; secondary and danger are added when a screen needs them. Heights 28, 36, 44. */
|
/** Figma "Button": primary is the one main action on a screen. Heights 28, 36, 44. */
|
||||||
export function Button({ href, children, variant = 'primary', size = 'md', className }: Props) {
|
export function Button({ href, children, variant = 'primary', size = 'md', trailingIcon, className }: Props) {
|
||||||
const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
|
const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
|
||||||
return (
|
return (
|
||||||
<Link href={href} className={classes}>
|
<Link href={href} className={classes}>
|
||||||
{children}
|
{children}
|
||||||
|
{trailingIcon && <Icon name={trailingIcon} size={20} />}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { CSSProperties } from 'react';
|
|
||||||
|
|
||||||
type Props = { name: 'menu' | 'chevron-right'; size?: number; className?: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws a single-colour Figma icon with a CSS mask so it takes the text colour
|
|
||||||
* of the current theme. The SVG file is the exact export from Figma.
|
|
||||||
*/
|
|
||||||
export function Icon({ name, size = 16, className }: Props) {
|
|
||||||
const style = {
|
|
||||||
'--icon-src': `url(/marketing/icons/${name}.svg)`,
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
} as CSSProperties;
|
|
||||||
return <span aria-hidden="true" className={['icon', className].filter(Boolean).join(' ')} style={style} />;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ 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';
|
||||||
import { NAV_LINKS, ROUTES } from './content.ts';
|
import { NAV_LINKS, ROUTES } from './content.ts';
|
||||||
import { Icon } from './Icon.tsx';
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|||||||
@@ -43,3 +43,70 @@ export const FOOTER_COLUMNS: readonly FooterColumn[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const TAGLINE = 'Your website, handled.';
|
export const TAGLINE = 'Your website, handled.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan names, prices and allowances are placeholders until decided (PRD D-HL-8, D-HL-14).
|
||||||
|
* PX-13: the marketing site shows them only once they are approved. Flip this when they are.
|
||||||
|
*/
|
||||||
|
export const PRICING_CONFIRMED = false;
|
||||||
|
|
||||||
|
export type Plan = {
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
allowance: string;
|
||||||
|
featured?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Draft numbers from the Figma design. Not shown while PRICING_CONFIRMED is false. */
|
||||||
|
export const PLANS: readonly Plan[] = [
|
||||||
|
{ name: 'Starter', price: '$19', allowance: '1 site · 50 changes a month' },
|
||||||
|
{ name: 'Studio', price: '$49', allowance: '5 sites · unlimited changes', featured: true },
|
||||||
|
{ name: 'Agency', price: '$149', allowance: '25 sites · team seats and client sharing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PLAN_FEATURES = [
|
||||||
|
'Unlimited changes with approval',
|
||||||
|
'Safe copy before every publish',
|
||||||
|
'Proof for every change',
|
||||||
|
'Undo for 30 days',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const EXAMPLES = [
|
||||||
|
{ title: 'Update the holiday hours', meta: 'Page · 2 min' },
|
||||||
|
{ title: 'Add a gluten-free menu', meta: 'Menu · 4 min' },
|
||||||
|
{ title: 'Fix the contact form', meta: 'Form · 3 min' },
|
||||||
|
{ title: 'Swap the homepage photo', meta: 'Media · 1 min' },
|
||||||
|
{ title: 'Publish a new blog post', meta: 'Post · 6 min' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FAQ. The first answer is from the design. The rest are drafted from the PRD (sections 1.5, 2.2, 11.8 and 16)
|
||||||
|
* for the product owner to review; they are not in the design yet.
|
||||||
|
*/
|
||||||
|
export const FAQ: ReadonlyArray<{ question: string; answer: string; open?: boolean }> = [
|
||||||
|
{
|
||||||
|
question: 'Will Wursor change my live site without asking?',
|
||||||
|
answer: 'No. Every change is made on a safe copy first. Nothing goes live until you review the preview and approve it.',
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Do I need my own hosting?',
|
||||||
|
answer:
|
||||||
|
'No. You can start a new site on Wursor Cloud, connect a WordPress site you already have, or build here and publish to your own WordPress. You can move between them later.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Can I undo a change?',
|
||||||
|
answer:
|
||||||
|
'Yes. Every change has a way back, and the undo button stays in view after you publish. If a change cannot be fully undone, Wursor says so before you approve it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Does it work with my theme and plugins?',
|
||||||
|
answer:
|
||||||
|
'Wursor works on a copy of your site, so anything unusual shows up in the preview and the checks, not on your live pages. It tells you what it could not check.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'How is my site kept safe?',
|
||||||
|
answer:
|
||||||
|
'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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ApprovalCard } from '../../product/ApprovalCard.tsx';
|
||||||
|
import { Fit } from '../../ui/Fit.tsx';
|
||||||
|
import { Icon } from '../../ui/Icon.tsx';
|
||||||
|
import { Button } from '../Button.tsx';
|
||||||
|
import { SectionHeader } from './SectionHeader.tsx';
|
||||||
|
|
||||||
|
const POINTS = [
|
||||||
|
'Nine checks, written in plain words',
|
||||||
|
'Looks right on a phone, a tablet and a laptop',
|
||||||
|
'One click to approve and publish, one click to undo',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ApproveWithProof() {
|
||||||
|
return (
|
||||||
|
<section className="m-approve">
|
||||||
|
<div className="m-approve-text">
|
||||||
|
<SectionHeader
|
||||||
|
align="left"
|
||||||
|
eyebrow="Approve with proof"
|
||||||
|
title="See it before it’s real."
|
||||||
|
subtitle="Drag to compare before and after. Read what Wursor checked. Then approve, or ask for changes."
|
||||||
|
mobileSubtitle="Drag to compare before and after, read what Wursor checked, then approve."
|
||||||
|
/>
|
||||||
|
<ul className="m-points">
|
||||||
|
{POINTS.map((p) => (
|
||||||
|
<li key={p} className="t-body-l">
|
||||||
|
<Icon name="check-circle" size={20} />
|
||||||
|
{p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button href="/trust" variant="secondary" size="lg" trailingIcon="arrow-right" className="m-hide-mobile">
|
||||||
|
How approval works
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="m-approve-visual" aria-hidden="true" inert>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="m-backdrop m-approve-backdrop" src="/marketing/backdrop.svg" alt="" />
|
||||||
|
<div className="m-approve-card">
|
||||||
|
<Fit designWidth={480} designHeight={441}>
|
||||||
|
<ApprovalCard />
|
||||||
|
</Fit>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Button } from '../Button.tsx';
|
||||||
|
import { ROUTES } from '../content.ts';
|
||||||
|
|
||||||
|
export function ClosingCta() {
|
||||||
|
return (
|
||||||
|
<section className="m-cta">
|
||||||
|
<div className="m-cta-panel">
|
||||||
|
<h2 className="t-display-m m-cta-title">Hand off the busywork. Keep the final say.</h2>
|
||||||
|
<p className="t-body-l m-cta-sub">Start a site or connect yours, and make your first change in minutes.</p>
|
||||||
|
<Button href={ROUTES.signUp} variant="light" size="lg">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { SiteMock } from '../../product/SiteMock.tsx';
|
||||||
|
import { Icon } from '../../ui/Icon.tsx';
|
||||||
|
import { Zoom } from '../../ui/Zoom.tsx';
|
||||||
|
import { EXAMPLES } from '../content.ts';
|
||||||
|
import { SectionHeader } from './SectionHeader.tsx';
|
||||||
|
|
||||||
|
function Thumb({ state }: { state: 'before' | 'after' }) {
|
||||||
|
return (
|
||||||
|
<div className="m-thumb" data-state={state}>
|
||||||
|
<Zoom ratio={130 / 760}>
|
||||||
|
<SiteMock state={state} />
|
||||||
|
</Zoom>
|
||||||
|
<span className="m-thumb-label t-caption">{state === 'before' ? 'Before' : 'After'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Examples() {
|
||||||
|
return (
|
||||||
|
<section className="m-section">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Examples"
|
||||||
|
title="Small jobs, done in minutes."
|
||||||
|
subtitle="Real requests from real owners. Each one ends with a preview you approve."
|
||||||
|
/>
|
||||||
|
<ul aria-label="Examples" className="m-carousel">
|
||||||
|
{EXAMPLES.map((e) => (
|
||||||
|
<li key={e.title} className="m-example">
|
||||||
|
<div className="m-thumbs" aria-hidden="true">
|
||||||
|
<Thumb state="before" />
|
||||||
|
<Thumb state="after" />
|
||||||
|
</div>
|
||||||
|
<p className="t-title-s">{e.title}</p>
|
||||||
|
<p className="m-example-meta t-caption">
|
||||||
|
<Icon name="clock" size={20} />
|
||||||
|
{e.meta}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Icon } from '../../ui/Icon.tsx';
|
||||||
|
import { FAQ } from '../content.ts';
|
||||||
|
import { SectionHeader } from './SectionHeader.tsx';
|
||||||
|
|
||||||
|
export function Faq() {
|
||||||
|
return (
|
||||||
|
<section className="m-section">
|
||||||
|
<SectionHeader eyebrow="FAQ" title="Questions, answered." />
|
||||||
|
<div className="m-faq">
|
||||||
|
{FAQ.map((item) => (
|
||||||
|
<details key={item.question} className="m-faq-item" open={item.open}>
|
||||||
|
<summary className="m-faq-question t-title-s">
|
||||||
|
<span>{item.question}</span>
|
||||||
|
<Icon name="chevron-down" size={20} className="m-faq-chevron" />
|
||||||
|
</summary>
|
||||||
|
<p className="t-body-m m-section-sub">{item.answer}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Workspace } from '../../product/Workspace.tsx';
|
||||||
|
import { Fit } from '../../ui/Fit.tsx';
|
||||||
|
import { Icon } from '../../ui/Icon.tsx';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '../Button.tsx';
|
||||||
|
import { ROUTES, TAGLINE } from '../content.ts';
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="m-hero">
|
||||||
|
<Link href="/trust" className="m-announce">
|
||||||
|
<span className="m-announce-new t-label-s">New</span>
|
||||||
|
<span className="t-label-m">Approve with proof: nine checks on every change</span>
|
||||||
|
<Icon name="chevron-right" size={20} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="t-display-xl m-hero-title">{TAGLINE}</h1>
|
||||||
|
<p className="t-body-l m-hero-sub">Tell Wursor what to change. Watch it work. Approve what you like.</p>
|
||||||
|
<div className="m-hero-actions">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<p className="t-caption m-hero-footnote">For WordPress sites · We host it, or you do</p>
|
||||||
|
<div className="m-hero-visual">
|
||||||
|
<div className="m-visual" aria-hidden="true" inert>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="m-backdrop" src="/marketing/backdrop.svg" alt="" />
|
||||||
|
<div className="m-hero-shot">
|
||||||
|
<Fit designWidth={1440} designHeight={900} initialScale={1100 / 1440} minScale={0.454}>
|
||||||
|
<Workspace />
|
||||||
|
</Fit>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Icon } from '../../ui/Icon.tsx';
|
||||||
|
import { Button } from '../Button.tsx';
|
||||||
|
import { PLAN_FEATURES, PLANS, PRICING_CONFIRMED, ROUTES } from '../content.ts';
|
||||||
|
import { SectionHeader } from './SectionHeader.tsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function PricingTeaser({ confirmed = PRICING_CONFIRMED }: { confirmed?: boolean }) {
|
||||||
|
if (!confirmed) {
|
||||||
|
return (
|
||||||
|
<section className="m-section">
|
||||||
|
<SectionHeader eyebrow="Pricing" title="Simple plans. No surprises." subtitle="Plans and prices will be published before launch." />
|
||||||
|
<div className="m-pricing-actions">
|
||||||
|
<Button href={ROUTES.signUp} size="lg">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
<Button href="/pricing" variant="ghost" size="lg" trailingIcon="arrow-right">
|
||||||
|
See pricing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="m-section">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Pricing"
|
||||||
|
title="Simple plans. No surprises."
|
||||||
|
subtitle="Hosting on Wursor Cloud is included. Change or cancel any time."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const STATS = [
|
||||||
|
{ value: '9', label: 'checks before anything goes live' },
|
||||||
|
{ value: '1 click', label: 'to undo any change, any time' },
|
||||||
|
{ value: 'Every step', label: 'on the record, in plain words' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ProofStrip() {
|
||||||
|
return (
|
||||||
|
<section aria-label="Proof" className="m-proof">
|
||||||
|
{STATS.map((s) => (
|
||||||
|
<div key={s.value} className="m-proof-stat">
|
||||||
|
<p className="t-display-l">{s.value}</p>
|
||||||
|
<p className="t-body-l m-section-sub">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Icon, type IconName } from '../../ui/Icon.tsx';
|
||||||
|
import { SectionHeader } from './SectionHeader.tsx';
|
||||||
|
|
||||||
|
const RULES: ReadonlyArray<{ icon: IconName; title: string; body: string }> = [
|
||||||
|
{
|
||||||
|
icon: 'shield',
|
||||||
|
title: 'Works on a safe copy',
|
||||||
|
body: 'Every change is made on a copy of your site first, so your live pages stay untouched until you say so.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'check-circle',
|
||||||
|
title: 'Nothing goes live without you',
|
||||||
|
body: 'Wursor shows you the result and waits. Nothing is published without your approval.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'list',
|
||||||
|
title: 'Everything is recorded',
|
||||||
|
body: 'Every step, check and decision is written down, so you can see exactly what happened and why.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'undo',
|
||||||
|
title: 'Undo in one click',
|
||||||
|
body: 'If you change your mind, one click puts your site back the way it was.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/** Shows `desktop` on wide screens and `mobile` on narrow ones. The design words a few lines more briefly on a phone. */
|
||||||
|
export function Responsive({ desktop, mobile }: { desktop: ReactNode; mobile?: ReactNode }) {
|
||||||
|
if (mobile === undefined) return <>{desktop}</>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="m-d">{desktop}</span>
|
||||||
|
<span className="m-m">{mobile}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
mobileTitle?: string;
|
||||||
|
mobileSubtitle?: string;
|
||||||
|
align?: 'center' | 'left';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Eyebrow, headline and one supporting sentence. Centred by default. */
|
||||||
|
export function SectionHeader({ eyebrow, title, subtitle, mobileTitle, mobileSubtitle, align = 'center' }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={`m-section-header m-align-${align}`}>
|
||||||
|
<p className="t-overline m-eyebrow">{eyebrow}</p>
|
||||||
|
<h2 className="t-display-l m-section-title">
|
||||||
|
<Responsive desktop={title} mobile={mobileTitle} />
|
||||||
|
</h2>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="t-body-l m-section-sub">
|
||||||
|
<Responsive desktop={subtitle} mobile={mobileSubtitle} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useId, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
|
||||||
|
import { BoardStrip } from '../../product/BoardStrip.tsx';
|
||||||
|
import { HARBOR } from '../../product/harbor-bakery.ts';
|
||||||
|
import { HarborConversation } from '../../product/HarborConversation.tsx';
|
||||||
|
import { PreviewColumn } from '../../product/PreviewColumn.tsx';
|
||||||
|
import { Fit } from '../../ui/Fit.tsx';
|
||||||
|
import { Icon, type IconName } from '../../ui/Icon.tsx';
|
||||||
|
import { Responsive } from './SectionHeader.tsx';
|
||||||
|
|
||||||
|
type Surface = 'conversation' | 'preview' | 'board';
|
||||||
|
|
||||||
|
const SURFACES: ReadonlyArray<{ value: Surface; label: string; icon: IconName; caption: string; mobileCaption?: string }> = [
|
||||||
|
{ value: 'conversation', label: 'Conversation', icon: 'sparkles', caption: 'Conversation: ask in plain words. Wursor explains each step as it goes.', mobileCaption: 'Ask in plain words. Wursor explains each step.' },
|
||||||
|
{ value: 'preview', label: 'Preview', icon: 'eye', caption: 'Preview: see the real page on a phone, tablet and laptop before anything goes live.' },
|
||||||
|
{ value: 'board', label: 'Board', icon: 'board', caption: 'Board: every change in one place, from idea to published.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each window is a share of the stage width with the design's aspect ratio, and its content is scaled to the
|
||||||
|
* window's own width, so the three views stay in proportion on any screen. Sizes are from the design (1344 × 720 stage).
|
||||||
|
*/
|
||||||
|
const WINDOWS: Record<Surface, { width: string; aspect: string; contentWidth: number; contentHeight: number; initialScale: number }> = {
|
||||||
|
conversation: { width: '41.667%', aspect: '560 / 620', contentWidth: 440, contentHeight: 852, initialScale: 560 / 440 },
|
||||||
|
preview: { width: '60.863%', aspect: '818 / 600', contentWidth: 760, contentHeight: 852, initialScale: 818 / 760 },
|
||||||
|
board: { width: '78.571%', aspect: '1056 / 341', contentWidth: 760, contentHeight: 246, initialScale: 1056 / 760 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function SurfaceWindow({ surface }: { surface: Surface }) {
|
||||||
|
const w = WINDOWS[surface];
|
||||||
|
const style = { '--w': w.width, aspectRatio: w.aspect } as CSSProperties;
|
||||||
|
return (
|
||||||
|
<div className="m-window" style={style}>
|
||||||
|
<Fit designWidth={w.contentWidth} designHeight={w.contentHeight} initialScale={w.initialScale} maxScale={3}>
|
||||||
|
{surface === 'conversation' && <HarborConversation />}
|
||||||
|
{surface === 'preview' && <PreviewColumn />}
|
||||||
|
{surface === 'board' && <BoardStrip lanes={HARBOR.board} />}
|
||||||
|
</Fit>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SurfacesShowcase() {
|
||||||
|
const [selected, setSelected] = useState<Surface>('conversation');
|
||||||
|
const id = useId();
|
||||||
|
const tabs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
const current = SURFACES.find((s) => s.value === selected)!;
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||||
|
const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||||||
|
if (!step) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const next = (index + step + SURFACES.length) % SURFACES.length;
|
||||||
|
setSelected(SURFACES[next]!.value);
|
||||||
|
tabs.current[next]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="m-showcase">
|
||||||
|
<div role="tablist" aria-label="Workspace views" className="m-feature-tabs">
|
||||||
|
{SURFACES.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
ref={(el) => {
|
||||||
|
tabs.current[i] = el;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`${id}-tab-${s.value}`}
|
||||||
|
aria-selected={s.value === selected}
|
||||||
|
aria-controls={`${id}-panel`}
|
||||||
|
tabIndex={s.value === selected ? 0 : -1}
|
||||||
|
className="m-feature-tab t-label-m"
|
||||||
|
onClick={() => setSelected(s.value)}
|
||||||
|
onKeyDown={(e) => onKeyDown(e, i)}
|
||||||
|
>
|
||||||
|
<Icon name={s.icon} size={20} />
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-tab-${selected}`} className="m-showcase-panel">
|
||||||
|
<div className="m-surface" aria-hidden="true" inert>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="m-backdrop" src="/marketing/backdrop.svg" alt="" />
|
||||||
|
<SurfaceWindow surface={selected} />
|
||||||
|
</div>
|
||||||
|
<p className="t-body-l m-section-sub m-caption">
|
||||||
|
<Responsive desktop={current.caption} mobile={current.mobileCaption} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Density } from '../../product/ConversationPanel.tsx';
|
||||||
|
import { Workspace } from '../../product/Workspace.tsx';
|
||||||
|
import { Fit } from '../../ui/Fit.tsx';
|
||||||
|
import { SectionHeader } from './SectionHeader.tsx';
|
||||||
|
|
||||||
|
const LEVELS: ReadonlyArray<{ density: Density; title: string; description: string }> = [
|
||||||
|
{ density: 'headline', title: 'Headline', description: 'Only what changed and what needs you.' },
|
||||||
|
{ density: 'steps', title: 'Steps', description: 'Each step, in a sentence. The default.' },
|
||||||
|
{ density: 'everything', title: 'Everything', description: 'Every look, edit, check and decision.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WatchTheWork() {
|
||||||
|
return (
|
||||||
|
<section className="m-section">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="The play-by-play"
|
||||||
|
title="Watch the work, your way."
|
||||||
|
subtitle="Wursor narrates every step. Choose how much you see: just the headlines, the steps, or everything it does."
|
||||||
|
mobileTitle="Watch every step."
|
||||||
|
mobileSubtitle="Choose how much you see: the headlines, the steps, or everything."
|
||||||
|
/>
|
||||||
|
<div className="m-densities">
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<div key={l.density} className="m-density">
|
||||||
|
<div className="m-density-head">
|
||||||
|
<h3 className="t-title-m">{l.title}</h3>
|
||||||
|
<p className="t-body-m m-section-sub">{l.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="m-crop" aria-hidden="true" inert>
|
||||||
|
<Fit designWidth={440} designHeight={600}>
|
||||||
|
<div className="m-crop-frame">
|
||||||
|
<div className="m-crop-shot">
|
||||||
|
<Workspace defaultDensity={l.density} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fit>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
/** Wursor's pointer on the preview, with its name. Position is in preview-stage pixels. */
|
||||||
|
export function AgentCursor({ left, top, name = 'Wursor' }: { left: number; top: number; name?: string }) {
|
||||||
|
const style: CSSProperties = { left, top };
|
||||||
|
return (
|
||||||
|
<div className="p-cursor" style={style} aria-hidden="true">
|
||||||
|
<Icon name="pointer" size={20} />
|
||||||
|
<span className="p-cursor-name">{name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
import { Button } from './Button.tsx';
|
||||||
|
|
||||||
|
type Props = { title: string; meta: string; onApprove?: () => void };
|
||||||
|
|
||||||
|
/** The decision point. Ready state only: approve, or (later) ask for changes. Publishing and Published are not designed here yet. */
|
||||||
|
export function ApprovalBar({ title, meta, onApprove }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="p-approval">
|
||||||
|
<span className="p-approval-icon">
|
||||||
|
<Icon name="check-circle" size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="p-approval-text">
|
||||||
|
<p className="p-approval-title">{title}</p>
|
||||||
|
<p className="p-approval-meta">{meta}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" size="md" trailingIcon="arrow-right" onClick={onApprove}>
|
||||||
|
Approve and publish
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApprovalBar } from './ApprovalBar.tsx';
|
||||||
|
import { CheckEvent } from './CheckEvent.tsx';
|
||||||
|
import { CompareSlider } from './CompareSlider.tsx';
|
||||||
|
import { Zoom } from '../ui/Zoom.tsx';
|
||||||
|
import { SiteMock } from './SiteMock.tsx';
|
||||||
|
|
||||||
|
/** Compare slider, the checks and the approval bar together: what the owner sees before anything goes live. */
|
||||||
|
export function ApprovalCard() {
|
||||||
|
return (
|
||||||
|
<div className="p-approvalcard">
|
||||||
|
<Zoom ratio={448 / 760}>
|
||||||
|
<div className="p-approvalcard-compare">
|
||||||
|
<CompareSlider before={<SiteMock state="before" />} after={<SiteMock state="after" />} />
|
||||||
|
</div>
|
||||||
|
</Zoom>
|
||||||
|
<CheckEvent result="passed" title="Checked 9 things. All passed." detail="Phone link works, 3 screen sizes, no new errors, speed unchanged." />
|
||||||
|
<ApprovalBar title="Ready for you" meta="2 changes · Everyday risk" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
type Props =
|
||||||
|
| { kind: 'wursor'; size?: 'sm' | 'md' }
|
||||||
|
| { kind: 'initials'; initials: string; name: string; size?: 'sm' | 'md' };
|
||||||
|
|
||||||
|
/** Person or Wursor agent identity: initials, or the brand mark on the inverse colour. */
|
||||||
|
export function Avatar(props: Props) {
|
||||||
|
const size = props.size ?? 'sm';
|
||||||
|
if (props.kind === 'wursor') {
|
||||||
|
return (
|
||||||
|
<span role="img" aria-label="Wursor" className={`p-avatar p-avatar-${size} p-avatar-wursor`}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="p-avatar-mark p-avatar-mark-light" src="/marketing/avatar-mark.svg" alt="" width={14} height={14} />
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="p-avatar-mark p-avatar-mark-dark" src="/marketing/avatar-mark-dark.svg" alt="" width={14} height={14} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span role="img" aria-label={props.name} className={`p-avatar p-avatar-${size} p-avatar-initials`}>
|
||||||
|
{props.initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { RiskChip } from './RiskChip.tsx';
|
||||||
|
import { StatusPill, type Status } from './StatusPill.tsx';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status: Status;
|
||||||
|
title: string;
|
||||||
|
meta: string;
|
||||||
|
risk?: 'everyday';
|
||||||
|
/** 0 to 1. Shown on a working change. */
|
||||||
|
progress?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One change on the board. A change that needs the owner is outlined in the accent colour and says why. */
|
||||||
|
export function BoardCard({ status, title, meta, risk, progress }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="p-boardcard" data-status={status}>
|
||||||
|
<p className="p-boardcard-title">{title}</p>
|
||||||
|
<p className="p-boardcard-meta">{meta}</p>
|
||||||
|
<div className="p-boardcard-footer">
|
||||||
|
<StatusPill status={status} />
|
||||||
|
{status === 'working' && risk && <RiskChip level={risk} />}
|
||||||
|
</div>
|
||||||
|
{status === 'working' && progress !== undefined && (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={title}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(progress * 100)}
|
||||||
|
className="p-progress"
|
||||||
|
>
|
||||||
|
<span className="p-progress-fill" style={{ width: `${progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'needs-you' && <p className="p-boardcard-ask">Wursor is waiting for your answer</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { BoardCard } from './BoardCard.tsx';
|
||||||
|
import { Counter } from './Counter.tsx';
|
||||||
|
import type { BoardLaneData } from './harbor-bakery.ts';
|
||||||
|
|
||||||
|
/** The board as a strip under the preview: four lanes, each with its count. The header count is what is not done yet. */
|
||||||
|
export function BoardStrip({ lanes }: { lanes: readonly BoardLaneData[] }) {
|
||||||
|
const open = lanes.filter((l) => l.status !== 'done').reduce((n, l) => n + l.cards.length, 0);
|
||||||
|
return (
|
||||||
|
<section aria-label="Board" className="p-board">
|
||||||
|
<div className="p-board-header">
|
||||||
|
<h3 className="p-board-title">Board</h3>
|
||||||
|
<Counter count={open} />
|
||||||
|
<span className="p-spacer" />
|
||||||
|
<span className="p-board-link">Open board</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-lanes">
|
||||||
|
{lanes.map((lane) => (
|
||||||
|
<div key={lane.status} role="group" aria-label={lane.title} className="p-lane">
|
||||||
|
<div className="p-lane-header">
|
||||||
|
<span>{lane.title}</span>
|
||||||
|
<Counter count={lane.cards.length} />
|
||||||
|
</div>
|
||||||
|
{lane.cards.map((c) => (
|
||||||
|
<BoardCard key={c.title} {...c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
leadingIcon?: IconName;
|
||||||
|
trailingIcon?: IconName;
|
||||||
|
onClick?: (() => void) | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Product button. Primary (black pill) is the one main action on a screen; secondary is the alternative; ghost is tertiary. */
|
||||||
|
export function Button({ children, variant = 'secondary', size = 'sm', leadingIcon, trailingIcon, onClick }: Props) {
|
||||||
|
const iconSize = 16;
|
||||||
|
return (
|
||||||
|
<button type="button" className={`p-btn p-btn-${variant} p-btn-${size}`} onClick={onClick}>
|
||||||
|
{leadingIcon && <Icon name={leadingIcon} size={iconSize} />}
|
||||||
|
<span>{children}</span>
|
||||||
|
{trailingIcon && <Icon name={trailingIcon} size={iconSize} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
import { Button } from './Button.tsx';
|
||||||
|
import { RiskChip } from './RiskChip.tsx';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
meta: string;
|
||||||
|
risk: 'everyday';
|
||||||
|
added: number;
|
||||||
|
removed: number;
|
||||||
|
onSeeChanges?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A change Wursor made to the draft copy. Always shows the risk, how much changed, and a route to the full comparison. */
|
||||||
|
export function ChangeEvent({ title, meta, risk, added, removed, onSeeChanges }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="p-change">
|
||||||
|
<div className="p-change-top">
|
||||||
|
<span className="p-change-tile">
|
||||||
|
<Icon name="pencil" size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="p-change-text">
|
||||||
|
<p className="p-change-title">{title}</p>
|
||||||
|
<p className="p-change-meta">{meta}</p>
|
||||||
|
</div>
|
||||||
|
<RiskChip level={risk} />
|
||||||
|
</div>
|
||||||
|
<div className="p-change-diff">
|
||||||
|
<span className="p-diff-add">+{added} lines</span>
|
||||||
|
<span className="p-diff-remove">−{removed} lines</span>
|
||||||
|
<span className="p-spacer" />
|
||||||
|
<Button leadingIcon="split" onClick={onSeeChanges}>
|
||||||
|
See what changed
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
type Props = { result: 'passed'; title: string; detail: string };
|
||||||
|
|
||||||
|
/** Wursor's own checks after a change. Passed is quiet and green. "Needs attention" is not designed yet. */
|
||||||
|
export function CheckEvent({ result, title, detail }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="p-check" data-result={result}>
|
||||||
|
<span className="p-check-icon">
|
||||||
|
<Icon name="check-circle" size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="p-check-text">
|
||||||
|
<p className="p-check-title">{title}</p>
|
||||||
|
<p className="p-check-detail">{detail}</p>
|
||||||
|
</div>
|
||||||
|
<Icon name="chevron-right" size={20} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
|
||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
type Props = { before: ReactNode; after: ReactNode; defaultPosition?: number };
|
||||||
|
|
||||||
|
const STEP = 5;
|
||||||
|
const clamp = (n: number) => Math.min(100, Math.max(0, n));
|
||||||
|
|
||||||
|
/** Drag the divider (or use the arrow keys) to compare the live site on the left with the draft on the right. */
|
||||||
|
export function CompareSlider({ before, after, defaultPosition = 50 }: Props) {
|
||||||
|
const [position, setPosition] = useState(clamp(defaultPosition));
|
||||||
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const moveTo = (clientX: number) => {
|
||||||
|
const rect = box.current?.getBoundingClientRect();
|
||||||
|
if (!rect || rect.width === 0) return;
|
||||||
|
setPosition(clamp(((clientX - rect.left) / rect.width) * 100));
|
||||||
|
};
|
||||||
|
const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
moveTo(e.clientX);
|
||||||
|
};
|
||||||
|
const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.buttons === 1) moveTo(e.clientX);
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
const next: Record<string, number | undefined> = {
|
||||||
|
ArrowLeft: position - STEP,
|
||||||
|
ArrowRight: position + STEP,
|
||||||
|
Home: 0,
|
||||||
|
End: 100,
|
||||||
|
};
|
||||||
|
const value = next[e.key];
|
||||||
|
if (value === undefined) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setPosition(clamp(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={box} className="p-compare">
|
||||||
|
<div className="p-compare-layer">{after}</div>
|
||||||
|
<div className="p-compare-layer p-compare-before" style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}>
|
||||||
|
{before}
|
||||||
|
</div>
|
||||||
|
<span className="p-compare-divider" style={{ left: `${position}%` }} aria-hidden="true" />
|
||||||
|
<span className="p-compare-label p-compare-label-before">Before</span>
|
||||||
|
<span className="p-compare-label p-compare-label-after">After</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="slider"
|
||||||
|
aria-label="Compare before and after"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(position)}
|
||||||
|
aria-valuetext={`${Math.round(position)}% before`}
|
||||||
|
className="p-compare-handle"
|
||||||
|
style={{ left: `${position}%` }}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<Icon name="split" size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Button } from './Button.tsx';
|
||||||
|
import { IconButton } from './IconButton.tsx';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Only the working state is designed so far. Empty, typing and pointing come with the app screens. */
|
||||||
|
state: 'working';
|
||||||
|
autonomy: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onStop?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Where the owner talks to Wursor. While Wursor works, stop is offered and typing still works. */
|
||||||
|
export function Composer({ autonomy, placeholder = 'Wursor is working. Add a note, or stop.', onStop }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="p-composer" data-state="working">
|
||||||
|
<textarea aria-label="Message to Wursor" placeholder={placeholder} rows={1} className="p-composer-input" />
|
||||||
|
<div className="p-composer-toolbar">
|
||||||
|
<div className="p-composer-side">
|
||||||
|
<IconButton icon="paperclip" label="Attach a file" />
|
||||||
|
<IconButton icon="pointer" label="Point at something on the page" />
|
||||||
|
<Button variant="ghost" trailingIcon="chevron-down">
|
||||||
|
{autonomy}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-composer-side">
|
||||||
|
<IconButton icon="mic" label="Dictate" />
|
||||||
|
<IconButton icon="stop" label="Stop" variant="inverse" onClick={onStop} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Composer } from './Composer.tsx';
|
||||||
|
import { SegmentedControl } from './SegmentedControl.tsx';
|
||||||
|
|
||||||
|
export type Density = 'headline' | 'steps' | 'everything';
|
||||||
|
|
||||||
|
const DENSITIES = [
|
||||||
|
{ value: 'headline', label: 'Headline' },
|
||||||
|
{ value: 'steps', label: 'Steps' },
|
||||||
|
{ value: 'everything', label: 'Everything' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
density: Density;
|
||||||
|
onDensityChange: (density: Density) => void;
|
||||||
|
composer?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The conversation: header with the detail level, the stream, and the composer. */
|
||||||
|
export function ConversationPanel({ density, onDensityChange, composer, children }: Props) {
|
||||||
|
return (
|
||||||
|
<section aria-label="Conversation panel" className="p-convo">
|
||||||
|
<div className="p-convo-header">
|
||||||
|
<h2 className="p-convo-title">Conversation</h2>
|
||||||
|
<span className="p-spacer" />
|
||||||
|
<SegmentedControl label="Detail" options={DENSITIES} value={density} onChange={onDensityChange} />
|
||||||
|
</div>
|
||||||
|
<div className="p-stream" data-density={density}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="p-composer-area">{composer ?? <Composer state="working" autonomy="Ask first" />}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Small count badge on the accent colour. */
|
||||||
|
export function Counter({ count }: { count: number }) {
|
||||||
|
return <span className="p-counter">{count}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
/** A browser window around a page. Drawn at 760 px wide and shown at 712 px, the width of the preview stage. */
|
||||||
|
export function DeviceFrame({ url, children }: { url: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="p-device">
|
||||||
|
<div className="p-device-scale">
|
||||||
|
<div className="p-device-bar">
|
||||||
|
<Icon name="dots" size={40} className="p-device-dots" />
|
||||||
|
<div className="p-device-address">
|
||||||
|
<Icon name="lock" size={20} />
|
||||||
|
<span>{url}</span>
|
||||||
|
</div>
|
||||||
|
<Icon name="refresh" size={20} />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ConversationPanel, type Density } from './ConversationPanel.tsx';
|
||||||
|
import { HarborStream } from './HarborStream.tsx';
|
||||||
|
|
||||||
|
/** The Harbor Bakery conversation on its own, with its own detail level. */
|
||||||
|
export function HarborConversation({ defaultDensity = 'steps' }: { defaultDensity?: Density }) {
|
||||||
|
const [density, setDensity] = useState<Density>(defaultDensity);
|
||||||
|
return (
|
||||||
|
<ConversationPanel density={density} onDensityChange={setDensity}>
|
||||||
|
<HarborStream density={density} />
|
||||||
|
</ConversationPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ChangeEvent } from './ChangeEvent.tsx';
|
||||||
|
import type { Density } from './ConversationPanel.tsx';
|
||||||
|
import { Message } from './Message.tsx';
|
||||||
|
import { Milestone } from './Milestone.tsx';
|
||||||
|
import { StepGroup } from './StepGroup.tsx';
|
||||||
|
import { StepLine } from './StepLine.tsx';
|
||||||
|
import { Thought } from './Thought.tsx';
|
||||||
|
|
||||||
|
const REASONING =
|
||||||
|
'The number is plain text on the Contact page and again in the footer. On a phone it needs to be a tel: link, and the tap target should be at least 44 px tall. I’ll check the header too, because it repeats the number.';
|
||||||
|
|
||||||
|
const CHANGE = { title: 'Changed the Contact page', meta: '1 section · 2 places', risk: 'everyday', added: 8, removed: 2 } as const;
|
||||||
|
|
||||||
|
/** The example conversation for a Harbor Bakery change, at each detail level. */
|
||||||
|
export function HarborStream({ density }: { density: Density }) {
|
||||||
|
if (density === 'headline') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Message role="user">Make the phone number tappable on mobile, and the same in the footer.</Message>
|
||||||
|
<Message role="wursor">
|
||||||
|
Two places show your phone number as plain text: the Contact page and the footer. Here is my plan. Nothing goes live until you approve.
|
||||||
|
</Message>
|
||||||
|
<Milestone>Plan ready</Milestone>
|
||||||
|
<Message role="wursor">I’ve changed the Contact page and the footer, and I’m checking them on a phone now.</Message>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (density === 'steps') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Message role="user">Make the phone number tappable on mobile, and the same in the footer.</Message>
|
||||||
|
<Message role="wursor">On it. I’ll show you each step as I go.</Message>
|
||||||
|
<StepGroup title="Looked at 4 pages" count={4}>
|
||||||
|
<StepLine kind="look" time="1s">Looked at the home page</StepLine>
|
||||||
|
</StepGroup>
|
||||||
|
<Thought seconds={6}>{REASONING}</Thought>
|
||||||
|
<StepLine kind="edit" time="4s">Made the Contact page number a tap-to-call link</StepLine>
|
||||||
|
<ChangeEvent {...CHANGE} />
|
||||||
|
<StepLine kind="edit" time="3s">Did the same in the footer</StepLine>
|
||||||
|
<StepLine kind="running" time="now">Taking a photo of the page on a phone</StepLine>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Message role="wursor">On it. I’ll show you each step as I go.</Message>
|
||||||
|
<Thought seconds={6} defaultExpanded>{REASONING}</Thought>
|
||||||
|
<StepLine kind="look" time="1s">Looked at home page</StepLine>
|
||||||
|
<StepLine kind="look" time="1s">Looked at contact page</StepLine>
|
||||||
|
<StepLine kind="look" time="1s">Looked at footer</StepLine>
|
||||||
|
<StepLine kind="search" time="2s">Searched your pages for the phone number</StepLine>
|
||||||
|
<StepLine kind="edit" time="4s">Made the Contact page number a tap-to-call link</StepLine>
|
||||||
|
<ChangeEvent {...CHANGE} />
|
||||||
|
<StepLine kind="edit" time="3s">Did the same in the footer</StepLine>
|
||||||
|
<StepLine kind="running" time="now">Taking a photo of the page on a phone</StepLine>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon: IconName;
|
||||||
|
label: string;
|
||||||
|
variant?: 'ghost' | 'inverse';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
onClick?: (() => void) | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Round button with an icon, for toolbars and the composer. `label` is its accessible name. */
|
||||||
|
export function IconButton({ icon, label, variant = 'ghost', size = 'sm', onClick }: Props) {
|
||||||
|
return (
|
||||||
|
<button type="button" aria-label={label} className={`p-iconbtn p-iconbtn-${variant} p-iconbtn-${size}`} onClick={onClick}>
|
||||||
|
<Icon name={icon} size={size === 'sm' ? 16 : 20} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Avatar } from './Avatar.tsx';
|
||||||
|
|
||||||
|
/** A message in the conversation. The owner's sit in a soft box; Wursor's are plain text beside its avatar. */
|
||||||
|
export function Message({ role, children }: { role: 'user' | 'wursor'; children: ReactNode }) {
|
||||||
|
if (role === 'user') {
|
||||||
|
return (
|
||||||
|
<div className="p-message p-message-user">
|
||||||
|
<p>{children}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="p-message p-message-wursor">
|
||||||
|
<Avatar kind="wursor" />
|
||||||
|
<p>{children}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
/** A divider in the stream that marks a stage in the life of a change. Always visible, even at the Headline level. */
|
||||||
|
export function Milestone({ children }: { children: string | ReactNode }) {
|
||||||
|
const label = typeof children === 'string' ? children : undefined;
|
||||||
|
return (
|
||||||
|
<div role="separator" aria-label={label} className="p-milestone">
|
||||||
|
<span className="p-milestone-line" />
|
||||||
|
<span className="p-milestone-chip">
|
||||||
|
<Icon name="list" size={20} />
|
||||||
|
<span>{children}</span>
|
||||||
|
</span>
|
||||||
|
<span className="p-milestone-line" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
type Props = { left: number; top: number; width: number; height: number; label: string };
|
||||||
|
|
||||||
|
/** A dashed accent box over the spot Wursor is editing. Position is in preview-stage pixels. */
|
||||||
|
export function PointHighlight({ left, top, width, height, label }: Props) {
|
||||||
|
const style: CSSProperties = { left, top, width, height };
|
||||||
|
return (
|
||||||
|
<div className="p-highlight" style={style} aria-hidden="true">
|
||||||
|
<span className="p-highlight-fill" />
|
||||||
|
<span className="p-highlight-tag">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { AgentCursor } from './AgentCursor.tsx';
|
||||||
|
import { BoardStrip } from './BoardStrip.tsx';
|
||||||
|
import { DeviceFrame } from './DeviceFrame.tsx';
|
||||||
|
import { HARBOR } from './harbor-bakery.ts';
|
||||||
|
import { PointHighlight } from './PointHighlight.tsx';
|
||||||
|
import { PreviewToolbar, type PreviewView } from './PreviewToolbar.tsx';
|
||||||
|
import { SiteMock } from './SiteMock.tsx';
|
||||||
|
|
||||||
|
/** The right-hand column of the workspace: view tabs, the live preview of the draft, and the board strip. */
|
||||||
|
export function PreviewColumn({ defaultView = 'preview' }: { defaultView?: PreviewView }) {
|
||||||
|
const [view, setView] = useState<PreviewView>(defaultView);
|
||||||
|
return (
|
||||||
|
<div className="p-previewcol">
|
||||||
|
<PreviewToolbar selected={view} tag="Draft copy" onSelect={setView} />
|
||||||
|
<div className="p-stage">
|
||||||
|
<DeviceFrame url={HARBOR.site.url}>
|
||||||
|
<SiteMock state="after" />
|
||||||
|
</DeviceFrame>
|
||||||
|
<PointHighlight left={409.96} top={312.17} width={152} height={33} label="Editing" />
|
||||||
|
<AgentCursor left={503.76} top={351.17} />
|
||||||
|
</div>
|
||||||
|
<BoardStrip lanes={HARBOR.board} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { IconButton } from './IconButton.tsx';
|
||||||
|
import { Tab } from './Tab.tsx';
|
||||||
|
import { Tag } from './Tag.tsx';
|
||||||
|
|
||||||
|
export type PreviewView = 'preview' | 'compare' | 'proof';
|
||||||
|
const TABS: ReadonlyArray<{ value: PreviewView; label: string }> = [
|
||||||
|
{ value: 'preview', label: 'Preview' },
|
||||||
|
{ value: 'compare', label: 'Compare' },
|
||||||
|
{ value: 'proof', label: 'Proof' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = { selected: PreviewView; tag?: string; onSelect?: (view: PreviewView) => void };
|
||||||
|
|
||||||
|
export function PreviewToolbar({ selected, tag, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="p-previewbar">
|
||||||
|
<div role="tablist" aria-label="View" className="p-tabs">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Tab key={t.value} label={t.label} selected={t.value === selected} onSelect={() => onSelect?.(t.value)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="p-spacer" />
|
||||||
|
{tag && <Tag label={tag} />}
|
||||||
|
<IconButton icon="monitor" label="Desktop" />
|
||||||
|
<IconButton icon="smartphone" label="Phone" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
const LABEL = { everyday: 'Everyday' } as const;
|
||||||
|
|
||||||
|
/** Plain-word risk level. Only "Everyday" is designed so far; add the others when their designs exist. */
|
||||||
|
export function RiskChip({ level }: { level: keyof typeof LABEL }) {
|
||||||
|
return (
|
||||||
|
<span className="p-risk">
|
||||||
|
<Icon name="shield-check" size={14} />
|
||||||
|
<span>{LABEL[level]}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
type Option<V extends string> = { value: V; label: string };
|
||||||
|
type Props<V extends string> = {
|
||||||
|
label: string;
|
||||||
|
options: readonly Option<V>[];
|
||||||
|
value: V;
|
||||||
|
onChange: (value: V) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Three-way (or n-way) choice, for example the play-by-play detail: Headline, Steps, Everything. */
|
||||||
|
export function SegmentedControl<V extends string>({ label, options, value, onChange }: Props<V>) {
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" aria-label={label} className="p-segmented">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={o.value === value}
|
||||||
|
className="p-segment"
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||||
|
import { Avatar } from './Avatar.tsx';
|
||||||
|
import { IconButton } from './IconButton.tsx';
|
||||||
|
import type { Status } from './StatusPill.tsx';
|
||||||
|
|
||||||
|
type Change = { title: string; status: Status; active?: boolean };
|
||||||
|
type Props = {
|
||||||
|
changes: readonly Change[];
|
||||||
|
account: { initials: string; name: string; role: string };
|
||||||
|
/** Where each place goes. Defaults to in-page anchors so a mockup has no real links. */
|
||||||
|
hrefs?: Partial<Record<'Home' | 'Board' | 'Activity' | 'Sites', string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLACES: ReadonlyArray<{ label: 'Home' | 'Board' | 'Activity' | 'Sites'; icon: IconName }> = [
|
||||||
|
{ label: 'Home', icon: 'home' },
|
||||||
|
{ label: 'Board', icon: 'board' },
|
||||||
|
{ label: 'Activity', icon: 'activity' },
|
||||||
|
{ label: 'Sites', icon: 'globe' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar({ changes, account, hrefs = {} }: Props) {
|
||||||
|
return (
|
||||||
|
<aside className="p-sidebar">
|
||||||
|
<nav aria-label="Workspace" className="p-sidebar-nav">
|
||||||
|
{PLACES.map((p) => (
|
||||||
|
<a key={p.label} href={hrefs[p.label] ?? '#'} className="p-navitem">
|
||||||
|
<Icon name={p.icon} size={18} />
|
||||||
|
<span>{p.label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="p-sidebar-changes-header">
|
||||||
|
<span className="t-overline">Changes</span>
|
||||||
|
<IconButton icon="plus" label="New change" />
|
||||||
|
</div>
|
||||||
|
<ul aria-label="Changes" className="p-changelist">
|
||||||
|
{changes.map((c) => (
|
||||||
|
<li key={c.title} className="p-changeitem" aria-current={c.active ? 'true' : undefined} data-status={c.status}>
|
||||||
|
<span className="p-statusdot" aria-hidden="true" />
|
||||||
|
<span className="p-changeitem-title">{c.title}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<span className="p-spacer" />
|
||||||
|
<div className="p-account">
|
||||||
|
<Avatar kind="initials" initials={account.initials} name={account.name} size="md" />
|
||||||
|
<div className="p-account-text">
|
||||||
|
<p>{account.name}</p>
|
||||||
|
<p>{account.role}</p>
|
||||||
|
</div>
|
||||||
|
<IconButton icon="sliders" label="Account settings" />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
import { HARBOR } from './harbor-bakery.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harbor Bakery contact page, drawn at its design width of 760 px. Before the change the phone number is
|
||||||
|
* plain text; after it, a tap-to-call link with a 44 px touch target.
|
||||||
|
*/
|
||||||
|
export function SiteMock({ state }: { state: 'before' | 'after' }) {
|
||||||
|
const { phone } = HARBOR;
|
||||||
|
return (
|
||||||
|
<div className="p-site" data-state={state}>
|
||||||
|
<div className="p-site-nav">
|
||||||
|
<p className="p-site-brand">Harbor Bakery</p>
|
||||||
|
<div className="p-site-links">
|
||||||
|
<span>Menu</span>
|
||||||
|
<span>Order</span>
|
||||||
|
<span>About</span>
|
||||||
|
<span className="p-site-current">Contact</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-site-rule" />
|
||||||
|
<div className="p-site-hero">
|
||||||
|
<p className="p-site-title">Find us on Quay Street</p>
|
||||||
|
<p className="p-site-sub">Fresh bread from 6:30, every morning.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-site-body">
|
||||||
|
<div className="p-site-col">
|
||||||
|
<p className="p-site-label">Opening hours</p>
|
||||||
|
<p className="p-site-line">{'Mon to Fri 6:30 to 17:00'}</p>
|
||||||
|
<p className="p-site-line">{'Saturday 7:00 to 15:00'}</p>
|
||||||
|
<p className="p-site-line">{'Sunday Closed'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-site-col">
|
||||||
|
<p className="p-site-label">Call or visit</p>
|
||||||
|
<p className="p-site-line">12 Quay Street</p>
|
||||||
|
{state === 'after' ? (
|
||||||
|
<a href={phone.href} className="p-site-phone">
|
||||||
|
<Icon name="smartphone" size={20} />
|
||||||
|
<span>{phone.callLabel}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="p-site-phone-plain">{phone.display}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-site-footer">
|
||||||
|
<span>12 Quay Street</span>
|
||||||
|
<span className={state === 'after' ? 'p-site-footer-link' : undefined}>{phone.display}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type Status = 'idea' | 'working' | 'needs-you' | 'done';
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<Status, string> = {
|
||||||
|
idea: 'Idea',
|
||||||
|
working: 'Working',
|
||||||
|
'needs-you': 'Needs you',
|
||||||
|
done: 'Done',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Where a change is, in plain words. The colour is never the only signal; the label is always shown. */
|
||||||
|
export function StatusPill({ status, dot = true }: { status: Status; dot?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className="p-status" data-status={status}>
|
||||||
|
{dot && <span className="p-dot" aria-hidden="true" />}
|
||||||
|
<span>{STATUS_LABEL[status]}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
import { Counter } from './Counter.tsx';
|
||||||
|
|
||||||
|
type Props = { title: string; count: number; defaultExpanded?: boolean; children?: ReactNode };
|
||||||
|
|
||||||
|
/** Groups a run of similar steps under one line at the Steps detail level. Expand to see each step. */
|
||||||
|
export function StepGroup({ title, count, defaultExpanded = false, children }: Props) {
|
||||||
|
const [open, setOpen] = useState(defaultExpanded);
|
||||||
|
return (
|
||||||
|
<div className="p-stepgroup">
|
||||||
|
<button type="button" className="p-stepgroup-header" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||||||
|
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={20} />
|
||||||
|
<span className="p-stepgroup-title">{title}</span>
|
||||||
|
<Counter count={count} />
|
||||||
|
</button>
|
||||||
|
{open && <div className="p-stepgroup-body">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Icon, type IconName } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
type Kind = 'look' | 'search' | 'edit' | 'running';
|
||||||
|
const ICON: Record<Kind, IconName> = { look: 'eye', search: 'search', edit: 'pencil', running: 'loader' };
|
||||||
|
|
||||||
|
/** One thing Wursor did. A running step swaps in a spinner and a stronger label. */
|
||||||
|
export function StepLine({ kind, time, children }: { kind: Kind; time: string; children: ReactNode }) {
|
||||||
|
const running = kind === 'running';
|
||||||
|
return (
|
||||||
|
<div className={`p-stepline${running ? ' p-stepline-running' : ''}`}>
|
||||||
|
{running ? (
|
||||||
|
<span role="status" aria-label="In progress" className="p-stepline-icon">
|
||||||
|
<Icon name={ICON[kind]} size={20} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="p-stepline-icon">
|
||||||
|
<Icon name={ICON[kind]} size={20} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="p-stepline-text">{children}</span>
|
||||||
|
<span className="p-stepline-time">{time}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Counter } from './Counter.tsx';
|
||||||
|
|
||||||
|
type Props = { label: string; selected?: boolean; count?: number; onSelect?: () => void };
|
||||||
|
|
||||||
|
/** A tab with an underline indicator for the selected one. Put tabs inside an element with role="tablist". */
|
||||||
|
export function Tab({ label, selected = false, count, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<button type="button" role="tab" aria-selected={selected} className="p-tab" onClick={onSelect}>
|
||||||
|
<span className="p-tab-row">
|
||||||
|
<span>{label}</span>
|
||||||
|
{count !== undefined && <Counter count={count} />}
|
||||||
|
</span>
|
||||||
|
<span className="p-tab-indicator" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
type Props = { label: string; closable?: boolean; onClose?: () => void };
|
||||||
|
|
||||||
|
/** Small outlined label, optionally removable. */
|
||||||
|
export function Tag({ label, closable = false, onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<span className="p-tag">
|
||||||
|
<span>{label}</span>
|
||||||
|
{closable && (
|
||||||
|
<button type="button" aria-label={`Remove ${label}`} className="p-tag-close" onClick={onClose}>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Icon } from '../ui/Icon.tsx';
|
||||||
|
|
||||||
|
type Props = { seconds: number; defaultExpanded?: boolean; children?: ReactNode };
|
||||||
|
|
||||||
|
/** Collapsed by default. Shows that Wursor reasoned; expand to read the reasoning in plain words. */
|
||||||
|
export function Thought({ seconds, defaultExpanded = false, children }: Props) {
|
||||||
|
const [open, setOpen] = useState(defaultExpanded);
|
||||||
|
return (
|
||||||
|
<div className="p-thought">
|
||||||
|
<button type="button" className="p-thought-header" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||||||
|
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={20} />
|
||||||
|
<span>Thought for {seconds} seconds</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="p-thought-body">
|
||||||
|
<span className="p-thought-rule" aria-hidden="true" />
|
||||||
|
<p>{children}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||