diff --git a/web/__tests__/marketing/Button.test.tsx b/web/__tests__/marketing/Button.test.tsx new file mode 100644 index 0000000..6ce9fed --- /dev/null +++ b/web/__tests__/marketing/Button.test.tsx @@ -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(Get started); + expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup'); + }); + + it('is the primary (inverse) pill by default', () => { + render(Get started); + expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn', 'btn-primary', 'btn-md'); + }); + + it('supports the ghost variant and the large size', () => { + render(See how it works); + expect(screen.getByRole('link', { name: 'See how it works' })).toHaveClass('btn-ghost', 'btn-lg'); + }); +}); diff --git a/web/__tests__/marketing/Footer.test.tsx b/web/__tests__/marketing/Footer.test.tsx new file mode 100644 index 0000000..c1d1090 --- /dev/null +++ b/web/__tests__/marketing/Footer.test.tsx @@ -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(); + expect(screen.getByText('Your website, handled.')).toBeInTheDocument(); + }); + + it('has the four link columns from the design', () => { + render(); + for (const heading of ['Product', 'Company', 'Resources', 'Legal']) { + expect(screen.getByRole('heading', { name: heading })).toBeInTheDocument(); + } + }); + + it('links only to pages that exist', () => { + render(); + 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(); + for (const label of ['Changelog', 'About', 'Blog', 'Contact', 'Terms', 'Privacy']) { + expect(screen.getByText(label).closest('a')).toBeNull(); + } + }); + + it('shows the copyright line', () => { + render(); + expect(screen.getByText('© 2026 Wursor')).toBeInTheDocument(); + }); +}); diff --git a/web/__tests__/marketing/Nav.test.tsx b/web/__tests__/marketing/Nav.test.tsx new file mode 100644 index 0000000..d5537ad --- /dev/null +++ b/web/__tests__/marketing/Nav.test.tsx @@ -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(); + expect(screen.getByRole('link', { name: 'Wursor' })).toHaveAttribute('href', '/'); + }); + + it('lists the five page links', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/web/app/(marketing)/page.tsx b/web/app/(marketing)/page.tsx index c41dc82..8a269f7 100644 --- a/web/app/(marketing)/page.tsx +++ b/web/app/(marketing)/page.tsx @@ -1,3 +1,12 @@ +import { Footer } from '../../src/marketing/Footer.tsx'; +import { Nav } from '../../src/marketing/Nav.tsx'; + export default function HomePage() { - return Wursor; + return ( + <> + + + + > + ); } diff --git a/web/public/marketing/README.md b/web/public/marketing/README.md new file mode 100644 index 0000000..9e273f0 --- /dev/null +++ b/web/public/marketing/README.md @@ -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. diff --git a/web/public/marketing/brand-mark-dark.svg b/web/public/marketing/brand-mark-dark.svg new file mode 100644 index 0000000..85476a9 --- /dev/null +++ b/web/public/marketing/brand-mark-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/marketing/brand-mark-light.svg b/web/public/marketing/brand-mark-light.svg new file mode 100644 index 0000000..4729ba1 --- /dev/null +++ b/web/public/marketing/brand-mark-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/marketing/icons/chevron-right.svg b/web/public/marketing/icons/chevron-right.svg new file mode 100644 index 0000000..ea0a771 --- /dev/null +++ b/web/public/marketing/icons/chevron-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/public/marketing/icons/menu.svg b/web/public/marketing/icons/menu.svg new file mode 100644 index 0000000..a61bab3 --- /dev/null +++ b/web/public/marketing/icons/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/marketing/BrandMark.tsx b/web/src/marketing/BrandMark.tsx new file mode 100644 index 0000000..88db74f --- /dev/null +++ b/web/src/marketing/BrandMark.tsx @@ -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 */} + + {/* eslint-disable-next-line @next/next/no-img-element */} + + > + ); +} diff --git a/web/src/marketing/Button.tsx b/web/src/marketing/Button.tsx new file mode 100644 index 0000000..901fa0c --- /dev/null +++ b/web/src/marketing/Button.tsx @@ -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 ( + + {children} + + ); +} diff --git a/web/src/marketing/Footer.tsx b/web/src/marketing/Footer.tsx new file mode 100644 index 0000000..a279c08 --- /dev/null +++ b/web/src/marketing/Footer.tsx @@ -0,0 +1,37 @@ +import Link from 'next/link'; +import { FOOTER_COLUMNS, TAGLINE } from './content.ts'; + +export function Footer() { + return ( + + ); +} diff --git a/web/src/marketing/Icon.tsx b/web/src/marketing/Icon.tsx new file mode 100644 index 0000000..d953bd2 --- /dev/null +++ b/web/src/marketing/Icon.tsx @@ -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 ; +} diff --git a/web/src/marketing/Nav.tsx b/web/src/marketing/Nav.tsx new file mode 100644 index 0000000..d3f5db1 --- /dev/null +++ b/web/src/marketing/Nav.tsx @@ -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 ( + + + setOpen(false)}> + + Wursor + + + + {NAV_LINKS.map((l) => ( + + {l.label} + + ))} + + + + + Sign in + + + Get started + + setOpen((v) => !v)} + > + + + + + + {open && ( + + {NAV_LINKS.map((l) => ( + setOpen(false)}> + {l.mobileLabel ?? l.label} + + ))} + + Get started + + + )} + + ); +} diff --git a/web/src/marketing/content.ts b/web/src/marketing/content.ts new file mode 100644 index 0000000..4614586 --- /dev/null +++ b/web/src/marketing/content.ts @@ -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.'; diff --git a/web/src/styles/marketing.css b/web/src/styles/marketing.css index 32badce..238be46 100644 --- a/web/src/styles/marketing.css +++ b/web/src/styles/marketing.css @@ -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); } +}