@surea11y/core 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -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/package.json +50 -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 +15 -51
- 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 +4 -3
- 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 +2 -0
- 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 +2 -0
- package/src/checks/automatic/avoid-inline-spacing.js +3 -2
- package/src/checks/automatic/binary-control-name-present.js +2 -0
- package/src/checks/automatic/button-name-present.js +7 -7
- package/src/checks/automatic/bypass-blocks-present.js +9 -7
- package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
- package/src/checks/automatic/combobox-name-present.js +2 -0
- 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 +10 -10
- 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 +2 -0
- package/src/checks/automatic/form-control-single-label.js +6 -7
- 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 +7 -9
- package/src/checks/automatic/input-image-alt-present.js +4 -6
- package/src/checks/automatic/label-in-name.js +15 -19
- package/src/checks/automatic/language-page-present.js +2 -0
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +2 -0
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +2 -0
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +2 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
- package/src/checks/automatic/meter-name-present.js +4 -3
- package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
- package/src/checks/automatic/object-text-alternative-present.js +2 -0
- package/src/checks/automatic/option-name-present.js +2 -0
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +8 -10
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +2 -0
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +2 -0
- package/src/checks/automatic/spinbutton-name-present.js +2 -0
- package/src/checks/automatic/summary-name-present.js +2 -0
- 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 +2 -0
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +2 -0
- 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 +2 -0
- package/src/checks/automatic/tooltip-name-present.js +2 -0
- package/src/checks/automatic/treeitem-name-present.js +2 -0
- package/src/checks/automatic/valid-lang.js +2 -0
- 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/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 +2 -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 +54 -47
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
- 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 +6772 -2158
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +18 -3
- package/surea11y.browser.js +2731 -999
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- 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,50 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.4.0] - 2026-08-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `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.
|
|
11
|
+
- `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.
|
|
12
|
+
- `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.
|
|
13
|
+
- 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`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `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.
|
|
17
|
+
- `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`).
|
|
18
|
+
- `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.
|
|
19
|
+
- `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`).
|
|
20
|
+
- `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).
|
|
21
|
+
- `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.
|
|
22
|
+
- `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).
|
|
23
|
+
- `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.
|
|
24
|
+
- `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.
|
|
25
|
+
- `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.
|
|
26
|
+
- `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.
|
|
27
|
+
- `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.
|
|
28
|
+
- `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.
|
|
29
|
+
- `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.
|
|
30
|
+
- `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.
|
|
31
|
+
- `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`).
|
|
32
|
+
- `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.
|
|
33
|
+
- `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.
|
|
34
|
+
- `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`).
|
|
35
|
+
- `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`).
|
|
36
|
+
- `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`).
|
|
37
|
+
- `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.
|
|
38
|
+
- `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).
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- **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).
|
|
42
|
+
- `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.
|
|
43
|
+
- `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.
|
|
44
|
+
- `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.
|
|
45
|
+
|
|
46
|
+
- 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.
|
|
47
|
+
|
|
48
|
+
### Removed
|
|
49
|
+
- `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.
|
|
50
|
+
|
|
7
51
|
## [1.3.0] - 2026-08-02
|
|
8
52
|
|
|
9
53
|
### Added
|
|
@@ -19,8 +63,8 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
19
63
|
|
|
20
64
|
### Fixed
|
|
21
65
|
- `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
|
|
66
|
+
- `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).
|
|
67
|
+
- `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
68
|
- `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
69
|
- `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
70
|
- `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`:
|
package/docs/CI_INTEGRATIONS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CI/CD pipeline integrations
|
|
2
2
|
|
|
3
|
-
Ready-to-paste templates wrapping the [CLI](
|
|
3
|
+
Ready-to-paste templates wrapping the [CLI](https://github.com/SureA11y/cli#readme) (`npx @surea11y/cli scan ...`) in GitHub Actions and Bitbucket Pipelines. If you're calling the library directly from your own Node script instead of the CLI, see [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result) instead — this page is specifically about the CLI as a pipeline step.
|
|
4
4
|
|
|
5
5
|
All of these rely on the CLI's own exit codes (`0` clean, `1` at least one — or one *new*, with `--baseline` — `fail` outcome, `2` a usage/scan error) to gate the pipeline; no extra scripting is required for basic pass/fail gating.
|
|
6
6
|
|
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
with:
|
|
22
22
|
node-version: 20
|
|
23
23
|
- run: npm ci && npm run build # produce whatever static HTML you're scanning
|
|
24
|
-
- run: npx @surea11y/
|
|
24
|
+
- run: npx @surea11y/cli scan ./dist/index.html
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
This fails the job the moment any `fail` outcome is found. For an existing site with pre-existing violations, see the baseline variant below instead of disabling the step.
|
|
@@ -30,13 +30,13 @@ This fails the job the moment any `fail` outcome is found. For an existing site
|
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
32
|
# Once, locally: record every current fail occurrence, commit the file.
|
|
33
|
-
npx @surea11y/
|
|
33
|
+
npx @surea11y/cli scan ./dist/index.html --write-baseline a11y-baseline.json
|
|
34
34
|
git add a11y-baseline.json
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
```yaml
|
|
38
38
|
- run: npm ci && npm run build
|
|
39
|
-
- run: npx @surea11y/
|
|
39
|
+
- run: npx @surea11y/cli scan ./dist/index.html --baseline a11y-baseline.json
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
See [`BASELINE.md`](./BASELINE.md) for what counts as "known" vs. "new", and how to regenerate the file as violations get fixed.
|
|
@@ -63,7 +63,7 @@ jobs:
|
|
|
63
63
|
node-version: 20
|
|
64
64
|
- run: npm ci && npm run build
|
|
65
65
|
- name: Scan (report, don't fail the job here)
|
|
66
|
-
run: npx @surea11y/
|
|
66
|
+
run: npx @surea11y/cli scan ./dist/index.html --baseline a11y-baseline.json --sarif results.sarif
|
|
67
67
|
continue-on-error: true
|
|
68
68
|
id: scan
|
|
69
69
|
- name: Upload SARIF to Code Scanning
|
|
@@ -91,7 +91,7 @@ pipelines:
|
|
|
91
91
|
script:
|
|
92
92
|
- npm ci
|
|
93
93
|
- npm run build
|
|
94
|
-
- npx @surea11y/
|
|
94
|
+
- npx @surea11y/cli scan ./dist/index.html --baseline a11y-baseline.json --html a11y-report.html
|
|
95
95
|
artifacts:
|
|
96
96
|
- a11y-report.html
|
|
97
97
|
```
|
|
@@ -100,4 +100,4 @@ The step fails the pipeline on the CLI's exit code exactly like any other `scrip
|
|
|
100
100
|
|
|
101
101
|
## Free-tier/private-repo minute limits
|
|
102
102
|
|
|
103
|
-
If your pipeline provider's free tier is minute-limited (Bitbucket Pipelines' free tier is 50 build-minutes/month on private workspaces, for example), a `jsdom`-based scan of static/server-rendered HTML (what the CLI does) is far cheaper than driving a real browser — see [
|
|
103
|
+
If your pipeline provider's free tier is minute-limited (Bitbucket Pipelines' free tier is 50 build-minutes/month on private workspaces, for example), a `jsdom`-based scan of static/server-rendered HTML (what the CLI does) is far cheaper than driving a real browser — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md#what-it-can-and-cant-scan) for what that trades away (no client-rendered content, no real CSS layout).
|
package/docs/ENGINE_OPTIONS.md
CHANGED
|
@@ -224,7 +224,7 @@ See the option-by-option table above for anything not shown here, and the `custo
|
|
|
224
224
|
|
|
225
225
|
Every shipped rule is baked into `src/core.js` at build time. `engineOptions.customRules` is the runtime escape hatch: an array of rule descriptors registered for that one call only — nothing is added to the static catalog (`getRulesCatalog()`/`getChecksCatalog()`), and nothing persists between calls. This is deliberate, not a limitation to work around: surea11y already takes fresh `engineOptions` per call with no mutable global config (unlike some other engines, which need a `configure()`/`reset()` step against a shared runtime), and custom rules follow that same per-call model.
|
|
226
226
|
|
|
227
|
-
Calling the library directly is one way in; the CLI also exposes this via `--custom-rules <path>` (a local file, loaded once per scan) — see [
|
|
227
|
+
Calling the library directly is one way in; the CLI also exposes this via `--custom-rules <path>` (a local file, loaded once per scan) — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md#custom-rules).
|
|
228
228
|
|
|
229
229
|
A descriptor has the *same shape as an internal rule module's own export* — if you already know how to write a rule file for this engine, you already know this API:
|
|
230
230
|
|
package/docs/I18N.md
CHANGED
|
@@ -6,10 +6,12 @@ Every rule's title/description, and every occurrence's summary/hint, is localize
|
|
|
6
6
|
|
|
7
7
|
| Locale | File | Keys | Coverage vs. English |
|
|
8
8
|
|---|---|---|---|
|
|
9
|
-
| `en` (English) | `src/i18n/en.js` |
|
|
10
|
-
| `fr` (French) | `src/i18n/fr.js` |
|
|
9
|
+
| `en` (English) | `src/i18n/en.js` | 614 | 100% (the canonical/fallback set) |
|
|
10
|
+
| `fr` (French) | `src/i18n/fr.js` | 614 | 100% |
|
|
11
|
+
| `de` (German) | `src/i18n/de.js` | 614 | 100% |
|
|
12
|
+
| `es` (Spanish) | `src/i18n/es.js` | 614 | 100% |
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
All four locales are fully translated as of this writing. That won't stay automatically true — every time a new rule (or a new i18n key) is added to `en.js`, every other locale needs the same key added, or it silently falls back to English for that string (see the fallback behavior below). `tests/i18n/i18n-locale-completeness.test.js` catches this: it fails the build if a locale listed in `FULLY_TRANSLATED_LOCALES` (currently `fr`, `de`, `es`) is missing any key present in `en.js`, and fails for *any* locale that has an orphaned key not in `en.js` (a sign of a typo or a stale key left behind after a rule was removed). Run `npm run i18n:report` any time to see per-locale coverage.
|
|
13
15
|
|
|
14
16
|
## Selecting a locale
|
|
15
17
|
|
|
@@ -17,7 +19,7 @@ Both locales are fully translated as of this writing. That won't stay automatica
|
|
|
17
19
|
runDomRulesInPage(url, null, { locale: 'fr' }, null);
|
|
18
20
|
```
|
|
19
21
|
|
|
20
|
-
Default is `'en'` if omitted. Any string is accepted — an unrecognized locale (e.g. `'
|
|
22
|
+
Default is `'en'` if omitted. Any string is accepted — an unrecognized locale (e.g. `'ja'`, not yet built) behaves exactly like a locale that's 0% translated: every string falls through to English (see below), not an error.
|
|
21
23
|
|
|
22
24
|
## Fallback behavior (per-string, not per-locale)
|
|
23
25
|
|
|
@@ -40,8 +42,9 @@ Both are included in the result alongside the already-resolved text, so you can
|
|
|
40
42
|
|
|
41
43
|
## Contributing a translation
|
|
42
44
|
|
|
43
|
-
1.
|
|
44
|
-
2.
|
|
45
|
-
3.
|
|
46
|
-
4.
|
|
47
|
-
5.
|
|
45
|
+
1. Scaffold the file: `npm run i18n:new <locale>` (e.g. `npm run i18n:new de`) creates `src/i18n/<locale>.js` with all of `en.js`'s keys already present, each seeded with the English text as a placeholder. It refuses to overwrite an existing locale file unless you pass `--force`.
|
|
46
|
+
2. Replace the placeholder values with real translations, key by key. Leave any you're unsure about as-is for now — a value identical to English is treated as untranslated, not broken (see the fallback behavior above).
|
|
47
|
+
3. Check progress any time with `npm run i18n:report` — it prints, per locale, how many keys have been translated vs. still match the English placeholder, plus any missing or orphaned keys.
|
|
48
|
+
4. You don't need every key on day one — the fallback behavior above means a partial translation degrades gracefully to English per-string, exactly like `fr`/`de`/`es` did during their own early stages. Ship what you have. A partial locale is valid and won't fail `i18n-locale-completeness.test.js` unless you also add it to `FULLY_TRANSLATED_LOCALES` in that file — only do that once `npm run i18n:report` shows 100% coverage.
|
|
49
|
+
5. Keep `{{placeholder}}` tokens (and `{{#foo}}...{{/foo}}` conditional blocks) in translated strings exactly as they appear in the English source — they're substituted/evaluated verbatim regardless of locale (e.g. `{{element}}`, `{{role}}`). Never translate HTML tag/attribute names (`<img>`, `aria-label`, `role="dialog"`, etc.) — they're code identifiers, not prose.
|
|
50
|
+
6. Run `npm run build && npm test` to confirm nothing broke, including locale-completeness.
|
package/docs/INTEGRATION.md
CHANGED
|
@@ -130,7 +130,7 @@ if (failures.length > 0) {
|
|
|
130
130
|
|
|
131
131
|
Notes for CI specifically:
|
|
132
132
|
- `cantTell` outcomes are advisory by design (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md#outcome-values)) — most teams log them without failing the build, since they require human judgment the CI run can't make.
|
|
133
|
-
- For "only fail on *new* violations," the CLI has a built-in baseline/allowlist mechanism (`--write-baseline`/`--baseline`, see [`BASELINE.md`](./BASELINE.md)). Calling the library directly, the same matching logic is available as `buildBaselineEntries(result)`/`matchBaseline(result, baselineEntries)` from `require('@surea11y/core/
|
|
133
|
+
- For "only fail on *new* violations," the CLI has a built-in baseline/allowlist mechanism (`--write-baseline`/`--baseline`, see [`BASELINE.md`](./BASELINE.md)). Calling the library directly, the same matching logic is available as `buildBaselineEntries(result)`/`matchBaseline(result, baselineEntries)` from `require('@surea11y/core/baseline')` — or diff `checksResults` against a saved prior run yourself if your pages don't fit that model (see `BASELINE.md`'s "known limitation").
|
|
134
134
|
- Prefer Pattern 1 (jsdom) in CI unless you specifically need real-browser layout — it avoids the extra weight of a Puppeteer/Playwright + browser-binary install in your pipeline.
|
|
135
135
|
|
|
136
136
|
## Browser extension context
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -14,7 +14,7 @@ surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at
|
|
|
14
14
|
|
|
15
15
|
- **jsdom (Node, no real browser) has no CSS layout engine.** Rules needing real geometry — most notably `target-size-minimum` (WCAG 2.5.8, needs real `getBoundingClientRect()`) — report `notApplicable` under plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 2) to get real findings from these rules.
|
|
16
16
|
- **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec), along with any other subtree hidden via `display:none`, `visibility:hidden`, `[hidden]`, or closed `<details>`, are **excluded from rule evaluation by default** — matching the visibility-aware behavior of other established engines. This is a deliberate default (`engineOptions.includeHiddenElements: false`), not an oversight: hidden content isn't reachable by assistive technology or keyboard until it's shown, so flagging a markup defect inside it by default would often be noise. Set `engineOptions.includeHiddenElements: true` to evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. See [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#engineoptions--the-rest) for the option and exactly which hiding mechanisms it covers.
|
|
17
|
-
- **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/
|
|
17
|
+
- **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/cli scan <url>`) specifically fetches static HTML only, with no JS execution — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. Other engines running inside an actual loaded browser tab see the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with either engine's rule correctness. `aria-checked-state-mismatch` is deliberately `manual`/`cantTell`-capped for this exact reason rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
|
|
18
18
|
|
|
19
19
|
## Deliberately not attempted — judgment calls, not automatable safely
|
|
20
20
|
|
package/docs/REPORT.md
CHANGED
|
@@ -18,7 +18,7 @@ Open `report.html` directly from disk. Works alongside any other output mode —
|
|
|
18
18
|
## Library usage
|
|
19
19
|
|
|
20
20
|
```js
|
|
21
|
-
const { renderHtmlReport } = require('@surea11y/core/
|
|
21
|
+
const { renderHtmlReport } = require('@surea11y/core/report');
|
|
22
22
|
const { runDomRulesInPage } = require('@surea11y/core');
|
|
23
23
|
|
|
24
24
|
const result = runDomRulesInPage(url, null, {}, null);
|