@surea11y/core 1.0.0 → 1.1.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.
@@ -82,6 +82,7 @@ const engineOptions = {
82
82
  mode: 'strictConformance', // 'strictConformance' (default) | 'auditorAssist'
83
83
  rootCanvasFallback: '#ffffff' // background assumed when the true root background isn't computable
84
84
  },
85
+ visibilityMode: 'styleOnly', // 'styleOnly' (default) | 'styleAndGeometry' — see below; scoped to the contrast rules only
85
86
 
86
87
  policyContract: 'a11y', // 'a11y' (default) | 'generic' | inline contract object — see POLICY.md
87
88
  policy: { // optional overrides on top of policyContract
@@ -94,7 +95,9 @@ const engineOptions = {
94
95
  },
95
96
 
96
97
  rules: {
97
- 'some-rule-id': { /* per-rule config, currently unused — see note */ }
98
+ 'some-rule-id': {
99
+ excludeSelectors: ['.some-noisy-widget'] // narrows candidates for THIS rule only — see note
100
+ }
98
101
  },
99
102
 
100
103
  probes: { /* optional host-supplied evidence, see note */ },
@@ -112,17 +115,107 @@ const engineOptions = {
112
115
  |---|---|
113
116
  | `locale` | Any string; resolution is per-string with graceful fallback (requested locale → `en` → the rule's literal English fallback text), so a partially-translated locale never produces missing text. See [`I18N.md`](./I18N.md) for current locale coverage. |
114
117
  | `includeShadowDom` | Default `true`: rules using `helpers.queryAllSmart` traverse into open shadow roots. Set `false` to scan only the light DOM. Closed shadow roots are never reachable either way (no DOM API exposes them). |
115
- | `excludeSelectors` | Elements matching any of these selectors (and their descendants) are skipped entirely — useful for cookie banners, third-party embeds, or known-noisy widgets you don't control. |
118
+ | `excludeSelectors` | Elements matching any of these selectors (and their descendants) are skipped entirely, for **every** rule — useful for cookie banners, third-party embeds, or known-noisy widgets you don't control. To exclude something from just one specific rule instead, use `rules[ruleId].excludeSelectors` below. |
116
119
  | `timestamp` | Passed straight through to the result's top-level `timestamp` field; the engine does not generate one itself (deterministic-by-design). |
117
120
  | `contrast.mode` | `strictConformance` (default): contrast rules stay silent (`notApplicable`/skip) whenever the true rendered background isn't confidently computable, to protect against false `fail`s. `auditorAssist`: trades some of that safety margin for more findings, intended for a human auditor who will double-check flagged cases, not for unattended CI gating. |
118
121
  | `contrast.rootCanvasFallback` | The assumed page background color when it's not computable at all — only matters in `auditorAssist` mode. |
122
+ | `visibilityMode` | Controls how strict the three contrast rules (`contrast-minimum`, `contrast-enhanced`, `contrast-computable`) are about deciding a text node is actually eligible to check. **Not read by any other rule.** `'styleOnly'` (default): eligibility is CSS-only — `display`, `visibility`, `opacity`, ancestor-hiding, etc. `'styleAndGeometry'`: adds real layout checks (`getClientRects()`/`getBoundingClientRect()`) on top of that — text with no client rects, or zero width/height, is excluded too. Reach for `'styleAndGeometry'` when running under a real browser/Playwright-Puppeteer (`runa11yCoreInPage`) and you want contrast findings to reflect actual rendered layout rather than just computed style; under plain jsdom (`runDomRulesInPage`) there's no real layout engine, so `'styleAndGeometry'` mostly just adds `getBoundingClientRect()` zero-size checks, not true clipping/overflow detection — see [`LIMITATIONS.md`](./LIMITATIONS.md). |
119
123
  | `policyContract` / `policy` | See [`POLICY.md`](./POLICY.md) — controls which outcomes/confidence values are allowed and whether manual rules' would-be `fail`s get coerced to `cantTell`. |
120
- | `output.includeSelector` / `.includeHtml` | Only affects the small number of rules (currently 4 of 123) that rely on the engine's automatic selector/HTML fill-in rather than building their own — most rules set `selector`/`html` themselves inside `runInPage` and are unaffected by this option. Not a reliable way to strip selectors/HTML from all output. |
121
- | `rules[ruleId]` | Passed through to that rule as `ctx.config`. The plumbing exists end-to-end, but **no shipped rule currently reads `ctx.config`** — this is infrastructure for future per-rule configurability, not a lever that changes any of today's 123 rules' behavior. |
124
+ | `output.includeSelector` / `.includeHtml` | Only affects the small number of rules (currently 4 of 125) that rely on the engine's automatic selector/HTML fill-in rather than building their own — most rules set `selector`/`html` themselves inside `runInPage` and are unaffected by this option. Not a reliable way to strip selectors/HTML from all output. |
125
+ | `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule currently reads `ctx.config`** for anything besides `excludeSelectors`. |
122
126
  | `probes` | An optional, JSON-safe evidence object your host application can supply (depth- and size-capped by the engine before rules see it, via `ctx.inputs.probes`) — for future rules that might accept externally-supplied signals (e.g. real layout measurements a static DOM scan can't compute itself). Not consumed by any current rule. |
123
127
  | `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` additionally adds a per-rule timing breakdown. Shape is not part of the stable output contract — don't build on it. |
124
128
  | `pingWaitTime` / `frameWaitTime` | Only read by `runa11yCoreAcrossFrames` (see [`INTEGRATION.md`](./INTEGRATION.md#cross-frame-scanning-including-cross-origin)) — how long to wait for a child frame to answer a ping (default `500`ms) and a full run request (default `60000`ms) before treating it as unreachable. Ignored by `runDomRulesInPage`/`runa11yCoreInPage`. |
125
129
 
130
+ ### Rule-scoped `excludeSelectors`
131
+
132
+ The top-level `excludeSelectors` applies to *every* rule — there's no way to exclude an element from just one rule while still running every other rule against it. `rules[ruleId].excludeSelectors` fills that gap: it narrows candidates for **that one rule only**, on top of (never instead of) the global list.
133
+
134
+ ```js
135
+ const engineOptions = {
136
+ excludeSelectors: ['#cookie-banner'], // applies to every rule, as always
137
+ rules: {
138
+ 'aria-required-children': {
139
+ excludeSelectors: ['mat-select', 'mat-stepper', 'mat-horizontal-stepper', 'mat-vertical-stepper']
140
+ },
141
+ 'aria-allowed-attr': {
142
+ excludeSelectors: ['mat-progress-spinner']
143
+ }
144
+ }
145
+ };
146
+ ```
147
+
148
+ Why you'd want this: Angular Material's `<mat-select>` builds its internal ARIA structure in a way that trips a false positive on `aria-required-children` specifically, even though the component is otherwise fine. With only the global `excludeSelectors`, the only way to silence that false positive is `excludeSelectors: ['mat-select']` — which also hides `mat-select` from *every other rule*, including `color-contrast` and `aria-allowed-attr`, silently dropping real coverage those checks never had a problem with. The example above keeps `mat-select` fully visible to every rule except the one that misfires on it.
149
+
150
+ Effective exclusions for a given rule are the **union** of the global list and that rule's own list — an element matching either is dropped from that rule's candidates. A rule whose only would-be-failing elements are all excluded this way reports `outcome: 'pass'` or `'notApplicable'` (matching that rule's own no-candidates convention), with `occurrences: []` — never `outcome: 'fail'` with an empty `occurrences` array, since that exact shape is reserved elsewhere in the schema to mean "this rule threw" (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)).
151
+
152
+ Accepts the same forms as the global option: an array (`['mat-select', 'mat-stepper']`) or a comma-separated string (`'mat-select, mat-stepper'`).
153
+
154
+ > If you're using a binding package (`@surea11y/binding-base` and its Playwright/Puppeteer wrappers), check that binding's own README for whether its `.exclude()` builder method has a rule-scoped form yet — this is an `engineOptions` shape documented here at the engine level; not every binding has picked it up.
155
+
156
+ ## Recipes — composing options for real scenarios
157
+
158
+ The reference above documents each option in isolation. These combine several at once, for scenarios you're likely to actually hit.
159
+
160
+ **CI gate: WCAG 2.2 AA only, ignore a third-party widget you don't control**
161
+
162
+ ```js
163
+ runDomRulesInPage(url, null, {
164
+ excludeSelectors: ['#cookie-banner', '.intercom-launcher'],
165
+ tags: { include: 'wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22a,wcag22aa' }
166
+ }, null);
167
+ ```
168
+
169
+ **Human auditor doing a deep contrast pass in a real browser** — trade some false-positive protection for more findings, and check real layout (not just computed style) since a real page is being driven. Shown with Puppeteer's `page.evaluate` (accepts multiple args); if you're on Playwright, wrap the four positional args into a single object first — see [`INTEGRATION.md`](./INTEGRATION.md):
170
+
171
+ ```js
172
+ const result = await page.evaluate(runa11yCoreInPage, url, null, {
173
+ contrast: { mode: 'auditorAssist' },
174
+ visibilityMode: 'styleAndGeometry'
175
+ }, null);
176
+ ```
177
+
178
+ **Scoped re-scan of one region after a UI change, skipping shadow DOM** — useful in a component-level test where you only care about the widget you just changed:
179
+
180
+ ```js
181
+ runDomRulesInPage(url, '#checkout-form', {
182
+ includeShadowDom: false
183
+ }, { includeRuleIds: ['form-control-programmatic-label-present', 'button-name-present'] });
184
+ ```
185
+
186
+ **Reproducible output for snapshot testing** — pin a `timestamp` so two runs of the same HTML produce byte-identical JSON, and request the debug timing breakdown:
187
+
188
+ ```js
189
+ runDomRulesInPage(url, null, {
190
+ timestamp: '2026-01-01T00:00:00Z',
191
+ perfStats: true,
192
+ profileRules: true
193
+ }, null);
194
+ ```
195
+
196
+ **A custom, org-specific rule alongside the built-ins**, only for this one call:
197
+
198
+ ```js
199
+ runDomRulesInPage(url, null, {
200
+ customRules: [{
201
+ id: 'org-no-inline-onclick',
202
+ meta: { title: 'No inline onclick handlers', defaultSeverity: 'moderate' },
203
+ runInPage(ctx) {
204
+ const els = ctx.helpers.queryAll('[onclick]');
205
+ const occurrences = els.map((el) => ({
206
+ selector: ctx.helpers.buildSelector(el),
207
+ html: el.outerHTML,
208
+ summary: 'Inline onclick handler found.',
209
+ hint: 'Move event handling into an external script.'
210
+ }));
211
+ return { ruleId: ctx.rule.ruleId, outcome: occurrences.length ? 'fail' : 'pass', severity: 'moderate', occurrences };
212
+ }
213
+ }]
214
+ }, null);
215
+ ```
216
+
217
+ See the option-by-option table above for anything not shown here, and the `customRules` section immediately below for the full descriptor contract.
218
+
126
219
  ## `customRules` — runtime-registered rules (equivalent to the rule/check registration pattern used by other engines)
127
220
 
128
221
  Every shipped rule is baked into `src/core.js` at build time. `engineOptions.customRules` is the runtime escape hatch: an array of rule descriptors registered for that one call only — nothing is added to the static catalog (`getRulesCatalog()`/`getChecksCatalog()`), and nothing persists between calls. This is deliberate, not a limitation to work around: surea11y already takes fresh `engineOptions` per call with no mutable global config (unlike some other engines, which need a `configure()`/`reset()` step against a shared runtime), and custom rules follow that same per-call model.
package/docs/I18N.md CHANGED
@@ -6,10 +6,10 @@ Every rule's title/description, and every occurrence's summary/hint, is localize
6
6
 
7
7
  | Locale | File | Keys | Coverage vs. English |
8
8
  |---|---|---|---|
9
- | `en` (English) | `src/i18n/en.js` | 590 | 100% (the canonical/fallback set) |
10
- | `fr` (French) | `src/i18n/fr.js` | 287 | ~49% — **partial**, not every string is translated yet |
9
+ | `en` (English) | `src/i18n/en.js` | 600 | 100% (the canonical/fallback set) |
10
+ | `fr` (French) | `src/i18n/fr.js` | 600 | 100% |
11
11
 
12
- That `fr` number is real and worth being honest about: about half of the engine's translatable strings don't have a French entry yet. That's not a bug — see the fallback behavior below, which means a missing `fr` key never produces broken or missing text, just an English string in the middle of otherwise-French output.
12
+ Both locales are fully translated as of this writing. That won't stay automatically true — every time a new rule (or a new i18n key) is added to `en.js`, `fr.js` needs the same key added, or it silently falls back to English for that string (see the fallback behavior below). There's no automated check for this yet; diff `Object.keys(require('./src/i18n/en.js'))` against `fr.js` after adding a rule to catch drift before it ships.
13
13
 
14
14
  ## Selecting a locale
15
15
 
@@ -103,7 +103,7 @@ Notes:
103
103
 
104
104
  ## An occurrence (`occurrences[i]`)
105
105
 
106
- Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` result has `occurrences: []` — this engine does not enumerate the elements it passed, only the ones it flagged; see the note in `docs/RULE_AUTHORING.md` on why "silence" from a `pass` rule is not the same as an enumerated list of passing elements).
106
+ Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` result has `occurrences: []` — this engine does not enumerate the elements it passed, only the ones it flagged).
107
107
 
108
108
  ```ts
109
109
  {
@@ -114,7 +114,7 @@ Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` re
114
114
  hint: string,
115
115
  i18n: { summaryKey: string, hintKey: string, params: object } | null,
116
116
  data: {
117
- visibilityFilter?: { targetSet: string, accEligible: boolean | null, reasons: string[] },
117
+ visibilityFilter?: { eligible: boolean, targetSet: string, accEligible: boolean | null, reasons: string[] },
118
118
  details?: object // rule-specific, non-normative — see below
119
119
  }
120
120
  }
@@ -128,7 +128,7 @@ Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` re
128
128
  | `summary` | Human-readable, already localized ("This button has no accessible name."). |
129
129
  | `hint` | Human-readable remediation guidance, already localized. |
130
130
  | `i18n` | The raw translation keys behind `summary`/`hint`, if you want to re-render them in a different locale yourself without re-running the scan. `null` if the occurrence didn't use key-based i18n. |
131
- | `data.visibilityFilter` | Present on most occurrences: why the engine considered this element eligible for accessibility-tree evaluation (or not). `reasons` is a list of machine-readable exclusion codes when `accEligible: false`. |
131
+ | `data.visibilityFilter` | Present on most occurrences: why the engine considered this element eligible (or not) under whichever eligibility model the rule used. `eligible` is that result; `targetSet` says which model produced it (`'dom'`: raw DOM/CSS visibility — most rules; `'acc'`: accessibility-tree eligibility). `accEligible` mirrors `eligible` only when `targetSet` is `'acc'`, otherwise `null` — don't read it as a second, independent signal. `reasons` is a list of machine-readable exclusion codes when `eligible: false`. |
132
132
  | `data.details` | Rule-specific structured data (e.g. `reasonCode`, computed metrics, resolved references) — **non-normative**: useful for building richer UI or debugging, but never changes what `outcome`/`severity` mean. Shape varies per rule; treat as best-effort extra context, not a stable contract. |
133
133
 
134
134
  ## A composite result (`rulesResults[i]`)
@@ -202,7 +202,7 @@ const result = runDomRulesInPage(
202
202
  {
203
203
  "selector": "html > body > button",
204
204
  "html": "<button></button>",
205
- "structuralPath": [1, 0],
205
+ "structuralPath": [1, 1],
206
206
  "summary": "This button has no accessible name.",
207
207
  "hint": "Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.",
208
208
  "data": {
@@ -222,7 +222,7 @@ const result = runDomRulesInPage(
222
222
  {
223
223
  "selector": "html > body > img",
224
224
  "html": "<img src=\"logo.png\">",
225
- "structuralPath": [1, 1],
225
+ "structuralPath": [1, 0],
226
226
  "summary": "Missing alt attribute on <img>.",
227
227
  "hint": "Add an alt attribute (use alt=\"\" only for decorative images)."
228
228
  }
@@ -12,7 +12,7 @@ Encoded by: `meta.type`
12
12
 
13
13
  - `automatic`
14
14
  - Rule makes a **normative decision**
15
- - Allowed outcomes: `pass`, `fail`, `notApplicable`
15
+ - Allowed outcomes: `pass`, `fail`, `notApplicable`, and `cantTell` as a defensive fallback only (e.g. an internal-failure safety net, or a computability gate a rule can't resolve — see `contrast-minimum.js`/`contrast-enhanced.js`/`contrast-computable.js`/`target-size-minimum.js`), never as its primary intended path
16
16
  - `manual`
17
17
  - Rule signals **human review required**
18
18
  - Allowed outcomes: `cantTell`, `notApplicable`
@@ -24,7 +24,7 @@ Manual rules MUST NOT make normative failure decisions.
24
24
  ### 1.2 Intent
25
25
  Encoded by: **rule id suffix**
26
26
 
27
- Current intents:
27
+ Illustrative intents (from the image-alternatives family used as the running example in §2, not an exhaustive list — the ruleset's 125 rules use dozens of distinct suffixes; `docs/RULE_CATALOG.md` is the generated, always-current list):
28
28
 
29
29
  - `present`
30
30
  - Verifies that a required **mechanism exists**
@@ -43,7 +43,7 @@ Intent determines whether a rule can be automatic.
43
43
  ### 1.3 Target Family
44
44
  Encoded by: **rule id prefix**
45
45
 
46
- Current families include:
46
+ Illustrative families, from the image-alternatives cluster (WCAG 1.1.1) used as the running example in §2 — not an exhaustive list. The ruleset's 125 rules span dozens of families (`aria-*`, `contrast-*`, `dialog-*`, `iframe-*`, `label-*`, `link-*`, `list-*`, `landmark-*`, and more); see `docs/RULE_CATALOG.md` for the generated, always-current list:
47
47
 
48
48
  - `img`
49
49
  - `area`
@@ -67,12 +67,13 @@ Rules MUST NOT mix families.
67
67
  ### 1.4 Target Set (Tree Scope)
68
68
  Encoded by: `data.visibilityFilter.targetSet`
69
69
 
70
- Current value used by this ruleset:
71
- - `acc` (accessibility tree)
70
+ Values used by this ruleset:
71
+ - `acc` (accessibility tree) — most rules
72
+ - `dom` (raw DOM/CSS visibility, no accessibility-tree computation) — e.g. `label-in-name.js`
72
73
 
73
- Rules explicitly log eligibility against the accessibility tree using:
74
- - `helpers.isAccTreeEligible`
75
- - `helpers.getEligibilityInfo(..., { targetSet: "acc" })`
74
+ Rules log eligibility against whichever tree scope they target using:
75
+ - `helpers.isAccTreeEligible` / `helpers.isDomVisibleEligible`
76
+ - `helpers.getEligibilityInfo(..., { targetSet: "acc" | "dom" })`
76
77
 
77
78
  This is a semantic constraint, not logging noise.
78
79
 
@@ -29,7 +29,7 @@ By design, this engine never enumerates the elements a rule *passed* — only th
29
29
  Two common causes, in order of likelihood:
30
30
 
31
31
  1. **The element is excluded from the accessibility tree** — `aria-hidden="true"`, `display: none`, `visibility: hidden`, `hidden`, or an `inert` ancestor. Most rules deliberately skip content that's already invisible to assistive technology (checking a hidden element would be meaningless, and could produce a misleading `fail` on content no user encounters). Some rules explicitly opt out of this gating when it wouldn't make sense to (e.g. `no-autoplay-audio` — hidden audio still plays sound) — check the specific rule's file header comment (`@applicability`) in `src/checks/`.
32
- 2. **`excludeSelectors`** — if you've configured this (directly or inherited from a shared config), confirm the element in question isn't matched by it.
32
+ 2. **`excludeSelectors`** — if you've configured this (directly or inherited from a shared config), confirm the element in question isn't matched by it. Remember this can also be scoped to a single rule via `engineOptions.rules[ruleId].excludeSelectors` (see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#rule-scoped-excludeselectors)) — if a rule you expect to fire keeps coming back `notApplicable`/`pass` for one element only, check whether that rule specifically has its own exclude list configured, not just the global one.
33
33
 
34
34
  ## "Does a clean scan (`pass` everywhere) mean the page is WCAG conformant?"
35
35
 
@@ -39,9 +39,9 @@ No — see [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md). A `pass` means every
39
39
 
40
40
  Treat it as "needs a human to look" — it's neither pass nor fail by design. Most teams log `cantTell` findings without failing CI on them, since failing a build on something the engine explicitly couldn't determine tends to train people to ignore the gate. See [`POLICY.md`](./POLICY.md) if you want to reshape this behavior (e.g. via a custom policy contract), and [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result) for a concrete CI-gating example.
41
41
 
42
- ## "Why is French only partially translated?"
42
+ ## "What happens if a locale is only partially translated?"
43
43
 
44
- It genuinely is — see [`I18N.md`](./I18N.md) for the exact current coverage. Missing keys fall back to English per-string (never a blank or broken result), so a partial locale degrades gracefully rather than failing outright.
44
+ Missing keys fall back to English per-string (never a blank or broken result), so a partial locale degrades gracefully rather than failing outright — see [`I18N.md`](./I18N.md) for the mechanism and current coverage. Both shipped locales (`en`, `fr`) are at full parity as of this writing, but that's not guaranteed to stay true automatically: adding a new rule adds a new key to `en.js`, and unless the same key is added to `fr.js` (or any other locale file you maintain), that string falls back to English until it is.
45
45
 
46
46
  ## "`runDomRulesInPage` vs `runa11yCoreInPage` — which one do I want?"
47
47
 
@@ -4,7 +4,7 @@ How individual rule results relate to a WCAG Success Criterion (SC), and what su
4
4
 
5
5
  ## The three layers
6
6
 
7
- 1. **Atomic rules** (`checksResults[]`) — one normative decision each, e.g. "does this `<img>` have an `alt` attribute." See [`RULE_CATALOG.md`](./RULE_CATALOG.md) for all 123.
7
+ 1. **Atomic rules** (`checksResults[]`) — one normative decision each, e.g. "does this `<img>` have an `alt` attribute." See [`RULE_CATALOG.md`](./RULE_CATALOG.md) for all 125.
8
8
  2. **Facets** — a WCAG SC is usually bigger than any one rule can decide deterministically. Internally, each SC is broken into named "facets" (e.g. 1.1.1 Non-text Content has facets like `img-alt-attr-present`, `text-alternative-quality`, `decorative-null`) tracked in `src/coverage/wcag-facets.js`, each marked `full` (a rule decides it with high confidence), `partial` (a rule decides *part* of it — see each rule's own scope notes), or `manual` (no safe automated heuristic exists at all). Run `npm run coverage` to regenerate `coverage/coverage-report.md`, the per-SC facet breakdown.
9
9
  3. **Composite (WCAG-SC rollup) rules** (`rulesResults[]`) — a generated aggregate of every atomic rule mapped to one SC, giving you one pass/fail/cantTell/notApplicable verdict per SC instead of having to roll up dozens of atomic results yourself. See [`RULE_CATALOG.md`](./RULE_CATALOG.md#composite-wcag-sc-rollup-rules-31) for the full list (e.g. `wcag-1.1.1-non-text-content` rolls up 22 atomic rules).
10
10
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Lightweight DOM rules accessibility core with modular rules.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
- "surea11y": "./bin/core.js"
7
+ "surea11y": "bin/core.js"
8
8
  },
9
9
  "author": "Jorge Rumoroso",
10
10
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  ],
38
38
  "scripts": {
39
39
  "build": "node scripts/build-core.js",
40
+ "pretest": "playwright install chromium",
40
41
  "test": "npm run build && node scripts/run-tests.js",
41
42
  "test:contrast-helpers": "node tests/contrast-helpers.test.js",
42
43
  "helpers-perf-bench": "node --expose-gc scripts/dom-helpers-perf-bench.js",
@@ -22,12 +22,7 @@
22
22
  * - SUPPORTED_ATTRS_BY_ROLE widened 2026-07-21 (7 new roles), cross-checked
23
23
  * against a widely-used reference engine's own per-role `allowedAttrs`
24
24
  * table AND verified each addition is an unambiguous, well-established ARIA
25
- * fact rather than a blind import — that engine's own table has ~68 roles,
26
- * far more than were reconciled here; this pass deliberately took only the
27
- * additions with clear, specific supported attributes (not just the
28
- * near-universal, thin `aria-expanded` that engine allows on most roles), and
29
- * left the rest for a dedicated future full-reconciliation pass rather
30
- * than rushing all 68 through at lower confidence:
25
+ * fact rather than a blind import:
31
26
  * - `searchbox`: identical to the already-covered `textbox` (ARIA
32
27
  * explicitly defines searchbox as textbox's subclass, same supported
33
28
  * set).
@@ -47,6 +42,34 @@
47
42
  * - `menu`/`menubar`/`toolbar`: activedescendant/orientation — standard
48
43
  * composite-widget properties, same family as the already-covered
49
44
  * `tablist`.
45
+ * - SUPPORTED_ATTRS_BY_ROLE full-reconciliation pass 2026-07-28: the
46
+ * 2026-07-21 pass deliberately deferred ~61 roles where the reference
47
+ * engine (axe-core) allows `aria-expanded`, treating it as too thin/
48
+ * near-universal to import blindly. Checked against `aria-query`
49
+ * (tracks the published WAI-ARIA 1.2 Recommendation, 6 June 2023 — the
50
+ * latest actually-published version, as opposed to the in-progress 1.3
51
+ * Editor's Draft) instead of axe-core directly, because axe-core's own
52
+ * source comments (`// Spec difference: Aria-expanded removed in 1.2`)
53
+ * show that most of its `aria-expanded` allowances are deliberate
54
+ * ARIA-1.1 legacy/AT-compat carryovers axe-core keeps on purpose, not
55
+ * current-spec facts — importing them wholesale would have re-added
56
+ * exactly the kind of unverified allowance the 07-21 pass was avoiding.
57
+ * `aria-query`'s per-role tables (with superclass inheritance resolved,
58
+ * e.g. `aria-activedescendant` via the abstract `composite` role) gave a
59
+ * spec-grounded diff instead. Net changes from that diff:
60
+ * - `aria-expanded` added to: checkbox, columnheader, gridcell, listbox,
61
+ * menuitemcheckbox, menuitemradio, row, rowheader, switch, tab —
62
+ * confirmed present in current spec for these roles specifically
63
+ * (unlike e.g. `listitem`, `dialog`, `alertdialog`, `heading`, which
64
+ * the spec does NOT list it for — those stay excluded).
65
+ * - `aria-activedescendant` added to: combobox, grid, listbox,
66
+ * radiogroup, row, spinbutton, tablist, treegrid (inherited from the
67
+ * abstract `composite` role for any composite/managed-focus widget).
68
+ * - `aria-readonly`/`aria-required` added to: switch, menuitemcheckbox,
69
+ * menuitemradio. `aria-posinset`/`aria-setsize` added to: tab, radio,
70
+ * menuitemcheckbox, menuitemradio. `aria-level` added to: tablist.
71
+ * - `tree`'s `aria-readonly` removed: not in aria-query's resolved
72
+ * props for `tree` (was an unverified carryover, not spec-backed).
50
73
  * - Not gated on isAccTreeEligible: this is a static markup property.
51
74
  */
52
75
 
@@ -101,39 +124,39 @@ function runInPage(ctx) {
101
124
  // role/attribute pairings from the WAI-ARIA role definitions are listed.
102
125
  const SUPPORTED_ATTRS_BY_ROLE = {
103
126
  alertdialog: ['aria-modal'],
104
- checkbox: ['aria-checked', 'aria-readonly', 'aria-required'],
105
- columnheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected'],
106
- combobox: ['aria-expanded', 'aria-autocomplete', 'aria-readonly', 'aria-required'],
127
+ checkbox: ['aria-checked', 'aria-readonly', 'aria-required', 'aria-expanded'],
128
+ columnheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected', 'aria-expanded'],
129
+ combobox: ['aria-expanded', 'aria-autocomplete', 'aria-readonly', 'aria-required', 'aria-activedescendant'],
107
130
  dialog: ['aria-modal'],
108
- grid: ['aria-multiselectable', 'aria-readonly', 'aria-colcount', 'aria-rowcount'],
109
- gridcell: ['aria-selected', 'aria-readonly', 'aria-required', 'aria-colindex', 'aria-colspan', 'aria-rowindex', 'aria-rowspan'],
131
+ grid: ['aria-multiselectable', 'aria-readonly', 'aria-colcount', 'aria-rowcount', 'aria-activedescendant'],
132
+ gridcell: ['aria-selected', 'aria-readonly', 'aria-required', 'aria-colindex', 'aria-colspan', 'aria-rowindex', 'aria-rowspan', 'aria-expanded'],
110
133
  heading: ['aria-level'],
111
- listbox: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation'],
134
+ listbox: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation', 'aria-expanded', 'aria-activedescendant'],
112
135
  listitem: ['aria-level', 'aria-posinset', 'aria-setsize'],
113
136
  menu: ['aria-activedescendant', 'aria-orientation'],
114
137
  menubar: ['aria-activedescendant', 'aria-orientation'],
115
- menuitemcheckbox: ['aria-checked'],
116
- menuitemradio: ['aria-checked'],
138
+ menuitemcheckbox: ['aria-checked', 'aria-expanded', 'aria-readonly', 'aria-required', 'aria-posinset', 'aria-setsize'],
139
+ menuitemradio: ['aria-checked', 'aria-expanded', 'aria-readonly', 'aria-required', 'aria-posinset', 'aria-setsize'],
117
140
  meter: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'],
118
141
  option: ['aria-selected', 'aria-checked', 'aria-posinset', 'aria-setsize'],
119
142
  progressbar: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'],
120
- radio: ['aria-checked'],
121
- radiogroup: ['aria-readonly', 'aria-required', 'aria-orientation'],
122
- row: ['aria-selected', 'aria-level', 'aria-posinset', 'aria-setsize', 'aria-colindex', 'aria-rowindex'],
123
- rowheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected'],
143
+ radio: ['aria-checked', 'aria-posinset', 'aria-setsize'],
144
+ radiogroup: ['aria-readonly', 'aria-required', 'aria-orientation', 'aria-activedescendant'],
145
+ row: ['aria-selected', 'aria-level', 'aria-posinset', 'aria-setsize', 'aria-colindex', 'aria-rowindex', 'aria-expanded', 'aria-activedescendant'],
146
+ rowheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected', 'aria-expanded'],
124
147
  scrollbar: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation', 'aria-controls'],
125
148
  searchbox: ['aria-activedescendant', 'aria-autocomplete', 'aria-multiline', 'aria-placeholder', 'aria-readonly', 'aria-required'],
126
149
  separator: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation'],
127
150
  slider: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation', 'aria-readonly'],
128
- spinbutton: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-readonly', 'aria-required'],
129
- switch: ['aria-checked'],
130
- tab: ['aria-selected'],
151
+ spinbutton: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-readonly', 'aria-required', 'aria-activedescendant'],
152
+ switch: ['aria-checked', 'aria-expanded', 'aria-readonly', 'aria-required'],
153
+ tab: ['aria-selected', 'aria-expanded', 'aria-posinset', 'aria-setsize'],
131
154
  table: ['aria-colcount', 'aria-rowcount'],
132
- tablist: ['aria-multiselectable', 'aria-orientation'],
155
+ tablist: ['aria-multiselectable', 'aria-orientation', 'aria-level', 'aria-activedescendant'],
133
156
  textbox: ['aria-activedescendant', 'aria-autocomplete', 'aria-multiline', 'aria-placeholder', 'aria-readonly', 'aria-required'],
134
157
  toolbar: ['aria-activedescendant', 'aria-orientation'],
135
- tree: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation'],
136
- treegrid: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation', 'aria-colcount', 'aria-rowcount'],
158
+ tree: ['aria-multiselectable', 'aria-required', 'aria-orientation', 'aria-activedescendant'],
159
+ treegrid: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation', 'aria-colcount', 'aria-rowcount', 'aria-activedescendant'],
137
160
  treeitem: ['aria-checked', 'aria-selected', 'aria-expanded', 'aria-level', 'aria-posinset', 'aria-setsize']
138
161
  };
139
162
 
@@ -117,14 +117,35 @@ function createDomHelpers(opts) {
117
117
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
118
118
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
119
119
 
120
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
121
+ // by dom-runner.js immediately before invoking each rule's applicability/
122
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
123
+ // closure state because rule execution is synchronous and single-rule-
124
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
125
+ var __activeRuleExcludeSelectors = [];
126
+
127
+ function __getEffectiveExcludeSelectors() {
128
+ return __activeRuleExcludeSelectors.length
129
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
130
+ : excludeSelectors;
131
+ }
132
+
133
+ function __setActiveRuleExcludeSelectors(list) {
134
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
135
+ }
136
+
120
137
  // Selector-related caches (selector uniqueness index, per-element built
121
- // selector strings) depend on includeShadowDom/excludeSelectors, since
122
- // those change which elements are considered when checking uniqueness.
123
- // The underlying storage is shared across createDomHelpers() calls on
124
- // the same window/document (see __domSharedCache below), so a run with
125
- // different options must not read/write another run's cached selectors.
126
- // This key partitions those caches per effective option set.
127
- const __selectorOptsKey = (includeShadowDom ? 'sd1' : 'sd0') + '|' + excludeSelectors.slice().sort().join(',');
138
+ // selector strings) depend on includeShadowDom/the effective exclude
139
+ // list, since those change which elements are considered when checking
140
+ // uniqueness. The underlying storage is shared across createDomHelpers()
141
+ // calls on the same window/document (see __domSharedCache below), so a
142
+ // run -- or a rule with its own rule-scoped excludes -- must not
143
+ // read/write another run/rule's cached selectors. This key partitions
144
+ // those caches per effective option set; recomputed per call (not a
145
+ // constant) since the effective list changes as the active rule changes.
146
+ function __getSelectorOptsKey() {
147
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
148
+ }
128
149
 
129
150
  // -------------------------------------------------------------------------
130
151
  // Per-run shared caches (DOM helpers)
@@ -886,9 +907,10 @@ function createDomHelpers(opts) {
886
907
 
887
908
 
888
909
  function isExcluded(el) {
889
- if (!excludeSelectors.length || !el || !el.closest) return false;
910
+ const eff = __getEffectiveExcludeSelectors();
911
+ if (!eff.length || !el || !el.closest) return false;
890
912
  try {
891
- return excludeSelectors.some((sel) => !!el.closest(sel));
913
+ return eff.some((sel) => !!el.closest(sel));
892
914
  } catch {
893
915
  return false;
894
916
  }
@@ -983,8 +1005,10 @@ function createDomHelpers(opts) {
983
1005
  if (!scope || !scope.querySelectorAll) return [];
984
1006
 
985
1007
  // Cache shadow root discovery per root to avoid repeated querySelectorAll('*') walks.
986
- // IMPORTANT: do not cache when excludeSelectors is non-empty (different helpers may differ).
987
- if (!excludeSelectors.length && __shadowRootsByRoot) {
1008
+ // IMPORTANT: do not cache when the effective exclude list (global
1009
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
1010
+ // may have different effective lists and must not share results.
1011
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
988
1012
  try {
989
1013
  const cached = __shadowRootsByRoot.get(scope);
990
1014
  if (cached) {
@@ -1059,7 +1083,7 @@ function createDomHelpers(opts) {
1059
1083
 
1060
1084
  function queryAllSmart(sel) {
1061
1085
  const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1062
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
1086
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
1063
1087
  }
1064
1088
 
1065
1089
  // -------------------------------------------------------------------------
@@ -1093,10 +1117,11 @@ function createDomHelpers(opts) {
1093
1117
  function __getSelectorCacheForOpts() {
1094
1118
  if (!__selectorCache) return null;
1095
1119
  try {
1096
- let wm = __selectorCache.get(__selectorOptsKey);
1120
+ const key = __getSelectorOptsKey();
1121
+ let wm = __selectorCache.get(key);
1097
1122
  if (!(wm instanceof WeakMap)) {
1098
1123
  wm = new WeakMap();
1099
- __selectorCache.set(__selectorOptsKey, wm);
1124
+ __selectorCache.set(key, wm);
1100
1125
  }
1101
1126
  return wm;
1102
1127
  } catch {
@@ -3507,7 +3532,8 @@ function createDomHelpers(opts) {
3507
3532
  return createSelectorUniqIndex();
3508
3533
  }
3509
3534
 
3510
- const cached = perScope.get(__selectorOptsKey);
3535
+ const key = __getSelectorOptsKey();
3536
+ const cached = perScope.get(key);
3511
3537
  if (cached) {
3512
3538
  __perfInc('uniqIndex.hit');
3513
3539
  return cached;
@@ -3516,7 +3542,7 @@ function createDomHelpers(opts) {
3516
3542
  __perfInc('uniqIndex.miss');
3517
3543
  const idx = createSelectorUniqIndex();
3518
3544
  try {
3519
- perScope.set(__selectorOptsKey, idx);
3545
+ perScope.set(key, idx);
3520
3546
  } catch { /* ignore */
3521
3547
  }
3522
3548
  __perfInc('uniqIndex.build');
@@ -4026,6 +4052,12 @@ function createDomHelpers(opts) {
4026
4052
  isAccTreeEligible,
4027
4053
  isDomVisibleEligible,
4028
4054
 
4055
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
4056
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
4057
+ // effect. Called by dom-runner.js before each rule invocation, not
4058
+ // intended for use by rule implementations.
4059
+ __setActiveRuleExcludeSelectors,
4060
+
4029
4061
  // Eligibility info wrapper
4030
4062
  getEligibilityInfo,
4031
4063
 
@@ -242,6 +242,15 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
242
242
  ? engineOptionsResolved.rules[defResolved.ruleId]
243
243
  : null;
244
244
 
245
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
246
+ // apply on top of the global excludeSelectors for exactly this rule's
247
+ // applicability check + run, then are cleared once this rule is done.
248
+ // Safe because rule execution below is synchronous and one rule at a
249
+ // time -- sharedHelpers is reused across all rules in this loop.
250
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
251
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
252
+ }
253
+
245
254
  const ctx = {
246
255
  document,
247
256
  window,
@@ -312,6 +321,14 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
312
321
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
313
322
  }
314
323
 
324
+ // Composite rollups below carry no occurrences/nodes of their own, so
325
+ // they never exercise rule-scoped excludes -- but clear the "active
326
+ // rule" state on sharedHelpers regardless, so nothing after this point
327
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
328
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
329
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
330
+ }
331
+
315
332
  // =========================
316
333
  // Composite rule aggregation (data-only rollups)
317
334
  // =========================