pour-engine 1.35.3 → 1.35.4

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.3',
9
+ version: '1.35.4',
10
10
  },
11
11
  };
package/engine/lib/dom.js CHANGED
@@ -96,13 +96,25 @@ export function cssPath(element) {
96
96
  return path;
97
97
  }
98
98
 
99
+ /** An element's attributes read through the prototype, not the instance:
100
+ * a form whose control is named "attributes" shadows the property with
101
+ * that control (byggmax.se, 2026-08-30), and the same goes for any named
102
+ * form control. Anything that is not a NamedNodeMap is looked up on the
103
+ * prototype the element was born with. */
104
+ const attributesGetter = typeof Element !== 'undefined' ? Object.getOwnPropertyDescriptor(Element.prototype, 'attributes')?.get : null;
105
+ export function attributesOf(element) {
106
+ const own = element.attributes;
107
+ if (own && typeof own.length === 'number' && typeof own.item === 'function') return own;
108
+ return attributesGetter ? attributesGetter.call(element) : [];
109
+ }
110
+
99
111
  /** Element's opening markup (attributes) plus a snippet of its own text —
100
112
  * enough to recognise it in a table. Built WITHOUT element.outerHTML, which
101
113
  * serializes the whole subtree and turns audits quadratic on huge pages
102
114
  * (container divs would each stringify most of the document). */
103
115
  export function htmlSnippet(element, maxLength = 300) {
104
116
  let html = `<${element.tagName.toLowerCase()}`;
105
- for (const { name, value } of element.attributes) {
117
+ for (const { name, value } of attributesOf(element)) {
106
118
  if (html.length >= maxLength) break;
107
119
  html += ` ${name}="${value}"`;
108
120
  }
@@ -4,7 +4,14 @@
4
4
  // The override is injected and removed inside one synchronous pass — the
5
5
  // engine only yields to the renderer between rules, so the page never
6
6
  // paints the probed state.
7
- const OVERRIDE = `* {
7
+ // The probe reflows the whole page for a moment. Two guards keep that
8
+ // invisible to the user: scroll anchoring is disabled while the override
9
+ // is in (or Chrome re-anchors during the inflated layout and leaves the
10
+ // page scrolled somewhere else when the probe lifts — measured at 884px
11
+ // of drift on a long report page), and the window scroll is restored
12
+ // after removal in case the browser clamped it anyway.
13
+ const OVERRIDE = `html { overflow-anchor: none !important; }
14
+ * {
8
15
  line-height: 1.5 !important;
9
16
  letter-spacing: 0.12em !important;
10
17
  word-spacing: 0.16em !important;
@@ -44,6 +51,9 @@ export default {
44
51
  if (!candidates.some(Boolean)) return elements.map(() => ({ status: 'pass' }));
45
52
 
46
53
  const before = candidates.map((element) => element && clipped(element));
54
+ const win = doc.defaultView;
55
+ const scrollX = win?.scrollX ?? 0;
56
+ const scrollY = win?.scrollY ?? 0;
47
57
  const probe = doc.createElement('style');
48
58
  probe.dataset.pourAudit = 'probe';
49
59
  probe.textContent = OVERRIDE;
@@ -55,6 +65,10 @@ export default {
55
65
  after = candidates.map((element) => element && clipped(element));
56
66
  } finally {
57
67
  probe.remove();
68
+ // The audit must never move the page: put the scroll back exactly
69
+ // where the user had it, after the removal's own reflow settles.
70
+ void doc.documentElement.offsetHeight;
71
+ if (win && (win.scrollX !== scrollX || win.scrollY !== scrollY)) win.scrollTo(scrollX, scrollY);
58
72
  }
59
73
 
60
74
  return elements.map((element, i) => {
@@ -31,6 +31,7 @@
31
31
  // and was narrowed in 1.2 (narrowed again in the 1.3 draft). Markup written
32
32
  // against 1.1 is not conforming under 1.2, and a tool on the older baseline
33
33
  // stays silent on it. Neither reading is a bug; they are different editions.
34
+ import { attributesOf } from '../../lib/dom.js';
34
35
  import { GLOBAL_ARIA, ROLE_ARIA, KNOWN_ARIA, effectiveRole } from '../../lib/roles.js';
35
36
 
36
37
  // aria-label/labelledby misuse on generic elements has its own rule
@@ -50,7 +51,7 @@ export default {
50
51
  // Plain loop, no intermediate arrays: this runs on every element of the
51
52
  // page, and nearly all have no aria-* attributes at all.
52
53
  const ariaAttrs = [];
53
- for (const { name } of element.attributes) {
54
+ for (const { name } of attributesOf(element)) {
54
55
  if (!name.startsWith('aria-')) continue;
55
56
  const attr = name.slice(5);
56
57
  // Unknown/misspelled attributes are aria-attr-valid's finding — judging
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  // ARIA 1.2 attribute names (shared vocabulary in lib/roles.js), and the
3
3
  // allowed values for enumerated ones.
4
+ import { attributesOf } from '../../lib/dom.js';
4
5
  import { KNOWN_ARIA as KNOWN, GLOBAL_ARIA, ROLE_ARIA, effectiveRole } from '../../lib/roles.js';
5
6
 
6
7
  // Note: "undefined" is a legal token for the tristate/undefinable attributes
@@ -64,7 +65,7 @@ export default {
64
65
  const invalid = [];
65
66
  const empty = [];
66
67
  let role;
67
- for (const { name, value } of element.attributes) {
68
+ for (const { name, value } of attributesOf(element)) {
68
69
  if (!name.startsWith('aria-')) continue;
69
70
  const attr = name.slice(5);
70
71
  if (!KNOWN.has(attr)) {
@@ -1,4 +1,5 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ import { attributesOf } from '../../lib/dom.js';
2
3
  // Concrete, usable roles — abstract roles are invalid in markup.
3
4
  const VALID_ROLES = new Set([
4
5
  'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
@@ -95,7 +96,7 @@ export default {
95
96
  const genericFallback = tag === 'div' || tag === 'span' || tag === 'svg';
96
97
  const focusable = element.tabIndex >= 0
97
98
  || element.matches('a[href], button, input, select, textarea, summary');
98
- const hasAriaProps = [...element.attributes].some(
99
+ const hasAriaProps = [...attributesOf(element)].some(
99
100
  (attr) => attr.name.startsWith('aria-') && attr.name !== 'aria-hidden');
100
101
  if (genericFallback && !focusable && !hasAriaProps) return { status: 'pass' };
101
102
  // The same two triggers decide for EVERY tag (2026-08-25 overnight
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.35.3",
3
+ "version": "1.35.4",
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",