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 loadBring 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 textScan 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 trapTest it with a keyboard. No scan covers it.
A result is cantTellFollow 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 responsiveScan at each width you care about.
Two runs of the same page differCompare engine.version and engine.environment (viewport, fonts and images still loading), and reload the page between scans.
A scan reports no failuresThat 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 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.
  • 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.toLowerCase and others) stops the scan with an error coded PAGE_BUILTINS_BROKEN that 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 adds then to Object.prototype is scanned as any other. A page that replaces Array.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, with PAGE_BUILTINS_BROKEN naming Array.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) by button-name-present, link-name-present, the menu item, option, summary, tab, tooltip and tree item name rules, and empty-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 declarative shadowrootmode="closed", so the engine can't see what a browser names from there. button-name-present and link-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 ElementInternals can'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 and aria-required-attr ask (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's internalsHookScript() for a page of your own (see INTEGRATION.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 focusable role="button" given its click listener from script is not reported (one that can't take focus is asked about by widget-keyboard-focusable all the same), a key listener added from script can't excuse an inline onclick, and password-paste-enabled can't tell whether a paste listener added from script puts back the text an inline onpaste cancels. 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-announcements judges only what is there: a live region hidden from assistive technology by aria-hidden or inert, or behind an open modal <dialog>, or left aria-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, and getAnnouncements() 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 with showModal() makes the rest of the document inert, and browsers give assistive technology only the dialog. A dialog opened with the open attribute or show() is not modal, even with aria-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 for showModal() by setting open and aria-modal="true" on the dialog sets engineOptions.assumeModal: true to 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) report notApplicable, 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 with role="dialog" or role="alertdialog" and aria-modal="true", also makes those structure rules notApplicable; aria-modal makes 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 without aria-modal="true" is handled only through the inert or aria-hidden the 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-present and region) are notApplicable: 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 sets engineOptions.topLevelDocument: true, so that page is judged as the page (@surea11y/cypress does). runa11yCoreAcrossFrames reads a frame under aria-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 sets embeddedAriaHidden and embeddedInert itself (see Engine options). Core 1.15.0 and later.
  • <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>, 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. 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.
  • An aria-details that names hidden content is not reported. aria-valid-attr-value checks 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 no showPopover(), and reads :popover-open inconsistently. Where the DOM has no showPopover, 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 content names nothing. jsdom computes no style for ::before and ::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, where button-name-present, link-name-present and 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-large is 32px) and calc() of lengths and numbers, so large text is told apart as in a browser. larger and smaller, 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 its adoptedStyleSheets reaches the computed style of the elements in it, so under jsdom colors, sizes, and display: none or visibility: hidden set 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-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.
  • 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} and style="background-image:url(hero.png)", the first read gives the image and every later one none. 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/testing does 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 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 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 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.
  • 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 getComputedStyle reads no rule inside @layer, @supports or @container, so a Tailwind v4 page's hidden and sr-only hid 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 @media as jsdom does: only all and screen apply, so a rule under (width >= 48rem) counts in a layer no more than outside one, and Tailwind's hidden md:block stays hidden under jsdom where a desktop browser shows it. @supports holds for any property test and for a selector() that parses, @container doesn't apply, and a revert, revert-layer or var() 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's a { 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, a var(), a rule under @supports, @layer, @container or 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 and p-as-heading judge text by the size it is drawn at: CSS zoom, an SVG transform and font-size-adjust included, the last from the font's own metrics (font-size: 24px; font-size-adjust: 0.3 draws Arial at about 14px, which is not large text). jsdom lays nothing out and reads no font, so there font-size is 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-focus focuses elements as the keyboard would, and switches transitions off on the element it measures; aria-hidden-focus focuses an element inside an aria-hidden subtree to see whether the page moves focus away at once (a focus-trap sentinel); text-spacing-content-loss adds 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 its focus and focusin handlers: a carousel may move to the focused slide, moving aria-hidden with it, or enable a button. The findings of that scan are those of the page as it was found: aria-hidden-focus reads 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 that text-spacing-content-loss found cut off as fitting; such a finding names the state it was made in (data.details.evidenceState: 'text-spacing'), and evidenceState() from @surea11y/core puts 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-lock asks about it (STYLESHEETS_NOT_READABLE), and css-hidden-focus and css-focus-indicator-suppressed can't see a focus rule in it. Pass its text in engineOptions.styleSheetTexts: preloadStyleSheets() fetches those the server shares (CORS), and a tool driving the browser can fetch the others from outside the page (see INTEGRATION.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 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.
  • 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-minimum and skip-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 in engine.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 (see INTEGRATION.md, which shows how to call it before a scan), and the result records in engine.environment whether 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 code BACKGROUND_OVERLAP) rather than measured. Text that a box with overflow: hidden keeps 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 emulated forced-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 (a forced-colors media query, or a system color such as GrayText), which the scan reports in engine.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 Canvas color of the color scheme the page is drawn in, which the page's own color-scheme decides: 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 by contrast-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-scheme rule. On a page drawn in dark (color-scheme: dark on 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 as light-dark(), CanvasText, LinkText or Canvas is 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-scheme rule, a light-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-line or a pseudo-element, or text measured over other paint. data.details.colorSchemesChecked lists 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 ::before or ::after content writes and a list item's ::marker are 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 ::before or ::after positioned 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 is cantTell (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 is cantTell when that can't be told. Under jsdom, placeholders are not measured at all. Details: LAYOUT_MEASUREMENT.md.
  • target-size-minimum works 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, most clip-path shapes 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 is notApplicable. Details: LAYOUT_MEASUREMENT.md.
  • Contrast is computed in sRGB. Colors given in oklch(), oklab(), lab(), lch() or color() (the computed form of color-mix() and relative colors too) are converted to sRGB, as a standard display shows them; one outside sRGB, such as a saturated display-p3 red, 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, a color() in a space it does not know, is not treated as transparent: contrast-computable asks about the text with reason code BACKGROUND_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-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 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-text asks 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.