pour-engine 1.23.0 → 1.25.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.23.0',
9
+ version: '1.25.0',
10
10
  },
11
11
  };
@@ -143,7 +143,18 @@ function textFromNodes(nodes, includeHidden) {
143
143
  continue;
144
144
  }
145
145
  if (tag === 'img' || tag === 'area') {
146
- text += ` ${node.getAttribute('alt') ?? ''} `;
146
+ // HTML-AAM order for an image's own name: aria-label → alt → title.
147
+ // A PRESENT-but-empty alt marks the image decorative and contributes
148
+ // nothing — title must not resurrect it. Only when alt is ABSENT do
149
+ // the fallbacks apply (a title-only <img> inside a link names the
150
+ // link; Chromium exposes exactly this, measured on a live site where
151
+ // the walk's old alt-or-nothing shortcut called a named link nameless).
152
+ const imgAria = node.getAttribute('aria-label')?.trim();
153
+ if (imgAria) { text += ` ${imgAria} `; continue; }
154
+ const alt = node.getAttribute('alt');
155
+ if (alt !== null) { text += ` ${alt} `; continue; }
156
+ const imgTitle = node.getAttribute('title')?.trim();
157
+ if (imgTitle) text += ` ${imgTitle} `;
147
158
  continue;
148
159
  }
149
160
  const ariaLabel = node.getAttribute('aria-label')?.trim();
