Colophon
How this site is built, documented the way I would want it documented if I were deciding whether to trust it.
Framework and rendering
Next.js 15.1.0 on the App Router, React 19.0.0, TypeScript in strict mode. Most routes are static (SSG), this page included. /uptime uses incremental static regeneration on a five-minute window (revalidate: 300), so its status data is never more than five minutes stale without needing a client poll; the home page now regenerates on an hourly window too, for the GitHub figures described under “Open decisions” below. /lab ships a static shell, the methodology prose, and the full FAQ corpus as server-rendered HTML; Ask the ledger, the one client component on that page, matches a question against that same corpus entirely in the browser. Once the page and its model have loaded, the only request it can make is one the visitor triggers deliberately: an optional card under the conversation that emails the exchange to me, described under “Open decisions” below.
On a capable device, that same page also starts a second, optional download in the background: a local language model, fetched via @mlc-ai/web-llm from Hugging Face’s CDN, the one external-CDN dependency anywhere on this site. Which one depends on the device. Everywhere, by default: Qwen3-1.7B-q4f16_1-MLC (the id names its own quantization; about 930MB). On hardware reporting enough apparent headroom for it instead: Qwen3-4B-q4f16_1-MLC (about 2.1GB), meaning a working WebGPU adapter whose own reported buffer limits comfortably clear what the larger model needs, at least 8GB of device memory when the browser discloses that figure at all (Safari and Firefox do not, and an undisclosed figure is never treated as disqualifying), and a non-mobile environment. If the larger model fails to start, or fails its own first reply, this falls back to the smaller one automatically and silently, once per page load; if that fails too, retrieval alone still answers every question, exactly as described below regardless of which tier was attempted. Every other request this feature makes is same-origin.
Ask the ledger is a conversation thread, one assistant turn per question, and every question still runs the same in-browser retrieval first. Once the language model has loaded, it answers every question directly, not only the ones retrieval matched confidently: it draws on whichever of the top retrieved entries clear a lower confidence floor as grounding context, as few as none for a greeting or a question outside what I have written about, and a compact source line appears under a turn only when there was context to draw on. If the model is not ready yet, retrieval alone decides a turn: a matched question shows its sourced answer immediately, small talk gets a short, plain reply, and anything else gets an honest line saying so instead of a guess. The browser caches whichever model it fetched after the first download, so a later visit does not refetch it. It starts automatically on any device with a working WebGPU adapter, unless the visitor’s browser reports a data-saver preference, which is honored instead of downloading anyway.
Typefaces
- Switzer: sans-serif, self-hosted as a single variable font from Fontshare. ITF Free Font License.
- Newsreader: serif, loaded via
next/font/google. SIL Open Font License. - Geist Mono: monospace, loaded via
next/font/google. SIL Open Font License.
All three load through next/font, which self-hosts the Google-served pair at build time (no runtime request to fonts.googleapis.com) and generates metric-matched fallback fonts so text does not reflow once the real font arrives.
Design tokens
Every colour, type size, spacing value and animation duration on this site resolves to a token defined once, in app/globals.css’s @theme block. Colour is an OKLCH ramp with a semantic layer on top (--bg, --text-primary, --border, and so on), so light and dark mode are two value sets for the same names, not two parallel stylesheets. Type is a fluid scale, nine sizes, each a clamp() between a 375px and 1440px viewport, so text resizes continuously with the viewport instead of jumping at breakpoints. Spacing runs on a 4px base unit. Motion has its own small token set (four easing curves, five durations) reused everywhere rather than invented per component.
The one photograph
Every corner on this site is square except one. The home page carries a single photograph of me, next to my name: the one image where the spec calls for zero, and the one rounded corner where the token system calls for none.
I built this site to argue that you should trust the work because you can check it; for one photograph, I am asking you to trust a face instead, and I would rather say that directly than let you notice the exception on your own.
Hosting and deployment
The whole site is on Vercel, deployed from this repository’s main branch. The footer on every page states the exact deploy SHA and timestamp, baked in at build time by CI, not fetched, not hand-edited. /lab was originally going to split off a self-hosted FastAPI backend for its live query widget; that backend was never provisioned, the widget was removed on 2026-08-14, and Ask the ledger replaced it with a feature whose answering runs on the visitor’s own device. It has one server route, /api/lab-lead, an ordinary Next.js route handler on the same deployment, and it runs only when a visitor chooses to email me a conversation. See “Open decisions” below for the actual trade-off.
Performance budget
Internal targets sit well inside the Core Web Vitals “good” thresholds: LCP under 1,200ms, INP under 120ms, CLS under 0.02. The home page’s LCP element is text, not the portrait photograph described under “The one photograph” above: that image ships loading="eager" with fetchPriority="low" and explicit width/height specifically so it cannot win the largest-contentful-paint slot or move CLS off zero. Fonts are self-hosted or build-time bundled specifically to remove a runtime font-host request from the critical path.
Open decisions
- Biome as the primary linter, plus a narrow ESLint pass
- Biome runs the primary lint and format pass (
biome check): fast enough to run on every save, and it covers most of what this codebase needs.eslint-config-nextruns separately (next lint) for the handful of Next.js-specific rules Biome does not model. Two linters is more moving parts than one; the alternative was accepting a gap in App Router-specific checks, which felt to me like the worse trade for a site meant to demonstrate defensive engineering. - Retiring the planned self-hosted Lab backend
- The original plan was a small FastAPI service on its own VPS behind Caddy: a visitor who opened the network tab on
/labwould see a request to a separately-operated service with its own TLS certificate and its own failure mode, not a function indistinguishable from the marketing site around it. It was never provisioned, so the widget that depended on it could only ever show one recorded example, permanently labelled as such. I removed that widget on 2026-08-14 rather than leave a demo that could never be live, and replaced it with Ask the ledger, which needs no retrieval backend at all: the trade is a real infrastructure-ownership demonstration for a feature that actually answers you, on your own device, every time. It has since gained one small server route, for the opt-in email described directly below, which is a different thing from the retrieval service this decision was about. - Asking for an email at the end of a conversation
- Once you have asked
/lab’s assistant something real, a card appears under the conversation offering to email it to me, with your address, so I can reply personally. It is optional, there is no gate, and dismissing it costs you nothing: the full corpus sits ungated below the widget either way. I ask for two reasons and state both on the card: this site is how the people who hire me find me, and the questions it fails to answer are the ones I should be writing. What gets sent is exactly the messages on screen plus your address and two timestamps, through Resend, an email relay, to my personal inbox. There is no database of conversations or addresses, no list, and the page-view analytics described below never sees this conversation or your address; the delivered email is the entire record, which is also what makes “email me and I’ll delete it” a promise I can actually keep. If you never use the card, nothing is stored and no request is made. The honest cost of shipping it: this site can no longer claim that nothing you type ever leaves your device, and I removed that sentence everywhere rather than qualify it with an asterisk. - Adding Vercel Web Analytics and Speed Insights
- Every page on this site now loads two small first-party scripts, served from this deployment’s own origin at
/_vercel/insights/script.jsand/_vercel/speed-insights/script.js: Vercel Web Analytics and Speed Insights. What they record is a page view and the Web Vitals timings for that load, the same LCP, INP and CLS figures named under “Performance budget” above, plus a referrer, a coarse device and browser class, and a country. I added them to see which pages actually get read and whether those targets hold up for real visitors, not just for me. Neither script sets a cookie or carries a cross-site identifier, and neither one sees anything typed into Ask the ledger or sent through the lead-capture card above: that is a separate, opt-in path with its own disclosure. The data goes to Vercel, the company already hosting this site, and stays there for as long as Vercel keeps it; I have not set a shorter retention window of my own. A content blocker, or disabling JavaScript entirely, stops both scripts from loading, and every page on this site still works exactly the same without them. - Counting home page loads at the edge
- Since 2026-08-16, the home page shows a running count of its own loads. Edge middleware increments a single integer in an Upstash Redis store (via the Vercel Marketplace) on every real
GET /a browser makes; router prefetches are excluded, and the increment is fire-and-forget, so it cannot slow the response. Bots are counted: this counts loads, not people, which is what “page loads” says and “visitors” would not. Nothing per-visitor is written anywhere: no cookie, no IP address, no user agent, just that single number, read back through/api/views. I wanted a number on the page itself, counted plainly, not a figure only I can see in a dashboard. An unconfigured store means the line does not render, never a false zero. There is nothing to undo: no per-visitor record was ever made. - Replacing the Synapse figures with live GitHub numbers
- When a fine-grained, read-only GitHub token, scoped to every repository, is set, the Synapse cells on the home strip are replaced by live figures from my GitHub account: contributions across the trailing 365 days including private repositories, commits to public repositories over the same window, repositories owned with the public share stated, and stars across public repositories. Private repositories contribute only a count, never a name. Each cell’s provenance panel names the API field behind it. I wanted numbers you can check against my account, not a fixed case study. Without the token, or if GitHub cannot be read at the first build, the strip shows the Synapse figures instead; a later refresh failure keeps serving the last successful numbers rather than flipping back. The honest cost: the home page is no longer purely static, it now regenerates hourly.
Stack
Read directly from this repository’s package.json at build time, not transcribed by hand, so it cannot drift from what actually ships.
- @axe-core/playwright@4.10.1
- @biomejs/biome@1.9.4
- @huggingface/transformers@4.2.0
- @lhci/cli@0.15.1
- @mlc-ai/web-llm@0.2.84
- @next/eslint-plugin-next@15.5.23
- @playwright/test@1.49.1
- @tailwindcss/postcss@4.0.0
- @types/node@22.10.2
- @types/react@19.0.2
- @types/react-dom@19.0.2
- @typescript-eslint/parser@8.0.0
- @vercel/analytics@2.0.1
- @vercel/speed-insights@2.0.0
- disposable-email-domains@1.0.62
- eslint@9.17.0
- eslint-config-next@15.1.0
- eslint-plugin-react-hooks@5.2.0
- linkinator@8.0.3
- motion@11.15.0
- next@15.1.0
- next-view-transitions@0.3.5
- react@19.0.0
- react-dom@19.0.0
- resend@6.20.0
- shiki@1.24.4
- tailwindcss@4.0.0
- typescript@5.7.2
- velite@0.2.1