pour-engine 1.11.0 → 1.13.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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.11.0',
9
+ version: '1.13.0',
10
10
  },
11
11
  };
@@ -111,7 +111,7 @@ meaning that only a person can settle, even where a rule decides most real pages
111
111
 
112
112
  | SC | Name | Level | Coverage | Rules |
113
113
  |---|---|---|---|---|
114
- | 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
114
+ | 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
115
115
  | 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
116
116
 
117
117
  **Totals:** 30 partially automated · 56 manual — with 74 rules.
@@ -42,10 +42,13 @@ import audioControl from './wcag/1.4.2-audio-control.js';
42
42
  import pauseStopHide from './wcag/2.2.2-pause-stop-hide.js';
43
43
  import mediaCaptions from './wcag/1.2.2-media-captions.js';
44
44
  import ariaAttrValid from './wcag/4.1.2-aria-attr-valid.js';
45
- // Demoted to best-practice: an unsupported ARIA attribute breaks an ARIA
45
+ // Reviews rather than asserts: an unsupported ARIA attribute breaks an ARIA
46
46
  // author MUST (1.2 §8.6) but is INERT, so name, role and value all survive
47
- // and no success criterion fails (see the note in the rule file).
48
- import ariaAllowedAttr from './best-practice/aria-allowed-attr.js';
47
+ // and no success criterion provably fails. It sits in the WCAG scope anyway,
48
+ // because a real authoring error that a browser silently discards should not
49
+ // be invisible in a default audit, and it asks whether the state is real
50
+ // instead of claiming a failure (see the note in the rule file).
51
+ import ariaAllowedAttr from './wcag/4.1.2-aria-allowed-attr.js';
49
52
  import ariaFieldName from './wcag/4.1.2-aria-field-name.js';
50
53
  // Demoted to best-practice: prohibited-but-inert ARIA on a generic element
51
54
  // fails no success criterion (see the note in the rule file).
@@ -20,7 +20,17 @@ export default {
20
20
  // owned elements, so the <li>s in a <ul role="none"> are presentational
21
21
  // too. There is no listitem left to be orphaned, and stepping over the
22
22
  // <ul> used to blame whatever happened to be above it.
23
- const listContainer = (el) => el.matches('ul, ol, menu') || el.getAttribute('role') === 'list';
23
+ //
24
+ // Any OTHER explicit role replaces the native list semantics entirely:
25
+ // a <ul role="navigation"> is a navigation landmark, not a list, so it
26
+ // cannot own listitems and its <li> children are orphaned exactly as if
27
+ // the <ul> were a <div>. Matching the tag alone waved those through
28
+ // (found on ar.wikipedia.org: portal links in <ul role="navigation">).
29
+ const listContainer = (el) => {
30
+ const role = el.getAttribute('role');
31
+ if (el.matches('ul, ol, menu')) return !role || ['list', 'presentation', 'none'].includes(role);
32
+ return role === 'list';
33
+ };
24
34
  while (parent && !listContainer(parent) && (
25
35
  ['presentation', 'none'].includes(parent.getAttribute('role') ?? '')
26
36
  || (!parent.hasAttribute('role') && (parent.tagName === 'DIV' || parent.tagName === 'SPAN'))
@@ -28,10 +38,17 @@ export default {
28
38
  parent = parent.assignedSlot?.parentElement ?? parent.parentElement ?? parent.getRootNode()?.host;
29
39
  }
30
40
  if (parent && listContainer(parent)) return { status: 'pass' };
41
+ const parentLabel = parent
42
+ ? `<${parent.tagName.toLowerCase()}${parent.getAttribute('role') ? ` role="${parent.getAttribute('role')}"` : ''}>`
43
+ : 'nothing';
31
44
  return {
32
45
  status: 'fail',
33
- message: `This <li> sits inside <${parent?.tagName.toLowerCase() ?? 'nothing'}> — outside a list, screen readers lose the item's list context entirely.`,
34
- fix: 'Wrap it in a <ul> or <ol>, or change it to a <div>/<p> if it isn’t really a list item.',
46
+ message: `This <li> sits inside ${parentLabel} — ${parent?.matches('ul, ol, menu') && parent.getAttribute('role')
47
+ ? 'the explicit role replaces the list semantics, so'
48
+ : 'outside a list,'} screen readers lose the item's list context entirely.`,
49
+ fix: parent?.matches('ul, ol, menu') && parent.getAttribute('role')
50
+ ? 'Remove the role from the list (use a wrapping element for the landmark), or give the items roles the container expects.'
51
+ : 'Wrap it in a <ul> or <ol>, or change it to a <div>/<p> if it isn’t really a list item.',
35
52
  };
36
53
  },
37
54
  };
@@ -1,17 +1,42 @@
1
1
  // WCAG SC 2.1.1 Keyboard (Level A) · SC 2.1.3 Keyboard (No Exception) (Level AAA)
2
+ //
3
+ // Matches EVERY element rather than a tag list: overflow makes a scroll
4
+ // container out of whatever it lands on — nav, header, footer, form, table
5
+ // wrappers, custom elements — and a tag list silently skipped all of them
6
+ // (found on carbondesignsystem.com: a scrollable <nav> the rule never saw).
7
+ // The cost stays flat because the overwhelming majority of elements exit on
8
+ // two integer reads before any style or visibility work happens.
2
9
  const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]';