package/engine/lib/dom.js CHANGED
@@ -47,7 +47,13 @@ export function isVisible(element) {
47
47
 
48
48
  /** Selector for an element within its own root (document or shadow root). */
49
49
  function cssPathInRoot(element) {
50
- if (element.id) return `#${CSS.escape(element.id)}`;
50
+ // An id anchors the selector only when it is UNIQUE in this root: real
51
+ // pages duplicate ids (three carousel pagers all id="default"), and a
52
+ // bare #id selector would round-trip every one of them to the first,
53
+ // collapsing distinct findings onto a single element.
54
+ const root = element.getRootNode();
55
+ const uniqueId = (el) => el.id && root.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1;
56
+ if (uniqueId(element)) return `#${CSS.escape(element.id)}`;
51
57
  const parts = [];
52
58
  let current = element;
53
59
  while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
@@ -66,7 +72,7 @@ function cssPathInRoot(element) {
66
72
  if (repeated) part += `:nth-of-type(${index})`;
67
73
  }
68
74
  parts.unshift(part);
69
- if (current.parentElement?.id) {
75
+ if (current.parentElement && uniqueId(current.parentElement)) {
70
76
  parts.unshift(`#${CSS.escape(current.parentElement.id)}`);
71
77
  break;
72
78
  }
@@ -16,6 +16,24 @@ export default {
16
16
  if (element.querySelector('track[kind="captions" i], track[kind="subtitles" i], track:not([kind])')) {
17
17
  return { status: 'pass' };
18
18
  }
19
+ // A video with no media resource at all presents nothing — there is no
20
+ // audio content to caption (lazy players before their source is set).
21
+ if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
22
+ return { status: 'pass' };
23
+ }
24
+ // The decorative no-audio-path signature — the same boundary
25
+ // video-loop-motion draws for 2.2.2: muted, autoplaying, no controls.
26
+ // With no controls there is no user path to unmute, so whatever audio
27
+ // the file may carry is never presented to anyone; audio that cannot be
28
+ // presented is not audio content under 1.2.2, and asking a human to
29
+ // verify captions for it is noise (measured on the benchmark corpus:
30
+ // our own site's aria-hidden hero loops, ffprobe-confirmed to carry no
31
+ // audio stream, plus the same family on four news/university homes).
32
+ // Properties, not attributes: script-muted heroes count too. A muted
33
+ // video WITH controls keeps its review — one click unmutes it.
34
+ if (element.muted && element.autoplay && !element.controls) {
35
+ return { status: 'pass' };
36
+ }
19
37
  // Captions may be burned in or provided by the player — a human must check.
20
38
  return {
21
39
  status: 'incomplete',
@@ -21,16 +21,23 @@ export default {
21
21
  const style = getComputedStyle(label);
22
22
  return style.display !== 'none' && style.visibility !== 'hidden';
23
23
  });
24
- // Label content includes aria-labels on things inside it (icon-only
25
- // labels like <label><svg aria-label="Dark mode"/></label> still name)
26
- // and image alts (<label><img alt="…"></label> is a visible, named
27
- // label — alt text feeds name-from-contents per accname).
28
- const labelsText = visibleLabels
29
- .map((l) =>
30
- `${l.textContent} ${[...l.querySelectorAll('[aria-label], img[alt], area[alt]')]
31
- .map((e) => e.getAttribute('aria-label') ?? e.getAttribute('alt')).join(' ')}`)
32
- .join(' ')
33
- .trim();
24
+ // A label's contribution is its ACCESSIBLE text, not its raw textContent:
25
+ // aria-hidden subtrees inside the label are excluded from the name
26
+ // computation (a custom radio whose only visible text sits in an
27
+ // aria-hidden styled twin names NOTHING — the browser's tree computes an
28
+ // empty name), while aria-label on the label itself or on things inside
29
+ // it (icon-only labels) and image alts DO feed the name per accname.
30
+ const accessibleText = (node) => {
31
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent;
32
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
33
+ if (node.getAttribute('aria-hidden') === 'true') return '';
34
+ const aria = node.getAttribute('aria-label');
35
+ if (aria?.trim()) return aria;
36
+ const alt = (node.tagName === 'IMG' || node.tagName === 'AREA') ? node.getAttribute('alt') : null;
37
+ if (alt?.trim()) return alt;
38
+ return [...node.childNodes].map(accessibleText).join(' ');
39
+ };
40
+ const labelsText = visibleLabels.map(accessibleText).join(' ').trim();
34
41
  const ariaLabel = element.getAttribute('aria-label')?.trim();
35
42
  const labelledby = labelledByName(element);
36
43
  if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
@@ -65,10 +72,18 @@ export default {
65
72
  };
66
73
  }
67
74
  if (element.labels?.length) {
75
+ // The label may LOOK fine while announcing nothing: all its text
76
+ // sitting inside an aria-hidden subtree (the styled-twin custom
77
+ // control pattern) is excluded from the name computation.
78
+ const looksLabelled = visibleLabels.some((l) => l.textContent.trim());
68
79
  return {
69
80
  status: 'fail',
70
- message: 'This field has a <label>, but the label is empty — it announces nothing.',
71
- fix: 'Put visible text inside the label.',
81
+ message: looksLabelled
82
+ ? 'This field\'s <label> shows text, but all of it is inside aria-hidden content — the accessible name computes to nothing.'
83
+ : 'This field has a <label>, but the label is empty — it announces nothing.',
84
+ fix: looksLabelled
85
+ ? 'Move the visible text out of the aria-hidden wrapper, or add aria-label to the field.'
86
+ : 'Put visible text inside the label.',
72
87
  };
73
88
  }
74
89
  return {
@@ -77,7 +77,12 @@ export default {
77
77
  // the visible boundary (border, or a fill contrasting with the page
78
78
  // around it). The FIELD the user perceives is the wrapper — but only a
79
79
  // SNUG wrapper counts: a page section's border is not a field boundary.
80
- for (let wrapper = element.parentElement, depth = 0; wrapper && depth < 2; wrapper = wrapper.parentElement, depth++) {
80
+ // Snugness is the guard, so the walk continues through transparent
81
+ // intermediate wrappers (autocomplete shells, JS mount points) as long
82
+ // as each stays field-sized — a search plate three levels up is still
83
+ // the field the user sees, while a page section fails the size check
84
+ // long before any depth cap would matter.
85
+ for (let wrapper = element.parentElement, depth = 0; wrapper && depth < 4; wrapper = wrapper.parentElement, depth++) {
81
86
  const wrapperRect = wrapper.getBoundingClientRect();
82
87
  if (wrapperRect.height > ownRect.height + 24 || wrapperRect.width > ownRect.width + 160) break;
83
88
  const wrapperStyle = getComputedStyle(wrapper);
@@ -17,9 +17,19 @@ export default {
17
17
  // Only descendants a user can actually reach count: hidden inputs,
18
18
  // disabled controls, display:none templates, and tabindex="-1" nodes
19
19
  // inside a control are inert markup, not competing tab stops.
20
+ // Two more shapes are unreachable by the same principle: aria-hidden
21
+ // content (AT ignores it wholesale — a decorative svg role="button"
22
+ // aria-hidden="true" inside a play button competes with nothing; if it
23
+ // is natively focusable that is aria-hidden-focus's finding, not a
24
+ // second one here), and role-only widgets never given a tabindex (a
25
+ // role="button" with no tabindex is not in the tab order — keyboard
26
+ // users cannot reach it, so it cannot be a competing stop).
27
+ const NATIVE = 'a[href], button, input, select, textarea, summary, audio[controls], video[controls]';
20
28
  const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
21
29
  !el.matches(':disabled') && el.getAttribute('tabindex') !== '-1'
22
- && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el));
30
+ && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
31
+ && !el.closest('[aria-hidden="true"]')
32
+ && (el.matches(NATIVE) || el.hasAttribute('tabindex')));
23
33
  if (!nested) return { status: 'pass' };
24
34
  return {
25
35
  status: 'fail',
@@ -20,12 +20,16 @@ export default {
20
20
  const role = element.getAttribute('role');
21
21
  const required = REQUIRED[role];
22
22
  if (element.hasAttribute(required)) return { status: 'pass' };
23
- // A redundant explicit role on the native element (input type=checkbox
24
- // role=checkbox — common framework output) needs no ARIA state: the
25
- // host language supplies checkedness/value natively.
23
+ // Native semantics supply the state regardless of WHICH state-bearing
24
+ // role is claimed: HTML-AAM maps an input's checkedness to aria-checked
25
+ // (input type=checkbox role="switch" — the standard toggle pattern —
26
+ // exposes checked=false/true in the browser's tree with no ARIA at all),
27
+ // and a range input's value to aria-valuenow likewise.
26
28
  const type = element.tagName === 'INPUT' ? element.type : null;
27
- const NATIVE_STATE = { checkbox: 'checkbox', radio: 'radio', range: 'slider' };
28
- if (type && NATIVE_STATE[type] === role) return { status: 'pass' };
29
+ if (type === 'checkbox' || type === 'radio') {
30
+ if (required === 'aria-checked') return { status: 'pass' };
31
+ }
32
+ if (type === 'range' && required === 'aria-valuenow') return { status: 'pass' };
29
33
  if (element.tagName === 'SELECT' && role === 'combobox') return { status: 'pass' };
30
34
  return {
31
35
  status: 'fail',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.23.0",
3
+ "version": "1.25.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",