Visibility & contrast extraction

Visibility, color and overlay extraction (jsExtractor.js)

Asqatasun injects JavaScript extractors into the rendered page via Selenium to capture runtime information that cannot be derived from static HTML alone: element visibility, computed foreground/background colors, image overlays and focus state. This page documents the full logic of these extractors.

Overview

Two JavaScript files work together during page load:

File Purpose Used by
jsExtractor.js Text nodes + colors + overlays + focus state Color contrast rules (3.2.x, 3.3.x)
visibilityExtractor.js Full visibility map with reason tracking Scenario audits

Both run before driver.pageSource is captured — this ordering is critical because the scripts may trigger scrollIntoView and focus/blur, and the CSS paths they produce must match the saved HTML (ScenarioLoaderImpl.kt proceedNewResult).

Output

For each traversed DOM element, jsExtractor.js emits a JSON object:

{
  "path": "body > div:eq(0) > p:eq(2)",
  "bgColor": "rgb(255; 255; 255)",
  "color": "rgb(0; 0; 0)",
  "fontSize": "16px",
  "fontWeight": "400",
  "textAlign": "start",
  "isHidden": false,
  "isTextNode": true,
  "hasPotentialImageOverlay": false,
  "isFocusable": false
}

When an overlay is detected, three extra fields are populated: overlayImageType, overlayImageSrc, and hasPotentialImageOverlay: true. When the element is focusable, three focus-related fields are added: outlineColorFocus, outlineStyleFocus, outlineWidthFocus.

1. Element traversal

The traversal starts at <body> and walks children recursively (extractInfo). Two guards prevent runaway recursion:

  • MAX_DEPTH = 500 — stops traversal on abnormally deep DOMs
  • Null / missing tagName check — defensive against malformed nodes

Excluded tags

isAllowedElement() returns false for tags that never carry visible text content or whose content is handled by other rules:

Tag Reason
<script>, <noscript>, <style>, <meta>, <link> Not rendered
<head>, <title> Document metadata
<svg> Handled by image rules, not by text contrast
<br> No text content
<option> Not rendered as inline text

Text node detection

isTextNode() identifies elements that carry text users can actually read:

  • <input type="text"> — the user types text into it
  • <img alt="..."> with a non-empty alt — the alternative text is “visible” to assistive technologies
  • Any element with at least one direct #text child whose textContent (after trimming) is non-empty

<iframe>, <noscript>, <script>, <style> are explicitly excluded.

The isTextNode flag is important downstream: overlay detection (§ 4) only runs on text nodes, and the Java ContrastChecker only applies WCAG ratios to text nodes.

2. Hidden element detection

isHidden() combines several CSS-level and visual-level criteria. An element is considered hidden if any of the following is true, OR if its parent is hidden (recursive check up to MAX_DEPTH).

Rendering-level criteria

Criterion CSS / HTML Description
hidden attribute <element hidden> HTML5 standard boolean attribute
display: none display: none Not rendered in layout
visibility: hidden visibility: hidden Invisible, occupies space
visibility: collapse visibility: collapse Table row/column collapsed

Transparent foreground with no visible effect

Lines 155-173. color: transparent usually hides text, but there are legitimate CSS tricks to make text visible despite a transparent color. The script detects these and keeps the element visible in those cases:

Property Rescue condition
-webkit-text-stroke-width Non-zero stroke width paints the text outline
text-shadow A shadow paints the text glyph
-webkit-text-fill-color Non-transparent fill color
background-clip: text Solid or image background clipped to text glyphs

Visually imperceptible patterns (sr-only / visually-hidden)

isVisuallyImperceptible() detects the W3C / Bootstrap / Tailwind pattern used to hide content visually while keeping it available to screen readers. These elements cannot display perceivable text and are treated as hidden.

Pattern Detection
Bounding rect ≤ 0 rect.width <= 0 or rect.height <= 0
Too small to display a glyph rect.width <= 3 AND rect.height <= 3
Positioned far off-screen rect.right < -1000 or rect.bottom < -1000 or rect.left > 10000 or rect.top > 10000
clip: rect(0,0,0,0) Matches rect(0, 0, 0, 0) with any spacing
clip: rect(1px,1px,1px,1px) The older sr-only variant
clip-path: inset(50%) Modern sr-only variant
clip-path: inset(100%) Full inset variant

Without this check, tracking pixels, skip links, screen-reader-only labels and other 1×1 elements produced false positives in rule 3.2.1 (contrast).

Parent inheritance

When an element itself is not hidden, the check walks up to its parent (lines 181-186). A child of a display: none ancestor is hidden even if it has display: block itself. Traversal stops at <html> or when MAX_DEPTH is reached.

3. Color extraction

Background color

