@surea11y/core 1.5.0 → 1.7.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.
- package/CHANGELOG.md +240 -149
- package/README.md +51 -44
- package/docs/ACT_RULE_MAPPING.md +245 -0
- package/docs/API_STABILITY.md +53 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
- package/docs/DESIGN_CHALLENGES.md +367 -0
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +42 -4
- package/docs/I18N.md +4 -4
- package/docs/INTEGRATION.md +4 -2
- package/docs/LIMITATIONS.md +9 -5
- package/docs/OUTPUT_SCHEMA.md +44 -6
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_AUTHORING.md +63 -36
- package/docs/RULE_CATALOG.md +1928 -169
- package/docs/RULE_HELPERS.md +333 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +21 -2
- package/docs/TROUBLESHOOTING.md +2 -2
- package/docs/WCAG_CONFORMANCE.md +34 -10
- package/package.json +11 -9
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +74 -10
- package/src/checks/automatic/aria-allowed-role.js +34 -25
- package/src/checks/automatic/aria-braille-equivalent.js +21 -13
- package/src/checks/automatic/aria-conditional-attr.js +22 -15
- package/src/checks/automatic/aria-deprecated-role.js +13 -1
- package/src/checks/automatic/aria-hidden-body.js +3 -3
- package/src/checks/automatic/aria-hidden-focus.js +5 -5
- package/src/checks/automatic/aria-prohibited-attr.js +23 -18
- package/src/checks/automatic/aria-prohibited-children.js +136 -43
- package/src/checks/automatic/aria-required-attr.js +119 -24
- package/src/checks/automatic/aria-required-children.js +54 -30
- package/src/checks/automatic/aria-required-parent.js +93 -15
- package/src/checks/automatic/aria-role-name-present.js +37 -23
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -10
- package/src/checks/automatic/autocomplete-valid.js +2 -2
- package/src/checks/automatic/avoid-inline-spacing.js +133 -6
- package/src/checks/automatic/binary-control-name-present.js +27 -5
- package/src/checks/automatic/button-name-present.js +92 -6
- package/src/checks/automatic/combobox-name-present.js +26 -6
- package/src/checks/automatic/contrast-computable.js +42 -0
- package/src/checks/automatic/contrast-enhanced.js +33 -1
- package/src/checks/automatic/contrast-minimum.js +33 -1
- package/src/checks/automatic/css-orientation-lock.js +138 -24
- package/src/checks/automatic/definition-list-children-valid.js +7 -8
- package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
- package/src/checks/automatic/dialog-name-present.js +20 -2
- package/src/checks/automatic/duplicate-id-aria.js +10 -3
- package/src/checks/automatic/duplicate-id.js +203 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +10 -1
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +68 -7
- package/src/checks/automatic/iframe-name-present.js +37 -3
- package/src/checks/automatic/iframe-title-unique.js +1 -1
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +204 -68
- package/src/checks/automatic/link-in-text-block.js +285 -29
- package/src/checks/automatic/link-name-present.js +22 -1
- package/src/checks/automatic/list-children-valid.js +6 -6
- package/src/checks/automatic/listbox-name-present.js +28 -8
- package/src/checks/automatic/listitem-parent-valid.js +4 -4
- package/src/checks/automatic/menuitem-name-present.js +20 -2
- package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
- package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
- package/src/checks/automatic/meter-name-present.js +23 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
- package/src/checks/automatic/option-name-present.js +23 -4
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +23 -4
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
- package/src/checks/automatic/searchbox-name-present.js +28 -8
- package/src/checks/automatic/server-side-image-map-absent.js +1 -1
- package/src/checks/automatic/slider-name-present.js +27 -6
- package/src/checks/automatic/spinbutton-name-present.js +28 -8
- package/src/checks/automatic/summary-name-present.js +18 -2
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +21 -2
- package/src/checks/automatic/table-headers-attr-valid.js +43 -8
- package/src/checks/automatic/table-th-has-data-cells.js +61 -5
- package/src/checks/automatic/target-size-minimum.js +155 -58
- package/src/checks/automatic/td-has-header.js +24 -23
- package/src/checks/automatic/textbox-name-present.js +28 -8
- package/src/checks/automatic/tooltip-name-present.js +21 -2
- package/src/checks/automatic/treeitem-name-present.js +23 -4
- package/src/checks/automatic/valid-lang.js +92 -7
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +3 -3
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +58 -11
- package/src/checks/manual/empty-table-header-manual.js +8 -8
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +563 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
- package/src/checks/manual/heading-order-manual.js +3 -3
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +4 -4
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +4 -4
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
- package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
- package/src/checks/manual/landmark-one-main-manual.js +6 -6
- package/src/checks/manual/landmark-unique-manual.js +9 -9
- package/src/checks/manual/link-name-quality-manual.js +161 -32
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +2 -2
- package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +6 -6
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
- package/src/checks/manual/region-manual.js +19 -19
- package/src/checks/manual/scope-attr-valid-manual.js +2 -2
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +5 -5
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +2 -2
- package/src/checks/manual/table-duplicate-name-manual.js +2 -2
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +8880 -41883
- package/src/earl.js +144 -0
- package/src/report.js +2 -2
- package/src/sarif.js +22 -2
- package/surea11y.browser.js +10 -37882
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/bin/surea11y-core.js +0 -20
package/CHANGELOG.md
CHANGED
|
@@ -4,258 +4,349 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.7.0] - 2026-08-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `@surea11y/core/earl` renders scan results as an EARL 1.0 report in JSON-LD — the W3C vocabulary for stating what a tool tested and what it found, and the format the ACT Rules community group accepts as an implementation report. `renderEarlReport(results, { assertor, mode })` takes an array as readily as one result, groups the graph by `TestSubject` as the ACT context requires, and sorts subjects by source and assertions by rule id so the same inputs give byte-identical output and a diff between engine versions means something. Unlike the SARIF and HTML reporters, which carry violations only, every rule that ran becomes an assertion: `pass` and `inapplicable` are what distinguish "checked and found nothing to check" from "does not implement that rule". Success Criteria come out as `WCAG2:<criterion-id>` derived from the criterion's own title, reading only the mappings that state a conformance level, since `normativeMappings` also carries Understanding-document references and other standards under the same `standard: "WCAG"`. A rule mapping to no criterion omits `isPartOf` rather than asserting an empty list. See [`docs/EARL.md`](./docs/EARL.md).
|
|
11
|
+
- The engine's extension boundary is declared. `src/index.js` re-exports the generated core verbatim, so the public surface was whatever the build happened to emit — 19 symbols, of which the six first-party consumers use two, and one of which (`__internal`) hands out an engine internal. `docs/API_STABILITY.md` now names the supported set (`runa11yCoreInPage`, `runDomRulesInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`, `getChecksCatalog`, `getRulesCatalog`) and lists the rest as exported-but-internal, free to change in a minor. The classification lives in `scripts/data/public-api.json` and `tests/public-api.test.js` fails when a new export appears unclassified, so a leak has to be a decision. Nothing is removed: that is a candidate for the next major, and no consumer needs it yet.
|
|
12
|
+
- The custom-rule descriptor is covered by semver. `engineOptions.customRules` was documented in full but appeared nowhere in the stability contract, so the one real plugin API carried no promise. `id`, `meta`, `runInPage(ctx)`, the optional `applicability(ctx)` and `data`, the `ctx.helpers` a rule receives, the result it returns, and the function-or-source-string form a binding needs to cross a realm boundary are all stable now. `engineOptions.policyContract`/`policy` and the reporter entry points are documented as the other two extension points.
|
|
13
|
+
- `overriddenBuiltinIds` joins the stable top-level result fields. It is always emitted and fully documented in `OUTPUT_SCHEMA.md`, but was missing from the stable list despite being how a consumer detects a `customRules` entry shadowing a built-in id.
|
|
14
|
+
- Rule ids and reason codes are now a documented contract, inventoried in `scripts/data/finding-ids.json` and enforced by `tests/finding-ids.test.js`. Both feed the finding fingerprint — `computeBaselineKey(ruleId, reasonCode, html)`, which backs both stored baselines and the SARIF `partialFingerprints` GitHub Code Scanning tracks alerts by — so either one changing silently unsuppresses every baselined finding and makes every open alert close and reopen as new. `data.details.reasonCode` moves out of `API_STABILITY.md`'s explicitly-unstable list into the stable set, as a deliberate exception to the rest of `data.details`: a rule may gain a code in a minor release, but a shipped one does not change, and a published rule id is removed or renamed only through a `deprecated`/`replacedBy` entry. Regenerate with `npm run finding-ids`. The build already rejected a renamed rule that a composite references; the inventory covers the rules no composite mentions, which it did not. See [`docs/API_STABILITY.md`](./docs/API_STABILITY.md#finding-identity).
|
|
15
|
+
- A `cantTell` occurrence can now say why it could not be decided. `occurrences[i].uncertainty` carries a `code` from a closed vocabulary — `not-computable`, `runtime-dependent`, `spec-only`, `equivalence-unknown`, `judgement-required`, `out-of-scope` — alongside `needed`, one sentence naming what would settle the question, and `evidence`, what the rule did establish so a reviewer starts from the engine's work rather than repeating it. The reason for a `cantTell` was previously only in `data.details.reasonCode`, which is per-rule, free-form and documented as not a stable contract, so nothing could branch on it. The field is present only on a `cantTell`-tier occurrence: on a `fail`-tier one it would claim the rule both decided and did not, and the engine drops it. Every automatic rule that can report `cantTell` carries it — the ARIA family that this release regraded reports `spec-only`, the contrast and CSS rules that cannot read their inputs report `not-computable`, and the target-size and label rules report `judgement-required` or `equivalence-unknown`. The engine attaches `out-of-scope` itself to the occurrences behind a `wcagVersionScope` coercion. A test holds the line so a new rule cannot report `cantTell` without saying why, and `validate:rules` rejects a code outside the vocabulary. Manual rules do not carry it, since `judgement-required` is what `type: "manual"` already means. Purely additive, so no `schemaVersion` bump. See [`docs/OUTPUT_SCHEMA.md`](./docs/OUTPUT_SCHEMA.md#uncertainty-codes).
|
|
16
|
+
- `occurrences[i].occurrenceOutcome` is documented. It has been in the output since rules began grading findings into a confident `fail` tier and a needs-review `cantTell` tier, but `OUTPUT_SCHEMA.md` never described it, so the reason a `fail` result can carry `cantTell` occurrences was undocumented. No behaviour change.
|
|
17
|
+
- `engineOptions.wcagVersion` (`'2.0'`, `'2.1'` or `'2.2'`) sets which version of WCAG a scan is conformance-testing against. It defaults to whatever your version-origin tags imply, and to `'2.2'` when they imply nothing, and the resolved target comes back on every result as `engine.wcagVersion`. See [`docs/ENGINE_OPTIONS.md`](./docs/ENGINE_OPTIONS.md).
|
|
18
|
+
- `docs/RULE_HELPERS.md`: reference for every function on `ctx.helpers` available to a rule's `runInPage` — around 35 flat helpers plus the `contrast.*`/`aria.*` namespaces. `docs/RULE_AUTHORING.md` §6 previously named only a dozen of them inline; the rest were discoverable only by reading `src/core/dom-helpers.js` directly. §6 now points here instead.
|
|
19
|
+
- `password-paste-enabled` raises an authentication field carrying an inline paste handler for review, the first coverage of WCAG 3.3.8. A password manager, or the clipboard for a one-time code, is the mechanism the criterion asks for, and blocking paste removes it. Advisory and capped at `cantTell`: whether a handler really stops the user depends on script the markup does not carry, so the two cases are reported apart rather than decided — one that only cancels, and one that goes further and may be re-inserting the text. In scope are `current-password`, `new-password` and `one-time-code` fields, plus `<input type="password">` unless its `autocomplete` names another purpose.
|
|
20
|
+
- `landmark-complementary-is-top-level` reports a complementary landmark nested inside another landmark, completing a family that already covered banner, contentinfo and main. Advisory and capped at `cantTell`, like its siblings. An unnamed `<aside>` inside sectioning content is not reported, since HTML-AAM leaves it no complementary role to nest.
|
|
21
|
+
- `@surea11y/core/i18n/<locale>` resolves each locale side file by path, alongside the existing `@surea11y/core/browser`. A binding that injects the standalone bundle into a page needs the matching dictionary to honour `engineOptions.locale`, and the exports map previously put both out of reach. An unshipped locale fails to resolve rather than resolving to nothing, so a caller can tell the difference and fall back. See [`docs/BINDING_AUTHORS_GUIDE.md`](./docs/BINDING_AUTHORS_GUIDE.md).
|
|
22
|
+
- `identical-iframes-same-purpose` checks that `<iframe>`/`<frame>` elements sharing an accessible name embed the same resource, implementing ACT rule 4b1c6c. It sits alongside `iframe-title-unique`, which asks the stricter and different question of whether the `title` attribute repeats at all: this one keys on the computed accessible name, counts only frames included in the accessibility tree, and judges the resource behind the name. `src` values are compared as resolved absolute URLs with the fragment removed and a trailing slash normalised away, so one directory written both ways is a single resource. Frames resolving to different URLs are reported `cantTell`, never `fail`: different resources can still be equivalent — differently worded copies of a page, or two adverts serving the same purpose — and neither the markup nor the embedded documents settle it, since content differing is exactly what those equivalent cases look like.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- A rule mapped only to a Success Criterion the target WCAG version removed can no longer report `fail`. Under the default 2.2 target that means `duplicate-id`: it still runs and still reports every duplicate it finds, but comes back `cantTell` with a `wcagVersionScope` field naming the criterion 2.2 dropped, so a default scan is never gated by SC 4.1.1. Coercing rather than excluding keeps the defect visible, since a duplicate id still breaks `<label for>`, fragment navigation and `getElementById` whatever the standard says. Target 2.0 or 2.1 for the real failure, or keep excluding the rule outright with `excludeTags: ['wcag22-removed']`.
|
|
26
|
+
- `aria-controls` pointing at an id no element has is no longer a failure of `aria-valid-attr-value`. The menu, listbox or panel it names is routinely built when the widget opens, so a static scan that cannot find it has not established a defect. A collapsed element (`aria-expanded="false"` or `aria-selected="false"`) passes outright, since the absence is what that state means; anything else is reported as `cantTell` for review. Every other ID-reference attribute is unchanged: a dangling `aria-labelledby` or `aria-owns` names content that was supposed to be there already.
|
|
27
|
+
- The standalone browser bundle and its locale side files are minified. `surea11y.browser.js` goes from 1430 KB to 706 KB, and from 294 KB to 165 KB over the wire. Most of a page's download was the 130 inlined rule bodies, and most of those were their own comments. The global, the API and the result are unchanged; the readable form of every rule remains its own module under `src/checks`.
|
|
28
|
+
- `runa11yCoreAcrossFrames` scans its own frame through `runa11yCoreInPage` instead of carrying a second copy of the rule catalog and the shared runner block. The generated `src/core.js` drops from 4.34 MB to 2.67 MB, and the published package from 7.7 MB to 5.3 MB unpacked. Both functions stay usable the bundler-free way they always were — raw source injected into a page — since `runa11yCoreInPage` is itself self-contained and free of `require()`.
|
|
29
|
+
- The composite rollup moved out of `runCore` into its own function, `rollupCompositeResults`, in `src/core/dom-runner.js`. Results are unchanged. It was a long inline block nothing else could reach; splitting it keeps `runCore` readable and lets the rollup be called on its own. Internal only, not part of the package's public exports.
|
|
30
|
+
- `aria-required-children`, `aria-prohibited-children` and `aria-required-parent` map to SC 1.3.1 Info and Relationships instead of 4.1.2 Name, Role, Value, and carry `wcag131` in place of `wcag412`. The ACT rules these three implement, `bc4a75` and `ff89c9`, name 1.3.1 as their only requirement, and it is the criterion the finding actually describes: a `role="listitem"` outside any list, or a `role="list"` owning no item, misstates the structure exposed to assistive technology rather than the name, role or value of a control. It also puts them beside the native-HTML checks that ask the same question, `listitem-parent-valid` and `list-children-valid`, which were already 1.3.1. Level A either way, and all three still run on a default scan and report exactly what they reported before; what moves is which composite the verdict lands in, `wcag-1.3.1-info-and-relationships` gaining the three and `wcag-4.1.2-aria-validity` losing them, and what a tag-filtered run selects, since `tags: ['wcag412']` no longer picks them up. Their coverage facets move to 1.3.1 with them.
|
|
31
|
+
- `aria-hidden-body` and `aria-role-name-present` carry the `aria` tag the rest of the family already had. Both are entirely about ARIA usage — `aria-hidden` on the document body, and the roles WAI-ARIA requires an accessible name for — so a run filtered on `tags: ['aria']` was silently missing two of them. No other tag changes, and no rule changes what it evaluates or reports.
|
|
32
|
+
- `aria-required-parent` honours `aria-busy="true"` on an ancestor, WAI-ARIA's own escape hatch for a widget script has not finished assembling. `aria-required-children` and `aria-prohibited-children` already read it off the container they check; from the item's side the marked element is an ancestor, so the walk looks up rather than at the element itself, and only the exact string `"true"` counts. It also outranks the roleless-generic-parent rule, since `aria-busy` is a global ARIA attribute and would otherwise block the context search and fail the very markup the spec says to mark. A `role="option"` inside a container still loading its `role="listbox"` wrapper is `notApplicable` now, not a failure.
|
|
33
|
+
- `docs/OUTPUT_SCHEMA.md` no longer defines `fail` as a high-confidence outcome. Eight automatic rules ship `fail` at `confidence: "medium"`, and that was never a contradiction: the outcome describes the decision procedure, which guesses at nothing, while `confidence` describes the model it decides against — curated WAI-ARIA tables, native-role mappings, an accessibility tree inferred from static markup. The outcome table, the `type: "manual"` note, `POLICY.md`, `SARIF.md` and `LIMITATIONS.md` all said "deterministic, high-confidence"; they say "deterministic" now, and the confidence section names the rules and explains what `medium` on a `fail` means. Documentation only, no behaviour change.
|
|
34
|
+
- `aria-required-children` no longer fails a container for being empty; it reports `cantTell` for every finding. The rule asks only whether the required content is present, and a container that owns nothing conveys nothing false — an empty `role="list"` is announced as a list with no items, which is what it is. Whether the content a container does own is valid is `aria-prohibited-children`'s decision, and that rule still fails, so a `role="button"` among list items or a `role="tablist"` of plain buttons is caught exactly as before, at the same criterion. This also settles an inconsistency with the native-HTML rules, which already judge only the children that exist: `<ul></ul>` passed while `<div role="list"></div>` failed. One shape loses its failure and is now reported for review instead: a container whose items never got their role, `<div role="list"><div>Item</div></div>`. Applicability, the `aria-busy` exemption and `aria-owns` resolution are unchanged.
|
|
35
|
+
- Six ARIA rules report `cantTell` where the violation leaves the exposed name, role and value intact, instead of `fail`. ACT maps five of them to WAI-ARIA author requirements rather than to WCAG, and lists 1.3.1/4.1.2 as "less strict" secondary requirements that an implicit role or a spec-supplied default can still satisfy; the engine was asserting a Level A failure on all of them. Two grade per finding: `aria-required-attr` fails where ARIA supplies no stand-in (`aria-checked` on checkbox/radio/switch/menuitem*, `aria-valuenow` on slider/scrollbar/meter and a focusable separator) and reports `cantTell` where it does (`aria-expanded` on combobox, `aria-level` on heading), the table generated from aria-query's `requiredProps` by `scripts/generate-aria-tables.js` so the two tiers cannot drift from the spec; `aria-roles-valid` fails on a roleless host left exposed as generic and reports `cantTell` where a native role survives the bad token. Four report `cantTell` throughout: `aria-valid-attr` (an undefined attribute is inert), `aria-allowed-role` (an ARIA-in-HTML author requirement with no ACT rule and no WCAG mapping anywhere), `aria-braille-equivalent` and `aria-conditional-attr`, the last two also dropping from `serious` to `moderate`. Every finding is still reported with the same occurrences; what changes is that a page whose only ARIA defects are of this kind comes back `cantTell` on `wcag-4.1.2-aria-validity` rather than `fail`, so a CI gate on `fail` stops gating on them. ACT `674b10`, `4e8ab6` and `5f99a7` still run clean (25 cases, 0 mismatches). Reasoning in [`docs/DESIGN_CHALLENGES.md`](./docs/DESIGN_CHALLENGES.md).
|
|
36
|
+
- `aria-allowed-role` no longer claims a WCAG Success Criterion. ARIA-in-HTML's permitted-roles table is an author conformance requirement of that specification: no ACT rule covers it, and no source maps it to a criterion, so declaring SC 4.1.2 at Level A overstated every finding it made. It now carries `best-practice` in place of `wcag2a`/`wcag412`, with `wcagSc: []`, no `normativeMappings` and no coverage facet, and it has left the `wcag-4.1.2-aria-validity` composite (13 contributors) and the 4.1.2 facet registry. It still runs on a default scan and still reports the same findings at `cantTell`; what changes is that a run filtered on `tags: ['wcag2a']` or `['wcag412']` no longer selects it, and a page whose only ARIA-in-HTML nit is this one now reaches `pass` on that composite instead of `cantTell`. This is the first automatic rule in the engine with no WCAG mapping, alongside the 25 manual `best-practice` rules that already had none.
|
|
37
|
+
- `docs/RULE_TAXONOMY.md` §1.1 no longer says an automatic rule may use `cantTell` only as a defensive fallback, "never as its primary intended path". Six rules now lead with it. The dividing line between `automatic` and `manual` is whether a rule can decide, not which outcome it reports: an automatic rule reporting `cantTell` has decided and is saying what it found, while a manual rule reports `cantTell` because the question is not decidable from markup. The three cases where `cantTell` is an automatic rule's primary path are listed there.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- A frame responder answered any window that could reach it, not just the frame embedding it. `a11yCoreEnableFrameResponder()` is documented as opting a frame in to being scanned *from above*, but the listener ran a scan for any sender: a sibling frame can obtain a reference through `parent.frames[i]` and `postMessage` across origins, and the reply carries `occurrences[].html` — DOM content the same-origin policy gives that sibling no way to read. An opener could do the same. A `run` command is now answered only when its sender is the direct parent, which the hop-by-hop relay makes the only legitimate case, and a window nothing embeds answers nobody. **Affects 1.3.0 through 1.6.0**, and only consumers that call `a11yCoreEnableFrameResponder()` in a framed page — the automation-driver patterns (`@surea11y/playwright`, `@surea11y/puppeteer`, the CLI) never use the responder and were never exposed.
|
|
41
|
+
- A reply could be accepted from a window the request never went to. Any window naming an in-flight `requestId` could settle it, forging a frame's scan result or its failure. Each pending request now records the window it was sent to and ignores answers from anywhere else, pings included — reachability is the addressed frame's verdict to give.
|
|
42
|
+
- `engineOptions.excludeSelectors` cost a multiple of the whole scan. Every rule queries through `isExcluded`, which walked an element's ancestor chain once per selector with nothing remembered between rules, so the work was repeated for all 130 of them: excluding a cookie banner and four ad slots turned a 2.3s scan of a 3574-element page into 10.5s, and 25 exclusions — an ordinary list for a real site — into 43s. Exclusion results are now memoized per element for the run, partitioned by the effective exclude list exactly as the selector cache already is, and the self-test uses `matches` against the element rather than `closest` against the chain, since a parent's answer already settles its descendants. The same page with 25 exclusions takes 3.1s, and the raw matching floor is 18ms against the 5588ms an equivalent scan used to spend. Rule-scoped `rules[ruleId].excludeSelectors` keep their own cache partition, and a malformed selector still excludes nothing without disabling the usable selectors beside it. `perfStats` gains `excluded.hit`/`excluded.miss`.
|
|
43
|
+
- A rule that could not check anything said so, and SARIF dropped it. The contrast rules attach an occurrence to their `notApplicable` result naming the eligible text count and pointing at `contrast-computable`; the HTML report showed it, SARIF did not, so a CI pipeline reading only SARIF saw no contrast alerts and had no way to tell a clean page from one where contrast was never computable. Those occurrences now reach SARIF as `note`-level entries in `runs[0].invocations[0].toolExecutionNotices`, each carrying `associatedRule.id`. They are deliberately not results: every SARIF result renders as an alert, and "not evaluated" is not one, so nothing new gates a build or appears in Code Scanning. The block is emitted only when there is something to say.
|
|
44
|
+
- `OUTPUT_SCHEMA.md`, `SARIF.md` and `EARL.md` all stated that a `notApplicable` result carries no occurrences. It can: a rule that had nothing to judge may attach one occurrence saying why, and the contrast rules do exactly that when no text had a computable background — the message names the eligible text count and points at `contrast-computable`. A consumer that took the documented shape at face value read `occurrences.length` as a violation count and got one for a rule that flagged nothing. The behaviour is deliberate and unchanged; the three documents now describe it, `OUTPUT_SCHEMA.md` notes that such an occurrence carries an empty `selector` because it describes the scan rather than an element, and `SARIF.md` states that SARIF omits it — a SARIF consumer treats every result as an alert, so a pipeline reading only SARIF cannot tell "checked, nothing to flag" from "could not check" and needs `checksResults` or the HTML report for that distinction.
|
|
45
|
+
- `OUTPUT_SCHEMA.md` claimed without qualification that the engine verifies a reported `selector` resolves to the element it names. `page-title-present` is the exception, reporting `head > title` and an `html` of `<title>(missing)</title>` for a page that has neither, since its finding is the absence itself. Both are constants and the baseline fingerprint they feed stays stable; the field now says so rather than promising a resolvable selector.
|
|
46
|
+
- `LIMITATIONS.md` now records that scan time under jsdom grows with the square of DOM depth. jsdom resolves inherited CSS by walking the ancestor chain on every `getComputedStyle` call, so 4000 elements in one chain cost 8.3s of `getComputedStyle` alone against 0.25s for the same 4000 as siblings, with no engine code involved. The engine's own walks are capped and stay linear. A real browser computes inherited style natively and does not have this shape.
|
|
47
|
+
- `buildStructuralPath` was the one ancestor walk here with no bound. A consistent tree ends it when an element is not found among its parent's children, but a parent chain that cycles while still reporting itself as each other's child never terminates. It now gives up past a depth no real document reaches and returns `null`, matching what the field already documents for a path it cannot determine.
|
|
48
|
+
- `avoid-inline-spacing` no longer fails text that cannot wrap. ACT 78fd32/24afc2/9e45ec apply only to text containing a soft wrap break, and running the official corpus turned this up as the engine's one false positive across 798 cases: a fixed-width paragraph inside a horizontally scrolling container, which never wraps however the viewport changes, was reported as a violation. Layout would settle whether text wraps and a static scan cannot, but two shapes do establish that no wrap is possible — text not allowed to wrap, and a fixed-width element inside a horizontally scrolling ancestor — and those now report `cantTell` with the `not-computable` uncertainty code and a new `INLINE_SPACING_NO_SOFT_WRAP` reason code. Everything else is still treated as wrapping, so an ordinary forced value below the metric fails exactly as before. A false positive is the one thing that blocks an ACT implementation report, which is why this is graded rather than left as the documented gap it had been.
|
|
49
|
+
- `link-in-text-block`, `avoid-inline-spacing` and `css-orientation-lock` reported `pass` for candidates they never evaluated. Each counted an element as applicable, met a condition it could not resolve, skipped it, and fell through to `pass` — the same clean result whether the element was checked and found sound or never checked at all. They report `cantTell` for those candidates now, the computability gate `RULE_TAXONOMY.md` §1.1 already allows automatic rules. A proven failure still outranks an undecided candidate, so a page with both fails as before. Concretely: `link-in-text-block` skipped a link whose contrast against the surrounding text was not computable, `avoid-inline-spacing` an `!important` spacing value that resolved to no ratio, and `css-orientation-lock` every cross-origin stylesheet — so a page with one readable stylesheet and three unreadable ones asserted no orientation lock existed.
|
|
50
|
+
- `link-in-text-block` treated every link as underlined outside a real browser. It read `text-decoration-line` and the `text-decoration` shorthand as one set of tokens, which only holds where the two agree. jsdom does not cascade the property: the shorthand reads back as the user agent's `underline` for every `<a>` whatever the author stylesheet says, and the longhand as `none` unless the author wrote the longhand themselves, so `text-decoration: underline` and `text-decoration: none` are indistinguishable in the computed style. Reading the longhand alone inverts the error into a false failure on correctly underlined links. A conforming CSSOM serialises the shorthand with the line value first, so disagreement between the two is now the signal to distrust both, and the rule resolves the declaration from the author stylesheets instead, as `css-orientation-lock` and `css-focus-indicator-suppressed` already read the CSSOM. `:hover` and other state rules are excluded, since they do not describe the link's resting appearance, and inline style outranks the stylesheets.
|
|
51
|
+
- `link-in-text-block` now tests the cues that do not depend on `text-decoration` first, so a link distinguished by font weight, font style or sufficient contrast is decided even where decoration cannot be read.
|
|
52
|
+
- `contrast-minimum`, `contrast-enhanced` and `contrast-computable` skipped text assigned straight to a shadow root. A text node whose parent is the shadow root itself has no parent element, and the scan resolved colors from the parent element alone, so `shadowRoot.textContent = 'Some text'` was walked and then dropped with no candidate recorded — a component rendering its text that way was reported `notApplicable` rather than checked. The host carries the inherited color and background that text renders with, so it is what the scan attributes the text to now. Text inside an element within a shadow root was always found and is unchanged.
|
|
53
|
+
|
|
54
|
+
## [1.6.0] - 2026-08-23
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
- `duplicate-id` flags a non-empty `id` repeated within the same document or shadow tree. Tagged `wcag2a` plus `wcag22-removed` since SC 4.1.1 (Parsing) was dropped in WCAG 2.2, so exclude it from a 2.2 conformance run with `excludeTags: ['wcag22-removed']`. See `docs/ENGINE_OPTIONS.md`.
|
|
58
|
+
- `form-control-label-quality` reviews a field's visible label text: a leftover placeholder, a label reused across several fields with nothing telling them apart, or a label split between visible and hidden parts. Sits alongside `form-control-programmatic-label-quality`, which judges the labelling mechanism instead of the text.
|
|
59
|
+
- `css-focus-indicator-suppressed` flags `:focus`/`:focus-visible` rules that remove the outline with no replacement drawn anywhere else.
|
|
60
|
+
- `heading-quality` flags placeholder heading text: generic words, numbered template slots, filenames, URLs.
|
|
61
|
+
- `presentational-children-focusable-absent` catches a focusable descendant left inside a role that makes its own children presentation-only (`button`, `img`, `option`, `tab`, ...): reachable by keyboard, but with no exposed role or name.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
- `docs/RULE_CATALOG.md` now includes each rule's applicability and expectation text alongside its summary, generated straight from source.
|
|
65
|
+
- `link-name-quality` weighs nearby context before flagging a link as non-descriptive (an `aria-describedby` target, or the enclosing list item/table cell/paragraph's own text) rather than judging the accessible name on its own. It also catches bare file-format names like "HTML" or "PDF" for the first time.
|
|
66
|
+
- Rule summaries and hints are reworded throughout: same meaning, plainer phrasing. Anything snapshot-testing that text will see a diff.
|
|
67
|
+
|
|
68
|
+
### Removed
|
|
69
|
+
- `bin/surea11y-core.js` and its `bin` entry, unused since the CLI moved to `@surea11y/cli` in 1.4.0.
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
- `aria-prohibited-children` no longer treats a container's *required* owned elements as the exhaustive list of children it may own. WAI-ARIA's "Required Owned Elements" says what a container must contain, not all it may contain. Using the required set as the allowed set also flagged markup the spec itself describes: a `role="separator"` between menu items, and a `role="caption"` on a `role="table"`/`role="grid"`. A second table now carries the difference; an entry needs a source, either an ARIA Required Context Role naming the container or the child role's own definition placing it there. `scripts/generate-aria-tables.js` generates the table and rejects any entry with neither (`treegrid: caption` and `rowgroup: rowheader` are both refused, with the reasoning recorded). Separators under `list`/`listbox`/`tablist`, captions under `treegrid`, and any other unsourced role still fail, and the failure message now lists the full allowed set rather than only the required roles.
|
|
73
|
+
- `aria-prohibited-children` no longer reports an item's own content as a prohibited child of the container. The walk treats a roleless wrapper as transparent, which it has to, since component markup routinely buries the real item several roleless levels down (an Angular Material card whose radio sits at `card > header > mat-radio-button > div > div > input[type=radio]`). But that transparency ran in both directions, so any role-bearing element anywhere in an item's subtree registered as an owned child of the container too far up, e.g. a `role="separator"` dividing two columns inside a radio card was reported as a prohibited child of the enclosing `role="radiogroup"`, six levels up. A roleless wrapper that holds a required item is now treated as an item wrapper: only the items it holds count as the container's owned children, and the rest of that subtree belongs to the item. This touches all twelve container roles in `REQUIRED_OWNED_ROLES`. Unchanged: `role="none"`/`"presentation"` wrappers, which really are removed from the accessibility tree with their children promoted, so a disallowed role inside one is still an owned child; a roleless wrapper holding no item at all, which is interposed content and still reported; and any genuinely stray direct child of the container.
|
|
74
|
+
- `aria-role-name-present` no longer fails `tablist`, `toolbar`, `menu`, `menubar` and `scrollbar` for having no accessible name. Its role list was a hand-written allowlist of roles WAI-ARIA lets an author name, which is the wrong predicate: the spec records *Name From* and *Accessible Name Required* separately, and those five are `nameRequired: false`. A single tab widget under a visible heading, a mainstream and usable pattern, was reported as a `serious` WCAG 4.1.2 Level A failure at `high` confidence, with no ACT counterpart validating the scope. The rule now evaluates only the roles ARIA actually requires a name for (`grid`, `meter`, `progressbar`, `radiogroup`, `tree`), so its Level A mapping is true of every finding it emits; the five dropped roles are out of applicability rather than passing. The set is generated from `aria-query` by `scripts/generate-aria-tables.js` and re-derived in the rule's tests, so it can't drift back to a hand-picked list. `meter`/`progressbar` stay despite their dedicated rules, which map to SC 1.1.1: this rule is their only 4.1.2 coverage. Open questions (a `cantTell`-tier alternative that was considered and rejected, and the five name-*required* roles still uncovered: `table`, `tabpanel`, `treegrid`, `application`, `marquee`) are tracked in `docs/DESIGN_CHALLENGES.md`.
|
|
75
|
+
- `isAccTreeEligible` ignored `content-visibility: hidden`, so content the browser skips entirely (no accessibility tree, no focus, no find-in-page) stayed in every rule's candidate list and could be reported as a failure. It blocks the subtree now, like `display:none`, using the `contentVisibilityHidden` reason `isDomVisibleEligible` already reported. The element carrying the declaration is unaffected: it hides its contents, not itself, so it stays in scope.
|
|
76
|
+
- `contrast-minimum`/`contrast-enhanced` stopped requiring a contrast ratio for text made entirely of punctuation or symbols; digit-only text is still checked.
|
|
77
|
+
- `contrast-computable` treats a declared `text-shadow` as a computability blocker (`cantTell`) instead of asserting pass or fail: a strong shadow can rescue contrast that would otherwise fail.
|
|
78
|
+
- `contrast-minimum`/`contrast-enhanced` stopped reporting a failure when text's foreground color exactly matches its background; that text isn't visible in the first place.
|
|
79
|
+
- `identical-links-same-purpose` covers `[role="link"]` elements too, not just `a[href]`, and can pull a link's destination out of an `onclick` attribute when there's no real `href`.
|
|
80
|
+
- `iframe-name-present` requires an iframe to be reachable by keyboard focus before it needs a name, and stopped flagging one already marked decorative with `role="none"`/`"presentation"`.
|
|
81
|
+
- `css-orientation-lock` uses a ±5-degree tolerance for its 90/270-degree rotation check instead of demanding an exact multiple of 90, and now decomposes `matrix()`/`matrix3d()`/`rotate3d()` rotations as well.
|
|
82
|
+
- `iframe-focusable-content` resolves a `srcdoc` iframe's embedded content, and exempts iframes 2px or smaller (tracking pixels) from needing to be free of focusable content.
|
|
83
|
+
- `bypass-blocks-present`'s heading mechanism requires the heading to actually be visible, not just present in the accessibility tree.
|
|
84
|
+
- `aria-valid-attr-value` stopped failing a bare or empty ARIA attribute value, and no longer requires `aria-errormessage`'s target to exist (`aria-activedescendant` still does).
|
|
85
|
+
- `valid-lang` scopes to text that actually inherits its language from the target element, rather than any text anywhere in its subtree.
|
|
86
|
+
- `role-img-text-alternative-present` covers `role="graphics-symbol"`/`"graphics-document"` too, including nested SVG shapes, and stopped flagging elements inside `aria-hidden="true"`.
|
|
87
|
+
- `img-alt-decorative` reviews any visible `<img>`/`<canvas>`/`<svg>` excluded from the accessibility tree, not just `<img alt="">`.
|
|
88
|
+
- `label-in-name` stopped flagging visible text standing in for an icon: a single character absent from the accessible name, or text rendered through a known icon font.
|
|
89
|
+
- `aria-prohibited-children` treats `role="group"`/`"rowgroup"` wrappers as transparent for every container role, not only the ones that also accept `group`/`rowgroup` as a leaf role themselves.
|
|
90
|
+
- Native containment roles (`<li>` to `listitem`, `<option>` to `option`, the table row/cell roles) are conditional on the element's actual parent now, matching HTML-AAM. Affects `aria-required-children`, `aria-prohibited-children`, `aria-required-parent`.
|
|
91
|
+
- `aria-allowed-attr` judges elements with no ARIA role at all (`<audio>`/`<video>`, and `<div>`/`<span>` as `generic`) instead of skipping them.
|
|
92
|
+
- `table-headers-attr-valid` scopes strictly to `table`/`grid`/`treegrid`; any other explicit role takes a table out of scope entirely.
|
|
93
|
+
- `aria-required-attr` requires `aria-valuenow` on a focusable `role="separator"`.
|
|
94
|
+
- `presentation-role-conflict` stopped flagging an `<img alt="">` that carries an explicit role of its own.
|
|
95
|
+
- Ten German/Spanish/French strings for `input-image-alt-present` had lost diacritics and apostrophes; restored.
|
|
96
|
+
- 19 rules had an English `title`/`description` that disagreed between source and dictionary; corrected, and `validate-rule.js` now asserts the two match.
|
|
97
|
+
- `scripts/generate-aria-tables.js --check` could never pass: it compared unformatted output against the formatted, committed tables.
|
|
98
|
+
|
|
7
99
|
## [1.5.0] - 2026-08-16
|
|
8
100
|
|
|
9
101
|
### Added
|
|
10
|
-
- `engine.locale` on the result records which dictionary a run actually used: `{ requested, resolved, reason }`, once per result. Locale fallback is graceful and per-string, so asking for a language the build doesn't carry has always produced fluent English with nothing in the output to say so
|
|
102
|
+
- `engine.locale` on the result records which dictionary a run actually used: `{ requested, resolved, reason }`, once per result. Locale fallback is graceful and per-string, so asking for a language the build doesn't carry has always produced fluent English with nothing in the output to say so. `reason` now distinguishes `ok`, `unknown-locale` (no dictionary for that code, a region subtag counts as its own locale) and `partial-dictionary` (dictionary used but missing keys). Purely additive, so `engine.schemaVersion` stays `"1.0.0"`. Documented in `docs/OUTPUT_SCHEMA.md` and `docs/API_STABILITY.md`.
|
|
11
103
|
- `npm run i18n:sync` rewrites every non-English locale file against `en.json`: adds keys that are new (seeded with the English text), drops keys `en.json` no longer has, and leaves existing translations untouched. `npm run i18n:check` performs the same comparison without writing and exits non-zero on drift. Adding a rule string previously meant editing four files by hand, with a missed one falling back to English silently.
|
|
12
104
|
|
|
13
105
|
### Changed
|
|
14
|
-
- The standalone browser bundle carries English only, and every other locale ships beside it as `surea11y.i18n.<locale>.js`, loaded with a second `<script>` tag. The bundle drops from 1580 KB to 1300 KB and stops growing as languages are added
|
|
106
|
+
- The standalone browser bundle carries English only, and every other locale ships beside it as `surea11y.i18n.<locale>.js`, loaded with a second `<script>` tag. The bundle drops from 1580 KB to 1300 KB and stops growing as languages are added. Asking for a locale whose side file is not loaded returns English with `engine.locale.reason` set to `dictionary-not-loaded`, so it's visible rather than silent. `require('@surea11y/core')` and every binding are unaffected and still carry all locales. `scripts/build-browser.js` now generates its own English-only core in memory instead of reading the shipped `src/core.js`.
|
|
15
107
|
- `npm run build` removes a `surea11y.i18n.<locale>.js` whose locale no longer exists. `files` matches those by glob, so a dropped language would otherwise have kept shipping.
|
|
16
|
-
- New `engineOptions.messages`, a `{ [locale]: { key: text } }` map checked before the built-in tables. It can override individual strings or supply a language the build does not carry, and it
|
|
17
|
-
- Locale codes are matched case-insensitively, so `pt-br` and `PT-BR` both find `pt-BR.json`. Only an exact spelling matched before
|
|
18
|
-
- A locale code carrying a subtag now falls back to its base language before falling back to English: `de-DE` and `de-AT` both use `de.json`, matched case-insensitively, and an exact `de-DE.json` still wins if one exists. Previously any subtag resolved straight to English, so a browser-supplied `de-DE` produced English output while `de` produced German. `engine.locale.reason` reports `primary-subtag` for it. A translator now only needs `pt.json` to serve every Portuguese variant
|
|
19
|
-
- Locale sources are JSON (`src/i18n/*.json`) rather than CommonJS modules
|
|
108
|
+
- New `engineOptions.messages`, a `{ [locale]: { key: text } }` map checked before the built-in tables. It can override individual strings or supply a language the build does not carry, and it's how a locale side file reaches the in-page runner. Omitted keys fall back normally.
|
|
109
|
+
- Locale codes are matched case-insensitively, so `pt-br` and `PT-BR` both find `pt-BR.json`. Only an exact spelling matched before. A code differing from its dictionary only in case reports `ok` rather than `primary-subtag`, since no fallback happened.
|
|
110
|
+
- A locale code carrying a subtag now falls back to its base language before falling back to English: `de-DE` and `de-AT` both use `de.json`, matched case-insensitively, and an exact `de-DE.json` still wins if one exists. Previously any subtag resolved straight to English, so a browser-supplied `de-DE` produced English output while `de` produced German. `engine.locale.reason` reports `primary-subtag` for it. A translator now only needs `pt.json` to serve every Portuguese variant.
|
|
111
|
+
- Locale sources are JSON (`src/i18n/*.json`) rather than CommonJS modules, so a translation is a data change with no executable code in the diff. The generated `src/core.js` and `surea11y.browser.js` are byte-identical across the conversion. `src/i18n/*` was already documented as internal and has never been in the published `files` allowlist.
|
|
20
112
|
- `npm run i18n:new` writes a locale file mirroring `en.json`'s key order. Its refusal to overwrite an existing locale now points at `i18n:sync`, which updates a file without discarding translations.
|
|
21
113
|
- Locale completeness is now asserted for every locale rather than for a hand-maintained list of the ones documented as complete; `i18n:sync` makes full key parity the only valid state.
|
|
22
114
|
- `aria-deprecated-role` occurrences resolve their hint through `ariaDeprecatedRole_guidance_directory`/`_generic`/`_default`, replacing `ariaDeprecatedRole_hint_fail`/`_hint_cantTell`. Reason codes are unchanged, so existing baselines keep matching.
|
|
23
115
|
- `formControl_programmaticLabelQuality_summary_cantTell` interpolates `{{method}}` in place of `{{methodLabel}}`, which held the same value once an unreachable branch was removed.
|
|
24
116
|
- `css-orientation-lock` reports a rule whose selector cannot be read through `cssOrientationLock_summary_fail_unknownSelector` instead of substituting a placeholder selector name.
|
|
25
117
|
- The HTML report's meta bar carries the resolved locale alongside the engine tag and schema version, naming the requested locale too when the two differ. A report generated in a locale the engine does not carry previously read as an ordinary English one. A result from an older engine has no `engine.locale` and gets no chip.
|
|
26
|
-
- `target-size-minimum` now reports `cantTell` instead of `fail` when an undersized inline link's only spacing conflict is another inline link in the same run of text (e.g. pipe-separated links in a `<nav>`).
|
|
118
|
+
- `target-size-minimum` now reports `cantTell` instead of `fail` when an undersized inline link's only spacing conflict is another inline link in the same run of text (e.g. pipe-separated links in a `<nav>`). SC 2.5.8's inline exception ("the target is in a sentence or its size is otherwise constrained by the line-height of non-target text") plausibly covers such a link, but whether it applies isn't decidable from geometry alone. Previously the outcome flipped between `fail` and `pass` based on the wrapping element's tag: the same links failed inside a `<nav>` yet passed inside a `<p>`, because the strict inline-text exception (`isInlineTextExceptionTarget`) requires a text-block container (`p`, `li`, `dd`, ...). The strict exception is unchanged; the new middle tier is scoped by a shared `isInlineLinkTarget` helper (link-like, rendered inline/inline-*, with visible text) and only downgrades when both the target and its conflicting neighbor match, so an inline link crowding a `<button>` or a block-displayed nav link still fails. New `cantTell` locale strings (`targetSizeMinimum_summary_cantTell_inlineLinkRun`/`_hint_`) in all four locales; occurrences carry reason code `undersized-inline-link-run`.
|
|
27
119
|
|
|
28
120
|
### Fixed
|
|
29
|
-
- `docs/TROUBLESHOOTING.md` still described two shipped locales and warned that key parity had to be maintained by hand. There are four, and `i18n:sync` with its build check is
|
|
30
|
-
- The README brand tag rendered twice on npm, once per theme. It relied on `#gh-dark-mode-only` and `#gh-light-mode-only`, fragments only GitHub acts on; every other renderer drew both images. A `<picture>` with a `prefers-color-scheme` source picks one variant on GitHub and npm alike
|
|
31
|
-
- A `fail` resting only on elements the engine could not walk to the root of is reported as `cantTell`. Ancestor walks stop after 200 steps so a malformed tree cannot hang a scan, but past that depth the walk cannot show an element is exposed
|
|
32
|
-
- `region` dominated the runtime of any page it fired on, taking roughly four minutes on a thousand unplaced elements and scaling cubically from there. It hand-built its occurrence objects, and an occurrence that arrives without its element makes the engine re-find one with `document.querySelector` to build `structuralPath
|
|
33
|
-
- `contrast-minimum`, `contrast-enhanced` and `contrast-computable` never examined text inside an open shadow root. Their text collection used a `TreeWalker`, which stops at a shadow boundary, and a `querySelectorAll` for value-bearing inputs, which
|
|
34
|
-
- `aria-roles-valid` and `aria-deprecated-role` missed a hidden shadow host. Their ancestor walk used `parentElement`, which stops at a shadow root, so a host carrying `aria-hidden` was never seen from inside its own shadow content
|
|
35
|
-
- `aria-roles-valid` and `aria-deprecated-role` now treat an `inert` subtree as programmatically hidden, alongside `display:none`, `visibility` and `aria-hidden`. The ACT glossary those two follow predates `inert` and names only the other three, but an inert subtree is out of the accessibility tree entirely, so a role on it reaches nobody.
|
|
121
|
+
- `docs/TROUBLESHOOTING.md` still described two shipped locales and warned that key parity had to be maintained by hand. There are four, and `i18n:sync` with its build check is what removed that risk.
|
|
122
|
+
- The README brand tag rendered twice on npm, once per theme. It relied on `#gh-dark-mode-only` and `#gh-light-mode-only`, fragments only GitHub acts on; every other renderer drew both images. A `<picture>` with a `prefers-color-scheme` source picks one variant on GitHub and npm alike, with absolute paths since the tarball ships `docs/**/*.md` but not the SVGs beside them.
|
|
123
|
+
- A `fail` resting only on elements the engine could not walk to the root of is reported as `cantTell`. Ancestor walks stop after 200 steps so a malformed tree cannot hang a scan, but past that depth the walk cannot show an element is exposed, and three rules were asserting violations on content nested inside `aria-hidden`, which is the one outcome this engine must not produce. An occurrence alongside one the walk did reach still keeps the `fail`, since a single confirmed element justifies it. Counted as `ancestorsIncludingSelf.truncated` in `perfStats`. This only reaches rules that report their element through `helpers.reportOccurrence`; the rest keep the old behaviour until they're migrated.
|
|
124
|
+
- `region` dominated the runtime of any page it fired on, taking roughly four minutes on a thousand unplaced elements and scaling cubically from there. It hand-built its occurrence objects, and an occurrence that arrives without its element makes the engine re-find one with `document.querySelector` to build `structuralPath`, a document-wide query per occurrence. It reports the element now: the same page takes under a second, with identical occurrences. A new `perfStats` counter, `structuralPath.selectorFallback`, makes the expensive path visible; `docs/RULE_AUTHORING.md` §4.3 documents it as a performance contract, and `tests/structural-path-fallback.test.js` ratchets the count of rules still building occurrences by hand so it can only shrink. Every rule now reports its element: `button-name-present` on 500 unnamed buttons went from 36.9s to 0.3s. Output is byte-identical across all 130 fixtures throughout.
|
|
125
|
+
- `contrast-minimum`, `contrast-enhanced` and `contrast-computable` never examined text inside an open shadow root. Their text collection used a `TreeWalker`, which stops at a shadow boundary, and a `querySelectorAll` for value-bearing inputs, which doesn't cross one either, so low-contrast text in a web component was reported as `notApplicable` rather than checked. Every open shadow root beneath a scan root is now walked in its own right, nested roots included. `includeShadowDom: false` still skips them, closed roots remain unreachable, and text is counted once whether it's slotted or not. Background resolution already crossed the boundary and is unchanged.
|
|
126
|
+
- `aria-roles-valid` and `aria-deprecated-role` missed a hidden shadow host. Their ancestor walk used `parentElement`, which stops at a shadow root, so a host carrying `aria-hidden` was never seen from inside its own shadow content. The walk now follows the composed tree, stepping over the shadow root to reach the host.
|
|
127
|
+
- `aria-roles-valid` and `aria-deprecated-role` now treat an `inert` subtree as programmatically hidden, alongside `display:none`, `visibility` and `aria-hidden`. The ACT glossary those two follow predates `inert` and names only the other three, but an inert subtree is out of the accessibility tree entirely, so a role on it reaches nobody. Rules that judge attribute syntax are unchanged, since that's a static-markup property regardless of what's visible today.
|
|
36
128
|
- The committed WCAG coverage report was stale, still describing `bypass-blocks-present` as an automatic rule under `src/checks/automatic/` after it became manual. The generator stamped a generation time into both files, so every run produced a diff and real drift had nowhere to show. The timestamp is gone (git records when the file changed), output is byte-stable across runs, and `npm run coverage:check` fails on drift the way the ARIA and language-subtag generators already do. CI runs it.
|
|
37
|
-
- A `runOnly` filter given as a comma-separated string (`includeRuleIds: 'img-alt-present'`) was silently dropped and every rule ran. The normalizer had always accepted a string; the check deciding whether `runOnly` carried any filters at all only recognised arrays, so the filter was parsed and then discarded. `ENGINE_OPTIONS.md` states these fields mirror their `engineOptions` counterparts, which have always taken a string.
|
|
38
|
-
- A partial `engineOptions.messages` entry replaced the built-in dictionary for that locale instead of layering over it, so overriding one German string silently returned every *other* string in English
|
|
39
|
-
- `engineOptions.locale` set to an inherited property name
|
|
129
|
+
- A `runOnly` filter given as a comma-separated string (`includeRuleIds: 'img-alt-present'`) was silently dropped and every rule ran. The normalizer had always accepted a string; the check deciding whether `runOnly` carried any filters at all only recognised arrays, so the filter was parsed and then discarded. `ENGINE_OPTIONS.md` states these fields mirror their `engineOptions` counterparts, which have always taken a string.
|
|
130
|
+
- A partial `engineOptions.messages` entry replaced the built-in dictionary for that locale instead of layering over it, so overriding one German string silently returned every *other* string in English, the opposite of what `docs/I18N.md` promised. A supplied dictionary now sits on top of the built-in one for the same locale, and completeness counts both layers.
|
|
131
|
+
- `engineOptions.locale` set to an inherited property name (`constructor`, `__proto__`, `toString`) was reported as the resolved locale, because the built-in table was tested for truthiness rather than for an own property holding a dictionary. Text stayed English; only `engine.locale` was wrong.
|
|
40
132
|
- A malformed `engineOptions.messages` entry (`{ de: null }`, a string, an array) could crash a scan rather than being ignored.
|
|
41
|
-
- `npm run validate:automatic-rules` and `validate:manual-rules` both failed, and had for some time, because nothing ran them. Three
|
|
42
|
-
- `docs/RULE_TEMPLATE.js` told rule authors to name i18n keys `checks.<ruleId>.occurrence.<case>.summary`. No key in `en.json` has ever used that shape
|
|
43
|
-
- `aria-deprecated-role`'s hint was English in every locale. Its two hint strings were `"{{guidance}}"
|
|
44
|
-
- `duplicate-id-aria` now reports `cantTell` instead of `fail`. A duplicated id doesn't break the reference
|
|
45
|
-
- `duplicate-id-aria` reported duplicates that sat outside the scanned scope, so a run using `contextSelector` or `excludeSelectors` flagged elements it was never asked about
|
|
133
|
+
- `npm run validate:automatic-rules` and `validate:manual-rules` both failed, and had for some time, because nothing ran them. Three bugs: the module contract asserted rules export *exactly* `id`, `meta`, `runInPage`, but 14 legitimately export `applicability` too; the free-variable scan stripped single-quoted strings before double-quoted ones, so an apostrophe inside a double-quoted hint opened a phantom string and exposed the code after it; and regex literals weren't stripped at all, so `replace(/"/g, ...)` did the same thing with its quote. All three are fixed and both validators now run in CI as `npm run validate:rules`. `CONTRIBUTING.md` described the wrong contract and has been corrected.
|
|
134
|
+
- `docs/RULE_TEMPLATE.js` told rule authors to name i18n keys `checks.<ruleId>.occurrence.<case>.summary`. No key in `en.json` has ever used that shape: 124 of the 125 rules use `<ruleName>_summary_<outcome>`. The template now states the real convention.
|
|
135
|
+
- `aria-deprecated-role`'s hint was English in every locale. Its two hint strings were `"{{guidance}}"`, a bare placeholder, and the text behind it was a hardcoded English literal passed in as a parameter, so a German, Spanish or French scan returned that language's output with one English sentence in it. The guidance now lives in the dictionaries and is translated in all four locales. `tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value that carries no translatable text.
|
|
136
|
+
- `duplicate-id-aria` now reports `cantTell` instead of `fail`. A duplicated id doesn't break the reference: it resolves to the first element in tree order, so the name is still computed. Whether that's the element the author meant isn't in the markup. WCAG 4.1.1, which covered duplicate ids outright, was removed in 2.2; what's left is 4.1.2, and that needs the name to be demonstrably wrong.
|
|
137
|
+
- `duplicate-id-aria` reported duplicates that sat outside the scanned scope, so a run using `contextSelector` or `excludeSelectors` flagged elements it was never asked about. Detection stays document-wide, since id uniqueness is a document property, but occurrences are now limited to the scanned scope.
|
|
46
138
|
|
|
47
139
|
## [1.4.1] - 2026-08-13
|
|
48
140
|
|
|
49
141
|
### Added
|
|
50
142
|
- `scripts/generate-aria-tables.js` generates `aria-allowed-attr`'s global/per-role/implicit-role tables from the `aria-query` package (a devDependency, output committed) instead of hand-maintaining them, with a `--check` mode that fails when the committed tables go stale.
|
|
51
|
-
- `scripts/generate-language-subtags.js` writes the IANA primary language subtags into `dom-helpers` from the `language-subtag-registry` package
|
|
143
|
+
- `scripts/generate-language-subtags.js` writes the IANA primary language subtags into `dom-helpers` from the `language-subtag-registry` package, same `--check` convention. New shared `helpers.isValidLanguageTag` backs both `valid-lang` and `html-lang-attr-present`.
|
|
52
144
|
|
|
53
145
|
### Changed
|
|
54
|
-
- `form-control-single-label` now grades by whether surplus `<label>`s actually compete for the accessible name, instead of always failing on label count: passes when an `aria-labelledby`/`aria-label` override supersedes the native labels
|
|
55
|
-
- `bypass-blocks-present` is now a manual rule (`cantTell`-capped) instead of automatic (`fail`-capable). WCAG 2.4.1 is about blocks "repeated on multiple Web pages"
|
|
56
|
-
- The concrete and abstract ARIA role sets in `aria-helpers` are now generated from `aria-query` too, alongside the attribute tables, so Digital Publishing roles (`doc-biblioref`, etc.) are recognised instead of reported as unknown.
|
|
146
|
+
- `form-control-single-label` now grades by whether surplus `<label>`s actually compete for the accessible name, instead of always failing on label count: passes when an `aria-labelledby`/`aria-label` override supersedes the native labels; `cantTell` when one non-empty label is joined by empty label associations with no override; fails only when two or more non-empty labels genuinely compete. Uses the same shared `labelContributesAccessibleName` helper as `form-control-programmatic-label-present`, so the two agree. Clears a false positive on the Angular Material selectable-card pattern, where a card adds an empty `<label for>` on top of Material's own label while the control is named by `aria-label`.
|
|
147
|
+
- `bypass-blocks-present` is now a manual rule (`cantTell`-capped) instead of automatic (`fail`-capable). WCAG 2.4.1 is about blocks "repeated on multiple Web pages", and whether a block is actually repeated across the site isn't decidable from one document. A single-snapshot scan can also catch a page mid-modal, when the real `<main>`/headings are correctly `aria-hidden`/`inert` for that state and only the dialog is exposed. Finding a recognized mechanism (a main landmark, a working same-page anchor, or a heading) still resolves to `notApplicable`; finding none now returns `cantTell` instead of asserting a violation the engine can't confirm.
|
|
148
|
+
- The concrete and abstract ARIA role sets in `aria-helpers` are now generated from `aria-query` too, alongside the attribute tables, so Digital Publishing roles (`doc-biblioref`, etc.) are recognised instead of reported as unknown.
|
|
57
149
|
- `aria-roles-valid` and `aria-deprecated-role` now skip programmatically hidden elements, where a role has no effect (ACT 674b10).
|
|
58
150
|
- `autocomplete-valid` now follows ACT 73f2c2's applicability: the `on`/`off` toggle, disabled controls (including `aria-disabled`), and input types with a fixed value (`submit`, `checkbox`, ...) are out of scope, since `autocomplete` can't describe a purpose for any of them.
|
|
59
|
-
- `meta-viewport-zoom-enabled` now applies only when `content` sets `maximum-scale` or `user-scalable
|
|
151
|
+
- `meta-viewport-zoom-enabled` now applies only when `content` sets `maximum-scale` or `user-scalable`, since setting neither can't restrict zoom.
|
|
60
152
|
- `aria-allowed-attr` now covers all 127 concrete ARIA roles instead of 35 hand-listed ones, so `button`, `link`, `img` and 27 other common roles are no longer skipped.
|
|
61
153
|
- Form-control naming is now split by native element vs. explicit ARIA role, so a control isn't reported by two rules at once: `binary-control-name-present`/`slider-name-present` are role-only, and `form-control-programmatic-label-present` skips a control whose explicit role has its own naming rule. An unlabelled checkbox used to produce two findings; now one.
|
|
62
154
|
|
|
63
155
|
### Fixed
|
|
64
|
-
- `contrast-minimum`/`contrast-enhanced` no longer report text hidden with the sr-only clip technique (`clip: rect(0,0,0,0)`/`clip-path: inset(50%+)
|
|
65
|
-
- `aria-allowed-attr` failed the four ARIA 1.2 ex-globals (`aria-disabled`, `aria-errormessage`, `aria-haspopup`, `aria-invalid`) on any role that doesn't support them, but they're deprecated on that role, not prohibited
|
|
66
|
-
- `aria-deprecated-role` failed `role="generic"`, but WAI-ARIA 1.2 §5.4 states that rule at SHOULD-NOT strength ("primarily for implementors of user agents")
|
|
67
|
-
- `nested-interactive-controls-absent` matched on role membership alone, so it failed a `role="listbox"` owning `role="option"` children, a `tablist` owning `tab`s, and every other composite widget whose managed children legitimately carry a widget role
|
|
68
|
-
-
|
|
69
|
-
- `avoid-inline-spacing` failed every inline `line-height`/`letter-spacing`/`word-spacing` declared `!important
|
|
70
|
-
- `table-th-has-data-cells` failed every `<th>` in any table with zero `<td>`, so a `role="presentation"` layout table carrying a stray `<th>`, a
|
|
71
|
-
- `label-in-name` compared label and name by character containment (`indexOf` on lowercased strings), which is wrong in both directions per WCAG 2.5.3's
|
|
72
|
-
- `img-alt-present`, `object-text-alternative-present` and `canvas-text-alternative-present` used `isAccTreeEligible`, which keeps a tabbable element eligible under `aria-hidden`, so they reported elements no assistive technology can reach. All three now use `isIncludedInAccessibilityTree`, matching the `*-name-present` rules
|
|
73
|
-
- `aria-roles-valid` judged only the first token of the `role` attribute, so `role="searchfield searchbox"` was reported invalid
|
|
74
|
-
- `autocomplete-valid` accepted a contact modality token before a non-contact field, so `"work photo"` passed even though a contact token is only valid when a contact field follows it
|
|
75
|
-
- `valid-lang` and `html-lang-attr-present` checked shape only (`/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/`), so `lang="eng"` and `lang="em-US"` passed although neither is a registered primary subtag
|
|
76
|
-
- `meta-viewport-zoom-enabled` passed values it couldn't parse (`user-scalable=0.5`, `maximum-scale=invalid`, `maximum-scale=yes`), even though CSS Device Adaptation treats an unparseable value as `0
|
|
77
|
-
- `meta-refresh-timing-absent` and `meta-refresh-no-exceptions` reported directives a browser never acts on (`"foo; URL=x"`, `"+72001"`, `"0:1"`), since a malformed value
|
|
78
|
-
- `aria-allowed-attr` only looked at `[role]`, so an ARIA state/property on an element with no explicit role went unjudged (e.g. `<button aria-sort>`, `<p aria-checked>`) even though ACT 5c01ea applies to any element in the accessibility tree. The generator now also emits an implicit-role table, gated by an explicit allowlist of elements whose role is unconditional
|
|
79
|
-
- `aria-allowed-attr` treated `aria-disabled`, `aria-haspopup`, `aria-invalid` and `aria-errormessage` as global attributes, so misuse on a role that doesn't support them went unreported; the generated table has the real 17 globals. It also no longer judges attributes against `role="none"`/`"presentation"`, since
|
|
80
|
-
- `input-image-alt-present` treated `alt=""` as marking an image button decorative and passed it, but an image button is a control, not decoration
|
|
81
|
-
- `input-image-alt-present` now scopes to elements included in the accessibility tree (was the looser `isAccTreeEligible`), and rejects an accessible name equal to the browser's own fallback for an image button (`"Submit Query"`/`"Submit"`) as no name at all, per ACT 59796f.
|
|
82
|
-
- `form-control-programmatic-label-present` used the looser `isAccTreeEligible` check,
|
|
83
|
-
- The 18 `*-name-present` rules judged an element whether or not it was actually reachable by assistive technology, so a focusable element inside `aria-hidden`
|
|
84
|
-
- `link-name-present`/`button-name-present` credited an accessible name from content regardless of an explicit `role`, so `<a href role="alert">Text</a>` passed even though
|
|
85
|
-
- `landmark-banner-is-top-level` and `landmark-contentinfo-is-top-level` selected any roleless `<header>`/`<footer>` as a candidate, regardless of nesting
|
|
86
|
-
- `getContentNameInfo` let a descendant's `title` outrank its own text when computing a name from subtree content, so `<a title="T">Text</a>` could name itself `"T"` instead of `"Text"`. Content now wins over `title` unless the descendant's content is empty, matching
|
|
156
|
+
- `contrast-minimum`/`contrast-enhanced` no longer report text hidden with the sr-only clip technique (`clip: rect(0,0,0,0)`/`clip-path: inset(50%+)`), since there's no visually-presented color to check. `opacity: 0` and off-screen positioning stay in scope, since either can be a single-property mistake on text meant to be visible, unlike the multi-property clip pattern.
|
|
157
|
+
- `aria-allowed-attr` failed the four ARIA 1.2 ex-globals (`aria-disabled`, `aria-errormessage`, `aria-haspopup`, `aria-invalid`) on any role that doesn't support them, but they're deprecated on that role, not prohibited, still allowed just discouraged. Now reports `cantTell` (reason `ARIA_ATTR_DEPRECATED`) for those four instead of `fail`; a genuinely unsupported attribute still fails.
|
|
158
|
+
- `aria-deprecated-role` failed `role="generic"`, but WAI-ARIA 1.2 §5.4 states that rule at SHOULD-NOT strength ("primarily for implementors of user agents"), so the usage is conforming. Now reports `cantTell` under reason code `ARIA_ROLE_AUTHOR_DISCOURAGED`. `fail` is retained for a role carrying an author MUST NOT; no ARIA 1.2/1.3 role does, outside the abstract roles `aria-roles-valid` already covers. See `docs/ARIA_DEPRECATION.md`.
|
|
159
|
+
- `nested-interactive-controls-absent` matched on role membership alone, so it failed a `role="listbox"` owning `role="option"` children, a `tablist` owning `tab`s, and every other composite widget whose managed children legitimately carry a widget role, even when driven entirely via `aria-activedescendant` with nothing separately focusable inside. A descendant now counts only when it's also focusable (`helpers.getFocusableInfo`, accounting for `contenteditable`, `:disabled`, `inert`, invalid/negative `tabindex`).
|
|
160
|
+
- The same rule's focusability gate still over-flagged the roving-tabindex pattern, where the active owned child genuinely carries `tabindex="0"` despite being a managed part of its container. Added an explicit owned-child map (`option`→`listbox`/`combobox`, `tab`→`tablist`, `treeitem`→`tree`, `menuitem(checkbox|radio)`→`menu`/`menubar`, `radio`→`radiogroup`); a child matching both the role and a matching ancestor container is exempt regardless of its own tabindex.
|
|
161
|
+
- `avoid-inline-spacing` failed every inline `line-height`/`letter-spacing`/`word-spacing` declared `!important` regardless of value, so `line-height: 2em !important` on 16px text was reported even though it already exceeds what WCAG 1.4.12 asks for. Now fails only below 1.5× font size for line-height, 0.12× for letter-spacing, 0.16× for word-spacing.
|
|
162
|
+
- `table-th-has-data-cells` failed every `<th>` in any table with zero `<td>`, so a `role="presentation"` layout table carrying a stray `<th>`, or a `<th>` that was `display:none`/`aria-hidden`, or a `<th role="cell">`, were all reported. Per ACT d0f69e a header cell now counts only while visible, in the accessibility tree, and not overridden to a role other than `rowheader`/`columnheader`.
|
|
163
|
+
- `label-in-name` compared label and name by character containment (`indexOf` on lowercased strings), which is wrong in both directions per WCAG 2.5.3's word-based algorithm: it accepted `aria-label="Discover Italy"` for the visible text "Discover It", and rejected `aria-label="Search by date (YYYY-MM-DD)"` against "Search by date". Now compares word lists (parenthesised text stripped, case-folded, NFKD-normalised, non-alphanumerics collapsed to spaces) and checks the label's words appear adjacent and in order in the name. An abbreviation or a differently-hyphenated word now reports `cantTell` instead of failing.
|
|
164
|
+
- `img-alt-present`, `object-text-alternative-present` and `canvas-text-alternative-present` used `isAccTreeEligible`, which keeps a tabbable element eligible under `aria-hidden`, so they reported elements no assistive technology can reach. All three now use `isIncludedInAccessibilityTree`, matching the `*-name-present` rules.
|
|
165
|
+
- `aria-roles-valid` judged only the first token of the `role` attribute, so `role="searchfield searchbox"` was reported invalid even though `role` takes a fallback list and a browser resolves it to the first token it recognises. The rule now fails only when no token names a concrete role.
|
|
166
|
+
- `autocomplete-valid` accepted a contact modality token before a non-contact field, so `"work photo"` passed even though a contact token is only valid when a contact field follows it.
|
|
167
|
+
- `valid-lang` and `html-lang-attr-present` checked shape only (`/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/`), so `lang="eng"` and `lang="em-US"` passed although neither is a registered primary subtag. Both now validate against the real IANA registry. `valid-lang` also now applies only where text actually inherits the language from the element, and a whitespace-only value fails.
|
|
168
|
+
- `meta-viewport-zoom-enabled` passed values it couldn't parse (`user-scalable=0.5`, `maximum-scale=invalid`, `maximum-scale=yes`), even though CSS Device Adaptation treats an unparseable value as `0`, which disables zoom exactly like an explicit `0` does.
|
|
169
|
+
- `meta-refresh-timing-absent` and `meta-refresh-no-exceptions` reported directives a browser never acts on (`"foo; URL=x"`, `"+72001"`, `"0:1"`), since a malformed value invalidates the whole directive rather than falling back to a default delay. Both now parse `content` with the shared declarative-refresh steps.
|
|
170
|
+
- `aria-allowed-attr` only looked at `[role]`, so an ARIA state/property on an element with no explicit role went unjudged (e.g. `<button aria-sort>`, `<p aria-checked>`) even though ACT 5c01ea applies to any element in the accessibility tree. The generator now also emits an implicit-role table, gated by an explicit allowlist of elements whose role is unconditional.
|
|
171
|
+
- `aria-allowed-attr` treated `aria-disabled`, `aria-haspopup`, `aria-invalid` and `aria-errormessage` as global attributes, so misuse on a role that doesn't support them went unreported; the generated table has the real 17 globals. It also no longer judges attributes against `role="none"`/`"presentation"`, since `presentation-role-conflict` already owns that case.
|
|
172
|
+
- `input-image-alt-present` treated `alt=""` as marking an image button decorative and passed it, but an image button is a control, not decoration: ACT 59796f requires a non-empty name, so an empty `alt` (or no `alt` at all) now fails. `alt=""` combined with `aria-label`/`aria-labelledby`/`title` still passes.
|
|
173
|
+
- `input-image-alt-present` now scopes to elements included in the accessibility tree (was the looser `isAccTreeEligible`), and rejects an accessible name equal to the browser's own fallback for an image button (`"Submit Query"`/`"Submit"`) as no name at all, per ACT 59796f.
|
|
174
|
+
- `form-control-programmatic-label-present` used the looser `isAccTreeEligible` check, so it reported controls no assistive technology can reach. Switched to `isIncludedInAccessibilityTree`, matching the `*-name-present` rules.
|
|
175
|
+
- The 18 `*-name-present` rules judged an element whether or not it was actually reachable by assistive technology, so a focusable element inside `aria-hidden` got a real pass/fail verdict even though such elements aren't in the accessibility tree at all. New shared `helpers.isIncludedInAccessibilityTree` routes all 18 rules to `notApplicable` there instead; the focus-order defect itself is still reported by `aria-hidden-focus`.
|
|
176
|
+
- `link-name-present`/`button-name-present` credited an accessible name from content regardless of an explicit `role`, so `<a href role="alert">Text</a>` passed even though a real accessibility tree computes an empty name for it. Naming from content is now gated by the ARIA 1.2 §5.2.8.5 allowlist of roles that actually support it, generated from each role's `nameFrom` so a role that inherits the behavior (like `doc-noteref` from `link`) is covered too.
|
|
177
|
+
- `landmark-banner-is-top-level` and `landmark-contentinfo-is-top-level` selected any roleless `<header>`/`<footer>` as a candidate, regardless of nesting, but per HTML-AAM those elements have no banner/contentinfo role at all once descended from `article`/`aside`/`main`/`nav`/`section`. Both now select through the same suppression-aware role lookup the rest of the file already used.
|
|
178
|
+
- `getContentNameInfo` let a descendant's `title` outrank its own text when computing a name from subtree content, so `<a title="T">Text</a>` could name itself `"T"` instead of `"Text"`. Content now wins over `title` unless the descendant's content is empty, matching the accname spec.
|
|
87
179
|
|
|
88
180
|
## [1.4.0] - 2026-08-08
|
|
89
181
|
|
|
90
182
|
### Added
|
|
91
|
-
- `tests/i18n/i18n-locale-completeness.test.js`: an automated check for the key-parity drift `docs/I18N.md` warned about but never enforced
|
|
92
|
-
- `src/i18n/de.js` and `src/i18n/es.js`: two new fully-translated (614/614 keys) locales, German and Spanish, joining `en`/`fr`.
|
|
93
|
-
- `npm run i18n:new <locale>` (`scripts/i18n-scaffold.js`) and `npm run i18n:report` (`scripts/i18n-report.js`): tooling
|
|
94
|
-
- 107 new direct unit tests targeting previously-uncovered branches in
|
|
183
|
+
- `tests/i18n/i18n-locale-completeness.test.js`: an automated check for the key-parity drift `docs/I18N.md` warned about but never enforced. It fails the build for any locale file with an orphaned key not present in `en.js`, and separately fails if a locale listed in `FULLY_TRANSLATED_LOCALES` (currently just `fr`) is missing any `en.js` key.
|
|
184
|
+
- `src/i18n/de.js` and `src/i18n/es.js`: two new fully-translated (614/614 keys) locales, German and Spanish, joining `en`/`fr`. Documented in `docs/I18N.md`'s coverage table and mentioned in `README.md`'s "Localized reporting" principle.
|
|
185
|
+
- `npm run i18n:new <locale>` (`scripts/i18n-scaffold.js`) and `npm run i18n:report` (`scripts/i18n-report.js`): tooling for community translation contributions. `i18n:new` scaffolds `src/i18n/<locale>.js` pre-populated with every `en.js` key, seeded with the English text as a placeholder. `i18n:report` prints per-locale progress. `docs/I18N.md`'s "Contributing a translation" section and `CONTRIBUTING.md` now point at this workflow instead of manual file creation.
|
|
186
|
+
- 107 new direct unit tests targeting previously-uncovered branches in `src/core/dom-helpers.js` and `src/core/contrast-helpers.js`. Coverage: `dom-helpers.js` 86.34%/67.91%/92.11% → 92.68%/74.26%/96.49% (lines/branches/functions); `contrast-helpers.js` 94.70%/71.02%/100% → 100%/84.76%/100%. No production-code changes resulted; every previously-untested branch was confirmed correct against spec, sibling-function behavior, or existing fixtures.
|
|
95
187
|
|
|
96
188
|
### Fixed
|
|
97
|
-
- `en.js`'s `mediaTranscriptPresent_summary_cantTell_missing` key contained French text
|
|
98
|
-
- `buildSimpleSelector` (
|
|
99
|
-
- `computeIdRefTargetTextAlternative` (
|
|
100
|
-
- `embed-text-alternative-quality-manual` treated a merely
|
|
101
|
-
- `heading-order-manual`, `landmark-banner-is-top-level-manual`, `landmark-contentinfo-is-top-level-manual`, and `landmark-main-is-top-level-manual` never filtered
|
|
102
|
-
- `image-redundant-alt-manual` collected a candidate `<img>`'s sibling text unconditionally
|
|
103
|
-
- `label-title-only-manual` checked only whether a `<label for
|
|
104
|
-
- `empty-table-header-manual` computed a header cell's
|
|
105
|
-
- `table-fake-caption-manual` treated an `aria-hidden` `<tr>` as the table's positional
|
|
106
|
-
- `td-has-header`
|
|
107
|
-
- `nested-interactive-controls-absent`'s nested-descendant search used
|
|
108
|
-
- `iframe-focusable-content`'s `hasFocusableCandidate` never checked whether a candidate inside a `tabindex="-1"` frame's embedded document was actually rendered
|
|
109
|
-
- `aria-helpers.js`'s `hasAccessibleNameHint` (decides whether a `<section>` resolves to the `'section[named]'` role key,
|
|
110
|
-
- `contrast-helpers.js`'s `getComputabilityBlocker` treated `backdrop-filter` the same as
|
|
111
|
-
- `aria-helpers.js`'s `validateAttrValue` treated an explicitly-
|
|
112
|
-
- `svg-text-alternative-present`'s applicability gate only recognized `role="img"` as an
|
|
113
|
-
- `aria-prohibited-attr`'s
|
|
114
|
-
- `isAccTreeEligible`
|
|
115
|
-
- `presentation-role-conflict-manual`'s conflicting-attribute check treated `aria-hidden="true"`
|
|
116
|
-
- `listitem-parent-valid`'s applicability check inspected the `<li>`'s parent for an explicit role override but never the `<li>`
|
|
117
|
-
- `aria-helpers.js`'s `ALLOWED_ROLES_BY_ELEMENT.button` list was missing `gridcell`, `separator`, `slider`, and `treeitem
|
|
118
|
-
- `focus-order-semantics-manual`'s `NON_INTERACTIVE_ROLES` set included `region`, flagging a tabbable `role="region"`
|
|
119
|
-
- `aria-helpers.js`'s `CONCRETE_ROLES` registry (backing `isValidConcreteRole`, which gates `aria-roles-valid` and 7 other rules) was scoped to core WAI-ARIA 1.2
|
|
189
|
+
- `en.js`'s `mediaTranscriptPresent_summary_cantTell_missing` key contained French text instead of English, so even a default English scan showed French for this one `cantTell` string. Replaced with the correct English string, matching the rule's own hardcoded fallback text and the placeholder convention used elsewhere in the file. `fr.js` was unaffected; `de.js`/`es.js` were generated correctly from the start.
|
|
190
|
+
- `buildSimpleSelector` (the bare-tag/attribute-anchor fallback `buildSelector` degrades to once every other anchoring strategy fails) had the same raw-vs-trimmed bug as `buildSelectorUncached`'s anchor builders (see the 1.3.0 `buildSelector` fix below), embedding the *trimmed* id/data-testid/name attribute value into the selector while only using the trimmed value to check truthiness, so a padded attribute produced a selector that could never resolve back to its element. Fixed by embedding the raw, untrimmed value in all three branches.
|
|
191
|
+
- `computeIdRefTargetTextAlternative` (resolves what an `aria-labelledby`/`aria-describedby` target itself contributes) had two bugs: it checked the target's own `aria-label` before its own `aria-labelledby`, backwards from the accname spec's ordering, so a target carrying both resolved to the stale one; and it never consulted a native `<label>` association at all, so a target that is itself a labeled form control resolved to empty text. Fixed by reordering the checks and adding the same native-label lookup `getAccessibleNameInfo` uses. A follow-up fix closed a cycle-detection gap this surfaced: a label whose content self-referenced back to the control it labels could round-trip and produce doubled text; an `opts.__idrefVisited` guard now threads through the resolution chain to prevent it.
|
|
192
|
+
- `embed-text-alternative-quality-manual` treated a merely-present (even broken/empty-resolving) `aria-labelledby` as a mechanism worth reviewing, producing a confusing `cantTell` for an `<embed>` that in fact has no text alternative at all. Its sibling rules (`object-`/`svg-`/`canvas-text-alternative-quality-manual`) already required the reference to resolve to non-empty text; `embed` alone disagreed. Now matches.
|
|
193
|
+
- `heading-order-manual`, `landmark-banner-is-top-level-manual`, `landmark-contentinfo-is-top-level-manual`, and `landmark-main-is-top-level-manual` never filtered candidates through `helpers.isAccTreeEligible`, so an `aria-hidden` heading or landmark (visually rendered but removed from the AT-perceived structure) was wrongly flagged, and for `heading-order` could also mask a real level skip immediately after it. All four now filter through `isAccTreeEligible`.
|
|
194
|
+
- `image-redundant-alt-manual` collected a candidate `<img>`'s sibling text unconditionally, including `aria-hidden` sibling text that assistive technology never announces, so it couldn't actually cause the double-announcement this rule exists to catch. Now skips AT-ineligible siblings when building the comparison.
|
|
195
|
+
- `label-title-only-manual` checked only whether a `<label for>`/wrapping `<label>` structurally existed, never whether it contributed a name, so an empty label exempted the control even though `title` was functionally its only real label. Rewritten to delegate to `helpers.getAccessibleNameInfo` and to also filter candidates through `isAccTreeEligible`.
|
|
196
|
+
- `empty-table-header-manual` computed a header cell's visible text via plain `el.textContent`, which includes `aria-hidden` descendant text a screen reader never announces. The text walk now skips AT-ineligible descendants, and a fully `aria-hidden` header cell is excluded as a candidate.
|
|
197
|
+
- `table-fake-caption-manual` treated an `aria-hidden` `<tr>` as the table's positional first row and counted `aria-hidden` cells toward a row's cell count. Rows and cells are now filtered through `isAccTreeEligible` first.
|
|
198
|
+
- `td-has-header` credited an `aria-hidden` `<th>` as a valid implicit header for other cells, so a `<td>` relying solely on one was wrongly reported `pass` when it has no accessible header at all (a false negative on a `serious` WCAG 1.3.1 check). An `aria-hidden` `<th>` no longer counts as a header; an `aria-hidden` `<td>` is no longer flagged either.
|
|
199
|
+
- `nested-interactive-controls-absent`'s nested-descendant search used raw `querySelectorAll` with no hidden-content filtering at all, so a `display:none` or non-focusable-`aria-hidden` nested control was wrongly failed. Both the outer candidate and the nested search now filter through `isAccTreeEligible`; a control that's `aria-hidden` but still tabbable correctly remains flagged, since that's the separate anti-pattern `aria-hidden-focus` targets.
|
|
200
|
+
- `iframe-focusable-content`'s `hasFocusableCandidate` never checked whether a candidate inside a `tabindex="-1"` frame's embedded document was actually rendered, so a `display:none`/`visibility:hidden`/`[hidden]` element was wrongly reported reachable. Added a self-contained rendering check scoped to genuine non-rendering, not `aria-hidden` (which alone doesn't remove native tab-order reachability).
|
|
201
|
+
- `aria-helpers.js`'s `hasAccessibleNameHint` (decides whether a `<section>` resolves to the `'section[named]'` role key, the only one that permits `role="region"`) only checked `aria-label`/`aria-labelledby`, not `title`, inconsistent with this engine's own `getLandmarkNameInfo` precedence. A `<section title="...">` was wrongly failed by `aria-allowed-role` for an explicit `role="region"` restatement. Now matches `getLandmarkNameInfo`'s precedence.
|
|
202
|
+
- `contrast-helpers.js`'s `getComputabilityBlocker` treated `backdrop-filter` the same as `filter`/`mix-blend-mode`/ancestor `opacity`, none occludable by a closer opaque ancestor background. That reasoning doesn't apply to `backdrop-filter`, which samples whatever's already rendered behind the element: a closer opaque `background-color` paints over that filtered result and hides it completely, the same physical occlusion `background-image` gets. `backdrop-filter` now participates in the same `paintOccluded` short-circuit as `background-image`/gradient; plain `filter`/`mix-blend-mode`/`opacity` remain unconditional blockers.
|
|
203
|
+
- `aria-helpers.js`'s `validateAttrValue` treated an explicitly-empty idref/idref-list ARIA attribute value (e.g. `aria-describedby=""`) as invalid. An empty value is a deliberate "no reference", not a broken one, and both the `idref` and `idref-list` cases now treat it as valid.
|
|
204
|
+
- `svg-text-alternative-present`'s applicability gate only recognized `role="img"` as an intent-to-convey signal for an `<svg>` root, missing `role="graphics-symbol"` and `role="graphics-document"`. Both roles are now recognized, still scoped to the `<svg>` root element only.
|
|
205
|
+
- `aria-prohibited-attr`'s roleless-element branch only recognized a small allowlist of native HTML tags as having no implicit role, never autonomous custom elements, which per the Custom Elements spec always have no implicit ARIA role. Fixed by adding a second applicability path: any tag containing a hyphen, except the small set of legacy hyphenated SVG/MathML tag names that predate Custom Elements (`annotation-xml`, `color-profile`, `font-face` and its variants, `missing-glyph`).
|
|
206
|
+
- `isAccTreeEligible` treated `hidden="until-found"` identically to a plain `hidden` attribute, excluding the element itself from every rule's candidate list. Per the HTML spec these differ: the UA stylesheet applies `content-visibility: hidden` for `until-found` (hides descendants, not the element carrying it) vs. `display: none` for any other `hidden` value. Fixed with a self-only override: the element carrying `hidden="until-found"` is no longer excluded when it's the one being checked; a real descendant, or any element with a plain `hidden` attribute, is unaffected.
|
|
207
|
+
- `presentation-role-conflict-manual`'s conflicting-attribute check treated `aria-hidden="true"` the same as any other global ARIA attribute, flagging it as restoring the implicit role, but `aria-hidden="true"` unconditionally removes the element (and any other attribute alongside it) from the accessibility tree regardless of role, so no assistive technology ever sees what this check warned about. An element's own `aria-hidden="true"` now clears its conflicting-attribute list entirely; focusability is unaffected and still flags on its own. `aria-hidden=""` (empty/invalid, doesn't hide) is unaffected and still triggers normally.
|
|
208
|
+
- `listitem-parent-valid`'s applicability check inspected the `<li>`'s parent for an explicit role override but never the `<li>` itself, wrongly flagging `<li role="tab">`/`role="menuitem">`/`role="presentation">` etc. inside an invalid parent, even though an explicit role fully overrides the `<li>`'s native listitem role. An `<li>` whose own explicit role isn't empty or `"listitem"` is no longer evaluated at all.
|
|
209
|
+
- `aria-helpers.js`'s `ALLOWED_ROLES_BY_ELEMENT.button` list was missing `gridcell`, `separator`, `slider`, and `treeitem`, confirmed against the W3C "ARIA in HTML" normative table. Fixed by adding the four missing roles.
|
|
210
|
+
- `focus-order-semantics-manual`'s `NON_INTERACTIVE_ROLES` set included `region`, flagging a tabbable `role="region"` as a meaningless tab stop, though that's a real, common, WCAG 2.1.1/2.1.3-grounded pattern. This engine's own sibling check, `scrollable-region-focusable`, already documents that basis for exactly this pattern, so flagging it here was internally inconsistent. Fixed by removing only `region` from the set; `navigation`/`status`/`tabpanel` remain flagged pending confirmed over-flagging evidence.
|
|
211
|
+
- `aria-helpers.js`'s `CONCRETE_ROLES` registry (backing `isValidConcreteRole`, which gates `aria-roles-valid` and 7 other rules) was scoped to core WAI-ARIA 1.2 tokens only, missing the three WAI-ARIA Graphics Module 1.0 roles (`graphics-document`/`graphics-object`/`graphics-symbol`), a separate W3C Recommendation with its own Accessibility API Mappings REC defining real AT support. `aria-roles-valid` wrongly reported these as invalid. All 8 rules gating on `isValidConcreteRole` key their per-role tables by explicit role name and default to skipping a role absent from the table, so recognizing these 3 as concrete introduces no new false positive in any of them. Digital Publishing WAI-ARIA roles were deliberately left for a future round pending confirmed real-world usage.
|
|
120
212
|
|
|
121
213
|
### Changed
|
|
122
|
-
- **The CLI now ships as a separate package, [`@surea11y/cli`](https://github.com/SureA11y/cli), and `@surea11y/core` has zero runtime dependencies.** `jsdom` was previously a real `dependencies` entry
|
|
123
|
-
- `package.json` now declares an explicit `exports` map: `.`, `./baseline`, `./report`, `./sarif`, `./browser`, `./package.json`. Previously there was no map at all, so
|
|
124
|
-
- `package.json` gained a `keywords` field (18 entries)
|
|
125
|
-
- `files` allowlist tightened from directory-level (`src`) to an explicit per-entry list, dropping ~721 KB of build
|
|
126
|
-
|
|
127
|
-
- A small `bin/surea11y-core.js` stub replaces the removed CLI entry point, so the pre-1.4.0 `npx @surea11y/core scan ...` still shown in older documentation prints an actionable redirect (`npx @surea11y/cli scan <file-or-url>`, exit 2) instead of npm's opaque `could not determine executable to run`. Deliberately **not** named `surea11y`: that binary belongs to `@surea11y/cli`, and since core is a transitive dependency of all seven bindings, the two would land in the same `node_modules/.bin` and collide — npm resolves such a conflict silently, with no warning, so a same-named stub could shadow a working CLI install. `npx` runs a package's single binary regardless of its name, which is what makes the redirect work without claiming the name. It writes only to stderr and adds no dependency.
|
|
214
|
+
- **The CLI now ships as a separate package, [`@surea11y/cli`](https://github.com/SureA11y/cli), and `@surea11y/core` has zero runtime dependencies.** `jsdom` was previously a real `dependencies` entry, pulling 39 transitive packages / ~25 MB into every install, including all six first-party consumers, none of which ever load it since they drive real browsers. The engine reads a DOM it's handed and never constructs one; only the CLI needed to parse HTML into a DOM. Splitting it puts that cost solely on people who install the CLI. **Breaking for CLI users**: `npx @surea11y/core scan ...` no longer exists; use `npx @surea11y/cli scan ...`. **Breaking for the documented Node+jsdom library workflow**: jsdom used to be available implicitly via npm hoisting; it now needs an explicit `npm install jsdom`. No rule logic, rule ID, outcome, or result shape changed. Shipped as a minor rather than a major since the package had no external consumers at this version and all six first-party ones were verified unaffected.
|
|
215
|
+
- `package.json` now declares an explicit `exports` map: `.`, `./baseline`, `./report`, `./sarif`, `./browser`, `./package.json`. Previously there was no map at all, so every internal file was reachable by deep `require()` and therefore implicitly public. The two documented deep imports move from `@surea11y/core/src/baseline`/`src/report` to `@surea11y/core/baseline`/`report`; `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*` and the generated `src/core.js` are now sealed. Documented in `docs/API_STABILITY.md`'s "Package entry points" section.
|
|
216
|
+
- `package.json` gained a `keywords` field (18 entries); the package previously had none and was effectively unfindable via npm search. `description` rewritten from a generic line to lead with the `cantTell` differentiator and the zero-dependency property.
|
|
217
|
+
- `files` allowlist tightened from directory-level (`src`) to an explicit per-entry list, dropping ~721 KB of build inputs that `scripts/build-core.js` already inlines into the generated `src/core.js` and that nothing requires at runtime. `src/checks/**` is kept, since the generated bundle `require()`s all 125 rule files at runtime. Published package: 175 → 152 files, 7.04 → 6.31 MB unpacked, 1.40 → 1.23 MB packed.
|
|
218
|
+
- A small `bin/surea11y-core.js` stub replaces the removed CLI entry point, so the pre-1.4.0 `npx @surea11y/core scan ...` shown in older documentation prints an actionable redirect instead of npm's opaque error. Not named `surea11y`, since that binary belongs to `@surea11y/cli` and the two would otherwise collide in `node_modules/.bin`.
|
|
128
219
|
|
|
129
220
|
### Removed
|
|
130
|
-
- `bin/core.js` and `docs/CLI.md
|
|
221
|
+
- `bin/core.js` and `docs/CLI.md`, moved to the [`@surea11y/cli`](https://github.com/SureA11y/cli) package. The binary name, flags, exit codes, and output formats are unchanged; only the package you install it from did.
|
|
131
222
|
|
|
132
223
|
## [1.3.0] - 2026-08-02
|
|
133
224
|
|
|
134
225
|
### Added
|
|
135
|
-
- `surea11y.browser.js`: a standalone browser bundle, regenerated by `npm run build` (`scripts/build-browser.js`) alongside `src/core.js`. Loading it via a plain `<script>` tag
|
|
136
|
-
- `surea11y scan --sarif <path>`: writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for GitHub Code Scanning or another SARIF-consuming dashboard, alongside the existing `--json`/`--html` outputs. `fail` occurrences map to SARIF `error`, `cantTell` to `warning
|
|
137
|
-
- `docs/CI_INTEGRATIONS.md`: ready-to-paste GitHub Actions workflow (basic exit-code gating, a `--baseline`-gated variant, and a SARIF-upload-to-Code-Scanning variant) and a Bitbucket Pipelines step template
|
|
138
|
-
- `surea11y scan --custom-rules <path>`: the CLI now exposes `engineOptions.customRules` (previously library-only
|
|
226
|
+
- `surea11y.browser.js`: a standalone browser bundle, regenerated by `npm run build` (`scripts/build-browser.js`) alongside `src/core.js`. Loading it via a plain `<script>` tag defines one global, `a11ycore`, exposing `runa11yCoreInPage`. Excludes `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` (cross-frame scanning needs the embedded frame to also load the engine and opt in, and would roughly double the bundle's size for a feature most script-tag consumers won't use; still available via `require('@surea11y/core')`). See `docs/INTEGRATION.md`'s "Pattern 3" and `README.md`'s "Standalone browser bundle" section.
|
|
227
|
+
- `surea11y scan --sarif <path>`: writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for GitHub Code Scanning or another SARIF-consuming dashboard, alongside the existing `--json`/`--html` outputs. `fail` occurrences map to SARIF `error`, `cantTell` to `warning`. New module `src/sarif.js`. See `docs/SARIF.md` for the full field mapping.
|
|
228
|
+
- `docs/CI_INTEGRATIONS.md`: ready-to-paste GitHub Actions workflow (basic exit-code gating, a `--baseline`-gated variant, and a SARIF-upload-to-Code-Scanning variant) and a Bitbucket Pipelines step template.
|
|
229
|
+
- `surea11y scan --custom-rules <path>`: the CLI now exposes `engineOptions.customRules` (previously library-only), letting an org register its own rule(s) for a scan without forking the engine. `<path>` is a local JS file, `require()`d directly, exporting a rule descriptor or an array of them. The flag is repeatable. Validated at load time; a missing file, a `require()`-time throw, or a malformed export exits `2` with a clear error.
|
|
139
230
|
|
|
140
231
|
### Changed
|
|
141
|
-
- `src/core/aria-helpers.js`
|
|
142
|
-
- `aria-prohibited-attr` now also flags `aria-label`/`aria-labelledby` on
|
|
232
|
+
- `src/core/aria-helpers.js` is always inlined into the generated `src/core.js` bundle, so Node's coverage tool could never attribute execution back to the module, and it had zero direct unit tests (function coverage 31%). Added `tests/core/aria-helpers.test.js`, covering role classification, `validateAttrValue`'s per-value-type branches, `getElementRoleKey`, and `getContainmentRole`. Function coverage: 31% → 100%.
|
|
233
|
+
- `aria-prohibited-attr` now also flags `aria-label`/`aria-labelledby` on roleless elements (no explicit role, no implicit/native role either), not just the small set of explicitly-role-restated naming-prohibited roles it already covered. A roleless element has naming attributes prohibited unless its tag is on a small allow-list or its closest real ancestor role is a widget-type role. The new branch reports two confidence tiers: a roleless element whose subtree already produces a non-empty accessible name from content is `cantTell` (the naming attribute might be a redundant/intentional override), while one with no other accessible-name source at all is a confident `fail`. A roleless helper element nested inside a real widget-type role is exempted. `getNativeRoleForElement` is now re-exported to back this check.
|
|
143
234
|
- License updated to Mozilla Public License 2.0 (MPL-2.0). `LICENSE`, `package.json`'s `license` field, and `README.md`'s License section updated accordingly.
|
|
144
235
|
|
|
145
236
|
### Fixed
|
|
146
|
-
- `buildSelector`
|
|
147
|
-
- `aria-required-parent`'s `hasAcceptableAncestorContext` treated an immediate `role="group"` ancestor as transparent for `listitem`/`treeitem`
|
|
148
|
-
- `form-control-programmatic-label-present` (via the shared `labelContributesAccessibleName
|
|
149
|
-
- `embed`/`object`/`video-poster-text-alternative-present`'s failing-occurrence `hint` text omitted `title` as a remediation option, even though each rule's own
|
|
150
|
-
- `listbox`/`searchbox`/`spinbutton`/`textbox`/`combobox`/`meter`/`progressbar-name-present`'s failing-occurrence `hint` text told authors to
|
|
151
|
-
- `contrast-minimum` attached a failing occurrence's element metadata
|
|
152
|
-
- `getContentNameInfo`
|
|
153
|
-
- `region` was scoped to
|
|
154
|
-
- `getComputabilityBlocker`
|
|
155
|
-
- `form-control-single-label` counted every `<label>`
|
|
156
|
-
- `aria-hidden-focus` now performs a conservative runtime focus-handoff probe for single-offender `aria-hidden` roots before deciding outcome confidence
|
|
157
|
-
- `getContainmentRole`
|
|
158
|
-
- `contrast-minimum`/`contrast-enhanced`
|
|
159
|
-
- `tests/contrast-helpers.test.js` never actually imported `src/core/contrast-helpers.js
|
|
160
|
-
- `aria-prohibited-attr` and `aria-hidden-focus` both collect two independent confidence tiers in one run
|
|
237
|
+
- `buildSelector` built its id/data-testid/name/aria-label anchor selectors by embedding the *trimmed* attribute value into the CSS selector string, while the uniqueness-index lookup that decided whether to use that anchor also keyed on the trimmed value. A CSS attribute/id selector requires an exact match against the real, untrimmed attribute, so any anchor attribute with leading/trailing whitespace produced a selector that could never match its own element and silently degraded to a bare-tag-name fallback resolving to the wrong element. Fixed by embedding the raw, untrimmed value in the actual selector string across all six anchor sites.
|
|
238
|
+
- `aria-required-parent`'s `hasAcceptableAncestorContext` treated an immediate `role="group"` ancestor as transparent for `listitem`/`treeitem` but never added the tested element's own role to the acceptable-context set at that point, so a standard arbitrarily-deep ARIA tree stopped at the second `treeitem` ancestor and failed. It now mirrors the correct behavior: passing a transparent `group` ancestor also adds the element's own role to the acceptable set from that point on.
|
|
239
|
+
- `form-control-programmatic-label-present` (via the shared `labelContributesAccessibleName`) never checked a `<label>`'s own `title` attribute as a last-resort name source, only its ARIA name and content name, so a structurally-associated label with empty content but a non-empty `title` was treated as not contributing a name. Now also checks `getNonEmptyTitle(lab)` after the aria-name and content-name checks come back empty.
|
|
240
|
+
- `embed`/`object`/`video-poster-text-alternative-present`'s failing-occurrence `hint` text omitted `title` as a remediation option, even though each rule's own `@expectation` lists a title attribute as a valid best-effort fallback and each rule's own `runInPage` accepts it. Fixed in the rule source, `src/i18n/en.js`, and `src/i18n/fr.js`.
|
|
241
|
+
- `listbox`/`searchbox`/`spinbutton`/`textbox`/`combobox`/`meter`/`progressbar-name-present`'s failing-occurrence `hint` text told authors to add visible text as a valid fix, but all seven roles are name-from-author-only per WAI-ARIA, so a developer following the hint would rerun the scan and see the same failure. Fixed in the rule source and both locale files.
|
|
242
|
+
- `contrast-minimum` attached a failing occurrence's element metadata as a non-standard top-level `occurrence.node` field, the only place in the whole rule catalog that did this; its twin `contrast-enhanced` already nests this under `occurrence.data.details`, the documented convention. Now matches.
|
|
243
|
+
- `getContentNameInfo` resolved an image-like descendant's contribution via `getAccessibleNameInfo`, which unconditionally falls back to a `title` attribute, so `title` silently outranked `alt` regardless of whether `alt` was present, including the common case of a correct `alt` alongside an unrelated `title` tooltip silently overriding it. Fixed by checking `getAriaNameInfo` first, then a native `<label>` association for `input[type=image]`, then `alt`, with `alt`'s own present/absent distinction deciding whether `title` is a legitimate last-resort fallback.
|
|
244
|
+
- `region` was scoped to direct children of `<body>` only, which turned out to be nearly inert on the most common real-world page shape, a single root mount `<div>` wrapping everything. Replaced with a recursive walk: descend from `<body>`, stop at landmarks/live regions/dialogs/buttons/`<svg>`/`<iframe>`/resolvable skip-links, collect the first node with genuine own content, then collapse contiguous unplaced content back up to its tightest shared ancestor.
|
|
245
|
+
- `getComputabilityBlocker` walked an element's entire ancestor chain looking for a background-image/gradient and reported it as a computability blocker regardless of whether a closer ancestor's own background-color was already fully opaque and would occlude it. Now tracks whether a closer, blend-mode/filter-free ancestor's background resolved fully opaque and, if so, suppresses `BACKGROUND_IMAGE_OR_GRADIENT` for anything farther out. Deliberately not extended to `mix-blend-mode`/`filter`/`backdrop-filter`/ancestor `opacity`, which are compositing-group operations that a closer opaque layer can't occlude through.
|
|
246
|
+
- `form-control-single-label` counted every associated `<label>` regardless of whether it was actually accessibility-tree-eligible, so a hidden decoy/overlay label still triggered a false "multiple labels" flag. Now filters candidate labels through `helpers.isAccTreeEligible` before counting.
|
|
247
|
+
- `aria-hidden-focus` now performs a conservative runtime focus-handoff probe for single-offender `aria-hidden` roots before deciding outcome confidence, observing a short deterministic scheduling window and tracing `focusin` transitions. If focus is handed off outside the same subtree during that window, the finding is downgraded from `fail` to `cantTell`.
|
|
248
|
+
- `getContainmentRole` treated any `role=""` attribute value as a real ancestor/descendant context role for required-context matching, even when the value isn't a valid recognized ARIA role, though real browser/AT behavior is to ignore an unrecognized value and fall back past it. Now validates the explicit role via `isValidConcreteRole` before accepting it.
|
|
249
|
+
- `contrast-minimum`/`contrast-enhanced` never evaluated `<input type="submit"|"button"|"reset">`'s visible label at all: that label renders from the `value` attribute, not a DOM text node, and these are void elements, so the existing text-walk-based candidate collection was structurally blind to them. Added a second candidate pass over these input types reusing the same eligibility gates as the text-node path.
|
|
250
|
+
- `tests/contrast-helpers.test.js` never actually imported `src/core/contrast-helpers.js`; it hand-copied `parseCssColorToRgba`/`compositeRgba`/`contrastRatio` as a duplicate implementation and tested that instead. It now requires the real module.
|
|
251
|
+
- `aria-prohibited-attr` and `aria-hidden-focus` both collect two independent confidence tiers in one run and both silently discarded every `cantTell`-tier finding whenever at least one `fail`-tier finding also existed on the same page. `target-size-minimum` had a related, worse variant: its uncertain cases were tracked only as a page-level boolean with no occurrence object at all. Added a shared `helpers.resolveTieredOutcome(failOccurrences, cantTellOccurrences, severity)` that all three now use: when any fail-tier finding exists the outcome stays `fail`, but both buckets' occurrences are returned together, each carrying its own distinguishing `reasonCode`.
|
|
161
252
|
|
|
162
253
|
## [1.2.0] - 2026-07-31
|
|
163
254
|
|
|
164
255
|
### Added
|
|
165
|
-
- CLI baseline/allowlist mechanism: `surea11y scan --write-baseline <path>` records every current `fail` occurrence (never fails the build); `surea11y scan --baseline <path>` then gates only on occurrences not already recorded there. Matching identity is `ruleId` + `reasonCode` + the occurrence's `html` snippet (
|
|
166
|
-
- `engineOptions.fragment`: 14 rules that check for
|
|
167
|
-
- Versioned public API contract: `docs/API_STABILITY.md`
|
|
168
|
-
- `surea11y scan --html <path>`: a self-contained, browsable HTML report (`src/report.js`'s `renderHtmlReport`)
|
|
256
|
+
- CLI baseline/allowlist mechanism: `surea11y scan --write-baseline <path>` records every current `fail` occurrence (never fails the build); `surea11y scan --baseline <path>` then gates only on occurrences not already recorded there. Matching identity is `ruleId` + `reasonCode` + the occurrence's `html` snippet (not `selector`/`structuralPath`, both position-derived and liable to shift when unrelated markup changes), multiset-matched so repeated identical violations are counted correctly. `buildBaselineEntries`/`matchBaseline` (`src/baseline.js`) are also usable directly by library consumers. See `docs/BASELINE.md`.
|
|
257
|
+
- `engineOptions.fragment`: 14 rules that check for a page-wide property now correctly report `notApplicable`, instead of a false `fail`/`cantTell`, when a scan is scoped to a subtree narrower than the whole document (via `contextSelector`) or run with `engineOptions.fragment: true`. New `helpers.isWholeDocumentScope()` backs this via each rule's `applicability(ctx)` export. See `docs/ENGINE_OPTIONS.md` and `docs/RULE_AUTHORING.md` §11.2.
|
|
258
|
+
- Versioned public API contract: `docs/API_STABILITY.md` codifies which result-shape fields are covered by semver and what triggers a patch/minor/major bump. Also adds a rule-ID deprecation mechanism: `meta.deprecated`/`meta.deprecation` (`{ replacedBy, reason, sinceVersion }`), validated by `normalizeRuleMeta` and surfaced through `getChecksCatalog()`. A deprecated rule keeps running normally; this is a catalog-level migration signal, not an automatic exclusion.
|
|
259
|
+
- `surea11y scan --html <path>`: a self-contained, browsable HTML report (`src/report.js`'s `renderHtmlReport`), hero summary, findings grouped by rule, a WCAG rollup, and a collapsed technical-data section with a searchable occurrence table. No external requests, dark-mode aware. See `docs/REPORT.md`.
|
|
169
260
|
|
|
170
261
|
### Fixed
|
|
171
|
-
- `aria-prohibited-children` resolved an owned child's role via `getExplicitRole` (explicit `role=""`
|
|
172
|
-
- `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-unique`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, and `region` all computed whether a `<header>`/`<footer>`/`<aside>` sits inside a sectioning-content ancestor
|
|
173
|
-
- `hasLandmarkScopingAncestor`
|
|
174
|
-
- `tabindex`, `heading-order`, `empty-heading`, `empty-table-header`, and `scope-attr-valid` all queried `document.querySelectorAll` directly instead of the shared `helpers.queryAllSmart`, so none of them respected `contextSelector` scoping, `excludeSelectors`, or shadow-DOM traversal
|
|
262
|
+
- `aria-prohibited-children` resolved an owned child's role via `getExplicitRole` (explicit `role=""` only), unlike its sibling `aria-required-children`, which resolves via `getContainmentRole` (falling back to a native-tag map: `li`→listitem, `tr`→row, `td`→cell, etc.). A bare `<li>` with no `role` attribute, the common `<ul role="list"><li>` CSS-reset pattern, was read as roleless and structurally transparent, so the ownership walk could recurse past the listitem boundary and report a focusable descendant several levels down as a disallowed owned child. Now uses `getContainmentRole`, so both rules resolve an owned child's role identically. The fix is general: it applies to every container role whose native-tag counterpart the containment map covers.
|
|
263
|
+
- `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-unique`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, and `region` all computed whether a `<header>`/`<footer>`/`<aside>` sits inside a sectioning-content ancestor by checking the ancestor's tag name alone. The correct algorithm is role-aware: an ancestor's bare tag only counts when it carries no `role` attribute at all; once a role is present, only that role's value decides membership. All 7 rules previously carried a duplicated copy of this tag-only check; they now share one `helpers.hasLandmarkScopingAncestor` implementation.
|
|
264
|
+
- `hasLandmarkScopingAncestor` and the separate local `hasLandmarkAncestor` in the three `*-is-top-level` rules both climbed via `parentElement` with no scope boundary, so a `contextSelector`-scoped scan could be affected by real DOM ancestry outside the analyzed subtree. Both now stop climbing once they reach one of the scan's own resolved roots.
|
|
265
|
+
- `tabindex`, `heading-order`, `empty-heading`, `empty-table-header`, and `scope-attr-valid` all queried `document.querySelectorAll` directly instead of the shared `helpers.queryAllSmart`, so none of them respected `contextSelector` scoping, `excludeSelectors`, or shadow-DOM traversal. All five now delegate to `helpers.queryAllSmart`/`.queryAll`.
|
|
175
266
|
|
|
176
267
|
## [1.1.2] - 2026-07-30
|
|
177
268
|
|
|
178
269
|
### Fixed
|
|
179
|
-
- `accesskeys` no longer over-reports duplicate `accesskey` values when one copy is structurally/CSS hidden by default
|
|
270
|
+
- `accesskeys` no longer over-reports duplicate `accesskey` values when one copy is structurally/CSS hidden by default. Candidate collection now follows the shared helper visibility policy, so only currently eligible elements are grouped unless `engineOptions.includeHiddenElements: true` is set.
|
|
180
271
|
- `skip-link` no longer treats fragment-target existence alone as sufficient. It now also flags skip links whose target exists but is currently unusable (hidden from the accessibility tree), while keeping geometry-based target checks gated to environments that expose reliable layout metrics.
|
|
181
|
-
- `page-has-heading-one` and `bypass-blocks-present` credited a fully non-rendered `<h1>`/`<main>`/heading
|
|
182
|
-
- `button-name-present` and `link-name-present` credited a `<button>`/`<a href>` element's rendered content as its accessible name even when an explicit `role` overrode it to a role
|
|
183
|
-
- `createDomHelpers()`'s element-keyed caches
|
|
184
|
-
- `buildSelector` could emit ambiguous selectors in multi-region scans when the target element was the last same-tag sibling,
|
|
185
|
-
- `queryAllSmart` could retain elements that are structurally hard-hidden when `isAccTreeEligible` short-circuited on an `inert` ancestor before reaching an outer `display:none`/`visibility:hidden`/`content-visibility:hidden` ancestor. It now performs a style-only DOM visibility fallback in that path
|
|
272
|
+
- `page-has-heading-one` and `bypass-blocks-present` credited a fully non-rendered `<h1>`/`<main>`/heading as satisfying the check, since both queried the raw DOM with no visibility filtering. Both now filter candidates through `isAccTreeEligible`, matching `landmark-one-main`'s established precedent.
|
|
273
|
+
- `button-name-present` and `link-name-present` credited a `<button>`/`<a href>` element's rendered content as its accessible name even when an explicit `role` overrode it to a value-bearing role (`combobox`, `listbox`, `textbox`, `slider`, `spinbutton`, `progressbar`, `scrollbar`), which is name-from-author-only per the accname spec. Both checks gated on the native host tag alone, never on whether `role` had overridden it. Both rules now exclude these value-roles from name-from-content; a programmatic name still works normally.
|
|
274
|
+
- `createDomHelpers()`'s element-keyed caches were persisted on `window.__a11ycoreSharedCache` and only initialized once per window/document, not once per run, so a window reused across separate calls (e.g. Jest's jsdom environment) could read back a stale cached value for an element that persists by reference across runs. Rule pass/fail outcomes were always computed against the live DOM; only cached diagnostic data like `occurrences[].html` could go stale. `runCore()` now resets the shared cache at the start of every run.
|
|
275
|
+
- `buildSelector` could emit ambiguous selectors in multi-region scans when the target element was the last same-tag sibling, since the `:nth-of-type()` disambiguator could be omitted on that segment. Selector construction now consistently disambiguates those cases.
|
|
276
|
+
- `queryAllSmart` could retain elements that are structurally hard-hidden when `isAccTreeEligible` short-circuited on an `inert` ancestor before reaching an outer `display:none`/`visibility:hidden`/`content-visibility:hidden` ancestor. It now performs a style-only DOM visibility fallback in that path.
|
|
186
277
|
|
|
187
278
|
### Changed
|
|
188
|
-
- Test coverage tightened for this release cycle: re-enabled and stabilized the previously skipped `role-img-text-alternative-present` i18n assertions
|
|
189
|
-
- Removed a dead `root`/`safeRoot` second argument passed to `helpers.queryAllSmart`/`helpers.queryAll` across 67 rule files: both helpers only ever accepted a single selector argument and scope internally via the run's resolved `contextSelector` roots, so the extra argument was silently ignored
|
|
279
|
+
- Test coverage tightened for this release cycle: re-enabled and stabilized the previously skipped `role-img-text-alternative-present` i18n assertions, and added regression coverage for the inert + outer hard-hidden filtering path in `queryAllSmart`.
|
|
280
|
+
- Removed a dead `root`/`safeRoot` second argument passed to `helpers.queryAllSmart`/`helpers.queryAll` across 67 rule files: both helpers only ever accepted a single selector argument and scope internally via the run's resolved `contextSelector` roots, so the extra argument was silently ignored. No behavior change. Also fixed `docs/RULE_TEMPLATE.md`, which declared `safeRoot` but never referenced it.
|
|
190
281
|
|
|
191
282
|
## [1.1.1] - 2026-07-29
|
|
192
283
|
|
|
193
284
|
### Changed
|
|
194
|
-
- **`engineOptions.includeHiddenElements` (default `false`)**: helper-driven rules now skip elements hidden by `display:none` (on the element or any ancestor), `visibility:hidden`/`collapse`, the `[hidden]` attribute, closed `<details>`, and other structurally-non-rendered content by default
|
|
285
|
+
- **`engineOptions.includeHiddenElements` (default `false`)**: helper-driven rules now skip elements hidden by `display:none` (on the element or any ancestor), `visibility:hidden`/`collapse`, the `[hidden]` attribute, closed `<details>`, and other structurally-non-rendered content by default. Filtering happens upstream in the shared `queryAllSmart` helper, before a rule's own pass/fail logic runs. Set `engineOptions.includeHiddenElements: true` to restore the previous behavior. 10 rule files whose own logic doesn't call the underlying eligibility check directly still inherit this filtering through `queryAllSmart`; their doc comments were updated to say so. See `docs/ENGINE_OPTIONS.md` and `docs/LIMITATIONS.md`.
|
|
195
286
|
|
|
196
287
|
### Fixed
|
|
197
|
-
- `docs/LIMITATIONS.md`: the `<dialog>`/UA-stylesheet-hidden-content note was stale
|
|
288
|
+
- `docs/LIMITATIONS.md`: the `<dialog>`/UA-stylesheet-hidden-content note was stale, claiming static-markup-validity checks still evaluate hidden content, which this release's default change makes no longer true. Corrected to describe the current default and how to opt back in.
|
|
198
289
|
|
|
199
290
|
## [1.1.0] - 2026-07-28
|
|
200
291
|
|
|
201
292
|
### Added
|
|
202
|
-
- `engineOptions.rules[ruleId].excludeSelectors`: rule-scoped exclusions, narrowing candidates for exactly one rule on top of (never instead of) the existing global `excludeSelectors`. Resolves the class of false positive where one rule misfires on a component
|
|
203
|
-
- Completed French (`fr`) localization: translated the 313 remaining `src/i18n/fr.js` keys, bringing French to full parity with English (600/600 keys, up from 287/600).
|
|
293
|
+
- `engineOptions.rules[ruleId].excludeSelectors`: rule-scoped exclusions, narrowing candidates for exactly one rule on top of (never instead of) the existing global `excludeSelectors`. Resolves the class of false positive where one rule misfires on a component while every other rule still needs to see it. See `docs/ENGINE_OPTIONS.md`'s "Rule-scoped `excludeSelectors`" section.
|
|
294
|
+
- Completed French (`fr`) localization: translated the 313 remaining `src/i18n/fr.js` keys, bringing French to full parity with English (600/600 keys, up from 287/600).
|
|
204
295
|
|
|
205
296
|
### Fixed
|
|
206
|
-
- `docs/RULE_TAXONOMY.md`: automatic rules' allowed outcomes was missing `cantTell` (4 rules use it as a defensive fallback); the "current intents"/"current families" lists were stale and read as exhaustive when the ruleset actually spans 54 suffixes/68 prefixes
|
|
297
|
+
- `docs/RULE_TAXONOMY.md`: automatic rules' allowed outcomes was missing `cantTell` (4 rules use it as a defensive fallback); the "current intents"/"current families" lists were stale and read as exhaustive when the ruleset actually spans 54 suffixes/68 prefixes, reframed as illustrative with a pointer to the generated `RULE_CATALOG.md`; `data.visibilityFilter.targetSet` was missing the `'dom'` value.
|
|
207
298
|
- `docs/OUTPUT_SCHEMA.md`: the `visibilityFilter` type was missing its always-present `eligible` field; the worked example's `structuralPath` values for the button/img pair were swapped; removed a citation to a note in `RULE_AUTHORING.md` that doesn't exist there.
|
|
208
|
-
- `docs/I18N.md`: stale key counts (`en` listed as 590, actual 600; `fr` coverage listed as ~49%, actual ~48% at the time)
|
|
299
|
+
- `docs/I18N.md`: stale key counts (`en` listed as 590, actual 600; `fr` coverage listed as ~49%, actual ~48% at the time), now updated to reflect full parity.
|
|
209
300
|
|
|
210
301
|
## [1.0.1] - 2026-07-28
|
|
211
302
|
|
|
212
303
|
### Changed
|
|
213
|
-
- Trimmed the published npm package: rule-authoring scaffolding (`docs/RULE_TEMPLATE.*`, `docs/RULE_TEST_TEMPLATE.md`, `docs/RULE_TEST_AUTHORING.md`, `docs/TEST_OUTCOME_STABILITY.md`) and the not-yet-documented `src/explain` module no longer ship in the tarball
|
|
304
|
+
- Trimmed the published npm package: rule-authoring scaffolding (`docs/RULE_TEMPLATE.*`, `docs/RULE_TEST_TEMPLATE.md`, `docs/RULE_TEST_AUTHORING.md`, `docs/TEST_OUTCOME_STABILITY.md`) and the not-yet-documented `src/explain` module no longer ship in the tarball; both stay in the git repo for contributors.
|
|
214
305
|
- README rewritten for clarity: corrected the install command and `require()` examples to the actual package name (`@surea11y/core`), and updated the rule count to 125.
|
|
215
|
-
- `docs/ENGINE_OPTIONS.md`: documented the previously-undocumented `visibilityMode` option (`'styleOnly'`/`'styleAndGeometry'`, scoped to the three contrast rules), and added a "Recipes" section with composed, runnable examples for common scenarios
|
|
306
|
+
- `docs/ENGINE_OPTIONS.md`: documented the previously-undocumented `visibilityMode` option (`'styleOnly'`/`'styleAndGeometry'`, scoped to the three contrast rules), and added a "Recipes" section with composed, runnable examples for common scenarios.
|
|
216
307
|
|
|
217
308
|
### Fixed
|
|
218
|
-
- `aria-allowed-attr`'s `SUPPORTED_ATTRS_BY_ROLE` table reconciled against the published WAI-ARIA 1.2 Recommendation
|
|
219
|
-
- README: a "Real browser execution" code sample passed four positional arguments to `page.evaluate()` and claimed it worked with "any" automation framework
|
|
309
|
+
- `aria-allowed-attr`'s `SUPPORTED_ATTRS_BY_ROLE` table reconciled against the published WAI-ARIA 1.2 Recommendation, since a number of the previous `aria-expanded` allowances turned out to be ARIA 1.1 legacy carryovers rather than current-spec facts. Added `aria-expanded` to 10 roles and `aria-activedescendant` to 8 composite-widget roles, plus smaller posinset/setsize/readonly/required/level gaps; removed `tree`'s unverified `aria-readonly`.
|
|
310
|
+
- README: a "Real browser execution" code sample passed four positional arguments to `page.evaluate()` and claimed it worked with "any" automation framework, but Playwright's `page.evaluate()` only accepts one argument alongside the function and throws on this pattern. Now shown as Puppeteer-specific, with a pointer to `INTEGRATION.md`'s wrapper for Playwright.
|
|
220
311
|
- README: the JSON output example referenced a nonexistent rule id (`link-name-quality`); corrected to the real id, `link-name-quality-manual`.
|
|
221
|
-
- README: Quick Start code samples labeled the runner's four positional arguments as `url, ruleFilter, options, policy`; corrected to the actual names (`pageUrl, contextSelector, engineOptions, runOnly`)
|
|
312
|
+
- README: Quick Start code samples labeled the runner's four positional arguments as `url, ruleFilter, options, policy`; corrected to the actual names (`pageUrl, contextSelector, engineOptions, runOnly`).
|
|
222
313
|
|
|
223
314
|
## [1.0.0] - 2026-07-26
|
|
224
315
|
|
|
225
316
|
### Added
|
|
226
|
-
- 125 rules (77 automatic/`fail`-capable, 48 manual/advisory)
|
|
227
|
-
- Full i18n support (English complete, French partial
|
|
317
|
+
- 125 rules (77 automatic/`fail`-capable, 48 manual/advisory). See `docs/RULE_CATALOG.md` for the full list.
|
|
318
|
+
- Full i18n support (English complete, French partial). See `docs/I18N.md`.
|
|
228
319
|
- A generated public rule catalog (`docs/RULE_CATALOG.md`, via `npm run docs:rule-catalog`) and WCAG facet-coverage report (`coverage/coverage-report.md`, via `npm run coverage`).
|
|
229
320
|
- This documentation set: `README.md`, `docs/OUTPUT_SCHEMA.md`, `docs/ENGINE_OPTIONS.md`, `docs/WCAG_CONFORMANCE.md`, `docs/POLICY.md`, `docs/I18N.md`, `docs/INTEGRATION.md`, `docs/LIMITATIONS.md`, `docs/TROUBLESHOOTING.md`, `LICENSE`.
|
|
230
321
|
- Multi-region `contextSelector` support: pass an array of selectors (or one comma-separated selector string) to scan multiple, possibly disjoint regions in a single run. Overlapping/nested regions are deduped automatically. See `docs/ENGINE_OPTIONS.md`.
|
|
231
322
|
- `includeShadowDom` now defaults to `true` (opt out with `includeShadowDom: false`).
|
|
232
|
-
- `structuralPath` on every `fail`/`cantTell` occurrence: a sibling-index path from `documentElement` down to the flagged element, a more robust element-identity mechanism than `selector` alone
|
|
233
|
-
- `engineOptions.customRules`: register additional rules at runtime, scan-scoped
|
|
234
|
-
- `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including
|
|
235
|
-
- `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion
|
|
236
|
-
- `docs/BINDING_AUTHORS_GUIDE.md`: a reference for building a
|
|
323
|
+
- `structuralPath` on every `fail`/`cantTell` occurrence: a sibling-index path from `documentElement` down to the flagged element, a more robust element-identity mechanism than `selector` alone. See `docs/OUTPUT_SCHEMA.md`.
|
|
324
|
+
- `engineOptions.customRules`: register additional rules at runtime, scan-scoped, matching the shape of an internal rule module. `runInPage`/`applicability` accept a real function or a function-source string, the latter needed for cross-realm callers whose `engineOptions` argument can't carry a live function across a serialization boundary. See `docs/ENGINE_OPTIONS.md`.
|
|
325
|
+
- `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including cross-origin) scanning for the plain-script-injection consumption mode, a cooperative `postMessage` protocol. See `docs/INTEGRATION.md`'s "Cross-frame scanning" section and `docs/OUTPUT_SCHEMA.md`'s "Cross-frame result" section.
|
|
326
|
+
- `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion, so a caller can select a WCAG 2.0/2.1/2.2 conformance target by combining tag sets. See `docs/ENGINE_OPTIONS.md`'s "Filtering by WCAG version" section and `src/coverage/wcag-version-map.js`.
|
|
327
|
+
- `docs/BINDING_AUTHORS_GUIDE.md`: a reference for building a new framework binding on top of this engine, what's already engine-level vs. what every binding has to build itself.
|
|
237
328
|
|
|
238
329
|
### Fixed (selected)
|
|
239
|
-
- A shared `buildSelector` helper bug where an ancestor element that was the
|
|
240
|
-
- Several `ALLOWED_ROLES_BY_ELEMENT` entries (`<label>`, `<table>`/`<td>`/`<th>`/`<tr>`, `input[type=checkbox][role=button]`) that were missing or too restrictive
|
|
330
|
+
- A shared `buildSelector` helper bug where an ancestor element that was the last of several same-tag siblings got no `:nth-of-type()` disambiguation, producing selectors that matched multiple elements.
|
|
331
|
+
- Several `ALLOWED_ROLES_BY_ELEMENT` entries (`<label>`, `<table>`/`<td>`/`<th>`/`<tr>`, `input[type=checkbox][role=button]`) that were missing or too restrictive.
|
|
241
332
|
- `aria-hidden-focus` false-flagging the common `tabindex="-1"`-behind-`aria-hidden` pattern (checked raw focusability instead of tabbability).
|
|
242
333
|
- A label-naming bug (`hasLabelAssociation` ignoring a `<label>`'s own `aria-label`), duplicated across 7 rule files, all fixed identically.
|
|
243
334
|
- A systemic "name from content" false-positive affecting 19 rule files (an `<img alt>` or `aria-label`-named descendant inside a link/button wasn't recognized as providing the accessible name).
|
|
244
|
-
- `contextSelector` resolving via `document.querySelector` (first match only) instead of `querySelectorAll
|
|
245
|
-
- Three rules (`form-control-programmatic-label-present`, `target-size-minimum`, `label-in-name`) that queried `ctx.root` directly instead of through the shared `queryAllSmart`/`queryAll` helpers,
|
|
246
|
-
- `aria-required-parent`/`aria-required-children`'s ancestor/descendant searches and `getContentNameInfo`'s
|
|
247
|
-
- `landmark-one-main` incorrectly also flagged "more than one main landmark"
|
|
248
|
-
- `aria-required-children`, `aria-prohibited-children`, `aria-required-parent`, and `aria-required-attr` flagged containers/elements
|
|
249
|
-
- `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source
|
|
335
|
+
- `contextSelector` resolving via `document.querySelector` (first match only) instead of `querySelectorAll`, so a selector matching several elements silently scanned only the first.
|
|
336
|
+
- Three rules (`form-control-programmatic-label-present`, `target-size-minimum`, `label-in-name`) that queried `ctx.root` directly instead of through the shared `queryAllSmart`/`queryAll` helpers, silently broken the moment `ctx.root` became an array.
|
|
337
|
+
- `aria-required-parent`/`aria-required-children`'s ancestor/descendant searches and `getContentNameInfo`'s name-from-content walk not following shadow-DOM `<slot>` assignment; a duplicated `resolveAriaLabelledbyText` pattern across 16 rules and `getLabelText` across 7 not checking an `aria-labelledby`/`<label>` target's `title` attribute as a final accname fallback.
|
|
338
|
+
- `landmark-one-main` incorrectly also flagged "more than one main landmark", out of its real scope (duplicates are `landmark-no-duplicate-main`'s job) and missing the accessibility-tree visibility filter its sibling rule already has.
|
|
339
|
+
- `aria-required-children`, `aria-prohibited-children`, `aria-required-parent`, and `aria-required-attr` flagged containers/elements not currently exposed to the accessibility tree at all. All four now skip elements that fail `isAccTreeEligible`; `aria-required-children`/`aria-required-attr` also honor `aria-busy="true"` as an explicit author signal of transient incompleteness.
|
|
340
|
+
- `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source. Replaced all 7 copies with one shared helper, `helpers.getLandmarkNameInfo`.
|
|
250
341
|
|
|
251
342
|
### Known limitations
|
|
252
|
-
See `docs/LIMITATIONS.md
|
|
343
|
+
See `docs/LIMITATIONS.md`: structural (keyboard-trap detection, reflow-at-zoom), environment-dependent (jsdom vs. real-browser geometry), and deliberately-not-automated (text-quality judgment calls) limitations, stated explicitly rather than left to be discovered.
|
|
253
344
|
|
|
254
345
|
---
|
|
255
346
|
|
|
256
347
|
# How to add an entry
|
|
257
348
|
|
|
258
349
|
When you ship a change worth calling out to consumers (not every commit):
|
|
259
|
-
1. Add a bullet under `[Unreleased]`, in the right subsection (`Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, `Security`)
|
|
350
|
+
1. Add a bullet under `[Unreleased]`, in the right subsection (`Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, `Security`), create the subsection if it doesn't exist yet for this cycle.
|
|
260
351
|
2. Write it from the consumer's perspective ("what changed for someone using this package"), not the implementation's.
|
|
261
352
|
3. When you tag a release, rename `[Unreleased]` to `## [x.y.z] - YYYY-MM-DD` and start a fresh empty `[Unreleased]` above it.
|