pour-engine 1.32.0 → 1.34.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.
Files changed (67) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/COVERAGE.md +5 -5
  3. package/engine/index.js +11 -4
  4. package/engine/lib/accessible-name.js +15 -4
  5. package/engine/lib/contrast.js +316 -11
  6. package/engine/lib/roles.js +30 -6
  7. package/engine/rules/best-practice/button-type.js +23 -2
  8. package/engine/rules/best-practice/fieldset-legend.js +7 -2
  9. package/engine/rules/best-practice/heading-order.js +2 -1
  10. package/engine/rules/best-practice/landmark-top-level.js +10 -0
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/new-window-link.js +34 -1
  13. package/engine/rules/best-practice/no-autofocus.js +15 -3
  14. package/engine/rules/best-practice/page-heading-one.js +15 -1
  15. package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
  16. package/engine/rules/best-practice/redundant-aria-label.js +24 -0
  17. package/engine/rules/best-practice/redundant-role.js +71 -20
  18. package/engine/rules/best-practice/region.js +6 -2
  19. package/engine/rules/index.js +13 -0
  20. package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
  21. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  22. package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
  23. package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
  24. package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
  25. package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
  26. package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
  27. package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
  28. package/engine/rules/wcag/1.3.1-form-label.js +39 -1
  29. package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
  30. package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
  31. package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
  32. package/engine/rules/wcag/1.3.4-orientation.js +21 -7
  33. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
  34. package/engine/rules/wcag/1.4.1-link-in-text-block.js +254 -13
  35. package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
  36. package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
  37. package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
  38. package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
  39. package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
  40. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
  41. package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
  42. package/engine/rules/wcag/2.4.2-document-title.js +8 -2
  43. package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
  44. package/engine/rules/wcag/2.4.4-link-name.js +11 -0
  45. package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
  46. package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
  47. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
  48. package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
  49. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
  50. package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
  51. package/engine/rules/wcag/2.5.8-target-size.js +141 -7
  52. package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
  53. package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
  54. package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
  55. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
  56. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +1 -1
  57. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
  58. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
  59. package/engine/rules/wcag/4.1.2-aria-label-misuse.js +1 -1
  60. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
  61. package/engine/rules/wcag/4.1.2-button-name.js +11 -0
  62. package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
  63. package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
  64. package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
  65. package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
  66. package/engine/wcag22.js +4 -4
  67. package/package.json +1 -1
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.32.0',
9
+ version: '1.34.0',
10
10
  },
11
11
  };
@@ -13,7 +13,7 @@ meaning that only a person can settle, even where a rule decides most real pages
13
13
  | SC | Name | Level | Coverage | Rules |
14
14
  |---|---|---|---|---|
15
15
  | 1.1.1 | [Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html) | A | Partially automated | area-alt, canvas-alt, embed-alt, image-alt, input-image-alt, object-alt, svg-img-alt |
