pour-engine 1.36.0 → 1.37.1

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.36.0',
9
+ version: '1.37.1',
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-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-label-misuse, aria-valid-refs, button-name, composite-widget-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, summary-name, valid-role, invoker-target |
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-label-misuse, aria-valid-refs, button-name, composite-widget-name, dialog-name, frame-title, invoker-target, label-for-valid, nested-interactive, role-required-aria, summary-name, 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:** 39 partially automated · 47 manual — with 88 rules.
package/engine/README.md CHANGED
@@ -112,7 +112,7 @@ Nothing in the spec is silently skipped — that claim is the product.
112
112
 
113
113
  ## Honest current state
114
114
 
115
- 74 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
115
+ 88 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
116
116
  open shadow roots (targets come back as `host >>> inner` selector chains);
117
117
  iframe traversal is the host's job, and the extension does it. Known gaps:
118
118
  the full accname spec (ours is a close approximation) and the language
@@ -1040,7 +1040,7 @@ export function viewportVeil(doc) {
1040
1040
  /** Parse a computed text-shadow list into layers. Computed style serializes
1041
1041
  * the colour first ("rgb(0, 0, 0) 1px 0px 2px") with px lengths; author-ish
1042
1042
  * orderings are tolerated. */
1043
- export function parseTextShadows(cssText) {
1043
+ function parseTextShadows(cssText) {
1044
1044
  if (!cssText || cssText === 'none') return [];
1045
1045
  const layers = [];
1046
1046
  for (const part of cssText.split(/,(?![^(]*\))/)) {
@@ -1116,7 +1116,7 @@ export function textShadowNegligible(cssText, fontSize) {
1116
1116
  * uncertain path keeps the FULL box, erring toward review, never past a
1117
1117
  * real overlap. Returns null when the clip leaves nothing paintable.
1118
1118
  */
1119
- export function paintableRect(element, rect) {
1119
+ function paintableRect(element, rect) {
1120
1120
  let { left, top, right, bottom } = rect;
1121
1121
  let mode = getComputedStyle(element).position;
1122
1122
  for (let a = element.parentElement; a; a = a.parentElement) {
@@ -1333,7 +1333,7 @@ export function imageLuminanceRange(url, overlays = [], grid = null) {
1333
1333
  * cover/contain hands sizing to an intrinsic this function does not have, or
1334
1334
  * when its position is not a plain length or percentage.
1335
1335
  */
1336
- export function backgroundImagePaintRects(element) {
1336
+ function backgroundImagePaintRects(element) {
1337
1337
  const style = getComputedStyle(element);
1338
1338
  const images = splitLayers(style.backgroundImage);
1339
1339
  if (!images.length) return [];
@@ -1492,29 +1492,6 @@ export function rangeWithBackdrop(range, under, overlays = []) {
1492
1492
  };
1493
1493
  }
1494
1494
 
1495
- /**
1496
- * Luminance range of a CSS gradient, from its colour stops. Interpolated
1497
- * colours stay close enough to their stops' luminances to bracket.
1498
- */
1499
- export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
1500
- const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
1501
- if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
1502
- const composited = stops.map((stop) => (overlays.length ? applyOverlays(stop, overlays) : stop));
1503
- const lums = composited.map(luminance);
1504
- const min = Math.min(...lums);
1505
- const max = Math.max(...lums);
1506
- // The COLOURS at the extremes, not just their luminances: translucent text
1507
- // takes its presented colour from the pixels beneath, so bracketing that
1508
- // blend needs the actual rgb to composite against, and luminance alone
1509
- // cannot be un-gamma'd back into one.
1510
- return {
1511
- min,
1512
- max,
1513
- minColor: composited[lums.indexOf(min)],
1514
- maxColor: composited[lums.indexOf(max)],
1515
- };
1516
- }
1517
-
1518
1495
  /** The layers of a computed background-image list, split on top-level
1519
1496
  * commas only: commas inside url() and gradient() belong to the layer. */
1520
1497
  export function splitBackgroundLayers(backgroundImageCss) {
package/engine/lib/dom.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // DOM helpers shared by all rules.
2
2
 
3
+ /** Elements that never reach the accessibility tree, so they cannot break
4
+ * the structure of a list or a description list. STYLE belongs with them:
5
+ * CSS-in-JS libraries inject <style> right beside the markup, and it was
6
+ * being counted as a stray child that broke the list. */
7
+ export const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
8
+
3
9
  /**
4
10
  * The audit context plus every OPEN shadow root beneath it, hosts before
5
11
  * their shadow trees. Collected once per audit so each rule can query every
@@ -24,6 +24,12 @@ export const GLOBAL_ARIA = new Set([
24
24
  'live', 'owns', 'relevant', 'roledescription', 'braillelabel', 'brailleroledescription',
25
25
  ]);
26
26
 
27
+ /** The eight landmark roles (ARIA 1.2, Landmark Roles). Shared by the
28
+ * landmark rules, the landmark lens and the screen reader's rotor. */
29
+ export const LANDMARK_ROLES = new Set([
30
+ 'banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search',
31
+ ]);
32
+
27
33
  /** Attributes each role supports IN ADDITION to the globals.
28
34
  *
29
35
  * Includes the four attributes ARIA 1.2 removed from the GLOBAL set
@@ -3,11 +3,7 @@
3
3
  // page outline. Complementary joins via the aside demotion semantics in
4
4
  // lib/roles.js: only an aside that actually reaches the accessibility tree
5
5
  // as complementary owes top-levelness.
6
- import { implicitRole } from '../../lib/roles.js';
7
-
8
- const LANDMARK_ROLES = new Set([
9
- 'main', 'navigation', 'banner', 'contentinfo', 'complementary', 'region', 'search', 'form',
10
- ]);
6
+ import { implicitRole, LANDMARK_ROLES } from '../../lib/roles.js';
11
7
 
12
8
  /** HTML-AAM (form, section): "If a form has no accessible name, do not
13
9
  * expose the element as a landmark", and a section is a region only with
@@ -1,18 +1,15 @@
1
1
  // ARIA APG landmark pattern: same-type landmarks need distinguishing
2
2
  // labels, and banner / contentinfo / main must be unique per page outright
3
3
  // (two "banners" are ambiguous however well they're labelled).
4
- import { implicitRole } from '../../lib/roles.js';
4
+ import { implicitRole, LANDMARK_ROLES } from '../../lib/roles.js';
5
5
  import { labelledByName } from '../../lib/accessible-name.js';
6
6
 
7
7
  const SINGLETON = new Set(['banner', 'contentinfo', 'main']);
8
8
 
9
- // ARIA 1.2, Landmark Roles: the eight roles that are landmarks. An explicit
9
+ // Only the eight ARIA landmark roles (lib/roles.js) count. An explicit
10
10
  // non-landmark role on a landmark element (<nav role="menubar">) makes it a
11
11
  // widget, not a landmark, so it has nothing to be distinguished from
12
12
  // (2026-08-25 overnight audit).
13
- const LANDMARK_ROLES = new Set([
14
- 'banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search',
15
- ]);
16
13
 
17
14
  /** Landmark role of the element in the accessibility tree — explicit role
18
15
  * first, else the implicit role (which handles header/footer demotion
@@ -2,7 +2,12 @@
2
2
  // Composite roles are meaningless without their required child roles —
3
3
  // role="list" with no listitems announces as an empty list.
4
4
  import { implicitRole } from '../../lib/roles.js';
5
- const REQUIRED_CHILDREN = {
5
+ // Every row of ARIA 1.2's Required Owned Elements table; verify:aria-tables
6
+ // diffs this map against the spec (2026-09-01, after feed and row were
7
+ // found missing).
8
+ export const REQUIRED_CHILDREN = {
9
+ feed: ['article'],
10
+ row: ['cell', 'columnheader', 'gridcell', 'rowheader'],
6
11
  list: ['listitem'],
7
12
  listbox: ['option', 'group'],
8
13
  menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
@@ -13,7 +13,13 @@
13
13
  // > group > treeitem exactly as authored). For the roles whose context does
14
14
  // not list group (listitem, tab, row, cell), group stays transparent as
15
15
  // before.
16
- const REQUIRED_PARENT = {
16
+ // Every row of ARIA 1.2's Required Context Role table; verify:aria-tables
17
+ // diffs this map against the spec (2026-09-01, after caption, columnheader
18
+ // and rowheader were found missing).
19
+ export const REQUIRED_PARENT = {
20
+ caption: ['figure', 'grid', 'table', 'treegrid'],
21
+ columnheader: ['row'],
22
+ rowheader: ['row'],
17
23
  listitem: ['list'],
18
24
  option: ['listbox', 'group'],
19
25
  menuitem: ['menu', 'menubar', 'group'],
@@ -27,7 +33,7 @@ const REQUIRED_PARENT = {
27
33
  gridcell: ['row'],
28
34
  };
29
35
 
30
- const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row' };
36
+ const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row', figure: 'figure' };
31
37
 
32
38
  export default {
33
39
  id: 'aria-required-parent',
@@ -1,13 +1,12 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
+ import { NEVER_RENDERED } from '../../lib/dom.js';
3
+
2
4
  const ALLOWED = new Set(['DT', 'DD', 'DIV']);
3
- // Nothing here reaches the accessibility tree, so none of it can corrupt
4
- // the term/description pairing: the same set list-structure keeps, for the
5
- // same reason (CSS-in-JS injects <style> right beside the markup). HTML's
6
- // content model does exclude style from dl, but validity is not the
7
- // criterion; 1.3.1 is about relationships assistive technology can
8
- // determine, and every dt/dd pair stays intact around an element that has
9
- // no tree presence (2026-08-25 overnight audit).
10
- const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
5
+ // NEVER_RENDERED cannot corrupt the term/description pairing: HTML's content
6
+ // model does exclude style from dl, but validity is not the criterion;
7
+ // 1.3.1 is about relationships assistive technology can determine, and
8
+ // every dt/dd pair stays intact around an element that has no tree
9
+ // presence (2026-08-25 overnight audit).
11
10
 
12
11
  export default {
13
12
  id: 'definition-list',
@@ -125,6 +125,23 @@ export default {
125
125
  : 'Put visible text inside the label.',
126
126
  };
127
127
  }
128
+ // A label points at this id, but the id is shared: for binds to the
129
+ // FIRST element with it, so this later twin has no label and no name
130
+ // even though a label sits beside it (hsbc.com download basket, two
131
+ // rows reusing one document id, 2026-09-01). The fix is a unique id,
132
+ // not another label.
133
+ if (element.id) {
134
+ const root = element.getRootNode();
135
+ const pointed = root.querySelector?.(`label[for="${CSS.escape(element.id)}"]`);
136
+ const first = root.getElementById?.(element.id);
137
+ if (pointed && first && first !== element) {
138
+ return {
139
+ status: 'fail',
140
+ message: `A <label for="${element.id}"> exists, but an earlier element on the page has the same id, so the label belongs to that one and this field has no label at all.`,
141
+ fix: `Give this field a unique id and point its label at that; ids must not repeat on a page.`,
142
+ };
143
+ }
144
+ }
128
145
  return {
129
146
  status: 'fail',
130
147
  message: 'This form field has no label — users cannot tell what to enter.',
@@ -1,9 +1,6 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
- // Nothing here reaches the accessibility tree, so none of it can corrupt a
3
- // list's structure. STYLE belongs with them: CSS-in-JS libraries inject
4
- // <style> right where the markup sits, and it was being counted as a stray
5
- // child that broke the list.
6
- const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
2
+ import { NEVER_RENDERED } from '../../lib/dom.js';
3
+
7
4
  const ITEM_ROLES = ['listitem', 'presentation', 'none'];
8
5
 
9
6
  export default {
@@ -84,10 +84,16 @@ export default {
84
84
  message: 'This table has no header cells and its cells hold block content (headings, paragraphs, lists or forms), which is a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
85
85
  };
86
86
  }
87
+ // A two-column table is a list of label-and-value pairs; its first
88
+ // column is the row headers, and that is the whole fix (gov.uk
89
+ // inheritance tax guidance, 2026-09-01: five such tables, all td).
90
+ const twoColumns = Math.max(0, ...rows.map((row) => row.cells.length)) === 2;
87
91
  return {
88
92
  status: 'fail',
89
93
  message: 'This looks like a data table but has no header cells — screen reader users get the data with no way to tell what each row/column means.',
90
- fix: 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
94
+ fix: twoColumns
95
+ ? 'Mark the first cell of each row as <th scope="row">: in a two-column table the first column names what the second holds.'
96
+ : 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
91
97
  };
92
98
  }
93
99
  return { status: 'pass' };
@@ -1,6 +1,6 @@
1
1
  // WCAG SC 2.4.1 Bypass Blocks (Level A)
2
2
  import { collectRoots, isEmbeddedDocument } from '../../lib/dom.js';
3
- import { effectiveRole } from '../../lib/roles.js';
3
+ import { effectiveRole, LANDMARK_ROLES } from '../../lib/roles.js';
4
4
 
5
5
  // ARIA11 lists the landmark roles: banner, complementary, contentinfo,
6
6
  // form, main, navigation, region, search. Judged by EFFECTIVE role, not tag
@@ -10,7 +10,6 @@ import { effectiveRole } from '../../lib/roles.js';
10
10
  // and missed the second. region and form are landmarks only when named
11
11
  // (ARIA 1.2 requires an accessible name for region; HTML-AAM maps form to
12
12
  // the form landmark only with one), so the name is checked here.
13
- const LANDMARK_ROLES = new Set(['banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search']);
14
13
  const NAMED_ONLY = new Set(['form', 'region']);
15
14
  const LANDMARK_CANDIDATES = 'main, header, footer, nav, aside, section, form, [role]';
16
15
  const hasName = (el) => Boolean(
@@ -40,6 +40,18 @@ const NAME_FROM_CONTENT = 'a[href], button, summary, h1, h2, h3, h4, h5, h6, th,
40
40
  + ' [role="checkbox"], [role="radio"], [role="switch"], [role="heading"],'
41
41
  + ' [role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"], [role="tooltip"]';
42
42
 
43
+ // ARIA 1.2's ten roles that prohibit naming, and the HTML elements that
44
+ // carry them implicitly (HTML-AAM); verify:aria-tables diffs the set
45
+ // against the spec. The rule started with div, span and p alone
46
+ // (generic and paragraph); the other eight, explicit or as strong, em,
47
+ // code, del, s, ins, sub and sup, joined 2026-09-01.
48
+ export const NAME_PROHIBITED_ROLES = ['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'strong', 'subscript', 'superscript'];
49
+ const IMPLICITLY_PROHIBITED = ['div', 'span', 'p', 'strong', 'em', 'code', 'del', 's', 'ins', 'sub', 'sup'];
50
+ const SELECTOR = ['aria-label', 'aria-labelledby'].flatMap((attr) => [
51
+ ...IMPLICITLY_PROHIBITED.map((tag) => `${tag}[${attr}]:not([role]):not([tabindex])`),
52
+ ...NAME_PROHIBITED_ROLES.map((role) => `[role="${role}"][${attr}]:not([tabindex])`),
53
+ ]).join(', ');
54
+
43
55
  export default {
44
56
  id: 'aria-label-misuse',
45
57
  name: 'aria-label placement',
@@ -50,8 +62,7 @@ export default {
50
62
  // §5.2.8.6 prohibits BOTH naming attributes on generic — aria-labelledby
51
63
  // pointing at perfectly real text is the same authoring error as an inline
52
64
  // aria-label, and the more tempting one (a card labelled by its own title).
53
- selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex]),'
54
- + ' div[aria-labelledby]:not([role]):not([tabindex]), span[aria-labelledby]:not([role]):not([tabindex]), p[aria-labelledby]:not([role]):not([tabindex])',
65
+ selector: SELECTOR,
55
66
  evaluate(element) {
56
67
  // Inside a name-from-content ancestor the label is NOT ignored: accname
57
68
  // applies the whole algorithm to each descendant while building the
@@ -72,7 +83,7 @@ export default {
72
83
  })();
73
84
  return {
74
85
  status: 'incomplete',
75
- message: `${attempt} on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard. Prohibited markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is what users actually hear.`,
86
+ message: `${attempt} on ${element.hasAttribute('role') ? `role="${element.getAttribute('role').trim()}"` : `a plain <${element.tagName.toLowerCase()}>`} is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard. Prohibited markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is what users actually hear.`,
76
87
  fix: 'If this element must reliably announce something of its own, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposure is guaranteed — or move the spoken text into visible or visually-hidden real text. If the label is stray, remove it. Do not add a role solely to legalise the label.',
77
88
  };
78
89
  },
@@ -15,7 +15,7 @@
15
15
  // control yet), and without aria-selected on an option (selection is
16
16
  // often conveyed by focus alone); the spec lists both, the DOM cannot
17
17
  // show a user is blocked, so the finding asks rather than asserts.
18
- const REQUIRED = {
18
+ export const REQUIRED = {
19
19
  checkbox: ['aria-checked'],
20
20
  switch: ['aria-checked'],
21
21
  radio: ['aria-checked'],
@@ -29,7 +29,7 @@ const REQUIRED = {
29
29
  combobox: ['aria-expanded', 'aria-controls'],
30
30
  option: ['aria-selected'],
31
31
  };
32
- const HELD_AT_REVIEW = new Set(['aria-controls', 'aria-selected']);
32
+ export const HELD_AT_REVIEW = new Set(['aria-controls', 'aria-selected']);
33
33
 
34
34
  // What each missing property costs the user, in the message.
35
35
  const COST = {
@@ -1,7 +1,7 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  import { attributesOf } from '../../lib/dom.js';
3
3
  // Concrete, usable roles — abstract roles are invalid in markup.
4
- const VALID_ROLES = new Set([
4
+ export const VALID_ROLES = new Set([
5
5
  'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
6
6
  'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
7
7
  'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.36.0",
3
+ "version": "1.37.1",
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",