ACT rule mapping

August 29, 2026 · View on GitHub

Cross-reference between the W3C ACT Rules (as published at act-rules.github.io) and this repo's rule catalog (docs/RULE_CATALOG.md). Built by matching rule names/descriptions and WCAG SC, not machine-generated, so treat close calls as a starting point for review rather than ground truth.

Summary (117 active ACT rules, excludes 3 deprecated):

  • 58 confirmed direct, family, or partial matches in our automatic/manual rules (scripts/data/act-rule-map.json is the machine-readable version of the table below)
  • ~2 are covered structurally by our composite/rollup layer, not a named rule
  • ~46 are gaps, no corresponding rule in this repo, listed in Gaps below

Every matched rule has now been run through ACT's own official test-case corpus (scripts/act-testcase-check.js, 713 test cases across the 51-rule matched set of the time). Started at 86 mismatches; real bugs were fixed, mapping errors corrected, and every remaining mismatch triaged into a scope difference, a jsdom/environment limit, or a genuine open design question (tracked in docs/DESIGN_CHALLENGES.md). A second pass then re-ran the whole corpus from a local checkout (see "Second pass" below) and repeated the exercise on what it turned up. Current state: 798 examples across the 58-rule matched set, 31 mismatches, all explained below or in that file, and — the figure that matters for an implementation report — zero false positives: no example ACT declares passed or inapplicable is failed by this engine, so every remaining mismatch is a case it does not catch rather than one it gets wrong; see "Progress" further down for the full per-rule breakdown. (The exact example count drifts slightly over time as ACT's own published corpus gains or loses cases; re-run scripts/act-testcase-check.js for the live figure rather than trusting this number indefinitely.)

Real rule bugs found and fixed this way, in rough chronological order:

  • button-name-present wasn't crediting the UA-default label on input[type=submit]/input[type=reset] with no value, and wasn't honoring role="none"/role="presentation" conflict-resolution.
  • link-name-quality was scoped to a[href] only; widened to a[href], area[href], [role="link"] to match ACT's "any semantic link" applicability (safe here, its logic is name-text-only, no destination resolution).
  • contrast-minimum/contrast-enhanced (shared isLargeText helper): a hardcoded 18.6667px bold-large-text threshold was a floating-point hair above the true value of 14pt converted to px, so text sized exactly at the boundary via pt units (the common real-world case) silently fell through to the stricter normal-text ratio. Fixed by deriving the threshold from the same parsePx() conversion instead of a decimal literal.
  • contrast-minimum/contrast-enhanced (shared isInactiveUiComponent helper): the WCAG 1.4.3/1.4.6 "inactive UI component" exception only walked the text's own ancestor chain for :disabled/aria-disabled, missing the case where the low-contrast text is a <label> (native association or aria-labelledby-referenced) for a sibling disabled widget rather than a descendant of one.
  • aria-required-parent: a roleless ancestor carrying a global ARIA attribute (e.g. aria-live) is still included in the accessibility tree, so it should block the required-context-role search the same way a real role would; it was being treated as transparent instead.
  • dom-helpers.js's inClosedDetailsContent(): a closed <details> element was judging its own accessibility-tree eligibility against its own open state (closest('details') matches the node itself), hiding the <details>/<summary> toggle along with the content it's supposed to keep hiding.
  • img-alt-present: alt=" " (whitespace-only) was treated the same as alt="" (the real decorative marker). Per HTML-AAM the img-to-presentation role flip only fires on the literal empty string, so a whitespace-only alt keeps the img role with an empty computed name, a real failure.
  • role-img-text-alternative-present (then named role-img-alt-present): an inline SVG's own <title> child element (the standard SVG-AAM naming mechanism) wasn't recognized as a name source, separate from the HTML title attribute.
  • table-headers-attr-valid: cells inside a role="presentation"/"none" table are out of scope entirely, and a referenced header can be any cell (td or th) of the same table, not only a th.
  • dom-helpers.js's offscreen heuristic: em/rem values (e.g. top: -999em) were compared against the -5000 px threshold as a bare number, so they never registered as offscreen.
  • getAccessibleNameInfo never consulted the alt attribute for img/area/input[type=image], so e.g. an <area> named only by alt had no computed name anywhere outside the img-specific rules.
  • getContentNameInfo: a role="presentation"/"none" image-like descendant was still contributing its alt text to an ancestor's content name, even though alt doesn't trigger presentational-roles conflict resolution.
  • identical-links-same-purpose: fell back to raw el.textContent instead of the content-aware name helper, so a link named only by a descendant image's alt (no text nodes at all) was silently skipped. Also fixed SVG <a>'s .href, which is an SVGAnimatedString rather than a plain string, so the destination comparison was reading a useless stringified wrapper for every SVG link.
  • meta-refresh-timing-absent / meta-refresh-no-exceptions: only the first valid meta refresh in a document is ever acted on by a browser; both rules were evaluating every matching <meta> tag independently. meta-refresh-no-exceptions's own header comment also claimed AAA drops the zero-delay exception; it doesn't, since an immediate redirect isn't a timed interruption at any level.
  • table-th-has-data-cells: extended the existing "<th> with zero <td> anywhere" check to its ARIA role="grid"/"treegrid" equivalent.
  • empty-heading: a heading whose only content is a role="presentation" image no longer gets that image's alt text as its name; a native heading tag marked role="none"/"presentation" but carrying a global ARIA attribute (even an empty one) is still evaluated as a heading, per conflict resolution.
  • aria-required-attr: an explicit role identical to an element's own native role is now exempt (e.g. <input type="checkbox" role="checkbox"> needs no aria-checked); role="combobox" now requires aria-controls once aria-expanded="true".
  • aria-allowed-attr had no answer for an element HTML-AAM maps to no ARIA role at all: <audio controls aria-orientation="horizontal"> (ACT 5c01ea's own failed example) was skipped, because an empty implicit-role lookup was indistinguishable from "a role this table does not model." A generated ROLELESS_ELEMENTS set makes the absence itself the answer. <div>/<span> also joined the context-free table as generic, so a role-specific attribute on a bare div is now reported rather than passed over.
  • getContainmentRole handed several native tags an implicit role in every context, where HTML-AAM makes them conditional: <li> is a listitem only inside <ul>/<ol>/<menu>, <option> only inside select/datalist/optgroup, the table family only inside a real table. ACT bc4a75 fails <div role="list"><li>Item</li>…</div> for exactly that reason and the engine passed it. The three rules built on that helper (aria-required-children, aria-prohibited-children, aria-required-parent) all inherit the fix.
  • identical-links-same-purpose's a[href]-only selector missed role="link" elements entirely; ACT fd3a94/b20e66's own failed examples are <span role="link" tabindex="0" onclick="location='...'">. Widened to a[href], [role="link"], with a regex fallback that reads a location/location.href/location.assign(...)/location.replace(...) destination straight out of the onclick attribute value, a literal string already present in markup, no script execution needed. This rule is cantTell-capped, so an unrecognized onclick shape just costs recall, not a false fail.
  • iframe-name-present's focusability exemption only applied inside the role="none"/"presentation" branch; a plain <iframe tabindex="-1"> with no role at all (ACT cae760's own passed example) still demanded a name. Per cae760's own Applicability text, an iframe is only in scope when it is BOTH accessibility-tree-eligible AND reachable by sequential focus navigation, unconditionally, not only as a role="none" carve-out. The focusability check now gates applicability directly, regardless of role.
  • avoid-inline-spacing failed text that can never take a soft wrap break, which ACT 78fd32/24afc2/9e45ec exclude from applicability altogether. This was the engine's last false positive on the corpus, and the rule's own header had already predicted it: layout settles whether text wraps and a static scan cannot. Two shapes do establish that no wrap is possible without layout — text not allowed to wrap, and a fixed-width element inside a horizontally scrolling ancestor — and those now report cantTell instead of fail. Everything else is still treated as wrapping, so an ordinary forced value below the metric fails as before.

