The Lab · 03 · Teardown
This site, torn down
A designer’s portfolio is a strange artifact: it is the one deliverable where the medium is also the sample. So here is the build, measured rather than described — what it costs, what it depends on, and what is still missing.
A site that argues for evidence must be willing to be inspected itself.
Measured
The whole thing, in numbers
| What | Measured | How |
|---|---|---|
| Hand-written HTML pages | 41 | find . -name "*.html", excluding the book, print sources and the two OG-card templates — those render to PNG and no visitor can reach them. This read 43 until 2026-09-03, when the templates were still counted as pages. |
| Stylesheet, entire site | 608.8 KB on disk · 134.7 KB gzipped | cat fonts.css styles.css ember.css | wc -c → 623,444 bytes, unminified, across the three sheets every content page links; gzipped they are 138,391 bytes over the wire. Counted all three after 2026-09-03, when this row measured styles.css alone and still called it “entire site” — it understated the CSS by 82%, and never moved when ember.css changed. book/book.css (71,850 bytes) is excluded: no classic page loads it. Unminified because there is no build step — gzip is what a reader pays, the disk number is what the choice costs me. The rendered-coverage instrument that earlier passes waited for now exists (tools/css-coverage.py): a rule is called dead only when Chrome’s rule-usage tracking, a DOM census of every page, and a JS-token sweep all agree — and the instrument is calibrated with a planted dead rule each run. Its first pass removed 115 rules from the ember sheet with zero geometry change on the sampled pages. This classic sheet keeps its dead weight by design: the coverage harness can only exercise the active theme, so deleting here would be a guess again. |
| Renders without JavaScript | 100% on the reading pages · 68% on /fit/ | Scripts disabled via CDP and <main> word counts diffed across nine pages on 2026-09-03. Identical with and without: the homepage (2,782 words), the AdTech case (3,417), /patterns/, /writing/, /screen/ and /resources/. Three are not. /fit/ keeps 305 of 447 — the matcher is client-side, and that page says so in its own copy. /book/ keeps 4,483 of 4,540 and /lab/ 1,114 of 1,145, both losing only scroll-revealed labels. This row read “100% of the copy” until 2026-09-03, generalised from two pages to the whole site. A noscript fallback restores the scroll-reveal elements. Increasingly relevant: in a 72-portfolio field review, nine sites served an empty shell to any non-browser reader — this one reads complete to a text-only or AI screener. |
| Runtime dependencies, classic site | 0 | No root package.json, no node_modules, no bundler config |
| Third-party domains serving code | 2 | Google Analytics and Microsoft Clarity (heatmaps, added 2026-08-15; honours Do-Not-Track). Attention measurement (scroll depth, section dwell) is first-party: attention.js, 159 lines, no network of its own. Both load async and gate nothing on first paint. Google Fonts left on 2026-08-14 when the nine font files then in use moved to this origin; the dyslexia typeface added two more on 2026-08-28. |
| Interactive pattern demos | 9 | All in patterns/demos.js — 234 lines, 12.5 KB |
| Its imports, requires, fetches | 0 | grep -nE "import |require\(|fetch\(" → no matches |
| Open Graph share cards | 32 | All exactly 1200×630, verified with sips -g pixelWidth |
| Pages carrying a skip link | 37 | grep -rl skip-link |
| Build steps between edit and deploy | 0 | Commit to master; GitHub Pages serves the files as written |
Decisions
Add to the fonts block: What it costs: nine files of my own to subset, preload and version by hand — and the gaps list below records that hand-bumped versions have been forgotten before.
No build step, and therefore no build to break
There is no bundler, no transpiler, no CI pipeline. A file I edit is the file the browser receives. The benefit is that this site cannot rot: no dependency will publish a breaking major, and nothing in it needs a lockfile to reproduce in three years.
One qualification, added when the nav and footer became shared: they now live in partials/ and a script stamps them into every page. Nothing runs at deploy — GitHub Pages still serves the committed file, and the file in the repo is the file you receive. But there is a generator between editing a partial and committing, and cache versions are now content hashes it computes rather than numbers I bump by hand. Before that, the nav existed in 37 copies which had all quietly diverged.
What it costs: no tree-shaking, no automatic minification, and cache-busting done by hand — you can see the manual query strings, styles.css?v=3a6ed7ac, in the source of every page. On a site of this size that is the right trade. On a product it would not be, and I would not defend it there.
The stylesheet is modular, on native cascade layers
Priority is declared once, at the top of the file, in one line: @layer base, utilities;. A rule in a later layer wins outright, whatever its specificity, so the cascade is something you read rather than something you reverse-engineer. Base holds the system and the theme; utilities holds the single-purpose classes that have to sit above both.
It is plain CSS. No preprocessor, no framework, no naming convention borrowed from a build tool — @layer has been in every major browser since 2022, and it replaces the thing most stylesheets use instead, which is volume.
There is no !important in the stylesheet. A declaration marked important is not claiming importance, it is claiming precedence over some other rule — which is the thing a layer states outright — so those declarations sit in a layer named emphasis instead of shouting from wherever they happened to be written.
What it costs: layers invert for !important, so if one is ever added back, an important declaration in an earlier layer will beat a later one — the reverse of the usual rule, and a trap worth knowing about. Splitting rules across layers also resolves one button two pixels wider on eleven pages; that was measured at 8× and accepted rather than discovered later. Both notes are at the top of the stylesheet, where the next person meets them before they meet the consequences.
Fonts are served from this origin, not a third party
Nine woff2 files, latin subsets only, preloaded for the two faces that gate the first paint. No preconnect, no Google Fonts, no request leaving this domain for type. font-display:swap paints text immediately in the fallback and re-renders when the face arrives.
<link rel="preload" as="font" type="font/woff2" crossorigin
href="/assets/fonts-web/source-sans-3-normal-400.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin
href="/assets/fonts-web/source-serif-4-normal-300_600.woff2">
<link rel="stylesheet" href="/fonts.css">
One type scale, written down, enforced by refusal
Every size on the classic site comes from twelve custom properties — --fs-eyebrow through --fs-display-xl — declared once in styles.css and documented in a 174-line spec in the repository. The rule in that file is one sentence: if a value isn’t in this file, it doesn’t ship. Roughly twenty earlier one-off sizes are listed explicitly as retired, so a stray font-size: 17px reads as a bug rather than a preference.
Writing this page is what caught the drift described at the bottom of it.
The documents are the same HTML, printed
The portfolio PDF and the résumé are not designed twice. They are HTML pages with an @page rule and millimetre units, printed to PDF by Chrome — which is why a wording change reaches the PDF in one step. The ATS-readable .docx is generated by a 107-line Node script using the docx library: the only npm dependency anywhere near this project, and it is offline tooling that never ships to a browser.
One honest exception
The book view does use React
“Zero dependencies” is true of the classic site you are reading and false of the book edition, which is a React application — around 120 KB of my own code plus React and React DOM, self-hosted from the repository rather than a CDN, so the page still has no third-party runtime.
Two views of one portfolio, built two ways, was a deliberate exercise: the classic site proves I can work without a framework, and the book proves I can work with one. It also means I have opinions about both that came from shipping, not reading.
What’s missing
“A hand-built static site doesn’t prove you can ship a modern stack.” The book edition of this same site runs React — the stack shows up where the job needs it and stays out where it doesn’t. That allocation, not the absence of a framework, is the decision on display here.
The gaps, before you find them
A teardown that only lists strengths is a brochure. These are the errata for the build itself — real, and the next things I would fix.
- There were no automated tests at all until the 76 assertions behind loop.js landed. The repository contained zero test files and an empty CI directory; verification was a manual checklist — every page at two widths, no overflow, console clean. Those 76 assertions, across 42 tests of loop.js, are the first tests in this codebase, which is a genuine gap being closed, not a strength.
- No continuous integration. Nothing ran those 42 tests on push. A GitHub Action now runs node lab/loop.test.js on every push and pull request touching lab/ — this repository’s first CI job.
- Cache-busting is manual. Version query strings are bumped by hand, which means they can be forgotten — and have been.
- Two third-party domains gated the first paint. Fixed 2026-08-14: all eleven font files are now served from this origin, Google Fonts is gone from the critical path, and two third-party services remain — Google Analytics and Microsoft Clarity, which request two loader domains directly and reach two more (google-analytics.com, bing.com) once those loaders run. Neither is loaded anywhere but the live domain. The type stack changed with it — Source Serif 4 and Source Sans 3, a superfamily, replacing a mix that had monospace carrying 34 labels a page.
- A token had drifted out of sync with its own spec. The design system documented --fs-display while the stylesheet defined --fs-display-xl — a rename that never reached the document. Found while writing this page, and fixed in the commit that published it.