@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/docs/API_STABILITY.md
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
Removing, renaming, or changing the type/meaning of any of these is a **major** version bump:
|
|
8
8
|
|
|
9
|
-
- Top-level result: `engine.tag`, `engine.schemaVersion`, `engine.locale` (the field and its `requested`/`resolved`/`reason` keys — the set of `reason` *values* is open and may gain entries in a minor), `url`, `checksResults` (an array), `rulesResults` (an array).
|
|
10
|
-
- Each `checksResults[i]` / `rulesResults[i]` entry: `ruleId`, `outcome`, `outcomeNormalized`, `severity`, `confidence`, `type`, `title`, `description`, `meta` (including `meta.normativeMappings`, `meta.deprecated`/`.deprecation` — see below), `engineOptions`, `schemaVersion`.
|
|
9
|
+
- Top-level result: `engine.tag`, `engine.schemaVersion`, `engine.locale` (the field and its `requested`/`resolved`/`reason` keys — the set of `reason` *values* is open and may gain entries in a minor), `engine.wcagVersion`, `engine.profile` (when present; the set of profile names may grow in a minor), `engine.optInRules` (when present; the set of tags may grow in a minor), `url`, `checksResults` (an array), `rulesResults` (an array), `overriddenBuiltinIds` (an array, empty when no `customRules` entry shadowed a built-in id — part of the extension contract, see below).
|
|
10
|
+
- Each `checksResults[i]` / `rulesResults[i]` entry: `ruleId`, `outcome`, `rollupIds` (check results only), `outcomeNormalized`, `severity`, `confidence`, `type`, `title`, `description`, `meta` (including `meta.normativeMappings`, `meta.deprecated`/`.deprecation` — see below), `engineOptions`, `schemaVersion`.
|
|
11
11
|
- Each occurrence (`occurrences[i]`): `selector`, `html`, `summary`, `hint`, `i18n`, `structuralPath`.
|
|
12
12
|
- The rule **catalog** (`getChecksCatalog()`/`getRulesCatalog()`, a separate surface from a scan result — see `RULE_AUTHORING.md`): `ruleId`, `title`, `description`, `tags`, `wcagSc`, `normativeMappings`, `defaultSeverity`, `defaultConfidence`, `type`, `deprecated`/`.deprecation`. Note `tags` lives here, not on a per-scan `checksResults[i].meta` — the two surfaces intentionally carry different subsets of a rule's metadata.
|
|
13
13
|
|
|
@@ -23,25 +23,84 @@ Since 1.4.0 the package declares an explicit `exports` map. These are the only i
|
|
|
23
23
|
| `@surea11y/core/baseline` | `src/baseline.js` | `buildBaselineEntries()`, `matchBaseline()` |
|
|
24
24
|
| `@surea11y/core/report` | `src/report.js` | `renderHtmlReport()` |
|
|
25
25
|
| `@surea11y/core/sarif` | `src/sarif.js` | `renderSarifReport()` |
|
|
26
|
+
| `@surea11y/core/junit` | `src/junit.js` | `renderJunitReport()` |
|
|
27
|
+
| `@surea11y/core/earl` | `src/earl.js` | `renderEarlReport()` |
|
|
28
|
+
| `@surea11y/core/en301549` | `src/en301549.js` | `EN301549_VERSIONS`, `EN301549_CLAUSES`, `en301549ClausesForSc()` |
|
|
29
|
+
| `@surea11y/core/wcag` | `src/wcag.js` | `WCAG_VERSIONS`, `wcagCriteria()`, `wcagCriterion()`, `wcagTags()` |
|
|
26
30
|
| `@surea11y/core/browser` | `surea11y.browser.js` | the standalone browser bundle, for bundlers that resolve it as a module |
|
|
27
31
|
|
|
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.
|
|
32
|
+
Anything **not** in that table — `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*`, `profiles/*` (a profile's tables and rules, which the engine reads), 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
33
|
|
|
30
34
|
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
35
|
|
|
36
|
+
## Extension points
|
|
37
|
+
|
|
38
|
+
The `exports` map above says which **paths** are importable. It does not say which **symbols** behind them are supported, and that distinction matters here: `src/index.js` re-exports the generated core verbatim, so every symbol the build emits reaches consumers whether or not it was meant for them. The classification lives in [`scripts/data/public-api.json`](../scripts/data/public-api.json) and is checked by `tests/public-api.test.js`, which fails when a new export appears unclassified — a leak has to be a decision, not an accident.
|
|
39
|
+
|
|
40
|
+
**Supported** — covered by semver, safe to build on:
|
|
41
|
+
|
|
42
|
+
| Export | For |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `runa11yCoreInPage` | Scanning from another JS realm: the whole engine is inlined, so `fn.toString()` re-evaluated in a browser tab works. What all five browser bindings use. |
|
|
45
|
+
| `runDomRulesInPage` | Scanning in the same Node process, dispatching through real `require()`. What `@surea11y/test-matchers` uses. |
|
|
46
|
+
| `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder` | Cross-frame scanning without an automation driver. |
|
|
47
|
+
| `getChecksCatalog()` / `getRulesCatalog()` | Reading the rule catalog; its stable fields are listed above. |
|
|
48
|
+
|
|
49
|
+
**Exported but internal** — reachable today, not supported, and free to change or disappear in a minor: `CHECK_DEFS`, `TEST_DEFS`, `COMPOSITE_RULES`, `DEFAULT_POLICY`, `POLICY_CONTRACTS`, `ENGINE_TAG`, `SCHEMA_VERSION`, `resolvePolicy`, `getCheckDefById`, `getCompositeRuleById`, `getChecksForRunOnly`, `getTestsForRunOnly`, `__internal`.
|
|
50
|
+
|
|
51
|
+
They stay exported rather than being removed, because removing them is itself a breaking change and no consumer needs it yet; the honest fix for now is to say they are not part of the contract. Note the two constants have supported equivalents on every result — `engine.tag` and `engine.schemaVersion` — so read them from there rather than importing them. Curating this list down to the supported set is a candidate for the next major.
|
|
52
|
+
|
|
53
|
+
### Extending the engine
|
|
54
|
+
|
|
55
|
+
Three things are meant to be extended, and all three go through `engineOptions` or a separate entry point rather than through the exported symbols above:
|
|
56
|
+
|
|
57
|
+
- **`engineOptions.customRules`** — the plugin mechanism: an array of rule descriptors registered for one call, never added to the static catalog and never persisted between calls. **The descriptor contract is covered by semver**: `id`, `meta`, `runInPage(ctx)` and the optional `applicability(ctx)` and `data`, along with the `ctx.helpers` a rule receives and the `{outcome, severity, occurrences}` it returns. That `runInPage`/`applicability` may be passed as a function *or* as a function-source string is part of the contract too, not a convenience: `engineOptions` crossing into another realm (a Playwright `page.evaluate`, say) cannot carry a live `Function`, so a binding has no other way to register one. A custom rule that shadows a built-in id replaces it for that scan and is reported back in `overriddenBuiltinIds`, so an accidental collision is visible rather than silent. A custom rule written against today's contract keeps working across minors; requiring a new field of it is a major. The full descriptor shape is in [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#customrules--runtime-registered-rules), the helpers in [`RULE_HELPERS.md`](./RULE_HELPERS.md), and the outcome rules a custom rule must obey in [`RULE_TAXONOMY.md`](./RULE_TAXONOMY.md).
|
|
58
|
+
- **`engineOptions.policyContract` / `engineOptions.policy`** — which outcomes and confidence values a scan may report, and whether a manual rule's would-be `fail` is coerced. The two option names, the built-in contract ids `'a11y'` and `'generic'`, and the inline-contract shape are supported; the `POLICY_CONTRACTS` export itself is not, since passing a string or an inline object is all a caller needs. See [`POLICY.md`](./POLICY.md).
|
|
59
|
+
- **Reporters** — `@surea11y/core/baseline`, `/report`, `/sarif` and `/earl` consume a result rather than hooking into the scan, which is why they are separate entry points. A consumer wanting a different output format reads the result shape above; nothing needs to be registered with the engine.
|
|
60
|
+
|
|
61
|
+
There is deliberately no hook for changing what a built-in rule decides. Overriding one means shipping a `customRules` entry that reuses its id, which the engine allows for a single call, warns about, and reports in `overriddenBuiltinIds` — so a scan that silently disagrees with the catalog is not possible.
|
|
62
|
+
|
|
32
63
|
## Explicitly unstable (not covered by semver)
|
|
33
64
|
|
|
65
|
+
- **What profiles build on: a rule's `settings` and `src/profile-kit.js`.** A rule's settings are the thresholds a built-in rule declares it reads from `ctx.config` (`contrast-minimum`'s `boldLargeMinPx`, `largeTextRatio`, `normalTextRatio`), for its variants ([`RULE_AUTHORING.md`](./RULE_AUTHORING.md#rule-variants)); a scan's `engineOptions.rules` cannot set them. `src/profile-kit.js` is the mapping a profile made with `npm run profile:new` uses, and is not exported. Both serve the profiles in this repository, which change with them, so they stay outside this contract until a profile can live outside it ([`profiles/README.md`](../profiles/README.md)).
|
|
34
66
|
- `perfStats` and `ruleTimings` — internal timing/debug counters, only present when `engineOptions.perfStats`/`.profileRules` is set. Shape not covered by this document.
|
|
35
|
-
- `occurrences[i].data.details` — rule-specific, non-normative extra context. Shape varies per rule and may change in a patch release; treat as best-effort, not a stable contract (this was already noted in `docs/OUTPUT_SCHEMA.md` before this document existed).
|
|
67
|
+
- `occurrences[i].data.details` — rule-specific, non-normative extra context. Shape varies per rule and may change in a patch release; treat as best-effort, not a stable contract (this was already noted in `docs/OUTPUT_SCHEMA.md` before this document existed). **`data.details.reasonCode` is the exception** and is stable — see [Finding identity](#finding-identity) below.
|
|
36
68
|
- `ruleInterfaceVersion` / `ruleVersion` on a rule's meta — currently unused scaffolding (every rule defaults to the same two static strings; nothing meaningfully sets or consumes them today). Not part of this contract until they're actually wired up to mean something.
|
|
37
69
|
|
|
70
|
+
## Finding identity
|
|
71
|
+
|
|
72
|
+
A consumer needs to know whether a finding it is looking at is the same one it saw last week. Two things in this package answer that, and both compute it the same way — `computeBaselineKey(ruleId, reasonCode, html)` in `src/baseline.js`:
|
|
73
|
+
|
|
74
|
+
- **Baselines.** `--write-baseline`/`--baseline` suppress known findings so a build only breaks on new ones.
|
|
75
|
+
- **SARIF.** `partialFingerprints['surea11y/violation/v1']`, which GitHub Code Scanning uses to decide whether an alert is the same alert or a new one.
|
|
76
|
+
|
|
77
|
+
So the identity is `ruleId` + `reasonCode` + the occurrence `html`, and two of those three are promises:
|
|
78
|
+
|
|
79
|
+
- **A rule id, once published, does not change.** Renaming or removing one is a major change. The supported path is to keep the id, mark it `deprecated` with `deprecation.replacedBy` naming the successor, and remove it only after the notice period.
|
|
80
|
+
- **A reason code, once a rule has shipped it, does not change.** This is a deliberate exception to the surrounding "`data.details` is unstable" rule: everything else under `data.details` is free-form, but `reasonCode` is load-bearing for identity, so it is pinned. Adding a new code to a rule is a minor change; changing or dropping an existing one is not, because every stored baseline entry and every open Code Scanning alert keyed on it stops matching.
|
|
81
|
+
- **A reason code retires only with the finding it named.** The promise is that a finding the engine still makes keeps its identity, not that a finding is made forever. When a correctness fix (a patch, see [below](#what-triggers-which-version-bump)) changes what a rule reports for an element, the finding the old code named no longer exists, and its code may go with it: a baseline entry or alert for it then closes, as it would for any fixed bug, rather than silently stopping to match a finding that is still there. Renaming a code for a finding that stays, or dropping one the rule still has a case for, is never allowed. A retirement is recorded, with its reason, in `CHANGELOG.md`, and under `retired` in `scripts/data/released-finding-ids.json` until the next release.
|
|
82
|
+
|
|
83
|
+
What the last release shipped is frozen in [`scripts/data/released-finding-ids.json`](../scripts/data/released-finding-ids.json), written by `npm run finding-ids:release -- <version>` as part of each release, and `tests/released-finding-ids.test.js` fails when a rule id or reason code from it is missing and not listed under `retired` with its reason. No commit rewrites that file between releases, so an identity cannot be dropped and the inventory regenerated in the same change without the test noticing. Each release freezes a new inventory and starts the `retired` list empty again, since what it held was not in that release; the changelog keeps the reasons.
|
|
84
|
+
|
|
85
|
+
Both are inventoried in [`scripts/data/finding-ids.json`](../scripts/data/finding-ids.json) for core's rules, and in each profile's own `scripts/data/finding-ids.json` for its rules, regenerated with `npm run finding-ids` and checked by `tests/finding-ids.test.js`, which fails when a published rule id or reason code disappears. The inventory is the record of what has been promised; the test is what stops the promise being broken by accident.
|
|
86
|
+
|
|
87
|
+
Note what identity does **not** include: `selector` and `structuralPath` deliberately stay out of the fingerprint, because both change when the surrounding page is edited, which would make every finding look new after an unrelated refactor. `html` is in, so editing the flagged element itself does read as a new finding — that is the intended trade-off, since the element's markup is the thing the finding is about.
|
|
88
|
+
|
|
89
|
+
### A removal that predates this guard
|
|
90
|
+
|
|
91
|
+
`area-alt-decorative`, shipped in 1.7.0, was removed afterwards without the deprecation period described [below](#rule-id-deprecation-policy). It asked a human whether an `<area>` with an empty `alt` was decorative, a question with no legitimate "yes": `area-alt-present` now fails that case outright ([`DESIGN_CHALLENGES.md`](./DESIGN_CHALLENGES.md)). The removal was accepted as an exception rather than reverted, and is recorded in the 1.8.0 changelog. Anything holding its id — a `runOnly` list, a baseline entry — matches nothing from the release after 1.7.0; the empty-`alt` area it asked about is reported by `area-alt-present` instead.
|
|
92
|
+
|
|
93
|
+
### A rename that predates this
|
|
94
|
+
|
|
95
|
+
`role-img-alt-present` became `role-img-text-alternative-present` with no deprecation entry and no major bump, before any of the above was written down. Anything holding the old id — a baseline entry, a `runOnly` list — silently matched nothing. The rename is not reversible now: the old id has been absent across every 1.x release, so a deprecation entry today would announce the retirement of something no current version answers to. It is recorded here instead, because it is the reason this section exists. Its source file, fixture and test kept the old name for a while afterwards, which is what made the rename easy to miss; they carry the rule's own id now.
|
|
96
|
+
|
|
38
97
|
## What triggers which version bump
|
|
39
98
|
|
|
40
99
|
- **Patch**: a correctness fix that changes *which* outcome a rule produces for the same input, without changing the shape or mechanism. Example: the fragment-scan applicability fix (`engineOptions.fragment`, see `ENGINE_OPTIONS.md`) changed several rules from incorrectly `fail`ing on a scoped subtree to correctly `notApplicable` — that's a patch, not a major bump, because no stable field's *shape* changed, only a bug got fixed. Don't over-index on "any output change = major" — bug fixes are expected to change output.
|
|
41
100
|
- **Minor**: adding a new stable field, adding a new rule to the catalog, or marking an existing rule `deprecated` (see below).
|
|
42
101
|
- **Major**: removing or renaming a stable field, changing a stable field's type or meaning, or removing a rule ID once its deprecation notice period has passed. Paired with an `engine.schemaVersion` bump specifically when the *shape* changes (as opposed to package-level major bumps for other reasons, e.g. dropping support for an old Node version).
|
|
43
102
|
|
|
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. `engine.locale` is the second: a new field next to the existing ones, with no change to any field a consumer already reads.
|
|
103
|
+
`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. `engine.locale` is the second: a new field next to the existing ones, with no change to any field a consumer already reads. `engine.wcagVersion` and the optional per-result `wcagVersionScope` are the third, on the same reasoning — but note the *outcome* change that came with them (a rule mapped to the removed SC 4.1.1 now reports `cantTell` instead of `fail` under the default 2.2 target) is an outcome fix of the kind described above, not a shape change.
|
|
45
104
|
|
|
46
105
|
## Release cadence
|
|
47
106
|
|
|
@@ -53,6 +112,8 @@ The version number is the contract — not a measure of how much has changed or
|
|
|
53
112
|
|
|
54
113
|
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
114
|
|
|
115
|
+
Each release freezes the finding identities it ships: run `npm run finding-ids:release -- <version>` and commit `scripts/data/released-finding-ids.json` with the release (see [Finding identity](#finding-identity)).
|
|
116
|
+
|
|
56
117
|
## Rule-ID deprecation policy
|
|
57
118
|
|
|
58
119
|
A rule can be marked deprecated in its own `meta`:
|
|
@@ -78,7 +139,7 @@ The process:
|
|
|
78
139
|
2. Leave it running normally for at least one full minor version cycle after the deprecation, so integrators pinned to `^x.y.0` have a real chance to see it before it's gone.
|
|
79
140
|
3. Remove the rule file entirely in a future **major** version, documented under `### Removed`.
|
|
80
141
|
|
|
81
|
-
|
|
142
|
+
`iframe-title-unique` was the first rule to use this mechanism, deprecated in 1.8.0 in favour of `identical-iframes-same-purpose` (see `DESIGN_CHALLENGES.md`). It also reports `notApplicable` on every page, because the `fail` it used to report was not a WCAG violation and waiting for 2.0.0 to stop reporting one was not acceptable. That is a property of the retired check, not of deprecation: a deprecated rule whose results are still correct keeps producing them, as described above. Its reason code, `IFRAME_TITLE_DUPLICATE`, is no longer emitted, so it retired with the finding it named, as the 1.8.0 changelog records (see [Finding identity](#finding-identity)).
|
|
82
143
|
|
|
83
144
|
## See also
|
|
84
145
|
|
|
@@ -36,6 +36,108 @@ A short list, derived from what the audit pass on `@surea11y/playwright` actuall
|
|
|
36
36
|
- [ ] If you support cross-frame scanning via your own driver, confirm each frame's result gets the same normalization (selector/structuralPath/severity) as a single-document scan — don't let a "per-frame" code path silently skip the shared result-shaping logic.
|
|
37
37
|
- [ ] TypeScript types (if you ship any) stay in sync with actual engine output — `structuralPath: number[] | null` and any new fields ([`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) is the source of truth) are easy to leave stale after an engine update.
|
|
38
38
|
|
|
39
|
-
##
|
|
39
|
+
## Getting the engine into the page without paying for it twice
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
A binding that crosses a realm boundary has to get the engine into the page
|
|
42
|
+
somehow. The obvious way — serialize `runa11yCoreInPage` with `.toString()` and
|
|
43
|
+
hand it to the driver's evaluate-in-page call — works, and is what
|
|
44
|
+
`@surea11y/playwright` did first, but it sends the whole engine **on every
|
|
45
|
+
call**: about 2.1MB per frame, per scan. A five-frame scan sends it five times,
|
|
46
|
+
and the next scan sends it all again.
|
|
47
|
+
|
|
48
|
+
The package ships a smaller way. `@surea11y/core/browser` is the standalone
|
|
49
|
+
bundle: the same `runa11yCoreInPage`, minified, about 780KB, which defines
|
|
50
|
+
`window.a11ycore`. Load it into the document once and every later scan costs a
|
|
51
|
+
few hundred bytes.
|
|
52
|
+
|
|
53
|
+
The shape below is deliberately driver-neutral — `evaluateInPage` stands for
|
|
54
|
+
whatever your driver calls it (`page.evaluate` in Playwright and Puppeteer,
|
|
55
|
+
`browser.execute` in WebdriverIO, `driver.executeScript` in Selenium, which
|
|
56
|
+
takes a *string* of script text rather than a function):
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
const fs = require('fs');
|
|
60
|
+
const BUNDLE = fs.readFileSync(require.resolve('@surea11y/core/browser'), 'utf8');
|
|
61
|
+
|
|
62
|
+
// Returns true when window.a11ycore is ready to use in this document.
|
|
63
|
+
async function ensureEngine(target) {
|
|
64
|
+
if (await evaluateInPage(target, () => typeof window.a11ycore !== 'undefined')) return true;
|
|
65
|
+
try {
|
|
66
|
+
await evaluateInPage(target, (src) => (0, eval)(src), BUNDLE);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
// Confirm it actually landed rather than assuming: see the CSP note below.
|
|
71
|
+
return evaluateInPage(target, () => typeof window.a11ycore !== 'undefined');
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then use it if it is there, and keep the serialize path as the fallback:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
async function scan(target, args) {
|
|
79
|
+
if (await ensureEngine(target)) {
|
|
80
|
+
return evaluateInPage(
|
|
81
|
+
target,
|
|
82
|
+
(a) => window.a11ycore.runa11yCoreInPage(a.url, a.contextSelector, a.engineOptions, a.runOnly),
|
|
83
|
+
args
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return evaluateInPage(target, serializedRunnerFn, args); // what your binding does today
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Four things to know before adapting it:
|
|
91
|
+
|
|
92
|
+
- **Verify the global appeared; don't assume.** That is what makes this safe to
|
|
93
|
+
adopt across drivers without auditing each one's execution model. A driver
|
|
94
|
+
whose script execution is subject to the page's own CSP will fail to define
|
|
95
|
+
the global, `ensureEngine` returns false, and the scan falls back to the
|
|
96
|
+
payload it uses today rather than breaking.
|
|
97
|
+
- **Don't reach for `addScriptTag`.** It injects an inline `<script>`, which a
|
|
98
|
+
page serving `script-src 'self'` refuses — confirmed in Chromium against both
|
|
99
|
+
a page and a sub-frame. Playwright's and Puppeteer's `evaluate` run through
|
|
100
|
+
CDP, outside the page's CSP, so the snippet above was confirmed working under
|
|
101
|
+
`script-src 'self'` with no `unsafe-eval`. Other drivers execute scripts by
|
|
102
|
+
other means; the presence check above is what covers the difference.
|
|
103
|
+
- **A navigation clears it.** `window.a11ycore` belongs to the document, so the
|
|
104
|
+
check has to run per frame and after every navigation. That is why `scan`
|
|
105
|
+
calls `ensureEngine` unconditionally rather than caching a flag on the
|
|
106
|
+
binding.
|
|
107
|
+
- **The bundle carries English only.** Every other locale is a side file, so a
|
|
108
|
+
binding forwarding `engineOptions.locale` must load the matching one the same
|
|
109
|
+
way, before the scan:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
const primary = String(locale || 'en').trim().toLowerCase().split('-')[0];
|
|
113
|
+
if (primary && primary !== 'en') {
|
|
114
|
+
let localePath;
|
|
115
|
+
try {
|
|
116
|
+
localePath = require.resolve(`@surea11y/core/i18n/${primary}`);
|
|
117
|
+
} catch {
|
|
118
|
+
localePath = null; // not a locale this build ships; English is the fallback
|
|
119
|
+
}
|
|
120
|
+
if (localePath) {
|
|
121
|
+
await evaluateInPage(target, (src) => (0, eval)(src), fs.readFileSync(localePath, 'utf8'));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Without it the scan still succeeds, but in English, with
|
|
127
|
+
`engine.locale.reason` reporting `dictionary-not-loaded` — the engine saying
|
|
128
|
+
this step was missed, rather than a failure to swallow.
|
|
129
|
+
|
|
130
|
+
Results are identical either way: same rules, same composites, same `engine`
|
|
131
|
+
block, verified over a page seeded with a spread of violations. This is only
|
|
132
|
+
about what crosses the wire.
|
|
133
|
+
|
|
134
|
+
### Which situation is your binding in
|
|
135
|
+
|
|
136
|
+
Only the first row pays the serialization cost this section is about.
|
|
137
|
+
|
|
138
|
+
| Binding | Realm | What to do |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| Playwright, Puppeteer, Selenium, WebdriverIO | Node drives a separate browser realm | Everything above: load `@surea11y/core/browser` into the document once, scan through `window.a11ycore`, keep the serialize path as fallback. |
|
|
141
|
+
| Cypress | Test code already runs in the browser, with the app under test in a same-origin frame | No serialization boundary, so nothing crosses the wire — but the engine still has to be evaluated into the app's realm rather than called from the runner's, or it reads the wrong `document`. `require` the engine and `win.eval` its source there (what `@surea11y/cypress` does), or read the bundle with `cy.readFile` and evaluate that instead; both put it in the right realm. Size is not the deciding factor here. |
|
|
142
|
+
| Jest, Vitest, or anything else driving jsdom in-process | One realm, in Node | None of this applies. `require('@surea11y/core')` and call `runDomRulesInPage` against the DOM you already have — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 1. Injecting a bundle here would be strictly worse. |
|
|
143
|
+
| Browser extension, bookmarklet, injected script | Already in the page | Load the bundle once and call it; that is the case it was built for. |
|
package/docs/CI_INTEGRATIONS.md
CHANGED
|
@@ -98,6 +98,49 @@ pipelines:
|
|
|
98
98
|
|
|
99
99
|
The step fails the pipeline on the CLI's exit code exactly like any other `script` entry; `a11y-report.html` (see [`REPORT.md`](./REPORT.md)) is attached as a downloadable build artifact so a reviewer can open it without re-running the scan locally.
|
|
100
100
|
|
|
101
|
+
## JUnit test reports
|
|
102
|
+
|
|
103
|
+
GitLab, Azure DevOps, Jenkins and CircleCI show JUnit XML in their own test views: each WCAG criterion becomes a suite and each rule a test (see [`JUNIT.md`](./JUNIT.md)). Save the scan as JSON, render it with `@surea11y/core/junit`, and keep the CLI's exit code for gating. The render step needs `@surea11y/core` in the project's own `devDependencies`.
|
|
104
|
+
|
|
105
|
+
### GitLab CI
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
a11y:
|
|
109
|
+
image: node:20
|
|
110
|
+
script:
|
|
111
|
+
- npm ci
|
|
112
|
+
- npm run build
|
|
113
|
+
- npx @surea11y/cli scan ./dist/index.html --json > a11y-scan.json || scan_exit=$?
|
|
114
|
+
- node -e "const fs = require('fs'); const { renderJunitReport } = require('@surea11y/core/junit'); fs.writeFileSync('a11y.junit.xml', renderJunitReport(JSON.parse(fs.readFileSync('a11y-scan.json', 'utf8'))))"
|
|
115
|
+
- exit ${scan_exit:-0}
|
|
116
|
+
artifacts:
|
|
117
|
+
when: always
|
|
118
|
+
reports:
|
|
119
|
+
junit: a11y.junit.xml
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`when: always` uploads the report even when the scan's exit code fails the job, which is exactly when the merge request widget is worth reading.
|
|
123
|
+
|
|
124
|
+
### Azure DevOps
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
steps:
|
|
128
|
+
- script: |
|
|
129
|
+
npm ci
|
|
130
|
+
npm run build
|
|
131
|
+
npx @surea11y/cli scan ./dist/index.html --json > a11y-scan.json || scan_exit=$?
|
|
132
|
+
node -e "const fs = require('fs'); const { renderJunitReport } = require('@surea11y/core/junit'); fs.writeFileSync('a11y.junit.xml', renderJunitReport(JSON.parse(fs.readFileSync('a11y-scan.json', 'utf8'))))"
|
|
133
|
+
exit ${scan_exit:-0}
|
|
134
|
+
displayName: Accessibility scan
|
|
135
|
+
- task: PublishTestResults@2
|
|
136
|
+
condition: succeededOrFailed()
|
|
137
|
+
inputs:
|
|
138
|
+
testResultsFormat: JUnit
|
|
139
|
+
testResultsFiles: a11y.junit.xml
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`cantTell` rules arrive as skipped tests, never failures. To gate on them too, pass `{ cantTellAs: 'failure' }` as the second argument to `renderJunitReport`; note that it changes the report, not the CLI's exit code.
|
|
143
|
+
|
|
101
144
|
## Free-tier/private-repo minute limits
|
|
102
145
|
|
|
103
146
|
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).
|
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
A running log of engine design decisions worth re-examining: cases where an existing choice turned out to conflict with a ground-truth source (usually the W3C ACT rules test corpus), or just looks questionable on a second look. Not all of these are bugs; some are tradeoffs that deserve a second opinion before being confirmed or overturned. Each entry has the decision as it stands, why it's being questioned, and its current status. Settled entries move to [Decided](#decided) at the bottom, with the reasoning kept, since a decision is only useful later if the argument behind it survives with it.
|
|
4
4
|
|
|
5
|
+
## Method
|
|
6
|
+
|
|
7
|
+
Before changing behavior that already ships, or agreeing that a challenge to it is right, establish why the current behavior exists rather than assuming it was either careless or considered.
|
|
8
|
+
|
|
9
|
+
- **Read the history first.** `git log -L <start>,<end>:<file>` (or `git log -S<token>`) on the exact lines, and check whether the commit message, this document, `RULE_AUTHORING.md`, or `CHANGELOG.md` already recorded a reason. A comment that states what the code does is not evidence of why it does it — say explicitly when no rationale can be found instead of inventing one to fill the gap.
|
|
10
|
+
- **Check for a second implementation of the same concept.** A question this engine has needed to answer more than once (eligibility, focusability, naming, inertness) is sometimes reimplemented locally inside one rule rather than shared through `dom-helpers.js`. When two independent implementations disagree on the same input, that disagreement is stronger evidence of a real problem than either implementation's own comment defending itself.
|
|
11
|
+
- **Weigh it against ground truth, not intuition:** the WCAG Understanding documents and Techniques, the ACT rules corpus (this repo's primary source, see the intro above), and the HTML/ARIA spec text for the exact mechanism in question — what `inert`, `aria-hidden`, or a native role default actually do, not what seems reasonable.
|
|
12
|
+
- **Compare against other accessibility testing engines where practical**, as one more data point on how the same ambiguity is usually resolved in the field — never as an authority on its own, and never named or quoted in a rule's comments, commits, or docs (this repo never names competing engines).
|
|
13
|
+
- **State the counter-argument before concluding.** Write down why the current behavior might be right, and why it hasn't already been revisited, before deciding it should change. "Nobody thought this through" and "this was already considered and rejected" call for different next steps.
|
|
14
|
+
- **Land the decision somewhere.** Fix it with a `CHANGELOG.md` entry that states the reasoning, or add/update an entry in this file's [Open](#open) or [Decided](#decided) section, so the argument survives with the decision the next time someone re-examines it.
|
|
15
|
+
- **After changing a rule's behavior**, regenerate every generated artifact that describes it, not just the code and tests: `npm run docs:rule-catalog`, `npm run coverage`, `npm run fixtures:index`, `npm run fixtures:markers`, `npm run finding-ids`, and `npm run docs:rule-review` (the by-hand review page). None of these run automatically from a source edit, and a stale one is easy to miss since nothing fails loudly except `fixtures:markers:check`/`coverage:check`/`finding-ids`'s own test.
|
|
16
|
+
|
|
5
17
|
## Open
|
|
6
18
|
|
|
7
19
|
### `label-in-name` compares against accessibility-tree text, where ACT uses *visible* inner text
|
|
@@ -28,6 +40,52 @@ A running log of engine design decisions worth re-examining: cases where an exis
|
|
|
28
40
|
|
|
29
41
|
## Decided
|
|
30
42
|
|
|
43
|
+
### `aria-required-children` failed a container for being empty, when its sibling already owns the question of whether the contents are valid, now advisory
|
|
44
|
+
|
|
45
|
+
**Decision as it stands (before the change):** the rule failed any container role with a "required owned elements" entry that had no descendant (or `aria-owns` target) carrying one of those roles. An empty `<div role="list">`, a `role="tablist"` before its tabs arrive, a `role="rowgroup"` with no rows: all `fail` at `moderate`, mapped to SC 1.3.1.
|
|
46
|
+
|
|
47
|
+
**Why it was questioned:** the rule asks one thing, whether the required content is PRESENT. Whether the content a container does own is VALID is `aria-prohibited-children`'s decision, and that rule fails independently. Absence conveys nothing false: an empty `role="list"` is announced as a list with no items, which is exactly what it is. The engine's own native-HTML rules already work this way, which made the ARIA side incoherent by comparison: `<ul></ul>` passes and `<div role="list"></div>` failed, same structure, same emptiness, opposite verdicts, with nothing in WCAG distinguishing them. ACT `bc4a75`, the authority this rule's `fail` rests on, turns out not to cover the shape at all: its Expectation is "each test target only owns elements with a semantic role from the required owned element list", which an empty container satisfies vacuously, and it publishes no empty-container example in either direction. The engine's clean run against that corpus was therefore silent about this case rather than confirming it.
|
|
48
|
+
|
|
49
|
+
**What was weighed:** three predicates were on the table. Cap the rule at `cantTell` outright; keep a `fail` for a container that owns roles but none of the required ones; or the stricter line used elsewhere in the industry, `cantTell` only when the container owns no content whatsoever, so a container of unroled elements still fails. The middle option was dropped once it was clear that every shape it would fail is already failed by `aria-prohibited-children`, making it a second rule agreeing with the first rather than a decision of its own, against this repo's one-rule-one-decision principle. The strict option was dropped because a container of unroled elements is equally "a broken list" and "an empty list with content inside it", and static markup does not settle which.
|
|
50
|
+
|
|
51
|
+
**Decision (2026-08-28):** the rule reports `cantTell` for every finding and can no longer `fail`. Applicability, the `aria-busy` escape hatch, accessibility-tree eligibility, `aria-owns` resolution and slot expansion are all unchanged, as is `aria-prohibited-children`.
|
|
52
|
+
|
|
53
|
+
**Accepted cost:** `<div role="list"><div>Item one</div><div>Item two</div></div>`, a list whose items never got their role, is now reported for review rather than failed, and no other rule fails it. That is the only shape that loses a failure; the fixture carries it as case 09 and a test pins the sibling rule still failing a genuinely disallowed child, so the safety net this depends on cannot be removed quietly.
|
|
54
|
+
|
|
55
|
+
**Status:** resolved 2026-08-28.
|
|
56
|
+
|
|
57
|
+
### The `aria-*` family reported ARIA-spec conformance as a WCAG 4.1.2 failure, where ACT's own mapping calls most of it "not required for conformance", now graded
|
|
58
|
+
|
|
59
|
+
**Decision as it stands (before the change):** 13 of the 16 automatic `aria-*` rules declared `wcagSc: ['4.1.2']` at level A (the other three were re-mapped to 1.3.1, see Status), `normative: true` (no rule anywhere in this repo sets `normative: false`) and `defaultConfidence: 'high'` — the exception being `aria-required-parent`, which is `medium` and still emits a flat `fail`. Eleven of the sixteen emit a flat `fail` with no second tier; five grade into a `cantTell` tier (`aria-allowed-attr`, `aria-deprecated-role`, `aria-prohibited-attr`, `aria-valid-attr-value`, `aria-hidden-focus`). Seventeen atomic rules feed one composite, `wcag-4.1.2-aria-validity`, whose own description says it rolls up checks "that ARIA role and attribute usage conforms to the WAI-ARIA specification"; any single contributor `fail` makes that SC verdict `fail`.
|
|
60
|
+
|
|
61
|
+
**Why it's being questioned:** the strictness is not in the detection logic, which is conservative and well guarded — `aria-required-children` honours `aria-busy`, accessibility-tree eligibility, `aria-owns` and slot projection; `REQUIRED_PROPS_BY_ROLE` deliberately omits context-dependent properties; `ALLOWED_ROLES_BY_ELEMENT` treats unmodelled elements as unconstrained. It is entirely in the verdict layer, and ACT's own Accessibility Requirements Mapping disagrees with it for most of the family:
|
|
62
|
+
|
|
63
|
+
| ACT rule | This repo | ACT's primary requirement | WCAG status per ACT |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| `4e8ab6` required states/properties | `aria-required-attr` | ARIA5 technique; ARIA 1.2 §5.2.2 | not required for WCAG conformance; 1.3.1/4.1.2 are secondary and "less strict", they "allow for fallback default values that may make some failures acceptable" |
|
|
66
|
+
| `5c01ea` property permitted | `aria-allowed-attr` | ARIA5 technique; ARIA 1.2 §8.6 | not required for WCAG conformance; 1.3.1/4.1.2 secondary, "less strict" |
|
|
67
|
+
| `5f99a7` attribute defined | `aria-valid-attr` | none | 1.3.1/4.1.2 secondary, "less strict" |
|
|
68
|
+
| `6a7281` valid value | `aria-valid-attr-value` | none | "not required for conformance to WCAG 2.1 at any level" |
|
|
69
|
+
| `674b10` role has valid value | `aria-roles-valid` | ARIA4, G108 techniques | not required for conformance to any W3C recommendation; 4.1.2 "can be satisfied through the implicit role" |
|
|
70
|
+
| `bc4a75` required owned elements | `aria-required-children`, `aria-prohibited-children` | **1.3.1 Info and Relationships** | required for conformance, on **1.3.1** |
|
|
71
|
+
| `ff89c9` required context role | `aria-required-parent` | **1.3.1 Info and Relationships** | required for conformance, on **1.3.1** |
|
|
72
|
+
|
|
73
|
+
Two separate problems fall out of that table. Five of the seven are ARIA *author* requirements that WCAG does not mandate, reported here as level-A WCAG failures at high confidence. The other two are conformance-required, but on 1.3.1, not the 4.1.2 all three rules declare: a plain SC misattribution that lands the verdict on the wrong criterion (the level is unaffected, both are A). `aria-allowed-role` is the weakest claim in the family — no ACT rule covers it and no external source maps ARIA-in-HTML's permitted-roles table to a Success Criterion, yet it fails at high confidence. Six ARIA rules have no ACT counterpart at all (`ACT_RULE_MAPPING.md`'s no-ground-truth list), which is exactly where the `fail` decision has nothing external checking it.
|
|
74
|
+
|
|
75
|
+
The distinction ACT is drawing is whether the exposed name, role and value survive the violation. `<button role="buton">` is still exposed as a button; `<div role="checkbox">` with no `aria-checked` gets ARIA's own `false` default, and whether that default is *wrong* is `aria-checked-state-mismatch`'s question, already capped at `cantTell`; `<div role="heading">` with no `aria-level` is still a heading at the user agent's default level; `aria-brailleroledescription` without `aria-roledescription` reaches no user at all and is currently `serious`. Against that, `aria-label` on a roleless `<span>` genuinely loses the name — and that case is already graded, by `aria-prohibited-attr`, on precisely this reasoning.
|
|
76
|
+
|
|
77
|
+
The engine's stated bar is that `fail` stays reserved for deterministic violations. The family currently reads that as high confidence *that the ARIA specification was violated*, which it reliably is; the argument here is that it should mean high confidence that *the Success Criterion the rule names is failed*, which for most of the family it is not.
|
|
78
|
+
|
|
79
|
+
**What the graded family looks like:** `aria-hidden-body`, `aria-hidden-focus` and `aria-role-name-present` stay `fail` unchanged — each is a present barrier, not a spec citation. `aria-required-children`/`-prohibited-children`/`-required-parent` stay `fail` on ACT's authority, and are the part of this entry already acted on. Four rules gain a second tier on the fallback-survival axis: `aria-roles-valid` (`fail` only where the host has no implicit role to fall back on, a distinction `getNativeRoleForElement` already computes), `aria-required-attr` (`fail` for `slider`/`scrollbar`/`meter` missing `aria-valuenow`, which is a genuinely absent value; `cantTell` for the roles ARIA gives a default), `aria-valid-attr` (an undefined attribute is inert, so `fail` only where the misspelling plausibly cost the element a name it does not otherwise have), and `aria-braille-equivalent` (the `aria-brailleroledescription` half has no user-facing consequence and should not be `serious`). The remaining question is whether the family needs a third outcome tier altogether — an ARIA-conformance finding that is real, reported, and does not claim a WCAG SC, expressible today as `normative: false` plus a WAI-ARIA `normativeMappings` entry, which would also keep it out of the SC composite.
|
|
80
|
+
|
|
81
|
+
**What was weighed against changing it:** it moves scans that are red today to yellow, which is a louder change for existing baselines than removing false positives ever was, and it weakens what integrators can gate CI on: `cantTell` is not enforceable the way `fail` is. There is also a real argument for the status quo — an ARIA spec violation is a decent leading indicator even when it is not itself a barrier, since undefined behaviour differs across assistive technology and today's harmless default is tomorrow's regression. The rebuttal is that the package's premise is telling you what it cannot tell you, and a graded `cantTell` carrying a reason code says more than a `fail` that overstates its own authority. Every mechanism this needs already ships: `aria-deprecated-role` grades on the strength of the spec's own statement (MUST NOT versus SHOULD NOT), `resolveTieredOutcome` carries both tiers, the 4.1.1 handling already coerces an out-of-scope SC to `cantTell` with a `wcagVersionScope` field, and `POLICY_CONTRACTS` exposes `allowedOutcomes`/`allowedConfidence`. What is missing is the second axis — grading on whether name, role and value survive, not only on how strongly ARIA words the requirement.
|
|
82
|
+
|
|
83
|
+
**Two by-products found while reading, both smaller and independently fixable:** `aria-hidden-body` and `aria-role-name-present` were missing the `aria` tag the rest of the family carries, so filtering by tag leaked; both carry it now. The second was wrong as written: `aria-required-parent` is not the only rule emitting a flat `fail` at `medium` confidence, it is one of six, three of them outside the ARIA family, so there is no anomaly at that rule to fix. What the reading did turn up is a documented contradiction, since `OUTPUT_SCHEMA.md` defined `fail` as a high-confidence outcome while six automatic rules shipped `fail` at `medium`; the outcome describes the decision procedure and `confidence` the model it decides against, and the docs say so now. `aria-required-parent` separately gained the `aria-busy` ancestor guard its two siblings already had. `nested-interactive-controls-absent` is untagged for `aria` too, left alone on purpose, since it covers native nesting as much as the ARIA kind.
|
|
84
|
+
|
|
85
|
+
**Status:** resolved 2026-08-28. The six rules above now grade, `aria-required-attr` and `aria-roles-valid` on a computed predicate and the other four wholesale, with the two implicit-value cases generated from aria-query so the tiers cannot drift from the spec by hand. Across the 137-fixture corpus the change moves 12 rule verdicts and one composite (`wcag-4.1.2-aria-validity`, `fail` to `cantTell` on 13 fixtures); no occurrence count changes anywhere, so nothing stopped being reported. ACT `674b10`, `4e8ab6` and `5f99a7` still run clean against their live corpora (25 cases, 0 mismatches), since the checker counts a `cantTell` carrying occurrences as satisfying a "failed" expectation.
|
|
86
|
+
|
|
87
|
+
Then the remaining overstatement went too. `aria-allowed-role` declared SC 4.1.2 with no ACT rule and no source mapping ARIA-in-HTML's permitted-roles table to a criterion; it now declares none, tagged `best-practice` with `wcagSc: []`, out of `wcag-4.1.2-aria-validity` and out of the facet registry. It is this engine's first automatic rule with no WCAG mapping, which `meta.normative` could not have expressed (that field is inert) and which needed the composites catalog, the facet registry and the level tags edited by hand. The rule still runs by default, still reports the same findings; on the fixture corpus the only effect is that `wcag-4.1.2-aria-validity` reaches `pass` on 8 fixtures where an ARIA-in-HTML nit was the sole remaining contributor. `docs/RULE_TAXONOMY.md` §1.1 was rewritten alongside it: the automatic/manual line is whether a rule can decide, not which outcome it reports, so a deterministic rule reporting `cantTell` is still automatic.
|
|
88
|
+
|
|
31
89
|
### `contrast-minimum`/`contrast-enhanced` treated symbol-only text as real text needing a contrast ratio, fixed
|
|
32
90
|
|
|
33
91
|
**Decision as it stands (before the fix):** the shared text scan's applicability gate (`isNonEmptyText` in `getTextScan()`, `src/core/contrast-helpers.js`) only checked for non-whitespace characters. A text node made entirely of punctuation/symbol glyphs (`----=====+++...±±±±@@@@@@@@`) counted the same as real words.
|
|
@@ -72,7 +130,7 @@ A running log of engine design decisions worth re-examining: cases where an exis
|
|
|
72
130
|
|
|
73
131
|
**Decision as it stands (before the fix):** `hasHeading()` credited any `<h1>`-`<h6>`/`[role="heading"]` that was included in the accessibility tree; an off-screen-positioned, clipped, opacity:0, or zero-size-overflow-hidden heading counted the same as a fully visible one. This mismatch was never individually triaged; it sat inside `ye5d6e`/`047fe0`'s combined "1, 2" mismatch count, both filed under one blanket "deliberate leniency" reason that only actually described a *different* shape (a heading positioned inside the repeated content it's supposed to be an escape from).
|
|
74
132
|
|
|
75
|
-
**Why it was questioned:** re-fetching `047fe0`'s live corpus surfaced a case that reason doesn't cover at all: `<h1 class="off-screen">` inside `<div id="main">`, correctly positioned *after* the repeated nav, still expected **failed** by ACT. Its own Expectation text requires the heading to be both "included in the accessibility tree" *and* "[visible](
|
|
133
|
+
**Why it was questioned:** re-fetching `047fe0`'s live corpus surfaced a case that reason doesn't cover at all: `<h1 class="off-screen">` inside `<div id="main">`, correctly positioned *after* the repeated nav, still expected **failed** by ACT. Its own Expectation text requires the heading to be both "included in the accessibility tree" *and* "[visible](https://www.w3.org/WAI/standards-guidelines/act/rules/047fe0/#visible)." A screen-reader-only heading gives sighted keyboard users no equivalent way to locate the start of non-repeated content, which is exactly the gap `047fe0` is checking for.
|
|
76
134
|
|
|
77
135
|
**Decision (2026-08-19):** `hasHeading()` now also requires the heading to carry no CSS-hiding hint (`helpers.getVisibilityHintsInfo`: off-screen, clipped, opacity:0, zero-size-overflow-hidden). The sibling `hasMainLandmark()`/`hasWorkingAnchorLink()` checks are left untouched; `cf77f2`'s own live text doesn't carry the same visibility requirement for a `<main>` landmark, confirmed by an existing regression test that pins a clipped-but-accessible `<main>` as still credited.
|
|
78
136
|
|
|
@@ -172,7 +230,7 @@ A running log of engine design decisions worth re-examining: cases where an exis
|
|
|
172
230
|
|
|
173
231
|
**Decision as it stands:** `src/checks/automatic/duplicate-id-aria.js` only flags a duplicate `id` when it's referenced by an ARIA ID-reference attribute. Its header comment: "Scoped to ids referenced by ARIA, not the broader/deprecated page-wide duplicate-id check (see ROADMAP.md's 'Skip' list)." That `ROADMAP.md` no longer exists in the repo (not found in the working tree or as a tracked file in `git log`, likely a local planning doc that was never committed), so the original reasoning behind "skip" isn't recoverable verbatim, only the pointer to it.
|
|
174
232
|
|
|
175
|
-
**Why it's being questioned:** while mining ACT's gap list
|
|
233
|
+
**Why it's being questioned:** while mining ACT's gap list for gaps worth turning into new rules, `3ea0c8` "Id attribute value is unique" is exactly this broader page-wide check, and it's detectable with a simple, deterministic document-wide scan. Checked ACT's own SC mapping for it: `3ea0c8` maps to **WCAG 4.1.1 Parsing**, which the Working Group formally **removed in WCAG 2.2** (browsers/AT no longer depend on strict-parsing conformance the way they did when that SC was written) and other accessibility engines deprecated their equivalent broad `duplicate-id` checks around the same time, for the same reason. So the original "skip" call was well-founded *for WCAG 2.2 conformance scoring specifically*.
|
|
176
234
|
|
|
177
235
|
That said, duplicate IDs are still a real, practical bug independent of which SC currently covers them: they break `<label for>` association, fragment navigation, and any `getElementById`/`querySelector('#...')` call, not just ARIA references. This engine already supports WCAG-version-scoped tagging (`wcag2a`/`wcag21a`/`wcag22aa`-style tags, see `docs/ENGINE_OPTIONS.md`'s WCAG-version filtering). A page-wide duplicate-id rule could be added and tagged as WCAG 2.0/2.1-only (`wcag411`-style, excluded from WCAG 2.2 tag sets) rather than either fully skipped or wrongly counted against 2.2 conformance, the two options the original either/or "skip" decision didn't have room for.
|
|
178
236
|
|
|
@@ -299,3 +357,105 @@ One existing test changed meaning with it: a bare `<option>` under `role="listbo
|
|
|
299
357
|
**Interaction with the item-wrapper fix above:** the two sets are used for different questions, on purpose. Only a *required* role makes a roleless wrapper an item wrapper, so a wrapper holding nothing but a separator is still interposed content and is still reported under a container that prohibits separators. The allowed set decides only the final verdict.
|
|
300
358
|
|
|
301
359
|
**Status:** resolved 2026-08-21. Separators in menus/menubars and captions on tables/grids pass; separators under `list`/`listbox`/`tablist`, captions under `treegrid`, and any stray role with no source behind it still fail. The reported allowed-roles list in the failure message now names the full allowed set rather than only the required roles.
|
|
360
|
+
|
|
361
|
+
### A dangling `aria-controls` was a `fail`, in a rule that cannot see the DOM the reference is about
|
|
362
|
+
|
|
363
|
+
**Decision as it stood:** `aria-valid-attr-value` treats every ID-reference attribute the same way. An idref-list whose tokens all fail to resolve is an invalid value, hence a `fail` under SC 4.1.2. Exactly one attribute already had a carve-out: `aria-errormessage`, on the strength of ACT 6a7281's own Background text, which names it as a non-required property whose target "may be created in response to an event that may or may not happen."
|
|
364
|
+
|
|
365
|
+
**Why it was questioned:** that carve-out's reasoning covers `aria-controls` at least as well. A disclosure button, a combobox, a menu button and a tab all name content the widget *builds when it opens*, so the reference is correct and the element genuinely is not in the DOM yet. A static scan that looks for it and does not find it has not established a defect; it has established that it looked at the wrong moment. Other accessibility engines reach the same conclusion: they do not report a missing `aria-controls` target as a violation, and at most ask for a review when the widget is expanded.
|
|
366
|
+
|
|
367
|
+
**Decision (2026-08-27):** `aria-controls` no longer fails on an unresolved target. When the element carries `aria-expanded="false"` or `aria-selected="false"` the absence is exactly what that state means, so the rule passes outright; otherwise it reports `cantTell` for human review, with reason code `idref-controls-not-found`. Every other idref/idref-list attribute keeps its `fail`, since a dangling `aria-labelledby` or `aria-owns` names content that was supposed to be there already and no state excuses it. The rule now reports two tiers through `helpers.resolveTieredOutcome`, so a real invalid value elsewhere on the page still gates as `fail` and carries the `cantTell` occurrences along rather than dropping them.
|
|
368
|
+
|
|
369
|
+
**Status:** resolved 2026-08-27.
|
|
370
|
+
|
|
371
|
+
### SC 4.1.1 Parsing could still fail a WCAG 2.2 run, unless the caller remembered a tag
|
|
372
|
+
|
|
373
|
+
**Decision as it stood:** `duplicate-id` maps to SC 4.1.1 and carries `wcag22-removed` (see the entry above). The tag was inert engine-side: it existed for consumers to pass to `excludeTags` themselves, and `docs/ENGINE_OPTIONS.md` told them to.
|
|
374
|
+
|
|
375
|
+
**Why it was questioned:** the package describes itself as a WCAG 2.2 engine, and a plain run, with no tags and no options, still reported a `fail` against a criterion WCAG 2.2 does not contain. The correct behaviour was reachable but opt-in, which is backwards: the default should be right and the deviation should be the thing you ask for. Leaving it to the caller also meant the honesty the tag was created to protect, that "the conformance arithmetic stays honest for every version", only held for callers who knew the tag existed.
|
|
376
|
+
|
|
377
|
+
**Decision (2026-08-27):** the engine resolves a target WCAG version per run, from `engineOptions.wcagVersion`, else whatever the caller's own version-origin tags imply, else `2.2`, and reports it as `engine.wcagVersion`. Under a 2.2 target a `wcag22-removed` rule cannot report `fail`: it runs, keeps every occurrence, and its outcome is coerced to `cantTell` with a `wcagVersionScope` field naming the removed criterion. Coercing rather than excluding was the deliberate choice, since a duplicate id still breaks `<label for>`, fragment navigation and `getElementById`, so dropping the rule from a 2.2 run would hide a real defect, and this engine's whole premise is telling you what it cannot tell you. `excludeTags: ['wcag22-removed']` still removes it entirely for anyone who wants that. The coercion deliberately does not go through `error`, the channel the two existing coercions use, because consumers read a non-empty `error` as "this rule threw" and nothing went wrong here.
|
|
378
|
+
|
|
379
|
+
**Status:** resolved 2026-08-27.
|
|
380
|
+
|
|
381
|
+
### `iframe-title-unique` failed any repeated frame `title`, filed as a deliberate stricter-than-ACT check, now deprecated in favour of `identical-iframes-same-purpose`
|
|
382
|
+
|
|
383
|
+
**Decision as it stood:** `ACT_RULE_MAPPING.md` recorded that `iframe-title-unique` flags any duplicate `title` attribute on `<iframe>`/`<frame>` elements "by design", a stricter check with no ACT counterpart of its own, and that `4b1c6c` would be closed by a separate rule. `identical-iframes-same-purpose` later closed it, and the two ran side by side, the older one reporting `fail` with `defaultConfidence: 'high'` under a normative mapping to WCAG 4.1.2 level A.
|
|
384
|
+
|
|
385
|
+
**Why it was questioned:** running ACT 4b1c6c's own test cases against the engine (#16) showed seven of its ten passed examples and one inapplicable example reported as `fail` by `iframe-title-unique`: two frames titled "List of Contributors" both showing `page-one.html`, a directory written with and without its trailing slash, the same page under two paths. WCAG 4.1.2 asks that a frame's name be programmatically determinable, not that it be unique, so a duplicate title establishes no violation, and `POLICY.md` promises that `fail` means a deterministic normative violation. The rule was breaking the engine's own contract, not only disagreeing with ACT. The first proposal (#17 as opened) kept the rule and gave it the sibling's verdict on the `title` attribute: pass when every frame in a set resolves to one resource, `cantTell` otherwise. But a frame's `title` is its accessible name unless `aria-label`/`aria-labelledby` overrides it, and in the one case where the two differ the title becomes the accessible description, a duplicate of which 4.1.2 does not forbid either. Reshaped, the rule reported the same elements, with the same outcome and uncertainty code, as `identical-iframes-same-purpose`, and copied its accessibility-tree gating to do so.
|
|
386
|
+
|
|
387
|
+
**Decision (2026-09-03):** the rule is deprecated, `replacedBy: 'identical-iframes-same-purpose'`, `sinceVersion: '1.8.0'`, the first use of the mechanism in `API_STABILITY.md`. Because a deprecated rule keeps running and reporting normally, deprecation alone would have left the wrong `fail` alive until 2.0.0, so `runInPage` is reduced to `notApplicable` on every page. The id stays in the catalog until the file is removed in 2.0.0, so a `runOnly` list holding it keeps resolving. `IFRAME_TITLE_DUPLICATE` is no longer emitted and retires with the finding it named: it was listed, with its reason, under `retired` in `scripts/data/released-finding-ids.json` until the 1.8.0 release froze its own inventory, so a stored baseline entry or open Code Scanning alert for it closes, as for a fixed false failure. The rule's facet under SC 4.1.2 is retired and its `coverage.facetsBySc` points at the successor's facet, which the validator requires to be non-empty for a rule with a WCAG mapping; the coverage report leaves deprecated rules out of a facet's coverage (#31), so it does not count as covering it. The scenario fixture is removed rather than left claiming outcomes the rule no longer produces, which is what the fixture-marker check exists to catch; the one case the sibling's tests lacked, several same-named sets on one page where only the set whose resources differ is reported, moved to its test file. This is recorded as a change of mind, not a bug fix: the old behaviour was chosen on purpose, and a stricter check still has to rest on a requirement.
|
|
388
|
+
|
|
389
|
+
**Revised (2026-10-02):** how the retired code is recorded. The PR first kept `IFRAME_TITLE_DUPLICATE` in `scripts/data/finding-ids.json` through an exemption in `tests/finding-ids.test.js`. Review found that `npm run finding-ids` still dropped it with every test passing, and agreed that the rule would declare its retired codes for the generator to read. Before the PR landed, `main` gained `scripts/data/released-finding-ids.json` and `tests/released-finding-ids.test.js`: what a release shipped is frozen there, no regeneration rewrites it, and a code can go only when it is listed under `retired` with its reason. That closes the same gap without a new deprecation field, so the code is retired there and the exemption was dropped.
|
|
390
|
+
|
|
391
|
+
**Status:** resolved 2026-09-03. Over the 23 ACT 4b1c6c test cases, passed/inapplicable fixtures yielding `fail` went 8 → 0; every failed example either iframe rule caught before is still `cantTell` from `identical-iframes-same-purpose`.
|
|
392
|
+
|
|
393
|
+
### `area-alt-present` treated `<area alt="">` as satisfying its check, borrowing `<img>`'s decorative marker for an element that can't be decorative
|
|
394
|
+
|
|
395
|
+
**Decision as it stands (before the change):** an `<area>` with a present-but-empty `alt` passed `area-alt-present` outright, the same treatment `<img alt="">` gets. A separate manual rule, `area-alt-decorative`, then asked a human to confirm the empty-`alt` area really was decorative.
|
|
396
|
+
|
|
397
|
+
**Why it was questioned:** `<img alt="">` has a real decorative use: the image can carry zero information while everything else on the page still works. An `<area>` has no equivalent — it exists in a used `<map>` only to be a hyperlink hotspot (that's the whole point of the `usemap`/`href` mechanism), so its HTML-AAM role is `link` and an empty `alt` just means an unnamed link, not a decorative one. There is no redundant sibling content standing in for it the way there is for a decorative image; the geometry itself is invisible.
|
|
398
|
+
|
|
399
|
+
**Decision (2026-09-14):** `area-alt-present` now fails an `<area>` with `alt=""` and no other accessible name (`aria-label`/`aria-labelledby`/`title` still count, same fallback order as before), with its own summary/hint distinct from the missing-`alt` case. `area-alt-decorative` is retired rather than kept dormant, since "is this decorative" never had a legitimate yes for this element — see [Removed] in `CHANGELOG.md`. `area-alt-quality` (non-empty `alt`, asking whether the text is accurate) is unaffected and stays a legitimate manual question.
|
|
400
|
+
|
|
401
|
+
**Accepted cost:** `scripts/data/finding-ids.json`'s rule-id inventory drops from 133 to 132. A stored baseline holding a `cantTell` finding against `area-alt-decorative` finds the id gone, not resolved.
|
|
402
|
+
|
|
403
|
+
**Status:** resolved 2026-09-14.
|
|
404
|
+
|
|
405
|
+
### `hasBlockingInert` ignored `inert` on an `<area>` itself or on its `<map>`, undocumented since the project's first commit
|
|
406
|
+
|
|
407
|
+
**Decision as it stands (before the change):** `hasBlockingInert` in `dom-helpers.js` carried an `<area>`-specific exception: `inert` on the area itself, or on its closest `<map>`, was ignored; only an `inert` ancestor outside that chain excluded the area. Present since the very first commit (`4961752`), pinned by a pointed unit test, with no rationale recorded in the commit, this file, or `RULE_AUTHORING.md`.
|
|
408
|
+
|
|
409
|
+
**Why it was questioned:** the shape reads like a generalization of a different, correct rule — that `aria-hidden` on a focusable element does not remove it from eligibility, since a real user can still Tab onto it (the exact pattern `aria-hidden-focus` exists to catch). `inert` is not `aria-hidden`: the HTML spec has it remove focusability directly, for every element type, with no image-map carve-out in the algorithm, so the "still really reachable" premise that justifies the `aria-hidden` exception does not hold for `inert`. More directly: `aria-hidden-focus.js` already implements this exact question independently (`hasInertAncestor`, walking the element itself and every ancestor) with no `<area>`/`<map>` exception at all — two pieces of code in the same repo disagreed on the identical input, `<area inert>`.
|
|
410
|
+
|
|
411
|
+
**Decision (2026-09-14):** the exception is removed. `inert` on the `<area>`, its `<map>`, or any ancestor now excludes the area uniformly, matching every other element's default handling and matching `aria-hidden-focus.js`'s own independent check. A genuinely inert `<area>` is `notApplicable` rather than a reported failure.
|
|
412
|
+
|
|
413
|
+
**Correction (2026-09-14, same day):** wrong. This reasoned from spec text without checking what a real browser actually does, which is exactly the thing the Method section above says to verify. Tested via real keyboard Tab navigation in Chromium and Firefox: an `<area inert>` and an `<area>` inside an `inert <map>` both stay in the tab order. `<area>`/`<map>` generate no box, so a browser's image-map hit-testing sits outside the pipeline `inert` operates on — the original, undocumented exception was empirically correct despite having no stated reason, and the `aria-hidden-focus.js` comparison that seemed to settle the question was comparing the wrong thing: that rule never evaluates `<area>` in practice, so it never had occasion to get this right or wrong. Re-reverted; see the entry below for the full, verified picture, which also turned up two related gaps this entry didn't touch.
|
|
414
|
+
|
|
415
|
+
**Status:** superseded 2026-09-14 by the entry below.
|
|
416
|
+
|
|
417
|
+
### `isPlatformFocusable` treated every `<area>` in a used map as focusable, `href` or not, unlike its own `<a>` branch three lines above it, fixed
|
|
418
|
+
|
|
419
|
+
**Decision as it stands (before the change):** `isPlatformFocusable`'s `tag === 'area'` branch (`dom-helpers.js`) treated an `<area>` as focusable purely for belonging to a `<map>` a rendered `<img usemap>` references — no `href` check. The `tag === 'a'` branch immediately above it requires a non-empty `href` before returning focusable. `area-alt-present` and `area-alt-quality` inherited this: both evaluated any `<area>` in a used map, `href` or not, at their own applicability gate (each rule's local `getReferencingImgForArea`/used-map lookup, not `isPlatformFocusable` itself for the baseline case).
|
|
420
|
+
|
|
421
|
+
**Why it was questioned:** per the HTML spec, an `<area>` with no `href` does not represent a hyperlink at all — "represents merely a region of the map that has no associated action" — so it is not focusable, not in the accessibility tree as a link, and has no accessible-name requirement to fail. Run against `scripts/other-engine-corpus-check.js` (built 2026-09-14) scoped to a third-party scanner's own area-alt rule, `area-alt-present`'s fixture agreed on exactly one of its seven failing cases — the one case that happened to carry `href` (`area_case_16`). The other six (missing alt, empty alt, aria-hidden-but-focusable, `role="presentation"`, `aria-disabled`, a dangling `aria-labelledby`) all omitted `href` entirely and the other tool didn't flag any of them. `area-alt-present` has no ACT counterpart (`docs/ACT_RULE_MAPPING.md`'s "Extra coverage beyond ACT" list), so this never surfaced through that comparison either — first time this rule had been checked against any outside ground truth. The `<area>` branch's own comment ("Engine policy: treat `<area>` as focusable when it's part of a *used* image map") dated to the project's first commit, with nothing explaining why `href` was left out unlike the `<a>` branch beside it.
|
|
422
|
+
|
|
423
|
+
**Decision (2026-09-14):** `isPlatformFocusable`'s `<area>` branch now requires a non-empty `href` before doing the used-map lookup, matching the `<a>` branch. `area-alt-present.js` and `area-alt-quality-manual.js` each gained the same `href` check at their own applicability gate, right after the existing used-map lookup, since that gate — not `isPlatformFocusable` — is what actually governs a case's baseline applicability; `isPlatformFocusable`'s branch only mattered for the `aria-hidden`-override and `role="presentation"`-exclusion paths. Both rules' fixtures had `href` retrofitted onto every case testing naming/eligibility logic (all but one case in each fixture previously omitted it), and `area_case_16` in `area-alt-present`'s fixture — previously "href present, still fails" — was repurposed to cover the newly-distinct branch, an `<area>` with no `href` in a used map, since its original point had become redundant with `area_case_01` once every other case also gained `href`.
|
|
424
|
+
|
|
425
|
+
**Status:** resolved 2026-09-14.
|
|
426
|
+
|
|
427
|
+
### `<area>`/`<map>` eligibility was checked against the wrong ground truth: spec text and `isAccTreeEligible`'s own layered design, not what a real browser does with a non-rendered element pair
|
|
428
|
+
|
|
429
|
+
**Decision as it stands (before the change):** three related exclusions, none verified against a real browser. `hidden`/`display:none`/`inert` on the `<area>` itself, or `display:none`/`inert` on its `<map>`, all excluded the area (the entry above covers `inert` specifically; `hidden` and `display:none` on the `<map>` were separate, pre-existing gaps with no carve-out at all, not something this session introduced). Separately, `area-alt-present.js`/`area-alt-quality-manual.js` gated the area's applicability on `isAccTreeEligible(img, ctx)` for the referencing `<img>`, which folds in `aria-hidden`.
|
|
430
|
+
|
|
431
|
+
**Why it was questioned:** the entry above records getting the `inert` question wrong once already by reasoning from spec text instead of testing it. Doing the same check for `hidden`/`display:none` this time, and testing all of it directly: loaded each scenario in a real page and drove actual keyboard Tab navigation in Chromium and Firefox (WebKit's headless tab handling looked broken in the same harness, so not counted). Result: `hidden` on the `<area>`, and `display:none` or `inert` on the `<map>`, all leave the area fully reachable — same non-rendered-element reasoning as `inert`, just never applied to the other two mechanisms. Separately, `aria-hidden` on the referencing `<img>` also leaves the area reachable, for a different reason: `<area>` is not a DOM descendant of `<img>`, only linked by the `usemap` IDREF, so `aria-hidden` has no ancestor relationship to propagate along, and the img is still rendered regardless of its ARIA state. Gating the area's applicability on the img's full `isAccTreeEligible` — which folds in `aria-hidden` — conflated two different questions: is the img actually rendered (genuinely relevant, since that's what the hotspot geometry depends on) versus is the img exposed to the accessibility tree (irrelevant here).
|
|
432
|
+
|
|
433
|
+
**Decision (2026-09-14):** `hasBlockingInert`'s `<area>` carve-out is restored (see entry above) with the verified reason recorded this time. The structural `hidden`-attribute check and the CSS `display:none` ancestor check in `isAccTreeEligible` both gained the same carve-out, extended to a `<map>` ancestor as well as the area itself (`visibility:hidden` needed no change — it was already skipped for `<area>` nodes and already gave the right answer). `area-alt-present.js`/`area-alt-quality-manual.js`'s referencing-`<img>` gate switched from `isAccTreeEligible` to `isDomVisibleEligible` (`{ visibilityMode: 'styleOnly', disableGeometry: true }`), the same DOM-only helper `aria-hidden-focus.js` already uses for this exact distinction — `hidden`/`display:none`/`visibility` on the img still excludes the area; `aria-hidden` on the img no longer does. Both fixtures gained the corrected titles/spans and cases, and a note in the `hidden_css`/`inertness` section headings that these mechanisms are ignored on the area/map itself but still block on a genuine outside ancestor.
|
|
434
|
+
|
|
435
|
+
**Accepted cost:** `area-alt-present`'s fixture goes from 6 failing cases to 11; `area-alt-quality`'s goes from 1 applicable case to 4. Both increases are real markup this rule should have been flagging all along, not new false positives — a genuinely inert or `hidden`-marked `<area>` in a used map, or one behind an `aria-hidden` image, is still a real, reachable, unnamed link.
|
|
436
|
+
|
|
437
|
+
**Status:** resolved 2026-09-14.
|
|
438
|
+
|
|
439
|
+
### Ancestor/group `opacity` was treated as an unconditional contrast-computability blocker, even when the backdrop is a single flat resolvable color, fixed
|
|
440
|
+
|
|
441
|
+
**Decision as it stands (before the change):** `contrast-computable`/`contrast-minimum`/`contrast-enhanced` all reported `cantTell` for any text with a fractional-`opacity` ancestor (group opacity), unconditionally. `contrast-all-scenarios.html`'s `case-blocker-ancestor-opacity` was the documented example: a `<p>` with `color:#000000; background-color:#ffffff; opacity:1` inside a `<div style="opacity:0.5">`, itself on a flat white (`bgWhite`) section.
|
|
442
|
+
|
|
443
|
+
**Why it was questioned:** run through `scripts/other-engine-corpus-check.js` unscoped against every fixture with a case, this was one of a small number of gaps that survived filtering out same-page noise and matched a same-topic finding (`color-contrast`) from the other tool. Verified directly rather than trusting either side: rendered the fixture in real Chromium and sampled the actual composited pixel color at the text (`(126,126,126)`) against the background (`(255,255,255)`) — a deterministic WCAG contrast ratio of ~4.06, which fails AA's 4.5:1 for normal text. The existing `getComputabilityBlocker` code comment gave the real reason it stayed unconditional: naively combining the existing per-element opacity product (used for the foreground) with the existing ancestor-opacity-aware background walk double-counts the ancestor's opacity, confirmed by hand with a second scenario (a colored ancestor background rather than white-on-white, where the two independently-computed colors landed on a visibly different, wrong answer from real compositing).
|
|
444
|
+
|
|
445
|
+
**Decision (2026-09-14):** `resolveGroupOpacityColors` in `contrast-helpers.js` resolves both colors in a single walk instead of combining two independently-computed ones after the fact: a background accumulator (as `computeEffectiveBackground` already builds) and a parallel foreground accumulator that receives the text's own color as its innermost layer, with every ancestor's own background-color and opacity applied to *both* accumulators in lockstep. Nothing is combined after the fact, so there is nothing left to double-count, and it handles any number of nested opacity ancestors — with or without their own background-color — uniformly. It only bails (falls back to the existing `ANCESTOR_OPACITY` `cantTell`, unchanged) for a blend-mode/filter/background-image anywhere in the chain, a missing declared text color, or a background that never reaches full opacity even after the whole chain is walked — the genuinely hard general case (a group over a gradient or other unresolvable content) stays exactly as conservative as before. `getComputabilityBlocker`, `computeEffectiveForeground` and `computeEffectiveBackground` all consult it transparently, so no call site elsewhere needed to change. `contrast-all-scenarios.html` gained a second ancestor-opacity case (a gradient sitting further out, beyond the resolvable opacity ancestor) specifically to keep `ANCESTOR_OPACITY` reachable in the fixture corpus `scripts/generate-finding-ids.js` scans — removing that reachability would have silently dropped a still-real reason code from the finding-ids inventory.
|
|
446
|
+
|
|
447
|
+
**Accepted cost:** the fixture's `case-blocker-ancestor-opacity` moves from `cantTell` (computable) to `pass` for `contrast-computable`, and from excluded to `fail` for `contrast-minimum`/`contrast-enhanced` — a real finding these rules should have caught all along, not a new false positive.
|
|
448
|
+
|
|
449
|
+
**Status:** resolved 2026-09-14.
|
|
450
|
+
|
|
451
|
+
### Six ARIA-widget naming rules credited a `<label>` to elements it never actually named, via an unguarded local copy of the label-association lookup
|
|
452
|
+
|
|
453
|
+
**Decision as it stands (before the change):** `combobox-name-present`, `listbox-name-present`, `searchbox-name-present`, `spinbutton-name-present`, `textbox-name-present` and `slider-name-present` each carried an identical local `getNativeLabelText(el)`, falling back to `el.closest('label')` (wrapping) or a `label[for]` map (`buildLabelForMap`) with no check that `el` was actually a labelable element. Each rule's own JSDoc already stated the intended contract correctly ("On a labelable element (`<input role="combobox">`) an associated `<label>` counts as well") — the implementation just never enforced the "labelable" half of it.
|
|
454
|
+
|
|
455
|
+
**Why it was questioned:** run through `scripts/other-engine-corpus-check.js` unscoped, `combobox-name-present`/`listbox-name-present`/`searchbox-name-present`/`spinbutton-name-present`/`textbox-name-present` all disagreed with the *same* other-tool rule, `aria-input-field-name` — a recurring pattern across five rules pointed at one systemic thing rather than five coincidences. Verified directly: rendered `<label>Wrapped label text <div role="combobox" tabindex="0"></div></label>` in real Chromium and Firefox and read the actual accessibility tree (`ariaSnapshot()`) — the combobox has no accessible name in either browser, wrapping or `for`-based. `slider-name-present` shared the same buggy local function but never actually triggered it: its own call site already gates the label lookup behind `kind === 'native-slider'`, so it was dead code there, not a live bug — its fixture already had the correct expectation on record.
|
|
456
|
+
|
|
457
|
+
**Decision (2026-09-14):** all six rules' local `getNativeLabelText` now delegate to the shared `getAssociatedLabelElements` (`dom-helpers.js`), which already correctly restricted the *wrapping* case to `wrap.querySelector(LABELABLE_SELECTOR) === el` but, it turned out, never applied the equivalent check to the `for`-based case — so `getAssociatedLabelElements` itself gained a labelable check up front, benefiting every existing caller, not just these six. That surfaced a second instance of the identical bug one level down: a pinned unit test in `tests/core/dom-helpers-name-computation.test.js` explicitly asserted that `<label for="x">Name</label><div id="x" role="button">` resolves to the name `"Name"` — verified against real Chromium/Firefox as wrong the same way, and corrected alongside a new test pinning the correct behavior for a genuinely labelable target.
|
|
458
|
+
|
|
459
|
+
**Accepted cost:** each of the five previously-affected rules (not `slider-name-present`) gains two new failing fixture cases — a wrapping-`<label>` case and a `label[for]`-plus-empty-content-title-fallback case — both real markup a screen reader user hears as unnamed, not new false positives.
|
|
460
|
+
|
|
461
|
+
**Status:** resolved 2026-09-14. `form-control-single-label.js` and `binary-control-name-present.js` have their own separate, similarly-unguarded `closest('label')` fallbacks, not touched here since their applicability already appears scoped to genuinely labelable elements (not verified) — worth a look if they ever start evaluating non-labelable targets.
|