Known limitations
What the engine doesn't evaluate, what depends on where a scan runs (jsdom, a browser, or the page's own scripts), and what needs a person to review. Some items are design decisions, each with its reasoning; each says what a scan can't establish and, where there is one, what to do about it. Read this before you rely on a clean result.
What to do about them
| If… | Then… |
|---|---|
| Content appears only after a click, a hover or a data load | Bring the page to that state yourself, then scan it. Scan each state that matters. |
| A check needs layout: target size, text spacing, contrast against what is painted under text | Scan in a real browser. jsdom has no layout (the result's engine.environment.layout is false), so these rules measure less there or report notApplicable, as the sections below say. |
| A requirement is about keyboard operation, such as a keyboard trap | Test it with a keyboard. No scan covers it. |
A result is cantTell | Follow the occurrence's howToCheck steps and decide it by hand. uncertainty says what was missing, and lean which way the page's evidence points (see Output schema). |
| The page is responsive | Scan at each width you care about. |
| Two runs of the same page differ | Compare engine.version and engine.environment (viewport, fonts and images still loading), and reload the page between scans. |
| A scan reports no failures | That is not a conformance claim. See WCAG conformance mapping. |
The sections below give the reasons.
Outside what one scan can see
A scan reads the page at one moment, in one state, at one viewport: the DOM, its computed styles and, in a real browser, its layout. It does not use the page as a person does. It doesn't move through it with a keyboard, open its menus, wait for its data or resize it. (In a browser a few rules focus an element or add a style sheet for the time of their check, and put it back; see the next section.) The items here need that kind of interaction over time, so another rule can't close them. They need an interaction test or a person:
- 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 isn't built: a scan doesn't explore user interactions or the states they lead to, and none of the checks that change the page briefly drives focus through it the way trap detection would. 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). Scanning the opened state checks what is in it, not whether a keyboard or screen-reader user can open, use and close it.
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 realgetBoundingClientRect()) — reportnotApplicableunder plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — seeINTEGRATION.mdPattern 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-spacingtreats 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 reportscantTellinstead. Text that never wraps for some other reason is still reported as a failure. - The engine runs on the page's own JavaScript built-ins. A page whose scripts break one it relies on (
Array.prototype.filter,Map,JSON.stringify,String.prototype.toLowerCaseand others) stops the scan with an error codedPAGE_BUILTINS_BROKENthat names each, rather than giving a result that reads well and is wrong; one broken before the bundle is injected (Object.keys) stops the bundle loading. A page that addsthentoObject.prototypeis scanned as any other. A page that replacesArray.from, as Prototype and MooTools do, is scanned too (since core 1.16.0): core's own code doesn't use it. A pack's or custom rule that uses it stops the scan there, withPAGE_BUILTINS_BROKENnamingArray.from, unless the selection leaves the rule out. DOM methods a page replaces (getComputedStyle,querySelectorAll,getBoundingClientRect) are not checked: rules read what they return. - A name is not worked out from content nested more than 256 levels deep. The walk stops there (Chrome gives no name from content 100 levels down), so an element whose only name is deeper is asked about (
name_contentTooDeep) bybutton-name-present,link-name-present, the menu item, option, summary, tab, tooltip and tree item name rules, andempty-heading; other rules that name from content read such content as empty. - Content in a closed shadow root can't be read. No script reaches inside
attachShadow({ mode: 'closed' })or a declarativeshadowrootmode="closed", so the engine can't see what a browser names from there.button-name-presentandlink-name-present, and the menu item, option, tab, tree item, summary and tooltip name rules, ask about an element whose name could come from a custom element that renders but shows no readable content, instead of failing it. A closed root on a built-in element (a<span>) looks like an empty one, such as an icon drawn by CSS, so a button over one is still failed. Check what such a component exposes by hand, for example with a screen reader. - A name or state a custom element sets through
ElementInternalscan't be read.this.attachInternals().ariaLabel = 'Save'names the element in every browser, but no script can read it back. Where a component keeps its internals on a property (_internals,internals,internals_,__internals), the engine reads them through it, and passes or fails the element by what they say. For any other defined custom element with no name (or no required state), the name rules andaria-required-attrask (name_internals,state_internals) instead of failing. Check those in the browser's accessibility tree, or install the opt-in internals hook before the page's scripts run:installInternalsHook()in each binding (see Getting Started) and in test-matchers, or core'sinternalsHookScript()for a page of your own (seeINTEGRATION.md, which shows where to install it). The hook records every element's internals for the scan, so it also tells an element that never attached any. A role set only through internals (internals.role) is read where the internals are, through a property or the hook, as the element's default role; kept in a private field without the hook, it is not seen, and the element is not asked about, since nearly every component could have one. Built-in elements, and custom elements that aren't defined, are judged as before. - Event listeners added from script can't be listed. A listener added with
addEventListener, or set as a property (el.onclick = fn), leaves nothing in the markup and no script can read it back, so the keyboard rules (click-handler-keyboard-operable,mouse-only-event-handlers) judge inline handler attributes only: a focusablerole="button"given its click listener from script is not reported (one that can't take focus is asked about bywidget-keyboard-focusableall the same), a key listener added from script can't excuse an inlineonclick, andpassword-paste-enabledcan't tell whether a paste listener added from script puts back the text an inlineonpastecancels. Install the opt-in listener hook (listenerHookScript()) before the page's scripts run: the rules then judge the listeners it records too, and, where it saw no key listener at all, fail a control that Enter and Space can't operate. A click listener a framework adds to its root element serves elements nothing names, so even with the hook those are judged by their markup. Where to install it:INTEGRATION.md, on GitHub. - How a live region was filled can't be seen afterwards. A status message is announced when a live region changes while assistive technology can reach it (4.1.3), but a scan sees the page at one moment. Without help,
live-region-announcementsjudges only what is there: a live region hidden from assistive technology byaria-hiddenorinert, or behind an open modal<dialog>, or leftaria-busy. Install the opt-in live-region hook (liveRegionHookScript()), and it also asks about a region the page added with its text already in it, or showed and filled at once, andgetAnnouncements()lists what the page made available to announce. Neither says what a screen reader spoke: that depends on the screen reader. The hook doesn't see inside shadow roots. Details:INTEGRATION.md, on GitHub. - A scan taken while a modal dialog is open sees the dialog, not the page. A
<dialog>opened withshowModal()makes the rest of the document inert, and browsers give assistive technology only the dialog. A dialog opened with theopenattribute orshow()is not modal, even witharia-modal="true": the page behind it stays reachable, and the engine judges it. jsdom has no top layer and can't open a modal dialog, so under jsdom nothing is behind one, and the same markup gets the same verdicts as in a browser (since core 1.16.0); a jsdom test that stands in forshowModal()by settingopenandaria-modal="true"on the dialog setsengineOptions.assumeModal: trueto have such a dialog taken as modal. Of several modal dialogs, only the topmost is judged. The engine follows the modal dialog: content behind it is left out of every rule, as other hidden content is, and takes no focus, and the rules about the page's own structure (page-has-heading-one,landmark-one-main,bypass-blocks-present,region) reportnotApplicable, since the scan cannot see that structure. Scan the page again with the dialog closed to judge what is behind it. A custom modal, a shown element withrole="dialog"orrole="alertdialog"andaria-modal="true", also makes those structure rulesnotApplicable;aria-modalmakes nothing inert, so every other rule still judges the page behind it. A modal dialog inside an open shadow root counts as one in the document; one in a closed shadow root can't be found. A custom modal withoutaria-modal="true"is handled only through theinertoraria-hiddenthe page puts on the rest of it, which hide the page but don't tell the structure rules a dialog is open. - A document shown in a frame is not judged as a page of its own. In a frame, the rules about the page (
page-title-present,page-title-patterns,html-lang-attr-present,meta-viewport-zoom-enabled,meta-viewport-large,page-has-heading-one,landmark-one-main,bypass-blocks-presentandregion) arenotApplicable: the page around the frame is what has the title, language and landmarks people use, and a browser ignores a framed document's viewport<meta>. A test runner that shows the page under test in a frame of its own setsengineOptions.topLevelDocument: true, so that page is judged as the page (@surea11y/cypress does).runa11yCoreAcrossFramesreads a frame underaria-hidden="true"as aria-hidden content and one under[inert]as inert content, where only how content looks is judged, and leaves out a frame behind an open modal dialog; a binding that scans frames one by one setsembeddedAriaHiddenandembeddedInertitself (see Engine options). Core 1.15.0 and later. <dialog>and other elements hidden by the default UA stylesheet (noopenattribute,display: noneby spec), along with any other subtree hidden viadisplay:none,visibility:hidden,[hidden], or closed<details>, and an[inert]subtree (which rules about how content looks, such as contrast and text spacing, still judge, since it is drawn), are excluded from rule evaluation by default. 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. SetengineOptions.includeHiddenElements: trueto evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. SeeENGINE_OPTIONS.mdfor the option and exactly which hiding mechanisms it covers.- An
aria-detailsthat names hidden content is not reported.aria-valid-attr-valuechecks that the id names an element, not that the element is shown. Chromium leaves the relation out of its accessibility tree while the element is hidden and puts it back once it is shown, so details that open on demand are announced when open, and WCAG asks nothing more. To check the relation as assistive technology gets it, scan the page with the details shown. - jsdom has no popovers. It ships HTML's rule hiding a closed
[popover], but noshowPopover(), and reads:popover-openinconsistently. Where the DOM has noshowPopover, the engine treats every[popover]other than an open<dialog>as closed and hidden, so its content is not checked; a popover the page's own CSS keeps visible is not checked under jsdom either. In a browser the engine reads what is displayed. To check a popover's content, scan the page in a browser with the popover open. - Under jsdom, text from CSS
contentnames nothing. jsdom computes no style for::beforeand::after, so a button or link whose only text is generated (content: "Save",content: attr(data-label)) is named in a browser and unnamed under jsdom, wherebutton-name-present,link-name-presentand the other name rules fail it. Scan such pages in a browser. - jsdom keeps a font size as written. A browser reports a computed font size in px; jsdom keeps a keyword or a
calc()as written. The engine reads the keywords at a browser's sizes for a 16px default (xx-largeis 32px) andcalc()of lengths and numbers, so large text is told apart as in a browser.largerandsmaller, which depend on the parent's size, are not resolved under jsdom: such text is held to the normal-text ratio, and its message gives its size as 0px. A browser resolves both. - jsdom ignores a shadow root's own style sheets. Confirmed in jsdom 29.1.1: neither a
<style>inside a shadow root nor itsadoptedStyleSheetsreaches the computed style of the elements in it, so under jsdom colors, sizes, anddisplay: noneorvisibility: hiddenset there are not seen. The rules then judge a component's content as if unstyled: contrast is measured on default colors, and content its CSS hides is judged as shown. The one exception is a link's color, which the contrast helpers work out from the shadow root's sheets themselves. Scan pages built from styled web components in a browser. - jsdom's computed
text-shadowis unreliable on a second read of the same element. Confirmed in jsdom 29.1.1: reading a computedtext-shadowvalue a second time on the same element — through any accessor, from any freshly-requestedCSSStyleDeclarationfor 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'stext-shadowexactly once per run and caching the result (see__textShadowInfoElinsrc/core/contrast-helpers.js), so a single scan is unaffected. It only surfaces if you readgetComputedStyle(el).textShadowyourself, more than once, against the same jsdom-parsed element — a real browser has no such bug. - A second scan of the same jsdom DOM can lose a style that overrides a shorthand. jsdom keeps an element's computed style once read, until the DOM next changes, and the copy it gives back from that cache loses a longhand that overrides a shorthand from a less specific rule: with
*{background:#888}andstyle="background-image:url(hero.png)", the first read gives the image and every later onenone. A scan keeps the style it read first, so it is unaffected, but a second scan of the same unchanged DOM can miss the image and pass text the first asked about (contrast-computable). Scan a fresh parse, or change the DOM between scans, which clears jsdom's cache;@surea11y/core/testingdoes this for its own second scan. 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
getComputedStylecall, 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 ofgetComputedStylealone, against 0.25s for the same 4000 as siblings. The engine reads each element's ancestors to the root, once per scan, though the contrast rules measure each text against every background above it; most of the time is still jsdom's. jsdom also resolves an inherited value by recursing up the elements above, which runs out of stack a thousand or so levels deep: the engine reads a property of an element more than 100 levels deep with the elements above it resolved first, so every scan reads the same value. It affectsrunDomRulesInPageand 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. - Under jsdom, scan time also grows with the size of the stylesheet. jsdom computes a style by matching every CSS rule against the element. The engine doesn't change the DOM during a scan, so each element is computed once, but that once costs O(rules): on a saved news page (7,916 elements, 2,683 CSS rules, jsdom 29.1.1), the first computation of 5,735 elements takes about 224 s of a 243 s scan. That is jsdom's cost; a real browser computes the same page in a fraction of a second. jsdom also leaves custom properties unsubstituted, so a color set with
var()is unreadable there: the text or background is asked about (contrast-computable) rather than measured. Run a real browser for large production pages. - Under jsdom, layered and conditional CSS is applied to what hides an element, with jsdom's media. jsdom's
getComputedStylereads no rule inside@layer,@supportsor@container, so a Tailwind v4 page'shiddenandsr-onlyhid nothing there. Since core 1.16.0 the engine applies those rules itself to the properties it reads to tell whether an element is shown, but takes@mediaas jsdom does: onlyallandscreenapply, so a rule under(width >= 48rem)counts in a layer no more than outside one, and Tailwind'shidden md:blockstays hidden under jsdom where a desktop browser shows it.@supportsholds for any property test and for aselector()that parses,@containerdoesn't apply, and arevert,revert-layerorvar()value leaves jsdom's. Other properties, colors among them, are still jsdom's. Scan in a browser for a page that changes what it shows by viewport. - Under jsdom, a link's color is worked out from the page's own rules. jsdom ranks its own
:link { color: #0000EE }with the page's rules by specificity alone, so a page'sa { color: #fff }loses to it, where a browser applies the page's rule. Under jsdom the engine works out the color of a link, and of the text inside it, from the page's rules as a browser's cascade does; a link no rule of the page colors keeps the browser's link color, and a link is taken as not visited. Where that can't be done (a style sheet that can't be read, avar(), a rule under@supports,@layer,@containeror nested in another), the text is asked about (contrast-computable,FOREGROUND_UNPARSABLE) rather than measured in jsdom's blue. In a browser the computed color is read as it is. - Under jsdom, text is judged by its computed
font-size. In a browser, the contrast rules andp-as-headingjudge text by the size it is drawn at: CSSzoom, an SVG transform andfont-size-adjustincluded, the last from the font's own metrics (font-size: 24px; font-size-adjust: 0.3draws Arial at about 14px, which is not large text). jsdom lays nothing out and reads no font, so therefont-sizeis taken as it is. Scan in a browser for a page that scales its text so. - In a real browser, some rules change the page for the time of their check, and the page's own scripts can react. To measure what the browser draws,
css-hidden-focusfocuses elements as the keyboard would, and switches transitions off on the element it measures;aria-hidden-focusfocuses an element inside anaria-hiddensubtree to see whether the page moves focus away at once (a focus-trap sentinel);text-spacing-content-lossadds a style sheet with the WCAG 1.4.12 spacing; the contrast rules add a blank, off-screen frame to read the browser's own colors for a placeholder or a drop-down<select>. The scan also moves each running animation to a fixed point while the rules run (a finite one to its end, an endless one to its start), so a fade-in is measured as it settles. All of this is put back before the rule or the scan returns: focus, scroll position, style attributes, animations, the added style sheet and the frame. What cannot be put back is what the page's scripts do in response, since focusing an element fires itsfocusandfocusinhandlers: a carousel may move to the focused slide, movingaria-hiddenwith it, or enable a button. The findings of that scan are those of the page as it was found:aria-hidden-focusreads which subtrees are hidden, and the markup it reports, before it focuses anything. But the page is left as its scripts made it, so a second scan of the same page can find something else: if you scan a page you go on using, or scan one page twice, reload it in between. Since the spacing is taken off again, a screenshot taken after the scan shows text thattext-spacing-content-lossfound cut off as fitting; such a finding names the state it was made in (data.details.evidenceState: 'text-spacing'), andevidenceState()from@surea11y/coreputs the page in it again for the picture, as the bindings'.screenshots()do. - A style sheet from another origin can't be read from the page. A page's script can't list the rules of a sheet another origin serves (a CDN, a fonts service), so
css-orientation-lockasks about it (STYLESHEETS_NOT_READABLE), andcss-hidden-focusandcss-focus-indicator-suppressedcan't see a focus rule in it. Pass its text inengineOptions.styleSheetTexts:preloadStyleSheets()fetches those the server shares (CORS), and a tool driving the browser can fetch the others from outside the page (seeINTEGRATION.md, on GitHub). - 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 nativecheckedproperty 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 whyaria-checked-state-mismatchis capped atmanual/cantTellrather than a hardfail. 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. - A layout is measured at one viewport, with the fonts the page has at that moment. Rules that measure what the browser draws,
text-spacing-content-loss,target-size-minimumandskip-link, hold for the viewport the page was scanned at: a responsive page can cut text off, or shrink a target, at one width and not at another, and text measured before its web font loads is a different width. The result records both inengine.environment, and the findings of those rules repeat their viewport and say what was measured (how far text went past the edge, how big a target is and how close its neighbour), so a finding that differs between runs can be traced to the conditions. Scan at each width you care about, and wait for the page to settle before scanning:waitForPageReady()waits for the load event, web fonts and images (seeINTEGRATION.md, which shows how to call it before a scan), and the result records inengine.environmentwhether fonts or images were still loading. - In a browser, contrast is measured against what is painted under the text, as the page is at that moment. Text over an image, a gradient, or paint covering only part of it is asked about (
contrast-computable, reason codeBACKGROUND_OVERLAP) rather than measured. Text that a box withoverflow: hiddenkeeps wholly out of view, such as a carousel slide off its track, is left out until it is in view, so scan the page in that state to check it. Fixed and sticky boxes over text are ignored. Under jsdom, text is measured against its ancestors alone. Scanned under forced colors (Windows high contrast, or an emulatedforced-colors: active), the page is kept in its own colors while it is measured, so the outcomes are those with forced colors off, unless the page's own CSS reacts to forced colors (aforced-colorsmedia query, or a system color such asGrayText), which the scan reports inengine.environment.forcedColorsCss; where it can't be (a Content Security Policy blocking the engine's<style>in a browser that can't adopt a style sheet), the colors measured are partly the browser's, and each contrast occurrence says so (details.forcedColors). How the measurement works, step by step:LAYOUT_MEASUREMENT.md, on GitHub. - Text on a page with no background is measured on the canvas of the page's color scheme, where that canvas is known. The browser paints its canvas behind a page that paints no opaque background, in the
Canvascolor of the color scheme the page is drawn in, which the page's owncolor-schemedecides: white for a page that declares none, whatever the user prefers, and the dark canvas for one drawn in dark. The contrast rules measure text there in either mode. Not measured, and asked about bycontrast-computable(BACKGROUND_NOT_OPAQUE_AT_ROOT): text in a frame, whose canvas can let the page around it show through, and text under forced colors, where the canvas is the user's palette. - Under jsdom, text is measured in the colors of the scheme the page is drawn in where they can be worked out, and asked about where they can't. jsdom computes the light scheme's colors and applies no
prefers-color-schemerule. On a page drawn in dark (color-scheme: darkon the root or in<meta name="color-scheme">), text the scheme colors takes the dark scheme's colors (Chromium's), and a text color given aslight-dark(),CanvasText,LinkTextorCanvasis worked out for dark. Text whose colors in that scheme can't be worked out is asked about (contrast-computable,COLOR_SCHEME_NOT_RENDERED;link-in-text-block,COLOR_NOT_COMPUTABLE) rather than measured in the light colors. In a browser, the colors are the ones it draws. - On a page that supports light and dark, the scheme the scan doesn't see is measured only where the text's colors can't change with it. A scan sees the scheme the browser prefers (jsdom: light). Text on the canvas is measured in the other scheme too, except where a
prefers-color-schemerule, alight-dark()value or a system color the page sets reaches the text's color or a background behind it, or where a style sheet can't be read; nor is text drawn by an outline, a shadow,::first-lineor a pseudo-element, or text measured over other paint.data.details.colorSchemesCheckedlists the schemes measured; scan again with the browser in the other scheme to check it. - Generated text is measured only in a browser, and only where it can be read. The text a
::beforeor::aftercontentwrites and a list item's::markerare measured in the pseudo-element's color and font; content that can't be read (a counter in a page's own@counter-style), and a::beforeor::afterpositioned out of the flow with no background of its own, are asked about (contrast-computable) instead. Markers are looked for only on<li>. Under jsdom, which computes no pseudo-element styles, generated text is left out. - A form field's text and placeholder are measured as the field draws them, with gaps. The text a browser draws in an empty date or time field, and a file input's button and file name, are not measured. A drop-down
<select>is measured as the closed control draws it, its shown option in the select's own colors; another of its options with colors of its own is shown only in the list the platform draws when it opens, and iscantTell(SELECT_OPTION_COLOR). A placeholder below its ratio fails (PLACEHOLDER_BELOW_THRESHOLD) when the page chose its colors, meets the ratio when they are all the browser's own (technique G148), and iscantTellwhen that can't be told. Under jsdom, placeholders are not measured at all. Details:LAYOUT_MEASUREMENT.md. target-size-minimumworks the hit region out from the layout rather than by hit-testing. Targets below the fold or in a scrolling box are measured whole, as if scrolled into view; fixed and sticky boxes over a target are ignored; 3D transforms, mostclip-pathshapes and an ancestor's rounded clipping are measured as a bounding box; and links between separators alone ("Edit | Delete") that crowd each other are asked about rather than exempted. Under jsdom the rule isnotApplicable. Details:LAYOUT_MEASUREMENT.md.- Contrast is computed in sRGB. Colors given in
oklch(),oklab(),lab(),lch()orcolor()(the computed form ofcolor-mix()and relative colors too) are converted to sRGB, as a standard display shows them; one outside sRGB, such as a saturateddisplay-p3red, is clipped to its edge, which is what Chromium draws on an sRGB screen. A wide-gamut screen can show that color more saturated than the ratio assumes. A background color the engine cannot convert, acolor()in a space it does not know, is not treated as transparent:contrast-computableasks about the text with reason codeBACKGROUND_UNPARSABLE, and the other contrast rules leave it out.
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-qualityandform-control-label-qualityflag leftover placeholders, numbered template slots, filenames and URLs against curated exact-match lists, the same precision-over-recall trade-offlink-name-qualitymakes. Both aremanualrules capped atcantTell— 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 13 ACT rules with no rule here; see the gaps section of
ACT_RULE_MAPPING.md) — 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) — whether an image holds text is in its pixels, which would need OCR-equivalent image understanding.
image-of-textasks only where markup shows a sign: an<input type="image">, and an<img>showing an SVG data URL whose markup holds<text>. A photo of a sentence, a banner with text baked in, or a CSS background image of text gives no sign, and is not asked about. 1.4.9, which allows no exceptions, has no rule. - 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
Most of the above is not unique to surea11y. Any tool that judges one state of the DOM shares the limits in Outside what one scan can see, and most automated tools leave the judgment calls to a person too, though tools differ in how they run, what they check, and how they report what they can't decide. 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 rule's pass does and doesn't claim, and why a WCAG criterion whose rules all pass is still cantTell.