16
- | 1.2.1 | [Audio-only and Video-only (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html) | A | Manual review | — |
16
+ | 1.2.1 | [Audio-only and Video-only (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html) | A | Partially automated | audio-transcript |
17
17
  | 1.2.2 | [Captions (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html) | A | Partially automated | media-captions |
18
18
  | 1.2.3 | [Audio Description or Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-or-media-alternative-prerecorded.html) | A | Manual review | — |
19
19
  | 1.2.4 | [Captions (Live)](https://www.w3.org/WAI/WCAG22/Understanding/captions-live.html) | AA | Manual review | — |
@@ -23,7 +23,7 @@ meaning that only a person can settle, even where a rule decides most real pages
23
23
  | 1.2.8 | [Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/media-alternative-prerecorded.html) | AAA | Manual review | — |
24
24
  | 1.2.9 | [Audio-only (Live)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-live.html) | AAA | Manual review | — |
25
25
  | 1.3.1 | [Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | A | Partially automated | aria-required-children, aria-required-parent, definition-list, dlitem-parent, form-label, list-structure, listitem-parent, p-as-heading, table-headers |
26
- | 1.3.2 | [Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html) | A | Manual review | — |
26
+ | 1.3.2 | [Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html) | A | Partially automated | reading-order-divergence |
27
27
  | 1.3.3 | [Sensory Characteristics](https://www.w3.org/WAI/WCAG22/Understanding/sensory-characteristics.html) | A | Manual review | — |
28
28
  | 1.3.4 | [Orientation](https://www.w3.org/WAI/WCAG22/Understanding/orientation.html) | AA | Partially automated | orientation-lock |
29
29
  | 1.3.5 | [Identify Input Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html) | AA | Partially automated | autocomplete-valid |
@@ -78,7 +78,7 @@ meaning that only a person can settle, even where a rule decides most real pages
78
78
  | 2.5.4 | [Motion Actuation](https://www.w3.org/WAI/WCAG22/Understanding/motion-actuation.html) | A | Manual review | — |
79
79
  | 2.5.5 | [Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) | AAA | Partially automated | target-size-enhanced |
80
80
  | 2.5.6 | [Concurrent Input Mechanisms](https://www.w3.org/WAI/WCAG22/Understanding/concurrent-input-mechanisms.html) | AAA | Manual review | — |
81
- | 2.5.7 | [Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) | AA | Manual review | — |
81
+ | 2.5.7 | [Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) | AA | Partially automated | drag-alternative |
82
82
  | 2.5.8 | [Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) | AA | Partially automated | target-size |
83
83
 
84
84
  ## Understandable
@@ -103,7 +103,7 @@ meaning that only a person can settle, even where a rule decides most real pages
103
103
  | 3.3.4 | [Error Prevention (Legal, Financial, Data)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html) | AA | Manual review | — |
104
104
  | 3.3.5 | [Help](https://www.w3.org/WAI/WCAG22/Understanding/help.html) | AAA | Manual review | — |
105
105
  | 3.3.6 | [Error Prevention (All)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-all.html) | AAA | Manual review | — |
106
- | 3.3.7 | [Redundant Entry](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html) | A | Manual review | — |
106
+ | 3.3.7 | [Redundant Entry](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html) | A | Partially automated | redundant-entry |
107
107
  | 3.3.8 | [Accessible Authentication (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html) | AA | Partially automated | auth-field-obstruction |
108
108
  | 3.3.9 | [Accessible Authentication (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-enhanced.html) | AAA | Manual review | — |
109
109
 
@@ -114,4 +114,4 @@ meaning that only a person can settle, even where a rule decides most real pages
114
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 |
115
115
  | 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
116
116
 
117
- **Totals:** 34 partially automated · 52 manual — with 82 rules.
117
+ **Totals:** 38 partially automated · 48 manual — with 86 rules.
package/engine/index.js CHANGED
@@ -22,10 +22,16 @@ function ruleMatchesTags(rule, tags) {
22
22
  /** Which WCAG version/level was requested, judged from the selected tags. */
23
23
  function scopeFromTags(tags) {
24
24
  if (!tags?.length) return { version: '2.2', level: 'AAA' }; // no filter → everything
25
- const version = tags.some((t) => t.startsWith('wcag22')) ? '2.2'
26
- : tags.some((t) => t.startsWith('wcag21')) ? '2.1' : '2.0';
27
- const level = tags.some((t) => /aaa$/.test(t)) ? 'AAA'
28
- : tags.some((t) => /aa$/.test(t)) ? 'AA' : 'A';
25
+ // Only version/level tags (wcag2a … wcag22aaa) define a scope. Criterion
26
+ // tags such as wcag221 must not read as version markers, and a tag list
27
+ // with no WCAG scope at all (best-practice only) has no checklist: a
28
+ // run that asked for no criteria owes no criteria (2026-08-25 audit).
29
+ const scoped = tags.filter((t) => /^wcag(2|21|22)(a|aa|aaa)$/.test(t));
30
+ if (!scoped.length) return null;
31
+ const version = scoped.some((t) => t.startsWith('wcag22')) ? '2.2'
32
+ : scoped.some((t) => t.startsWith('wcag21')) ? '2.1' : '2.0';
33
+ const level = scoped.some((t) => /aaa$/.test(t)) ? 'AAA'
34
+ : scoped.some((t) => /aa$/.test(t)) ? 'AA' : 'A';
29
35
  return { version, level };
30
36
  }
31
37
 
@@ -39,6 +45,7 @@ const LEVEL_ORDER = { A: 0, AA: 1, AAA: 2 };
39
45
  */
40
46
  function manualReviewCriteria(tags) {
41
47
  const scope = scopeFromTags(tags);
48
+ if (!scope) return [];
42
49
  return wcagCatalog
43
50
  .filter((criterion) => criterion.automation !== 'auto')
44
51
  .filter((criterion) => VERSION_ORDER[criterion.since] <= VERSION_ORDER[scope.version])
@@ -9,6 +9,11 @@
9
9
  const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number', 'date',
10
10
  'datetime-local', 'month', 'time', 'week', '']);
11
11
 
12
+ /** HTML "labelable elements": the ones whose `.labels` the browser fills
13
+ * (form-associated custom elements are labelable too but expose their
14
+ * labels through ElementInternals, so they are not reachable here). */
15
+ const LABELABLE = new Set(['input', 'select', 'textarea', 'button', 'meter', 'output', 'progress']);
16
+
12
17
  export function accessibleName(element) {
13
18
  return computeName(element, false);
14
19
  }
@@ -53,11 +58,17 @@ function computeName(element, inLabelledBy) {
53
58
  if (alt) return alt;
54
59
  }
55
60
 
61
+ // HTML-AAM puts the associated <label> before the element's own subtree
62
+ // for EVERY labelable element (button, meter, output, progress as well as
63
+ // input, select, textarea), and the browser names <label for="b"><button
64
+ // id="b"> from the label. Widened from the three field tags 2026-08-25
65
+ // overnight audit: button-name was asserting a label-named button nameless.
66
+ if (LABELABLE.has(tag) && element.labels?.length) {
67
+ const text = [...element.labels].map((label) => label.textContent).join(' ').trim();
68
+ if (text) return text;
69
+ }
70
+
56
71
  if (tag === 'input' || tag === 'select' || tag === 'textarea') {
57
- if (element.labels?.length) {
58
- const text = [...element.labels].map((label) => label.textContent).join(' ').trim();
59
- if (text) return text;
60
- }
61
72
  if (element.type === 'submit' || element.type === 'reset' || element.type === 'button') {
62
73
  // The value PROPERTY, not the attribute: scripts set `el.value`
63
74
  // without reflecting it, and the property is what names the button.
@@ -131,6 +131,7 @@ let mediaRectsCache = null;
131
131
  let panelRectsCache = null;
132
132
  let pseudoCache = new WeakMap();
133
133
  let zeroClipCache = new WeakMap();
134
+ let firstLineRulesCache = new WeakMap();
134
135
  const HAS_IMAGE = Symbol('background-image in chain');
135
136
 
136
137
  export function resetAuditCaches() {
@@ -141,6 +142,7 @@ export function resetAuditCaches() {
141
142
  panelRectsCache = null;
142
143
  pseudoCache = new WeakMap();
143
144
  zeroClipCache = new WeakMap();
145
+ firstLineRulesCache = new WeakMap();
144
146
  }
145
147
 
146
148
  /**
@@ -359,6 +361,87 @@ export function cumulativeOpacity(element) {
359
361
  return cached;
360
362
  }
361
363
 
364
+ /** One node's own opacity at rest: the landing value of a running
365
+ * animation on it, otherwise its computed opacity. */
366
+ function nodeRestingOpacity(node) {
367
+ const animators = opacityAnimators(node.ownerDocument);
368
+ if (animators.has(node)) {
369
+ const rest = animators.get(node);
370
+ return rest === null ? 1 : rest;
371
+ }
372
+ const value = parseFloat(getComputedStyle(node).opacity);
373
+ return Number.isFinite(value) ? value : 1;
374
+ }
375
+
376
+ /**
377
+ * The pair a viewer is presented with when the text sits inside an
378
+ * opacity group that also paints its own background.
379
+ *
380
+ * CSS group opacity renders an element and its subtree together, then
381
+ * blends that whole picture over what lies behind the element. Folding the
382
+ * opacity into the text colour alone and judging it against the group's
383
+ * own (undimmed) background describes a pair the page never paints: black
384
+ * text in a white card at opacity .5 over a mid-grey page is presented as
385
+ * rgb(64) on rgb(191), 5.6:1, while "half-black on white" scores 3.97:1.
386
+ *
387
+ * Walks the flat tree from the text to the OUTERMOST node with opacity
388
+ * below 1, compositing each node's background beneath the accumulated text
389
+ * and background pixels and then scaling both by that node's opacity, which
390
+ * is exactly the order the renderer applies. The result is composited over
391
+ * the background resolved behind the outermost carrier.
392
+ *
393
+ * Returns null when the chain carries no opacity, or no background paint
394
+ * inside the group (the old text-only blend is then correct), or paint this
395
+ * arithmetic cannot follow (an image, a blend mode, a filter inside the
396
+ * group). Returns { unresolved: true, opacity } when the backdrop behind the
397
+ * carrier is itself an image: the pair is then a human call, not a guess.
398
+ * Otherwise { foreground, background, opacity, text, paint }: the presented
399
+ * pair (both opaque) and the group's own pixels before compositing.
400
+ * (2026-08-25 overnight audit, defect 1.)
401
+ */
402
+ export function opacityGroupPaint(element, foreground) {
403
+ const flatParent = (node) => node.parentElement ?? node.getRootNode()?.host ?? null;
404
+ let top = null;
405
+ let opacity = 1;
406
+ for (let node = element; node && node.nodeType === 1; node = flatParent(node)) {
407
+ const own = nodeRestingOpacity(node);
408
+ if (own < 1) { top = node; opacity *= own; }
409
+ }
410
+ if (!top) return null;
411
+ let text = { ...foreground };
412
+ let paint = { r: 0, g: 0, b: 0, a: 0 };
413
+ let painted = false;
414
+ for (let node = element; node && node.nodeType === 1; node = flatParent(node)) {
415
+ const style = getComputedStyle(node);
416
+ if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return null;
417
+ if ((style.mixBlendMode && style.mixBlendMode !== 'normal')
418
+ || (style.filter && style.filter !== 'none')
419
+ || (style.backdropFilter && style.backdropFilter !== 'none')) return null;
420
+ const color = parseColor(style.backgroundColor);
421
+ if (color && color.a > 0) {
422
+ // A node's background lies beneath its content and inside its own
423
+ // opacity group, so it composites in before that group's alpha applies.
424
+ text = composite(text, color);
425
+ paint = composite(paint, color);
426
+ painted = true;
427
+ }
428
+ const own = nodeRestingOpacity(node);
429
+ if (own < 1) {
430
+ text = { ...text, a: text.a * own };
431
+ paint = { ...paint, a: paint.a * own };
432
+ }
433
+ if (node === top) break;
434
+ }
435
+ if (!painted) return null;
436
+ const behindNode = flatParent(top);
437
+ const behind = behindNode ? effectiveBackground(behindNode) : canvasColor(element.ownerDocument);
438
+ if (!behind) return { unresolved: true, opacity };
439
+ // `text` and `paint` are the group's own rendered pixels, alpha intact,
440
+ // so a caller can re-present the pair over a different backdrop (an
441
+ // overlapping panel the walk cannot see) without walking again.
442
+ return { foreground: composite(text, behind), background: composite(paint, behind), opacity, text, paint };
443
+ }
444
+
362
445
  /**
363
446
  * Is something painted between this element and the ancestor that provides
364
447
  * its background colour — a positioned <img>, video, overlay…? If so, the
@@ -805,17 +888,26 @@ export function paintedBackdrop(element) {
805
888
  // the stack. Its background-image was already adjudicated upstream by the
806
889
  // image-sampling path, so only its colour participates here.
807
890
  let acc = null;
891
+ // The translucent layers met on the way down, nearest the text first, in
892
+ // the shape backgroundImageSource hands out as `overlays`. When the walk
893
+ // ends on an image they are the paint BETWEEN the image and the glyphs
894
+ // (the hero pattern: a photo as <img>, a 70% black scrim div, a white
895
+ // heading), and the sampled pixels must be seen through them. Dropping
896
+ // them judged the raw photo and failed text that sat on the dimmed one.
897
+ // (2026-08-25 overnight audit, defect 2.)
898
+ const overlays = [];
808
899
  const own = parseColor(getComputedStyle(element).backgroundColor);
809
900
  if (own && own.a > 0) {
810
901
  if (own.a >= 1) return { color: own, scrim };
811
902
  acc = own;
903
+ overlays.push(own);
812
904
  }
813
905
  if (start === -1) return 'unresolved';
814
906
  for (const layer of stack.slice(start + 1)) {
815
907
  // Replaced elements paint their content, not a background — a photo or
816
908
  // video in the stack is an image backdrop whatever its styles say.
817
909
  if (/^(img|video|canvas|svg|picture|object|embed|iframe)$/i.test(layer.tagName)) {
818
- return { image: layer, scrim };
910
+ return { image: layer, scrim, overlays };
819
911
  }
820
912
  const style = getComputedStyle(layer);
821
913
  // A shadow HOST in the stack hides its shadow tree from the hit-test
@@ -846,7 +938,7 @@ export function paintedBackdrop(element) {
846
938
  // layer paints is a reason to ask a human, never to rule it out.
847
939
  const covers = (r) => !r
848
940
  || (point.x >= r.left && point.x < r.right && point.y >= r.top && point.y < r.bottom);
849
- if (!rects.length || rects.some(covers)) return { image: layer, scrim };
941
+ if (!rects.length || rects.some(covers)) return { image: layer, scrim, overlays };
850
942
  }
851
943
  let color = parseColor(style.backgroundColor);
852
944
  if (!color) return 'unresolved';
@@ -857,6 +949,7 @@ export function paintedBackdrop(element) {
857
949
  if (color.a === 0) continue;
858
950
  acc = acc ? composite(acc, color) : color;
859
951
  if (acc.a >= 1) return { color: acc, scrim };
952
+ overlays.push(color);
860
953
  }
861
954
  // Every layer was translucent: the canvas shows through underneath.
862
955
  const canvas = canvasColor(doc);
@@ -1085,8 +1178,37 @@ const overlayKey = (overlays) =>
1085
1178
  * one. Cached per URL.
1086
1179
  */
1087
1180
  const imageRangeCache = new Map();
1088
- export function imageLuminanceRange(url, overlays = []) {
1089
- const cacheKey = overlays.length ? `${url}|${overlayKey(overlays)}` : url;
1181
+ /** The largest sampling grid an image is read through, per axis. Above it
1182
+ * the read is `reduced` (see sampleGridFor) and callers must not assert a
1183
+ * pass from the range: detail finer than the grid could hide under the
1184
+ * glyphs. A 1024-square read is a million samples, once per image. */
1185
+ const MAX_SAMPLE_AXIS = 1024;
1186
+
1187
+ /**
1188
+ * How finely to sample an image painted over `extent` CSS pixels: one sample
1189
+ * per four painted pixels, never fewer than the historical 32 per axis, and
1190
+ * capped at MAX_SAMPLE_AXIS. A 32-square read of a 1024px hero averaged
1191
+ * every 32x32 block into one value, so 16px black squares in a white image
1192
+ * (logos, lettering, texture, anything glyph-sized) vanished from the range
1193
+ * and a pass was asserted from pixels that never contained them. Four
1194
+ * painted pixels per sample is finer than a glyph stroke; whatever is
1195
+ * narrower than that is the dither WCAG's relative-luminance Note 4 lets an
1196
+ * average stand for. `reduced` says the cap bit, so the grid is coarser than
1197
+ * that. (2026-08-25 overnight audit, defect 4.)
1198
+ */
1199
+ export function sampleGridFor(extent) {
1200
+ if (!extent || !(extent.width > 0) || !(extent.height > 0)) return null;
1201
+ const axis = (value) => Math.min(MAX_SAMPLE_AXIS, Math.max(32, Math.ceil(value / 4)));
1202
+ const width = axis(extent.width);
1203
+ const height = axis(extent.height);
1204
+ return { width, height, reduced: extent.width / width > 4.5 || extent.height / height > 4.5 };
1205
+ }
1206
+
1207
+ export function imageLuminanceRange(url, overlays = [], grid = null) {
1208
+ const width = grid?.width ?? 32;
1209
+ const height = grid?.height ?? 32;
1210
+ const sizeKey = width === 32 && height === 32 ? '' : `|${width}x${height}`;
1211
+ const cacheKey = `${url}${overlays.length ? `|${overlayKey(overlays)}` : ''}${sizeKey}`;
1090
1212
  if (imageRangeCache.has(cacheKey)) return imageRangeCache.get(cacheKey);
1091
1213
  const promise = new Promise((resolve) => {
1092
1214
  const img = new Image();
@@ -1095,13 +1217,12 @@ export function imageLuminanceRange(url, overlays = []) {
1095
1217
  img.onload = () => {
1096
1218
  clearTimeout(timer);
1097
1219
  try {
1098
- const size = 32;
1099
1220
  const canvas = document.createElement('canvas');
1100
- canvas.width = size;
1101
- canvas.height = size;
1221
+ canvas.width = width;
1222
+ canvas.height = height;
1102
1223
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
1103
- ctx.drawImage(img, 0, 0, size, size);
1104
- const data = ctx.getImageData(0, 0, size, size).data;
1224
+ ctx.drawImage(img, 0, 0, width, height);
1225
+ const data = ctx.getImageData(0, 0, width, height).data;
1105
1226
  let min = 1;
1106
1227
  let max = 0;
1107
1228
  // The colours at those extremes, kept for the same reason the
@@ -1143,8 +1264,13 @@ export function imageLuminanceRange(url, overlays = []) {
1143
1264
  resolve({ transparent: true, width: img.naturalWidth, height: img.naturalHeight });
1144
1265
  return;
1145
1266
  }
1267
+ // `reduced`: the grid was capped below four painted pixels per sample
1268
+ // AND the image really holds more pixels than the grid, so detail
1269
+ // was averaged away. An icon stretched across a huge box loses
1270
+ // nothing to the cap and is not flagged.
1271
+ const reduced = Boolean(grid?.reduced) && (img.naturalWidth > width || img.naturalHeight > height);
1146
1272
  resolve(min <= max
1147
- ? { min, max, minColor, maxColor, hasAlpha: alphaSeen, width: img.naturalWidth, height: img.naturalHeight }
1273
+ ? { min, max, minColor, maxColor, hasAlpha: alphaSeen, width: img.naturalWidth, height: img.naturalHeight, reduced }
1148
1274
  : null);
1149
1275
  } catch {
1150
1276
  resolve(null); // tainted canvas: cross-origin image without CORS headers
@@ -1260,7 +1386,10 @@ function splitLayers(value) {
1260
1386
 
1261
1387
  export function backgroundImagePaintRect(element, intrinsic) {
1262
1388
  const style = getComputedStyle(element);
1263
- if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
1389
+ // Layers are counted at the top level of the list: an SVG data URI that
1390
+ // references a pattern with url(#p) inside itself is still one layer.
1391
+ // (2026-08-25 overnight audit, defect 8.)
1392
+ if (splitLayers(style.backgroundImage).filter((layer) => layer !== 'none').length !== 1) return null;
1264
1393
  const box = element.getBoundingClientRect();
1265
1394
  return imagePaintRectInBox(
1266
1395
  { left: box.left, top: box.top, right: box.right, bottom: box.bottom },
@@ -1357,6 +1486,125 @@ export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
1357
1486
  };
1358
1487
  }
1359
1488
 
1489
+ /** The layers of a computed background-image list, split on top-level
1490
+ * commas only: commas inside url() and gradient() belong to the layer. */
1491
+ export function splitBackgroundLayers(backgroundImageCss) {
1492
+ return splitLayers(backgroundImageCss);
1493
+ }
1494
+
1495
+ /**
1496
+ * The URL inside one url() layer, or null when the layer is not a url().
1497
+ * The whole layer is the function call, so the URL runs to the layer's own
1498
+ * closing paren: a regex that stopped at the FIRST ")" cut an SVG data URI
1499
+ * short at the url(#pattern) reference inside it, and the truncated image
1500
+ * then failed to load. (2026-08-25 overnight audit, defect 8.)
1501
+ */
1502
+ export function backgroundLayerUrl(layer) {
1503
+ const match = /^url\(([\s\S]*)\)$/.exec((layer ?? '').trim());
1504
+ if (!match) return null;
1505
+ let inner = match[1].trim();
1506
+ const quote = inner[0];
1507
+ if ((quote === '"' || quote === "'") && inner.endsWith(quote)) {
1508
+ inner = inner.slice(1, -1).replace(new RegExp(`\\\\${quote}`, 'g'), quote);
1509
+ }
1510
+ return inner;
1511
+ }
1512
+
1513
+ /** Tokens that open a gradient argument without naming a colour: angles,
1514
+ * side keywords, shapes, positions and colour hints. */
1515
+ const GRADIENT_NON_COLOR = /^(?:(?:to|at|from|in|circle|ellipse|closest-side|closest-corner|farthest-side|farthest-corner)\b|calc\(|-?\d|\.\d)/i;
1516
+
1517
+ /**
1518
+ * The colour stops of ONE gradient layer, every stop run through parseColor
1519
+ * so modern syntaxes (oklch(), lab(), color()) count as stops instead of
1520
+ * being skipped by an rgb()-only regex. Returns
1521
+ * { colors, unparsed, translucent, space }
1522
+ * where `unparsed` counts stops no parser could read, `translucent` says at
1523
+ * least one stop has alpha below 1 (the gradient reveals what is beneath
1524
+ * it), and `space` names an interpolation colour space other than sRGB when
1525
+ * the gradient declares one (in-between colours cannot then be computed by
1526
+ * sRGB arithmetic). (2026-08-25 overnight audit, defect 8.)
1527
+ */
1528
+ export function gradientStops(layer) {
1529
+ const result = { colors: [], unparsed: 0, translucent: false, space: null };
1530
+ const open = layer.indexOf('(');
1531
+ const close = layer.lastIndexOf(')');
1532
+ if (open === -1 || close <= open) return result;
1533
+ for (const part of splitLayers(layer.slice(open + 1, close))) {
1534
+ const space = /(?:^|\s)in\s+([a-z0-9-]+)/i.exec(part);
1535
+ if (space && space[1].toLowerCase() !== 'srgb') result.space = space[1].toLowerCase();
1536
+ // The colour token: the first function call in the part (rgb(), oklch(),
1537
+ // color()...), read to its matching paren, or else the leading word.
1538
+ let token = null;
1539
+ const call = /([a-z][a-z0-9-]*)\(/i.exec(part);
1540
+ if (call) {
1541
+ let depth = 0;
1542
+ for (let i = call.index; i < part.length; i++) {
1543
+ if (part[i] === '(') depth += 1;
1544
+ if (part[i] === ')') { depth -= 1; if (depth === 0) { token = part.slice(call.index, i + 1); break; } }
1545
+ }
1546
+ } else {
1547
+ token = part.trim().split(/\s+/)[0] ?? '';
1548
+ }
1549
+ if (!token || GRADIENT_NON_COLOR.test(token)) continue;
1550
+ // currentcolor survives into some computed serialisations and the canvas
1551
+ // probe would happily paint it black: that is not a parse, it is a guess.
1552
+ const color = /^currentcolor$/i.test(token) ? null : parseColor(token);
1553
+ if (!color) { result.unparsed += 1; continue; }
1554
+ if (color.a < 1) result.translucent = true;
1555
+ result.colors.push(color);
1556
+ }
1557
+ return result;
1558
+ }
1559
+
1560
+ /**
1561
+ * Luminance range of ONE gradient layer, from the colours it actually
1562
+ * paints: the interpolation between each pair of adjacent stops is sampled
1563
+ * at `steps` points, not just its ends. Interpolated colours do NOT stay
1564
+ * inside the stops' luminance range: sRGB interpolation trades the channels
1565
+ * off against each other, and luminance is convex per channel, so a hue-
1566
+ * crossing gradient (red to blue) is darkest in the middle, where black text
1567
+ * that passes at both ends drops to 3.5:1. Sixteen steps per segment
1568
+ * resolves that curve to well within the ratio's second decimal. Returns
1569
+ * null when the stops cannot be sampled (unparseable, translucent, or a
1570
+ * non-sRGB interpolation space); gradientStops() says which.
1571
+ * Positions and colour hints are ignored: they move colours along the
1572
+ * gradient line, they do not add or remove any, so the sampled set is the
1573
+ * painted set (a hard stop paints fewer colours than sampled, which only
1574
+ * ever widens the range, and a wider range asserts less).
1575
+ * (2026-08-25 overnight audit, defect 3.)
1576
+ */
1577
+ export function sampledGradientRange(layer, overlays = [], steps = 16) {
1578
+ const { colors, unparsed, translucent, space } = gradientStops(layer);
1579
+ if (!colors.length || unparsed || translucent || space) return null;
1580
+ const samples = [];
1581
+ if (colors.length === 1) samples.push(colors[0]);
1582
+ for (let i = 0; i + 1 < colors.length; i++) {
1583
+ const from = colors[i];
1584
+ const to = colors[i + 1];
1585
+ for (let s = 0; s <= steps; s++) {
1586
+ const t = s / steps;
1587
+ samples.push({
1588
+ r: from.r + (to.r - from.r) * t,
1589
+ g: from.g + (to.g - from.g) * t,
1590
+ b: from.b + (to.b - from.b) * t,
1591
+ a: 1,
1592
+ });
1593
+ }
1594
+ }
1595
+ const composited = samples.map((sample) => (overlays.length ? applyOverlays(sample, overlays) : sample));
1596
+ let min = Infinity;
1597
+ let max = -Infinity;
1598
+ let minColor = null;
1599
+ let maxColor = null;
1600
+ for (const sample of composited) {
1601
+ const l = luminance(sample);
1602
+ if (l < min) { min = l; minColor = sample; }
1603
+ if (l > max) { max = l; maxColor = sample; }
1604
+ }
1605
+ return { min, max, minColor, maxColor, sampled: true };
1606
+ }
1607
+
1360
1608
  /** Contrast ratio from two luminances. */
1361
1609
  function ratioFromLuminance(l1, l2) {
1362
1610
  const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
@@ -1379,6 +1627,63 @@ export function rangeVerdict(foreground, range, required) {
1379
1627
  return { verdict: 'mixed', worst, best };
1380
1628
  }
1381
1629
 
1630
+ /**
1631
+ * Does any stylesheet in this tree scope style ::first-line or
1632
+ * ::first-letter at all? Walked once per root per audit (nested rules and
1633
+ * imports included) so the two extra pseudo computed-style reads below are
1634
+ * paid only on pages that can need them. An unreadable cross-origin sheet
1635
+ * counts as "yes": not knowing is a reason to look, never to skip.
1636
+ */
1637
+ function rootHasFirstLineRules(root) {
1638
+ let has = firstLineRulesCache.get(root);
1639
+ if (has !== undefined) return has;
1640
+ has = false;
1641
+ const scan = (rules) => {
1642
+ for (const rule of rules) {
1643
+ if (rule.selectorText && /::?first-(?:line|letter)\b/.test(rule.selectorText)) return true;
1644
+ const inner = rule.cssRules ?? rule.styleSheet?.cssRules;
1645
+ if (inner && scan(inner)) return true;
1646
+ }
1647
+ return false;
1648
+ };
1649
+ const sheets = [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])];
1650
+ for (const sheet of sheets) {
1651
+ try {
1652
+ if (scan(sheet.cssRules)) { has = true; break; }
1653
+ } catch {
1654
+ has = true;
1655
+ break;
1656
+ }
1657
+ }
1658
+ firstLineRulesCache.set(root, has);
1659
+ return has;
1660
+ }
1661
+
1662
+ /**
1663
+ * Colours that repaint part of this element's own text through ::first-line
1664
+ * or ::first-letter, when they differ from the colour the element itself
1665
+ * computes. Each entry is { pseudo, color, style } with the pseudo's computed
1666
+ * style attached, because a drop cap may also change size and weight and so
1667
+ * its own large-scale threshold. Only block containers get a first line of
1668
+ * their own; inline elements are left to their base colour.
1669
+ * (2026-08-25 overnight audit, defect 6.)
1670
+ */
1671
+ export function pseudoTextColors(element, style) {
1672
+ if (style.display === 'inline' || style.display === 'contents') return [];
1673
+ if (!rootHasFirstLineRules(element.getRootNode())) return [];
1674
+ const paintedColor = (s) => (s.webkitTextFillColor && s.webkitTextFillColor !== s.color ? s.webkitTextFillColor : s.color);
1675
+ const base = paintedColor(style);
1676
+ const found = [];
1677
+ for (const pseudo of ['::first-line', '::first-letter']) {
1678
+ const pseudoStyle = getComputedStyle(element, pseudo);
1679
+ const css = paintedColor(pseudoStyle);
1680
+ if (!css || css === base) continue;
1681
+ const color = parseColor(css);
1682
+ if (color) found.push({ pseudo, color, style: pseudoStyle });
1683
+ }
1684
+ return found;
1685
+ }
1686
+
1382
1687
  /** WCAG "large text": ≥24px, or ≥18.66px (14pt) bold. */
1383
1688
  export function isLargeText(style) {
1384
1689
  const size = parseFloat(style.fontSize);
@@ -35,6 +35,10 @@ export const GLOBAL_ARIA = new Set([
35
35
  * usage is ever worth surfacing, it belongs in best-practice, not in a
36
36
  * critical 4.1.2 failure. */
37
37
  export const ROLE_ARIA = {
38
+ // aria-colindextext / aria-rowindextext are ARIA 1.3 (Chromium maps them)
39
+ // and belong to cell, gridcell, columnheader, rowheader and row. They were
40
+ // in KNOWN_ARIA without a role that owned them, so the engine recognised
41
+ // the attribute and then said no role supports it. 2026-08-25 overnight audit.
38
42
  link: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid'],
39
43
  button: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'pressed'],
40
44
  checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
@@ -57,12 +61,12 @@ export const ROLE_ARIA = {
57
61
  heading: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level'],
58
62
  list: ['disabled', 'errormessage', 'haspopup', 'invalid'],
59
63
  listitem: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level', 'posinset', 'setsize'],
60
- row: ['activedescendant', 'colindex', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'rowindex', 'selected', 'setsize'],
64
+ row: ['activedescendant', 'colindex', 'colindextext', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'rowindex', 'rowindextext', 'selected', 'setsize'],
61
65
  rowgroup: ['disabled', 'errormessage', 'haspopup', 'invalid'],
62
- cell: ['colindex', 'colspan', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowindex', 'rowspan'],
63
- gridcell: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected'],
64
- columnheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
65
- rowheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
66
+ cell: ['colindex', 'colindextext', 'colspan', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowindex', 'rowindextext', 'rowspan'],
67
+ gridcell: ['colindex', 'colindextext', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowindextext', 'rowspan', 'selected'],
68
+ columnheader: ['colindex', 'colindextext', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowindextext', 'rowspan', 'selected', 'sort'],
69
+ rowheader: ['colindex', 'colindextext', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowindextext', 'rowspan', 'selected', 'sort'],
66
70
  table: ['colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowcount'],
67
71
  grid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'readonly', 'rowcount'],
68
72
  treegrid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required', 'rowcount'],
@@ -124,6 +128,9 @@ const INPUT_ROLES = {
124
128
  button: 'button', submit: 'button', reset: 'button', image: 'button',
125
129
  };
126
130
 
131
+ /** Input types that become a combobox when a list attribute is present. */
132
+ const DATALIST_TYPES = new Set(['text', 'search', 'tel', 'url', 'email']);
133
+
127
134
  const TAG_ROLES = {
128
135
  button: 'button', textarea: 'textbox', img: 'img', article: 'article',
129
136
  aside: 'complementary', nav: 'navigation', main: 'main',
@@ -145,7 +152,24 @@ const TAG_ROLES = {
145
152
  export function implicitRole(element) {
146
153
  const tag = element.tagName.toLowerCase();
147
154
  if (tag === 'a' || tag === 'area') return element.hasAttribute('href') ? 'link' : 'generic';
148
- if (tag === 'input') return INPUT_ROLES[element.type] ?? null;
155
+ if (tag === 'input') {
156
+ // HTML-AAM: a text, search, tel, url or email input WITH a list attribute
157
+ // maps to combobox (Chromium exposes it so), which is the role that
158
+ // supports aria-expanded. 2026-08-25 overnight audit.
159
+ if (DATALIST_TYPES.has(element.type) && element.hasAttribute('list')) return 'combobox';
160
+ return INPUT_ROLES[element.type] ?? null;
161
+ }
162
+ // HTML-AAM: td is cell in a table, gridcell when the ancestor table has
163
+ // role grid or treegrid; th is columnheader or rowheader by its scope in
164
+ // either. The unconditional td=cell mapping reviewed every valid
165
+ // aria-selected on an APG data grid. 2026-08-25 overnight audit.
166
+ if (tag === 'td' || tag === 'th') {
167
+ if (tag === 'th' && element.getAttribute('scope')?.toLowerCase() === 'row') return 'rowheader';
168
+ if (tag === 'th') return 'columnheader';
169
+ const table = element.closest('table');
170
+ const tableRole = table && effectiveRole(table);
171
+ return tableRole === 'grid' || tableRole === 'treegrid' ? 'gridcell' : 'cell';
172
+ }
149
173
  if (tag === 'select') return element.multiple || element.size > 1 ? 'listbox' : 'combobox';
150
174
  if (tag === 'img') return element.getAttribute('alt') === '' ? 'presentation' : 'img';
151
175
  if (tag === 'header') return element.closest('article, aside, main, nav, section') ? 'generic' : 'banner';
@@ -1,3 +1,12 @@
1
+ /** The form's default button: the first submit button in tree order (the
2
+ * one implicit submission activates). Reads the form's own descendants;
3
+ * controls associated from outside via form="" are out of this rule's
4
+ * selector anyway. Written as a query, not form.elements, because a
5
+ * control named "elements" shadows that property on the form. */
6
+ function defaultButton(form) {
7
+ return form.querySelector('button, input[type="submit"], input[type="image"]');
8
+ }
9
+
1
10
  export default {
2
11
  id: 'button-type',
3
12
  name: 'Button types in forms',
@@ -6,10 +15,22 @@ export default {
6
15
  help: 'Buttons inside forms should declare their type',
7
16
  helpUrl: 'https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-type',
8
17
  selector: 'form button:not([type]), form button[type=""]',
9
- evaluate() {
18
+ evaluate(element) {
19
+ // HTML, button type: "The missing value default and invalid value
20
+ // default are both the Submit Button state." That fixes the default; it
21
+ // does not make relying on it an error. When this is the form's only
22
+ // button AND its default button, submitting is its whole purpose and
23
+ // nothing happens by accident, so there is nothing to report. Otherwise
24
+ // the finding states the implicit-default fact and asks for the type
25
+ // to be declared (2026-08-25 overnight audit).
26
+ const form = element.form ?? element.closest('form');
27
+ const buttons = form.querySelectorAll('button');
28
+ if (buttons.length === 1 && buttons[0] === element && defaultButton(form) === element) {
29
+ return { status: 'pass' };
30
+ }
10
31
  return {
11
32
  status: 'fail',
12
- message: 'A <button> without type inside a form defaults to type="submit" — pressing Enter (or activating it with AT) can submit the form by accident.',
33
+ message: 'A <button> without a type inside a form is a submit button by default (the HTML missing value default is the Submit Button state), so activating it submits the form. Declare the type so the intent is explicit.',
13
34
  fix: 'Add type="button" (or type="submit" if submission is intended).',
14
35
  };
15
36
  },