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
+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); }
}