Check a Figure's Part Colours Against Dark and Light Plates with WCAG Contrast
A figure title in #222222 gets 15.24:1 on a #FAFAFA plate and 1.18:1 on a #121212 plate, so the same figure can look fine in one theme and lose its title in the other. To catch this, score every part colour against both plates and list the failures by plate.
How the calculation works
Contrast compares light output, but hex values are gamma-encoded, so a channel value of 128 doesn't emit half the light of 255. The calculation takes three steps:
- Linearize each channel.
linearizeundoes the sRGB encoding, with a straight-line segment for very dark values and a power curve for the rest. - Compute relative luminance. This is a weighted sum using 0.2126 for red, 0.7152 for green and 0.0722 for blue. Green counts for almost ten times as much as blue, which is why you can't judge contrast by averaging channels.
- Take the ratio. Calculate
(lighter + 0.05) / (darker + 0.05). The 0.05 keeps the ratio finite against pure black and caps it at 1.05 / 0.05, or 21:1.
Each part also needs a threshold. This program uses 4.5 for normal-size text (SC 1.4.3) and 3.0 for graphical objects (SC 1.4.11).
The program
from __future__ import annotations
TEXT_MIN = 4.5 # WCAG 2.x SC 1.4.3, normal-size text
GRAPHIC_MIN = 3.0 # WCAG 2.x SC 1.4.11, graphical objects
PLATES = {"dark": "#121212", "light": "#FAFAFA"}
PARTS = [
("title", "#222222", TEXT_MIN),
("axis labels", "#767676", TEXT_MIN),
("gridlines", "#DDDDDD", GRAPHIC_MIN),
("series: blue", "#1F77B4", GRAPHIC_MIN),
("series: orange", "#FF7F0E", GRAPHIC_MIN),
("series: navy", "#1B2A49", GRAPHIC_MIN),
]
def linearize(value: int) -> float:
s = value / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
def luminance(color: str) -> float:
r, g, b = (linearize(int(color[i:i + 2], 16)) for i in (1, 3, 5))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast(a: str, b: str) -> float:
high, low = sorted((luminance(a), luminance(b)), reverse=True)
return (high + 0.05) / (low + 0.05)
# Published reference values: black/white is 21:1 and #777777 on white is 4.48:1.
assert round(contrast("#000000", "#FFFFFF"), 2) == 21.0
assert round(contrast("#777777", "#FFFFFF"), 2) == 4.48
assert contrast("#FF7F0E", "#121212") == contrast("#121212", "#FF7F0E")
failures: dict[str, list[str]] = {plate: [] for plate in PLATES}
plate_headers = "".join(f"{f'{p} {c}':>20}" for p, c in PLATES.items())
print(f"{'part':<16}{'colour':<9}{'lum':>6}{'min':>6}{plate_headers}")
for name, color, minimum in PARTS:
cells = ""
for plate, background in PLATES.items():
ratio = contrast(color, background)
passed = ratio >= minimum
if not passed:
failures[plate].append(name)
cells += f"{f'{ratio:.2f}:1 ' + ('pass' if passed else 'FAIL'):>20}"
print(f"{name:<16}{color:<9}{luminance(color):>6.3f}{minimum:>6.1f}{cells}")
print()
for plate, names in failures.items():
print(f"Fails on {plate}: {', '.join(names) or 'none'}")
fail_sets = [set(names) for names in failures.values()]
both = [name for name, _, _ in PARTS if all(name in s for s in fail_sets)]
clean = [name for name, _, _ in PARTS if not any(name in s for s in fail_sets)]
print(f"Fails on both: {', '.join(both) or 'none'}")
print(f"Passes on both: {', '.join(clean) or 'none'}")
The comparison uses the unrounded ratio, so a value that prints as 4.50:1 can still fail if it is really 4.496.
The results
part colour lum min dark #121212 light #FAFAFA
title #222222 0.016 4.5 1.18:1 FAIL 15.24:1 pass
axis labels #767676 0.181 4.5 4.12:1 FAIL 4.35:1 FAIL
gridlines #DDDDDD 0.723 3.0 13.79:1 pass 1.30:1 FAIL
series: blue #1F77B4 0.168 3.0 3.89:1 pass 4.62:1 pass
series: orange #FF7F0E 0.365 3.0 7.40:1 pass 2.43:1 FAIL
series: navy #1B2A49 0.024 3.0 1.31:1 FAIL 13.65:1 pass
Fails on dark: title, axis labels, series: navy
Fails on light: axis labels, gridlines, series: orange
Fails on both: axis labels
Passes on both: series: blue
Three parts fail on each plate, but only the axis labels appear on both lists:
- Fails on dark: title, axis labels and navy series. The title and navy series are too close in luminance to the dark plate.
- Fails on light: axis labels, gridlines and orange series. Gridlines score lowest on this plate at 1.30:1.
- Fails on both: axis labels only, at 4.12:1 and 4.35:1.
- Passes on both: blue series only.
Why the blue series passes and the labels don't
Blue and the axis labels have similar luminance, 0.168 and 0.181. On the dark plate, blue actually scores lower (3.89:1 against 4.12:1) but still passes because it is held to 3.0, not 4.5. On the light plate, blue's 4.62:1 clears either threshold, while the labels miss 4.5 at 4.35:1. Normal-size blue text in that colour would fail on dark, so its dark-plate pass applies only to its role as a series.
Why no single colour can pass on both plates as normal-size text
Multiply a part's two ratios: 4.12 × 4.35 is about 17.9, and blue's 3.89 × 4.62 lands in the same place allowing for rounding. This isn't a coincidence. For a colour with luminance between the plates, its own L + 0.05 cancels, leaving the contrast between the plates themselves.
The best any colour can do is split that evenly, giving the square root of 17.9, or about 4.2:1. That is below 4.5, and colours outside the plates' luminance range do worse, so no single colour can meet the normal-size text threshold on this plate pair. The program doesn't test this directly. It follows from the formula and applies only to these two plates, because the product depends on the plates.
With these plates, the practical fix is to give text a separate colour for each plate. Series colours do have a shared range at 3.0, because 3.0 × 3.0 is well under 17.9, and blue falls inside it.
What the checks cover
The program has three assertions:
- Black on white: this must round to 21.0, which is the published value.
- **
#777777on white:** this must round to 4.48, also a published value. It exercises the mid-tone path through the power curve. - Argument order: swapping the two colours must give the same ratio. This documents symmetry, since the first two assertions already catch a removed
sortedcall, and it says nothing about the luminance maths.
These assertions check the formula only in part. The two published values use greys, whose equal channels can't reveal swapped channel weights, or wrong ones that still sum to 1. The assertions also don't test the verdict on any part. If you assign #DDDDDD the wrong threshold, nothing will complain.
Where it bites
- Off-white plates change results. The labels fail at 4.35:1 on
#FAFAFA, but on pure white the formula gives 1.05 / 0.231, a little above 4.5. That is hand arithmetic from thelumcolumn, not a case the program runs. Test the real plate colour, not an approximation. - Assigning thresholds is your decision. The program treats gridlines as required graphics, but SC 1.4.11 covers graphics needed to understand the content. If yours are decorative, the 1.30:1 result may not count as a failure.
- Text size is ignored. Every text part is held to 4.5, including any large enough to qualify for the lower large-text threshold.
- Input is limited. The parser expects
#RRGGBB, so it doesn't handle transparency.
Where this stops being enough
This check only compares each part with the plate. It doesn't compare series with each other, so two lines could both pass and still be hard to tell apart. It also misses labels placed on bars or filled areas, where the real background isn't the plate.
Because the ratio uses luminance alone, it can't tell you whether hues remain distinct under colour vision deficiency. It also knows nothing about line weight, marker size or antialiasing, all of which affect how visible a mark is. Use the failure lists as a first filter, then look at the rendered figure on both plates.
Disclosure: I'm the founder of Homingo; Homingo sells this skill, and this article was written with AI assistance, with the code run before publishing. This is one small idea behind what we package in Isometric Explainers, our Claude Code skill: Claude Code draws how-it-works machines for your docs, blogs and landing pages, with live readouts and built-in layout, motion and geometry checks, and one command turns the same figure into a 1080x1920 explainer reel. Skill Rs 1,499 (about US$17), Complete Kit Rs 2,499 (about US$28; adds 3 more machine reel projects, the finished demo reel, 4 video lessons and a written tutorial), one-time, prices in INR, international cards accepted; it needs Claude access with Claude Code and Node.js 20.10+, Claude plans not included: Isometric Explainers. Everything above works without it.
Comments
Post a Comment