Mapping-table corrections found this way (data-only, no rule-code change):

  • bc4a75 was mapped to aria-required-children alone, but this repo splits ACT's single question into two atomic decisions: "does a required child exist" and "is every owned child allowed," the second being aria-prohibited-children. Measuring one rule against a two-part expectation reported the missing half as an engine gap; it was a mapping gap. Now a family match.
  • qt1vmo and 23a2a8 were missing existing sibling rules from their ourRuleIds family (canvas-text-alternative-quality/svg-text-alternative-quality, and role-img-text-alternative-present, respectively); the code to catch these cases already existed, just wasn't wired into the mapping.
  • bf051a was mapped to valid-lang (which skips the <html> element by design) instead of html-lang-attr-present, which actually validates it.
  • b40fd1 was mapped to region (an unrelated best-practice check) instead of bypass-blocks-present, which already implements this exact WCAG 2.4.1 technique alongside its cf77f2/ye5d6e/047fe0 siblings.
  • oj04fd was mapped to css-hidden-focus on a surface keyword match ("focus," "visible"); the two check unrelated things (element visibility while focused vs. whether a focus indicator is suppressed by CSS). Removed from the matched table; see the Gaps section, where it's also flagged as a plausible new-rule candidate.
  • cc0f0a and c4a8a4 were mapped to form-control-programmatic-label-quality and page-title-patterns, both of which only catch weak-primary-mechanism/generic-pattern cases, never real label-text-vs-field or title-vs-content relevance, un-covered by either rule. Moved both ACT ids to Gaps and both of our rules to Extra coverage (they remain valid, independent checks with no ACT counterpart of their own).

