Skip to content

How this site is built

Nobody picks 340 color pairs by hand.

Next.js, my own components, and two scripts that refuse to let me ship something broken. This page is about the two scripts.

The color dial in the sidebar turns the whole site through seventeen hues. Each one has a dark page and light bands, and in every one of them the text, two lighter shades of text, the accent, and the outlines on buttons have to stay readable on two different surfaces. That’s 340 pairs that each have to pass WCAG AA. I didn’t choose any of them.

Seventeen themes, none of them chosen

scripts/themes.mjs starts from the two palettes the site already had, the dark page and the light bands, written out as hex. It converts every color to OKLCH, a color space where lightness, colorfulness, and hue are separate dials, and turns the hue by a seventeenth of the wheel for each new theme.

Then it solves for lightness, and this is the part that matters. Contrast in WCAG isn’t measured in OKLCH lightness. It’s measured in relative luminance, which counts green far more than blue, so a blue and a green at the same OKLCH lightness don’t have the same contrast against the same background. The script searches for the lightness at which each new color has the same relative luminance the original had, so its contrast comes back to where it started.

Two things are allowed to give way, in this order. Colorfulness, when a hue can’t hold it inside the range a screen can show. Then a nudge of lightness away from the background, when rounding to eight-bit color eats the last of a pair’s headroom, until it clears its floor with a 0.03 margin. The default theme is the original palette, byte for byte.

The first version failed, and the build said so

My first attempt held OKLCH lightness fixed and only turned the hue. It’s the obvious move and it’s very nearly right. Here is what the check says about it, re-run with that approach put back:

node scripts/themes.mjs --checkfirst approach, exit code 1

Result
14 of 340 pairs fail
Worst
stop 10 Orchid dark control on raised = 2.89 (need 3)
Also
Aqua through Olive, every one the same pair

Every failure was the same pair: the outline on buttons and controls, against the raised surface in dark mode, which has a floor of 3:1. Fourteen of the seventeen themes drifted under it, and Teal is listed at 3.00 only because the report rounds to two places. Nobody would have seen it in a screenshot. Solving for luminance, with that small margin, put every one back over the line.

Control on raised, dark modelightness fixed → luminance solved, need 3.00

0 Lime
3.02 → 3.02
1 Fern
3.03 → 3.03
2 Jade
3.00 → 3.05
3 Teal
3.00 → 3.05
4 Aqua
2.98 → 3.07
5 Sky
2.94 → 3.07
6 Azure
2.91 → 3.04
7 Cobalt
2.93 → 3.07
8 Indigo
2.90 → 3.04
9 Violet
2.91 → 3.05
10 Orchid
2.89 → 3.03
11 Magenta
2.92 → 3.05
12 Rose
2.90 → 3.06
13 Crimson
2.94 → 3.06
14 Rust
2.96 → 3.05
15 Amber
2.99 → 3.04
16 Olive
2.99 → 3.03

The same script runs before every build. npm run build is themes.mjs --check and then next build: it generates all seventeen themes again, checks every pair, and exits with an error if a single one is under WCAG AA, which stops the build before it starts. When everything passes, it prints one line:

npm run buildevery build

themes
17 themes, 340 pairs, all clear WCAG AA

It checks three more things per theme that aren’t in the 340: the type sitting on the highlighter, and the paper and ink of the drawings, which change color with everything else.

A check that only runs when I remember to run it is a check that doesn’t run.

A browser that remembers every bug

Lint and the build catch type errors and broken imports. They don’t catch a menu that traps your keyboard, because that compiles perfectly. So scripts/check-behavior.mjs opens the site in a real Chrome, through Playwright, at laptop and phone sizes, and does what a person would: taps, tabs, presses Escape, turns JavaScript off.

Every check in it is a bug that actually shipped and got fixed. If one comes back, the script fails. Anything that has never been seen to fail isn’t in there, and neither are speed numbers, which change with the network and would make it fail for no reason.

  • Stickers. On a phone, a tap used to latch a sticker open for good, and several could stack up over the text. Now a second tap closes it, and only one is ever open.
  • The ⌘K menu. Tab used to walk straight out of it onto the page behind, and Escape only worked from the search box. Now forty presses of Tab stay inside, and Escape closes it from anywhere.
  • The sidebar on a phone. Closed, it sat off-screen with nine links still reachable by keyboard and no visible focus. Now closed means unreachable.
  • The drawn cursor. The worst one. The stylesheet hid the normal pointer whether or not the drawn one had loaded, so with slow or blocked JavaScript a desktop visitor had no pointer at all. Now every rule that hides it depends on the drawn one being there, and the switch that turns it off has to work and stick.
  • Analytics. The off button in the footer has to actually stop Google Analytics and clear its cookies, not just change its label. This one only runs against a production build, and says so when it skips.

npm run checka local run, November 2026

Passed
All 18 checks
Skipped
analytics: opt-out (analytics is not enabled on this build)

Where they run

Locally whenever I run them, and on GitHub on every push and pull request: lint, then the build with its color check, then the browser check. I’ll be straight about the limit. The site deploys to Vercel whether or not that passes, so it’s a signal rather than a gate. What it buys is that a fixed bug coming back shows up as a red mark on the exact commit that brought it back, instead of waiting for someone to notice.

All of it is public: github.com/dabomba9/dustinreed-portfolio. The color generator is scripts/themes.mjs and the browser check is scripts/check-behavior.mjs. Both explain themselves at the top, in more detail than this page.

~/how-its-built0%