Skip to content

← All use cases

Colour contrast across a marketing site and an authenticated application

One design token, eighteen failing nodes, and the landing-page CTA

What makes it hard

Thirty-three of forty accessibility tests failed. That reads like a module in trouble; it was two design tokens.

The brand primary was below the 4.5:1 floor for normal text in every single usage:

  • white on the primary, which is the signup call to action on the landing page, at 14px: 2.83:1
  • badge text on its own tint: 2.50:1
  • link text on a near-white card: 2.78:1

Two muted text steps on the dark hero failed as well, at 4.48:1 and 3.15:1 — one of them marginal enough that it would pass on a rounding change and fail again on the next.

Eighteen failing nodes on the landing page alone, and all eighteen were repetitions of those five combinations across repeated cards. The primary call to action on the highest-traffic page in the product was the worst offender.

The reporting shape is the lesson. Thirty-three failures across five feature files looks like thirty-three defects and gets triaged as a suite problem. Every one of them resolved to a single custom property.

How to cover it

Be exact about what SDODS gives you here, because the answer is less than people expect.

  • @a11y is a real scenario tag from the optional set.
  • gates.a11y is a real field on a process in sdods.project.yaml.
  • Neither is implemented. No shipped step runs axe-core, and the gate is schema-only — it is declared and inert. The same is true of the performance budgets (pageLoadMs, lcpMs, apiP95Ms): they validate, they are stored, and nothing enforces them.

So an accessibility module today is carried by project-local steps: load axe-core in a page object, expose one Gherkin phrase of your own, and tag the scenarios @a11y so they are selectable even though the tag does not yet enable anything.

What the failure shape teaches, which is the transferable part:

  • Report by token, not by node. Eighteen nodes that resolve to one custom property is one fix. Have the step group violations by the resolved colour pair before it reports, and the run tells you there are two problems rather than thirty-three.
  • A visual baseline will not catch this. the page should match the visual baseline {string} compares against a screenshot that was captured with the failing colour in it. It is happy forever. Contrast is a computed property, not a pixel diff — this is one of the clearest cases where @visual and @a11y are genuinely different tests.
  • Push the check left. A contrast assertion belongs in the design-token pipeline, where a failing pair cannot merge. The suite should be the second line, catching a token used in a combination nobody modelled.
  • Root-cause the halves separately. The public surfaces were root-caused to the token; the authenticated and admin surfaces, which were the larger half, had not been. Keyboard order, focus visibility and landmark semantics are separate assertions with separate causes, and folding them into one number hides that.

Scenario sketch

gherkin
@ui @regression @a11y
Scenario: The landing page has no serious contrast violations
  Given I navigate to the "landing" page
  Then the page should have no contrast violations below 4.5

@ui @regression @a11y
Scenario: The primary call to action meets the normal-text threshold
  Given I navigate to the "landing" page
  Then the element with test id "hero-cta-signup" should meet a contrast ratio of 4.5

Both Then steps are project-local. They are not in @sdods/core/steps and nothing in the shared library will resolve them — write them in your project's steps directory over axe-core, and phrase them in the same style as the shared steps so the feature file reads as one library.

Until gates.a11y is implemented, gate on the module in CI instead: sdods run -p <slug> -e staging -m a11y -t @regression and let the exit code do the work.

Other use cases