feat(web): marketing Button, Nav (with mobile menu) and Footer from Figma, test-first

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014SdXCbrN5PtGExkpvMaMHx
This commit is contained in:
Claude
2026-10-12 15:00:38 +00:00
parent e314819629
commit 913d7a8832
16 changed files with 471 additions and 1 deletions
+20
View File
@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Button } from '../../src/marketing/Button.tsx';
describe('Button', () => {
it('renders a link with its label and destination', () => {
render(<Button href="/signup">Get started</Button>);
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
});
it('is the primary (inverse) pill by default', () => {
render(<Button href="/signup">Get started</Button>);
expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn', 'btn-primary', 'btn-md');
});
it('supports the ghost variant and the large size', () => {
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');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Footer } from '../../src/marketing/Footer.tsx';
describe('Footer', () => {
it('shows the brand and tagline', () => {
render(<Footer />);
expect(screen.getByText('Your website, handled.')).toBeInTheDocument();
});
it('has the four link columns from the design', () => {
render(<Footer />);
for (const heading of ['Product', 'Company', 'Resources', 'Legal']) {
expect(screen.getByRole('heading', { name: heading })).toBeInTheDocument();
}
});
it('links only to pages that exist', () => {
render(<Footer />);
const nav = screen.getByRole('navigation', { name: 'Footer' });
const links = within(nav).getAllByRole('link').map((a) => [a.textContent, a.getAttribute('href')]);
expect(links).toEqual([
['Overview', '/product'],
['How it works', '/how-it-works'],
['Trust and safety', '/trust'],
['Pricing', '/pricing'],
['Docs', '/docs'],
]);
});
it('shows pages not built yet as plain text, not dead links (D-HL-17)', () => {
render(<Footer />);
for (const label of ['Changelog', 'About', 'Blog', 'Contact', 'Terms', 'Privacy']) {
expect(screen.getByText(label).closest('a')).toBeNull();
}
});
it('shows the copyright line', () => {
render(<Footer />);
expect(screen.getByText('© 2026 Wursor')).toBeInTheDocument();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { Nav } from '../../src/marketing/Nav.tsx';
describe('Nav', () => {
it('links the brand to the home page', () => {
render(<Nav />);
expect(screen.getByRole('link', { name: 'Wursor' })).toHaveAttribute('href', '/');
});
it('lists the five page links', () => {
render(<Nav />);
const primary = screen.getByRole('navigation', { name: 'Primary' });
const hrefs = within(primary).getAllByRole('link').map((a) => [a.textContent, a.getAttribute('href')]);
expect(hrefs).toEqual([
['Product', '/product'],
['How it works', '/how-it-works'],
['Trust', '/trust'],
['Pricing', '/pricing'],
['Docs', '/docs'],
]);
});
it('sends Sign in to sign-in and Get started to sign-up (PX-13)', () => {
render(<Nav />);
expect(screen.getAllByRole('link', { name: 'Sign in' })[0]).toHaveAttribute('href', '/signin');
expect(screen.getAllByRole('link', { name: 'Get started' })[0]).toHaveAttribute('href', '/signup');
});
it('keeps the mobile menu closed until the menu button is pressed', async () => {
render(<Nav />);
const button = screen.getByRole('button', { name: 'Open menu' });
expect(button).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument();
await userEvent.click(button);
expect(screen.getByRole('button', { name: 'Close menu' })).toHaveAttribute('aria-expanded', 'true');
const menu = screen.getByRole('navigation', { name: 'Mobile' });
expect(within(menu).getAllByRole('link').map((a) => a.textContent)).toEqual([
'Product', 'How it works', 'Trust and safety', 'Pricing', 'Docs', 'Get started',
]);
});
it('closes the mobile menu with Escape', async () => {
render(<Nav />);
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument();
});
it('closes the mobile menu after a link is chosen', async () => {
render(<Nav />);
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const menu = screen.getByRole('navigation', { name: 'Mobile' });
await userEvent.click(within(menu).getByRole('link', { name: 'Pricing' }));
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument();
});
});
+10 -1
View File
@@ -1,3 +1,12 @@
import { Footer } from '../../src/marketing/Footer.tsx';
import { Nav } from '../../src/marketing/Nav.tsx';
export default function HomePage() {
return <main>Wursor</main>;
return (
<>
<Nav />
<main />
<Footer />
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
Static assets exported from the Figma file "Wursor - weown.net" (marketing site page).
Do not redraw or edit them. Re-export from Figma if the design changes.
| File | Figma node |
|---|---|
| brand-mark-light.svg | Brand/Mark (28:30), light |
| brand-mark-dark.svg | Brand/Mark in Nav Bar, dark (136:72493) |
| icons/menu.svg | Icon in Menu · Mobile (143:77164) |
| 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.
+6
View File
@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Brand/Mark">
<path id="Vector" d="M4.5 15.5C4.5 11.358 7.858 8 12 8C16.142 8 19.5 11.358 19.5 15.5" stroke="#F2F1EC" stroke-width="3.25" stroke-linecap="round" stroke-linejoin="round"/>
<circle id="ember" cx="12" cy="15.5" r="2" fill="#FF7A33"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Brand/Mark">
<path id="Vector" d="M4.5 15.5C4.5 11.358 7.858 8 12 8C16.142 8 19.5 11.358 19.5 15.5" stroke="#14120B" stroke-width="3.25" stroke-linecap="round" stroke-linejoin="round"/>
<circle id="ember" cx="12" cy="15.5" r="2" fill="#E8590C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 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-right">
<path id="Vector" d="M7.5 15L12.5 10L7.5 5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 335 B

+5
View File
@@ -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 4H14M5.33333 8H14M5.33333 12H14M2 4H2.00667M2 8H2.00667M2 12H2.00667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 377 B

+11
View File
@@ -0,0 +1,11 @@
/** Wursor mark: an arch with a dot at its centre. The arch follows the theme; the dot is accent. */
export function BrandMark({ size = 24 }: { size?: number }) {
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="brand-mark brand-mark-light" src="/marketing/brand-mark-light.svg" alt="" width={size} height={size} />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="brand-mark brand-mark-dark" src="/marketing/brand-mark-dark.svg" alt="" width={size} height={size} />
</>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
type Props = {
href: string;
children: ReactNode;
variant?: 'primary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
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. */
export function Button({ href, children, variant = 'primary', size = 'md', className }: Props) {
const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
return (
<Link href={href} className={classes}>
{children}
</Link>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Link from 'next/link';
import { FOOTER_COLUMNS, TAGLINE } from './content.ts';
export function Footer() {
return (
<footer className="footer">
<div className="footer-top">
<div className="footer-brand">
<p className="t-title-m">Wursor</p>
<p className="t-body-m footer-muted">{TAGLINE}</p>
</div>
<nav aria-label="Footer" className="footer-columns">
{FOOTER_COLUMNS.map((col) => (
<div key={col.heading} className="footer-column">
<h2 className="t-label-m">{col.heading}</h2>
<ul>
{col.items.map((item) => (
<li key={item.label} className="t-body-m footer-muted">
{item.href ? <Link href={item.href}>{item.label}</Link> : item.label}
</li>
))}
</ul>
</div>
))}
</nav>
</div>
<div className="footer-bottom t-label-s">
<span className="footer-status">
<span className="dot" aria-hidden="true" /> All systems normal
</span>
<span className="footer-spacer" />
<span className="footer-muted">English</span>
<span className="footer-faint">© 2026 Wursor</span>
</div>
</footer>
);
}
+16
View File
@@ -0,0 +1,16 @@
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} />;
}
+72
View File
@@ -0,0 +1,72 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { BrandMark } from './BrandMark.tsx';
import { Button } from './Button.tsx';
import { NAV_LINKS, ROUTES } from './content.ts';
import { Icon } from './Icon.tsx';
export function Nav() {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
return (
<header className="nav">
<div className="nav-bar">
<Link href="/" className="nav-brand" onClick={() => setOpen(false)}>
<BrandMark />
<span className="t-title-s">Wursor</span>
</Link>
<nav aria-label="Primary" className="nav-links t-body-m">
{NAV_LINKS.map((l) => (
<Link key={l.href} href={l.href}>
{l.label}
</Link>
))}
</nav>
<div className="nav-actions">
<Link href={ROUTES.signIn} className="nav-signin t-label-m">
Sign in
</Link>
<Button href={ROUTES.signUp} size="md" className="nav-cta">
Get started
</Button>
<button
type="button"
className="nav-menu-button"
aria-label={open ? 'Close menu' : 'Open menu'}
aria-expanded={open}
aria-controls="mobile-menu"
onClick={() => setOpen((v) => !v)}
>
<Icon name="menu" />
</button>
</div>
</div>
{open && (
<nav id="mobile-menu" aria-label="Mobile" className="nav-menu">
{NAV_LINKS.map((l) => (
<Link key={l.href} href={l.href} className="t-title-m" onClick={() => setOpen(false)}>
{l.mobileLabel ?? l.label}
</Link>
))}
<Button href={ROUTES.signUp} size="lg" className="nav-menu-cta" >
Get started
</Button>
</nav>
)}
</header>
);
}
+45
View File
@@ -0,0 +1,45 @@
export type NavLink = { label: string; mobileLabel?: string; href: string };
export const NAV_LINKS: readonly NavLink[] = [
{ label: 'Product', href: '/product' },
{ label: 'How it works', href: '/how-it-works' },
{ label: 'Trust', mobileLabel: 'Trust and safety', href: '/trust' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Docs', href: '/docs' },
];
export const ROUTES = {
signUp: '/signup',
signIn: '/signin',
} as const;
/** A footer item with no `href` is a page that is not built yet (PRD D-HL-17). */
export type FooterItem = { label: string; href?: string };
export type FooterColumn = { heading: string; items: readonly FooterItem[] };
export const FOOTER_COLUMNS: readonly FooterColumn[] = [
{
heading: 'Product',
items: [
{ label: 'Overview', href: '/product' },
{ label: 'How it works', href: '/how-it-works' },
{ label: 'Trust and safety', href: '/trust' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Changelog' },
],
},
{
heading: 'Company',
items: [{ label: 'About' }, { label: 'Customers' }, { label: 'Careers' }, { label: 'Contact' }],
},
{
heading: 'Resources',
items: [{ label: 'Docs', href: '/docs' }, { label: 'Blog' }, { label: 'Examples' }, { label: 'Status' }],
},
{
heading: 'Legal',
items: [{ label: 'Terms' }, { label: 'Privacy' }, { label: 'Security' }, { label: 'Cookies' }],
},
];
export const TAGLINE = 'Your website, handled.';
+105
View File
@@ -10,3 +10,108 @@ body {
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--color-accent-default); outline-offset: 2px; }
/* ---------- Icons and brand mark ---------- */
.icon {
display: inline-block;
flex: none;
background-color: currentColor;
-webkit-mask: var(--icon-src) center / contain no-repeat;
mask: var(--icon-src) center / contain no-repeat;
}
.brand-mark { display: block; flex: none; }
.brand-mark-dark { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .brand-mark-light { display: none; }
:root:not([data-theme="light"]) .brand-mark-dark { display: block; }
}
:root[data-theme="dark"] .brand-mark-light { display: none; }
:root[data-theme="dark"] .brand-mark-dark { display: block; }
/* ---------- Button ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border-radius: var(--radius-full);
white-space: nowrap;
cursor: pointer;
font: 500 13px/16px var(--font-sans);
}
.btn-sm { height: var(--size-control-sm); padding: 0 var(--space-3); }
.btn-md { height: var(--size-control-md); padding: 0 var(--space-4); }
.btn-lg { height: var(--size-control-lg); padding: 0 var(--space-5); font: 500 14px/20px var(--font-sans); }
.btn-primary { background: var(--color-bg-inverse); color: var(--color-text-inverse); }
.btn-ghost { background: transparent; color: var(--color-text-primary); }
/* ---------- Nav ---------- */
.nav { position: relative; background: var(--color-bg-canvas); }
.nav-bar {
display: flex;
align-items: center;
height: 72px;
padding: 0 var(--space-10);
border-bottom: 1px solid var(--color-border-subtle);
}
.nav-brand { display: flex; align-items: center; gap: var(--space-2); flex: 1 0 0; }
.nav-links { display: flex; gap: var(--space-8); color: var(--color-text-secondary); }
.nav-actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-4); flex: 1 0 0; }
.nav-menu-button {
display: none;
align-items: center;
justify-content: center;
width: var(--size-control-md);
height: var(--size-control-md);
border: 0;
border-radius: var(--radius-full);
background: transparent;
color: var(--icon-color);
cursor: pointer;
}
.nav-menu { display: none; }
@media (max-width: 900px) {
.nav-bar { padding: 0 var(--space-5); }
.nav-links, .nav-signin { display: none; }
.nav-actions { flex: none; gap: 0; }
.nav-cta { height: var(--size-control-sm); padding: 0 var(--space-3); }
.nav-menu-button { display: inline-flex; }
.nav-menu {
position: fixed;
inset: 72px 0 0 0;
z-index: 10;
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-4) var(--space-6) var(--space-6);
background: var(--color-bg-canvas);
overflow-y: auto;
}
.nav-menu > a:not(.btn) { padding: 14px 0; }
.nav-menu .nav-menu-cta { margin-top: auto; width: 100%; }
}
/* ---------- Footer ---------- */
.footer { display: flex; flex-direction: column; gap: 56px; padding: 0 120px 48px; }
.footer-top { display: flex; align-items: flex-start; gap: 48px; }
.footer-brand { display: flex; flex-direction: column; gap: var(--space-2); width: 280px; flex: none; }
.footer-columns { display: flex; flex: 1 0 0; gap: 48px; }
.footer-column { display: flex; flex-direction: column; gap: var(--space-3); flex: 1 0 0; min-width: 0; }
.footer-column h2 { margin: 0; }
.footer-column ul { display: flex; flex-direction: column; gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
.footer-muted { color: var(--color-text-secondary); }
.footer-faint { color: var(--color-text-tertiary); }
.footer-bottom { display: flex; align-items: center; gap: var(--space-6); }
.footer-status { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--color-text-secondary); }
.footer-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-status-success-dot); }
.footer-spacer { flex: 1 0 0; }
.footer p { margin: 0; }
@media (max-width: 900px) {
.footer { padding: 0 var(--space-5) var(--space-8); gap: var(--space-8); }
.footer-top { flex-direction: column; gap: var(--space-8); }
.footer-columns { flex-wrap: wrap; gap: var(--space-8) var(--space-6); width: 100%; }
.footer-column { flex: 1 1 40%; }
.footer-bottom { flex-wrap: wrap; gap: var(--space-3) var(--space-6); }
}