@nejcm/dev-toolbar/ext/a11y
axe-core on the bar: a scan you ask for, violations grouped by impact, click-to-highlight over the page, and the same object in diagnostics() that the panel renders.
import { a11y } from "@nejcm/dev-toolbar/ext/a11y";
// Once, at module scope. Not inside render.
const extensions = [a11y({ rules: { region: { enabled: false } } })];npm install --save-dev axe-core # the optional peer; see the cost belowThe cost, stated plainly
This is the only extension with a peer dependency, and the only place where the package's "zero runtime dependencies" needs a footnote.
axe-coreis apeerDependenciesentry marked optional. It is not a dependency of this package, nothing installs it for you, and a consumer who leaves it out pays nothing and ships nothing.- If you do install it, it is roughly 550 KB of JavaScript and a real supply-chain surface in your dev tree. Measured on 4.13.0, a Vite build emits it as its own lazy chunk (587 KB raw / 160 KB gzip) rather than folding it into your app bundle, because it is reached through
import("axe-core")— when the extension starts, or on the first scan underloadOn: "scan". - By default that chunk is fetched and parsed when the toolbar mounts, not when you press Scan: the import runs in
start()so the panel can say "not installed" before anyone clicks a button that cannot work. Installing the peer is what costs you the download; scanning is what costs you the CPU. - If 160 KB on every page load is too much for a scan you run once a week — a staging build every engineer opens, say — pass
loadOn: "scan". The import then waits for the first scan, the panel says so until then ("axe-core has not been checked yet. Scan this page to load it and run the accessibility check."), and a missing peer shows up as"unsupported"from that first scan instead of at mount. What you give up is the up-front "not installed" notice, a one-time setup concern; what you save is a per-page-load download.scanOnStart: trueis a request for a scan at start, so together withloadOn: "scan"the import happens at start anyway — because a scan was asked for, not ahead of one. LeavescanOnStartoff if a cheap mount is the point. /ext/a11yitself is 9.7 KB gzipped — the smallest first-party extension. The peer is the cost; the extension is not.
Without the peer installed, nothing throws. The import rejects, the panel shows the "unsupported" state — the same word /ext/metrics uses for a missing platform API — with the install command in it, and every command still answers with status: "unsupported" instead of failing. Measured with Vite 8: a build with axe-core absent succeeds and emits a stub chunk that throws when imported, which is exactly the rejection the extension is written against. src/ext/a11y/__tests__/peer.test.ts proves it where it actually has to hold: it copies the built package into a temporary directory outside this checkout, where import("axe-core") genuinely cannot resolve, and asserts the state and the install command. test/fixtures/jest-consumer is a CommonJS consumer that never opts in, and reaches the same state for a second reason — under Jest a native import() does not produce a module at all.
What it does, and does not
- Nothing runs on a timer. A full-document axe pass is tens of milliseconds at best and seconds on a large page, so it runs on a click, a command or an agent call.
scanOnStartexists and defaults tofalse. - The toolbar is not the app. The default context is
{ exclude: [["[data-dev-toolbar]"]] }, so the bar's own chrome is never reported as your page's problem. - It is a floor, not a verdict. axe checks what it can see in the DOM. The empty state says so rather than claiming the page is accessible.
- It draws its own boxes.
/ext/overlayshas box-drawing helpers and this extension deliberately does not reach for them: an extension may never import a sibling (src/core/__tests__/boundary.test.tsenforces it), and ~80 lines of duplication is cheaper than a shared surface with two callers. The third caller is when that moves into/runtime. - No panel of its own for depth. There is no rule browser, no filter row and no pass list — that is Lighthouse's and the axe extension's job. This owns the summary and the export.
Options
| Option | Default | What it does |
|---|---|---|
context | { exclude: [["[data-dev-toolbar]"]] } | axe's context, passed through |
axeOptions | {} | Merged into axe's run options, so runOnly, resultTypes and the rest pass through. resultTypes always gains "violations" — see below |
rules | — | axe's own { [ruleId]: { enabled } }; wins over axeOptions.rules |
load | () => import("axe-core") | How axe is obtained. Hand over an already-loaded copy, or a stub in a test |
redactOptions | — | /runtime's RedactOptions, for the masking below |
nodeLimit | 5 | Elements listed per violated rule. The count stays exact |
scanOnStart | false | Scan once when the extension starts. With loadOn: "scan" this is the first scan, so the peer is imported at start after all |
loadOn | "start" | When axe-core is imported. "start": at mount, so a missing peer is reported before anyone scans. "scan": on the first scan, so mounting costs no download — see the cost above |
now | performance.now | Clock, for the duration figure |
presentation | "default" | How the bar control looks — see Bar presentation |
Plus the usual id / label / align / order / priority / hidden / keepMounted / injectStyles / styleNonce. keepMounted defaults to true here, as it does for /ext/flags and /ext/theme-editor. The report itself does not depend on it — it lives in the runtime the factory built, not in the panel — so what staying mounted preserves is the groups list's scroll position.
Commands
Every command declares a description, and an input schema where it takes an argument — contract v2's fields, which is what makes the extension readable to /ext/agent rather than only clickable.
| Command | Input | Returns |
|---|---|---|
a11y.scan | — | Runs axe once, now. Resolves with the report |
a11y.export | copy?: boolean | The last report, without scanning. copy also writes it to the clipboard as JSON |
a11y.highlight | rule?: string, node?: number | Draws a box over one flagged element. Omit rule to clear. A rule or node the last report does not list also clears |
a11y.clear | — | Drops the report and the highlight, back to pending. A scan in flight is disowned, not cancelled |
a11y.scan returning the report is what makes one agent call enough: scan and read in the same round trip.
diagnostics() is the panel's own object
diagnostics() returns the A11yReport the panel is rendering — the same object, by reference, not a second serialisation of it. So nothing important is panel-only, and nothing an agent reads is less redacted than what you see. The status field is worth reading first:
status | Meaning |
|---|---|
"unsupported" | axe-core is not installed, or its module failed to load. unsupportedReason says which |
"pending" | No scan result is held: nothing has been scanned yet, or the last scan was cleared. Says nothing about axe itself — the import may still be in flight, not yet requested under loadOn: "scan", or long done |
"ok" | A scan completed. total, counts, groups are meaningful |
"failed" | A scan ran and threw, or resolved with something that is not an axe result. error says what |
Whether axe has been imported is not a status: the panel reads it from A11ySnapshot.axeLoaded, which is how it knows to say "not checked yet" under loadOn: "scan" and to stay quiet once a scan has loaded axe, even after Clear returns the report to "pending". axeVersion is the bridge's proxy for the same fact — null until axe is imported, a version string after, for any engine that reports one.
Two things the snapshot holds are not in the report: that loading flag, and geometry — the highlight's rect is re-measured on scroll and resize, and report.selected already names the element it belongs to.
Counts, resultTypes and one axe at a time
nodeCount and nodeTotal are exact, and nodes is the capped list — truncated says when the two differ. That promise is only keepable if axe is asked for complete violation results: when resultTypes leaves a type out, axe truncates that type's nodes to one and puts nothing in the output to say so, which turns an exact count into a quiet lie. So "violations" is added back to whatever resultTypes you pass. Your other entries are kept, so resultTypes: ["passes"] still avoids collecting full incomplete and inapplicable results — it just cannot switch violations off.
axe refuses to run twice at once ("Axe is already running"), and the normal arrangement is one axe-core copy shared by every runtime on the page. Passes are therefore queued per axe copy, not per runtime: two extensions, or two createA11yRuntime() calls, both get an ok report instead of one of them getting axe's error. Each keeps its own context, options and report — nothing is shared but the turn to run. Within a single runtime, concurrent scan() callers still share one pass and one report.
There is deliberately no timeout. A load or a run that never settles leaves that scan pending forever. The blast radius differs: ensureAxe() is awaited before the engine queue is taken, so a hung load strands only its own scan, while a hung run holds the per-engine queue and takes every later scan on that copy with it. A timeout would have to be long enough for a slow scan of a large page; the judgement here — not a measurement — is that it would then turn a working scan into a false failure more often than it rescues a hung one.
clear() disowns a scan in flight rather than cancelling it: axe exposes no abort, so the pass runs to completion and its result is thrown away instead of repopulating the report you just cleared. The next scan() starts its own pass rather than joining the one whose report is gone. Teardown and a remount work the same way, so a result from a previous mount can never land on the new one.
Masking, and its limit
axe's results are your markup on its way into a bug report, and the element it flags is often the one holding a secret — an unlabelled <input type="password" value="…"> is a violation and a credential. So:
- Attribute values survive only for the attributes accessibility is about (
aria-*,id,class,role,type,for,name,alt,title,placeholder,href,src, and a handful more). Everything else —value, everydata-*, anything a framework invented — becomes[redacted]wholesale, without first being tested for credential shape. - Kept attribute values and snippet text go through
redactText(), which scans for credential shapes andscheme://…runs anywhere inside the value, so a?token=in anhrefis masked while?page=2survives. Selectors, axe's failure summaries, rule ids, help text and a thrown reason go through the runtime'sredactProse()— whole-valueredact()matching plus everyscheme://…run throughredactUrl().hrefandsrcare known to be URLs, so the whole value is tried as one first — a relativehref="/reset?token=…&page=2", the common SPA shape, is masked the same way an absolute one is. The embedded-URL scan also runs, including when the whole-value pass found a credential. - The markup is parsed, not pattern-matched. A tag ends at the first
>outside an attribute value, sodata-secret="a>SECRET"— which a<[^>]*>regex ends early, carrying the tail through unmasked — is masked like any otherdata-*. A comment is dropped whole (<!--[redacted]-->) rather than masked, and a declaration, CDATA section or processing instruction makes the whole snippet[unreadable]: their contents are not markup the walk can mask attribute by attribute, and both paths leaked a credential when they tried. Anything the walk cannot read confidently — an unterminated quote or tag, or a bare<in text where a serialiser would have written<— is reported as[unreadable]rather than emitted raw. That is deliberately blunt: losing a snippet costs a line of the panel, guessing at one costs a credential. - Both are bounded. A snippet, a summary and a selector are capped at 4 KB. A run of text longer than that is cut back to a whitespace boundary first, so a truncated value can never be half a credential — a run with no whitespace inside the cap is dropped entirely rather than split — and an attribute value over the cap is dropped whole (
[redacted]) instead of truncated. Redacting is a regex pass per value, so this is a performance bound as much as a size one: a 100 KB snippet took seconds before it was capped. - The limit: snippet text and kept attributes are scanned, so a credential with a label in front of it (
aria-label="key Bearer SECRET",<p>Authorization: Bearer …</p>) is now masked. What survives is a credential in a shape neither matcher knows — an AWS access-key id, say — becauseredactText()widens where a shape is looked for, not which shapes count. A credential split across a child tag (<p>Bearer <b>sk-…</b></p>) also survives, because the two halves are never one string. Selectors and failure summaries go throughredactProse(), so aBearer …inside a sentence with no URL around it survives there — the prose masker's documented limit (runtime.md). These are pinned by tests so they cannot close silently and be assumed gone, and the mitigation is the same: the panel shows you the text, because you are the last check before it reaches a ticket. That mitigation is why axe's failure summaries are rendered under each element rather than being export-only — "read it before you share it" only works if the panel shows everything the export carries.
Accepted limits
A relative reference after another URL can survive even in href or src. For example, https://a.test/?x=1 /b?token=SECRET and https://a.test/p https://b.test/?x=1 ?token=SECRET are exported unchanged. The whole-value URL pass does not find their second query, and the embedded scanner only recognises scheme:// runs. A single href="/reset?token=X&page=2" still becomes href="/reset?token=[redacted]&page=2".
This limit is deliberate. Free text can contain /foo or ?x=1 without identifying a URL. Splitting every whitespace-separated run into candidate relative URLs would add an ambiguous matcher to the shared text redactor. The two examples above are pinned through the scan and export commands.
Not a security boundary. If a page renders credentials in the DOM, treat the export the way you would treat a screenshot of that page.
Highlighting
The box is drawn from the overlay slot — not the panel — so a collapsed chip does not take it with it, and it carries the same guarantees /ext/overlays' surface does: pointer-events: none !important so a click always reaches your page, and z-index: -1 !important inside the toolbar root so it never covers the bar, the panel or ⌘K. It disappears while the bar is hidden and on teardown.
axe can report an element it reached through an iframe. querySelector cannot follow that path, so such an element is listed and left unhighlightable rather than mis-aimed at something in the top document.
An element inside an open shadow root is different: axe reports it as a nested selector path ([["#host", "#bad"]]), the report shows that path as #host >> #bad, and the highlight follows it through shadowRoot one step at a time. A closed shadow root is not a limit of this extension: axe cannot see into one at all, so nothing inside it is reported in the first place.
Bar presentation
a11y({ presentation: { preset: "icon", icon: <AccessibilityIcon /> } });presentation changes how the bar control looks, never what it scans. The four knobs — a preset, your own ReactNode icon, a render callback and an accessible-name override — the preset-by-preset table and the rules every extension shares are in kit.md. Two of those rules are worth repeating before the specifics: "default" is byte-identical to what shipped before the option existed, and presentation, like every factory option, is fixed when the factory is called — to change it at runtime, remount the toolbar or reload.
What is specific to this extension:
- The short bar word is
a11y. Presets select it;labelstays the identity in the⋮menu and in the accessible name. That swing used to be written by hand here asisOverflowed ? label : "a11y". - The accessible name is
${label} (a11y), <state>—Accessibility (a11y), pending,Accessibility (a11y), 2 violations, andAxe (a11y), pendingif you configurelabel: "Axe". WCAG 2.5.3 Label in Name wants the word you can see inside the name, and the bar paintsa11y; it wasAccessibility, pendingbefore.labelleads rather than the abbreviation because a screen reader reads "a11y" as "a eleven y", while speech-input matching only needs containment — and the parenthesised form contains the⋮row's visible text, which is the fulllabel, as well. - The chip's readout is left to
title, which is already the description.scan,…,error,NAor the violation count is what the value span paints, andaria-labelreplaces content — but with noaria-describedbya browser readstitleas the accessible description, and this chip's title (Accessibility: click to scan this page) says what the span abbreviates with more context than the span has. The state the span stands for is in the name as well (, pending). So there is noaria-describedbyand noidon the value span: adding one would displace the title rather than add to it./ext/metricsis the only chip that overrides this — see styling.md. - The icon lands in
data-dtb-part="a11y-icon"and the word ina11y-label— two new, additive part names to style against. TViewisA11yReport, the same objectdiagnostics()publishes, soicon: (report) => (report.status === "ok" && report.total === 0 ? <Clear /> : <Flag />)branches on what the chip is already saying.- The dot, its severity and
data-dtb-statusare not a preset's to change, nor a callback's. A preset changes text, not state.