Meeting AAA, and proving it

Automated tools cover a minority of WCAG at AAA. axe-core, run with every rule tag enabled, evaluates a handful of AAA criteria and nothing at all for most of the rest. A build that passes axe and then claims AAA is claiming something it did not check.

So the program has three tiers, and each criterion is assigned to exactly one.

Structural. Met by construction, and tested once against the engine rather than on every site. The design system holds 7:1 across every token pairing, the layouts emit the landmarks and the skip link, and the closed component set has no way to produce the markup that would break the criterion.

Automated. Checked on every build, on every page, in both colour schemes, at 320px, 768px, and 1280px. A failure blocks the deploy.

Attested. A person judges it and records the judgment in .cms/conformance.yml. The record names the criterion, the pages it covers, the evidence, who signed it, and when. An attestation expires when content it covers changes in a relevant way, which is what keeps the ledger from becoming a one-time formality.

Shipping and claiming are different acts

A site with unreviewed criteria can ship. It simply has not earned the claim yet, and its generated statement says so in the first sentence: built to WCAG 2.2 at level AAA and does not yet claim conformance.

Declaring claim: AAA in site.yml publishes the claim, and from that moment an unsigned review is a false statement, so the gate blocks on it.

conformance:
  target: AAA
  # claim: AAA   # uncomment to publish the claim

Getting this wrong in either direction is bad. Block by default and a fresh site is unshippable until someone does sixteen reviews, which teaches people to switch the check off. Never block and the claim means nothing. Separating the two lets the check be strict about the thing that matters.

The ledger

# .cms/conformance.yml
standard: WCAG 2.2
target: AAA

criteria:
  1.4.6:
    tier: structural
    evidence: >
      Every token pairing in base.css v2 is measured against the least
      favourable ground it can land on. Verified by the engine's token test
      and by the axe color-contrast-enhanced rule on every built page.

  1.4.9:
    tier: attested
    scope: media with contains_text
    attested_by: tyler@fivepaths.com
    attested_on: 2026-08-19
    evidence: >
      Three images carry text. All three are screenshots of the product,
      which 1.4.9 permits as they are a picture of a user interface rather
      than text presented as an image. Alt text carries the same information.
    invalidated_by: [media.add, media.replace, block.shots]

  3.1.5:
    tier: attested
    scope: all pages
    attested_by: tyler@fivepaths.com
    attested_on: 2026-08-19
    evidence: >
      Estimated reading level on /developers/ and /api/ exceeds lower
      secondary. Both carry a plain block supplying the plain-language
      version.
    invalidated_by: [block.prose, block.plain]

  3.3.6:
    tier: not-applicable
    evidence: The site submits no data. Contact is a mailto link.
    invalidated_by: [block.form, component.form]

cms check fails when a criterion has no entry, when an attestation has been invalidated by a change since it was signed, or when a not-applicable entry's trigger appears (a form block on a site that declared it has no forms).

cms conformance prints what needs re-attesting and why, which is the report the agent hands a person before a release.

The ledger, as built

All 86 WCAG 2.2 criteria are tracked, not only the 31 at AAA: conformance at AAA requires meeting every A and AA criterion too, and a ledger listing only the AAA ones claims something it has not checked. WCAG 2.2 removed 4.1.1 Parsing, so 86 is the current count.

The engine ships a default tier for each, sized for a static content site:

The ledger, as built
Tier Count Meaning
structural 23 Met by construction; tested against the engine, not per site
automated 22 Checked on every build
attested 16 A person judged it and signed
not applicable 25 Does not arise here, with a reason recorded

A site overrides any of them, and has to: n/a stops being true the moment that site adds a form or a video, and the engine detects exactly that.

cms conformance                          the summary and what needs attention
cms conformance --update                 add the engine defaults to the ledger
cms conformance --sign 3.1.5 --by <name> record a review

Three things the ledger enforces, each verified:

  • A signature needs a person. --sign without --by is refused, because a signature is a claim that someone looked.
  • Only attested criteria can be signed. Signing an automated one is refused: the build already verifies it.
  • A signature expires. It records a hash of the content in its scope, and a later edit to that content invalidates it. Same mechanism as a translation record and a site-context record, which is three uses of one idea.

The generated statement page is bound like any other generator, so it validates and renders through the same path as a hand-written page. It reports 86 criteria, and when reviews are outstanding it names them and withholds the claim. Nobody writes it, so it cannot describe a state the site is not in.

What is built, as of now

cms check runs the static checks in about 0.2 seconds and the full pass, with axe in a real browser across two colour schemes at 320, 768, and 1280 pixels, in about 8 seconds for three pages.

The number that motivates the whole three-tier design is concrete: axe-core 4.13 ships 105 rules, and 3 of them are tagged AAA (color-contrast-enhanced, identical-links-same-purpose, meta-refresh-no-exceptions). Everything else at that level is judgment or measurement.

What is built, as of now
Criterion Tier How
1.1.1 Non-text Content automated Alt required at build time from the media sidecar; a missing attribute fails
1.3.1 Info and Relationships automated Table caption and `th` required; heading tree checked
1.4.6 Contrast (Enhanced) automated axe `color-contrast-enhanced`, both schemes
2.3.3 Animation from Interactions automated Stylesheets parsed for motion without a reduced-motion block
2.4.6 Headings and Labels automated Exactly one `h1`, no skipped levels, no empty headings
2.4.8 Location automated One `aria-current="page"` per page
2.4.9 Link Purpose (Link Only) automated Context-free link text blocked; same name to different targets blocked
2.4.12 Focus Not Obscured (Enhanced) automated Every focusable element focused and tested against fixed and sticky elements
2.5.5 Target Size (Enhanced) automated Rendered rectangles measured after wrapping, with the in-sentence exception
3.1.1 Language of Page automated `lang` present
3.2.5 Change on Request automated No meta refresh; a new tab must announce itself
1.4.9 Images of Text attested The `contains_text` sidecar flag drives the attestation
3.1.5 Reading Level attested Advisory estimate, plus the `plain` block
2.3.2 Three Flashes attested Frame analysis is out of scope; a person signs
1.2.x Time-based Media not applicable No video component exists yet, by design. See below

