Applies to
design.not-colour-alone, design.contrast-in-mockup.
Covers design.contrast-in-mockup, design.not-colour-alone. Related: a11y.contrast,
a11y.focus-visible, design.state-set.
Contrast and colour-alone are both settled by opening the design file rather than the repository, and both are about a decision that is nearly free in the kit and expensive at every stage after it.
Contrast has a blocker twin in the front-end volume, a11y.contrast, that measures the same pairs
in the browser: fixed in the kit a failing pair costs one variable, found in the browser it costs a
conversation with the client about their brand. Note which of the two design rules is the blocker.
It is the Level A one, not the AA one.
Which ratio applies to what
| What | Ratio | Where it comes from |
|---|---|---|
| Body text | 4.5:1 | WCAG 1.4.3 Contrast (Minimum), AA |
| Text at 24px, or 19px bold, and above | 3:1 | 1.4.3's own Large Text exception |
| Interface components: borders, control edges, meaningful icons | 3:1 | 1.4.11 Non-text Contrast, AA |
| Focus indicators | 3:1 | a11y.focus-visible owns it: 2.4.7 asks for an indicator, 1.4.11 supplies the ratio |
Two honest notes, both already recorded against the rule's citation. The pixel figures are ours. WCAG says 18 point, or 14 point bold. At 96dpi that is 24px and 18.67px, and this standard rounded the second up to 19px, so bold text at 18.7px gets the 3:1 allowance under WCAG and does not get it here. Proving it in the kit is ours too. WCAG binds delivered web content; it has nothing to say about a design file, a plugin report or a date on a screenshot. That mechanism is this standard's addition, like 99 of its 100 citations.
Then a definition rather than a note: a colour has no contrast ratio. A pair does, and that is where measurement usually goes wrong. One delivered build ships a global focus ring at 1.48:1 against the page background while clearing 10:1 on that project's dark surfaces, so it had plainly been checked against one of the two backgrounds it lands on. A token carrying alpha has no fixed ratio at all: another theme's body colour is a 60 percent overlay, and its 4.36:1 is the ratio against the cream behind it, not a property of the token.
The state that fails is the one nobody screenshots
A live project, August 2026. Primary button, straight from the client's published kit:
| State | Label on fill | Verdict |
|---|---|---|
| Rest | 3.17:1 | under the 4.5:1 its label needs |
| Hover | 2.91:1 | under even the 3:1 floor for interface components |
| Pressed | 2.64:1 | worse again |
The label is light and both interaction fills lighten. So contrast drops exactly when someone is using the control, and the resting state - the one in the mockup, the one in the screenshot, the best of the three - already fails. Five surfaces in that palette came in under the bar: 3.17, 2.91, 2.64, 3.45 and 1.39.
The fix is one polarity decision, not three measurements. Choose the label colour so the interaction ramp moves contrast the safe way:
- Light label, so the fill has to darken on hover and darken again on press.
- Dark label, so the fill can lighten.
Get that right once and the whole state set is safe by construction. Get it wrong and every state is a separate defect. On that project a near-black brand navy as the label measured 5.82:1 on the same fill and made the ramp run the right way in a single change.
This is also why design.state-set and this rule get done together. Hover, pressed, disabled and
loading live on component variants that are often not placed on any frame, and a contrast plugin
reads what is on the canvas. Sweep the variants, not the frames, or you measure the one state that
was never in doubt.
Why it stops being a bug and becomes a negotiation
That 5.82:1 fix was written, measured, and reverted.
Not because it was wrong. Because by then it was not a code change. Changing how a client's primary button looks is the agency's call, so the developer's real options were to ship a known failure or repaint a brand nobody had asked him to repaint. It went back to the agency on 2026-08-24, and that project's record still reads FAIL on both rules. The build-side one is a blocker, and a blocker cannot be waived.
The severity split is deliberate and it is a cost table: design.contrast-in-mockup is required,
owned by the agency, at the design stage; a11y.contrast is a blocker, owned by the build, at
setup. So the blocker sits with the person who is not allowed to fix it. After handoff the only
cheap move left is measurement, and measurement does not close the finding: that project pinned all
three ratios in its own test file so the suite goes red the moment the palette moves, and it is
still failing.
Which is why the acceptance test asks for 0 pairs left unmeasured, not 0 pairs passing. Measuring is what the design stage owes. A measured failure the client accepts is a decision somebody made; an unmeasured pair is nobody's decision at all.
The build side does report it back. wp-starter/design-tokens runs npm run design:lint over
every component foreground and background pair, so the same failing pairs return from the developer
within days of handoff. One caveat if you want it as a gate: it emits contrast findings as
warnings, so the command exits 0 with the failures printed. A rule that wants it to stop a build
has to ask for 0 warnings, not exit 0.
Colour alone breaks for more people than you are picturing
1.4.1 says one sentence and nothing else, at Level A, below the AA bar the rest of this standard targets:
Color is not used as the only visual means of conveying information, indicating an action, prompting a response, or distinguishing a visual element.
Its only note says it addresses colour perception specifically. The greyscale method, the underlines, the status shapes, the chart patterns, the 0-distinctions count and the dated export are all ours. Colour vision deficiency is the case everyone names when arguing about this rule and it is not the one that decides it on a real site. These are: a phone in direct sunlight; a cheap panel or a projector in a lit room; dark mode, where a palette tuned in light inverts and the hue relationships do not survive; forced colours and high contrast modes, which replace the palette outright; a printed or photocopied page; and any surface where the CMS renders the content and the colour was never applied at all.
The greyscale filter is a proxy chosen because it cannot give a false pass. If two things are still tellable apart with hue removed, they differ in luminance, and luminance survives every kind of colour vision deficiency. The reverse does not hold, so greyscale can fail something a particular viewer would have managed. That is the direction to be wrong in.
What both delivered builds actually did
Verdict on both rules, in both reference builds: aspirational. Either could have done it and neither did. Both resets kill link decoration globally, in roughly this form:
a { color: inherit; text-decoration: none; }
Both then restore it properly on legal pages and in footers. Neither restores it on an
editor-controlled rich-text surface: searching one theme's FAQ block stylesheet for an a rule or
for text-decoration returns zero matches. So a link an editor pastes into a WYSIWYG field is not
conveyed by colour alone. It is conveyed by nothing, because color: inherit took the hue away as
well. 1.4.1 at least assumes you kept the colour.
Everything else passed in both builds: required markers are words, the active navigation item carries weight or a fill, and neither project had a chart. The single failure is the single place where link styling is written per surface rather than once per element, which is the shape of this defect generally: the rule is satisfied everywhere the designer drew a link and broken everywhere the client will make one. Neither repository holds a contrast measurement either - one hit for "wcag" across both trees, about tap targets, and no axe, pa11y or plugin output anywhere - while one ships 16px body text at 4.36:1 and that 1.48:1 focus ring on every page.
The exports that settle both rules
design.not-colour-alone is settled by a dated greyscale export and design.contrast-in-mockup by
a dated plugin report over the kit, and the mistake shared by both is exporting the resting state
only. A link that underlines on hover is colour-alone at rest, which is the state it spends its
life in, and the same goes for the active tab, the required marker and the status dot. Export the
states, keep the date, and hand both files over with the kit: they are the only evidence that the
question was asked while it was still cheap to answer.