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:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.';
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user