Not yet built: 2.4.13 Focus Appearance, 3.1.3 and 3.1.4 (the glossary first-use check), and the conformance ledger itself, which is M9.

The check that was checking nothing

Worth recording above the others, because it invalidated results rather than producing them. The browser pass loaded each page as file:///…/public/x.html. A stylesheet linked as /assets/site.90919f496303.css resolves, under file://, to the filesystem root. Only the CDN's base.css has an absolute URL, so it was the only one that ever loaded.

Every layout measurement between M3 and M6 therefore described an unstyled page. Target size, focus-not-obscured, and axe's contrast rules were all running against markup missing two of its three stylesheets, and 40 of the 83 errors on gtfs.media were artifacts of it.

The check now serves the built site over HTTP for its own duration, and asserts that every linked stylesheet actually loaded before it measures anything. A check that silently measures the wrong thing is worse than no check, because it reports a number people believe.

Four defects these checks found on their first run

Worth recording, because they are the argument for the gate existing:

  • The shared footer's FivePaths link measured 99x25, under the 44px target. The byline is a sentence, so the correct fix was marking it up as one, which also made the WCAG inline exception apply for the right reason.
  • Every pre code block was a scrollable region with no keyboard access. A keyboard user could not reach the part of a sample that ran off-screen.
  • The legal layout emitted an h1 from the page title while a hero on the same page emitted another. Two h1 elements on both legal pages.
  • Markdown images in prose bypassed the media pipeline entirely: no dimensions, no srcset, no alt validation. They now resolve through it like any other.

What is checked automatically

Beyond the axe rule set, these are engine checks worth writing because they catch the failures this design system actually produces.

Focus not obscured (2.4.12). The sticky header is the risk. The check tabs through every focusable element on every page at all three widths and asserts the focused element's rectangle is fully inside the viewport and not underneath the header or any other fixed element. scroll-padding-top fixes anchor navigation and does nothing for tabbing, so this needs testing rather than assuming.

Target size (2.5.5). Measure the rendered rectangle of every focusable element and assert 44 by 44 CSS pixels, or an exception the criterion allows. Measured, because a control can meet the rule in the stylesheet and miss it once real text wraps.

Focus appearance (2.4.13). Compute the focus indicator's area against the perimeter of the element it surrounds, and its contrast against both the adjacent colours. The 3px teal outline offset 3px passes; the check is there so a site override cannot quietly break it.

Reduced motion coverage (2.3.3). Every animation and transition reachable from the site's CSS must have a prefers-reduced-motion: reduce fallback. The check parses the stylesheets rather than the DOM, so it catches an animation added to site.css on the day it lands.

Link purpose (2.4.9). Two rules. No link text drawn from a blocklist of context-free phrases. No two links with the same accessible name pointing at different URLs on the same page, which is axe's identical-links-same-purpose plus the site's own affordance text.

Unusual words and abbreviations (3.1.3, 3.1.4). The site keeps a term list in src/data/glossary.yml. The check asserts that the first use of a listed term on a page either links to the glossary entry or is wrapped in abbr with an expansion. gtfs.media already has the glossary page; this connects it to the prose.

Change on request (3.2.5). No meta refresh, no script-initiated navigation, and every link that opens a new tab carrying the visually-hidden note the design system defines.

Location (2.4.8). The nav emits aria-current="page" from nav.yml and the page's nav frontmatter field. The check asserts exactly one match per page.

Images. Alt text present or an explicit decorative flag; alt not equal to the filename or a variation of it; width and height on every image so nothing shifts on load.

Media, if it ever appears

The AAA criteria for time-based media (1.2.6 through 1.2.9) are the most expensive on the list: sign language interpretation, extended audio description, and a full text alternative. None of the current sites have video.

The engine handles this by making the cost visible at authoring time rather than at audit time. There is no generic video block. A video component, when one is added, will require captions, a transcript, and an audio description track as schema fields, all required. A page cannot ship a video that fails those criteria, because it cannot ship a video without them.

The published statement

cms build generates an accessibility statement page from the ledger: the standard claimed, the date of the last full check, the criteria met and how, and any known exceptions with their remediation dates. It is generated, so it cannot describe a state the site is not in.

It is also what closes the gap this document opens with. The claim becomes "AAA, with these criteria automated, these attested on this date, and these not applicable for this reason", which is a claim the repo can support.

Per locale

The ledger has a locale axis. Reading level (3.1.5) has to be judged in each language by someone who reads it, and an attestation signed against the English prose says nothing about the Spanish. Language of parts (3.1.2) needs the inline field type that carries a lang. Target size and measure are checked per locale too, because text expansion moves both.

A locale with no ledger entries fails the check rather than inheriting the default locale's. See TRANSLATION.md.

Testing the engine, not just the sites

Structural criteria are only structural if the engine is tested. The engine's own suite renders a fixture site exercising every component in every band, in both colour schemes, and runs the full check against it. A component added without its accessibility rules fails that suite before it reaches a site.

Next

Continue with media.