LiveAudit

JavaScript API

init, scan, show, dock, hide — and what each of them costs.

On an unlocked page the global LiveAudit exists. Nothing starts by itself: the WebAssembly module loads on the first init(), the layer appears on the first show().

await LiveAudit.show();                         // scan the document, draw the layer
await LiveAudit.show(element);                  // scan a subtree instead
await LiveAudit.show(undefined, { dock: 'left' }); // and dock the sidebar left
LiveAudit.hide();                               // remove it; nothing stays behind

scan(root?, options?)

Scans and returns the result without drawing anything — for your own UI, or for reading the findings in the console.

const result = await LiveAudit.scan(document.body);
result.documents[0].report.findings;  // Finding[]
result.documents[0].nodes;            // nodes collected

Each same-origin <iframe> is scanned as its own document with its own id space and appears as a further entry in documents. A cross-origin frame cannot be entered and is reported as an untested scope — not as a silent omission.

show(root?, options?)

Scans, then draws the layer. options are those of scan, plus dock for the edge the sidebar attaches to (right, left, top, bottom; the choice is remembered).

The contrast pass: { rendering: true }

Contrast needs computed styles and the effective background behind the text, which is a second walk over the collected elements. It costs 1.9× to 4.6× the collector, so it is not part of every scan:

await LiveAudit.show(undefined, { rendering: true });

Without it the contrast rules do not run; the report lists them as not run, with the reason — never as PASS, and never silence.

The same pass feeds heuristic checks: CSS reordering over focusable content, endless animation without a pause control, min-width above 320 px, elements that look clickable but have no role, controls hidden under fixed bars, targets under 24 × 24 px that also miss the spacing exception. They report REVIEW, never FAIL — a place to look, not a verdict.

Focus visibility: { rendering: true, focus: true }

Whether keyboard focus is visible can only be measured by focusing. This pass focuses every reachable control once (focusVisible: true, preventScroll), compares outline, shadow, border, background and text before and after, and puts focus back where it was:

await LiveAudit.show(undefined, { rendering: true, focus: true });

It is the one pass that changes the page’s state: the page receives focus and blur and may react to them. Without it, focus visibility is reported as UNTESTED.

The checklist: manual/*

What no machine can decide — captions and transcripts, whether an alt text fits, text styled as a heading, colour as the only cue, time limits, error messages, captchas — appears once per page as UNTESTED whenever the page contains something it applies to. It is the list of what still needs a person.

Live mode: watch(root?, options?)

Scans, draws the layer, and keeps it current while the page changes — for pages that build themselves after load, or that you are editing while you look at them.

await LiveAudit.watch();                 // stays current
await LiveAudit.watch(undefined, { debounceMs: 500 });
LiveAudit.unwatch();                     // stop; the layer stays as it is

Only the changed subtree is scanned again, 200 ms after the last mutation and at most 1 s after the first (maxWaitMs), so a page that never settles still updates. Collecting the DOM is the measured bottleneck — about 90% of a scan — so a full rescan per mutation would be felt on any page that moves.

Two limits, both deliberate:

  • Rules that speak about the scope as a whole — a missing main landmark, a missing title, a missing h1 — stay as the last full scan left them. A subtree is not a document, and answering those questions on a fragment would invent a verdict. They are as old as the last full scan, which beats being wrong.
  • Mutations inside a same-origin frame are not watched. Frames are rescanned when a subtree above them changes.

State that changes without a mutation — :checked, :has(), custom properties — is picked up on change (the whole observed root) and transitionend (the element that moved). For anything else, rescan yourself:

await LiveAudit.rescan();                // in live mode: the observed root
await LiveAudit.rescan(panel);           // in live mode: just this subtree

Without live mode, rescan() repeats the last show().

Unlocking from your own code

import LiveAudit, { enable } from '/vendor/liveaudit/inspector.js';
enable();  // programmatic unlock, bypassing the ?liveaudit flag

enable() is what this site’s own demo page uses: the page is the tool’s purpose, so it says so in code instead of relying on a query parameter.

Others

Call Does
init() Loads and instantiates the WebAssembly module; repeated calls share one load
isVisible() Whether the layer is currently in the document
dock(side) Moves the sidebar without scanning again
remember() / forget() Keeps the unlock for this origin, or drops it

Edit this page on GitHub