@surea11y/core 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -20
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @surea11y/core
2
2
 
3
3
  <a href="https://www.npmjs.com/package/@surea11y/core"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/SureA11y/core/main/docs/assets/brand-tag-dark.svg"><img alt="surea11y core" src="https://raw.githubusercontent.com/SureA11y/core/main/docs/assets/brand-tag-light.svg"></picture></a>
4
+ [![Test](https://github.com/SureA11y/core/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/SureA11y/core/actions/workflows/test.yml)
4
5
  [![npm](https://img.shields.io/npm/v/@surea11y/core?style=flat-square&label=npm&labelColor=101413&color=3A4441)](https://www.npmjs.com/package/@surea11y/core)
5
6
  [![node](https://img.shields.io/node/v/@surea11y/core?style=flat-square&label=node&labelColor=101413&color=3A4441)](package.json)
6
7
  [![license](https://img.shields.io/badge/license-MPL--2.0-3A4441?style=flat-square&labelColor=101413)](LICENSE)
@@ -341,7 +342,8 @@ A simplified example looks like this:
341
342
  {
342
343
  "engine": {
343
344
  "tag": "a11ycore",
344
- "schemaVersion": "1.0.0"
345
+ "schemaVersion": "1.0.0",
346
+ "locale": { "requested": "en", "resolved": "en", "reason": "ok" }
345
347
  },
346
348
  "url": "https://example.com/",
347
349
  "checksResults": [
@@ -376,7 +378,7 @@ A simplified example looks like this:
376
378
  ```
377
379
 
378
380
  The second result illustrates the engine's conservative stance: it can
379
- confirm a link has text, but whether that text is genuinely descriptive
381
+ confirm a link has text, but whether that text is actually descriptive
380
382
  requires human judgement, so it reports `cantTell` instead of guessing.
381
383
 
382
384
  Each finding contains enough information to answer four questions:
@@ -418,7 +420,12 @@ and progressively explore more advanced features.
418
420
  | `docs/TROUBLESHOOTING.md` | Frequently asked questions and common issues. |
419
421
  | `docs/RULE_AUTHORING.md` | Writing custom accessibility rules. |
420
422
  | `docs/RULE_TAXONOMY.md` | Rule categorization model. |
423
+ | `docs/ACT_RULE_MAPPING.md` | Which ACT rules this engine implements, which it doesn't, and where the two differ by design. |
424
+ | `docs/DESIGN_CHALLENGES.md` | Open and settled design questions, each with the reasoning behind the call. |
425
+ | `docs/ARIA_DEPRECATION.md` | How deprecated ARIA roles and attributes are graded, and how to apply a later spec revision. |
421
426
  | `CONTRIBUTING.md` | Contributing guidelines. |
427
+ | `GOVERNANCE.md` | Who decides what, and the license commitment. |
428
+ | `SUPPORT.md` | Where to ask, and what response to expect. |
422
429
  | `SECURITY.md` | Security policy and vulnerability reporting. |
423
430
  | `CHANGELOG.md` | Release history. |
424
431
 
@@ -480,6 +487,9 @@ surea11y.i18n.<locale>.js # Generated per-locale side files for that bundle
480
487
  src/
481
488
  index.js # Public API
482
489
  core.js # Generated runtime bundle
490
+ baseline.js # Baseline entry point (@surea11y/core/baseline)
491
+ report.js # HTML report entry point (@surea11y/core/report)
492
+ sarif.js # SARIF entry point (@surea11y/core/sarif)
483
493
 
484
494
  checks/
485
495
  automatic/ # Deterministic automated rules
@@ -487,20 +497,31 @@ src/
487
497
 
488
498
  core/ # Shared engine runtime
489
499
  policy/ # Policy implementations
490
- i18n/ # Localized messages
500
+ i18n/ # Localized messages (JSON, one file per locale)
491
501
  coverage/ # WCAG coverage definitions
492
502
  catalogs/ # Composite rule catalogs
503
+ explain/ # Occurrence grouping, internal
493
504
 
494
505
  scripts/
495
- build-core.js # Generates the runtime bundle
506
+ build-core.js # Generates src/core.js
507
+ build-browser.js # Generates the browser bundle and its locale side files
508
+ generate-*.js # Generated docs and data tables (each supports --check)
509
+ validate-*.js # Rule module contract checks
510
+ i18n-*.js # Locale scaffolding, sync and coverage reporting
496
511
 
512
+ coverage/ # Generated WCAG facet coverage report
497
513
  docs/ # Project documentation
498
514
 
499
515
  tests/
500
- fixtures/ # Rule fixtures
501
- engine-checks/ # Engine and rule tests
516
+ fixtures/ # Rule fixtures, plus the generated fixture index
517
+ engine-checks/ # Per-rule tests
518
+ core/ helpers/ i18n/ # Engine internals, shared test helpers, locale tests
502
519
  ```
503
520
 
521
+ Everything under `src/` other than the entry points above is internal — see
522
+ [`docs/API_STABILITY.md`](./docs/API_STABILITY.md) for what the `exports` map
523
+ actually promises.
524
+
504
525
  This separation allows the engine to evolve independently from framework
505
526
  integrations while keeping the rule authoring experience consistent.
506
527
 
@@ -0,0 +1,243 @@
1
+ # ACT rule mapping
2
+
3
+ Cross-reference between the [W3C ACT Rules](https://act-rules.github.io/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.
4
+
5
+ **Summary (117 active ACT rules, excludes 3 deprecated):**
6
+ - **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)
7
+ - **~2** are covered structurally by our composite/rollup layer, not a named rule
8
+ - **~46 are gaps**, no corresponding rule in this repo, listed in [Gaps](#gaps-no-corresponding-rule) below
9
+
10
+ **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`](./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, 32 mismatches, all explained** below or in that file; 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.)
11
+
12
+ Real rule bugs found and fixed this way, in rough chronological order:
13
+ - `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.
14
+ - `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).
15
+ - `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.
16
+ - `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.
17
+ - `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.
18
+ - `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.
19
+ - `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.
20
+ - `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.
21
+ - `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`.
22
+ - `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.
23
+ - `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.
24
+ - `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.
25
+ - `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.
26
+ - `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.
27
+ - `table-th-has-data-cells`: extended the existing "`<th>` with zero `<td>` anywhere" check to its ARIA `role="grid"`/`"treegrid"` equivalent.
28
+ - `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.
29
+ - `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"`.
30
+ - `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.
31
+ - `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.
32
+ - `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.
33
+ - `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.
34
+
35
+ Mapping-table corrections found this way (data-only, no rule-code change):
36
+ - `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.
37
+ - `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.
38
+ - `bf051a` was mapped to `valid-lang` (which skips the `<html>` element by design) instead of `html-lang-attr-present`, which actually validates it.
39
+ - `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.
40
+ - `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.
41
+ - `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](#extra-coverage-beyond-act) (they remain valid, independent checks with no ACT counterpart of their own).
42
+
43
+ Gaps closed since:
44
+ - `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.
45
+ - `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.
46
+ - `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.
47
+ - `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`.
48
+ - `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.
49
+ - `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.
50
+ - `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; see `docs/ENGINE_OPTIONS.md` for how a 2.2 conformance run excludes it.
51
+
52
+ ### Second pass: the corpus read from a local checkout
53
+
54
+ 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.
55
+
56
+ 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:
57
+
58
+ Real rule bugs, fixed:
59
+ - `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.
60
+ - `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.
61
+ - `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.
62
+
63
+ 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.
64
+
65
+ Re-checked against the live rule page (not just the local checkout) and settled, all in `docs/DESIGN_CHALLENGES.md`'s Decided section:
66
+ - `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>`.
67
+ - `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 `listitem`s in a `role="group"` was wrongly flagged. Group/rowgroup are transparent unconditionally. `bc4a75` now runs clean.
68
+ - `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.
69
+
70
+ 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.
71
+
72
+ We also have automatic rules with **no ACT counterpart at all** (see [Extra coverage](#extra-coverage-beyond-act)), mostly a finer-grained decomposition of ACT's single "form field has accessible name" rule into one rule per ARIA widget role.
73
+
74
+ ### Progress: full validation results, by ACT rule
75
+
76
+ **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` (42 of 57 matched rules).
77
+
78
+ **Remaining mismatches (32 total), all triaged:**
79
+
80
+ | ACT ID | Mismatches | Category |
81
+ |---|---|---|
82
+ | `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) |
83
+ | `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 |
84
+ | `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 |
85
+ | `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) |
86
+ | `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 |
87
+ | `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 |
88
+ | `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 |
89
+ | `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) |
90
+ | `78fd32` | 1 | documented limitation: `avoid-inline-spacing`'s own header comment already states it can't detect "text that never soft-wraps" without real layout |
91
+ | `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 |
92
+ | `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 |
93
+ | `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) |
94
+ | `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) |
95
+ | `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 |
96
+
97
+ ## Matched rules
98
+
99
+ | ACT ID | ACT rule name | Our rule(s) | Match |
100
+ |---|---|---|---|
101
+ | `5f99a7` | ARIA attribute is defined in WAI-ARIA | `aria-valid-attr` | exact |
102
+ | `ff89c9` | ARIA required context role | `aria-required-parent` | exact |
103
+ | `bc4a75` | ARIA required owned elements | `aria-required-children`, `aria-prohibited-children` | family (we split by decision) |
104
+ | `6a7281` | ARIA state or property has valid value | `aria-valid-attr-value` | exact |
105
+ | `5c01ea` | ARIA state or property is permitted | `aria-allowed-attr` | exact |
106
+ | `80f0bf` | Audio/video avoids autoplaying audio | `no-autoplay-audio` (manual) | family |
107
+ | `4c31df` | Autoplaying audio/video has a control mechanism | `no-autoplay-audio` (manual) | family |
108
+ | `aaa1bf` | Autoplaying audio/video has no audio > 3s | `no-autoplay-audio` (manual) | family |
109
+ | `73f2c2` | Autocomplete attribute has valid value | `autocomplete-valid` | exact |
110
+ | `97a4e1` | Button has non-empty accessible name | `button-name-present` | exact |
111
+ | `cf77f2` | Bypass Blocks of Repeated Content | `bypass-blocks-present` (manual) | exact |
112
+ | `ye5d6e` | Instrument to move focus to non-repeated content | `bypass-blocks-present` (manual) | family |
113
+ | `047fe0` | Document has heading for non-repeated content | `bypass-blocks-present` (manual) | family |
114
+ | `b40fd1` | Document has a landmark with non-repeated content | `bypass-blocks-present` (manual) | family |
115
+ | `46ca7f` | Element marked as decorative is not exposed | `presentation-role-conflict` (manual) | exact |
116
+ | `oj04fd` | Element in sequential focus order has visible focus | `css-focus-indicator-suppressed` (manual) | partial |
117
+ | `6cfa84` | Element with aria-hidden has no content in sequential focus nav | `aria-hidden-focus` | exact |
118
+ | `de46e4` | Element with lang attribute has valid language tag | `valid-lang` | exact |
119
+ | `307n5z` | Element with presentational children has no focusable content | `presentational-children-focusable-absent` | exact |
120
+ | `4e8ab6` | Element with role attribute has required states/properties | `aria-required-attr` | exact |
121
+ | `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) |
122
+ | `cc0f0a` | Form field label is descriptive | `form-control-label-quality` (manual) | partial |
123
+ | `a25f45` | Headers attribute refers to cells in same table | `table-headers-attr-valid` | exact |
124
+ | `ffd0e9` | Heading has non-empty accessible name | `empty-heading` (manual) | family |
125
+ | `b49b2e` | Heading is descriptive | `heading-quality` (manual) | partial |
126
+ | `b5c3f8` | HTML page has lang attribute | `html-lang-attr-present` | exact |
127
+ | `2779a5` | HTML page has non-empty title | `page-title-present` | exact |
128
+ | `5b7ae0` | HTML page lang/xml:lang attributes match | `html-xml-lang-mismatch` | exact |
129
+ | `bf051a` | HTML page lang attribute has valid language tag | `html-lang-attr-present` | exact |
130
+ | `3ea0c8` | Id attribute value is unique | `duplicate-id` | exact |
131
+ | `cae760` | Iframe element has non-empty accessible name | `iframe-name-present` | exact |
132
+ | `akn7bn` | Iframe with negative tabindex has no interactive content | `iframe-focusable-content` | exact |
133
+ | `qt1vmo` | Image accessible name is descriptive | `img-alt-quality`, `canvas-text-alternative-quality`, `svg-text-alternative-quality` (all manual) | family |
134
+ | `59796f` | Image button has non-empty accessible name | `input-image-alt-present` | exact |
135
+ | `23a2a8` | Image has non-empty accessible name | `img-alt-present`, `role-img-text-alternative-present` | family |
136
+ | `e88epe` | Image not in the accessibility tree is decorative | `img-alt-decorative` (manual) | exact |
137
+ | `24afc2` | Letter spacing in style attrs not `!important` | `avoid-inline-spacing` | exact |
138
+ | `78fd32` | Line height in style attrs not `!important` | `avoid-inline-spacing` | exact (combined rule) |
139
+ | `9e45ec` | Word spacing in style attrs not `!important` | `avoid-inline-spacing` | exact (combined rule) |
140
+ | `c487ae` | Link has non-empty accessible name | `link-name-present` | exact |
141
+ | `aizyf1` | Link is descriptive | `link-name-quality` (manual) | exact |
142
+ | `5effbb` | Link in context is descriptive | `link-name-quality` (manual) | partial |
143
+ | `fd3a94` | Links with identical names + same context, equivalent purpose | `identical-links-same-purpose` (manual) | exact |
144
+ | `b20e66` | Links with identical accessible names, equivalent purpose | `identical-links-same-purpose` (manual) | exact |
145
+ | `m6b1q3` | Menuitem has non-empty accessible name | `menuitem-name-present` | exact |
146
+ | `bc659a` | Meta element has no refresh delay | `meta-refresh-timing-absent` | exact |
147
+ | `bisz58` | Meta element has no refresh delay (no exception) | `meta-refresh-no-exceptions` | exact |
148
+ | `b4f0c3` | Meta viewport allows for zoom | `meta-viewport-zoom-enabled` | exact |
149
+ | `8fc3b6` | Object element rendering non-text content has accessible name | `object-text-alternative-present` | exact |
150
+ | `b33eff` | Orientation not restricted via CSS transform | `css-orientation-lock` | exact |
151
+ | `674b10` | Role attribute has valid value | `aria-roles-valid` | exact |
152
+ | `0ssw9k` | Scrollable element is keyboard accessible | `scrollable-region-focusable` (manual) | exact |
153
+ | `7d6734` | SVG element with explicit role has accessible name | `svg-text-alternative-present`, `role-img-text-alternative-present` | family |
154
+ | `d0f69e` | Table header cell has assigned cells | `table-th-has-data-cells` | exact |
155
+ | `09o5cg` | Text has enhanced contrast | `contrast-enhanced` | exact |
156
+ | `afw4f7` | Text has minimum contrast | `contrast-minimum` | exact |
157
+ | `f51b46` | Video auditory content has captions | `video-caption` (manual) | partial |
158
+ | `2ee8b8` | Visible label is part of accessible name | `label-in-name` | exact |
159
+
160
+ Structural (not a named rule, but the check exists via a different mechanism):
161
+ - `off6ek` / `ucwvc8` (language subtag matches page/default language): partially overlaps `html-xml-lang-mismatch` + `valid-lang` but not a full match.
162
+
163
+ ## Gaps (no corresponding rule)
164
+
165
+ Grouped by theme, with WCAG SC where ACT declares one:
166
+
167
+ **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.
168
+
169
+ **Keyboard trap (2.1.2)**, 3 rules: `80af7b`, `ebe86a`, `a1b64e`. No automated keyboard-trap detection at all today; see [Keyboard trap detection: scoping notes](#keyboard-trap-detection-scoping-notes) below.
170
+
171
+ **Sensory/visual gaps:**
172
+ - `9bd38c` Content has alternative for visual reference (1.3.3, sensory characteristics)
173
+ - `0va7u6` HTML graphics contain no text (1.4.5, images of text)
174
+ - `59br37` Zoomed text node not clipped by CSS overflow (1.4.10, reflow)
175
+ - `36b590` Error message describes invalid form field value (3.3.1)
176
+ - `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
177
+
178
+ **Motion/input (2.5.4, 2.1.4):**
179
+ - `7677a9` / `c249d5` Device motion actuation has UI alternative / can be disabled
180
+ - `ffbc54` No keyboard shortcut uses only printable characters
181
+
182
+ **Structural/HTML validity:**
183
+ - `e6952f` Attribute is not duplicated (raw HTML parsing-level check)
184
+ - `efbfc7` Auto-updating text content can be paused/stopped/hidden (2.2.2, beyond meta-refresh)
185
+ - `3e12e1` Block of repeated content is collapsible
186
+
187
+ **Judgment-call gaps found during test-case validation:**
188
+ - `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`.
189
+ - `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.
190
+ - `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.
191
+ - `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](#extra-coverage-beyond-act) 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.
192
+ - `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.
193
+
194
+ ### Gaps ranked for automatability
195
+
196
+ 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:
197
+
198
+ **High confidence, new automatic rule, deterministic DOM check, both now closed, see the notes under "Matched rules":**
199
+ - ~~`307n5z` Element with presentational children has no focusable content~~: closed by the new `presentational-children-focusable-absent` rule.
200
+ - ~~`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).
201
+
202
+ **Medium confidence, new manual/`cantTell` rule (same pattern as existing quality checks):**
203
+ - ~~`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.
204
+ - ~~`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.
205
+ - ~~`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.
206
+ - ~~`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.
207
+ - `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.
208
+
209
+ **Design decision needed before building (not purely a confidence question):**
210
+ - ~~`3ea0c8` Page-wide unique `id`~~: built as `duplicate-id`, tagged `wcag2a` plus the new `wcag22-removed`, which a 2.2 conformance run excludes and a 2.0/2.1 run keeps. 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`.
211
+
212
+ **Lower confidence, needs a different technique than the rest of the engine:**
213
+ - `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).
214
+ - `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.
215
+ - `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.
216
+ - 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.
217
+
218
+ ## Keyboard trap detection: scoping notes
219
+
220
+ `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.
221
+
222
+ **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.
223
+
224
+ **Why this is a different risk class, not just more work:**
225
+ - **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.
226
+ - **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.
227
+ - **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.
228
+ - **"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.
229
+
230
+ **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.
231
+
232
+ ## Extra coverage beyond ACT
233
+
234
+ 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:
235
+
236
+ `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`.
237
+
238
+ ## Next steps
239
+
240
+ 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.
241
+ 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.
242
+ 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.
243
+ 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](#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.
@@ -11,7 +11,7 @@ Removing, renaming, or changing the type/meaning of any of these is a **major**
11
11
  - Each occurrence (`occurrences[i]`): `selector`, `html`, `summary`, `hint`, `i18n`, `structuralPath`.
12
12
  - The rule **catalog** (`getChecksCatalog()`/`getRulesCatalog()`, a separate surface from a scan result — see `RULE_AUTHORING.md`): `ruleId`, `title`, `description`, `tags`, `wcagSc`, `normativeMappings`, `defaultSeverity`, `defaultConfidence`, `type`, `deprecated`/`.deprecation`. Note `tags` lives here, not on a per-scan `checksResults[i].meta` — the two surfaces intentionally carry different subsets of a rule's metadata.
13
13
 
14
- This list is deliberately not a new, invented guarantee — it codifies what the 6 real consumers above (and `docs/OUTPUT_SCHEMA.md`'s own worked examples) already depend on today, either directly or as documented shape.
14
+ This list isn't a new, invented guarantee: it codifies what the 6 real consumers above (and `docs/OUTPUT_SCHEMA.md`'s own worked examples) already depend on today, either directly or as documented shape.
15
15
 
16
16
  ## Package entry points (covered by semver)
17
17
 
@@ -71,7 +71,7 @@ const meta = {
71
71
 
72
72
  `meta.deprecated: true` requires both `deprecation.reason` and `deprecation.sinceVersion` — `normalizeRuleMeta` (`src/core/rule-meta.js`) throws a clear build-time error otherwise, the same way it already validates `meta.i18n.titleKey`.
73
73
 
74
- **A deprecated rule keeps running and producing results completely normally** — `pass`/`fail`/`cantTell`/`notApplicable` exactly as before. Deprecation is a catalog-level signal (visible via `getChecksCatalog()`, and in `docs/RULE_CATALOG.md`) for integrators to plan a migration on their own schedule — deliberately **not** an automatic exclusion (there is no `engineOptions.excludeDeprecated` flag). Silently dropping a rule's results the moment it's deprecated would be exactly the kind of surprise this document exists to prevent.
74
+ **A deprecated rule keeps running and producing results completely normally** — `pass`/`fail`/`cantTell`/`notApplicable` exactly as before. Deprecation is a catalog-level signal (visible via `getChecksCatalog()`, and in `docs/RULE_CATALOG.md`) for integrators to plan a migration on their own schedule, **not** an automatic exclusion (there is no `engineOptions.excludeDeprecated` flag). Silently dropping a rule's results the moment it's deprecated would be exactly the kind of surprise this document exists to prevent.
75
75
 
76
76
  The process:
77
77
  1. Mark the rule `deprecated: true` with `deprecation.reason`/`.replacedBy`/`.sinceVersion` set. Document it under `CHANGELOG.md`'s `### Deprecated` section (a standard Keep-a-Changelog category that's been in this project's changelog template since the beginning but never actually used until now).
@@ -6,7 +6,7 @@ The first real binding, `@surea11y/playwright` (a sibling project, not part of t
6
6
 
7
7
  ## Core-engine vs. binding-layer: know which side you're on
8
8
 
9
- Some engine-parity features (relative to other established engines) are **engine-level** — call the engine, get the behavior, no binding code required. Others are **binding-layer** — the engine deliberately doesn't own them, because they only make sense once you have a real automation driver (a `Page`/`Browser`/`ElementHandle`-shaped object) in front of you. Building a new binding without knowing which is which leads to either reimplementing something the engine already does, or missing something because "surely the engine handles that."
9
+ Some engine-parity features (relative to other established engines) are **engine-level** — call the engine, get the behavior, no binding code required. Others are **binding-layer** — the engine doesn't own them, because they only make sense once you have a real automation driver (a `Page`/`Browser`/`ElementHandle`-shaped object) in front of you. Building a new binding without knowing which is which leads to either reimplementing something the engine already does, or missing something because "surely the engine handles that."
10
10
 
11
11
  **Already engine-level, works the moment you call `runa11yCoreInPage`/`runDomRulesInPage` — no binding code needed:**
12
12
  - All rule execution, WCAG SC mapping, composite rollups.
@@ -17,7 +17,7 @@ Some engine-parity features (relative to other established engines) are **engine
17
17
 
18
18
  **Binding-layer — your binding has to build these itself, the engine won't:**
19
19
  - **Element references.** The engine returns `selector`/`structuralPath` strings, never a live handle — it has no concept of your driver's element-reference type. Resolve `occurrences[i].selector` back to a real handle yourself (Playwright's approach: `page.evaluateHandle` instead of `page.evaluate`, then `elementHandle.$(selector)` per occurrence — see `.elementRef(true)` in `@surea11y/playwright`).
20
- - **Result verbosity/reporter filtering.** The engine deliberately always returns every rule's outcome, including `pass`/`notApplicable` — "not a violations-only list" is a stated engine design choice (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)), not an oversight to work around. If your consumers want a trimmed view for CI-scale output, that's a post-filter your binding adds (`@surea11y/playwright`'s `.reportOnly(['fail','cantTell'])` is a simple array-filter over the full result — no engine change).
20
+ - **Result verbosity/reporter filtering.** The engine always returns every rule's outcome, including `pass`/`notApplicable` — "not a violations-only list" is a stated engine design choice (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)), not an oversight to work around. If your consumers want a trimmed view for CI-scale output, that's a post-filter your binding adds (`@surea11y/playwright`'s `.reportOnly(['fail','cantTell'])` is a simple array-filter over the full result — no engine change).
21
21
  - **Formatted failure output for your framework's own assertion/reporting style** (e.g. Playwright/Jest-style multi-line failure messages). The engine's raw result is framework-agnostic on purpose; shaping it into "what shows up in a failed test's stack trace" is squarely binding territory.
22
22
 
23
23
  ## The serialization-boundary caveat
@@ -38,4 +38,4 @@ A short list, derived from what the audit pass on `@surea11y/playwright` actuall
38
38
 
39
39
  ## A known engine-side tradeoff worth knowing about
40
40
 
41
- `src/core.js` is not small (~3.1MB as of 2026-07-22) because the bundler-free, no-driver-context functions (`runa11yCoreInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`) each carry their own complete self-contained copy of the rule catalog. If your binding only ever uses `require('@surea11y/core')` in Node and injects `runa11yCoreInPage.toString()` into the page (the same pattern `@surea11y/playwright` uses), your actual browser-injected payload is unaffected by this — only your Node-side `require()` footprint grows. Worth knowing if your binding's own package size matters to your consumers.
41
+ `src/core.js` is not small (~4.3MB as of 2026-08-20) because the bundler-free, no-driver-context functions (`runa11yCoreInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`) each carry their own complete self-contained copy of the rule catalog. If your binding only ever uses `require('@surea11y/core')` in Node and injects `runa11yCoreInPage.toString()` into the page (the same pattern `@surea11y/playwright` uses), your actual browser-injected payload is unaffected by this — only your Node-side `require()` footprint grows. Worth knowing if your binding's own package size matters to your consumers.