a11y-evidence
v0.1.1
Published
Turn your accessibility test results into legal evidence — EAA Annex V, EN 301 549, CI baseline
Maintainers
Readme
a11y-evidence
Turn your accessibility test results into legal evidence.
axe-core runs about 69 million times a week. Detecting accessibility defects
is a solved problem. It produces JSON that says "43 violations, rule
color-contrast, selector .btn-primary".
That is not what an auditor, a lawyer or a public buyer asks for. They ask:
- since when do you test, and how often?
- what have you fixed, when, and by whom?
- what are you choosing not to fix, and why?
- how do you stop a regression reaching production?
No npm package makes that translation. a11y-evidence does.
npx a11y-evidence init
npx a11y-evidence scan
npx a11y-evidence lock --update
npx a11y-evidence report --oralThis tool produces documentation from your own test results. It does not certify compliance with any law or standard, and it is not legal advice.
The document you need is probably not the one you found
This is the mistake this package exists to stop.
| | Annex V information | Public sector statement | |---|---|---| | Law | Directive (EU) 2019/882 — the European Accessibility Act | Directive (EU) 2016/2102 | | Applies to | services on the market, including private ones | public sector bodies' websites and apps | | Model | none prescribed; content requirements only | prescribed model, Commission Implementing Decision (EU) 2018/1523 | | Where it lives | in the general terms or an equivalent document | published on the site | | Feedback mechanism | not required | required | | Link to the enforcement body | not required | required | | Written and oral form | required | not required |
The template that is easy to find online is the public sector one. Publishing it for a private service does not satisfy Annex V — and it commits you to a feedback and enforcement procedure that does not apply to you.
So a11y-evidence report generates the Annex V information, and it will refuse
to generate the public sector statement unless you tell it you are a public
sector body:
$ npx a11y-evidence report --public-sector
a11y-evidence: refusing to generate a public sector accessibility statement.
The public sector statement follows the model in Commission Implementing
Decision (EU) 2018/1523, under Directive (EU) 2016/2102. It applies to public
sector bodies, and it commits you to a feedback mechanism and to a national
enforcement procedure.
For a service on the market, the document you need is the Annex V accessibility
information under Directive (EU) 2019/882 — which is what `a11y-evidence report`
generates by default. Publishing the public sector model instead does not
satisfy Annex V.The three commands
scan
Orchestrates the engines you already use and normalises what they emit. It does not reimplement any analysis — it is a layer above axe-core, pa11y and Lighthouse.
Runs are written to .a11y/runs/<timestamp>-<sha>.json in a format that does not
depend on the engine, and they are meant to be committed. The history is the
evidence.
lock
The core of the product: a baseline in .a11y/baseline.json, versioned in your
repository, where every known violation is qualified by a person:
{
"id": "3c1334f1d668",
"ruleId": "color-contrast",
"url": "https://example.com/pricing",
"normalisedSelector": ".pricing-card > p",
"wcagCriteria": ["1.4.3"],
"en301549Clauses": ["9.1.4.3"],
"status": "waiver",
"justification": "Third-party pricing widget; vendor ticket ACME-4471",
"owner": "design-system",
"dueDate": "2026-06-30",
"firstSeen": "2026-01-05T09:00:00.000Z",
"lastSeen": "2026-02-10T09:00:00.000Z"
}In CI, the exit code is non-zero only if a violation is new, a waiver has passed its due date, or a waiver has no justification. Never for the existing stock. That is the condition for adoption: no team accepts a tool that breaks the build over four hundred inherited errors on the day they install it, and a tool that gets switched off produces no evidence at all.
Real output, adopting an existing codebase and then catching a regression:
$ a11y-evidence lock --update
a11y-evidence lock
6 open, 0 waived, 0 resolved
6 new, 0 newly resolved
baseline updated .a11y/baseline.json
6 finding(s) adopted into the baseline. Qualify each one
with a status, an owner and a due date before it counts as evidence.
No new violations and no expired waivers.
$ echo $?
0
# ... someone ships a button with no accessible name ...
$ a11y-evidence lock
a11y-evidence lock
7 open, 0 waived, 0 resolved
1 new, 0 newly resolved
new-violation New violation: button-name at #checkout on https://example.test/
Existing findings never fail the build. Only a new violation, an expired
waiver, or a waiver with no justification does.
$ echo $?
1Entry ids survive the churn of a real codebase. The selector is reduced to what
identifies the element on purpose — ids, data-testid, roles, element names —
and everything cosmetic is dropped, so a CSS-in-JS class that changes on every
build (css-1a2b3c → css-9z8y7x) does not make an entry look new.
report
Writes four artifacts into .a11y/reports/:
annex-v.md/annex-v.html— the text to put in your general terms, structured requirement by requirement and driven by the real state of the baseline. It will not write "we are committed to"; every line is a fact with a date, and there is a test that enforces that.en301549-report.html— the criterion-by-criterion table in the VPAT vocabulary (Supports / Partially supports / Does not support / Not evaluated), with the baseline entries that justify every answer that is not "Supports". This is the document a buyer asks for.remediation-log.md— the history: when you scanned, what appeared, what was fixed, mean and median time to fix. This is the proof over time that Annex V asks for, and it is the one thing a snapshot can never give you.annex-v-oral.txtwith--oral— the spoken script. Annex V explicitly requires the information in written and oral form, and no template on the market ships it. Short sentences, no tables, and the abbreviations a speech synthesiser mangles are spelled out:
There are 55 requirements at level A and level double A. 18 are met.
0 are partly met. 5 are not met. 32 have not been checked automatically,
and need a person to assess them.The HTML reports are themselves WCAG 2.2 AA: a language on the root, one h1, a
skip link, landmarks, table captions and scoped headers, a visible focus
indicator, and status never signalled by colour alone. It would be absurd
otherwise, and it is the first thing a prospect will check.
"Not evaluated" is a real answer
Automated testing detects a minority of WCAG failures. Of the 55 Level A and AA criteria in WCAG 2.2, the axe rule set covers 23.
A tool that tests 23 criteria and prints "Supports" for the other 32 is not producing evidence, it is producing a misrepresentation. So this one says so:
32 of 55 criteria are reported as "Not evaluated":
no automated rule covers them. They need manual assessment before any
conformance claim covers them.You can record a manual assessment for any criterion, and it appears in the report with your remarks. What you cannot do is have the tool claim support for something nobody checked — and recorded findings always outrank a manual claim of support.
The mapping table
src/mapping/ holds the correspondence from test rule to WCAG 2.2 success
criterion to EN 301 549 clause. It is the part of this project that matters most,
so none of it is written from memory:
- Rule → criterion is generated from axe-core's own rule metadata, which
tags every rule with the criteria it tests and, since 4.7, with the EN 301 549
clauses directly.
npm run generate:mappingregenerates it;test/mapping.test.tsfails if it drifts from the installed axe-core. - Criterion → clause is derived, not transcribed. Clause 9.x.y.z of EN 301 549 is WCAG success criterion x.y.z (EN 301 549 v3.2.1, clause 9 and Table A.1), so a second table that could drift from the first is not kept.
- pa11y codes carry the criterion in their own structure
(
WCAG2AA.Principle1.Guideline1_1.1_1_1.H37), so they are parsed rather than looked up — which means pa11y rules this tool has never seen still map.
$ a11y-evidence coverage
Mapping coverage (axe-core)
105 rules known
70 map to a success criterion
30 are best-practice advice, not conformance failures
5 unmapped
23 distinct criteria covered
unmapped: color-contrast-enhanced, duplicate-id, duplicate-id-active,
identical-links-same-purpose, meta-refresh-no-exceptionsNothing is silently dropped. A rule that maps nowhere is reported as unmapped,
and every exclusion carries its reason — those five are AAA criteria, or
4.1.1 Parsing, which WCAG 2.2 removed. Best-practice rules are kept out of the
baseline entirely: failing a build over advice would be wrong.
Configuration
npx a11y-evidence init scaffolds .a11y/config.json with comments:
{
"targets": [
"http://localhost:3000/",
{ "url": "http://localhost:3000/account", "name": "Account", "state": "authenticated" }
],
"engine": "axe", // or "pa11y", "lighthouse"
"viewport": { "width": 1280, "height": 800 },
"setup": "./a11y-setup.mjs", // optional: sign in first
"directory": ".a11y",
"service": {
"name": "Example Service",
"provider": "Example Ltd",
"contact": "[email protected]",
"publicSector": false
}
}playwright, pa11y and lighthouse are optional peer dependencies — you
install the one you use, and a missing one produces a sentence telling you what
to install rather than a stack trace.
CI
A composite GitHub Action ships in action.yml:
- uses: Jonathanlight/a11y-evidence@v0
with:
command: all
update-baseline: ${{ github.ref == 'refs/heads/main' }}
oral: 'true'GitLab CI: see .gitlab-ci.example.yml. Gate merge
requests against the committed baseline; let only the default branch move it.
What this does not do
- It does not fix accessibility, and it does not suggest fixes anywhere.
- It issues no certification of any kind.
- It invents no WCAG or EN correspondence: every mapping cites its source.
- There is no SaaS, no hosted dashboard, no account, and no telemetry. Everything is a file in your repository — which is also what makes it usable inside an enterprise.
Licence
MIT. See NOTICE for the sources every mapping is derived from.