Gaps closed since:

  • 307n5z "Element with presentational children has no focusable content" is now implemented by the new presentational-children-focusable-absent rule. The gap entry it replaces described the wrong mechanism; it read the rule as being about an explicit role="presentation"/"none" attribute, which is the exact misreading ACT's own Background section warns against. The rule is about the implicit presentational-children trait a role carries (button, checkbox, img, option, tab, ...): those roles drop their whole subtree from the accessibility tree, so a descendant that still takes a tab stop receives focus with no role and no name. Clean against all 11 of ACT's examples.
  • 46ca7f "Element marked as decorative is not exposed" needed no new rule at all: presentation-role-conflict already implements it, end to end, and was simply never mapped, the gap entry was a mapping miss, same class as the qt1vmo/23a2a8 misses above. It runs clean against all 10 of ACT's examples, and the one scope difference the corpus exposed (an <img alt=""> carrying an explicit role of its own is not "marked as decorative," since the explicit role wins over the presentation role empty alt would confer) is now fixed in the rule.
  • b49b2e "Heading is descriptive" is now half-covered by the new heading-quality rule: a heading whose accessible name is a placeholder, a generic word ("Heading", "Untitled"), a numbered template slot ("Section 2"), a filename, or a URL, cannot describe anything, and that much is deterministic. Whether a well-formed heading is about the content after it is not, so the rule is cantTell-capped and b49b2e is mapped partial. Clean on all 6 passed and both inapplicable examples (no false positives); the 5 failed examples (grown from 4 as the live corpus gained a case since this was last checked) are all the topic-relevance shape.
  • oj04fd "Element in sequential focus order has visible focus" is now partly covered by the new css-focus-indicator-suppressed rule: it reads the page's own stylesheets for a :focus/:focus-visible rule that removes the outline, and reports the tab stops it matches unless some other focus rule draws a replacement for them. ACT's expectation is a pixel comparison between the focused and unfocused states, which no static check performs, and its passed examples paint their indicator from an onfocus handler onto a sibling, so the rule is cantTell-capped and the mapping is partial.
  • cc0f0a "Form field label is descriptive" is now partly covered by the new form-control-label-quality rule. Three shapes of a bad label are decidable from markup: a placeholder string, a label repeated on several fields with no visible heading, legend or row text telling them apart, and a label split between visible and hidden parts. The second and third are what ACT's own failed examples 4 and 5 test, and the rule catches both; the remaining three failures turn on the meaning of an ordinary word, so the mapping is partial and the rule is cantTell-capped.
  • 5effbb "Link in context is descriptive" is now partly covered by link-name-quality, which already flagged a curated list of generic phrases ("click here", "read more", ...) with no regard for context. It gained a second curated list, bare file-format names ("HTML", "PDF", "EPUB", ...), and both lists now check for adjacent context (an aria-describedby target, the enclosing list item/table cell/paragraph's own text, or, for the format-name list only, a table's first-row header) before flagging, so a phrase that context already resolves is left alone. Clean on 17 of ACT's 18 examples; the remaining one needs to judge whether an ordinary word ("Workshop") actually relates to a nearby paragraph, a step beyond a phrase list.
  • 3ea0c8 "Id attribute value is unique" is closed by the new duplicate-id rule, clean against all 10 of ACT's examples, including the per-tree scoping that keeps the same id inside two different shadow roots from counting as a duplicate. It is the first rule in the catalog whose Success Criterion no longer exists in the current WCAG version, so it carries wcag22-removed alongside its wcag2a origin tag; under the default 2.2 target the engine reports its findings as cantTell rather than fail. See docs/ENGINE_OPTIONS.md for that and for excluding the rule outright.

Second pass: the corpus read from a local checkout

