pour-engine 1.35.1 → 1.35.3

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.35.1',
9
+ version: '1.35.3',
10
10
  },
11
11
  };
@@ -31,17 +31,29 @@ const roleOf = (element) =>
31
31
  * as empty (2026-08-25 overnight audit; same reasoning as
32
32
  * aria-required-parent's flat-tree walk). Closed roots stay invisible, as
33
33
  * everywhere else in the engine.
34
+ *
35
+ * The walk also follows <slot>s downward: a container rendered inside a
36
+ * shadow root (a design system's <div role="list"> holding only a <slot>)
37
+ * owns the light-DOM items projected into it, which is what Chromium
38
+ * exposes as list > listitem. Reading the shadow tree alone reported the
39
+ * list as empty (spectrum.adobe.com, 2026-08-27 disagreement crawl).
34
40
  */
35
41
  function composedDescendants(element) {
36
42
  const found = [];
37
43
  const pending = [element];
44
+ const seen = new Set();
45
+ const enter = (el) => {
46
+ if (seen.has(el)) return;
47
+ seen.add(el);
48
+ found.push(el);
49
+ if (el.shadowRoot) pending.push(el.shadowRoot);
50
+ if (el.tagName === 'SLOT') for (const assigned of el.assignedElements({ flatten: true })) { enter(assigned); pending.push(assigned); }
51
+ };
38
52
  for (let i = 0; i < pending.length; i++) {
39
53
  const scope = pending[i];
40
54
  if (scope.shadowRoot) pending.push(scope.shadowRoot);
41
- for (const el of scope.querySelectorAll('*')) {
42
- found.push(el);
43
- if (el.shadowRoot) pending.push(el.shadowRoot);
44
- }
55
+ if (scope.tagName === 'SLOT') for (const assigned of scope.assignedElements({ flatten: true })) { enter(assigned); pending.push(assigned); }
56
+ for (const el of scope.querySelectorAll('*')) enter(el);
45
57
  }
46
58
  return found;
47
59
  }
@@ -1,4 +1,6 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ import { ROLE_ARIA } from '../../lib/roles.js';
3
+
2
4
  export default {
3
5
  id: 'listitem-parent',
4
6
  name: 'List item placement',
@@ -28,8 +30,16 @@ export default {
28
30
  // cannot own listitems and its <li> children are orphaned exactly as if
29
31
  // the <ul> were a <div>. Matching the tag alone waved those through
30
32
  // (found on ar.wikipedia.org: portal links in <ul role="navigation">).
33
+ //
34
+ // The role that counts is the first token the browser knows. An unknown
35
+ // token (<ol role="breadcrumbs">, seen on a design-system home) is
36
+ // dropped by every user agent (ARIA 1.2 §7.1, fallback roles), so the
37
+ // native list role stands and its <li>s are owned as usual; valid-role
38
+ // reports the misspelling on its own. Reading the raw attribute made
39
+ // the rule blame the list's items for a token that changed nothing.
31
40
  const listContainer = (el) => {
32
- const role = el.getAttribute('role');
41
+ const tokens = (el.getAttribute('role') ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
42
+ const role = tokens.find((t) => ROLE_ARIA[t]) ?? null;
33
43
  if (el.matches('ul, ol, menu')) return !role || ['list', 'presentation', 'none'].includes(role);
34
44
  return role === 'list';
35
45
  };
@@ -1014,6 +1014,47 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
1014
1014
  message: 'This text overlaps a coloured block that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
1015
1015
  };
1016
1016
  }
1017
+ // The panel scan keeps to positioned and floated paint because only
1018
+ // those cross sibling boundaries. The TEXT can be the one crossing:
1019
+ // an absolutely positioned floating label sits on the static <input>
1020
+ // beside it, and the walk, which never leaves the label's ancestors,
1021
+ // read the grey section behind the form (worldbank.org sign-up,
1022
+ // 2026-08-27, asserted 4.44:1 against the wrapper while the glyphs
1023
+ // sit on the input's white, 4.67:1; the same label passed once
1024
+ // scrolled into view and hit-tested). When the text or an ancestor
1025
+ // up to its containing block is out of flow, the static opaque
1026
+ // boxes inside that containing block are candidates too. Bounded by
1027
+ // the containing block's subtree; a huge one keeps the walk-only
1028
+ // behaviour rather than paying a full-page style pass per element.
1029
+ if (!blindOnly) {
1030
+ let outOfFlow = false;
1031
+ let block = null;
1032
+ for (let node = element; node && node.nodeType === 1 && node !== doc.body; node = node.parentElement ?? node.getRootNode()?.host) {
1033
+ const position = getComputedStyle(node).position;
1034
+ if (node !== element && position !== 'static') { block = node; break; }
1035
+ if (position === 'absolute' || position === 'fixed') outOfFlow = true;
1036
+ }
1037
+ if (outOfFlow && block) {
1038
+ const inside = block.querySelectorAll('*');
1039
+ if (inside.length <= 2000) {
1040
+ for (const sibling of inside) {
1041
+ if (sibling === element || sibling.contains(element) || element.contains(sibling)) continue;
1042
+ const siblingStyle = getComputedStyle(sibling);
1043
+ if (siblingStyle.position !== 'static' || siblingStyle.visibility === 'hidden') continue;
1044
+ const paint = parseColor(siblingStyle.backgroundColor);
1045
+ if (!paint || paint.a < 1) continue;
1046
+ const siblingRect = sibling.getBoundingClientRect();
1047
+ if (siblingRect.width < 24 || siblingRect.height < 12 || !near(siblingRect) || !textIntersects(element, siblingRect)) continue;
1048
+ if ((contrastRatio(foreground, paint) >= required) !== (direction === 'pass')) {
1049
+ return {
1050
+ status: 'incomplete',
1051
+ message: 'This text is positioned over a coloured box that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
1052
+ };
1053
+ }
1054
+ }
1055
+ }
1056
+ }
1057
+ }
1017
1058
  return null;
1018
1059
  };
1019
1060
 
@@ -52,14 +52,25 @@ export default {
52
52
  // When the element's role is modelled and does not support the attribute
53
53
  // at all, the value is moot and aria-allowed-attr already reviews the
54
54
  // attribute; reporting the value too would be the same finding twice.
55
+ //
56
+ // An EMPTY value is a third thing. ARIA defines the empty string as the
57
+ // default for aria-current (absent, empty and undefined all mean false),
58
+ // so aria-current="" is a correct way to say "not current" and nothing
59
+ // is lost (a design-system home writes it that way on every non-active
60
+ // link). For the other enumerated states an empty value is one the
61
+ // browser drops, which is the unknown-name situation again: nothing
62
+ // exposed wrongly, possibly a state the author meant. Review, not fail.
55
63
  const unknown = [];
56
64
  const invalid = [];
65
+ const empty = [];
57
66
  let role;
58
67
  for (const { name, value } of element.attributes) {
59
68
  if (!name.startsWith('aria-')) continue;
60
69
  const attr = name.slice(5);
61
70
  if (!KNOWN.has(attr)) {
62
71
  unknown.push(name);
72
+ } else if (ENUMS[name] && value.trim() === '') {
73
+ if (name !== 'aria-current') empty.push(name);
63
74
  } else if (ENUMS[name] && !ENUMS[name].includes(value.trim().toLowerCase())) {
64
75
  if (!GLOBAL_ARIA.has(attr)) {
65
76
  role ??= effectiveRole(element);
@@ -75,6 +86,13 @@ export default {
75
86
  fix: 'Correct the attribute name/value against the ARIA specification, or remove it.',
76
87
  };
77
88
  }
89
+ if (empty.length) {
90
+ return {
91
+ status: 'incomplete',
92
+ message: `${empty.join(', ')} is empty, so the browser ignores it and no state is exposed. Was a value intended here? If a real state was meant (${empty.map((n) => ENUMS[n].slice(0, 3).join(', ')).join('; ')}, ...), it is missing and this is a 4.1.2 failure; if the attribute is a template leftover, no one is affected.`,
93
+ fix: 'Give the attribute one of its allowed values, or remove it.',
94
+ };
95
+ }
78
96
  if (unknown.length) {
79
97
  return {
80
98
  status: 'incomplete',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.35.1",
3
+ "version": "1.35.3",
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",