3
10
 
4
- /** Focusable content in the FLAT tree: the node's own descendants, nodes
11
+ /** Reachable with the Tab key — what actually lets a keyboard user move
12
+ * focus into the region and scroll it. Focusable-but-not-tabbable content
13
+ * (tabindex="-1") does not help: measured in Chrome 151, a scroller whose
14
+ * content is all tabindex="-1" is treated by the browser itself as holding
15
+ * nothing reachable (it auto-focuses the scroller, same as when empty). */
16
+ function isTabbable(element) {
17
+ if (element.tabIndex < 0 || element.disabled) return false;
18
+ if (typeof element.checkVisibility === 'function') {
19
+ return element.checkVisibility({ visibilityProperty: true });
20
+ }
21
+ return true;
22
+ }
23
+
24
+ /** Tabbable content in the FLAT tree: the node's own descendants, nodes
5
25
  * slotted in from the light DOM, and content of open shadow roots. */
6
- function hasFocusableContent(node) {
7
- if (node.querySelector?.(FOCUSABLE)) return true;
26
+ function hasTabbableContent(node) {
27
+ for (const el of node.querySelectorAll?.(FOCUSABLE) ?? []) {
28
+ if (isTabbable(el)) return true;
29
+ }
8
30
  for (const slot of node.querySelectorAll?.('slot') ?? []) {
9
31
  for (const assigned of slot.assignedElements?.() ?? []) {
10
- if (assigned.matches?.(FOCUSABLE) || assigned.querySelector?.(FOCUSABLE)) return true;
32
+ if ((assigned.matches?.(FOCUSABLE) && isTabbable(assigned))) return true;
33
+ for (const el of assigned.querySelectorAll?.(FOCUSABLE) ?? []) {
34
+ if (isTabbable(el)) return true;
35
+ }
11
36
  }
12
37
  }
13
38
  for (const el of node.querySelectorAll?.('*') ?? []) {
14
- if (el.shadowRoot && hasFocusableContent(el.shadowRoot)) return true;
39
+ if (el.shadowRoot && hasTabbableContent(el.shadowRoot)) return true;
15
40
  }
16
41
  return false;
17
42
  }
@@ -22,27 +47,60 @@ export default {
22
47
  tags: ['wcag2a', 'wcag211', 'wcag213'], // 2.1.3 is 2.1.1's AAA twin — same check satisfies both
23
48
  help: 'Scrollable regions must be reachable by keyboard',
24
49
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html',
25
- selector: 'div, section, article, main, aside, ul, ol, dl, pre, output, figure',
26
- evaluate(element) {
50
+ selector: '*',
51
+ // Visibility is checked inside, AFTER the cheap overflow gate: filtering
52
+ // every element of the page up front would cost a style read per element;
53
+ // gating first pays it only on the handful that actually overflow.
54
+ visibleOnly: false,
55
+ evaluate(element, { isVisible }) {
56
+ // Cheap-first: two integer reads reject almost every element with no
57
+ // style resolution at all. display:none subtrees have no layout, so
58
+ // both deltas are 0 and they exit here too.
59
+ const overflowsX = element.scrollWidth > element.clientWidth + 1;
60
+ const overflowsY = element.scrollHeight > element.clientHeight + 1;
61
+ if (!overflowsX && !overflowsY) return { status: 'pass' };
62
+ // The document scroller belongs to the browser: arrow keys scroll the
63
+ // page without any author-supplied focus stop. Overflow set on <body>
64
+ // can also propagate to the viewport, which is the same UA-owned
65
+ // scrollbar wearing different markup.
66
+ if (element === element.ownerDocument.documentElement || element === element.ownerDocument.body) {
67
+ return { status: 'pass' };
68
+ }
69
+ // A zero-extent box (a collapsed drawer at max-height: 0) presents no
70
+ // scrollable region — nothing of it is on screen to scroll. Its open
71
+ // state is a different audit state, judged when it is actually open.
72
+ if (!element.clientWidth || !element.clientHeight) return { status: 'pass' };
27
73
  const style = getComputedStyle(element);
28
74
  // Judged PER AXIS: an overflow the user can't scroll on its own axis
29
75
  // (overflow-x: hidden marquees whose overflow-y merely computes to
30
76
  // auto) is not a scrollable region — content moves by animation, not
31
77
  // by user scrolling.
32
- const scrollableX = /(auto|scroll)/.test(style.overflowX)
33
- && element.scrollWidth > element.clientWidth + 1;
34
- const scrollableY = /(auto|scroll)/.test(style.overflowY)
35
- && element.scrollHeight > element.clientHeight + 1;
78
+ const scrollableX = overflowsX && /(auto|scroll)/.test(style.overflowX);
79
+ const scrollableY = overflowsY && /(auto|scroll)/.test(style.overflowY);
36
80
  if (!scrollableX && !scrollableY) return { status: 'pass' };
81
+ if (!isVisible(element)) return { status: 'pass' };
37
82
  if (element.tabIndex >= 0) return { status: 'pass' };
38
- // Focusable content inside lets keyboard users scroll by moving focus —
83
+ // Tabbable content inside lets keyboard users scroll by moving focus —
39
84
  // including content slotted in from the light DOM and content inside
40
85
  // open shadow roots (carousels built as web components).
41
- if (hasFocusableContent(element)) return { status: 'pass' };
86
+ if (hasTabbableContent(element)) return { status: 'pass' };
87
+ // An explicit negative tabindex is the author OPTING OUT of the tab
88
+ // order, and the browser honours it: measured in Chrome 151, a scroller
89
+ // with tabindex="-1" is skipped by Tab even though the same scroller
90
+ // without the attribute would be auto-focused. Nothing inside is
91
+ // tabbable either, so the overflowed content is unreachable by keyboard
92
+ // in every engine — assertable, not browser-dependent.
93
+ if (element.hasAttribute('tabindex')) {
94
+ return {
95
+ status: 'fail',
96
+ message: 'This region scrolls, holds nothing tabbable, and its tabindex="-1" removes the region itself from the tab order — keyboard users cannot reach the overflowed content in any browser.',
97
+ fix: 'Change tabindex="-1" to tabindex="0" (plus role="region" and an aria-label describing it), or make something inside it tabbable.',
98
+ };
99
+ }
42
100
  // NOT a failure, because the browser may already have solved it.
43
101
  //
44
102
  // Chromium puts a scroll container in the tab order by itself when the
45
- // container holds nothing focusable — and that condition is exactly the
103
+ // container holds nothing tabbable — and that condition is exactly the
46
104
  // one this rule fires on, so in Chrome every element reaching this line
47
105
  // IS keyboard reachable. Measured, not assumed: Chrome 151 tabs
48
106
  // before → the scroller → after, while `element.tabIndex` still reports
@@ -57,7 +115,7 @@ export default {
57
115
  // don't.
58
116
  return {
59
117
  status: 'incomplete',
60
- message: 'This region scrolls and holds nothing focusable, so reaching its overflowed content depends on the browser. Chromium gives such containers keyboard focus automatically; not every engine does. Tab to it in the browsers you support, and add tabindex="0" if it cannot be reached.',
118
+ message: 'This region scrolls and holds nothing tabbable, so reaching its overflowed content depends on the browser. Chromium gives such containers keyboard focus automatically; not every engine does. Tab to it in the browsers you support, and add tabindex="0" if it cannot be reached.',
61
119
  fix: 'If you support browsers that do not focus scroll containers, add tabindex="0" (plus role="region" and an aria-label describing it).',
62
120
  };
63
121
  },
@@ -199,9 +199,183 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
199
199
  const encloses = (a, b) =>
200
200
  a.left <= b.left && a.right >= b.right && a.top <= b.top && a.bottom >= b.bottom;
201
201
 
202
+ // ── Obscured targets ──────────────────────────────────────────────
203
+ // The criterion measures the region that ACCEPTS the pointer action.
204
+ // When another target paints over part of this one, clicks in the
205
+ // overlap go to the other target, so the accepting region is only the
206
+ // part left uncovered — a 26px logo link tiled 20px from the next link
207
+ // offers the pointer 20px, whatever its box says (found on ant.design).
208
+ // Box-sized-fine targets are therefore re-measured as the largest
209
+ // uncovered sub-rectangle before they may pass.
210
+ //
211
+ // Overlap only counts between targets that MOVE TOGETHER. A footer
212
+ // link sliding under a fixed header overlaps it at this scroll
213
+ // position and not the next, and asserting a size failure from scroll
214
+ // state invents a defect the layout does not have (the same trap the
215
+ // reachability test below documents). Targets share motion when they
216
+ // share a fixed/sticky context — both inside the same pinned bar, or
217
+ // neither pinned at all.
218
+ const fixedContexts = new Map();
219
+ const fixedContextOf = (element) => {
220
+ if (fixedContexts.has(element)) return fixedContexts.get(element);
221
+ let context = null;
222
+ for (let node = element; node && node !== element.ownerDocument.documentElement; node = node.parentElement) {
223
+ const position = getComputedStyle(node).position;
224
+ if (position === 'fixed' || position === 'sticky') { context = node; break; }
225
+ }
226
+ fixedContexts.set(element, context);
227
+ return context;
228
+ };
229
+ const overlapOf = (a, b) => {
230
+ const left = Math.max(a.left, b.left);
231
+ const top = Math.max(a.top, b.top);
232
+ const right = Math.min(a.right, b.right);
233
+ const bottom = Math.min(a.bottom, b.bottom);
234
+ // Only MATERIAL overlap obscures. A 1–2px intrusion is the border-
235
+ // collapse convention (button groups with negative margins) plus
236
+ // layout rounding — asserting a size failure from a 1px shave off a
237
+ // 24px control invents a defect no pointer will ever notice (seen
238
+ // live: a consent button measured 112×23 through a 1px overlap).
239
+ return right - left > 2 && bottom - top > 2
240
+ ? { left, top, right, bottom, width: right - left, height: bottom - top } : null;
241
+ };
242
+ /** Does target j's paint sit ABOVE target i where they overlap? The
243
+ * paint stack at the overlap's midpoint answers it directly; the first
244
+ * layer belonging to exactly one of the two decides. Off-viewport
245
+ * points cannot be probed, and guessing from document order or
246
+ * z-index is not evidence on pages full of stacking contexts
247
+ * (transforms, opacity), so an unprovable overlap asserts nothing —
248
+ * an audit run with the region scrolled into view proves it then. */
249
+ const paintsOver = (j, i, overlap) => {
250
+ const doc = elements[i].ownerDocument;
251
+ const win = doc.defaultView;
252
+ const x = overlap.left + overlap.width / 2;
253
+ const y = overlap.top + overlap.height / 2;
254
+ if (!win || typeof doc.elementsFromPoint !== 'function'
255
+ || x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
256
+ for (const layer of doc.elementsFromPoint(x, y)) {
257
+ const inJ = elements[j] === layer || elements[j].contains(layer);
258
+ const inI = elements[i] === layer || elements[i].contains(layer);
259
+ if (inJ && !inI) return true;
260
+ if (inI && !inJ) return false;
261
+ }
262
+ return false; // neither reachable there — a third element's problem, not j's
263
+ };
264
+ /** Largest uncovered sub-rectangle after removing the overlap: the best
265
+ * of the four slabs beside/above/below it (exact for one obscurer,
266
+ * a safe under-approximation applied sequentially for several). */
267
+ const uncovered = (rect, overlap) => {
268
+ const slabs = [
269
+ { left: rect.left, top: rect.top, right: overlap.left, bottom: rect.bottom },
270
+ { left: overlap.right, top: rect.top, right: rect.right, bottom: rect.bottom },
271
+ { left: rect.left, top: rect.top, right: rect.right, bottom: overlap.top },
272
+ { left: rect.left, top: overlap.bottom, right: rect.right, bottom: rect.bottom },
273
+ ].map((s) => ({ ...s, width: s.right - s.left, height: s.bottom - s.top }))
274
+ .filter((s) => s.width >= 1 && s.height >= 1);
275
+ // Note: rect may be a DOMRect, whose properties do not survive object
276
+ // spread — the zero-rect is built from explicit reads.
277
+ if (!slabs.length) {
278
+ return { left: rect.left, top: rect.top, right: rect.left, bottom: rect.top, width: 0, height: 0 };
279
+ }
280
+ return slabs.reduce((best, s) => (s.width * s.height > best.width * best.height ? s : best));
281
+ };
282
+ // Spatial hash so finding overlap candidates stays near-linear on pages
283
+ // with tens of thousands of targets: only targets sharing a cell can
284
+ // intersect, so each target is tested against its cell-mates only.
285
+ const CELL = 256;
286
+ let cellIndex = null;
287
+ const cellsOf = (r) => {
288
+ const keys = [];
289
+ for (let cx = Math.floor(r.left / CELL); cx <= Math.floor(r.right / CELL); cx++) {
290
+ for (let cy = Math.floor(r.top / CELL); cy <= Math.floor(r.bottom / CELL); cy++) {
291
+ keys.push(`${cx}:${cy}`);
292
+ }
293
+ }
294
+ return keys;
295
+ };
296
+ const overlapCandidates = (i) => {
297
+ if (!cellIndex) {
298
+ cellIndex = new Map();
299
+ rects.forEach((r, j) => {
300
+ if (!laidOut[j]) return;
301
+ for (const key of cellsOf(r)) {
302
+ if (!cellIndex.has(key)) cellIndex.set(key, []);
303
+ cellIndex.get(key).push(j);
304
+ }
305
+ });
306
+ }
307
+ const seen = new Set();
308
+ for (const key of cellsOf(rects[i])) {
309
+ for (const j of cellIndex.get(key) ?? []) if (j !== i) seen.add(j);
310
+ }
311
+ return seen;
312
+ };
313
+ /** The pointer-accepting rect of a box-sized-fine target, reduced by
314
+ * every overlapping target painted above it — or null when nothing
315
+ * qualifying overlaps. */
316
+ const obscuredRect = (i) => {
317
+ let effective = null;
318
+ for (const j of overlapCandidates(i)) {
319
+ if (elements[j].contains(elements[i]) || elements[i].contains(elements[j])) continue; // same control, nested markup
320
+ if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) continue; // one control drawn twice
321
+ // Two links to the same place are one control in two boxes (a
322
+ // card's image link over its title link): clicks in the overlap
323
+ // land on the same destination, which is the criterion's
324
+ // "equivalent" case, provable here because the hrefs match.
325
+ if (typeof elements[i].href === 'string' && elements[i].href === elements[j].href) continue;
326
+ const overlap = overlapOf(effective ?? rects[i], rects[j]);
327
+ if (!overlap) continue;
328
+ if (fixedContextOf(elements[j]) !== fixedContextOf(elements[i])) continue; // scroll-state, not layout
329
+ if (!paintsOver(j, i, overlap)) continue;
330
+ effective = uncovered(effective ?? rects[i], overlap);
331
+ }
332
+ return effective;
333
+ };
334
+
202
335
  return elements.map((element, i) => {
203
336
  if (!laidOut[i]) return { status: 'pass' }; // not laid out / not a pointer target
204
- if (!undersized[i]) return { status: 'pass' };
337
+ if (!undersized[i]) {
338
+ // Box is big enough — but the pointer only gets the uncovered part.
339
+ const effective = obscuredRect(i);
340
+ if (!effective || (effective.width >= min && effective.height >= min)) return { status: 'pass' };
341
+ if (isInTextLine(element)) return { status: 'pass' };
342
+ // A target the pointer never actually hits is a keyboard
343
+ // affordance, not a pointer target — an in-flow skip link whose
344
+ // box sits beneath the header's paint (python.org's is hit-tested
345
+ // to its container at every probe point). Probed per fragment:
346
+ // if every in-viewport probe misses the element, pointers never
347
+ // interact with it and its pointer size is nobody's defect.
348
+ // (Hit-tests paid only here, on candidate failures.)
349
+ const everHit = (() => {
350
+ const doc = element.ownerDocument;
351
+ const win = doc.defaultView;
352
+ if (!win || typeof doc.elementsFromPoint !== 'function') return true;
353
+ const fragments = [...element.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
354
+ let tested = false;
355
+ for (const fragment of (fragments.length ? fragments : [rects[i]])) {
356
+ const x = fragment.left + fragment.width / 2;
357
+ const y = fragment.top + fragment.height / 2;
358
+ if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) continue;
359
+ tested = true;
360
+ if (doc.elementsFromPoint(x, y).includes(element)) return true;
361
+ }
362
+ return !tested; // nothing probe-able → don't know → don't silence
363
+ })();
364
+ if (!everHit) return { status: 'pass' };
365
+ // The spacing exception cannot apply: the obscuring target is at
366
+ // distance zero. UA-control cannot either: the browser sized the
367
+ // box, but the author's layout took part of it away.
368
+ const rect = rects[i];
369
+ return {
370
+ status: 'fail',
371
+ message: `This target's box is ${Math.round(rect.width)}×${Math.round(rect.height)}px, but another target overlaps it and clicks in the overlap go there — the part that still accepts the pointer is ${Math.round(effective.width)}×${Math.round(effective.height)}px, below the ${min}×${min}px minimum.`,
372
+ fix: 'Space the targets so they no longer overlap, or enlarge this one until the uncovered part reaches the minimum.',
373
+ data: {
374
+ box: { width: Math.round(rect.width), height: Math.round(rect.height) },
375
+ effective: { width: Math.round(effective.width), height: Math.round(effective.height) },
376
+ },
377
+ };
378
+ }
205
379
  if (uaControlled(element)) return { status: 'pass' }; // UA Control exception
206
380
  if (isInTextLine(element)) return { status: 'pass' };
207
381
  if (spacingException) {
@@ -3,19 +3,25 @@
3
3
  // state or property". An unsupported attribute breaks that MUST, so this is a
4
4
  // real, provable authoring error and the rule asserts it.
5
5
  //
6
- // It is NOT a WCAG failure, which is why it lives here rather than under
7
- // 4.1.2. An unsupported attribute is INERT, measured in Chromium:
8
- // aria-expanded on a button exposes expanded=false, while the same attribute
9
- // on a complementary landmark exposes no state at all. It is dropped, not
10
- // misreported. So the role is still exposed, the name is still computed, and
11
- // nothing is announced incorrectly. Name, role and value all survive, which
12
- // is all 4.1.2 asks for.
6
+ // It is not, on its own, a WCAG failure. An unsupported attribute is INERT,
7
+ // measured in Chromium: aria-expanded on a button exposes expanded=true,
8
+ // while the same attribute on complementary, heading or list exposes no
9
+ // state at all. It is dropped, not misreported. So the role is still
10
+ // exposed, the name is still computed, and nothing is announced incorrectly.
11
+ // Name, role and value all survive, which is all 4.1.2 asks for.
13
12
  //
14
13
  // What CAN fail 4.1.2 is the thing underneath: if the element genuinely has
15
- // the state the author reached for, that state is now invisible. Whether it
16
- // does is exactly what the DOM cannot establish, so the message asks rather
17
- // than asserts. Reporting the ARIA error as a WCAG violation would have been
18
- // asserting a criterion failure off the back of a question.
14
+ // the state the author reached for, that state is now invisible to a screen
15
+ // reader. Whether it does is exactly what the DOM cannot establish.
16
+ //
17
+ // So this sits in the WCAG scope and REVIEWS rather than asserts. It lived
18
+ // under best-practice before, which was defensible on the letter of 4.1.2
19
+ // and wrong in practice: teams act on what a tool marks as WCAG, so a real
20
+ // ARIA error that a browser silently discards went unseen in a default
21
+ // audit. Asserting a violation would state a criterion failure the browser
22
+ // does not actually cause; staying silent leaves a genuine authoring error
23
+ // unreported. Asking is the honest third option, and it is the one the
24
+ // engine is built around.
19
25
  //
20
26
  // Note "unsupported" is distinct from "prohibited" (§5.2.5), a separate
21
27
  // category about naming that this rule does not cover.
@@ -34,9 +40,9 @@ const HANDLED_ELSEWHERE = new Set(['label', 'labelledby']);
34
40
  export default {
35
41
  id: 'aria-allowed-attr',
36
42
  impact: 'moderate',
37
- tags: ['best-practice'],
43
+ tags: ['wcag2a', 'wcag412'],
38
44
  help: 'ARIA attributes must be supported by the element’s role',
39
- helpUrl: 'https://www.w3.org/TR/wai-aria-1.2/#state_property_processing',
45
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
40
46
  selector: '*',
41
47
  visibleOnly: false,
42
48
  evaluate(element) {
@@ -60,8 +66,8 @@ export default {
60
66
  if (!disallowed.length) return { status: 'pass' };
61
67
  const names = disallowed.map((n) => `aria-${n}`);
62
68
  return {
63
- status: 'fail',
64
- message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. If this element really does have that state, it is currently invisible to anyone using a screen reader.`,
69
+ status: 'incomplete',
70
+ message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Does this element really have that state? If it does, it is invisible to a screen reader and this is a 4.1.2 failure. If it does not, the attribute is simply stray and no one is affected.`,
65
71
  fix: `Move ${names.join('/')} to the element whose role supports it, usually the control that toggles this one, or remove it if the element has no such state. Adding a role to this element to make the attribute legal is rarely right: the host language restricts which roles each element may take.`,
66
72
  };
67
73
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",