Skip to content

Dark mode

There is no dark mode to implement. Every colour in the system carries both themes in one value:

--ads-surface: light-dark(var(--ads-n-0), var(--ads-n-900));

light-dark() resolves against color-scheme, which cascades. So one declaration per token covers automatic (following the OS), forced light and forced dark, in both directions.

Never write a dark: variant

There is no second styling pass here, which means there is no second pass to forget to update. A dark:-style override reintroduces exactly the drift the single-value approach removes.

Switching

<html data-theme="dark">   <!-- forced -->
<html data-theme="light">  <!-- forced -->
<html>                     <!-- follow the OS -->
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"]  { color-scheme: dark; }

Because the attribute only sets color-scheme, and every colour resolves against it, the toggle is three lines of JavaScript and no re-render.

What this does not do for you

light-dark() makes a colour defined in both themes. It does not make it readable in both. That distinction cost this project fifty-four contrast failures, found the first time anyone rendered a page in dark mode and measured it.

The cause was single-valued intent colours. --ads-brand was chosen to read on white, where it gives 7.0:1; on a near-black surface it gives 3.5:1. Every link, badge, chip, alert heading, status pill and log line failed at once, because all of them paint an intent colour as text.

The fix was a fourth shade per intent — -ink — which is the one that is not a mix against the surface and therefore has to change per theme. See Knobs and derivation.

The rule that follows: when you paint an intent colour as text, use -ink. When you use it as a solid fill behind --ads-brand-fg, use the knob.

Testing it

The browser sweep walks the real DOM in both schemes and measures each element's text against whatever its ancestors actually painted — because the question is what a colour lands on, and no static reading of the stylesheet knows that.

Colours are read as pixels, via a canvas, rather than parsed from getComputedStyle strings. Four separate measurements in this project were wrong before they were right, all for the same reason: a computed value comes back in whatever notation it arrived in, and an oklab() mix read by a regex that takes the first three numbers reports a lightness and two axes as red, green and blue.

See Accessibility for the full set of conditions swept.