Known limitations

Stated plainly and upfront, not left for you to discover. Every item here is a deliberate, reasoned decision, not an oversight — but "deliberate" doesn't mean "unimportant to know before you rely on this tool."

Structural — this architecture cannot do these at all

surea11y is a static DOM scan: it reads the DOM tree and computed styles at one instant, with no ability to simulate user interaction, wait for async state changes, or measure real layout at arbitrary viewport sizes. These aren't missing rules — no rule implementation, however clever, can close them without a fundamentally different architecture (real browser automation driving actual keyboard/pointer events over time):

  • Keyboard-trap detection (WCAG 2.1.2) — requires driving focus around the page and watching where it lands, which no single read of the DOM can do. A technique that would work has been scoped out (see the keyboard-trap section of ACT_RULE_MAPPING.md), but it would be this engine's first check to mutate the page it is inspecting, so it is not built and would not be part of a normal scan if it were. Nothing in static markup stands in for it in the meantime.
  • Reflow / clipping at zoom (WCAG 1.4.10) — requires real layout measurement (clientWidth/scrollWidth) at a simulated 320px-equivalent viewport. Unlike some CSS-declaration-based heuristics elsewhere in this engine, there is no static markup proxy for "does content get clipped at 400% zoom" at all.
  • Dynamic/post-interaction state — anything that only exists after a click, hover, or async data load (a modal's contents, a dropdown's options, form-validation error messages) is invisible to a scan of the page's current DOM. If your framework renders it eagerly (even off-screen/hidden), it's scannable; if it only exists after interaction, it isn't, unless you drive that interaction yourself before scanning (e.g. click the button, then scan).

Environment-dependent — depends on how you run it

  • jsdom (Node, no real browser) has no CSS layout engine. Rules needing real geometry — most notably target-size-minimum (WCAG 2.5.8, needs real getBoundingClientRect()) — report notApplicable under plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — see INTEGRATION.md Pattern 2) to get real findings from these rules.
  • Whether text wraps needs layout, so text-spacing findings on non-wrapping text are reported for review. WCAG 1.4.12 and the ACT rules behind it apply only to text containing a soft wrap break, which a static scan cannot establish. avoid-inline-spacing treats text as wrapping by default, so an ordinary forced value below the metric still fails; where no wrap is possible for a reason that is visible without layout — text not allowed to wrap, or a fixed-width element inside a horizontally scrolling ancestor — it reports cantTell instead. Text that never wraps for some other reason is still reported as a failure.
  • <dialog> and other elements hidden by the default UA stylesheet (no open attribute, display: none by spec), along with any other subtree hidden via display:none, visibility:hidden, [hidden], or closed <details>, are excluded from rule evaluation by default — matching the visibility-aware behavior of other established engines. This is a deliberate default (engineOptions.includeHiddenElements: false), not an oversight: hidden content isn't reachable by assistive technology or keyboard until it's shown, so flagging a markup defect inside it by default would often be noise. Set engineOptions.includeHiddenElements: true to evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. See ENGINE_OPTIONS.md for the option and exactly which hiding mechanisms it covers.
  • jsdom's computed text-shadow is unreliable on a second read of the same element. Confirmed in jsdom 29.1.1: reading a computed text-shadow value a second time on the same element — through any accessor, from any freshly-requested CSSStyleDeclaration for that element, regardless of caching — silently returns a different, wrong "no shadow" value instead of the real declared one. The first read is always correct. This engine works around it internally by reading each element's text-shadow exactly once per run and caching the result (see __textShadowInfoEl in src/core/contrast-helpers.js), so a single scan is unaffected. It only surfaces if you read getComputedStyle(el).textShadow yourself, more than once, against the same jsdom-parsed element — a real browser has no such bug.
  • Under jsdom, scan time grows with the square of DOM depth, not with element count. jsdom resolves inherited CSS by walking an element's ancestor chain on every getComputedStyle call, so one call per element costs O(elements x depth). Measured on jsdom 29.1.1 with no engine code involved: 4000 elements in one chain take 8.3s of getComputedStyle alone, against 0.25s for the same 4000 as siblings. The engine's own ancestor walks are capped and stay linear, so this is jsdom's cost rather than the rules'. It affects runDomRulesInPage and anything built on it, including @surea11y/test-matchers; a real browser computes inherited style natively and does not have this shape. Component frameworks routinely nest 100-300 deep, which is comfortably fast — it becomes noticeable past roughly 1000.
  • Static markup vs. live/post-hydration DOM state. The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see INTEGRATION.md). But the CLI (npx @surea11y/cli scan <url>) specifically fetches static HTML only, with no JS execution — see the CLI docs. For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. <input type="checkbox" aria-checked="true"> shipped before client JS sets the native checked property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. A scan running inside an actual loaded browser tab sees the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with rule correctness. That's why aria-checked-state-mismatch is capped at manual/cantTell rather than a hard fail. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.

Not attempted: judgment calls that aren't automatable safely

These have no comparably safe heuristic at this engine's bar (fail must stay reserved for deterministic violations, full stop). Building them anyway would either catch almost nothing (too narrow to be useful) or risk real false positives (too broad to trust):

  • "Is this heading/label text meaningful?" — real headings and labels are enormously varied and legitimately short ("FAQ," "Name," "Overview" are all fine), so nothing decides from markup whether a heading describes the section under it or a label describes the field beside it. What is decidable is that some strings cannot describe anything: heading-quality and form-control-label-quality flag leftover placeholders, numbered template slots, filenames and URLs against curated exact-match lists, the same precision-over-recall trade-off link-name-quality makes. Both are manual rules capped at cantTell — they raise a candidate for review, they never assert the text is wrong.
  • "Does this error message describe the problem?" — what triggers a validation error and its content are almost always JS/validation-library-driven, invisible to a static scan in the first place; not just a heuristic-design problem.
  • Fine-grained time-based-media sub-checks (WCAG 1.2.x has ~8 distinct ACT-rule-level cases beyond what's built) — audio/video content itself is fundamentally unverifiable from static markup; the two broadest, safest cases are covered (media-alternative-transcript-evidence, video-caption), the narrower ones are not, by design.
  • Images-of-text content analysis (WCAG 1.4.5/1.4.9) — would need OCR-equivalent image understanding; out of scope for a static-markup engine.
  • Motion-actuation controls (WCAG 2.5.4) — niche, low real-world incidence; not prioritized, not structurally impossible.
  • img-alt-decorative's two ACT edge cases — an <img> whose current network request state isn't "completely available" (still loading, or broken), and a <canvas> that is fully transparent (nothing actually drawn on it), are both exempt from ACT's own applicability. Neither is decidable from a static DOM scan (no image decoding, no canvas pixel readback), so both are left in scope rather than exempted — a rare false positive a human reviewer dismisses at a glance, safer than silently under-reporting a real excluded/undecorative element.

What this means in practice

None of the above is unique to surea11y — every static-analysis accessibility tool (other established engines included) shares the structural limitations, and most share the judgment-call ones too. The reason to state it explicitly here: a pass from this engine (or any automated tool) is never a substitute for the manual review WCAG itself requires for the criteria above. See WCAG_CONFORMANCE.md for exactly what a pass/composite pass does and doesn't claim.