Dark mode¶
There is no dark mode to implement. Every colour in the system carries both themes in one value:
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 -->
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.