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.jsonis 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-presentwasn't crediting the UA-default label oninput[type=submit]/input[type=reset]with novalue, and wasn't honoringrole="none"/role="presentation"conflict-resolution.link-name-qualitywas scoped toa[href]only; widened toa[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(sharedisLargeTexthelper): a hardcoded18.6667pxbold-large-text threshold was a floating-point hair above the true value of14ptconverted to px, so text sized exactly at the boundary viaptunits (the common real-world case) silently fell through to the stricter normal-text ratio. Fixed by deriving the threshold from the sameparsePx()conversion instead of a decimal literal.contrast-minimum/contrast-enhanced(sharedisInactiveUiComponenthelper): 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 oraria-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'sinClosedDetailsContent(): a closed<details>element was judging its own accessibility-tree eligibility against its ownopenstate (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 asalt=""(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 theimgrole with an empty computed name, a real failure.role-img-text-alternative-present(then namedrole-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 HTMLtitleattribute.table-headers-attr-valid: cells inside arole="presentation"/"none"table are out of scope entirely, and a referenced header can be any cell (tdorth) of the same table, not only ath.dom-helpers.js's offscreen heuristic:em/remvalues (e.g.top: -999em) were compared against the-5000px threshold as a bare number, so they never registered as offscreen.getAccessibleNameInfonever consulted thealtattribute forimg/area/input[type=image], so e.g. an<area>named only byalthad no computed name anywhere outside the img-specific rules.getContentNameInfo: arole="presentation"/"none"image-like descendant was still contributing itsalttext to an ancestor's content name, even thoughaltdoesn't trigger presentational-roles conflict resolution.identical-links-same-purpose: fell back to rawel.textContentinstead of the content-aware name helper, so a link named only by a descendant image'salt(no text nodes at all) was silently skipped. Also fixed SVG<a>'s.href, which is anSVGAnimatedStringrather 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 ARIArole="grid"/"treegrid"equivalent.empty-heading: a heading whose only content is arole="presentation"image no longer gets that image'salttext as its name; a native heading tag markedrole="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 noaria-checked);role="combobox"now requiresaria-controlsoncearia-expanded="true".aria-allowed-attrhad no answer for an element HTML-AAM maps to no ARIA role at all:<audio controls aria-orientation="horizontal">(ACT5c01ea's own failed example) was skipped, because an empty implicit-role lookup was indistinguishable from "a role this table does not model." A generatedROLELESS_ELEMENTSset makes the absence itself the answer.<div>/<span>also joined the context-free table asgeneric, so a role-specific attribute on a bare div is now reported rather than passed over.getContainmentRolehanded several native tags an implicit role in every context, where HTML-AAM makes them conditional:<li>is alistitemonly inside<ul>/<ol>/<menu>,<option>only insideselect/datalist/optgroup, the table family only inside a real table. ACTbc4a75fails<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'sa[href]-only selector missedrole="link"elements entirely; ACTfd3a94/b20e66's own failed examples are<span role="link" tabindex="0" onclick="location='...'">. Widened toa[href], [role="link"], with a regex fallback that reads alocation/location.href/location.assign(...)/location.replace(...)destination straight out of theonclickattribute value, a literal string already present in markup, no script execution needed. This rule iscantTell-capped, so an unrecognizedonclickshape just costs recall, not a false fail.iframe-name-present's focusability exemption only applied inside therole="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-spacingfailed 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 reportcantTellinstead offail. 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):
bc4a75was mapped toaria-required-childrenalone, 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 beingaria-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.qt1vmoand23a2a8were missing existing sibling rules from theirourRuleIdsfamily (canvas-text-alternative-quality/svg-text-alternative-quality, androle-img-text-alternative-present, respectively); the code to catch these cases already existed, just wasn't wired into the mapping.bf051awas mapped tovalid-lang(which skips the<html>element by design) instead ofhtml-lang-attr-present, which actually validates it.b40fd1was mapped toregion(an unrelated best-practice check) instead ofbypass-blocks-present, which already implements this exact WCAG 2.4.1 technique alongside itscf77f2/ye5d6e/047fe0siblings.oj04fdwas mapped tocss-hidden-focuson 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.cc0f0aandc4a8a4were mapped toform-control-programmatic-label-qualityandpage-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 newpresentational-children-focusable-absentrule. The gap entry it replaces described the wrong mechanism; it read the rule as being about an explicitrole="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-conflictalready implements it, end to end, and was simply never mapped, the gap entry was a mapping miss, same class as theqt1vmo/23a2a8misses 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 newheading-qualityrule: 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 iscantTell-capped andb49b2eis mappedpartial. 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 newcss-focus-indicator-suppressedrule: it reads the page's own stylesheets for a:focus/:focus-visiblerule 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 anonfocushandler onto a sibling, so the rule iscantTell-capped and the mapping ispartial.cc0f0a"Form field label is descriptive" is now partly covered by the newform-control-label-qualityrule. 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 ispartialand the rule iscantTell-capped.5effbb"Link in context is descriptive" is now partly covered bylink-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 (anaria-describedbytarget, 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 newduplicate-idrule, 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 carrieswcag22-removedalongside itswcag2aorigin tag; under the default 2.2 target the engine reports its findings ascantTellrather thanfail. Seedocs/ENGINE_OPTIONS.mdfor 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-validonly took a table out of scope forrole="presentation"/"none". Any explicit role replaces the native table semantics, so<table role="heading">has no table for a cell'sheadersattribute to describe either; the applicability now keeps onlytable/grid/treegrid, matching ACT a25f45.aria-required-attrnever requiredaria-valuenowon aseparator. 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 ascombobox'saria-controls, which the rule already handles.iframe-name-presentdemanded a name from an iframe the author had marked decorative withrole="none"/"presentation". ACT cae760 excludes those outright, and the contradiction between "decorative" and a restored role ispresentation-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-childrenonly evaluating containers with an explicitroleisn't a gap; ACTbc4a75's own Applicability text requires one, and its Inapplicable Example 2 is a bare<ul><li>.aria-prohibited-childrenwas a real bug: it only treatedrole="group"/role="rowgroup"as transparent when the container's own required-owned set namedgroup/rowgroup, sorole="list"wrapping validlistitems in arole="group"was wrongly flagged. Group/rowgroup are transparent unconditionally.bc4a75now runs clean.label-in-namehad no exemption for "non-text content" characters; ACT2ee8b8's own failed examples for us,<button aria-label="close">X</button>and a Material-Icons-font-remappedsearch->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-claimedaria-hidden/visually-hidden/inline-concatenation divergence at all (unsubstantiated against current ground truth, likely a local-checkout artifact same as thebc4a75case); that narrower part stays open indocs/DESIGN_CHALLENGES.mdon its own merits, not as an ACT mismatch.2ee8b8now 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 ID | Mismatches | Category |
|---|---|---|
ff89c9 | 1 | env/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) |
aaa1bf | 1 | different 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 |
ye5d6e | 1 | scoped 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 |
047fe0 | 2 | one 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) |
e086e5 | 2 | accepted 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 |
oj04fd | 1 | env/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 |
cc0f0a | 3 | inherent 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 |
b49b2e | 5 | inherent 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) |
aizyf1 | 2 | inherent 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 |
5effbb | 1 | genuine 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 |
d0f69e | 3 | documented 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, afw4f7 | 4, 4 | environment-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) |
f51b46 | 1 | inherent 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 ID | ACT rule name | Our rule(s) | Match |
|---|---|---|---|
5f99a7 | ARIA attribute is defined in WAI-ARIA | aria-valid-attr | exact |
ff89c9 | ARIA required context role | aria-required-parent | exact |
bc4a75 | ARIA required owned elements | aria-required-children, aria-prohibited-children | family (we split by decision) |
6a7281 | ARIA state or property has valid value | aria-valid-attr-value | exact |
5c01ea | ARIA state or property is permitted | aria-allowed-attr | exact |
80f0bf | Audio/video avoids autoplaying audio | no-autoplay-audio (manual) | family |
4c31df | Autoplaying audio/video has a control mechanism | no-autoplay-audio (manual) | family |
aaa1bf | Autoplaying audio/video has no audio > 3s | no-autoplay-audio (manual) | family |
73f2c2 | Autocomplete attribute has valid value | autocomplete-valid | exact |
97a4e1 | Button has non-empty accessible name | button-name-present | exact |
cf77f2 | Bypass Blocks of Repeated Content | bypass-blocks-present (manual) | exact |
ye5d6e | Instrument to move focus to non-repeated content | bypass-blocks-present (manual) | family |
047fe0 | Document has heading for non-repeated content | bypass-blocks-present (manual) | family |
b40fd1 | Document has a landmark with non-repeated content | bypass-blocks-present (manual) | family |
46ca7f | Element marked as decorative is not exposed | presentation-role-conflict (manual) | exact |
oj04fd | Element in sequential focus order has visible focus | css-focus-indicator-suppressed (manual) | partial |
6cfa84 | Element with aria-hidden has no content in sequential focus nav | aria-hidden-focus | exact |
de46e4 | Element with lang attribute has valid language tag | valid-lang | exact |
307n5z | Element with presentational children has no focusable content | presentational-children-focusable-absent | exact |
4e8ab6 | Element with role attribute has required states/properties | aria-required-attr | exact |
e086e5 | Form field has non-empty accessible name | form-control-programmatic-label-present, textbox-name-present, combobox-name-present, listbox-name-present, searchbox-name-present, slider-name-present, spinbutton-name-present | family (we split by widget role) |
cc0f0a | Form field label is descriptive | form-control-label-quality (manual) | partial |
a25f45 | Headers attribute refers to cells in same table | table-headers-attr-valid | exact |
ffd0e9 | Heading has non-empty accessible name | empty-heading (manual) | family |
b49b2e | Heading is descriptive | heading-quality (manual) | partial |
b5c3f8 | HTML page has lang attribute | html-lang-attr-present | exact |
2779a5 | HTML page has non-empty title | page-title-present | exact |
5b7ae0 | HTML page lang/xml:lang attributes match | html-xml-lang-mismatch | exact |
bf051a | HTML page lang attribute has valid language tag | html-lang-attr-present | exact |
3ea0c8 | Id attribute value is unique | duplicate-id | exact |
cae760 | Iframe element has non-empty accessible name | iframe-name-present | exact |
akn7bn | Iframe with negative tabindex has no interactive content | iframe-focusable-content | exact |
qt1vmo | Image accessible name is descriptive | img-alt-quality, canvas-text-alternative-quality, svg-text-alternative-quality (all manual) | family |
59796f | Image button has non-empty accessible name | input-image-alt-present | exact |
23a2a8 | Image has non-empty accessible name | img-alt-present, role-img-text-alternative-present | family |
e88epe | Image not in the accessibility tree is decorative | img-alt-decorative (manual) | exact |
24afc2 | Letter spacing in style attrs not !important | avoid-inline-spacing | exact |
78fd32 | Line height in style attrs not !important | avoid-inline-spacing | exact (combined rule) |
9e45ec | Word spacing in style attrs not !important | avoid-inline-spacing | exact (combined rule) |
c487ae | Link has non-empty accessible name | link-name-present | exact |
aizyf1 | Link is descriptive | link-name-quality (manual) | exact |
5effbb | Link in context is descriptive | link-name-quality (manual) | partial |
fd3a94 | Links with identical names + same context, equivalent purpose | identical-links-same-purpose (manual) | exact |
b20e66 | Links with identical accessible names, equivalent purpose | identical-links-same-purpose (manual) | exact |
m6b1q3 | Menuitem has non-empty accessible name | menuitem-name-present | exact |
bc659a | Meta element has no refresh delay | meta-refresh-timing-absent | exact |
bisz58 | Meta element has no refresh delay (no exception) | meta-refresh-no-exceptions | exact |
b4f0c3 | Meta viewport allows for zoom | meta-viewport-zoom-enabled | exact |
8fc3b6 | Object element rendering non-text content has accessible name | object-text-alternative-present | exact |
b33eff | Orientation not restricted via CSS transform | css-orientation-lock | exact |
674b10 | Role attribute has valid value | aria-roles-valid | exact |
0ssw9k | Scrollable element is keyboard accessible | scrollable-region-focusable (manual) | exact |
7d6734 | SVG element with explicit role has accessible name | svg-text-alternative-present, role-img-text-alternative-present | family |
d0f69e | Table header cell has assigned cells | table-th-has-data-cells | exact |
09o5cg | Text has enhanced contrast | contrast-enhanced | exact |
afw4f7 | Text has minimum contrast | contrast-minimum | exact |
f51b46 | Video auditory content has captions | video-caption (manual) | partial |
2ee8b8 | Visible label is part of accessible name | label-in-name | exact |
Structural (not a named rule, but the check exists via a different mechanism):
off6ek/ucwvc8(language subtag matches page/default language): partially overlapshtml-xml-lang-mismatch+valid-langbut 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:
9bd38cContent has alternative for visual reference (1.3.3, sensory characteristics)0va7u6HTML graphics contain no text (1.4.5, images of text)59br37Zoomed text node not clipped by CSS overflow (1.4.10, reflow)36b590Error message describes invalid form field value (3.3.1)c4a8a4HTML page title is descriptive (2.4.2):page-title-patternsonly 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/c249d5Device motion actuation has UI alternative / can be disabledffbc54No keyboard shortcut uses only printable characters
Structural/HTML validity:
e6952fAttribute is not duplicated (raw HTML parsing-level check)efbfc7Auto-updating text content can be paused/stopped/hidden (2.2.2, beyond meta-refresh)3e12e1Block 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 toiframe-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 manualidentical-links-same-purpose.iframe-title-uniqueinstead flags any duplicatetitleattribute 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;4b1c6citself stays a gap, closing it for real would mean a new manualidentical-iframes-same-purpose-style rule, not a fix toiframe-title-unique.5effbb"Link in context is descriptive" was originally mapped tolink-in-text-blockon 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 ofaizyf1/link-name-quality, unrelated tolink-in-text-block's WCAG 1.4.1 color-distinguishability check (which is itself correctly scoped toa[href]only, per its own header comment, not a bug). This was later closed by teachinglink-name-qualityto weigh adjacent context; see "Gaps closed since" below.oj04fd"Element in sequential focus order has visible focus" was originally mapped tocss-hidden-focuson 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-visibleCSS suppressing the outline with no replacement), a completely different concern fromcss-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 tocss-hidden-focus, and built as one since,css-focus-indicator-suppressed, which is whatoj04fdmaps to now.cc0f0a"Form field label is descriptive" andc4a8a4"HTML page title is descriptive" were both mapped to rules that only catch a narrower, adjacent concern:form-control-programmatic-label-qualityflags 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-patternsflags 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.cc0f0ahas since been picked up by a new rule of its own,form-control-label-quality;c4a8a4remains a gap.d0f69e"Table header cell has assigned cells" is a confirmed correct match, buttable-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 ARIArole="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":
: closed by the new307n5zElement with presentational children has no focusable contentpresentational-children-focusable-absentrule.: no new rule needed; the existing46ca7fElement marked as decorative is not exposedpresentation-role-conflictalready implements it (mapping miss, not a coverage gap).
Medium confidence, new manual/cantTell rule (same pattern as existing quality checks):
: closed by the newb49b2eHeading is descriptiveheading-qualityrule, modelled onlink-name-quality's curated-phrase heuristic. It reaches the placeholder half only (see the mismatch table above), sob49b2eis 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.: closed by teaching5effbbLink in context is descriptivelink-name-qualityto weigh adjacent context: anaria-describedbytarget, 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.: closed by the newoj04fdFocus indicator suppressed via CSScss-focus-indicator-suppressedrule. 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 .linkis not.link's own suppression) and crediting a replacement drawn anywhere the focused element causes it, its own rule, a pseudo-element, a sibling.: closed by the newcc0f0aForm field label is descriptiveform-control-label-qualityrule, sitting alongsideform-control-programmatic-label-qualityrather 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.c4a8a4HTML 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):
: built as3ea0c8Page-wide uniqueidduplicate-id, taggedwcag2aplus the newwcag22-removed, which a 2.2 conformance run reports ascantTell(or excludes outright) and a 2.0/2.1 run keeps as a real failure. The decision that unblocked it, and the reasoning, are indocs/DESIGN_CHALLENGES.md's "Decided" section; the tag is documented indocs/ENGINE_OPTIONS.md.
Lower confidence, needs a different technique than the rest of the engine:
e6952fAttribute 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).efbfc7Auto-updating content can be paused/stopped/hidden,7677a9/c249d5device-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.ffbc54No single-printable-character keyboard shortcut: HTMLaccesskeyvalues 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/keydownhandler 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'ssrcdochandling). 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
- Validate matched rules against ACT's official test cases: done for the full matched set (see "Progress" above); re-run
scripts/act-testcase-check.jsafter any future change to a matched rule to catch regressions. - 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. - Build the highest-confidence automatable gaps:
307n5zand46ca7fare 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) and5effbb(link-in-context) are done too, which empties the manual/cantTelltier exceptc4a8a4(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. - 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.