act-rules.github.io is unreachable from the environment this pass ran in, so the examples were read straight out of a local checkout of the act-rules/act-rules.github.io repository (_rules/*.md) instead of the generated per-case pages scripts/act-testcase-check.js fetches. Same corpus, different entry point: running the manifest's existing entries through it reproduces the published per-rule results (97a4e1 clean, 6cfa84 clean, d0f69e 3 mismatches, ...), which is what makes the new results trustworthy.

It carries 821 examples against the 713 test-case pages the first pass fetched. The gap is not explained from here, the published pages can't be diffed against without network access, so everything it surfaced beyond the 49 already-triaged mismatches was read on its own merits rather than assumed to be a regression. What it found, in four groups:

Real rule bugs, fixed:

  • table-headers-attr-valid only took a table out of scope for role="presentation"/"none". Any explicit role replaces the native table semantics, so <table role="heading"> has no table for a cell's headers attribute to describe either; the applicability now keeps only table/grid/treegrid, matching ACT a25f45.
  • aria-required-attr never required aria-valuenow on a separator. A plain separator is a structural divider that needs no value, but a focusable one is a splitter the user can move, and WAI-ARIA requires the value then, the same conditional shape as combobox's aria-controls, which the rule already handles.
  • iframe-name-present demanded a name from an iframe the author had marked decorative with role="none"/"presentation". ACT cae760 excludes those outright, and the contradiction between "decorative" and a restored role is presentation-role-conflict's report, not this rule's.

Mapping fix (data-only): e086e5's family was missing binary-control-name-present and menuitem-name-present, so ACT's checkbox/radio/switch/menuitemcheckbox/menuitemradio cases looked uncovered when the rules for them already existed, the same shape as the qt1vmo/23a2a8 misses above.

Re-checked against the live rule page (not just the local checkout) and settled, all in docs/DESIGN_CHALLENGES.md's Decided section:

  • aria-required-children/aria-prohibited-children only evaluating containers with an explicit role isn't a gap; ACT bc4a75's own Applicability text requires one, and its Inapplicable Example 2 is a bare <ul><li>.
  • aria-prohibited-children was a real bug: it only treated role="group"/role="rowgroup" as transparent when the container's own required-owned set named group/rowgroup, so role="list" wrapping valid listitems in a role="group" was wrongly flagged. Group/rowgroup are transparent unconditionally. bc4a75 now runs clean.
  • label-in-name had no exemption for "non-text content" characters; ACT 2ee8b8's own failed examples for us, <button aria-label="close">X</button> and a Material-Icons-font-remapped search->magnifying-glass button, are both text standing in for an icon rather than literal words. The live corpus's actual 13 examples don't exercise the separately-claimed aria-hidden/visually-hidden/inline-concatenation divergence at all (unsubstantiated against current ground truth, likely a local-checkout artifact same as the bc4a75 case); that narrower part stays open in docs/DESIGN_CHALLENGES.md on its own merits, not as an ACT mismatch. 2ee8b8 now runs clean.

The afw4f7/09o5cg same-color note and the 8fc3b6 "data URL" note that used to sit here are both gone: the former was a misreading (see docs/DESIGN_CHALLENGES.md's Decided section) and is fixed, and the latter no longer reproduces against the live corpus (re-verified 2026-08-19, 0/14), corpus drift, not a code change on our side.

We also have automatic rules with no ACT counterpart at all (see Extra coverage), mostly a finer-grained decomposition of ACT's single "form field has accessible name" rule into one rule per ARIA widget role.

Progress: full validation results, by ACT rule

Clean (0 mismatches): 5f99a7, 80f0bf, 4c31df, 73f2c2, 97a4e1, cf77f2, b40fd1, 46ca7f, 6cfa84, 307n5z, 4e8ab6, a25f45, ffd0e9, b5c3f8, 2779a5, 5b7ae0, bf051a, qt1vmo, 59796f, 23a2a8, 24afc2, 9e45ec, c487ae, m6b1q3, bc659a, bisz58, b4f0c3, 674b10, 0ssw9k, 3ea0c8, 5c01ea, bc4a75, 2ee8b8, e88epe, 7d6734, de46e4, 6a7281, 8fc3b6, akn7bn, fd3a94, b20e66, cae760, 78fd32 (43 of 58 matched rules).

Two rules changed what they report after this table was last regenerated, both from fail to cantTell: 3ea0c8/duplicate-id under the default WCAG 2.2 target, and 6a7281/aria-valid-attr-value for an unresolved aria-controls target (see docs/DESIGN_CHALLENGES.md). The verdicts above should still hold, since evaluate() in scripts/act-testcase-check.js counts a cantTell carrying occurrences as satisfying an ACT "failed" expectation and a cantTell never breaks a "passed" one, but neither was re-run against the live corpus at the time (the site was unreachable from that environment). Re-run both when convenient.

Remaining mismatches (31 total), all triaged. Every one is an ACT failed example this engine does not flag — a coverage gap, which a partially consistent implementation is allowed — not an example it fails wrongly:

ACT IDMismatchesCategory
ff89c91env/harness limit: jsdom doesn't execute inline <script>, so a runtime-created shadow root is invisible to the test fetcher (not the real engine, which runs after page scripts)
aaa1bf1different question, not a gap in ours: aaa1bf's own applicability/expectation is purely about clip duration ("does the audio stay under 3s," explicitly not exempted by a controls mechanism); no-autoplay-audio answers WCAG 1.4.2's other disjunct instead (does a pause/stop/volume mechanism exist). Duration isn't knowable from static markup regardless, no browser decodes media at scan time, so this mismatch can't close even in principle, not because our rule falls short of it
ye5d6e1scoped leniency: whether repeated-boilerplate content wraps the skip target is a cross-page judgment undecidable from one document; the rule's own header comment already reasons through this trade-off
047fe02one of each: scoped leniency (a heading sitting inside the repeated <nav> block itself, same cross-page judgment as ye5d6e above), and an env/harness limit (a heading positioned off-screen via a <link>ed external stylesheet the test fetcher doesn't load, same class as oj04fd below; tests/engine-checks/manual/bypass-blocks-present.test.js pins the real, fixed behavior with the same CSS inlined)
e086e52accepted divergence, decided 2026-08-19, see docs/DESIGN_CHALLENGES.md's "Decided" section: <label for>/wrapping association stays honoured on non-natively-labelable ARIA widgets, because a screen reader that announces such a label makes "no accessible name" a false positive. Real AT behaviour wins over the spec reading here
oj04fd1env/harness limit: ACT's one failed example keeps its outline: none in a linked stylesheet, which the example runner does not fetch, so no focus rule is visible to parse at all. Inlining that same CSS reports the element (tests/engine-checks/manual/css-focus-indicator-suppressed.test.js pins it); a real page hands the engine its stylesheets through the CSSOM
cc0f0a3inherent limitation: form-control-label-quality catches the three deterministic shapes (a placeholder label, a label repeated with no visible heading/legend/row telling the fields apart, a label split between visible and hidden parts, which covers ACT's failed examples 4 and 5). The remaining three fail on the meaning of a well-formed word, <label>Menu</label> over a first-name field, which no markup-level check reaches
b49b2e5inherent limitation: heading-quality catches placeholder heading text (a generic word, a numbered template slot, a filename, a URL), which is the deterministic half of this rule; whether a well-formed heading actually describes the content after it is a reading-comprehension judgment, and all 5 of ACT's failed examples are exactly that shape ("Weather" over opening hours, across five different heading-naming mechanisms)
aizyf12inherent tension with 5effbb's own examples, not a bug: both remaining cases (<a>this product</a> after "See the description of", and a format-name list under an "Ulysses" heading) are ACT's own passed examples for 5effbb (context-aware) but failed examples for aizyf1 (context-blind: the accessible name alone, ignoring what makes it clear, must already be descriptive). One shared rule can credit context or not, not both on the same markup; link-name-quality sides with 5effbb's reading, which is what its context-detection is for
5effbb1genuine judgment gap: the one remaining case (<a>Workshop</a> after an unrelated paragraph) needs to judge whether an ordinary word actually relates to nearby prose, not a phrase-list or context-structure question link-name-quality can answer
d0f69e3documented false-negative policy: table-th-has-data-cells's own header comment explains it only catches the unambiguous "zero data cells anywhere" case, not real positional header-association (the new ARIA-grid coverage added during this pass is real but doesn't happen to close these 3 specific positional-mismatch cases)
09o5cg, afw4f74, 4environment-dependent: gradient/image backgrounds not yet decomposed into solid contributing colors, and jsdom not executing inline <script> (same class as ff89c9, not really about includeShadowDom); the text-shadow-as-contrast-aid case is fixed (contrast-computable now reports cantTell for it, see docs/LIMITATIONS.md for the jsdom double-read bug this uncovered)
f51b461inherent limitation: video-caption's own header comment states it can't verify a caption track's content accuracy, only that one is declared, a human-judgment task

Matched rules

ACT IDACT rule nameOur rule(s)Match
5f99a7ARIA attribute is defined in WAI-ARIAaria-valid-attrexact
ff89c9ARIA required context rolearia-required-parentexact
bc4a75ARIA required owned elementsaria-required-children, aria-prohibited-childrenfamily (we split by decision)
6a7281ARIA state or property has valid valuearia-valid-attr-valueexact
5c01eaARIA state or property is permittedaria-allowed-attrexact
80f0bfAudio/video avoids autoplaying audiono-autoplay-audio (manual)family
4c31dfAutoplaying audio/video has a control mechanismno-autoplay-audio (manual)family
aaa1bfAutoplaying audio/video has no audio > 3sno-autoplay-audio (manual)family
73f2c2Autocomplete attribute has valid valueautocomplete-validexact
97a4e1Button has non-empty accessible namebutton-name-presentexact
cf77f2Bypass Blocks of Repeated Contentbypass-blocks-present (manual)exact
ye5d6eInstrument to move focus to non-repeated contentbypass-blocks-present (manual)family
047fe0Document has heading for non-repeated contentbypass-blocks-present (manual)family
b40fd1Document has a landmark with non-repeated contentbypass-blocks-present (manual)family
46ca7fElement marked as decorative is not exposedpresentation-role-conflict (manual)exact
oj04fdElement in sequential focus order has visible focuscss-focus-indicator-suppressed (manual)partial
6cfa84Element with aria-hidden has no content in sequential focus navaria-hidden-focusexact
de46e4Element with lang attribute has valid language tagvalid-langexact
307n5zElement with presentational children has no focusable contentpresentational-children-focusable-absentexact
4e8ab6Element with role attribute has required states/propertiesaria-required-attrexact
e086e5Form field has non-empty accessible nameform-control-programmatic-label-present, textbox-name-present, combobox-name-present, listbox-name-present, searchbox-name-present, slider-name-present, spinbutton-name-presentfamily (we split by widget role)
cc0f0aForm field label is descriptiveform-control-label-quality (manual)partial
a25f45Headers attribute refers to cells in same tabletable-headers-attr-validexact
ffd0e9Heading has non-empty accessible nameempty-heading (manual)family
b49b2eHeading is descriptiveheading-quality (manual)partial
b5c3f8HTML page has lang attributehtml-lang-attr-presentexact
2779a5HTML page has non-empty titlepage-title-presentexact
5b7ae0HTML page lang/xml:lang attributes matchhtml-xml-lang-mismatchexact
bf051aHTML page lang attribute has valid language taghtml-lang-attr-presentexact
3ea0c8Id attribute value is uniqueduplicate-idexact
cae760Iframe element has non-empty accessible nameiframe-name-presentexact
akn7bnIframe with negative tabindex has no interactive contentiframe-focusable-contentexact
qt1vmoImage accessible name is descriptiveimg-alt-quality, canvas-text-alternative-quality, svg-text-alternative-quality (all manual)family
59796fImage button has non-empty accessible nameinput-image-alt-presentexact
23a2a8Image has non-empty accessible nameimg-alt-present, role-img-text-alternative-presentfamily
e88epeImage not in the accessibility tree is decorativeimg-alt-decorative (manual)exact
24afc2Letter spacing in style attrs not !importantavoid-inline-spacingexact
78fd32Line height in style attrs not !importantavoid-inline-spacingexact (combined rule)
9e45ecWord spacing in style attrs not !importantavoid-inline-spacingexact (combined rule)
c487aeLink has non-empty accessible namelink-name-presentexact
aizyf1Link is descriptivelink-name-quality (manual)exact
5effbbLink in context is descriptivelink-name-quality (manual)partial
fd3a94Links with identical names + same context, equivalent purposeidentical-links-same-purpose (manual)exact
b20e66Links with identical accessible names, equivalent purposeidentical-links-same-purpose (manual)exact
m6b1q3Menuitem has non-empty accessible namemenuitem-name-presentexact
bc659aMeta element has no refresh delaymeta-refresh-timing-absentexact
bisz58Meta element has no refresh delay (no exception)meta-refresh-no-exceptionsexact
b4f0c3Meta viewport allows for zoommeta-viewport-zoom-enabledexact
8fc3b6Object element rendering non-text content has accessible nameobject-text-alternative-presentexact
b33effOrientation not restricted via CSS transformcss-orientation-lockexact
674b10Role attribute has valid valuearia-roles-validexact
0ssw9kScrollable element is keyboard accessiblescrollable-region-focusable (manual)exact
7d6734SVG element with explicit role has accessible namesvg-text-alternative-present, role-img-text-alternative-presentfamily
d0f69eTable header cell has assigned cellstable-th-has-data-cellsexact
09o5cgText has enhanced contrastcontrast-enhancedexact
afw4f7Text has minimum contrastcontrast-minimumexact
f51b46Video auditory content has captionsvideo-caption (manual)partial
2ee8b8Visible label is part of accessible namelabel-in-nameexact

Structural (not a named rule, but the check exists via a different mechanism):

  • off6ek / ucwvc8 (language subtag matches page/default language): partially overlaps html-xml-lang-mismatch + valid-lang but not a full match.

Gaps (no corresponding rule)

Grouped by theme, with WCAG SC where ACT declares one:

Audio/video alternatives (1.2.x), largest gap cluster, 12 ACT rules: 1a02b0, e7aa44, 2eb176, afb423, eac66b, ab4d13, c5a4ea, 1ea59c, 1ec09b, c3232f, d7ba54, ee13b5, fd26cf. We only have video-caption and media-alternative-transcript-evidence (both manual/low-confidence); full media-alternative testing (transcripts, audio description, sign language equivalence) is unimplemented.

Keyboard trap (2.1.2), 3 rules: 80af7b, ebe86a, a1b64e. No automated keyboard-trap detection at all today; see Keyboard trap detection: scoping notes below.

Sensory/visual gaps:

  • 9bd38c Content has alternative for visual reference (1.3.3, sensory characteristics)
  • 0va7u6 HTML graphics contain no text (1.4.5, images of text)
  • 59br37 Zoomed text node not clipped by CSS overflow (1.4.10, reflow)
  • 36b590 Error message describes invalid form field value (3.3.1)
  • c4a8a4 HTML page title is descriptive (2.4.2): page-title-patterns only catches generic/templated title patterns, never whether a plausible-looking title actually matches the page's content; see the judgment-call note below

Motion/input (2.5.4, 2.1.4):

  • 7677a9 / c249d5 Device motion actuation has UI alternative / can be disabled
  • ffbc54 No keyboard shortcut uses only printable characters

Structural/HTML validity:

  • e6952f Attribute is not duplicated (raw HTML parsing-level check)
  • efbfc7 Auto-updating text content can be paused/stopped/hidden (2.2.2, beyond meta-refresh)
  • 3e12e1 Block of repeated content is collapsible

Judgment-call gaps found during test-case validation:

  • 4b1c6c "Iframes with identical accessible names have equivalent purpose" was originally mapped to iframe-title-unique, but running ACT's own test cases against it exposed that they test different things: ACT's rule accepts a duplicate name when the two iframes point to equivalent content (same resource, mirrors, equivalent ads/sections) and only fails when duplicate-named iframes point to genuinely different content, a content-equivalence judgment call, the same class of check as our existing manual identical-links-same-purpose. iframe-title-unique instead flags any duplicate title attribute outright, by design (see its own header comment), a stricter, different, independently-valid check with no ACT counterpart of its own. Moved to "Extra coverage" below; 4b1c6c itself stays a gap, closing it for real would mean a new manual identical-iframes-same-purpose-style rule, not a fix to iframe-title-unique.
  • 5effbb "Link in context is descriptive" was originally mapped to link-in-text-block on a name-similarity guess ("link" + "context/text"); its real applicability/expectation (fetched directly from act-rules.github.io) is "the accessible name together with its programmatically determined link context describes the purpose of the link," WCAG 2.4.4, the context-aware sibling of aizyf1/link-name-quality, unrelated to link-in-text-block's WCAG 1.4.1 color-distinguishability check (which is itself correctly scoped to a[href] only, per its own header comment, not a bug). This was later closed by teaching link-name-quality to weigh adjacent context; see "Gaps closed since" below.
  • oj04fd "Element in sequential focus order has visible focus" was originally mapped to css-hidden-focus on a surface keyword match ("focus," "visible"); its real applicability/expectation is about whether a browser draws any visible focus indicator for a normally-visible, normally-positioned element (i.e. :focus/:focus-visible CSS suppressing the outline with no replacement), a completely different concern from css-hidden-focus's actual check (a keyboard-focusable element that is itself visually hidden by CSS, e.g. opacity:0/clip/off-screen). Removed from the matched table and moved to Gaps; a plausible new-rule candidate, not a fix to css-hidden-focus, and built as one since, css-focus-indicator-suppressed, which is what oj04fd maps to now.
  • cc0f0a "Form field label is descriptive" and c4a8a4 "HTML page title is descriptive" were both mapped to rules that only catch a narrower, adjacent concern: form-control-programmatic-label-quality flags a weak primary labeling mechanism (title/placeholder used instead of a real label), never whether a properly-associated label's own text is relevant to the field; page-title-patterns flags generic/templated title patterns (e.g. "Home", "Untitled"), never whether a specific, plausible-looking title actually matches the page's content (ACT's own failed example: <title>Apple harvesting season</title> on a page about clementines, a real title/content mismatch neither pattern-list nor mechanism-check was ever designed to catch). Both ACT ids moved to Gaps; both of our rules moved to Extra coverage as valid, independent, narrower checks with no ACT counterpart of their own. cc0f0a has since been picked up by a new rule of its own, form-control-label-quality; c4a8a4 remains a gap.
  • d0f69e "Table header cell has assigned cells" is a confirmed correct match, but table-th-has-data-cells's own header comment already documents its scope as limited to the single unambiguous case (a table/ARIA-grid with header cells but zero data cells anywhere) rather than the full HTML5 header-association algorithm, 3 of ACT's test cases specifically probe the excluded "some particular header doesn't actually describe any cell, even though the table has data cells elsewhere" shape (both for native <table> and the ARIA role="grid" equivalent added during this validation pass), which stays an accepted, documented false negative rather than a bug.

Gaps ranked for automatability

The goal is maximum automation, not just parity with ACT's own scope; several gaps above are worth turning into new rules even though they were never going to be "quick" ACT-mapping fixes. Ranked by confidence that a deterministic (or manual/cantTell-heuristic) DOM check can actually catch them:

High confidence, new automatic rule, deterministic DOM check, both now closed, see the notes under "Matched rules":

  • 307n5z Element with presentational children has no focusable content: closed by the new presentational-children-focusable-absent rule.
  • 46ca7f Element marked as decorative is not exposed: no new rule needed; the existing presentation-role-conflict already implements it (mapping miss, not a coverage gap).

Medium confidence, new manual/cantTell rule (same pattern as existing quality checks):

  • b49b2e Heading is descriptive: closed by the new heading-quality rule, modelled on link-name-quality's curated-phrase heuristic. It reaches the placeholder half only (see the mismatch table above), so b49b2e is a partial match rather than an exact one. One thing the original plan here got wrong: it proposed flagging single characters, and ACT's own passed example is <h1>A</h1> above a glossary; length carries no signal for headings, unlike page titles.
  • 5effbb Link in context is descriptive: closed by teaching link-name-quality to weigh adjacent context: an aria-describedby target, the direct text of the enclosing list item/table cell/paragraph, or (for a new bare-format-name phrase list only, e.g. "HTML"/"EPUB") a table's first-row header. A phrase-list match with adequate context found nearby is no longer flagged. Runs clean against 17 of ACT's own 18 examples; the one remaining case (<a>Workshop</a> after an unrelated paragraph) needs to judge whether an ordinary word actually relates to nearby prose, a step beyond what a phrase list or context-structure check can do.
  • oj04fd Focus indicator suppressed via CSS: closed by the new css-focus-indicator-suppressed rule. The CSSOM parsing turned out to be the easy half; what needed the care was deciding which rule a suppression belongs to (only the selector's subject, so .card:focus .link is not .link's own suppression) and crediting a replacement drawn anywhere the focused element causes it, its own rule, a pseudo-element, a sibling.
  • cc0f0a Form field label is descriptive: closed by the new form-control-label-quality rule, sitting alongside form-control-programmatic-label-quality rather than replacing it (one judges the label's text, the other the labelling mechanism). The curated placeholder list turned out to be the weakest of its three signals; the duplicate-label and split-label checks are what actually catch ACT's own failed examples.
  • c4a8a4 HTML page title is descriptive (title-vs-content relevance): harder to heuristic than the others in this tier (needs some notion of "does this title's vocabulary overlap with the page's own content," not just a pattern/word list), so lower confidence within this tier.

Design decision needed before building (not purely a confidence question):

  • 3ea0c8 Page-wide unique id: built as duplicate-id, tagged wcag2a plus the new wcag22-removed, which a 2.2 conformance run reports as cantTell (or excludes outright) and a 2.0/2.1 run keeps as a real failure. The decision that unblocked it, and the reasoning, are in docs/DESIGN_CHALLENGES.md's "Decided" section; the tag is documented in docs/ENGINE_OPTIONS.md.

Lower confidence, needs a different technique than the rest of the engine:

  • e6952f Attribute is not duplicated: only detectable from the raw HTML text (the DOM has already collapsed duplicate attributes by the time any DOM-based rule runs), a different input than every other rule in this engine uses. Feasible only where raw source is available (not guaranteed in every integration).
  • efbfc7 Auto-updating content can be paused/stopped/hidden, 7677a9/c249d5 device-motion actuation: both require observing behavior over time (a MutationObserver window, or JS event-listener presence), not a markup snapshot. Could become a low-confidence manual heuristic (e.g. flag <marquee>, role="marquee"/role="timer" without a visible pause control) but real coverage is inherently limited.
  • ffbc54 No single-printable-character keyboard shortcut: HTML accesskey values are statically visible, but most JS-implemented character-key shortcuts (the actual common real-world case) live in event-handler logic this engine doesn't execute or trace.
  • Audio/video alternatives (1.2.x, 12 rules), 9bd38c, 0va7u6, 3e12e1: these require judging semantic accuracy (does the transcript match the audio, is the image text redundant, is a "read more" toggle actually collapsible) that's beyond markup-level heuristics; stay manual/out of scope for now.

Keyboard trap detection: scoping notes

80af7b/ebe86a/a1b64e (SC 2.1.2) all ask the same underlying question: can focus reach every element and cycle back out to the browser chrome using standard Tab/Shift+Tab, and if a widget intentionally intercepts that (a modal, a canvas-based editor), does it announce and honor an escape mechanism. None of that is decidable from a single read of the DOM/CSSOM, which is what every rule in this engine does today (runInPage(ctx) runs once, synchronously, and never mutates the page it's inspecting). Answering it needs to actually drive focus around the page and watch what happens, a different kind of check than anything built so far.

What the technique would look like. jsdom's .focus()/.blur() already fire real, synchronous blur/focus/focusin/focusout events per spec; what's missing is the browser's own behavior of advancing focus on Tab in the first place, which jsdom never implements. A simulator would need to: (1) compute the document's own sequential focus order (reusing the existing focusability/tabindex helpers), (2) for each focusable element, focus it and dispatch a synthetic keydown (key: 'Tab') at it, (3) if nothing calls preventDefault(), apply the browser's own default itself by focusing the next element in that order, then check where focus actually landed, a bounce-back trap (ACT's own failed example: an onblur handler that refocuses itself) shows up right there, synchronously, because the refocus happens inside the same call stack as step 3's .focus(). A widget that does call preventDefault() (a custom focus-trap library) would need a second pass trying whatever escape combination its own visible/accessible help text claims (e.g. Ctrl+M) and checking again.

Why this is a different risk class, not just more work:

  • It's the first mutating check in this engine. Every other rule is read-only against whatever DOM it's handed; this one would need to move real focus and dispatch real events on the live page being scanned, then restore the original focus state afterward. A blur/focus/keydown handler on a real production page can have side effects well beyond focus management (analytics, animations, state changes) that a scan has never before been able to trigger.
  • Cost scales with the page. Every other check here is a handful of DOM queries; this one is inherently O(number of focusable elements) live focus/event cycles, expensive on a large real page, not something to run by default in every scan the way runOnly-less calls do today.
  • A new jsdom-vs-real-browser fidelity gap. Several already-fixed mismatches in this file turned out to be simulation quirks, not real bugs (css-orientation-lock's tolerance window, iframe-focusable-content's srcdoc handling). A hand-rolled Tab-order simulator is exactly the kind of thing likely to diverge from what a real browser does at the edges (shadow DOM, iframes, contenteditable, native <video>/<audio> controls), and there's no ACT-corpus-style ground truth to validate the simulator itself against beyond the 3 rules' own small example sets.
  • "Cycle to the browser UI," the last leg of a1b64e/80af7b, is genuinely outside the DOM: it means focus leaving the document into the browser chrome (address bar, tab strip), which no DOM-level tool, including a real headless-browser automation layer, can directly observe from inside the page. The best a simulator can do is confirm the last forward tab stop and first backward tab stop don't refuse to yield focus, not that a real browser chrome would actually receive it next.

Recommendation, not yet built: if this goes ahead, it belongs behind an explicit opt-in (default off) rather than folded into a normal scan, something like a new rule type distinct from automatic/manual (both currently synchronous and non-mutating by contract), gated by an engineOptions flag a caller sets on purpose, documented as changing live focus state during the scan. That's a call for whoever owns this engine's safety contract with its integrators, not something to land quietly inside the existing rule set.

Extra coverage beyond ACT

Rules in this repo with no ACT counterpart, mostly finer decomposition of ACT's single e086e5 "form field has accessible name" rule into one rule per ARIA widget type, plus some ARIA-validity and structural rules ACT doesn't break out separately:

aria-hidden-body, aria-braille-equivalent, aria-conditional-attr, aria-deprecated-role, aria-prohibited-attr, aria-prohibited-children, aria-role-name-present, binary-control-name-present, canvas-text-alternative-present, combobox-name-present, contrast-computable, definition-list-children-valid, deprecated-elements-not-used, dialog-name-present, dlitem-parent-valid, duplicate-id-aria, embed-text-alternative-present, form-control-programmatic-label-quality, form-control-single-label, iframe-title-unique, list-children-valid, listbox-name-present, listitem-parent-valid, meter-name-present, nested-interactive-controls-absent, option-name-present, page-title-patterns, progressbar-name-present, searchbox-name-present, server-side-image-map-absent, slider-name-present, spinbutton-name-present, summary-name-present, svg-image-text-alternative-present, tab-name-present, target-size-minimum, td-has-header, textbox-name-present, tooltip-name-present, treeitem-name-present, video-poster-text-alternative-present, area-alt-present.

Next steps

  1. Validate matched rules against ACT's official test cases: done for the full matched set (see "Progress" above); re-run scripts/act-testcase-check.js after any future change to a matched rule to catch regressions.
  2. Resolve the open design questions in docs/DESIGN_CHALLENGES.md: these are the highest-value remaining item, each is a confirmed, understood defect or scope gap in an already-matched rule, just deferred because the fix is a real behavior change (not a quick patch) that deserves a careful look and fixture coverage before landing.
  3. Build the highest-confidence automatable gaps: 307n5z and 46ca7f are done (see above). 3ea0c8 (page-wide unique id) is done as well, once the WCAG-version-tagging question it was blocked on was decided. b49b2e (heading quality), oj04fd (suppressed focus indicator), cc0f0a (label-text relevance) and 5effbb (link-in-context) are done too, which empties the manual/cantTell tier except c4a8a4 (page-title-vs-content relevance), the one remaining candidate in that list, harder to heuristic than the others, since it needs some notion of vocabulary overlap between a title and the page's own content rather than a phrase/pattern list.
  4. Keyboard trap detection (80af7b/ebe86a/a1b64e) remains a large, currently-unaddressed gap with no automated detection at all. Scoped, not yet built; see Keyboard trap detection: scoping notes: a workable technique exists (drive focus/Tab events and observe where focus lands), but it would be this engine's first mutating, page-state-changing check, with cost and fidelity tradeoffs unlike anything built so far, a call for whoever owns the safety contract with integrators before writing code.