Designing for light and dark from day one

Design

Dark mode bolted on at the end always looks bolted on. Every colour in Kool is a token, and every token has both values from the moment it is created. Nothing ships until it has been read in both.

That is not a slogan, it is a data structure. In design/tokens.json a colour is not a string, it is a pair. brand500 is #7B68EE in light and #C4BAFF in dark, and there is no way to write only one of them, because the Swift side's Color(light:dark:) takes a pair and the file's own metadata says every colour carries both. You cannot forget the dark value. The type will not let you have only half a colour.

Doing it this way costs something up front. Thirty-five colours means seventy decisions, and a fair number of them are not obvious: a border that reads as a hairline on white can vanish entirely on near-black, and the honest fix is usually a different value rather than the same value at a different opacity. Doing it at the end costs more, because by then the decisions are spread across hundreds of call sites and each one has an opinion.

The failure worth describing is one we hit on our own website, because it shows what goes wrong when a colour and the surface behind it stop being decided together.

The landing page runs a journey where the canvas changes colour as you scroll, white for some sections and near-black for others. That is done in one rule per step: the rule sets the background and swaps the foreground token in the same breath. Text and page always agree, because they are declared in the same place.

Then we put an opaque layer over the page for an unrelated reason, and that layer painted the general background token rather than inheriting what the body was actually showing. The result was that the page under the text stayed dark while the text switched to its on-white colour. The bug report we got was "you changed my font colours". No font colour had been touched. The page beneath them had.

The fix was to make the layer inherit, so it can never hold an opinion about the background that differs from the body's. The lesson generalises past that one page: a colour is only ever correct relative to what is behind it, so the two have to be decided in the same place, and any element that paints its own background is claiming to know something it usually does not.

Both themes, every time, from the first commit. It is cheaper, and it means "does this work in dark mode" stops being a question anybody has to remember to ask.

All posts