Colophon

How this site is built.

Most portfolios tell you what their clients got. This one also tells you how the portfolio itself is made. If something here is useful for your own project, help yourself.

Stack.

Framework
Next.js 16 (App Router) on React 19
Language
TypeScript everywhere
Styles
Tailwind CSS v4 with @theme tokens; dark + light via a data-theme attribute flipped by an inline bootstrap script (no flash)
Motion
framer-motion, throttled carefully on mobile
Hosting
Vercel Pro, SSR mode
Data
Vercel KV (Upstash Redis) for the portfolio config + any toggle-without-deploy flags
Forms
Formspree
Analytics
Self-hosted Umami (cookieless, no visitor tracking) + Vercel Speed Insights
Auth (admin)
NextAuth v5 with GitHub OAuth

Typography.

Three typefaces, all self-hosted via next/font:

Geist Sans
Body text, UI. Variable weight. The workhorse.
Bricolage Grotesque
Headings. Variable wdth + wght. Nav links subtly shift weight on hover.
Fraunces (italic only)
Editorial accents - the headline 'actually work.', section titles, headline metrics on portfolio cards. opsz axis for optical sizing. Italic-only to save ~100KB of font bandwidth.
Geist Mono
Domain labels on portfolio cards. Not preloaded - saves mobile bandwidth.

Performance.

A live PageSpeed Insights run is one click away. Current numbers (median across every public route):

Desktop perf
99 / 100 average (100 on /terms)
Mobile perf
85-89 average, throttled Moto G4 profile
Accessibility
96-100 on every route, WCAG AA contrast
Best practices
100 across the site
SEO
100 across the site
Homepage HTML
~80 KB transferred on first load
Portfolio images
WebP q88 @ 1600w max, 80-150 KB each after a hard audit (PNG originals were 2-3MB)
Regression guard
Lighthouse CI runs on every PR against the Vercel preview, asserts on thresholds

How the portfolio screenshots work.

A Puppeteer script at scripts/screenshots.mjs hits every live client site, waits for animations to settle, dismisses common cookie banners, and writes a PNG at 1280x720 @ 2x. The PNGs get piped through sharp to produce the WebP versions used in production. There's also an ffmpeg path that captures a silent 3-second WebM loop with a soft scroll, which will land when there's time to record them.

Theme bootstrapping.

The light/dark toggle is backed by CSS custom properties under a [data-theme] attribute on <html>. An inline <script> in the head reads localStorage.theme and sets the attribute before first paint, so light-mode users never see a dark-mode flash. The particle canvas on the hero watches the same attribute via a MutationObserver and swaps its base colour live when you flip the toggle.

Accessibility.

Semantic HTML first, ARIA where it adds value. Every interactive element is keyboard-navigable with a visible focus ring. Motion-heavy sections (the hero canvas, page transitions) respect prefers-reduced-motion. Text contrast is WCAG AA in both themes, verified via tokens rather than eyeballing hex values.

Bits I'd do differently.

No site is a straight line. Some honest retros from the build:

  • I shipped a WebGL domain-warped gradient shader as the hero background because it looked premium on my desktop. Mobile Lighthouse promptly returned a 41 and 167 seconds of blocking time. I spent a session progressively throttling it (octaves, DPR, framerate, visibility gating) before admitting that two different heroes for two different viewports was a worse outcome than one particle graph everywhere. Kept the particles, threw the shader away.
  • The "Lighthouse 95+" badge in the footer is true on desktop (avg 99) and aspirational on mobile (avg 85). I have looked at tightening the claim. For now the PSI link is one tap away so anyone can check.
  • The first portfolio data pass had V Clarke Books' headline metric reading "Author-managed via Decap CMS" - technically true, but a tool name is not an outcome. Still on the backlog to replace with a real figure.
  • The first Lighthouse sweep surfaced a 3.5MB PNG hero photo and five portfolio screenshots totalling ~10MB. The second sweep surfaced that I had been compressing them with the wrong settings for a week. Lesson: measure the bytes on the wire, don't trust the build report.
Build 979d471