a11yfix
v0.1.0
Published
Accessibility linter that writes the patch, not just the report. WCAG 2.2 and EAA, from your source: JSX, Vue, Svelte, HTML, CSS. Zero dependencies.
Maintainers
Readme
A11yFix
Accessibility tools tell you what is broken. This one writes the patch.
npx a11yfix src --diff--- a/src/Card.tsx
+++ b/src/Card.tsx
@@ -1,9 +1,9 @@
export function Card({ title }: { title: string }) {
return (
<div className="bg-white p-6">
- <h2 className="text-gray-400 text-2xl font-bold">{title}</h2>
- <p className="text-gray-400">Supporting copy that is hard to read.</p>
- <span className="text-emerald-500">Status: active</span>
+ <h2 className="text-gray-500 text-2xl font-bold">{title}</h2>
+ <p className="text-gray-500">Supporting copy that is hard to read.</p>
+ <span className="text-emerald-700">Status: active</span>
<p className="text-gray-700">This passes and must be left alone.</p>
</div>
);That image is generated by running the command, not drawn — node scripts/gen-demo-svg.mjs,
and CI fails if it stops matching what the command prints.
npm install -D a11yfix # or run it with no install at all: npx a11yfix .Node 20 or newer. Zero runtime dependencies, by design — a tool you point at your source should not bring a dependency tree with it.
Why this exists
Every accessibility checker — axe, Lighthouse, WAVE, Pa11y — inspects the rendered DOM. It tells you that a node on a page fails a contrast ratio. Then a human works out which component produced that node, finds the file, finds the colour, decides what to change it to, and checks the change did not break the design.
That translation step is most of the work, and no tool does it.
If the question you are actually asking is "why not just run axe", that is the right question and it has its own page: Where A11yFix sits among the tools you already have. Short answer: it does not replace any of them. Run axe. Add this when you want the findings turned into a patch.
A11yFix reads your source, keeps byte-exact offsets for every tag and attribute, and emits a patch. It scans 3,300 files in 1.3 seconds, needs no browser, no network, no API key, and no runtime dependencies at all.
What it actually fixes
Contrast repair is the centrepiece, because it is the most common failure on the web and the only common one with an arithmetic answer rather than a judgement call.
Given a foreground and a background, A11yFix finds the smallest perceptual change that reaches the required ratio. It works in OKLCH: hue is held fixed, lightness moves by the minimum needed, and chroma is only reduced if the result would fall outside sRGB.
It also picks the right side of the pair to move. White text on a mid-tone button gets fixed by darkening the button, not by turning the label charcoal — a naive fixer gets this wrong about half the time.
In a Tailwind codebase it swaps the shade rather than injecting a hex literal, walking
the ramp until it finds a step that actually clears the threshold, and quotes the
ratio that step really reaches. It composites bg-black/10 against what is behind it,
resolves CSS custom properties and Tailwind v4 @theme tokens, and stops at a gradient
or a background image instead of pretending the colour underneath is what you see.
Contrast is three of the 70 rules. The rest cover images and media alternatives, form
labelling, document structure, keyboard operability, ARIA correctness, and link semantics.
59 of them name the WCAG success criterion they implement. The other 11 are things every
practitioner will tell you to do that WCAG has no criterion for — a page with no <main>,
an accesskey, a data table with no caption — and 10 of them are reported as warnings or
information rather than as errors. The eleventh, A11Y-TODO-001, is not about the page:
it reports a placeholder this tool itself wrote, and it is an error precisely so that an
unfinished fix cannot pass as a finished one. docs/coverage.md says
which rule is which.
One of those 11 is different in kind from the rest. A11Y-DOC-016 reports an accessibility
overlay — accessiBe, UserWay, AudioEye, or the bvi.js «версия для слабовидящих» switch
that is the usual answer to ГОСТ Р 52872-2019 — and says, as info and without asking
you to remove anything, what it does not change: an image with no alt has no alt at
any font size. On nine live Russian institutional sites, three carried a switch and all
three had images with no alternative text behind it. See the field
report.
What a run looks like
Every finding names who is affected, quotes the source, and says what the patch would do and what it would achieve.
demo/Card.tsx
4:7 error Text contrast is 2.54:1, below the required 3:1 (#9ca3af on #ffffff). WCAG 1.4.3
People with low vision, colour vision deficiency, or anyone reading on a phone
in sunlight cannot reliably make out this text.
<h2 className="text-gray-400 text-2xl font-bold">
<h2 className="text-gray-500 text-2xl font-bold">
fixable (review): Replace text-gray-400 with text-gray-500, reaching 4.83:1.
A11Y-COLOR-001
3 findings (3 errors, 0 warnings) across 1 file
0 fixable automatically, 3 fixable with review, 0 need a person.
Run with --fix to apply, or --diff to preview.Two of the three findings are cut from that block and the impact sentence is wrapped to the width of this page; everything else is what the command prints.
The second <h2> line is the finding's own answer, not a description of it — the line as
it would read after the patch, so the change can be checked by looking rather than by
applying. And that "reaching 4.83:1" is recomputed from the shade the patch actually
writes, not from the ideal colour the solver aimed at. It is a number you can check.
What it will never do
It does not invent alt text. It cannot see the image. A plausible-sounding wrong
description is worse than a missing one: it lies to a screen-reader user and makes
every downstream checker report the page as fixed. Same for link text, button labels, and
lang. Those are reported and never patched: the finding stays, and the exit code stays
1, until a person writes the words.
Where a fix does leave a placeholder for someone to complete, A11Y-TODO-001 reports the
placeholder itself as an error, so an unfinished fix cannot pass as a finished one.
It does not claim conformance. Automated testing reaches roughly a third of WCAG's success criteria. A clean run means the machine-checkable subset passes. Keyboard flows, focus order, meaningful alternative text and actual screen-reader behaviour still need a human. Every run prints this. See docs/coverage.md for the exact list of what is and is not covered.
It does not patch what it cannot prove. If a colour resolves through a runtime expression, if the element sits over a background image, or if a fix would need a change large enough to alter the design, A11yFix says so and leaves the file alone.
Fix safety
Every fix carries a safety level, and nothing is written without you asking.
| Level | Meaning | Applied by |
|---|---|---|
| automatic | One provably correct answer, independent of context | --fix |
| review | Correct in the common case, worth a glance | --fix --include-review |
| manual | Needs knowledge the tool does not have | never — advice only |
Usage
npx a11yfix . # report, change nothing
npx a11yfix src --diff # preview the patch
npx a11yfix src --fix # apply automatic fixes
npx a11yfix src --fix --include-review # apply reviewable fixes too
npx a11yfix . --json # machine-readable
npx a11yfix . --all # include info findings and repeats
npx a11yfix --rules # list all 70 rules
npx a11yfix . --baseline-write # record existing findings, then gate on new ones
npx a11yfix . --baseline # report only what is not in the baseline
npx a11yfix . --report # standalone HTML audit reportExit code is 1 when any error-severity finding remains, so it works as a CI gate.
Adopting on a project that already has findings
The first run on an existing application is not going to be zero. A real one produced 1382 findings, and nobody fixes 1382 things before merging the next feature. Record them once and gate on the difference:
npx a11yfix . --baseline-write # writes .a11yfix-baseline.json — commit it
npx a11yfix . --baseline # in CI: exit 1 only on findings that are not in itFindings are matched by rule, file and the shape of the code — whitespace collapsed, quotes unified — never by line number. Editing lines above a finding, or running the file through a formatter, does not make it look new. A second copy of an already-known violation is known; a third is new.
When findings in the baseline stop occurring, the run says so and suggests rewriting it, so the file shrinks as the codebase improves instead of quietly accumulating permission to regress.
The report
--report writes one self-contained HTML file: no scripts, no fonts, no images. It
opens offline, prints, and survives being emailed. Each finding carries the file, the
line and the exact source that triggered it, so every claim in it can be checked in
under a minute — and the limits of what a source analyser can see are stated before the
findings rather than after.
npx a11yfix . --report # a11yfix-report.html
npx a11yfix . --report audit.html --lang ruIt groups findings by rule, names the file and line for every one, quotes the source that
triggered it, and links each rule to the W3C's own page for the criterion it belongs to.
By default it lists the first twelve occurrences of each rule and says how many it left
out; --report --all lists every one, which is what you want when the file is going to
somebody as a deliverable rather than being skimmed.
It states what the tool cannot check before the findings rather than after. A report that lets a passing automated scan imply conformance is the thing the FTC fined an overlay vendor for in 2025, and burying the caveat at the bottom is how that happens.
Where the change is one a11yfix would write, the report shows it as two lines rather than describing it:
now <a href="https://shop.example.ru/" target="_blank" class="link">
after the <a href="https://shop.example.ru/" target="_blank" class="link" rel="noopener">
changeThat is the whole deliverable when the scan came from a URL. There is no repository to
send a diff to, and a content editor cannot apply one; they can retype a line. The label
says "after the change" rather than "correct" because some patches are a floor and not a
finish — role="button" tabindex="0" makes an element focusable and still needs a key
handler — and the sentence directly under each pair says which is which. A finding only a
person can settle, like a missing alt, never gets a second line: inventing one is the
thing this tool exists not to do.
--lang ru translates the report, not the terminal: the person who runs the command
reads English compiler output all day, and the person the report is handed to may not.
Every rule has Russian text and a test fails the build if one is added without it. Where
a patch is offered, the change itself — text-gray-400 → text-gray-600 — is printed in
both languages, because that is the line a reader checks against their own file.
The Russian report also names each criterion the way ГОСТ Р 52872-2019 names it — «1.4.3 Контрастность (минимальные требования) (п. 4.1.4)» — because that is the wording in the contract the reader is holding. No correspondence had to be invented: the standard was written from WCAG 2.1 and kept its numbering exactly, so the mapping is an identity and all 78 criteria are transcribed from the standard's own text. It still does not issue a conformance statement, and docs/gost.md says which 19 of the 78 the tool reaches and why the other 59 need a person.
Scanning a live page
Not everyone who needs this has a repository to hand you. A library, a school, a museum whose site an agency built in 2019 has a URL and nothing else, so a URL is enough:
npx a11yfix https://example.ru/
npx a11yfix https://example.ru/ --report audit.html --lang ruIt reads the HTML the server sends and the stylesheets that page links from its own origin. It is not a crawler and not a browser — one page, no JavaScript. That last part is the thing to understand rather than work around: a client-rendered application serves a nearly empty shell to anything that is not a browser, every rule then finds nothing, and a run like that must never read as a pass. So it says so:
read https://www.msu.ru/
the served HTML is nearly empty — this page is assembled by JavaScript
in the browser, so almost nothing here can be checked from the source.
Scan the repository instead, or a URL the server renders in full.The report carries the same caveat, because the person holding it cannot otherwise tell
which of the two kinds of scan produced it. --fix, --diff and --baseline-write are
refused for a URL: there is no file to change, and a diff against a copy the owner does
not have is worse than no diff.
Encodings are honoured, including windows-1251, which is still in use on the sites this mode exists for — a page decoded as the wrong charset would have every text rule reporting on mojibake.
A whole site, from its own sitemap
npx a11yfix --sitemap https://example.ru/sitemap.xml --report audit.html --lang ruFifty pages by default, one at a time with a pause between them; --max-files moves the
cap. A sitemap index is followed one level down.
The fifty are spread evenly across the list rather than taken from the front of it. A
sitemap comes out in whatever order the CMS emitted it, which is usually oldest section
first: on one museum site the first twelve entries include 1script.php, script.php and
test.php. Reading the front of the list produces an audit of one corner of the site and
calls it an audit of the site. The report says what it sampled and from how many, and the
front page is always read even when the sitemap forgets to list it.
The report then opens with every page it read, worst first — which is the coverage the reader is owed and, incidentally, the order to work through. A page assembled in the browser is marked as one, so its low count is not mistaken for a good one.
Configuration
.a11yfixrc.json in the project root, or an a11yfix key in package.json. A
command-line flag always wins over the file.
{
"level": "AA",
"ignore": ["apps/web/public/**", "**/*.stories.tsx"],
"rules": { "A11Y-LINK-007": "off" }
}The same thing from the command line: --ignore "apps/web/public/**" --disable A11Y-LINK-007.
For a single justified exception, a comment in the file beats turning the rule off everywhere:
<!-- a11yfix-disable-next-line A11Y-IMG-001 -->
<img src="divider.png">a11yfix-disable-line and a11yfix-disable-file work too, in HTML comments and in JSX
{/* … */} comments. Naming no rule suppresses every rule on that line. There is
deliberately no "disable from here on" — a blanket switch buried mid-file is
indistinguishable from the tool being broken, and nobody ever finds it again.
Suppressions that stop matching anything are reported by file and line, so they cannot quietly outlive the problem they were written for.
Every run prints which config it used, how many files were ignored and how many rules are off, so a finding that never appears can be traced to the line that silenced it.
Supported sources: .html .jsx .tsx .vue .svelte .astro, with .css/.scss
read for colour resolution. .ts and .js are deliberately not parsed as markup — they
usually contain HTML only inside string literals, and reporting on those is noise.
GitHub Action
- uses: iam-artificis/a11yfix@v1 # or @v0.1.0 to pin exactly
with:
path: src
mode: comment # or: fail | prv1 tracks the action's inputs, not the package version: it moves when path, mode,
level or token change, which is not on every release. Pin @v0.1.0 if you would
rather nothing move at all, and set the version input to pin the npm package with it.
comment posts the findings on the pull request and updates the same comment on every
push, fail gates the build, pr opens a follow-up pull request containing the fixes.
pr never pushes to the branch under test.
Verified, not asserted
Four properties are enforced by the test suite rather than claimed here:
- Every diff applies. Generated patches are round-tripped through real
git applyand compared byte-for-byte against the direct edit — LF and CRLF, with and without a trailing newline, single-hunk and multi-hunk. - Every contrast value matches the WCAG reference. The colour maths is checked against published ratios, and OKLCH conversion round-trips exactly.
- Every ratio a fix claims to reach is the ratio it reaches. The number in the report is recomputed from the palette step actually written, not from the solver's ideal.
- No fix invents text. A machine check asserts that nothing written into an
alt,aria-label,titleorlangposition is anything but an empty value or a marked TODO.
Precision
Tuned to miss a real issue rather than invent one, because a false positive costs a developer an afternoon and their trust in everything else the tool said.
Measured against public repositories at the time of writing:
| Repository | Files | Errors | Warnings |
|---|---|---|---|
| vercel/commerce | 45 | 4 | 0 |
| tailwindlabs/tailwindcss.com | 150 | 26 | 14 |
| documenso/documenso | 674 | 52 | 14 |
| calcom/cal.com | 989 | 828 | 287 |
| shadcn-ui/ui | 3334 | 100 | 65 |
Getting there meant fixing five classes of confident, wrong finding that only real code
produced — a discarded Tailwind opacity modifier reading bg-green-500/10 as solid
green, a stylesheet in one package colouring pages in another, <Html> from an email
library treated as the document root, markup inside a dedent block treated as a page.
Each is now a regression test. The full write-up, with pinned commits and the cases where
the tool is still wrong, is in docs/field-report.md.
Licence
Apache-2.0.
