@surea11y/core 1.3.0 → 1.4.1
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 +87 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -0
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/CI_INTEGRATIONS.md +7 -7
- package/docs/ENGINE_OPTIONS.md +1 -1
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +6 -6
- package/package.json +52 -16
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +4 -6
- package/src/checks/automatic/aria-allowed-attr.js +663 -134
- package/src/checks/automatic/aria-allowed-role.js +2 -0
- package/src/checks/automatic/aria-braille-equivalent.js +2 -0
- package/src/checks/automatic/aria-conditional-attr.js +8 -7
- package/src/checks/automatic/aria-deprecated-role.js +107 -38
- package/src/checks/automatic/aria-hidden-body.js +6 -4
- package/src/checks/automatic/aria-hidden-focus.js +12 -13
- package/src/checks/automatic/aria-prohibited-attr.js +98 -105
- package/src/checks/automatic/aria-prohibited-children.js +56 -87
- package/src/checks/automatic/aria-required-attr.js +6 -7
- package/src/checks/automatic/aria-required-children.js +7 -10
- package/src/checks/automatic/aria-required-parent.js +20 -25
- package/src/checks/automatic/aria-role-name-present.js +2 -0
- package/src/checks/automatic/aria-roles-valid.js +33 -6
- package/src/checks/automatic/aria-valid-attr-value.js +18 -15
- package/src/checks/automatic/aria-valid-attr.js +2 -0
- package/src/checks/automatic/autocomplete-valid.js +39 -1
- package/src/checks/automatic/avoid-inline-spacing.js +181 -20
- package/src/checks/automatic/binary-control-name-present.js +13 -3
- package/src/checks/automatic/button-name-present.js +54 -21
- package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
- package/src/checks/automatic/combobox-name-present.js +10 -1
- package/src/checks/automatic/contrast-computable.js +2 -0
- package/src/checks/automatic/contrast-enhanced.js +2 -0
- package/src/checks/automatic/contrast-minimum.js +2 -0
- package/src/checks/automatic/css-orientation-lock.js +21 -27
- package/src/checks/automatic/definition-list-children-valid.js +6 -6
- package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
- package/src/checks/automatic/dialog-name-present.js +18 -11
- package/src/checks/automatic/dlitem-parent-valid.js +2 -0
- package/src/checks/automatic/duplicate-id-aria.js +4 -3
- package/src/checks/automatic/embed-text-alternative-present.js +2 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
- package/src/checks/automatic/form-control-single-label.js +110 -43
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
- package/src/checks/automatic/iframe-focusable-content.js +246 -18
- package/src/checks/automatic/iframe-name-present.js +2 -0
- package/src/checks/automatic/iframe-title-unique.js +3 -1
- package/src/checks/automatic/img-alt-present.js +23 -18
- package/src/checks/automatic/input-image-alt-present.js +101 -52
- package/src/checks/automatic/label-in-name.js +97 -27
- package/src/checks/automatic/language-page-present.js +7 -1
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +52 -17
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +10 -1
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +10 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
- package/src/checks/automatic/meter-name-present.js +12 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
- package/src/checks/automatic/object-text-alternative-present.js +16 -7
- package/src/checks/automatic/option-name-present.js +10 -1
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +16 -11
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +10 -1
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +13 -2
- package/src/checks/automatic/spinbutton-name-present.js +10 -1
- package/src/checks/automatic/summary-name-present.js +10 -1
- package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
- package/src/checks/automatic/svg-text-alternative-present.js +17 -5
- package/src/checks/automatic/tab-name-present.js +10 -1
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +69 -6
- package/src/checks/automatic/target-size-minimum.js +5 -0
- package/src/checks/automatic/td-has-header.js +24 -1
- package/src/checks/automatic/textbox-name-present.js +10 -1
- package/src/checks/automatic/tooltip-name-present.js +10 -1
- package/src/checks/automatic/treeitem-name-present.js +10 -1
- package/src/checks/automatic/valid-lang.js +18 -3
- package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
- package/src/checks/manual/accesskeys-manual.js +3 -1
- package/src/checks/manual/area-alt-decorative-manual.js +2 -0
- package/src/checks/manual/area-alt-quality-manual.js +2 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
- package/src/checks/manual/aria-text-manual.js +6 -5
- package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/css-hidden-focus.js +184 -9
- package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
- package/src/checks/manual/empty-heading-manual.js +17 -17
- package/src/checks/manual/empty-table-header-manual.js +52 -25
- package/src/checks/manual/focus-order-semantics-manual.js +16 -4
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
- package/src/checks/manual/heading-order-manual.js +28 -1
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
- package/src/checks/manual/image-redundant-alt-manual.js +21 -1
- package/src/checks/manual/img-alt-decorative-manual.js +2 -0
- package/src/checks/manual/img-alt-quality-manual.js +2 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
- package/src/checks/manual/label-title-only-manual.js +29 -22
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
- package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
- package/src/checks/manual/landmark-one-main-manual.js +12 -23
- package/src/checks/manual/landmark-unique-manual.js +37 -52
- package/src/checks/manual/link-name-quality-manual.js +2 -0
- package/src/checks/manual/media-transcript-present-manual.js +2 -0
- package/src/checks/manual/meta-viewport-large-manual.js +3 -1
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/p-as-heading-manual.js +2 -0
- package/src/checks/manual/page-has-heading-one-manual.js +12 -11
- package/src/checks/manual/page-title-patterns-manual.js +2 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
- package/src/checks/manual/region-manual.js +27 -36
- package/src/checks/manual/scope-attr-valid-manual.js +3 -1
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
- package/src/checks/manual/skip-link-manual.js +7 -6
- package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/tabindex-manual.js +3 -1
- package/src/checks/manual/table-duplicate-name-manual.js +5 -4
- package/src/checks/manual/table-fake-caption-manual.js +24 -3
- package/src/checks/manual/video-caption-manual.js +2 -0
- package/src/checks/manual-review.js +2 -0
- package/src/core.js +11820 -3317
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +20 -5
- package/surea11y.browser.js +4943 -1388
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- package/src/checks/automatic/bypass-blocks-present.js +0 -215
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -1211
- package/src/core/contrast-helpers.js +0 -1302
- package/src/core/dom-helpers.js +0 -4493
- package/src/core/dom-runner.js +0 -787
- package/src/core/frame-messaging.js +0 -261
- package/src/core/frame-scan.js +0 -190
- package/src/core/rollup-composites.js +0 -127
- package/src/core/rule-meta.js +0 -176
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -1228
- package/src/i18n/fr.js +0 -1185
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -59
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,91 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.4.1] - 2026-08-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `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.
|
|
11
|
+
- `scripts/generate-language-subtags.js` writes the IANA primary language subtags into `dom-helpers` from the `language-subtag-registry` package (a devDependency), same `--check` convention. New shared `helpers.isValidLanguageTag` backs both `valid-lang` and `html-lang-attr-present`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `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 (they contribute nothing to the name); `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`. New `cantTell` locale strings in all four locales.
|
|
15
|
+
- `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" — whether a block is actually repeated across the site isn't decidable from one document, so a page that legitimately needs no bypass mechanism is indistinguishable from one that omits a required one. 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 actually confirm. The same-page-anchor check is also now shadow-DOM-aware.
|
|
16
|
+
- 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. Three ARIA 1.3 roles the package doesn't carry yet (`comment`, `suggestion`, `text`) are supplemented back.
|
|
17
|
+
- `aria-roles-valid` and `aria-deprecated-role` now skip programmatically hidden elements, where a role has no effect (ACT 674b10).
|
|
18
|
+
- `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.
|
|
19
|
+
- `meta-viewport-zoom-enabled` now applies only when `content` sets `maximum-scale` or `user-scalable` — setting neither can't restrict zoom, so there's nothing to judge.
|
|
20
|
+
- `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.
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- `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%+)`, the pattern Angular CDK's live-announcer, Bootstrap's `.visually-hidden` and most `.sr-only` implementations use) — 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.
|
|
25
|
+
- `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 (never a global, never deprecated on the role) still fails. Same "deprecated but allowed" treatment as the `role="generic"` fix below, applied to attributes instead of roles. New `cantTell` locale strings in all four locales.
|
|
26
|
+
- `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") — the usage is conforming, so failing it was a false positive. Now reports `cantTell` under reason code `ARIA_ROLE_AUTHOR_DISCOURAGED`, alongside the existing deprecated-role case. `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. Reconciled the deprecation data (in `aria-helpers`, since `aria-query` doesn't carry it) against the full WAI-ARIA 1.2 role characteristics tables — see `docs/ARIA_DEPRECATION.md`.
|
|
27
|
+
- `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 — an Angular Material autocomplete panel driven entirely via `aria-activedescendant`, with nothing inside it a separate focus target, was reported although WCAG 4.1.2's concern is two *operable* controls occupying one place. A descendant now counts only when it's also focusable (`helpers.getFocusableInfo`, accounting for `contenteditable`, `:disabled`, `inert`, invalid/negative `tabindex`); a genuinely focusable nested control (a `<button>` inside an `<a href>`, an option given its own `tabindex`) still fails.
|
|
28
|
+
- `nested-interactive-controls-absent`'s focusability gate (above) still over-flagged a composite widget using the roving-tabindex pattern, where the active owned child genuinely carries `tabindex="0"` and so passed the focusable check despite being a managed part of its container, not an independent nested control. 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. An orphan role with no owning container (e.g. a stray `role="option"` outside any `listbox`) is unaffected and still counts if focusable.
|
|
29
|
+
- `avoid-inline-spacing` failed every inline `line-height`/`letter-spacing`/`word-spacing` declared `!important`, whatever the value — so `line-height: 2em !important` on 16px text was reported even though it already exceeds what WCAG 1.4.12 asks for. The criterion is about the resulting metrics, not the `!important` keyword: a forced value that already meets them leaves the user nothing to override. Now fails only below 1.5× font size for line-height, 0.12× for letter-spacing, 0.16× for word-spacing, taken from computed style where laid out and from the declared value otherwise. Applicability follows ACT: visible text of its own, rendered, not positioned off-screen; `inherit`/`unset` specify no spacing and are out of scope, `initial`/`revert` resolve to `normal` and stay in scope.
|
|
30
|
+
- `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 table whose only header 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`; an explicit `role="rowheader"`/`role="columnheader"` stays in scope.
|
|
31
|
+
- `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 actual word-based algorithm: it accepted `aria-label="Discover Italy"` for the visible text "Discover It" (a substring, though "it" isn't the word "italy"), and rejected `aria-label="Search by date (YYYY-MM-DD)"` against "Search by date" or a label with decorative punctuation/emoji, though each matches once normalised. 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 (`"University Ave."` vs `"University Avenue"`) now reports `cantTell` instead of failing, since neither is decidable from markup; a genuine mismatch alongside an uncertain one still fails. Two new locale strings in all four locales.
|
|
32
|
+
- `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; a plain decorative `aria-hidden` image (the attribute's ordinary use) was already out of scope and is unaffected.
|
|
33
|
+
- `aria-roles-valid` judged only the first token of the `role` attribute, so `role="searchfield searchbox"` was reported invalid — `role` takes a fallback list, and a browser resolves it to the first token it recognises (`searchbox` here, confirmed against Chrome). The rule now fails only when no token names a concrete role.
|
|
34
|
+
- `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 (`"work email"` is fine, `"work photo"` isn't).
|
|
35
|
+
- `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 (the registry lists a three-letter code only when no two-letter one exists — that's why `"en"` is registered and `"eng"` isn't). 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.
|
|
36
|
+
- `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. A negative `maximum-scale` is out of range and correctly still passes.
|
|
37
|
+
- `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 makes the whole directive invalid rather than falling back to a default delay. Both now parse `content` with the shared declarative-refresh steps.
|
|
38
|
+
- `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 (verified against Chrome's own accessibility tree) so a future `aria-query` update can't silently widen the rule.
|
|
39
|
+
- `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 presentational role conflict resolution can drop that role on a focusable element — `presentation-role-conflict` already owns that case.
|
|
40
|
+
- `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, since the control does have a name; that judgment call moved entirely to `input-image-alt-decorative`, so one element now yields one finding instead of two.
|
|
41
|
+
- `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. New locale strings for the fallback-name case in all four locales.
|
|
42
|
+
- `form-control-programmatic-label-present` used the looser `isAccTreeEligible` check, which keeps a focusable `aria-hidden` control "eligible", so it reported controls no assistive technology can reach. Switched to `isIncludedInAccessibilityTree`, matching the `*-name-present` rules and settling an existing inconsistency with `binary-control-name-present`, which already excluded the same case.
|
|
43
|
+
- 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` (both the tabbable and the IDREF-referenced case) got a real pass/fail verdict even though ACT's own glossary says such elements aren't in the accessibility tree at all — Chrome agrees, reporting `ignored: true` with no name. New shared `helpers.isIncludedInAccessibilityTree` routes all 18 rules to `notApplicable` there instead; the focus-order defect itself is still reported by `aria-hidden-focus`, which already owns it.
|
|
44
|
+
- `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 Chrome 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; an unrecognised role falls back to the implicit `link`/`button` role and is unaffected. That allowlist initially missed module roles that *inherit* the behaviour from a superclass — `doc-noteref` inherits from `link`, so `<a role="doc-noteref"><sup>1</sup></a>` lost the name Chrome still reports (`"1"`) — so it's now generated from each role's `nameFrom` instead of hand-enumerated.
|
|
45
|
+
- `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`, so there was no landmark there to be "nested". Both now select through the same suppression-aware role lookup the rest of the file already used, which cut false positives sharply on a sample corpus (236/236 for banner, 1/2 for contentinfo). An explicit `role="banner"`/`role="contentinfo"` is unaffected and still flagged when genuinely nested.
|
|
46
|
+
- `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 Chrome and the accname spec. Same class of bug as the earlier `alt`-vs-`title` fix for image descendants, just never applied to the generic case.
|
|
47
|
+
|
|
48
|
+
## [1.4.0] - 2026-08-08
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
- `tests/i18n/i18n-locale-completeness.test.js`: an automated check for the key-parity drift `docs/I18N.md` warned about but never enforced — previously, adding a new i18n key to `en.js` without a matching `fr.js` entry shipped silently (the per-string English fallback documented there masks it entirely), so `fr`'s claimed 100% coverage could quietly rot with no test ever catching it. The new test fails the build for any locale file with an "orphaned" key not present in `en.js` (a typo, or a key left behind after a rule was renamed/removed), and separately fails if a locale listed in the test's own `FULLY_TRANSLATED_LOCALES` array (currently just `fr`) is missing any `en.js` key — a partial locale not in that list still passes, matching the documented graceful-degradation behavior for locales that are deliberately incomplete. `docs/I18N.md` updated to point at the test instead of the old "diff the keys by hand" instruction.
|
|
52
|
+
- `src/i18n/de.js` and `src/i18n/es.js`: two new fully-translated (614/614 keys) locales, German and Spanish, joining `en`/`fr`. Both added to `FULLY_TRANSLATED_LOCALES` in `tests/i18n/i18n-locale-completeness.test.js`; `tests/i18n/i18n-locale-switch.test.js` now runs its EN-vs-translated contract check against every non-English locale file found in `src/i18n/`, not just `fr`. Documented in `docs/I18N.md`'s coverage table and mentioned in `README.md`'s "Localized reporting" principle.
|
|
53
|
+
- `npm run i18n:new <locale>` (`scripts/i18n-scaffold.js`) and `npm run i18n:report` (`scripts/i18n-report.js`): tooling to lower the barrier 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 (immediately valid — passes the completeness test's no-orphaned-keys check on day one), refusing to overwrite an existing file unless `--force` is passed. `i18n:report` prints per-locale progress (a key is counted as translated once its value differs from the English placeholder — a coincidentally-identical string, e.g. a bare `{{placeholder}}`-only value, slightly undercounts, documented as a known heuristic limitation rather than hidden). Both are pure-function-plus-CLI-wrapper modules (same shape as `scripts/build-browser.js`) with dedicated tests (`tests/i18n-scaffold.test.js`, `tests/i18n-report.test.js`). `docs/I18N.md`'s "Contributing a translation" section and `CONTRIBUTING.md` rewritten to point at this workflow instead of manual file creation.
|
|
54
|
+
- 107 new direct unit tests targeting previously-uncovered branches in the two most heavily-depended-on shared modules, `src/core/dom-helpers.js` (the accessible-name/description computation chain, IDREF resolution, `isDomVisibleEligible`/`getVisibilityHintsInfo`, and the `buildSelector*` family — the exact function family that produced the `buildSelector`/`buildSimpleSelector` trimming bugs fixed in `1.3.0`) and `src/core/contrast-helpers.js` (CSS color parsing, cache-degradation fallbacks, `getTextScan`). 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 investigated was confirmed correct (verified against the WAI-ARIA/HTML-AAM spec, sibling-function behavior, or existing fixtures) rather than assumed correct, including one asymmetry (`getAccessibleNameInfo` vs. `computeIdRefTargetTextAlternative` on the UA-default "Submit"/"Reset" label for a value-less `<input type="submit"|"reset">`) that looked like a bug at first but is confirmed intentional per `tests/fixtures/button-name-present-all-scenarios.html`'s case_10. New/extended test files: `tests/core/dom-helpers-name-computation.test.js`, `tests/core/dom-helpers-eligibility.test.js`, `tests/core/build-selector.test.js`, `tests/core/build-structural-path.test.js`, `tests/contrast-helpers.test.js`, `tests/contrast-helpers-dom.test.js`, `tests/cache-tests/contrast-helpers-cache.test.js`.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- `en.js`'s `mediaTranscriptPresent_summary_cantTell_missing` key contained French text (`'La présence d'une transcription...'`) instead of English — the canonical, fallback-of-last-resort locale was itself broken for this one string, meaning even a default (unspecified-locale) scan showed French to an English-reading user for this specific `cantTell` occurrence. Found incidentally while building the new `es` locale (a translating agent flagged that its English source sentence was actually French). Replaced with a proper English string, `'A transcript or other text alternative for this <{{element}}> is not strongly evidenced on the page.'`, matching the rule's own hardcoded fallback text and the `<{{element}}>` placeholder convention used by every sibling rule in the file (e.g. `iframeNamePresent_summary_fail`). `fr.js` was unaffected (already had a correct, if less specific, French translation); `de.js`/`es.js` were generated correctly from the start since both translating agents rendered the intended meaning rather than propagating the French text.
|
|
58
|
+
- `buildSimpleSelector` (shared `src/core/dom-helpers.js`, the bare-tag/attribute-anchor fallback `buildSelector` degrades to once every other anchoring strategy fails) had the same raw-vs-trimmed bug just fixed in `buildSelectorUncached`'s anchor builders (see 1.3.0's `buildSelector` fix below), but that fix never propagated here: it embedded the *trimmed* id/data-testid/name attribute value into the selector string while only using the trimmed value to check truthiness — so an element with a padded attribute (e.g. `id=" foo "`, or a templated `data-testid`/`name` ending in whitespace) got a selector that could never resolve back to it via `querySelector`. Found while extending direct-unit-test coverage of `dom-helpers.js`'s selector builders, continuing the same sweep that found the `buildSelector` bug. Fixed by embedding the raw, untrimmed value in all three branches (id, data-testid family, name), matching every other anchor builder in the file. 3 new regression tests (`tests/core/build-selector.test.js`).
|
|
59
|
+
- `computeIdRefTargetTextAlternative` (shared `src/core/dom-helpers.js`, resolves what an `aria-labelledby`/`aria-describedby` TARGET itself contributes by re-applying name computation to it, rather than reading raw `textContent`) had two related bugs found in the same sweep: (1) it checked the target's own `aria-label` before its own `aria-labelledby`, backwards from the accname spec's 2A-before-2B ordering and inconsistent with this same file's `getAriaNameInfo`, so a target carrying both a stale `aria-label` and a more specific, more current `aria-labelledby` resolved to the wrong, stale text; (2) it never consulted a native `<label>` association at all, so a target that is itself a labeled form control (e.g. `<input id="cb">` named via `<label for="cb">`, with no ARIA naming attributes of its own) resolved to empty text instead of the label — missing exactly the label-before-value/content priority `getAccessibleNameInfo` uses, which this function otherwise exists to mirror for a referenced target. Fixed by reordering the aria-labelledby/aria-label checks and inserting the same two-step native-label lookup (`.labels` API, then `label[for]` fallback) `getAccessibleNameInfo` uses, in the same priority slot. Caught immediately after, while extending coverage for the fix itself, that the new native-label lookup could re-enter a cycle undetected: a label whose content contains a descendant `aria-labelledby`'d back to the very control it labels (self-contradictory but not invalid markup) round-tripped through the same label's text once or more, since `getTextFromIdRefs`/`getTextFromIdRefsIdrefEligible` always started a brand-new cycle-detection `Set` rather than reusing one already in flight higher up the same resolution chain — bounded by the existing depth counter (so it terminated, not hung), but produced doubled/garbled text (e.g. `"Custom Custom ignored text label label"` instead of `"Custom ignored text label"`) for what used to resolve correctly before this same fix. Closed by threading an optional `opts.__idrefVisited` cycle-guard `Set` through `getTextFromIdRefs`/`getTextFromIdRefsIdrefEligible`/`computeIdRefTargetTextAlternative`, and seeding it with the element itself at both places a control's own `.labels` are walked (`getAccessibleNameInfo`'s own lookup, and the new one in `computeIdRefTargetTextAlternative`). 3 new regression tests (`tests/core/dom-helpers-name-computation.test.js`): the two original priority/native-label cases, plus the self-referencing-label cycle guard.
|
|
60
|
+
- `embed-text-alternative-quality-manual` treated a merely-*present* (even broken/empty-resolving) `aria-labelledby` attribute as "still a mechanism, worth reviewing" — deliberately, per its own prior comment — producing a confusing `cantTell` ("review this text alternative for accuracy") for an `<embed>` that has no text alternative at all (e.g. `aria-labelledby` pointing at a nonexistent id). Its sibling automatic rule, `embed-text-alternative-present`, already reports that exact case as a `fail` (no accessible name), so this manual rule's job — reviewing the quality of a name that DOES exist — never applied. Found by diffing this rule against its `object-`/`svg-`/`canvas-text-alternative-quality-manual` siblings, all three of which correctly require `aria-labelledby` to resolve to non-empty text before treating it as a detected mechanism; `embed` alone disagreed. Now matches: only a resolved, non-empty `aria-labelledby` counts. 1 new fixture case + updated occurrence-count assertion (`tests/engine-checks/manual/embed-text-alternative-quality.test.js`).
|
|
61
|
+
- `heading-order-manual`, `landmark-banner-is-top-level-manual`, `landmark-contentinfo-is-top-level-manual`, and `landmark-main-is-top-level-manual` never filtered their candidate elements through `helpers.isAccTreeEligible` — `queryAllSmart`'s default hidden-content policy only excludes "hard" CSS-based hiding (`display:none`, `visibility:hidden`, etc.), not the softer `aria-hidden` exclusion, which removes an element from the accessibility tree while leaving it visually rendered. For `heading-order`, an `aria-hidden` heading was both wrongly flagged itself (it isn't part of the AT-perceived document outline at all) AND could mask a real skip immediately after it, by wrongly advancing the "highest heading level reached so far" tracker on a level no assistive-technology user actually encounters (e.g. `<h1>`, `<h3 aria-hidden="true">`, `<h4>` reported the harmless `h1→h3` "skip" instead of the real `h1→h4` one). For the three `*-is-top-level` landmark rules, an `aria-hidden` `<header>`/`<footer>`/`<main>` nested inside another landmark was wrongly flagged as "nested inside another landmark region" even though, from AT's perspective, there's no real landmark there at all to be nested. Found while extending direct coverage of these rules and noticing every other rule in the catalog performs this check but these four didn't. All four now filter candidates through `isAccTreeEligible` before considering them. 4 new regression tests (one per rule).
|
|
62
|
+
- `image-redundant-alt-manual` collected a candidate `<img>`'s sibling text unconditionally when checking for a redundant duplicate of its `alt`, including an `aria-hidden` sibling's text — which is never actually announced to assistive technology, so it can't cause the "same words twice" double-announcement this rule exists to catch (e.g. `<a><img alt="Home"><span aria-hidden="true">Home</span></a>` was wrongly flagged as redundant, even though AT only ever hears "Home" once, from the `alt`). Same root cause and same sweep as the four rules above. Now skips `aria-hidden`/otherwise AT-ineligible siblings when building the "other text" comparison. 1 new regression test.
|
|
63
|
+
- `label-title-only-manual` checked only whether a `<label for="...">`/wrapping `<label>` structurally *existed* for a titled form control, never whether it actually contributed a name — an empty `<label for="x"></label>` or an empty wrapping `<label>` exempted the control from this rule even though `title` was still, functionally, its only real label (the same "structural association alone isn't enough" class of bug already fixed elsewhere in this engine, e.g. `dom-helpers.js`'s `hasLabelAssociation`/`labelContributesAccessibleName`). Rewritten to delegate to the shared `helpers.getAccessibleNameInfo` (the same aria → native-label → title precedence every other name-dependent rule uses) instead of a local, hand-rolled check, and to also filter candidates through `isAccTreeEligible` (the same gap as the rules above). 3 new regression tests (empty `label[for]`, empty wrapping `<label>`, plus an updated fixture case).
|
|
64
|
+
- `empty-table-header-manual` computed a header cell's "visible text" via plain `el.textContent`, which includes text from `aria-hidden` descendants — text a real screen reader never announces, exactly the AT-announcement gap this rule's own header comment extensively researched (real NVDA/VoiceOver/JAWS testing). A `<th>` whose only text came from an `aria-hidden` descendant (e.g. `<th><span aria-hidden="true">Name</span></th>`) was wrongly treated as having visible text and never flagged, even though AT announces nothing for it at all. Found while extending direct coverage of this rule. The text walk now skips `aria-hidden`/otherwise AT-ineligible descendants, and a fully `aria-hidden` header cell itself is now excluded as a candidate (same gap as the rules above). 2 new regression tests.
|
|
65
|
+
- `table-fake-caption-manual` treated an `aria-hidden` `<tr>` as the table's positional "first row" for its single-cell-first-row heuristic, and counted `aria-hidden` cells toward a row's cell count — an `aria-hidden` single-cell row sitting above ordinary multi-cell rows was wrongly flagged, even though the real, AT-exposed first row is an ordinary multi-cell row with no fake-caption shape at all. Same root cause and same sweep as the rules above. Rows and cells are now filtered through `isAccTreeEligible` before this heuristic runs. 1 new regression test.
|
|
66
|
+
- `td-has-header` (the first `automatic`, `fail`/`pass`-capable rule caught by this sweep, rather than the `cantTell`-capped manual rules above) credited an `aria-hidden` `<th>` as a valid implicit row/column header for other cells — a real screen reader never announces an `aria-hidden` header, 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`-severity WCAG 1.3.1 check). Same root cause and same sweep as the manual rules above. An `aria-hidden` `<th>` no longer counts as a header for other cells, and an `aria-hidden` `<td>` is no longer flagged either (it isn't exposed to AT, so it has no need for one). 2 new regression tests.
|
|
67
|
+
- `nested-interactive-controls-absent`'s nested-descendant search used the raw native `querySelectorAll`, not `helpers.queryAllSmart` — so, unlike every other rule's candidate collection, it wasn't subject to ANY hidden-content filtering at all, not even hard CSS-based hiding (`display:none`), let alone `aria-hidden`. A `display:none` or non-focusable-`aria-hidden` nested control was wrongly reported as a `fail` (nested interactive controls), even though a descendant that is never actually rendered or exposed to AT creates no real ambiguity for any user — it isn't there to be confused with the outer control. Found while extending direct coverage of this rule. Both the outer candidate and its nested-descendant search now filter through `isAccTreeEligible`; a nested control that is `aria-hidden` but *still tabbable* (a real, separately-flagged anti-pattern this engine's `aria-hidden-focus` rule targets) correctly remains flagged, since `isAccTreeEligible` already treats that specific combination as still AT-reachable in practice. 2 new regression tests.
|
|
68
|
+
- `iframe-focusable-content`'s `hasFocusableCandidate` never checked whether a candidate inside a `tabindex="-1"` frame's embedded document was actually rendered — a `display:none`/`visibility:hidden`/`[hidden]` element (via itself or an ancestor) was wrongly reported as "still reachable by keyboard," even though it is never rendered or focusable in any real browser. Since the embedded document is a distinct realm (this rule's own header comment already explains why it can't reuse the outer document's shared eligibility helpers), added a small self-contained rendering check instead — deliberately checking only genuine non-rendering, not `aria-hidden`, since `aria-hidden` alone doesn't remove a real browser's native tab-order reachability (the same `aria-hidden-focus` anti-pattern noted above), so an `aria-hidden`-but-visually-rendered candidate correctly stays flagged. 3 new regression tests.
|
|
69
|
+
- `aria-helpers.js`'s `hasAccessibleNameHint` (decides whether a `<section>` resolves to the `'section[named]'` role key, whose `ALLOWED_ROLES_BY_ELEMENT` entry is the only one that permits `role="region"`) only checked `aria-label`/`aria-labelledby`, not `title` — inconsistent with this same engine's own `getLandmarkNameInfo` (`aria-label` → `aria-labelledby` → `title`), which the 7 manual landmark-check rules already correctly delegate to after a prior fix (verified against a reference engine and a real page, DuckDuckGo's `<nav title="navigation">`). A `<section title="...">` named only via `title` was wrongly `fail`ed by `aria-allowed-role` for an explicit `role="region"` restatement, even though this engine's own landmark rules already treat a title-named section as a real, region-eligible landmark. `hasAccessibleNameHint` now matches `getLandmarkNameInfo`'s precedence. 1 new regression test.
|
|
70
|
+
- `contrast-helpers.js`'s `getComputabilityBlocker` treated `backdrop-filter` the same as plain `filter`/`mix-blend-mode`/ancestor `opacity` — none occludable by a closer, fully-opaque ancestor background, per the reasoning in the `2026-08-01` `background-image` occlusion fix above (the `[1.3.0]` entry below). That reasoning doesn't apply to `backdrop-filter`: unlike `filter`/`mix-blend-mode` (compositing-GROUP operations on the element's own rendered subtree, which a closer opaque layer sits *inside* and can't escape), `backdrop-filter` samples whatever is already rendered *behind* the element — a closer-to-`el` fully-opaque `background-color` paints *over* that filtered result at `el`'s screen position and hides it completely, the same physical occlusion `background-image` gets. Confirmed with a live Chromium repro (not just spec-reading, per this engine's no-false-positives bar): a `backdrop-filter: blur()` ancestor containing an inner fully-opaque `background-color` div renders that div pixel-flat, zero blur bleed-through, while sibling content without that opaque layer clearly shows the blurred backdrop. `backdrop-filter` now participates in the same `paintOccluded` short-circuit as `background-image`/gradient; plain `filter`/`mix-blend-mode`/`opacity` remain unconditional blockers, unchanged. 4 new regression tests (2 in `tests/contrast-helpers-dom.test.js`, 2 in `tests/engine-checks/automatic/contrast-computable.test.js`): closer-opaque-occludes-backdrop-filter, semi-transparent-does-NOT-occlude (regression guard), and confirming plain `filter` is unaffected.
|
|
71
|
+
- `aria-helpers.js`'s `validateAttrValue` treated an explicitly-EMPTY idref/idref-list ARIA attribute value (e.g. `aria-describedby=""`, `aria-activedescendant=""`) as invalid (`expected-single-idref`/`empty-idref-list`) — a false positive. A widely-used reference engine's own standards table sets `allowEmpty: true` on every idref/idref-list ARIA attribute with zero exceptions (verified across its whole bundled source: `aria-activedescendant`, `aria-controls`, `aria-describedby`, `aria-details`, `aria-errormessage`, `aria-flowto`, `aria-labelledby`, `aria-owns`), treating an empty value as a deliberate "no reference" rather than a broken one. Found while re-examining an existing `rule-mapping.js` scope note in the comparisons repo that had flagged this exact question as unverified; confirmed live on chase.com's login form, which ships `aria-describedby=""` unconditionally on its username/password inputs (a common React/Vue conditionally-empty-attribute templating pattern, not a markup error). Both the `idref` and `idref-list` cases now treat an empty value as valid; the already-verified partial-dangling-idref-list behavior (only flag when NONE of the space-separated ids resolve) is unchanged. 4 new regression tests (`tests/core/aria-helpers.test.js`, `tests/engine-checks/automatic/aria-valid-attr-value.test.js`); 1 existing fixture case (`avav_case_10`) flipped from expected-FAIL to expected-PASS.
|
|
72
|
+
- `svg-text-alternative-present`'s applicability gate only recognized `role="img"` as an "intent to convey" signal for an `<svg>` root element, missing `role="graphics-symbol"` and `role="graphics-document"` — the other two ARIA Graphics-module roles a widely-used reference engine's `svg-img-alt` rule also treats as name-requiring (`selector: '[role="img"], [role="graphics-symbol"], svg[role="graphics-document"]'`). A previously-documented, small (3-record) known scope gap in the comparisons repo's `rule-mapping.js`; closed by adding both roles to the same applicability check `role === 'img'` already gated on. Deliberately still scoped to the `<svg>` root element only, not arbitrary `role="graphics-symbol"` descendants nested inside an `<svg>` (a separate, broader feature this check has never covered, not attempted here). 4 new regression tests plus 2 new fixture cases (`svg_case_25`/`26`).
|
|
73
|
+
- `aria-prohibited-attr`'s "roleless element" branch (Tier 2, added 2026-07-31) only recognized a small, curated allowlist of NATIVE HTML tags as having no implicit role (`ROLELESS_NATIVE_TAGS`) — it never considered autonomous CUSTOM elements (author-defined, hyphenated web-component tags), which per the Custom Elements spec always have no implicit ARIA role, with none of the conditional-role nuance that makes native tags like `<a>`/`<section>`/`<form>` deliberately excluded from a blanket check. A real-world, generalizable gap, not a rare edge case: found via a `KNOWN_SCOPE_DIFFERENCE` re-audit against a widely-used reference engine, confirmed on rottentomatoes.com's homepage (106 occurrences of `<play-button aria-label="Play ...">` on one page alone) and Angular Material's demo site (`<app-carousel aria-label="Guides">`). Fixed by adding a second, separate applicability path: any tag containing a hyphen (the Custom Elements spec's mandatory naming requirement) EXCEPT the small, spec-reserved set of legacy hyphenated SVG/MathML tag names that predate Custom Elements and are not actually custom elements (`annotation-xml`, `color-profile`, `font-face` and its `-src`/`-uri`/`-format`/`-name` variants, `missing-glyph`). 5 new regression tests.
|
|
74
|
+
- `isAccTreeEligible` (shared `dom-helpers.js`, backing `queryAllSmart`'s default hidden-content policy used by nearly every rule) 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 are genuinely different states: 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 (hides everything) — confirmed live via `getComputedStyle` on a real `hidden="until-found"` element (`display: block`, `content-visibility: hidden`). A widely-used reference engine's own hidden-detection makes this exact self-vs-ancestor distinction for `content-visibility: hidden`. Fixed with a self-only override: when the element carrying `hidden="until-found"` is the one being checked (not a true ancestor of some other node), it's no longer excluded; a real descendant of such an element, or any element with a plain `hidden` attribute (any value other than "until-found"), is unaffected and still excluded exactly as before. Confirmed real, positive, measurable impact live on irs.gov's FAQ accordion panels (`<div hidden="until-found" aria-labelledby="...">`) — 4 other rule pairs (`aria-allowed-attr`, `aria-valid-attr`, `aria-valid-attr-value`, `aria-checked-state-mismatch`) now correctly evaluate these panels where before the fix surea11y had no record for them at all. Honest caveat: the originally-motivating `aria-prohibited-attr` case on the SAME irs.gov panels is NOT resolved by this fix alone — those specific panels also sit inside a `role="tabpanel"` ancestor, which trips a separate, pre-existing, unrelated exemption in `aria-prohibited-attr.js` ("roleless helper node inside a real widget — not flagged") that was not touched here; whether that exemption is too broad is a separate, debatable design question left for a future round, not addressed as part of this fix. 5 new regression tests (`tests/core/dom-helpers-eligibility.test.js`, `tests/engine-checks/automatic/aria-prohibited-attr.test.js`); full suite plus fixtures/real-world/live corpus regression passes clean.
|
|
75
|
+
- `presentation-role-conflict-manual`'s conflicting-attribute check treated `aria-hidden="true"` (the exact valid truthy value) the same as any other global ARIA attribute, flagging it as "restoring the implicit role and cancelling the presentational intent" — but that consequence can never actually happen: `aria-hidden="true"` unconditionally removes the element (and its "restored" role, and any OTHER conflicting attribute alongside it, e.g. `aria-label`) from the accessibility tree regardless of role, so no assistive technology ever sees the thing this check warned about. Found while investigating the comparisons repo's cross-engine report: a reference engine's own `presentation-role-conflict` rule uses its default `excludeHidden: true` gather-time filter, which drops any `aria-hidden="true"` element before that rule's own equivalent check ever runs — chasing why surfaced that surea11y's flag on the same pattern was itself substantively wrong, not just differently scoped. Confirmed extremely common on real pages: decorative-icon double-hiding via `alt=""` + `aria-hidden="true"` together (or `role="presentation"`/`role="none"` + `aria-hidden="true"`), e.g. `<svg role="presentation" aria-hidden="true">` icon patterns. Fixed: an element's own `aria-hidden="true"` now clears its conflicting-attribute list entirely (any other attribute present alongside it is equally inert for the same reason); focusability is unaffected and still flags on its own, since a keyboard user can still tab onto an `aria-hidden="true"` focusable element regardless (the `aria-hidden-focus` anti-pattern, a real, separate hazard). An `aria-hidden=""` (empty/invalid value — does not hide) is unaffected and still triggers normally, matching the original Slack-homepage case this attribute was added for. 6 new regression tests, 3 new fixture cases (`prc_case_11`/`12`/`13`).
|
|
76
|
+
- `listitem-parent-valid`'s applicability check inspected the `<li>`'s parent for an explicit role override but never the `<li>` element 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 — the same "any explicit role wins over the tag's native role" principle this check's own header comment already applied to the parent side, just never extended to the element itself. Found while investigating the comparisons repo's cross-engine report: a reference engine's own `listitem` rule's `no-role-matches` matcher excludes ANY `<li>` carrying a role attribute (any value) from candidacy entirely, which is what surfaced that surea11y's broader evaluation of those elements was itself wrong, not just a scope difference. Confirmed extremely common on real pages: HubSpot-generated nav menus (`role="none"`, one page alone had 745 occurrences), Docusaurus-style tab lists (`role="tab"`), Ant Design menu dropdowns (`role="presentation"`/`role="menuitem"`), carousel indicator dots, GitHub's file-tree sidebar (`role="treeitem"`). Fixed: an `<li>` whose own explicit role isn't empty or `"listitem"` is no longer evaluated at all; an explicit `role="listitem"` restatement is unaffected (a no-op, not an override) and still gets the normal parent-validity check. 6 new regression tests, 4 new fixture cases (`lpv_case_10`–`13`).
|
|
77
|
+
- `aria-helpers.js`'s `ALLOWED_ROLES_BY_ELEMENT.button` list was missing `gridcell`, `separator`, `slider`, and `treeitem` — a real false positive, not just a scope gap. Found while investigating the comparisons repo's cross-engine report and independently confirmed against the actual W3C "ARIA in HTML" normative table for `<button>` (not just a reference engine's own implementation of it, specifically to rule out the reference engine itself being wrong before matching its behavior) — all 14 roles it lists, including the 4 missing ones, are the correct permitted set. Concretely found via MUI's DatePicker calendar, which renders every day cell as `<button role="gridcell" data-testid="day">` — a standard, ARIA-Authoring-Practices-Guide-recommended composite-grid pattern, 92 false-positive occurrences on one page alone. Fixed by adding the 4 missing roles to the list. 6 new regression tests, 5 new fixture cases (`aar_case_46`–`50`).
|
|
78
|
+
- `focus-order-semantics-manual`'s `NON_INTERACTIVE_ROLES` set included `region`, flagging a tabbable `role="region"` (`<div role="region" tabindex="0">`) as a meaningless tab stop — but that's a real, common, WCAG 2.1.1/2.1.3-grounded pattern, not a mistake. Found via OneTrust's near-ubiquitous cookie-consent banner (`<div id="onetrust-banner-sdk" role="region" tabindex="0">`), plus carousels, a GitHub resizable filter pane, and a PrimeReact toast region. Verified via two independent sources before removing the flag, not just because a reference engine happened to disagree: (1) this engine's own sibling check, `scrollable-region-focusable`, already documents WCAG 2.1.1/2.1.3 as the normative basis for exactly this pattern (a region deliberately made keyboard-reachable), so flagging it here was internally inconsistent within the same engine, independent of any other tool's behavior; (2) a reference engine's own equivalent rule independently allowlists `region` (along with `navigation`/`status`/`tabpanel`) via a dedicated role table, confirming this is a deliberate, recognized exemption elsewhere too, not an accidental convenience to copy. Fixed by removing only `region` from the set — `navigation`/`status`/`tabpanel` remain flagged, since there's no confirmed over-flagging evidence for those three yet. 2 new regression tests, 1 new fixture case.
|
|
79
|
+
- `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 role tokens only, missing the three WAI-ARIA Graphics Module 1.0 roles (`graphics-document`/`graphics-object`/`graphics-symbol`) — a separate W3C Recommendation, same REC tier as core ARIA 1.2 itself, with a companion Graphics Accessibility API Mappings 1.0 REC defining real AT support (verified 2026-08-05 directly against both specs, not assumed from a reference engine's behavior). `aria-roles-valid` wrongly reported these as `ARIA_ROLE_INVALID` ("not a recognized ARIA role") even though they're genuine, AT-recognized tokens. Confirmed live on behance.net's primary nav (`role="graphics-symbol img"` on visible, non-decorative `<svg>` icons — not hidden, reaching real users) and notion.so's icon set; previously documented as a known, deliberately-deferred gap in the comparisons repo's `rule-mapping.js` (found via StubHub's `<img role="graphics-symbol">`). Traced the blast radius before fixing: all 8 rules gating on `isValidConcreteRole` (`aria-required-parent`, `aria-required-attr`, `aria-required-children`, `aria-allowed-attr`, `aria-allowed-role`, `aria-prohibited-children`, `aria-prohibited-attr`, `aria-deprecated-role`) key their own per-role tables by explicit role name and default to skipping/no-op for a role absent from that table, so newly recognizing these 3 roles as "concrete" cannot introduce a new false positive in any of them — confirmed by reading each one's table-lookup code, not assumed. Deliberately did NOT also add Digital Publishing WAI-ARIA (`doc-abstract` etc., a similarly real, REC-track module) in the same pass — no `doc-*` usage found anywhere in the live/real-world corpus, unlike `graphics-*` which has confirmed traffic; left for a future round if evidence turns up. 5 new regression tests (`tests/core/aria-helpers.test.js`, `tests/engine-checks/automatic/aria-roles-valid.test.js`) plus 1 new fixture case (`arv_case_07`); full suite plus fixtures/real-world/live corpus regression passes clean (real-world: 118/118 pages, 22 actionable divergences unchanged; live: 225/225 pages, confirmed the two newly-surfaced divergences from this round's first full unfiltered live rescan in a while are unrelated to this fix — see the comparisons repo's project notes).
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
- **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 of this package, pulling **39 transitive packages / ~25 MB** into every install — including all six first-party consumers (`@surea11y/playwright`, `puppeteer`, `selenium`, `cypress`, `webdriverio`, `test-matchers`), none of which ever load it, because they drive real browsers. The engine reads a DOM it is handed and never constructs one; only the CLI needed to parse HTML *into* a DOM, and therefore needed jsdom. Splitting it puts that cost solely on people who install the CLI, and makes `"dependencies": {}` literally true rather than a claim needing a footnote. This mirrors the convention every binding in the ecosystem already follows — the heavy environment-specific driver (`playwright`, `puppeteer`, `cypress`, …) is a peer/optional install, never a transitive one. **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 off this package, so the README's Quick Start worked after `npm install @surea11y/core` alone; it now needs an explicit `npm install jsdom`. No rule logic, rule ID, outcome, or result-shape changed — `require('@surea11y/core')` returns exactly the same object it did in 1.3.0. Shipped in a minor rather than a major deliberately: the package has no external consumers at this version, and all six first-party ones were verified unaffected (none references jsdom in source, and `test-matchers` already declares its own).
|
|
83
|
+
- `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 — which meant the engine's file layout could not be reorganised without risking someone's import. The two deep imports that were actually documented move from `@surea11y/core/src/baseline`/`src/report` to `@surea11y/core/baseline`/`report` (`docs/INTEGRATION.md`, `docs/REPORT.md` updated); `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*` and the generated `src/core.js` are now sealed and resolve with `ERR_PACKAGE_PATH_NOT_EXPORTED`. The `<script src="node_modules/@surea11y/core/surea11y.browser.js">` form is a filesystem path, not module resolution, and is unaffected. Documented as a versioned contract in `docs/API_STABILITY.md`'s new "Package entry points" section.
|
|
84
|
+
- `package.json` gained a `keywords` field (18 entries) — the package previously had none at all and was effectively unfindable via npm search. Claims are limited to what the catalog actually backs: WCAG 2.0/2.1/2.2 are tagged across all 125 rules, so those are included; `rgaa` (zero occurrences anywhere in the repo) and `act-rules` (one source comment, no tags or mappings) were deliberately left out rather than claimed. `description` rewritten from the generic "Lightweight DOM rules accessibility core with modular rules." to lead with the `cantTell` differentiator and the zero-dependency property.
|
|
85
|
+
- `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/i18n/` (359 KB — all four locales are inlined), `src/core/` (296 KB), `src/coverage/`, `src/catalogs/`, `src/policy/`, and the `rules-and-tags.full.{csv,json}` data files. `src/checks/**` is deliberately **kept** despite also being a build input — the generated bundle `require()`s all 125 rule files at runtime, so dropping it would break every consumer. Published package: 175 → 152 files, 7.04 → 6.31 MB unpacked, 1.40 → 1.23 MB packed. Verified by installing the actual tarball into a clean project and running a real scan through every declared entry point.
|
|
86
|
+
|
|
87
|
+
- 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.
|
|
88
|
+
|
|
89
|
+
### Removed
|
|
90
|
+
- `bin/core.js` and `docs/CLI.md` — moved to the [`@surea11y/cli`](https://github.com/SureA11y/cli) package (see above). The binary name is unchanged (`surea11y`), as are all its flags, exit codes, and output formats; only the package you install it from changed.
|
|
91
|
+
|
|
7
92
|
## [1.3.0] - 2026-08-02
|
|
8
93
|
|
|
9
94
|
### Added
|
|
@@ -19,8 +104,8 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
19
104
|
|
|
20
105
|
### Fixed
|
|
21
106
|
- `buildSelector` (shared `src/core/dom-helpers.js`, backs every rule's `occurrence.selector`) built its id/data-testid/name/aria-label anchor selectors — both for the target element itself and for a climbed ancestor — 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 DOM attribute, so any anchor attribute with leading/trailing whitespace produced a selector that could never match its own element. Found 2026-08-02 via the cross-engine comparisons project on Slack's real homepage: 7 promo-card `<header>` elements each sit under a `<div role="region" aria-label="...">` whose templated aria-label ends in a trailing `", "` (a string-concatenation artifact, not a typo) — `el.matches(candidate)` correctly returned false for the trimmed-value candidate, degrading all 7 to `buildSimpleSelector`'s bare-tag-name fallback (`"header"`), a selector that resolves to the *first* `<header>` on the whole page (the real site banner) rather than any of the 7 actual elements — silently pointing any consumer of `occurrence.selector` (this comparisons project's own tooling included) at the wrong element. Fixed by keeping the trimmed value for the uniqueness-index key (unchanged) but embedding the raw, untrimmed attribute value in the actual selector string, across all six anchor sites (the five direct-anchor builders plus the ancestor-climbing anchor). 3 new regression tests (`tests/core/build-selector.test.js`): a direct aria-label anchor, an ancestor aria-label anchor reproducing the Slack shape, and a padded id.
|
|
22
|
-
- `aria-required-parent`'s `hasAcceptableAncestorContext` treated an immediate `role="group"` ancestor as transparent for `listitem`/`treeitem` (continuing the walk past it, per `GROUP_TRANSPARENT_FOR_ROLES`) but never added the tested element's own role to the acceptable-context set at that point, unlike the reference engine's `getMissingContext` it was modeled on — so a standard, arbitrarily-deep ARIA tree (`tree > treeitem > group > treeitem > group > treeitem...`) stopped at the second `treeitem` ancestor and failed, since plain `"treeitem"` was never itself an acceptable context role. Found via a live-DOM cross-engine run on GitHub's PR "Files changed" file-tree sidebar (`github.com/*/pull/*/files`): 40 false-positive `fail` occurrences across nested directory/file `treeitem`s, all real
|
|
23
|
-
- `form-control-programmatic-label-present` (via the shared `labelContributesAccessibleName`, `src/core/dom-helpers.js`) never checked a `<label>`'s own `title` attribute as a last-resort name source — only its ARIA name and its content name — so a structurally-associated `<label for>`/wrapping `<label>` with empty content but a non-empty `title` (accname's title-fallback step, which applies to the label element itself, not just the control it labels) was treated as not contributing a name at all, wrongly failing an otherwise-correctly-labeled control. Found via a full fixtures cross-engine regression: `slider-name-present-all-scenarios.html`'s case_22 (`<label for="..." title="Search"></label>`, designed for a different rule but exercised here too since the cross-engine tool runs every rule against every fixture) is explicitly documented as an intentional `PASS`, and
|
|
107
|
+
- `aria-required-parent`'s `hasAcceptableAncestorContext` treated an immediate `role="group"` ancestor as transparent for `listitem`/`treeitem` (continuing the walk past it, per `GROUP_TRANSPARENT_FOR_ROLES`) but never added the tested element's own role to the acceptable-context set at that point, unlike the reference engine's `getMissingContext` it was modeled on — so a standard, arbitrarily-deep ARIA tree (`tree > treeitem > group > treeitem > group > treeitem...`) stopped at the second `treeitem` ancestor and failed, since plain `"treeitem"` was never itself an acceptable context role. Found via a live-DOM cross-engine run on GitHub's PR "Files changed" file-tree sidebar (`github.com/*/pull/*/files`): 40 false-positive `fail` occurrences across nested directory/file `treeitem`s, all real reference-engine `pass`. `hasAcceptableAncestorContext` now mirrors the reference engine's actual behavior: passing a transparent `group` ancestor also adds the element's own role to the acceptable set from that point on (a lazily-cloned working copy, never mutating the caller's shared `Set`). 1 new regression test (multi-level nested treeitem).
|
|
108
|
+
- `form-control-programmatic-label-present` (via the shared `labelContributesAccessibleName`, `src/core/dom-helpers.js`) never checked a `<label>`'s own `title` attribute as a last-resort name source — only its ARIA name and its content name — so a structurally-associated `<label for>`/wrapping `<label>` with empty content but a non-empty `title` (accname's title-fallback step, which applies to the label element itself, not just the control it labels) was treated as not contributing a name at all, wrongly failing an otherwise-correctly-labeled control. Found via a full fixtures cross-engine regression: `slider-name-present-all-scenarios.html`'s case_22 (`<label for="..." title="Search"></label>`, designed for a different rule but exercised here too since the cross-engine tool runs every rule against every fixture) is explicitly documented as an intentional `PASS`, and the reference engine's `label` rule already agreed — only this rule's own label-name check was missing the fallback. Now also checks `getNonEmptyTitle(lab)` after the aria-name and content-name checks come back empty. 1 new regression test.
|
|
24
109
|
- `embed`/`object`/`video-poster-text-alternative-present`'s failing-occurrence `hint` text omitted `title` as a remediation option, even though each rule's own documented `@expectation` explicitly lists a title attribute as a valid "best-effort fallback" mechanism and each rule's own `runInPage` accepts it (`mechanism: 'title'`) — the hint just never mentioned it, understating the easiest fix available to authors. Found via the same systematic check as the `*-name-present` hint fix above, applied to the other `*-text-alternative-present` rules that accept a weak `title` fallback. Fixed in the rule source, `src/i18n/en.js`, and `src/i18n/fr.js`.
|
|
25
110
|
- `listbox`/`searchbox`/`spinbutton`/`textbox`/`combobox`/`meter`/`progressbar-name-present`'s failing-occurrence `hint` text told authors to "provide visible text that is not hidden from assistive technologies" as a valid fix — but all seven of these roles are deliberately name-from-author-only per WAI-ARIA (verified against a reference engine's own checks; each rule's own `evaluate()`/`hasName()` explicitly has no content-based naming branch, several with their own real-world false-positive comments explaining exactly why). A developer following the hint would add visible text, rerun the scan, and see the same failure, since content was never a recognized mechanism for these roles — the hint sent them down a dead end. Found via a systematic diff of the `*-name-present` rule family (the same technique that found the `contrast-minimum`/`contrast-enhanced` occurrence-shape bug above): the family splits cleanly into roles that support content-based naming (`menuitem`/`option`/`tab`/`tooltip`/`treeitem`/`summary`, whose hints correctly mention visible text) and roles that don't (these seven, whose hints incorrectly did). Fixed in the rule source, `src/i18n/en.js`, and `src/i18n/fr.js` (the actual localized strings shown to users, which had the same bug, translated) — all three needed to change since the rule's own inline `hint` and the i18n bundle are independently duplicated copies. New regression tests assert the corrected hint text for a case with real visible text present that still, correctly, fails.
|
|
26
111
|
- `contrast-minimum` attached a failing occurrence's element metadata (`selector`/`tagName`) as a non-standard top-level `occurrence.node` field — the only place in the entire rule catalog that did this; every other rule, including its own twin `contrast-enhanced` (same threshold logic, different WCAG level), nests this kind of diagnostic metadata under `occurrence.data.details`, the documented convention. Found while extending direct-unit-test coverage of the two rules and diffing them line-by-line as near-identical twins — a difference that shouldn't have existed. Now matches `contrast-enhanced`'s `occurrence.data.details.node` shape exactly. No rule/test previously relied on the old `occurrence.node` field's existence.
|
package/README.md
CHANGED
|
@@ -1,33 +1,57 @@
|
|
|
1
1
|
# @surea11y/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@surea11y/core#gh-dark-mode-only)
|
|
4
|
+
[](https://www.npmjs.com/package/@surea11y/core#gh-light-mode-only)
|
|
5
|
+
[](https://www.npmjs.com/package/@surea11y/core)
|
|
6
|
+
[](package.json)
|
|
7
|
+
[](LICENSE)
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
identify objective accessibility issues early in the software lifecycle.
|
|
7
|
-
It runs against either static HTML or fully rendered browser pages,
|
|
8
|
-
producing deterministic, standards-traceable results that are suitable
|
|
9
|
-
for local development, automated testing and CI/CD pipelines.
|
|
9
|
+
> **Accessibility testing that tells you what it can't tell you.**
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
reporting tools.
|
|
11
|
+
surea11y is an accessibility engine for teams that need to know what automated
|
|
12
|
+
testing *can't* establish. It reports findings, non-findings, and — unusually —
|
|
13
|
+
explicit uncertainty, so results are auditable rather than reassuring.
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
*Sure* means certainty about what is known, and honesty about what isn't.
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
results
|
|
17
|
+
It runs against either static HTML or fully rendered browser pages, producing
|
|
18
|
+
deterministic, standards-traceable results suitable for local development,
|
|
19
|
+
automated testing and CI/CD pipelines.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
Unlike browser extensions or cloud-based services, surea11y is a library-first
|
|
22
|
+
project. You install it, run it where your code runs, and receive structured
|
|
23
|
+
results that can be consumed by people, scripts or reporting tools.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
rule makes a single deterministic decision. If a violation can be
|
|
26
|
-
proven, the outcome is `fail`. If human judgement is required, the
|
|
27
|
-
engine reports `cantTell` instead of guessing.
|
|
25
|
+
## What automated testing can and cannot do
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
Automated tools are commonly reckoned to catch somewhere around a third of WCAG
|
|
28
|
+
issues. The remainder require human judgement. That ceiling is a property of
|
|
29
|
+
static analysis itself, not a gap in any particular tool.
|
|
30
|
+
|
|
31
|
+
surea11y's answer is to be explicit about which side of that line every result
|
|
32
|
+
falls on. Each rule makes a single deterministic decision:
|
|
33
|
+
|
|
34
|
+
- **`fail`** — a violation provable from the DOM. Reserved for objective,
|
|
35
|
+
normative cases.
|
|
36
|
+
- **`pass`** — this rule's specific condition is met. Not a claim that the page
|
|
37
|
+
is accessible.
|
|
38
|
+
- **`cantTell`** — a human has to decide this, and the result says what was
|
|
39
|
+
ambiguous.
|
|
40
|
+
- **`notApplicable`** — the rule's precondition isn't present.
|
|
41
|
+
|
|
42
|
+
`cantTell` is the point of the project. An engine that quietly discards what it
|
|
43
|
+
cannot determine produces a shorter report and a false sense of coverage.
|
|
44
|
+
|
|
45
|
+
## What this engine does not detect
|
|
46
|
+
|
|
47
|
+
Keyboard traps, reflow and clipping at 400% zoom, anything that only exists
|
|
48
|
+
after a click or an async load, and judgement calls such as whether a heading is
|
|
49
|
+
meaningful — these lie outside what a static DOM scan can establish. Each is a
|
|
50
|
+
reasoned decision rather than an oversight.
|
|
51
|
+
|
|
52
|
+
[`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md) lists them in full with the
|
|
53
|
+
reasoning for each. A `pass` from this engine — or from any automated tool — is
|
|
54
|
+
never a substitute for the manual review WCAG itself requires.
|
|
31
55
|
|
|
32
56
|
### Key principles
|
|
33
57
|
|
|
@@ -44,7 +68,9 @@ automated quality gates.
|
|
|
44
68
|
- **Extensible.** Add custom rules, register policies and filter scans
|
|
45
69
|
by rule IDs, tags or WCAG version.
|
|
46
70
|
- **Localized reporting.** Human-readable messages can be translated
|
|
47
|
-
without affecting machine-readable data.
|
|
71
|
+
without affecting machine-readable data. Ships with `en`, `fr`, `de`,
|
|
72
|
+
and `es` today — see [`docs/I18N.md`](./docs/I18N.md) to use one or
|
|
73
|
+
contribute another.
|
|
48
74
|
|
|
49
75
|
---
|
|
50
76
|
|
|
@@ -92,6 +118,26 @@ For a detailed comparison of both execution models, see
|
|
|
92
118
|
|
|
93
119
|
---
|
|
94
120
|
|
|
121
|
+
## Which package do I need?
|
|
122
|
+
|
|
123
|
+
surea11y is a family of packages sharing one engine. Install the one that
|
|
124
|
+
matches how you test — each pulls in `@surea11y/core` for you.
|
|
125
|
+
|
|
126
|
+
| I want to… | Install |
|
|
127
|
+
|---|---|
|
|
128
|
+
| Add accessibility checks to **Playwright** tests | [`@surea11y/playwright`](https://github.com/SureA11y/playwright#readme) |
|
|
129
|
+
| …**Puppeteer** | [`@surea11y/puppeteer`](https://github.com/SureA11y/puppeteer#readme) |
|
|
130
|
+
| …**Selenium** | [`@surea11y/selenium`](https://github.com/SureA11y/selenium#readme) |
|
|
131
|
+
| …**Cypress** | [`@surea11y/cypress`](https://github.com/SureA11y/cypress#readme) |
|
|
132
|
+
| …**WebdriverIO** | [`@surea11y/webdriverio`](https://github.com/SureA11y/webdriverio#readme) |
|
|
133
|
+
| Assert in **Jest or Vitest** component tests | [`@surea11y/test-matchers`](https://github.com/SureA11y/test-matchers#readme) |
|
|
134
|
+
| Scan static HTML from a **terminal or CI pipeline** | [`@surea11y/cli`](https://github.com/SureA11y/cli#readme) |
|
|
135
|
+
| Run the engine against **a DOM I already have** | `@surea11y/core` (this package) |
|
|
136
|
+
|
|
137
|
+
The rest of this README covers `@surea11y/core` itself.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
95
141
|
## Installation
|
|
96
142
|
|
|
97
143
|
Install the core package from npm:
|
|
@@ -100,10 +146,13 @@ Install the core package from npm:
|
|
|
100
146
|
npm install @surea11y/core
|
|
101
147
|
```
|
|
102
148
|
|
|
103
|
-
The core engine has
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
149
|
+
The core engine has **zero runtime dependencies**. Installing it pulls
|
|
150
|
+
nothing else into your tree, which keeps it lightweight and suitable for
|
|
151
|
+
embedding into your own tooling.
|
|
152
|
+
|
|
153
|
+
The engine needs a DOM to read, but it never creates one — you supply it,
|
|
154
|
+
whether that's jsdom, a Playwright page, or the live document in a
|
|
155
|
+
browser. That is why nothing is installed on your behalf.
|
|
107
156
|
|
|
108
157
|
---
|
|
109
158
|
|
|
@@ -111,18 +160,21 @@ suitable for embedding into your own tooling.
|
|
|
111
160
|
|
|
112
161
|
### CLI
|
|
113
162
|
|
|
114
|
-
The CLI
|
|
163
|
+
The CLI ships as a separate package, [`@surea11y/cli`](https://www.npmjs.com/package/@surea11y/cli),
|
|
164
|
+
so that installing the engine never pulls a DOM implementation into
|
|
165
|
+
projects that already have one:
|
|
115
166
|
|
|
116
167
|
```bash
|
|
117
|
-
npx @surea11y/
|
|
118
|
-
npx @surea11y/
|
|
168
|
+
npx @surea11y/cli scan ./index.html
|
|
169
|
+
npx @surea11y/cli scan https://example.com/
|
|
119
170
|
```
|
|
120
171
|
|
|
121
172
|
The CLI analyses static HTML. It does not execute client-side
|
|
122
173
|
JavaScript, making it ideal for static sites and server-rendered
|
|
123
174
|
applications.
|
|
124
175
|
|
|
125
|
-
For available options, exit codes and advanced usage, see
|
|
176
|
+
For available options, exit codes and advanced usage, see the
|
|
177
|
+
[CLI documentation](https://github.com/SureA11y/cli#readme).
|
|
126
178
|
|
|
127
179
|
---
|
|
128
180
|
|
|
@@ -133,7 +185,12 @@ surea11y exposes two entry points depending on where your code executes.
|
|
|
133
185
|
#### Node.js + jsdom
|
|
134
186
|
|
|
135
187
|
Use `runDomRulesInPage()` when your application already has a DOM
|
|
136
|
-
available through jsdom.
|
|
188
|
+
available through jsdom. jsdom is not a dependency of this package, so
|
|
189
|
+
install it alongside if you don't already have it:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm install jsdom
|
|
193
|
+
```
|
|
137
194
|
|
|
138
195
|
```js
|
|
139
196
|
const { JSDOM } = require("jsdom");
|
|
@@ -331,7 +388,6 @@ and progressively explore more advanced features.
|
|
|
331
388
|
|---|---|
|
|
332
389
|
| `docs/OUTPUT_SCHEMA.md` | Complete description of every field returned by the engine. |
|
|
333
390
|
| `docs/API_STABILITY.md` | Semver guarantees on the result shape, and the rule-ID deprecation policy. |
|
|
334
|
-
| `docs/CLI.md` | CLI commands, options, exit codes and examples. |
|
|
335
391
|
| `docs/BASELINE.md` | CI baseline/allowlist: gate builds only on new violations. |
|
|
336
392
|
| `docs/REPORT.md` | Self-contained HTML report: browsable summary, WCAG rollup, filterable occurrence table. |
|
|
337
393
|
| `docs/SARIF.md` | SARIF 2.1.0 report for GitHub Code Scanning and other SARIF dashboards. |
|
|
@@ -405,9 +461,6 @@ implementations and supporting infrastructure remain clearly separated.
|
|
|
405
461
|
```text
|
|
406
462
|
surea11y.browser.js # Generated standalone browser bundle
|
|
407
463
|
|
|
408
|
-
bin/
|
|
409
|
-
core.js # CLI entry point
|
|
410
|
-
|
|
411
464
|
src/
|
|
412
465
|
index.js # Public API
|
|
413
466
|
core.js # Generated runtime bundle
|
|
@@ -485,6 +538,27 @@ supported versions and the preferred disclosure process.
|
|
|
485
538
|
|
|
486
539
|
---
|
|
487
540
|
|
|
541
|
+
## Versioning & stability
|
|
542
|
+
|
|
543
|
+
`@surea11y/core` follows [semantic versioning](https://semver.org/). The result
|
|
544
|
+
shape is a written contract — see [`docs/API_STABILITY.md`](docs/API_STABILITY.md)
|
|
545
|
+
for exactly which fields are covered by semver, what triggers a patch/minor/major
|
|
546
|
+
bump, the release cadence, and the rule-ID deprecation policy.
|
|
547
|
+
|
|
548
|
+
In short: patch and minor releases are always backward-compatible, so a consumer
|
|
549
|
+
pinned to a `^1.y.0` range is never broken by an upgrade within the `1.x` line.
|
|
550
|
+
Correctness fixes ship as patches when ready; feature work is batched into
|
|
551
|
+
periodic minors; breaking changes are reserved for major versions and are rare by
|
|
552
|
+
design.
|
|
553
|
+
|
|
554
|
+
## Maintainer
|
|
555
|
+
|
|
556
|
+
surea11y is built and maintained by [Jorge Rumoroso](https://github.com/rumoroso).
|
|
557
|
+
|
|
558
|
+
Bug reports and rule proposals are welcome via
|
|
559
|
+
[issues](https://github.com/SureA11y/core/issues). For security disclosures see
|
|
560
|
+
[`SECURITY.md`](./SECURITY.md).
|
|
561
|
+
|
|
488
562
|
## License
|
|
489
563
|
|
|
490
564
|
This project is released under the Mozilla Public License 2.0 (MPL-2.0).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
3
|
+
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
// Redirects the pre-1.4.0 `npx @surea11y/core scan ...` that older docs still
|
|
7
|
+
// show. Not named `surea11y`: that belongs to @surea11y/cli, and core is a
|
|
8
|
+
// transitive dependency of every binding, so both would collide in one .bin.
|
|
9
|
+
|
|
10
|
+
process.stderr.write(
|
|
11
|
+
`The surea11y CLI is no longer part of @surea11y/core (moved in 1.4.0).
|
|
12
|
+
|
|
13
|
+
npx @surea11y/cli scan <file-or-url>
|
|
14
|
+
|
|
15
|
+
Install it with: npm install --save-dev @surea11y/cli
|
|
16
|
+
Docs: https://github.com/SureA11y/cli
|
|
17
|
+
`
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
process.exit(2);
|
package/docs/API_STABILITY.md
CHANGED
|
@@ -13,6 +13,22 @@ Removing, renaming, or changing the type/meaning of any of these is a **major**
|
|
|
13
13
|
|
|
14
14
|
This list is deliberately not a new, invented guarantee — it codifies what the 6 real consumers above (and `docs/OUTPUT_SCHEMA.md`'s own worked examples) already depend on today, either directly or as documented shape.
|
|
15
15
|
|
|
16
|
+
## Package entry points (covered by semver)
|
|
17
|
+
|
|
18
|
+
Since 1.4.0 the package declares an explicit `exports` map. These are the only importable paths, and removing or repointing one is a **major** bump:
|
|
19
|
+
|
|
20
|
+
| Specifier | Resolves to | Contents |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `@surea11y/core` | `src/index.js` | the full engine surface (`runDomRulesInPage`, `runa11yCoreInPage`, catalog accessors, …) |
|
|
23
|
+
| `@surea11y/core/baseline` | `src/baseline.js` | `buildBaselineEntries()`, `matchBaseline()` |
|
|
24
|
+
| `@surea11y/core/report` | `src/report.js` | `renderHtmlReport()` |
|
|
25
|
+
| `@surea11y/core/sarif` | `src/sarif.js` | `renderSarifReport()` |
|
|
26
|
+
| `@surea11y/core/browser` | `surea11y.browser.js` | the standalone browser bundle, for bundlers that resolve it as a module |
|
|
27
|
+
|
|
28
|
+
Anything **not** in that table — `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*`, and the generated `src/core.js` itself — is internal. Before 1.4.0 there was no `exports` map, so those paths were technically reachable via deep `require()`; they were never documented as public and are no longer resolvable. The `<script src="node_modules/@surea11y/core/surea11y.browser.js">` form documented in the README is a filesystem path, not module resolution, and is unaffected.
|
|
29
|
+
|
|
30
|
+
Declaring this map is what lets the engine's internal file layout change without a major bump. Note that `src/checks/*` is still *shipped* (the generated bundle `require()`s it at runtime) — shipped is not the same as public.
|
|
31
|
+
|
|
16
32
|
## Explicitly unstable (not covered by semver)
|
|
17
33
|
|
|
18
34
|
- `perfStats` and `ruleTimings` — internal timing/debug counters, only present when `engineOptions.perfStats`/`.profileRules` is set. Shape not covered by this document.
|
|
@@ -27,6 +43,16 @@ This list is deliberately not a new, invented guarantee — it codifies what the
|
|
|
27
43
|
|
|
28
44
|
`engine.schemaVersion` has been `"1.0.0"` since the engine's first release and has never needed a bump — nothing has changed a stable field's shape yet. Adding the `deprecated`/`deprecation` meta fields described below is purely additive (new optional fields, ignored safely by anything not looking for them), so it does **not** warrant a schema bump either — this is the policy's first real application.
|
|
29
45
|
|
|
46
|
+
## Release cadence
|
|
47
|
+
|
|
48
|
+
The version number is the contract — not a measure of how much has changed or how often. surea11y follows semver strictly, so what a bump *means* is fixed regardless of how frequently they happen:
|
|
49
|
+
|
|
50
|
+
- **Patch (`x.y.Z`)** — rule-correctness fixes and documentation updates. Released promptly, as needed, rather than held back; always safe to adopt within a major line.
|
|
51
|
+
- **Minor (`x.Y.0`)** — additive, backward-compatible work: new rules, new locales, new `engineOptions`, new output formats. Batched into periodic releases rather than shipped one change at a time.
|
|
52
|
+
- **Major (`X.0.0`)** — a breaking change to a stable field (see above). Rare by design; the entire point of the stable-fields list is to keep these infrequent and well-signposted.
|
|
53
|
+
|
|
54
|
+
Because every `1.x` release is backward-compatible, a consumer pinned to a `^1.y.0` range is never broken by an upgrade within the line — so a steady stream of patch/minor releases reflects active maintenance and prompt fixes, not instability. Frequency of releases is not a signal of churn; a change to a **major** version is.
|
|
55
|
+
|
|
30
56
|
## Rule-ID deprecation policy
|
|
31
57
|
|
|
32
58
|
A rule can be marked deprecated in its own `meta`:
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
|
2
|
+
|
|
3
|
+
# ARIA deprecation handling
|
|
4
|
+
|
|
5
|
+
WAI-ARIA states two strengths of author rule. SHOULD NOT leaves the usage
|
|
6
|
+
conforming; MUST NOT does not. The engine grades on that distinction:
|
|
7
|
+
deprecated or otherwise discouraged usage resolves to **`cantTell`**, so the
|
|
8
|
+
author decides, and only prohibited usage `fail`s.
|
|
9
|
+
|
|
10
|
+
`aria-query` encodes no deprecation status — its role fields are `props`,
|
|
11
|
+
`requiredProps` and `prohibitedProps`, and it simply drops a deprecated
|
|
12
|
+
property from the role's `props`. Left alone that turns every deprecated
|
|
13
|
+
pairing into a not-allowed `fail`. The deprecation data therefore lives in a
|
|
14
|
+
spec-derived layer the engine owns, the same pattern as `SUPPLEMENTAL_GLOBALS`
|
|
15
|
+
(the 1.3 globals aria-query lacks).
|
|
16
|
+
|
|
17
|
+
## The data layer — `src/core/aria-helpers.js`
|
|
18
|
+
|
|
19
|
+
Four sets and four predicates, consumed by `aria-allowed-attr` (attributes)
|
|
20
|
+
and `aria-deprecated-role` (roles):
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
DEPRECATED_ATTRS // states/properties deprecated on roles that do not
|
|
24
|
+
// support them -> cantTell
|
|
25
|
+
DEPRECATED_ROLES // roles deprecated but valid -> cantTell
|
|
26
|
+
AUTHOR_DISCOURAGED_ROLES // roles reserved for user agents at SHOULD NOT
|
|
27
|
+
// strength -> cantTell
|
|
28
|
+
AUTHOR_PROHIBITED_ROLES // roles carrying an author MUST NOT -> fail
|
|
29
|
+
|
|
30
|
+
isDeprecatedAttr(attr, role) // role param reserved for per-role granularity
|
|
31
|
+
isDeprecatedRole(role)
|
|
32
|
+
isAuthorDiscouragedRole(role)
|
|
33
|
+
isAuthorProhibitedRole(role)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Contents, reconciled against WAI-ARIA 1.2
|
|
37
|
+
|
|
38
|
+
- `DEPRECATED_ATTRS` = `aria-disabled`, `aria-errormessage`, `aria-haspopup`,
|
|
39
|
+
`aria-invalid`. ARIA 1.2 kept these four in the global set as deprecated
|
|
40
|
+
rather than removing them (change log, 07-May-2020), and marks each
|
|
41
|
+
"deprecated on this role" in the characteristics table of every role that
|
|
42
|
+
does not support it — 84 of the 94 role definitions carry at least one such
|
|
43
|
+
annotation. No role treats any of the four as prohibited, so the flat set
|
|
44
|
+
cannot downgrade a prohibited pairing to `cantTell`. These four are also the
|
|
45
|
+
only attributes annotated that way anywhere in the specification.
|
|
46
|
+
- `DEPRECATED_ROLES` = `directory`, the only role marked
|
|
47
|
+
`[Deprecated in ARIA 1.2]`, superseded by `list`.
|
|
48
|
+
- `AUTHOR_DISCOURAGED_ROLES` = `generic`, which §5.4 describes as "primarily
|
|
49
|
+
for implementors of user agents. Authors SHOULD NOT use this role in
|
|
50
|
+
content."
|
|
51
|
+
- `AUTHOR_PROHIBITED_ROLES` is empty. The only author MUST NOT covering roles
|
|
52
|
+
applies to the abstract roles, which `aria-roles-valid` already reports.
|
|
53
|
+
|
|
54
|
+
`aria-dropeffect` and `aria-grabbed` are deprecated in full (since ARIA 1.1)
|
|
55
|
+
but remain global in 1.2 and 1.3, so they are allowed on every role and pass.
|
|
56
|
+
Deliberately: naming them would flag markup no version of the specification
|
|
57
|
+
disallows, and the spec offers no replacement to move to — it records only
|
|
58
|
+
that one is "expected to be replaced by a new feature in a future version".
|
|
59
|
+
|
|
60
|
+
The properties ARIA does prohibit — `aria-label`, `aria-labelledby`, and
|
|
61
|
+
`aria-roledescription` on `generic` — are disjoint from the deprecated set and
|
|
62
|
+
are `aria-prohibited-attr`'s concern.
|
|
63
|
+
|
|
64
|
+
Four pairings pass rather than reporting `cantTell`: `aria-errormessage` and
|
|
65
|
+
`aria-invalid` on `menuitemcheckbox` and `menuitemradio`, which aria-query
|
|
66
|
+
lists among the role's supported properties while ARIA 1.2 marks them
|
|
67
|
+
deprecated there. The generated table follows aria-query, which errs towards
|
|
68
|
+
allowing the usage.
|
|
69
|
+
|
|
70
|
+
## Verifying a spec revision
|
|
71
|
+
|
|
72
|
+
The role characteristics tables in the specification are machine-readable:
|
|
73
|
+
each role section carries `td.role-properties` (supported), `td.role-inherited`
|
|
74
|
+
(inherited) and `td.role-disallowed` (prohibited), and a deprecated entry is
|
|
75
|
+
suffixed "(deprecated on this role in ARIA 1.2)". Extracting those three cells
|
|
76
|
+
per role gives the full allowed/deprecated/prohibited matrix, which can be
|
|
77
|
+
diffed against the generated tables in `aria-allowed-attr` (`GLOBAL_ATTRS`,
|
|
78
|
+
`SUPPORTED_ATTRS_BY_ROLE`) plus `DEPRECATED_ATTRS` to confirm that no pairing
|
|
79
|
+
the specification allows or merely deprecates resolves to `fail`.
|
|
80
|
+
|
|
81
|
+
## Applying a later revision
|
|
82
|
+
|
|
83
|
+
A specification change is a data edit; no rule logic changes:
|
|
84
|
+
|
|
85
|
+
- A deprecation promoted to prohibited: remove it from `DEPRECATED_ATTRS` or
|
|
86
|
+
`DEPRECATED_ROLES` so it falls back to the `fail` path, or move a role into
|
|
87
|
+
`AUTHOR_PROHIBITED_ROLES`.
|
|
88
|
+
- A new deprecation: add it to the relevant set.
|
|
89
|
+
- A deprecation that becomes per-role rather than uniform: `isDeprecatedAttr`
|
|
90
|
+
already receives the role, so a `(role, attr)` map replaces the flat set
|
|
91
|
+
behind the same predicate.
|
|
92
|
+
|
|
93
|
+
Then extend `tests/engine-checks/automatic/aria-allowed-attr.test.js` and
|
|
94
|
+
`aria-deprecated-role.test.js`, run `npm run build`, `node scripts/run-tests.js`,
|
|
95
|
+
`npm run format:check` and `npm run i18n:report`.
|