How this site is built
This portfolio is a small codebase, and I run it like a production one: typed code, tests on every change, and no step I can't repeat. Here is what is under the hood and why. The source is on GitHub.
Stack
- Next.js 16 with static export. I work with Next.js every day. Here it builds every page into a plain HTML file, which my shared host serves as is. That gives each page its own URL and link preview, with no server to run.
- Server Components for the content. The sections are rendered to HTML at build time, so the page is readable before any JavaScript runs, or without it. Only the header and the command palette run in the browser.
- TypeScript in strict mode, React 19.
- Plain CSS with design tokens. Colours, spacing and fonts are custom properties on
:root, redefined for dark mode. No UI framework. - Self-hosted fonts through
next/font, so the page makes no requests to Google Fonts.
Two skins, one codebase
The >_ button in the header switches between the standard style and a Terminal style. Both render the same markup and the same content.
- All Terminal rules live in one stylesheet,
src/design/skin-terminal.css, scoped underhtml.skin-terminal. The scope is wrapped in:where(), which adds no specificity. The skin's rules therefore compete with the component styles on equal terms and win only because they load last. All stylesheets are imported in one place, in a fixed order. - A small inline script in the page
<head>applies the saved theme and skin before the first paint, so a returning visitor never sees the other look flash. It also reads?skin=and?theme=from the URL, so a link like?skin=terminal&theme=darkopens in exactly that look, for that visit only. - The Terminal font, JetBrains Mono, is not preloaded. The browser downloads it only when the Terminal style is on.
- The ⌘K command palette is one component in both skins. Its commands are plain data with a title for the standard style and a shell alias for the Terminal style, where it becomes a prompt:
cd work,cat cv,theme dark,help.
Accessibility
- Section links move focus to the section's heading, keep a shareable fragment URL, and work with Back. With reduced motion turned on, they jump instead of scrolling.
- The mobile menu closes with Escape and returns focus to the Menu button. A closed menu is out of the tab order.
- Expandable details use native
<details>elements. The palette uses a native modal<dialog>, which keeps focus inside and makes the page behind it inert, with an ARIA combobox for the list. - Every build runs an automated accessibility scan (axe, WCAG 2.1 AA) on each page in both skins and both themes, and on the open palette. A violation fails the checks and stops the publish.
Tests and delivery
- Unit tests with Vitest and Testing Library cover navigation and focus, the theme and skin controls, the command palette and the command search.
- End-to-end tests with Playwright run against the exported site, the same files the host serves. They check each page's title, description and Open Graph tags, reading without JavaScript, the saved style applying before paint, and zero console errors. They also check that nothing scrolls sideways at 320px and 801px, and that header items never overlap between 801px and 1280px.
- GitHub Actions runs lint, type checking, formatting, the unit tests, the build and the end-to-end tests on every pull request. Merging to
mainruns the same checks, then publishes the exported site to the branch the host serves. - Changes land as small, reviewed pull requests, each with its checks and a note on what was verified.
Performance
Lighthouse, mobile profile, median of three runs on the home page, measured on 30 September 2026:
| Metric | Result |
|---|---|
| Performance | 96 |
| Accessibility | 100 |
| Best practices | 100 |
| SEO | 100 |
| First contentful paint | 1.4 s |
| Largest contentful paint | 2.8 s |
| Total blocking time | 39 ms |
| Cumulative layout shift | 0 |
| Total transfer | 311 kB |
| JavaScript (gzipped) | 144 kB |
| CSS (gzipped) | 6.3 kB |
Most of the JavaScript is the Next.js and React runtime, which loads after the page has already painted. The hero portrait is a 16 kB WebP.