Contrast

Masking and contrast are the same axis pointing opposite ways, and only one end of it has ever been measured here. Every rung below is live. Read down until you cannot read them, and that is the answer nothing in this repository can supply.

What is already known, and it is only the attacker's half. The masking ratio is min(swing) / |text − background|, so high contrast means a large separation means a low ratio. Measured on stroke-width bars: at 4.21:1 a single captured frame is readable, at 2.26:1 it is marginal, at 1.72:1 it is masked. What nobody has established is where you stop being able to read it, and the answer decides whether a usable band exists at all.

The midpoint is held constant down the ladder so only the separation changes. Mid-grey has the most headroom in both directions, so it is the friendliest place for the split. Drag it toward either end and watch the masking column fall away even at separations that were fine before.

Colour, not just separation

The ladder above is grey, so it only tests how far apart the two tones are. Real designs have a hue, and hue matters for a reason that is not obvious: swing is per channel. A saturated colour has at least one channel near 0 or 255, that channel has no room to be displaced, and the pair cannot carry noise however far apart the two colours look.

Same separation in every cell below. Only hue and saturation change.

If the saturated row masks worse than the muted row at identical separation, that is the per-channel headroom showing. It is also why the shipped defaults are near-greys rather than a brand colour, and why fit desaturates when it has to.
Measured: at 4:1 this technique does not mask. We searched every grey pair around 4:1 for the one that masks best, and even the winner leaves a single frame giving up most of the value, where a pair nearer 2:1 gives up almost none. Nothing recovers this at 4:1, because the ratio is min(swing) / separation and 4:1 is a large separation. The ladder above measures it live if you want the numbers on your own pair.

That does not make 4:1 wrong, it makes it a different product. The anti-scraping half does not depend on the palette at all: the value is canvas pixels either way, so it is absent from the DOM, the accessibility tree, innerText and Select All whatever the contrast. Choose 4:1 and you keep every one of those absolutely, and give up the capture story. Choose 2:1 and you keep both, at a contrast below WCAG AA for body text.

Pick a pair

Text and background hue move independently here, because a coloured accent on a neutral ground is a real design and the solved row below only ever pairs a hue with itself. Every slider previews what it produces.

contrast … masking … one frame … pair …

Palettes at a contrast you choose

Pick a contrast and every hue below is solved to hit it, so you can compare colours rather than greys at the readability level you actually want. The lightness pair is searched per hue, because the same two lightnesses give different contrast at different hues.

Watch the masking column as you raise the target. Around 4:1 it sits near 0.45, which means a single captured frame is giving up noticeably more than it does at 2:1. That is not a bug in the palette, it is the trade: the same separation that makes it comfortable for you makes it recoverable for a capture. If you want 4:1, the honest position is that this is a scraping defence with a weak capture story rather than both at once.

What to look for

A rung can be masked and unreadable, which is useless, or readable and leaking, which is worse than useless because it looks protected. The interesting rung is the last one you can still read. Everything below it is where this technique actually lives.