When two theme rules tie

A light theme set alongside high contrast inherited the dark palette by source order, and the fix became a per-theme rule for the duel snakes.

A player who turned on the light theme and high contrast at the same time got a word panel label that measured 1.41:1 against its background, far below the 4.5:1 that WCAG asks for. The automated audits never saw it, because they only load the default dark theme. The cause was a tie in the cascade, and the fix grew from one token into a rule that applies to every theme.

Two blocks with equal weight#

The light theme and the high-contrast setting each have their own block of colour tokens. Both selectors are single attribute selectors with the same specificity, so when a player has both attributes set, the browser decides each token by source order. The high-contrast block comes later in the stylesheet, so it won. Any token the combined block did not set fell through to the dark high-contrast value, which is tuned for a black background:

:root → [data-theme='light'] → [data-contrast='high'] → [data-theme='light'][data-contrast='high']
                                ^ won the tie by source order          ^ now sets its own values

The combined block existed, but it overrode only one surface colour, so the label and the snake head inherited the high-contrast gold on a near-white board. The label had only recently been given a token of its own in an earlier fix on the duel word panel, which is how the bug became visible there (the earlier post on that fix).

The same pass found text on the accent colour. The dark high-contrast theme never overrode the text colour used on accent backgrounds, so white sat on gold on every primary button, the send key and the skip link. The text is black there now, and the light high-contrast block sets white explicitly rather than inheriting the dark value.

Heads that differed by hue#

The duel’s two snake heads were the other half of the problem. Players told them apart mainly by hue, red against orange or gold against cyan, which gives little to go on for anyone with a colour vision deficiency. Each theme now separates the heads by both hue family and lightness, and the luminance gap is measured for every theme:

theme      player head    opponent head    head gap (luminance ratio)
dark       red            bright amber     1.69 → 2.91
light      deep red       violet           1.56 → 2.10
dark HC    gold           deeper blue      1.13 → 2.12
light HC   dark crimson   blue / navy      1.45 → 2.42

Under simulated protanopia, deuteranopia and tritanopia, the worst-case separation between the heads rose in every theme, from between 7 and 23 to between 23 and 28 in OKLab distance. The opponent’s colour also has to clear 4.5:1 as text in every theme, because it colours the score and the game log, so the same test file checks both constraints in each theme.

Checks that had to fail first#

The contrast test file grew from 34 checks to 84. Each new check was run against the old colours first and failed, then passed after its fix, so every assertion has been seen to catch something. Because the test runs in every theme, a new colour token that misses its ratio now fails the test run, rather than waiting for an audit that only looks at the dark default.