@surea11y/core 1.6.0 → 1.8.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 +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,146 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.8.0] - 2026-10-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Japanese (`ja`) is a shipped locale: `src/i18n/ja.json` translates every key `en.json` has, `{ locale: 'ja' }` and `ja-JP` resolve to it with `engine.locale.reason` `ok` and `primary-subtag`, and the build emits `surea11y.i18n.ja.js` for the standalone bundle, reachable as `@surea11y/core/i18n/ja` through the existing `./i18n/*` export. WCAG terms follow WAIC's Japanese translation of WCAG 2.2; `docs/I18N.md` lists the conventions the file keeps. `ja` was the documentation's example of a locale that falls back to English, so that example is now `ko`.
|
|
11
|
+
- When the HTML report's own labels are in another language than the scan (a dictionary supplied at scan time, which the report cannot use), rule titles, summaries and hints translated by that dictionary carry `lang` with the scan's locale, so a screen reader reads them with the right pronunciation. Text that fell back to English is left unmarked.
|
|
12
|
+
- A test holds every locale file to the same `{{placeholder}}` set as `en.json`, so a translation cannot drop a value silently.
|
|
13
|
+
- `@surea11y/core/junit` renders a scan result as JUnit XML for the test views of GitLab, Azure DevOps, Jenkins and CircleCI. `renderJunitReport(result, options)` makes one `<testsuite>` per WCAG Success Criterion and one `<testcase>` per rule mapped to it, so a defect repeated across a page is one failing test listing every occurrence, and counts stay stable between runs. Suites come from each rule's own WCAG mappings rather than the composites, so every rule that ran is reported even when composites were excluded; rules mapped to no criterion go into a final `Other checks` suite. `fail` becomes `<failure>`, `cantTell` becomes `<skipped>` (JUnit has no "could not tell", and skipped surfaces it without failing a build, as SARIF's `warning` does), `pass` is a bare testcase, and `notApplicable` is left out unless `includeNotApplicable` is set. `cantTellAs: 'failure'` gates on undecided rules instead. `baselineEntries` drops known failures exactly as SARIF does, and a rule whose every failure is known is skipped with the count rather than reported as passing. Each suite's properties carry the criterion, its level, the EN 301 549 clause, the composite's outcome, and the run's engine, WCAG version, profile, locale and URL. Every `time` is `"0"` and `timestamp` appears only when the result has one, so the same scan renders byte-identical XML. See [`docs/JUNIT.md`](./docs/JUNIT.md); [`docs/CI_INTEGRATIONS.md`](./docs/CI_INTEGRATIONS.md#junit-test-reports) has GitLab and Azure DevOps recipes.
|
|
14
|
+
- `engineOptions.profile` names a conformance target instead of a hand-written tag set: `wcag22-aa`, `en301549-v4.1.1` (both WCAG 2.2 A and AA), `en301549-v3.2.1` (WCAG 2.1 A and AA) and `section508` (WCAG 2.0 A and AA). Level tags do not nest, so a correct A + AA target meant listing four or six tags; a profile supplies them, and the WCAG target version follows from them as it already did, which is what lets `duplicate-id` still fail under `en301549-v3.2.1`, where 4.1.1 Parsing still exists, and not under `en301549-v4.1.1`. An include in `runOnly` or in `engineOptions.rules`/`.tags`/`.tests` still selects the rules instead; excludes apply on top of the profile from either route, so a binding's `disableTags()` narrows it rather than replacing it; and an explicit `wcagVersion` still wins. The run reports the profile it used as `engine.profile`; one that did not take effect, an unknown name or an overridden one, is ignored like other invalid option values, but logs a `console.warn` and leaves `engine.profile` absent, since a caller who asked for a conformance target and silently got a full run would misread the result. A profile selects rules; it is not a claim that passing them meets the named standard. The HTML report's meta bar and the SARIF run's `properties` show the WCAG target and profile a scan used. See [`docs/ENGINE_OPTIONS.md`](./docs/ENGINE_OPTIONS.md#conformance-profiles).
|
|
15
|
+
- A scan can name, on every atomic and composite result, the EN 301 549 clause that restates each of its WCAG Success Criteria. It does so only when asked: `engineOptions.mappings` takes `'en301549'` for both versions or `'en301549:V3.2.1'` / `'en301549:V4.1.1'` for one, an EN 301 549 profile switches on the version it targets, and the run reports what it named as `engine.mappings`. By default a result names WCAG only, since a clause of a standard the caller does not audit against is noise in every report; an unknown name or version is ignored with a console warning, and a `customRules` rule keeps exactly the mappings it declares. When asked for, after its WCAG entries, `meta.normativeMappings` carries `{ standard: "EN 301 549", version, requirement: "9.1.1.1", title, wcagSc: ["1.1.1"] }` once per version of the standard that includes the criterion: V3.2.1 (2021-03, built on WCAG 2.1) and V4.1.1 (2026-09, built on WCAG 2.2). The two restate different criteria, so a 2.5.8 result carries only V4.1.1, a 4.1.1 result only V3.2.1, and an AAA result neither. The table is `src/coverage/en301549-map.js`, taken from the ETSI text and held by `tests/coverage/en301549-map.test.js` to exactly the Level A and AA criteria of each version's WCAG version in the engine's own registry: 50 for V3.2.1, 55 for V4.1.1. Titles are each version's own wording. Rules do not declare these entries; the build derives them from each rule's WCAG mapping, so `manual-review` drops the three V3.2.1 entries it carried by hand. SARIF adds an `en301549-<clause>` tag per clause (one tag where both versions share it), and the HTML report's WCAG rollup names the clause under each criterion. `normativeMappings` was already not WCAG-only, and with EN 301 549 switched on it carries other-standard entries on nearly every result: a consumer that reads every entry's `requirement` as a WCAG criterion must filter on `standard` first, as SARIF, EARL and the HTML report do. `wcagSc` is unchanged. The catalogs name the same clauses under the same options: `getChecksCatalog(engineOptions)` in `normativeMappings`, and composite entries from `getRulesCatalog(engineOptions)` as `meta.standardMappings`. `wcagSc` on each entry names the criterion it restates, so JUnit files a clause under the right suite even for a rule mapped to several criteria. EN 301 549 is registered in `src/coverage/standards.js`, which the build, `engineOptions.mappings`, the profiles and the SARIF, JUnit and HTML reporters all read, so another standard is a table and a registry entry (see [`docs/WCAG_CONFORMANCE.md`](./docs/WCAG_CONFORMANCE.md#adding-another-standard)). The table itself is public as `@surea11y/core/en301549` (`EN301549_VERSIONS`, `EN301549_CLAUSES`, `en301549ClausesForSc()`), frozen, for tools that need to know which criteria a version requires. See [`docs/WCAG_CONFORMANCE.md`](./docs/WCAG_CONFORMANCE.md#en-301-549).
|
|
16
|
+
- A registered standard can bring rollups of its own (`composites()` in its registry entry), which a run under its profile adds to `rulesResults` next to the WCAG ones. Each carries the standard's wording as its title, `meta.standard` and `data.details.standard`/`version`/`criterion`, and decides its outcome the same way as a WCAG rollup. They are opt-in like the standard's own rules: default, WCAG and EN 301 549 runs do not produce them, and `getRulesCatalog()` lists them only under options that ask for the standard. The HTML report shows them in their own section after the WCAG rollup. Every check result now also carries `rollupIds`, the rollups that group it in that run, empty when there are none, so a finding no WCAG rollup groups is not lost to a consumer reading `rulesResults` alone.
|
|
17
|
+
- Opt-in rules. A rule tagged with a standard's rule tag (from `ruleTag` in `src/coverage/standards.js`) checks a requirement only that standard makes, so it is off by default: it runs only through that standard's profile, a selection that includes the tag, its own id or the id of its standard's rollup that groups it, never in a default run, a WCAG tag set or a WCAG or EN 301 549 profile. Excludes still apply. A WCAG scan therefore never fails a page for something WCAG does not require, and `docs/WCAG_CONFORMANCE.md` now states the promise as "a violation of the standard and version you targeted". A standard's profile can also run every rule the standard maps (`mappedRules` in the registry), computed at build time. See [`docs/ENGINE_OPTIONS.md`](./docs/ENGINE_OPTIONS.md#opt-in-rules).
|
|
18
|
+
- `engineOptions.optInRules` runs opt-in rules outside their standard's profile: `'all'` for every standard's, or a list of rule tags. It only lifts the opt-in gate, so the rest of the selection still decides: with no profile and no filter, `{ optInRules: 'all' }` runs every rule the engine has and every standard's rollups, while under a WCAG profile it runs the WCAG rules only and under a standard's own profile it changes nothing for that standard's rules. Excludes apply as usual, and a tag that is no opt-in tag is ignored with a console warning. It is meant for seeing everything the engine can report, not as a conformance target, so the result says when it added rules: `engine.optInRules` lists the tags whose rules it added, and the HTML report's meta bar, SARIF's run properties and JUnit's suite properties show it. It adds no other standard's numbers to results; `mappings` still does that. `getRulesCatalog(engineOptions)` and `getChecksForRunOnly()` follow the option the same way. See [`docs/ENGINE_OPTIONS.md`](./docs/ENGINE_OPTIONS.md#running-every-rule-optinrules).
|
|
19
|
+
- `text-spacing-content-loss` checks WCAG 1.4.12 in a browser. It applies line height 1.5, 2em after paragraphs, letter spacing 0.12em and word spacing 0.16em in a style sheet that wins over the page's own, and fails text that a box with `overflow: hidden` or `clip` then cuts off by at least half a line. It asks about text pushed out by less and text that comes to overlap other text. In any environment it asks about a style sheet rule that forces line-height, letter-spacing or word-spacing below those values with `!important`: a tool that adds its own style sheet cannot override it, though a user style sheet can. In jsdom without such a rule it is `notApplicable`. It runs by default, so in a browser the `wcag-1.4.12-text-spacing` rollup can now fail or ask where only `avoid-inline-spacing` spoke before. Text that was not visible before the spacing (visually hidden text, text hidden by opacity or visibility, text outside its clipping box) is left out, and a box that scrolls keeps what goes past it reachable, so only text a person could read and then loses is reported.
|
|
20
|
+
- Helpers for rules, documented in [`docs/RULE_HELPERS.md`](./docs/RULE_HELPERS.md): `queryAllSource(selector)`, the same query as `queryAllSmart()` (shadow roots, scope, `excludeSelectors`) without the hidden-content filter, for checks that apply to the whole source; `getDoctypeInfo()`, the doctype a page has; `hasSkipLinkWording(text)`, whether a link's text reads as a skip link in the languages the engine ships, one list for every rule that looks for a skip link; and an optional bold-text threshold for `contrast.isLargeText()`, which keeps WCAG's when left out.
|
|
21
|
+
- Profiles: a standard with verdicts of its own lives in its own folder under `profiles/`, holding its entry, tables, rules, messages and the languages it offers, tests, scenario pages, docs and records. The engine reaches it only through `profiles/index.js`, a profile uses only what core publishes, and core's own tests never read a profile's files, which `tests/profile-boundary.test.js` checks both ways; a profile never changes what a rule decides, only which rules run (`tests/profile-outcomes.test.js`). No standard ships as a profile in this release. `npm run profile:new -- <key> --name "<Name>"` creates one that builds and passes its tests as it is; filling it in is editing two tables and adding rules. Core's tests of what it does with a profile's standard (mappings, opt-in rules, rollups, variants, `ctx.standard`, JUnit and report output) run against a sample profile, `tests/fixtures/profiles/sample/`, built into a copy of the engine. See [`profiles/README.md`](./profiles/README.md).
|
|
22
|
+
- `@surea11y/core/wcag` publishes WCAG's Success Criteria as each version of WCAG 2 publishes them: `wcagCriteria(version, { levels })`, `wcagCriterion(sc, version)`, and `wcagTags(version, levels)` for the rule tags that select a version's criteria. 4.1.1 Parsing is a Level A criterion in 2.0 and 2.1 and absent from 2.2, where the engine's own table, written for 2.2, gives it no level. EN 301 549's profiles take their tags from it.
|
|
23
|
+
- A rule reads which standard and version the run targets as `ctx.standard` (`{ key, name, version }`, or `null` when no standard's profile selected the run), for a requirement that differs between versions of a standard. See [`docs/RULE_AUTHORING.md`](./docs/RULE_AUTHORING.md).
|
|
24
|
+
- A standard's profile can leave rules and WCAG criteria out (`exclude: { rules, criteria }` in its registry entry), for a standard that waives a criterion or replaces a WCAG check with its own. Those rules and their WCAG rollups do not run, the rule catalog leaves them out too, and the result names what was left out as `engine.profileExcludes`. No built-in profile excludes anything.
|
|
25
|
+
- Rule variants: a rule that is a core rule with other thresholds is declared as data (`from`, `config`) rather than copied, and runs the base rule's code with its own id, settings and messages. `contrast-minimum` declares its thresholds as `settings`; they are set by variants only, never by a scan's options. See [`docs/RULE_AUTHORING.md`](./docs/RULE_AUTHORING.md#rule-variants).
|
|
26
|
+
- The finding identities a release ships are frozen in `scripts/data/released-finding-ids.json` (`npm run finding-ids:release -- <version>`, as part of each release), and `tests/released-finding-ids.test.js` fails when one is missing from a later commit without a recorded retirement and its reason. The inventory test alone compared against a file the same commit could regenerate. `docs/API_STABILITY.md` states when a reason code may retire: only with the finding it named, through a correctness fix.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- `p-as-heading` also asks about a `<div>` that holds only text and inline markup, and reads the weight and size of each piece of text, so a paragraph made bold by a styled `<span>` is asked about and one with a word in normal weight is not. Text inside a heading, button, label, legend, caption, table header or `<summary>`, and a `<div>` with a role, are left out. It can return `cantTell` on pages where it was `notApplicable` before, under WCAG 1.3.1.
|
|
30
|
+
- `no-autoplay-audio` also asks about `<embed>` and `<object>` elements that load sound, video or a plugin, and about `<bgsound>`, unless `autostart` or `autoplay` is set to false; the fallback inside an `<object>` it already asks about is not asked about again. WCAG 1.4.2 covers any sound that plays on its own. It can return `cantTell` on pages where it was `notApplicable` before.
|
|
31
|
+
- `link-name-present` maps to WCAG 2.4.4 Link Purpose (In Context) as well as 4.1.2, as ACT rule `c487ae` does: a link with no accessible name has no purpose to determine. It joins the `wcag-2.4.4-link-purpose-in-context` composite, which until now rolled up only the manual `link-name-quality` and so could never fail; an unnamed link now fails it. The rule's results also name EN 301 549 9.2.4.4 when asked for.
|
|
32
|
+
- `area-alt-present` fails an `<area>` with `alt=""` and no other accessible name, instead of treating an empty `alt` as satisfying the check. An `<area>` in a used image map is always a link — it has no visual content of its own the way an `<img>` does — so `alt=""` never carried `<img alt="">`'s decorative meaning; it just left the link's name empty. `aria-label`/`aria-labelledby` and a non-empty `title` still count as valid naming mechanisms, as they did before. The empty-`alt` case gets its own message (`area_altPresent_summary_fail_empty`/`_hint_fail_empty`), distinct from the missing-`alt` one, in every shipped locale.
|
|
33
|
+
- `manual-review` no longer names EN 301 549 clauses by default. It carried three V3.2.1 entries by hand; they now come, for both versions, from its WCAG mapping when EN 301 549 is asked for (`engineOptions.mappings` or an EN 301 549 profile).
|
|
34
|
+
- `en.json` has new keys for rollup titles (`catalog.rules.*`) and the HTML report (`report_*`). A caller dictionary written for 1.7.0 still works, and those strings fall back to English, but `engine.locale.reason` now reads `partial-dictionary` until it adds them.
|
|
35
|
+
|
|
36
|
+
### Deprecated
|
|
37
|
+
- `iframe-title-unique` is deprecated in favour of `identical-iframes-same-purpose`, and reports `notApplicable` on every page. It failed two frames for sharing a `title`, which WCAG 4.1.2 does not require: the criterion asks that a frame's name be exposed, not unique, and ACT rule 4b1c6c accepts the same name on frames that embed the same resource, so seven of its ten passed examples failed. Whether frames sharing a name embed the same resource is what `identical-iframes-same-purpose` checks. A deprecated rule normally keeps running, so this one was also reduced to `notApplicable`, to stop the false failure now rather than when the file is removed in 2.0.0. Its id stays in the catalog with `deprecated: true` and `deprecation.replacedBy`, the first rule to use the mechanism in `docs/API_STABILITY.md`. `IFRAME_TITLE_DUPLICATE` is retired with the finding it named, so a stored baseline entry or Code Scanning alert for it closes. Its scenario page and message keys are removed. (#16, #17)
|
|
38
|
+
|
|
39
|
+
### Removed
|
|
40
|
+
- `area-alt-decorative`, the manual-review companion that asked a human to confirm an empty-`alt` `<area>` was decorative. That question never had a legitimate "yes" — `area-alt-present` now fails the case outright (see above) — so its fixture and tests are removed with it. Anything holding its id, a `runOnly` list or a baseline entry, matches nothing now. It was removed without the deprecation period the policy asks for, an exception recorded in [`docs/API_STABILITY.md`](./docs/API_STABILITY.md#a-removal-that-predates-this-guard).
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- `autocomplete-valid` accepts `impp` on its own and the `tel-local-prefix` and `tel-local-suffix` field names. It read `impp` as a prefix such as `home` or `work`, which HTML does not, so `autocomplete="impp"` failed.
|
|
44
|
+
- `tests/fixtures/INDEX.md` finds the scenario page of a rule whose test names it from the rule id (`` `${RULE_ID}-all-scenarios.html` ``). Twenty rules with a page were listed as having none.
|
|
45
|
+
- `identical-iframes-same-purpose` has a scenario page, so every rule that decides something now has one.
|
|
46
|
+
- `deprecated-elements-not-used` no longer fails `<blink>`, which no browser makes blink, and asks instead of failing on `<marquee>`, since the page may offer its own pause control. Its reason code `DEPRECATED_NON_STOPPABLE_ELEMENT` is retired with the failure it named: a `<marquee>` is now reported as `MARQUEE_PAUSE_MECHANISM_UNKNOWN` (`cantTell`), and a `<blink>` not at all, so a stored baseline entry or Code Scanning alert for either closes rather than matching the new finding.
|
|
47
|
+
- `area-alt-quality` and `input-image-alt-quality` ask about an `<area>` or image button named only by `aria-label`, `aria-labelledby` or `title`, not only by `alt`, and list every source they found.
|
|
48
|
+
- `input-image-alt-present` asks instead of failing when the author's name for an image button equals a browser default such as "Submit".
|
|
49
|
+
- `canvas-text-alternative-present` fails a `<canvas role="img">` named only by its fallback content, and passes a canvas with `role="presentation"` or `"none"`.
|
|
50
|
+
- `img-alt-decorative` no longer treats `alt=" "` as the decorative marker, which contradicted `img-alt-present`'s failure on it.
|
|
51
|
+
- `no-autoplay-audio` and `media-alternative-transcript-evidence` report `<audio>` without `controls` in real browsers. The browser's own stylesheet hides it, so both rules skipped it; hidden media still plays.
|
|
52
|
+
- `server-side-image-map-absent` asks instead of failing, since the map's destinations may also be offered as links, and ignores `ismap` on an image outside a link, where it does nothing.
|
|
53
|
+
- `table-fake-caption` no longer asks about layout tables or tables already named by `aria-label`, `aria-labelledby` or `title`.
|
|
54
|
+
- `td-has-header` accepts `role="columnheader"`/`"rowheader"` cells as headers and no longer fails layout tables or empty cells such as a table's corner cell.
|
|
55
|
+
- `video-caption` asks about a video whose only text track is `kind="subtitles"`, which may be a translation rather than captions.
|
|
56
|
+
- `autocomplete-valid` fails a field name that does not suit the control, such as `street-address` on a single-line input or `email` on a number input (`AUTOCOMPLETE_FIELD_CONTROL_MISMATCH`), and no longer fails a control disabled by a disabled `<fieldset>`.
|
|
57
|
+
- `definition-list-children-valid` fails a `<dd>` before the first `<dt>`, a `<dt>` after the last `<dd>` (`DL_DT_DD_ORDER`), and text directly inside the `<dl>`.
|
|
58
|
+
- `dialog-name-present` checks open native `<dialog>` elements and resolves `role="alertdialog dialog"` to its first valid role.
|
|
59
|
+
- `duplicate-id` compares ids exactly as written, so `id="a "` and `id="a"` are no longer duplicates.
|
|
60
|
+
- `heading-order` uses a valid `aria-level` on `<h1>` to `<h6>`.
|
|
61
|
+
- `html-lang-attr-present` and `valid-lang` judge only the primary language subtag, so `fr-FR-!!` and `en-US_x` pass.
|
|
62
|
+
- `label-in-name` compares accented words whole: `poser` is no longer found inside `Déposer`, and `Deposer` does not match `Déposer`.
|
|
63
|
+
- `label-in-name` no longer fails a control whose `aria-labelledby` points at no element or only at elements with no text, or whose `aria-label` is empty once trimmed. The accessible name computation skips such an attribute and takes the name from the next source, so `<button aria-labelledby="missing">Save</button>` is named "Save"; the rule took the name as empty and reported that "Save" was missing from it. Such a control is not named by either attribute and is now out of the rule's scope. The dangling reference is still reported by `aria-valid-attr-value`.
|
|
64
|
+
- `list-children-valid` no longer fails a `<ul>` or `<ol>` given another role such as `tree`, `listbox` or `menubar`, and `listitem-parent-valid` accepts `<li>` inside `<menu>`.
|
|
65
|
+
- `avoid-inline-spacing` asks instead of failing when the text may never wrap: a single word, or text short enough to fit on one line at 320 CSS pixels (`INLINE_SPACING_SHORT_TEXT`).
|
|
66
|
+
- `css-focus-indicator-suppressed` also asks about an outline removed by a rule with no focus state, such as `a { outline: none }`, when no focus rule draws a replacement (F78); such pages were notApplicable.
|
|
67
|
+
- `css-hidden-focus` no longer asks about an off-screen or clipped element, such as a skip link, that a `:focus`, `:focus-visible` or `:not(:focus)` rule brings back into view.
|
|
68
|
+
- `css-orientation-lock` asks when an orientation media query hides the page's main content (a "rotate your device" message, F100); such pages passed.
|
|
69
|
+
- `link-in-text-block` asks instead of passing when a link differs from the surrounding text only by a colour of 3:1 or more, since G183 also needs a cue on hover and focus; it passes links marked by a border, shadow, outline, background, icon or generated content instead of failing them, and checks `role="link"` elements.
|
|
70
|
+
- `mouse-only-event-handlers` asks about an element whose `onfocus`/`onblur` or key handlers can never run, because neither it nor, for key handlers, a descendant can take focus.
|
|
71
|
+
- `page-title-patterns` reviews a `<title>` the parser left in `<body>`, which it skipped, and `page-title-present` reports a missing title, not an empty one, when the only `<title>` on the page is inside an inline `<svg>`.
|
|
72
|
+
- `skip-link` recognises skip links worded in French, German, Spanish and Japanese, and the page's first link when it points into the page before `main`.
|
|
73
|
+
- `aria-hidden-focus` no longer fails a control that an ancestor `<fieldset disabled>` disables, and judges an `<area href>` by the image that uses its map: an aria-hidden area in a used map fails, one in a map no image uses is not reported.
|
|
74
|
+
- `aria-required-attr` no longer asks for `aria-checked` on a native checkbox or radio given a checkable role such as `switch`: the native checked state supplies it.
|
|
75
|
+
- `aria-valid-attr-value` fails integers below the range WAI-ARIA sets, such as `aria-level="0"` or `aria-setsize="-2"`, and asks instead of failing when an `aria-labelledby`, `aria-describedby` or other ID list points only at ids that do not exist; a missing `aria-activedescendant` target still fails.
|
|
76
|
+
- `aria-allowed-role` asks about a role other than `main` on `<main>`, and about foreign roles on the rows and cells of a native table.
|
|
77
|
+
- `aria-prohibited-attr` fails `aria-label` or `aria-labelledby` on a native `<caption>`, as it does on `role="caption"`.
|
|
78
|
+
- `aria-role-name-present` accepts the name a fieldset's `<legend>`, a table's `<caption>` or a `<label>` on `<progress>` or `<meter>` gives the element.
|
|
79
|
+
- An `<svg>` with a `<title>` child names the button or link that contains it, so `button-name-present` no longer fails such a button.
|
|
80
|
+
- `progressbar-name-present` and `slider-name-present` accept an associated `<label>` on any labelable element, not only `<input type="range">`.
|
|
81
|
+
- The combobox, textbox, searchbox, spinbutton and slider name rules accept the placeholder of a text-like input or textarea as its name, as the accessible name computation does.
|
|
82
|
+
- `role-img-text-alternative-present` no longer reports an unnamed `<svg role="img">`, which `svg-text-alternative-present` already reports.
|
|
83
|
+
- English words left inside translated text are translated, and the terms each locale uses are consistent. Spanish said "landmark" in 39 strings while also using *región de referencia*; it now uses *región de referencia* throughout. German said "Header" for a table header cell (now *Kopfzelle*) and "Blending" (now *Mischmodi*); Spanish said "El caption de la tabla" (now `<caption>`). The role name `complementary` stays as code in German, Spanish and French, like `banner`, `contentinfo` and `main` already did. The Spanish description of `media-alternative-transcript-evidence` had translated the result code `cantTell`, which now stays as it is. `links-target-blank-noopener` read as a requirement in French and Spanish ("doivent"/"deben") although it only raises findings for review, and its French description said the rule fails a page; both now match the English.
|
|
84
|
+
- HTML report cards cap a long selector or summary at 220 characters, as the report's code always said they did: the helper that does it was written but never called, so a deeply nested selector could stretch a card across the page. The findings table and the embedded data keep the full values, and the hint is never shortened, since the table does not repeat it.
|
|
85
|
+
- The HTML report is written in the scan's language. Its headings, table columns, outcome and severity names, headline, notes and pager were hard-coded English, so a Japanese scan produced Japanese findings inside an English page. They now come from new `report_*` keys in every dictionary (all five locales), `<html lang>` names the locale, and dates and numbers use its format. The card heading now labels the severity ("(Fail, serious severity)") rather than listing two bare words, which also reads correctly in every language. A report for a locale the engine does not ship, from a caller-supplied dictionary, keeps English labels and marks its findings with their own `lang`. The report reads the dictionaries through two additions to the internal `__internal` export (`translate`, `resolveLocale`); nothing public changes.
|
|
86
|
+
- The rules that judge text by known phrases recognize German, Spanish, French and Japanese as well as English. `link-name-quality` (「こちら」, "Hier klicken", "Leer más", "En savoir plus"), `heading-quality` (「見出し」, 「第1章」, "Sans titre"), `form-control-label-quality` (「入力欄」, "Eingabefeld"), `page-title-patterns` (「トップページ」, "Startseite", "Inicio - …") and `media-alternative-transcript-evidence` (「文字起こし」, "Transkript", "transcripción") matched English only, so on a page in another language they missed what they exist to catch. English is always checked and the element's own language (nearest `lang`) is added, so a word that is generic in one language ("Suite", "Plus") is not flagged on an English page; transcript words are checked in every language. Text is NFKC-normalized, so full-width forms match, and trailing arrows no longer hide a generic link ("Read more »"). `page-title-patterns` counted characters, which made a complete Japanese title such as 「お問い合わせ」 "very short"; CJK characters now count double. The three rules' descriptions say which languages are covered, in every locale. English behavior is otherwise unchanged.
|
|
87
|
+
- The last findings whose text bypassed the dictionaries are translated. `css-hidden-focus` listed the hiding techniques it found as internal codes (`opacityZero,offscreen`) in every language; its summary now states each one as a sentence ("Its opacity is 0. It is positioned off-screen."), driven by one boolean parameter per technique, and `visibilityHints` stays in the parameters for callers that read it. The focus-redirect findings of `css-hidden-focus` and `iframe-focusable-content` were built in English with no dictionary key; they now use `cssHidden_focus_summary_cantTell_redirect`/`_hint_cantTell_redirect` and `iframeFocusableContent_summary_cantTell_redirect`/`_hint_cantTell_redirect`. All five locales. The English `css-hidden-focus` summary changes wording; finding fingerprints do not include message text, so baselines are unaffected.
|
|
88
|
+
- WCAG rollup titles and descriptions (`rulesResults[].title`/`.description`, the HTML report's "WCAG rollup" section, and composite entries from `getRulesCatalog()`) now follow the scan locale. The runner already localized a composite that named `meta.titleKey`/`meta.descriptionKey`, but none of the 34 did, and the nine `catalog.rules.*` entries in the dictionaries were never referenced, so every locale showed English rollups. Every composite now names its keys; 25 new title/description pairs are translated in all five locales, using each language's published success criterion names. English output is unchanged: `en.json` holds the catalog's exact text, and a test keeps the two in step.
|
|
89
|
+
- The three contrast rules now say what to do. `contrast-computable`'s `cantTell` findings, and the `fail` and engine-failure findings of `contrast-minimum` and `contrast-enhanced`, all had an empty hint, so a finding explained why contrast could not be measured, or that it was too low, and stopped there. `contrast-computable` now gives a hint per cause: a background image or gradient (`contrastComputable_hint_cantTell_background`), a blend mode, filter or text shadow (`_effect`), a transparent background up to the root (`_rootNotOpaque`); anything else, and an engine failure in any of the three rules, gets `contrast_hint_cantTell_manual`, which says to measure by hand and gives the ratios. The `fail` hints (`contrastMinimum_hint_fail`, `contrastEnhanced_hint_fail`) name the ratio the text has to reach. All five locales. Message text is not part of the finding fingerprint, so stored baselines are not affected.
|
|
90
|
+
- `aria-braille-equivalent` no longer puts English into translated messages. Both of its findings shared one template filled with `{{requires}}`, and for a missing accessible name that value was the English phrase "an accessible name", so every locale read "…aber nicht an accessible name…". Each case now has its own message: `ariaBrailleEquivalent_summary_fail_label`/`_hint_fail_label` and `ariaBrailleEquivalent_summary_fail_roleDescription`/`_hint_fail_roleDescription`, in all five locales, replacing `ariaBrailleEquivalent_summary_fail`/`_hint_fail`. The label hint also says how to add a name. `data.details.requires` is unchanged, and message text is not part of the finding fingerprint, so stored baselines are not affected. A caller dictionary (`engineOptions.messages`) that overrides the old keys falls back to English for this rule until it uses the new ones.
|
|
91
|
+
- The French text for `mediaTranscriptPresent_summary_cantTell_missing` dropped its `{{element}}` placeholder, so it never named the `<audio>` or `<video>` element concerned. Restored.
|
|
92
|
+
- The French hint for an unverified transcript (`mediaTranscriptPresent_hint_cantTell_unverified`) repeated the missing-transcript summary, with a literal `{element}` in it. It now says what to do, and the placeholder test also rejects single braces.
|
|
93
|
+
- SARIF and the HTML report no longer label EN 301 549 clauses as WCAG criteria. Both read every `normativeMappings` entry's `requirement` as a Success Criterion, so `manual-review`, which maps to EN 301 549 9.2.1.1, 9.2.4.3 and 9.2.4.7 alongside WCAG 2.1.1, 2.4.3 and 2.4.7, gained the SARIF tags `wcag-9.2.1.1`, `wcag-9.2.4.3` and `wcag-9.2.4.7`, and its report card showed a "WCAG 9.2.1.1" chip. Its Understanding-document entries also repeated each WCAG chip a second time. Only WCAG Success Criteria are tagged and chipped now; an entry that names no `standard` is still read as WCAG, as before. EARL already made this distinction and is unchanged. Tag and chip text only: finding fingerprints and stored baselines are not affected.
|
|
94
|
+
- The shared DOM-eligibility cache could leak an `opacity:0` verdict between unrelated callers. Its cache key covered `visibilityMode`/`disableGeometry` but not `ignoreOpacity`, so a caller that explicitly asks to ignore opacity-based hiding (`queryAllSmart`'s own inert check, matching `visibilityMode: 'styleOnly', disableGeometry: true`) shared a bucket with five callers that don't (`aria-hidden-focus`'s two eligibility checks, and the manual `area-alt-quality`, `css-focus-indicator-suppressed`, `css-hidden-focus`, `form-control-label-quality` rules, all using the identical opts minus `ignoreOpacity`). Whichever ran first for a given `opacity:0` element within a scan could leave its verdict cached for the other, incorrectly. `ignoreOpacity` is now part of the cache key, so each combination gets its own bucket. Verified directly: an isolated call correctly excludes an `opacity:0` element (`reasons: ['opacityZero']`), but previously returned eligible when preceded by an `ignoreOpacity: true` call on the same element; now it doesn't. No rule's fixture or test changed, confirming the collision wasn't already covered — a regression test was added directly against the cache (`tests/cache-tests/dom-helpers-eligibility-cache.test.js`) rather than relying on hitting it through a real scan.
|
|
95
|
+
- `deprecated-elements-not-used` and `server-side-image-map-absent` report `pass` on a clean page instead of `notApplicable`. Both query directly for the violation (`blink, marquee`; `img[ismap]`), so every match already became an occurrence and the two outcomes exhausted every case — the `pass` branch each carried was dead code. Both rules' own `@expectation` already phrases the clean case as an absence claim ("neither element is present" / "no image uses ismap"), which is simply true when nothing is found, not inapplicable — matching how `aria-hidden-body`, `page-title-present`, and the rest of this shape already behave. The practical effect lands hardest on `deprecated-elements-not-used`: it is the sole atomic contributor to the `wcag-2.2.2-pause-stop-hide` composite, so that composite could never report `pass` for any page, ever — a fully clean scan showed SC 2.2.2 as "not applicable" rather than "passes." `server-side-image-map-absent` shares 4 sibling contributors in `wcag-2.1.1-keyboard`, so the same limitation there was already masked almost all the time. Neither rule emits an occurrence on the clean path, so no finding fingerprint or stored baseline is affected — this only changes the rule- and composite-level `outcome` field.
|
|
96
|
+
- `aria-braille-equivalent`'s `cantTell` message read "has aria-braillelabel but no an accessible name" — a double negative from a shared template ("but no {{requires}}") reused for a value that already carried its own article ("an accessible name"). Changed the template to "but not {{requires}}", which reads correctly for both call sites (the other fills `{{requires}}` with the bare attribute name `aria-roledescription`, which "not" also suits). English only; `de`/`fr` already used the "not" phrasing and `es`'s "no" already serves that role in Spanish. Message text only, not part of the finding fingerprint, so no stored baseline is affected.
|
|
97
|
+
- `inert` on an `<area>` itself, or on its `<map>`, now excludes the area the same as `inert` anywhere else, instead of being silently ignored. The exception dated to the project's first commit and had no recorded rationale; it read as a generalization of the correct, separate rule that `aria-hidden` on a focusable element does not remove it from eligibility, since a real user can still Tab to it. `inert` is not `aria-hidden`: the HTML spec has it remove focusability outright, with no image-map carve-out, and `aria-hidden-focus.js`'s own inert check (`hasInertAncestor`) already treated `<area>`/`<map>` with no special case, so the two rules disagreed on the same input. `hasBlockingInert` in `dom-helpers.js` drops the carve-out; an inert `<map>` or `<area>` is `notApplicable` now, matching how an inert ancestor outside the map already behaved.
|
|
98
|
+
- `avoid-inline-spacing` and `link-in-text-block` no longer discard their `cantTell` findings when something else on the page fails. Both built an undecided list and then returned early with the fail list alone, so a single spacing failure silently removed every element the rule could not decide — the one thing an engine that reports what it cannot tell you must not do. `helpers.resolveTieredOutcome` exists to merge the two tiers and `target-size-minimum` already used it; both rules now do the same. The rule-level outcome is unchanged, `fail` where it was `fail`, and the confident failures are byte-identical; what changes is that the result carries the `cantTell`-tier occurrences alongside them, each with `occurrenceOutcome: "cantTell"` and the `uncertainty` block naming what a human would have to settle. On their own fixtures that is three more occurrences for `avoid-inline-spacing` — a `calc()` that resolves to no ratio, and two elements whose text cannot take a soft wrap break — and one more for `link-in-text-block`, a link over a background image whose contrast against the surrounding text is not computable. `link-in-text-block` gains the `BACKGROUND_IMAGE_OR_GRADIENT` reason code in `scripts/data/finding-ids.json`, which the rule always had but could never emit on a page that also failed; the addition is purely additive, so no existing finding fingerprint or stored baseline changes.
|
|
99
|
+
|
|
100
|
+
## [1.7.0] - 2026-08-29
|
|
101
|
+
|
|
102
|
+
### Added
|
|
103
|
+
- `@surea11y/core/earl` renders scan results as an EARL 1.0 report in JSON-LD — the W3C vocabulary for stating what a tool tested and what it found, and the format the ACT Rules community group accepts as an implementation report. `renderEarlReport(results, { assertor, mode })` takes an array as readily as one result, groups the graph by `TestSubject` as the ACT context requires, and sorts subjects by source and assertions by rule id so the same inputs give byte-identical output and a diff between engine versions means something. Unlike the SARIF and HTML reporters, which carry violations only, every rule that ran becomes an assertion: `pass` and `inapplicable` are what distinguish "checked and found nothing to check" from "does not implement that rule". Success Criteria come out as `WCAG2:<criterion-id>` derived from the criterion's own title, reading only the mappings that state a conformance level, since `normativeMappings` also carries Understanding-document references and other standards under the same `standard: "WCAG"`. A rule mapping to no criterion omits `isPartOf` rather than asserting an empty list. See [`docs/EARL.md`](./docs/EARL.md).
|
|
104
|
+
- The engine's extension boundary is declared. `src/index.js` re-exports the generated core verbatim, so the public surface was whatever the build happened to emit — 19 symbols, of which the six first-party consumers use two, and one of which (`__internal`) hands out an engine internal. `docs/API_STABILITY.md` now names the supported set (`runa11yCoreInPage`, `runDomRulesInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`, `getChecksCatalog`, `getRulesCatalog`) and lists the rest as exported-but-internal, free to change in a minor. The classification lives in `scripts/data/public-api.json` and `tests/public-api.test.js` fails when a new export appears unclassified, so a leak has to be a decision. Nothing is removed: that is a candidate for the next major, and no consumer needs it yet.
|
|
105
|
+
- The custom-rule descriptor is covered by semver. `engineOptions.customRules` was documented in full but appeared nowhere in the stability contract, so the one real plugin API carried no promise. `id`, `meta`, `runInPage(ctx)`, the optional `applicability(ctx)` and `data`, the `ctx.helpers` a rule receives, the result it returns, and the function-or-source-string form a binding needs to cross a realm boundary are all stable now. `engineOptions.policyContract`/`policy` and the reporter entry points are documented as the other two extension points.
|
|
106
|
+
- `overriddenBuiltinIds` joins the stable top-level result fields. It is always emitted and fully documented in `OUTPUT_SCHEMA.md`, but was missing from the stable list despite being how a consumer detects a `customRules` entry shadowing a built-in id.
|
|
107
|
+
- Rule ids and reason codes are now a documented contract, inventoried in `scripts/data/finding-ids.json` and enforced by `tests/finding-ids.test.js`. Both feed the finding fingerprint — `computeBaselineKey(ruleId, reasonCode, html)`, which backs both stored baselines and the SARIF `partialFingerprints` GitHub Code Scanning tracks alerts by — so either one changing silently unsuppresses every baselined finding and makes every open alert close and reopen as new. `data.details.reasonCode` moves out of `API_STABILITY.md`'s explicitly-unstable list into the stable set, as a deliberate exception to the rest of `data.details`: a rule may gain a code in a minor release, but a shipped one does not change, and a published rule id is removed or renamed only through a `deprecated`/`replacedBy` entry. Regenerate with `npm run finding-ids`. The build already rejected a renamed rule that a composite references; the inventory covers the rules no composite mentions, which it did not. See [`docs/API_STABILITY.md`](./docs/API_STABILITY.md#finding-identity).
|
|
108
|
+
- A `cantTell` occurrence can now say why it could not be decided. `occurrences[i].uncertainty` carries a `code` from a closed vocabulary — `not-computable`, `runtime-dependent`, `spec-only`, `equivalence-unknown`, `judgement-required`, `out-of-scope` — alongside `needed`, one sentence naming what would settle the question, and `evidence`, what the rule did establish so a reviewer starts from the engine's work rather than repeating it. The reason for a `cantTell` was previously only in `data.details.reasonCode`, which is per-rule, free-form and documented as not a stable contract, so nothing could branch on it. The field is present only on a `cantTell`-tier occurrence: on a `fail`-tier one it would claim the rule both decided and did not, and the engine drops it. Every automatic rule that can report `cantTell` carries it — the ARIA family that this release regraded reports `spec-only`, the contrast and CSS rules that cannot read their inputs report `not-computable`, and the target-size and label rules report `judgement-required` or `equivalence-unknown`. The engine attaches `out-of-scope` itself to the occurrences behind a `wcagVersionScope` coercion. A test holds the line so a new rule cannot report `cantTell` without saying why, and `validate:rules` rejects a code outside the vocabulary. Manual rules do not carry it, since `judgement-required` is what `type: "manual"` already means. Purely additive, so no `schemaVersion` bump. See [`docs/OUTPUT_SCHEMA.md`](./docs/OUTPUT_SCHEMA.md#uncertainty-codes).
|
|
109
|
+
- `occurrences[i].occurrenceOutcome` is documented. It has been in the output since rules began grading findings into a confident `fail` tier and a needs-review `cantTell` tier, but `OUTPUT_SCHEMA.md` never described it, so the reason a `fail` result can carry `cantTell` occurrences was undocumented. No behaviour change.
|
|
110
|
+
- `engineOptions.wcagVersion` (`'2.0'`, `'2.1'` or `'2.2'`) sets which version of WCAG a scan is conformance-testing against. It defaults to whatever your version-origin tags imply, and to `'2.2'` when they imply nothing, and the resolved target comes back on every result as `engine.wcagVersion`. See [`docs/ENGINE_OPTIONS.md`](./docs/ENGINE_OPTIONS.md).
|
|
111
|
+
- `docs/RULE_HELPERS.md`: reference for every function on `ctx.helpers` available to a rule's `runInPage` — around 35 flat helpers plus the `contrast.*`/`aria.*` namespaces. `docs/RULE_AUTHORING.md` §6 previously named only a dozen of them inline; the rest were discoverable only by reading `src/core/dom-helpers.js` directly. §6 now points here instead.
|
|
112
|
+
- `password-paste-enabled` raises an authentication field carrying an inline paste handler for review, the first coverage of WCAG 3.3.8. A password manager, or the clipboard for a one-time code, is the mechanism the criterion asks for, and blocking paste removes it. Advisory and capped at `cantTell`: whether a handler really stops the user depends on script the markup does not carry, so the two cases are reported apart rather than decided — one that only cancels, and one that goes further and may be re-inserting the text. In scope are `current-password`, `new-password` and `one-time-code` fields, plus `<input type="password">` unless its `autocomplete` names another purpose.
|
|
113
|
+
- `landmark-complementary-is-top-level` reports a complementary landmark nested inside another landmark, completing a family that already covered banner, contentinfo and main. Advisory and capped at `cantTell`, like its siblings. An unnamed `<aside>` inside sectioning content is not reported, since HTML-AAM leaves it no complementary role to nest.
|
|
114
|
+
- `@surea11y/core/i18n/<locale>` resolves each locale side file by path, alongside the existing `@surea11y/core/browser`. A binding that injects the standalone bundle into a page needs the matching dictionary to honour `engineOptions.locale`, and the exports map previously put both out of reach. An unshipped locale fails to resolve rather than resolving to nothing, so a caller can tell the difference and fall back. See [`docs/BINDING_AUTHORS_GUIDE.md`](./docs/BINDING_AUTHORS_GUIDE.md).
|
|
115
|
+
- `identical-iframes-same-purpose` checks that `<iframe>`/`<frame>` elements sharing an accessible name embed the same resource, implementing ACT rule 4b1c6c. It sits alongside `iframe-title-unique`, which asks the stricter and different question of whether the `title` attribute repeats at all: this one keys on the computed accessible name, counts only frames included in the accessibility tree, and judges the resource behind the name. `src` values are compared as resolved absolute URLs with the fragment removed and a trailing slash normalised away, so one directory written both ways is a single resource. Frames resolving to different URLs are reported `cantTell`, never `fail`: different resources can still be equivalent — differently worded copies of a page, or two adverts serving the same purpose — and neither the markup nor the embedded documents settle it, since content differing is exactly what those equivalent cases look like.
|
|
116
|
+
|
|
117
|
+
### Changed
|
|
118
|
+
- A rule mapped only to a Success Criterion the target WCAG version removed can no longer report `fail`. Under the default 2.2 target that means `duplicate-id`: it still runs and still reports every duplicate it finds, but comes back `cantTell` with a `wcagVersionScope` field naming the criterion 2.2 dropped, so a default scan is never gated by SC 4.1.1. Coercing rather than excluding keeps the defect visible, since a duplicate id still breaks `<label for>`, fragment navigation and `getElementById` whatever the standard says. Target 2.0 or 2.1 for the real failure, or keep excluding the rule outright with `excludeTags: ['wcag22-removed']`.
|
|
119
|
+
- `aria-controls` pointing at an id no element has is no longer a failure of `aria-valid-attr-value`. The menu, listbox or panel it names is routinely built when the widget opens, so a static scan that cannot find it has not established a defect. A collapsed element (`aria-expanded="false"` or `aria-selected="false"`) passes outright, since the absence is what that state means; anything else is reported as `cantTell` for review. Every other ID-reference attribute is unchanged: a dangling `aria-labelledby` or `aria-owns` names content that was supposed to be there already.
|
|
120
|
+
- The standalone browser bundle and its locale side files are minified. `surea11y.browser.js` goes from 1430 KB to 706 KB, and from 294 KB to 165 KB over the wire. Most of a page's download was the 130 inlined rule bodies, and most of those were their own comments. The global, the API and the result are unchanged; the readable form of every rule remains its own module under `src/checks`.
|
|
121
|
+
- `runa11yCoreAcrossFrames` scans its own frame through `runa11yCoreInPage` instead of carrying a second copy of the rule catalog and the shared runner block. The generated `src/core.js` drops from 4.34 MB to 2.67 MB, and the published package from 7.7 MB to 5.3 MB unpacked. Both functions stay usable the bundler-free way they always were — raw source injected into a page — since `runa11yCoreInPage` is itself self-contained and free of `require()`.
|
|
122
|
+
- The composite rollup moved out of `runCore` into its own function, `rollupCompositeResults`, in `src/core/dom-runner.js`. Results are unchanged. It was a long inline block nothing else could reach; splitting it keeps `runCore` readable and lets the rollup be called on its own. Internal only, not part of the package's public exports.
|
|
123
|
+
- `aria-required-children`, `aria-prohibited-children` and `aria-required-parent` map to SC 1.3.1 Info and Relationships instead of 4.1.2 Name, Role, Value, and carry `wcag131` in place of `wcag412`. The ACT rules these three implement, `bc4a75` and `ff89c9`, name 1.3.1 as their only requirement, and it is the criterion the finding actually describes: a `role="listitem"` outside any list, or a `role="list"` owning no item, misstates the structure exposed to assistive technology rather than the name, role or value of a control. It also puts them beside the native-HTML checks that ask the same question, `listitem-parent-valid` and `list-children-valid`, which were already 1.3.1. Level A either way, and all three still run on a default scan and report exactly what they reported before; what moves is which composite the verdict lands in, `wcag-1.3.1-info-and-relationships` gaining the three and `wcag-4.1.2-aria-validity` losing them, and what a tag-filtered run selects, since `tags: ['wcag412']` no longer picks them up. Their coverage facets move to 1.3.1 with them.
|
|
124
|
+
- `aria-hidden-body` and `aria-role-name-present` carry the `aria` tag the rest of the family already had. Both are entirely about ARIA usage — `aria-hidden` on the document body, and the roles WAI-ARIA requires an accessible name for — so a run filtered on `tags: ['aria']` was silently missing two of them. No other tag changes, and no rule changes what it evaluates or reports.
|
|
125
|
+
- `aria-required-parent` honours `aria-busy="true"` on an ancestor, WAI-ARIA's own escape hatch for a widget script has not finished assembling. `aria-required-children` and `aria-prohibited-children` already read it off the container they check; from the item's side the marked element is an ancestor, so the walk looks up rather than at the element itself, and only the exact string `"true"` counts. It also outranks the roleless-generic-parent rule, since `aria-busy` is a global ARIA attribute and would otherwise block the context search and fail the very markup the spec says to mark. A `role="option"` inside a container still loading its `role="listbox"` wrapper is `notApplicable` now, not a failure.
|
|
126
|
+
- `docs/OUTPUT_SCHEMA.md` no longer defines `fail` as a high-confidence outcome. Eight automatic rules ship `fail` at `confidence: "medium"`, and that was never a contradiction: the outcome describes the decision procedure, which guesses at nothing, while `confidence` describes the model it decides against — curated WAI-ARIA tables, native-role mappings, an accessibility tree inferred from static markup. The outcome table, the `type: "manual"` note, `POLICY.md`, `SARIF.md` and `LIMITATIONS.md` all said "deterministic, high-confidence"; they say "deterministic" now, and the confidence section names the rules and explains what `medium` on a `fail` means. Documentation only, no behaviour change.
|
|
127
|
+
- `aria-required-children` no longer fails a container for being empty; it reports `cantTell` for every finding. The rule asks only whether the required content is present, and a container that owns nothing conveys nothing false — an empty `role="list"` is announced as a list with no items, which is what it is. Whether the content a container does own is valid is `aria-prohibited-children`'s decision, and that rule still fails, so a `role="button"` among list items or a `role="tablist"` of plain buttons is caught exactly as before, at the same criterion. This also settles an inconsistency with the native-HTML rules, which already judge only the children that exist: `<ul></ul>` passed while `<div role="list"></div>` failed. One shape loses its failure and is now reported for review instead: a container whose items never got their role, `<div role="list"><div>Item</div></div>`. Applicability, the `aria-busy` exemption and `aria-owns` resolution are unchanged.
|
|
128
|
+
- Six ARIA rules report `cantTell` where the violation leaves the exposed name, role and value intact, instead of `fail`. ACT maps five of them to WAI-ARIA author requirements rather than to WCAG, and lists 1.3.1/4.1.2 as "less strict" secondary requirements that an implicit role or a spec-supplied default can still satisfy; the engine was asserting a Level A failure on all of them. Two grade per finding: `aria-required-attr` fails where ARIA supplies no stand-in (`aria-checked` on checkbox/radio/switch/menuitem*, `aria-valuenow` on slider/scrollbar/meter and a focusable separator) and reports `cantTell` where it does (`aria-expanded` on combobox, `aria-level` on heading), the table generated from aria-query's `requiredProps` by `scripts/generate-aria-tables.js` so the two tiers cannot drift from the spec; `aria-roles-valid` fails on a roleless host left exposed as generic and reports `cantTell` where a native role survives the bad token. Four report `cantTell` throughout: `aria-valid-attr` (an undefined attribute is inert), `aria-allowed-role` (an ARIA-in-HTML author requirement with no ACT rule and no WCAG mapping anywhere), `aria-braille-equivalent` and `aria-conditional-attr`, the last two also dropping from `serious` to `moderate`. Every finding is still reported with the same occurrences; what changes is that a page whose only ARIA defects are of this kind comes back `cantTell` on `wcag-4.1.2-aria-validity` rather than `fail`, so a CI gate on `fail` stops gating on them. ACT `674b10`, `4e8ab6` and `5f99a7` still run clean (25 cases, 0 mismatches). Reasoning in [`docs/DESIGN_CHALLENGES.md`](./docs/DESIGN_CHALLENGES.md).
|
|
129
|
+
- `aria-allowed-role` no longer claims a WCAG Success Criterion. ARIA-in-HTML's permitted-roles table is an author conformance requirement of that specification: no ACT rule covers it, and no source maps it to a criterion, so declaring SC 4.1.2 at Level A overstated every finding it made. It now carries `best-practice` in place of `wcag2a`/`wcag412`, with `wcagSc: []`, no `normativeMappings` and no coverage facet, and it has left the `wcag-4.1.2-aria-validity` composite (13 contributors) and the 4.1.2 facet registry. It still runs on a default scan and still reports the same findings at `cantTell`; what changes is that a run filtered on `tags: ['wcag2a']` or `['wcag412']` no longer selects it, and a page whose only ARIA-in-HTML nit is this one now reaches `pass` on that composite instead of `cantTell`. This is the first automatic rule in the engine with no WCAG mapping, alongside the 25 manual `best-practice` rules that already had none.
|
|
130
|
+
- `docs/RULE_TAXONOMY.md` §1.1 no longer says an automatic rule may use `cantTell` only as a defensive fallback, "never as its primary intended path". Six rules now lead with it. The dividing line between `automatic` and `manual` is whether a rule can decide, not which outcome it reports: an automatic rule reporting `cantTell` has decided and is saying what it found, while a manual rule reports `cantTell` because the question is not decidable from markup. The three cases where `cantTell` is an automatic rule's primary path are listed there.
|
|
131
|
+
|
|
132
|
+
### Fixed
|
|
133
|
+
- A frame responder answered any window that could reach it, not just the frame embedding it. `a11yCoreEnableFrameResponder()` is documented as opting a frame in to being scanned *from above*, but the listener ran a scan for any sender: a sibling frame can obtain a reference through `parent.frames[i]` and `postMessage` across origins, and the reply carries `occurrences[].html` — DOM content the same-origin policy gives that sibling no way to read. An opener could do the same. A `run` command is now answered only when its sender is the direct parent, which the hop-by-hop relay makes the only legitimate case, and a window nothing embeds answers nobody. **Affects every published version before 1.7.0**, and only consumers that call `a11yCoreEnableFrameResponder()` in a framed page — the automation-driver patterns (`@surea11y/playwright`, `@surea11y/puppeteer`, the CLI) never use the responder and were never exposed.
|
|
134
|
+
- A reply could be accepted from a window the request never went to. Any window naming an in-flight `requestId` could settle it, forging a frame's scan result or its failure. Each pending request now records the window it was sent to and ignores answers from anywhere else, pings included — reachability is the addressed frame's verdict to give.
|
|
135
|
+
- `engineOptions.excludeSelectors` cost a multiple of the whole scan. Every rule queries through `isExcluded`, which walked an element's ancestor chain once per selector with nothing remembered between rules, so the work was repeated for all 130 of them: excluding a cookie banner and four ad slots turned a 2.3s scan of a 3574-element page into 10.5s, and 25 exclusions — an ordinary list for a real site — into 43s. Exclusion results are now memoized per element for the run, partitioned by the effective exclude list exactly as the selector cache already is, and the self-test uses `matches` against the element rather than `closest` against the chain, since a parent's answer already settles its descendants. The same page with 25 exclusions takes 3.1s, and the raw matching floor is 18ms against the 5588ms an equivalent scan used to spend. Rule-scoped `rules[ruleId].excludeSelectors` keep their own cache partition, and a malformed selector still excludes nothing without disabling the usable selectors beside it. `perfStats` gains `excluded.hit`/`excluded.miss`.
|
|
136
|
+
- A rule that could not check anything said so, and SARIF dropped it. The contrast rules attach an occurrence to their `notApplicable` result naming the eligible text count and pointing at `contrast-computable`; the HTML report showed it, SARIF did not, so a CI pipeline reading only SARIF saw no contrast alerts and had no way to tell a clean page from one where contrast was never computable. Those occurrences now reach SARIF as `note`-level entries in `runs[0].invocations[0].toolExecutionNotices`, each carrying `associatedRule.id`. They are deliberately not results: every SARIF result renders as an alert, and "not evaluated" is not one, so nothing new gates a build or appears in Code Scanning. The block is emitted only when there is something to say.
|
|
137
|
+
- `OUTPUT_SCHEMA.md`, `SARIF.md` and `EARL.md` all stated that a `notApplicable` result carries no occurrences. It can: a rule that had nothing to judge may attach one occurrence saying why, and the contrast rules do exactly that when no text had a computable background — the message names the eligible text count and points at `contrast-computable`. A consumer that took the documented shape at face value read `occurrences.length` as a violation count and got one for a rule that flagged nothing. The behaviour is deliberate and unchanged; the three documents now describe it, `OUTPUT_SCHEMA.md` notes that such an occurrence carries an empty `selector` because it describes the scan rather than an element, and `SARIF.md` states that SARIF omits it — a SARIF consumer treats every result as an alert, so a pipeline reading only SARIF cannot tell "checked, nothing to flag" from "could not check" and needs `checksResults` or the HTML report for that distinction.
|
|
138
|
+
- `OUTPUT_SCHEMA.md` claimed without qualification that the engine verifies a reported `selector` resolves to the element it names. `page-title-present` is the exception, reporting `head > title` and an `html` of `<title>(missing)</title>` for a page that has neither, since its finding is the absence itself. Both are constants and the baseline fingerprint they feed stays stable; the field now says so rather than promising a resolvable selector.
|
|
139
|
+
- `LIMITATIONS.md` now records that scan time under jsdom grows with the square of DOM depth. jsdom resolves inherited CSS by walking the ancestor chain on every `getComputedStyle` call, so 4000 elements in one chain cost 8.3s of `getComputedStyle` alone against 0.25s for the same 4000 as siblings, with no engine code involved. The engine's own walks are capped and stay linear. A real browser computes inherited style natively and does not have this shape.
|
|
140
|
+
- `buildStructuralPath` was the one ancestor walk here with no bound. A consistent tree ends it when an element is not found among its parent's children, but a parent chain that cycles while still reporting itself as each other's child never terminates. It now gives up past a depth no real document reaches and returns `null`, matching what the field already documents for a path it cannot determine.
|
|
141
|
+
- `avoid-inline-spacing` no longer fails text that cannot wrap. ACT 78fd32/24afc2/9e45ec apply only to text containing a soft wrap break, and running the official corpus turned this up as the engine's one false positive across 798 cases: a fixed-width paragraph inside a horizontally scrolling container, which never wraps however the viewport changes, was reported as a violation. Layout would settle whether text wraps and a static scan cannot, but two shapes do establish that no wrap is possible — text not allowed to wrap, and a fixed-width element inside a horizontally scrolling ancestor — and those now report `cantTell` with the `not-computable` uncertainty code and a new `INLINE_SPACING_NO_SOFT_WRAP` reason code. Everything else is still treated as wrapping, so an ordinary forced value below the metric fails exactly as before. A false positive is the one thing that blocks an ACT implementation report, which is why this is graded rather than left as the documented gap it had been.
|
|
142
|
+
- `link-in-text-block`, `avoid-inline-spacing` and `css-orientation-lock` reported `pass` for candidates they never evaluated. Each counted an element as applicable, met a condition it could not resolve, skipped it, and fell through to `pass` — the same clean result whether the element was checked and found sound or never checked at all. They report `cantTell` for those candidates now, the computability gate `RULE_TAXONOMY.md` §1.1 already allows automatic rules. A proven failure still outranks an undecided candidate, so a page with both fails as before. Concretely: `link-in-text-block` skipped a link whose contrast against the surrounding text was not computable, `avoid-inline-spacing` an `!important` spacing value that resolved to no ratio, and `css-orientation-lock` every cross-origin stylesheet — so a page with one readable stylesheet and three unreadable ones asserted no orientation lock existed.
|
|
143
|
+
- `link-in-text-block` treated every link as underlined outside a real browser. It read `text-decoration-line` and the `text-decoration` shorthand as one set of tokens, which only holds where the two agree. jsdom does not cascade the property: the shorthand reads back as the user agent's `underline` for every `<a>` whatever the author stylesheet says, and the longhand as `none` unless the author wrote the longhand themselves, so `text-decoration: underline` and `text-decoration: none` are indistinguishable in the computed style. Reading the longhand alone inverts the error into a false failure on correctly underlined links. A conforming CSSOM serialises the shorthand with the line value first, so disagreement between the two is now the signal to distrust both, and the rule resolves the declaration from the author stylesheets instead, as `css-orientation-lock` and `css-focus-indicator-suppressed` already read the CSSOM. `:hover` and other state rules are excluded, since they do not describe the link's resting appearance, and inline style outranks the stylesheets.
|
|
144
|
+
- `link-in-text-block` now tests the cues that do not depend on `text-decoration` first, so a link distinguished by font weight, font style or sufficient contrast is decided even where decoration cannot be read.
|
|
145
|
+
- `contrast-minimum`, `contrast-enhanced` and `contrast-computable` skipped text assigned straight to a shadow root. A text node whose parent is the shadow root itself has no parent element, and the scan resolved colors from the parent element alone, so `shadowRoot.textContent = 'Some text'` was walked and then dropped with no candidate recorded — a component rendering its text that way was reported `notApplicable` rather than checked. The host carries the inherited color and background that text renders with, so it is what the scan attributes the text to now. Text inside an element within a shadow root was always found and is unchanged.
|
|
146
|
+
|
|
7
147
|
## [1.6.0] - 2026-08-23
|
|
8
148
|
|
|
9
149
|
### Added
|