getBackgroundColor() returns one of three values:

  1. "background-image:url(...)" when a background-image is set — the contrast checker cannot compute a ratio in this case and raises NMI
  2. "rgb(r; g; b)" when a solid background-color is set (commas are replaced with ; to simplify JSON parsing — see formatColor())
  3. "transparent" or "rgba(0; 0; 0; 0)" when no background is defined

When the computed value is transparent, the extractor substitutes the parent’s resolved background color so the contrast checker sees the color that will actually be painted behind the text (lines 463-473). If no parent has an opaque background, the root <body> falls back to white rgb(255; 255; 255) — this matches the browser’s default rendering.

Foreground color

getForegroundColor() simply reads the computed color property and normalizes commas.

Typography

fontSize, fontWeight and textAlign are captured via getComputedStyle. These feed the ContrastChecker thresholds — normal text is checked at 4.5:1 below 24 px; bold text at 3:1 below 18.5 px.

4. Overlay image detection

Text on top of images or gradients has no computable contrast ratio. Rather than marking every transparent-bg text as FAILED, jsExtractor.js tries two heuristics to detect images “behind” the text and routes such elements to NMI instead.

4.1 Browser hit-testing

checkImageBehind() uses document.elementsFromPoint() at 5 points on the element (center + 4 inset corners):

  1. Skip if bounding rect is 0 or ≤ 1 px (already covered by isVisuallyImperceptible, but redundancy is cheap)
  2. Skip if the element is far off-screen
  3. If the element is outside the viewport, scrollIntoView({block: 'center'}) is called to make it hit-testable
  4. For each point, walk the element stack returned by elementsFromPoint:
    • Skip self and its descendants (elem.contains(el))
    • Skip ancestors (el.contains(elem))
    • Match <img>, <picture>, <video>, <canvas>, or any element with a background-image: url(...)
  5. Return {type, src} on first match

Only called when isTextNode is true AND the computed bgColor is transparent (line 468).

4.2 Pseudo-element sibling overlays

checkPseudoOverlay() catches a pattern elementsFromPoint cannot see: a sibling <div> with ::before / ::after pseudo-elements containing a gradient or image that overlays text. Walks up to 5 levels up the parent chain and inspects siblings for ::before/::after with content, absolute/fixed positioning, and background. Pure DOM traversal — no reflow, no side effects.

Only called on text nodes when the primary overlay detection returned nothing (line 480-482).

4.3 Decision flow

The Java ContrastChecker.createRemarkOnBadContrastElement uses the overlay fields like this:

Condition Outcome Message
isHidden NMI BadContrastHiddenElement
hasPotentialImageOverlay NMI BadContrastPositionedElementWithImageInContext
Contrast ratio exactly 1.00 (fg == bg) on visible element NMI SuspectContrastCheckImageBehindText — heuristic: text invisibly matching its background is likely meant to be on an image the hit-test missed
Alternative contrast mechanism enabled NMI BadContrastButAlternativeContrastMechanismOnPage
Default FAILED BadContrast

See OVERLAY_DETECTION_NOTES.md for the design rationale and abandoned approaches.

5. Focusability

isPotentiallyFocusable() is a performance gate: focus() / blur() calls trigger layout and event firing, so they are only issued on elements that can actually take focus:

  • <a href> / <area href>
  • <button>, <input>, <select>, <textarea>
  • <details>, <summary>
  • Any element with tabindex
  • Any contenteditable element

When focus is accepted (document.activeElement === elem), the focus-state outline is captured (outline-color, outline-style, outline-width) for use by RGAA rule 10.7 (focus indicator).

6. Path building

buildPath() produces a jQuery-style path such as body > div:eq(0) > p:eq(2). The path is the contract between the JS extractor and the Java side: after loading, DomElementExtractor uses SSPHandler.domCssLikeSelectNodeSet(path) to re-locate the element in the jsoup-parsed HTML.

This is why script execution must happen before pageSource capture: any DOM mutation after extraction (e.g. from scrollIntoView triggering lazy rendering frameworks) would invalidate the paths.

7. JSON escaping

escapeForJson() escapes backslashes, quotes, newlines, carriage returns and tabs before values are concatenated into the result array. Without this, URLs containing quotes in background-image:url("..."), or text content with special characters, would break JSON parsing in DomElementExtractor.

8. Observability

The extractor logs execution stats (lines 554-564) captured by Selenium’s console log handler:

jsExtractor stats: time=42ms, maxDepthReached=18,
  elementsProcessed=1234, focusOperations=56

The first entry of the result array is a _stats object with the same information for Kotlin-side parsing.

When -Dasqatasun.debug.domelement=true is set, a detailed overlay trace is written to /tmp/asqatasun-debug/{auditId}_{url}_overlayDebug.json by ScenarioLoaderImpl.saveDebugSource, capturing the element stack returned by elementsFromPoint at each check. See DEBUG_OVERLAY in the source.

Related