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:
| 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.
--signwithout--byis 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.
| 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
precode 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
legallayout emitted anh1from the page title while a hero on the same page emitted another. Twoh1elements 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.