Accessibility · Perception
Checking Contrast Against What Renders, Not What Is Declared
Computed styles, compositing, canvas readback, and the sampling decisions that decide whether a measured ratio corresponds to anything a reader sees.
The hardest part of contrast auditing is not the arithmetic. The formula is a dozen lines and has been stable for two decades. The hard part is obtaining the two colors that the reader’s eye actually receives, in the state the reader actually sees them, and knowing which of the many pairs on a page are the ones that need checking. Tools that skip that step produce numbers that are internally consistent and externally meaningless.
Computed style is a starting point, not an answer
getComputedStyle returns the resolved value of a property after the cascade, inheritance, and relative-unit resolution. For color this is usually the used color, so reading it gives the text’s ink after variables and media queries have been applied. For background-color it gives the element’s own background, which is frequently transparent, and that is where the naive approach breaks: the visible backdrop is whatever ancestor paints behind the element, possibly through several transparent layers, possibly an image, possibly a gradient.
Walking up the ancestor chain until an opaque background is found is the standard reconstruction, and it fails in four ways. It ignores background images and gradients, which paint over the background color. It ignores that the ancestor’s background may itself be partially transparent, requiring repeated compositing rather than a single lookup. It ignores elements that paint in a different order than the DOM suggests, such as positioned elements with negative z-index or fixed-position overlays. And it ignores pseudo-elements, which can paint a background over the parent’s.
Each of these is a real page pattern. A card with a translucent white background over a page with a subtle gradient produces a backdrop that no single computed value describes. The reconstruction has to composite the chain, and compositing has to happen in the space the browser uses, which for legacy sRGB content is the encoded channel space rather than linear light.
Compositing order matters more than the arithmetic
The compositing equation for a source-over blend in encoded space is the standard one: the result channel is the source channel times its alpha plus the destination channel times the destination alpha times one minus the source alpha. The detail that catches implementations is that alpha itself accumulates, and that a chain of three translucent layers is not equivalent to a single layer with the product of the alphas when the layers have different colors.
A concrete case: a 50 percent white panel over a 50 percent black panel over a white page. The black layer takes the page to an encoded value of 0.5, and the white layer then lifts it to 0.75. A model that treats two half-opaque layers as a 50/50 mix of black and white predicts 0.5, an error large enough to flip a verdict at the margin, because 0.75 linearises to about 0.52 while 0.5 linearises to about 0.21.
Backdrop filters add a readback step that the cascade does not describe. A backdrop-filter: blur() element samples what has already been painted behind it, filters it, and composites the result. The backdrop is therefore a function of the rendered output rather than of any computed property, and no amount of style inspection will produce it. The only correct source is the rendered pixels.
Reading rendered pixels
The rendered result is available through two routes. A screenshot of the viewport gives the composited output exactly as the compositor produced it, at the device pixel ratio in use, and is the closest thing to ground truth that a test can obtain without a colorimeter. The canvas API gives the same pixels for a region the page has drawn itself, and it is the route used when the measurement has to run inside the page.
For a screenshot, the measurement has three decisions. The first is the device pixel ratio, because a screenshot at 2× contains four times the samples and the glyph edges are resolved differently than at 1×; a ratio computed on a supersampled capture is not the ratio a 1× viewer sees, and both are legitimate as long as the report says which. The second is the region: the ink area of the glyphs, not the glyph box, and not the antialiased edges, since including edges drives the minimum toward 1:1 for any text. The third is the statistic: the minimum over the region for the worst-case reading, a low percentile for the typical reading, and both reported rather than one.
from collections import Counter
def backdrop_mode(screenshot, glyph_mask, ring_mask):
"""screenshot: HxWx3 array. Returns the most common colour in the ring."""
counts = Counter(map(tuple, screenshot[ring_mask & ~glyph_mask]))
return counts.most_common(1)[0][0]
The modal color of the ring around the glyph is the right summary for a uniform backdrop and a poor one for a photograph, where the worst pixel behind the text is the relevant quantity. The same code with a minimum over the samples answers the other question. Choosing which to report is a decision about what claim is being made, and both claims are useful.
Canvas readback requires the page to have drawn into a canvas it controls, or to have used drawImage with a same-origin image. A cross-origin image taints the canvas and makes getImageData throw, which is a hard boundary rather than a nuisance: an audit running inside a page cannot read pixels from a third-party image without cooperation. The workaround is to do the measurement from a screenshot taken by the automation driver, which is not subject to the same restriction.
Which pairs to check at all
A page of any size has more color pairs than can be enumerated, and most of them are not text. The filtering is what makes an audit tractable, and the filter has to be based on what the user sees rather than on what the markup says.
Text nodes are the starting point, but they are not sufficient. Text rendered by ::before and ::after is invisible to a walker that only visits text nodes; icon fonts render glyphs from a font; an SVG text element is text; an image of text is text that no audit can measure. Each of these is a different case with a different answer, and the last one is a genuine failure that automation cannot flag because there is nothing to measure.
The state dimension is the other axis. A control has default, hover, focus, active, visited, selected, and disabled appearances, and the contrast that matters may be the one in a state that a static audit never renders. A button whose default label is 5:1 and whose disabled label is 2.4:1 is a violation of the same criterion if the disabled text is meant to be read, and the exemption for disabled controls is narrower than it is often taken to be. The audit that catches this has to drive the states, which means interaction rather than a single page load.
The size dimension is a third. The 4.5:1 threshold applies to text below the large-text boundary, and the boundary depends on computed font size and weight, which depends on the loaded font and on user styles. A page that renders at 18.66 px bold sits at the boundary by construction and will flip classification on a machine where the font fails to load and a fallback with different metrics is used. Reporting the computed size and weight alongside the ratio makes the classification auditable instead of implicit.
What the numbers can and cannot support
A reported ratio is a claim about two colors. It is not a claim about legibility, and the gap between the two is largest exactly where audits are used to settle disputes: near the threshold, at small sizes, in thin weights, and over textured backdrops. Perceptual models that account for weight and polarity narrow the gap for text but introduce their own parameter choices; a report that includes both metrics is more honest than one that picks the metric that passes.
There is also the question of what the audit is measuring against. Conformance is defined against the specification’s formula on the colors as rendered under the specified conditions, which include a default color scheme and no user overrides. A page that passes in the default scheme and fails in a user’s dark-mode preference, or that fails entirely under forced colors, is conformant and unusable. The only way to close that gap is to render under the alternative conditions as well, which is a second pass with a different emulated media state rather than a different formula.
The final limitation is that all of this measures a static frame. Text over video, text over an animated gradient, a hover state that transitions through a low-contrast intermediate — these have a distribution of states over time, and the audit that samples one frame is sampling one member of it. The defensible approach is to bound the state rather than to sample it: make the backdrop constant in the region where text is drawn, and the measurement becomes a measurement of the design rather than of a moment.
That is also where the audit’s output belongs. A number attached to a screenshot without the state, the pixel ratio, the region, and the metric named is not reproducible, and a claim that cannot be reproduced cannot settle anything. The machinery described in the luminance computation is the fixed part; everything else is context that the report has to carry.