
Photo by Kym MacKinnon on Unsplash
The site has had one look since I built it. Warm off-white, near-black text, a single blue.
I liked it when I launched it. I still like it today. But I am often working on the site late at night, on a machine that has gone dark hours earlier, and the page arrives like somebody turning the lights on.
So: dark mode. And because this is me, I wanted something more ambitious than a toggle control. It should switch on its own, based on the reader’s local time.
There is a CSS media query called prefers-color-scheme. It reports whatever the machine says about light or dark. It has been in every browser that matters for years, it needs no permission prompt, and it makes no network request.
What I had not properly thought about is what sits behind that setting. On my Mac it is easy: set Appearance to Auto in System Settings and macOS switches at local sunrise and sunset, using the machine’s own location. iOS does the same. Android has a sunset-to-sunrise schedule. KDE Plasma has automatic day and night switching.
So the automatic switching I had in mind already had an engine. But…
Windows does not play ball. Windows 10 and 11 give you Light, Dark and Custom, and no scheduling whatsoever. Night Light is a colour-temperature filter, not a theme, and people conflate the two constantly. GNOME exposes a light or dark preference with no standard automatic switching either.
I had built my plan based entirely on the platform I happen to use, which is a habit that continues to bite me. I write Mac software, so almost everything I reason about starts on a Mac and, while I have no metrics one way or another, I suspect plenty of my readers might not be on a Mac at all.
The obvious rescue is to fall back to the clock. You do not need geo-location to know the local time. The browser hands you a time zone through Intl.DateTimeFormat().resolvedOptions().timeZone and the local clock through Date, with no permission and no third party involved. Dark after sunset, light before. Windows users get the automatic behaviour their operating system will not give them.
There is one problem, and it kills the idea outright.
prefers-color-scheme used to have three states. Light, dark, and no-preference. That third value was removed from the specification, and no browser ships it. What browsers do now is report light when there is no setting at all.
So the two cases I would need to tell apart are indistinguishable. A browser saying light might mean “this person went into settings and chose light”, or it might mean “this operating system has no opinion and light is the default answer”. There is no way to ask which.
Which means a clock-based fallback cannot be targeted. It would fire for everybody reporting light, including everybody who deliberately chose light, and it would be worst precisely where I was trying to help. On Windows, where many people never touch the appearance setting, a visitor who likes light pages would find this site going dark on them at seven in the evening for no reason they could see or control.
I would rather ship no automatic switching at all than switching that overrules what somebody has chosen. So there is no clock in this, no location, and no third-party request. The media query follows the operating system, and a three-state toggle in the header covers the platforms whose operating system will not.
That toggle is the actual answer to the Windows problem. The result: automatic switching wherever I can do it deterministically, a manual toggle wherever I cannot, and no surprises for the reader either way.
As it happens, I had built the stylesheet on custom properties (this was not my first rodeo). Twenty-two named tokens: background, card, border, three grades of text, the brand blue, and semantic green, amber and rose. Nothing in the layout referred to a colour directly. It referred to var(--bg-card) and let the token decide.
That meant dark mode was, in the main, a second set of values for the same twenty-two names. I could spend my time choosing colours, rather than re-building hundreds of lines of CSS.
I did make one deliberate structural choice. The dark values live exactly once, in their own --dark-* tokens, and are mapped onto the live tokens twice: once behind the media query, once behind a data-theme attribute so the toggle can override the operating system in both directions. Plain CSS gives you no way to share one block between a media query and an ordinary selector, so the mapping has to repeat. The values do not. Changing a colour is still a one-line edit, and the two blocks cannot drift apart into different palettes.
The tokens covered the layout. They did not cover everything.
Seventy-three colour values were sitting in the stylesheet and in five pages’ inline styles, written as literal hex and rgba, invisible to the token system. Every one had to be caught before dark mode could work, and sorting them turned out to be the genuinely interesting part of the job.
Six were #fff, all of them text sitting on a blue button. In light mode white on #004080 is fine. In dark mode the blue has to lift to something like #7db3e8 to be legible at all, and white text on that is not fine, so the value needed to become a token in its own right and flip to a near-black in dark.
Most of the rest were shadows, and shadows taught me something I had not considered. My instinct was to treat them as a colour to swap out. But a shadow is how you signal that a card sits above the page, and in dark mode a black shadow at eight per cent opacity against a near-black background does not read at all. The card goes flat. So the dark shadows keep the same geometry at five to seven times the opacity. They became whole tokens rather than colour tokens, because the spread and the alpha both have to change together.
Then a handful of one-offs. The translucent header background, which was a specific alpha over the light page colour and needed the equivalent over the dark one. The gold hover on notes rows. The white mount on the photo frames, which stays pale in dark mode because a photo mount should, but dims so it stops glaring.
By the end the stylesheet had no hardcoded colours left outside the token definitions, which was less about tidiness than about consequences. Any one I missed would have been a white rectangle in a dark page.
Three pages are deliberately exempt. Centipede, Mr. Do! and Star Raiders are canvases, and a canvas is not themed. The 404 page stays dark in both modes, because it was always meant to be dark and folding it into the token system would have made it light half the time.
This is the part I did not go looking for.
While choosing the dark values I wrote a small script to check contrast ratios against the WCAG AA threshold of 4.5 to 1 for body text. I ran it on the dark palette, which passed. Then, out of curiosity, I pointed it at the light palette that has been live on this site since the beginning.
Four failing pairs, across three tokens.
Muted text at 3.45 against a white card and 3.22 against the page. The semantic green at 3.77. The gold on note-row hover at 3.77. All of them below the threshold, all of them shipped, all of them looked at by me hundreds of times without registering.
I fixed those and thought I was finished. What I had actually done was measure the wrong thing very accurately, which is the most useful thing I learned all day.
My check was testing each colour against --bg-card, the white of a card. That is where I had been picturing the text. But the section labels on the home page are small pills: the accent colour as text, on its own colour at eight per cent opacity, sitting on the page background rather than on a card. That is a darker surface than white, and it is the real worst case for those colours. It had never occurred to me to test it, so it was never tested.
Adding the pill surfaces turned up two more failures I had not seen. Rose at 3.88. Amber at 4.22. And muted text failed on another surface I had also never measured, at 3.00.
Five tokens, not three. I had been measuring the wrong thing accurately for months.
Fixing them wanted the smallest change that would clear the threshold, because I like this palette and did not want a new one. So instead of picking colours by eye I searched down the lightness axis of each one, holding hue and saturation fixed, and took the first value that passed on every surface it actually sits on. Amber moved by eight parts in 255 and is imperceptible. Muted text moved by thirty and is the one you might notice if you knew to look. There is no way around that one: the requirement is more contrast, and the page background is already nearly white, so there is nowhere to go but darker text.
One small trap on the way. Darkening the green also darkens its pill, because the pill is derived from it, and the slightly darker pill pushed the green back under the threshold. It needed one more step to clear its own background.
The measurement is now a script in the repository rather than something I did once. It reads the palettes out of the stylesheet instead of taking my word for what they are, checks every foreground against every surface it is actually painted on, and fails with an error code if anything drops below AA in either theme. I also broke it deliberately, restoring the old muted grey, to watch it catch all four surfaces and fail. Because a check I have only ever seen pass tells me nothing at all.
Most of this site is hand-written HTML. These notes, however, are generated from Markdown by a script.
Both share their navigation and footer through a partial, and the script that generates the notes has a comment explaining exactly why: single source of truth, edit the partial and both the hand-written pages and the generated ones pick it up.
The page head was not in that arrangement. It had been copied inline into the generator instead.
I never picked up on it, because for a year the head only held stylesheet and font links and those never changed. Then the theme work put a script in the head, the one that reads your stored preference and applies it before the page paints so you do not get a flash of the wrong theme.
So the shared partial dutifully gave every notes page the toggle button, and the duplicated head withheld the script that defines what the button does. A hundred-odd pages with a control that threw an error when clicked and silently ignored your saved preference. Choose dark on the home page, click into any post, and you would be back in light with a button that did nothing.
The generator now reads the head from the partial like it reads the nav. Its incremental rebuild check also only watched the nav and footer for changes, so a future head edit would have left every notes page with a stale one and said nothing. It watches all three now.
I have a soft spot for this class of bug. Nothing was broken. A comment described the correct design, the design was two-thirds implemented, and the missing third was invisible until a feature happened to depend on it.
The toggle needed an icon in the header, next to the navigation links. Half-filled circle for follow-the-system, sun for light, moon for dark.
It took me four goes to make it sit level with the words beside it, and I got it wrong differently each time.
The first attempt let the row stretch, which is what flexbox does by default. Every text item grew to match the tallest thing in the row, which was now my thirty-pixel button, and the words stayed at the top of their newly taller boxes while the icon centred in the full height. The icon sat about four pixels low.
So I centred everything. That looked nearly right and was still wrong, by about a pixel and a third, and it took me a while to see why. A line of text does not visually centre in the middle of its line box. A line box contains the space below the baseline for descenders, plus the leading, so its geometric middle sits above where the letters look centred. Centred on the box, the icon ended up above the text’s optical centre.
Third attempt: align on the baseline, and let the browser do the arithmetic. CSS has a value, vertical-align: middle, defined as the box’s midpoint against the parent’s baseline plus half its x-height, which is exactly the optical centre of lowercase text. Derived from the font’s own metrics, no number for me to guess.
The icon went exactly where it should. The menu moved.
Baseline-aligning the whole row meant the button, which hangs below the baseline, grew the list’s box downward, and the header centres that box, so the words lifted. I had fixed the icon by breaking the seven things next to it, which is a worse bug than the one I started with.
At that point I stopped adjusting things and modelled the line box on paper. Ascender, descender, x-height, half-leading, where the baseline actually falls, what each candidate would do.
The model reproduced the fault immediately, and named it. vertical-align: middle puts a box’s midpoint half an x-height above the baseline, which means a box of height H reaches up H/2 + xHeight/2 above it. Once that number exceeds the ascent the text itself demands, the button rather than the text governs the top of the line, and drags the baseline down with it. My thirty-pixel button reached 18.82 pixels above the baseline against the text’s 16.29, so it took charge and pulled the icon down by 2.53.
There is a ceiling, and it is calculable. Below about twenty-five pixels at this size the text stays in charge and the icon lands exactly where it should. The button is now one line box tall, comfortably under, and the alignment is the browser’s arithmetic rather than a number I nudged until it looked right.
Four attempts to move an icon by two and a half pixels. Two of them were guesses, and the guesses were the slow part. The moment I wrote down what a line box is actually made of, it took one calculation.
Making the button shorter shrank its clickable area, so I gave it back with an absolutely-positioned overlay. Neat, invisible, worked.
But… I do not like absolute positioning. It creates a dependency on whatever the containing block turns out to be, and a stacking-context surprise for somebody later, and I had spent it on something purely cosmetic. So I went looking for the layout-native version.
There is not one, and there did not need to be, because the requirement was imaginary. The navigation links have no padding. Their clickable areas have always been one line box tall, thirty-two pixels apart. My button matching its neighbours exactly was the correct answer, and the bespoke hit area I had built for it was the odd thing in the row.
I deleted the overlay and replaced it with nothing. The stylesheet now has three uses of absolute positioning in nearly nine hundred lines, and I would like to keep it that way or, better still, purge the absolutes altogether. That is work for another day.
About an hour, all in. And the reason it was an hour is the point I want to make.
The stylesheet was already built on tokens. Nothing in the layout knew what colour it was, it asked for var(--bg-card) and let the token answer, so a second theme meant a second set of values rather than a rewrite. That decision was made months ago, for no better reason than it seemed tidier at the time, by a version of me who had no idea this was coming.
Everything else surfaced because I happened to be in there looking at colours anyway. The values that had escaped the token system, the contrast that had been failing since launch, the generator only two-thirds faithful to its own comment. None of it was hard to fix once I could see it.
So this is an argument for doing the dull organisational work early, and I do not think I had appreciated quite how strong it is. A tidy foundation made the feature cheap, and cheap is what left me attention to spare for five things I had not gone looking for.