pour-engine 1.33.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 (65) 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 +224 -17
  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 +122 -3
  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-attr-valid.js +49 -13
  57. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
  58. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
  59. package/engine/rules/wcag/4.1.2-button-name.js +11 -0
  60. package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
  61. package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
  62. package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
  63. package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
  64. package/engine/wcag22.js +4 -4
  65. package/package.json +1 -1
@@ -2,13 +2,19 @@
2
2
  import {
3
3
  parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
4
4
  backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
5
- gradientLuminanceRange, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
5
+ sampledGradientRange, gradientStops, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
6
6
  opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
7
7
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
8
8
  pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
9
- showRatio,
9
+ showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors,
10
10
  } from '../../lib/contrast.js';
11
11
 
12
+ /** The first url() among a background-image list's layers, or null. */
13
+ const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
14
+
15
+ /** The painted size of a rect, for choosing an image sampling grid. */
16
+ const extentOf = (rect) => (rect ? { width: rect.right - rect.left, height: rect.bottom - rect.top } : null);
17
+
12
18
  /** The strict separator set for the pure-decoration exemption: pipes,
13
19
  * interpuncts, slashes, dashes and guillemets — one glyph, optionally
14
20
  * repeated ("···"). Deliberately excludes brackets, colons, stars and
@@ -34,6 +40,58 @@ const veiledIncomplete = (resting, veiled) => ({
34
40
  + ' — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
35
41
  });
36
42
 
43
+ /** Roles whose element (and, for composites and groups, whose focusable
44
+ * descendants) aria-disabled="true" really switches off, per WAI-ARIA's
45
+ * list of roles that support the attribute. On anything else, above all a
46
+ * plain <div>, the attribute has no effect on the content it wraps. */
47
+ const ARIA_DISABLED_HOSTS = 'button, a[href], input, select, textarea, fieldset, optgroup, option, '
48
+ + '[role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], '
49
+ + '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], '
50
+ + '[role="slider"], [role="spinbutton"], [role="textbox"], [role="searchbox"], [role="combobox"], '
51
+ + '[role="gridcell"], [role="treeitem"], [role="scrollbar"], [role="separator"], [role="application"], '
52
+ + '[role="group"], [role="listbox"], [role="menu"], [role="menubar"], [role="radiogroup"], '
53
+ + '[role="tablist"], [role="tree"], [role="treegrid"], [role="grid"], [role="toolbar"]';
54
+
55
+ /**
56
+ * Is this text part of an inactive user interface component? 1.4.3 exempts
57
+ * exactly that, and the glossary defines a component as "a part of the
58
+ * content that is perceived by users as a single control for a distinct
59
+ * function". Two attributes used to exempt whatever they sat on:
60
+ * - aria-disabled="true" on a wrapper that is not a control (a <div>
61
+ * around paragraphs) disables nothing, so its prose keeps its verdict.
62
+ * - a disabled <fieldset> leaves the content of its first <legend> active
63
+ * (HTML: "except any descendants of the first legend element child");
64
+ * the legend is the group's caption, not a disabled control.
65
+ * (2026-08-25 overnight audit, defect 7.)
66
+ */
67
+ function inactiveComponentText(element) {
68
+ for (let node = element; node; node = node.parentElement) {
69
+ const disabled = node.closest(':disabled');
70
+ if (!disabled) break;
71
+ if (disabled.tagName !== 'FIELDSET') return true;
72
+ const legend = element.closest('legend');
73
+ if (!legend || legend.parentElement !== disabled
74
+ || disabled.querySelector(':scope > legend') !== legend) return true;
75
+ // Inside the first legend of this disabled fieldset: HTML keeps that
76
+ // content active. An outer disabled fieldset could still disable it,
77
+ // so the walk continues from above this one.
78
+ node = disabled;
79
+ }
80
+ const ariaDisabled = element.closest('[aria-disabled="true"]');
81
+ return Boolean(ariaDisabled && ariaDisabled.matches(ARIA_DISABLED_HOSTS));
82
+ }
83
+
84
+ /** A control the keyboard cannot reach either: with pointer-events: none
85
+ * on top, nothing can operate it, which is what "inactive" means. Native
86
+ * controls are focusable unless told otherwise; a role-only widget is
87
+ * focusable only when the author gave it a tabindex. */
88
+ function unreachableControl(control) {
89
+ if (control.matches(':disabled, [aria-disabled="true"], [tabindex="-1"]')) return true;
90
+ if (control.closest('[inert]')) return true;
91
+ const nativeFocusable = control.matches('button, a[href], input, select');
92
+ return !nativeFocusable && !control.hasAttribute('tabindex') && !control.isContentEditable;
93
+ }
94
+
37
95
  /**
38
96
  * Is a duplicate copy of the same text painted exactly on top of this
39
97
  * element? The gradient-text pattern ships two copies — a plain-colour
@@ -91,7 +149,7 @@ function textIntersects(element, rect) {
91
149
  * `intrinsic` rides along so the caller can reason about icon-sized images.
92
150
  */
93
151
  async function imageVsText(imageSource, element, doc) {
94
- const url = imageSource.css.match(/url\(["']?(.*?)["']?\)/)?.[1];
152
+ const url = firstLayerUrl(imageSource.css);
95
153
  let intrinsic = null;
96
154
  if (url) {
97
155
  try { intrinsic = await imageLuminanceRange(new URL(url, doc.baseURI).href); } catch { /* unknowable */ }
@@ -116,10 +174,10 @@ async function imageVsText(imageSource, element, doc) {
116
174
  sizedSmall = px.length > 0 && px.every((value) => value <= 40);
117
175
  }
118
176
  const paint = backgroundImagePaintRect(imageSource.element, dimensionless ? null : intrinsic);
119
- if (!paint) return { relation: 'unknown', intrinsic, dimensionless, isGradient, sizedSmall };
177
+ if (!paint) return { relation: 'unknown', intrinsic, dimensionless, isGradient, sizedSmall, paint: null };
120
178
  return {
121
179
  relation: textIntersects(element, paint) ? 'under' : 'clear',
122
- intrinsic, dimensionless, isGradient, sizedSmall,
180
+ intrinsic, dimensionless, isGradient, sizedSmall, paint,
123
181
  };
124
182
  }
125
183
 
@@ -150,10 +208,20 @@ function textVisuallyHidden(element, style, foreground) {
150
208
  }
151
209
 
152
210
  // Turn a luminance-range bracket into a rule outcome (or null = can't tell).
153
- function verdictFromRange(range, foreground, required, what) {
211
+ // `reduced`: the image was sampled through a grid coarser than four painted
212
+ // pixels per sample, so glyph-scale detail may be missing from the range; a
213
+ // pass read off it is not provable and goes to a human instead.
214
+ // (2026-08-25 overnight audit, defect 4.)
215
+ function verdictFromRange(range, foreground, required, what, reduced = false) {
154
216
  if (!range) return null;
155
217
  const { verdict, worst, best } = rangeVerdict(foreground, range, required);
156
- if (verdict === 'pass') return { status: 'pass' };
218
+ if (verdict === 'pass') {
219
+ if (!reduced) return { status: 'pass' };
220
+ return {
221
+ status: 'incomplete',
222
+ message: `The ${what} behind this text is too large to sample at glyph scale, so detail narrower than the sampling grid could sit under the text unseen. Every sampled pixel passes (worst ${showRatio(worst)}:1), so confirm by eye.`,
223
+ };
224
+ }
157
225
  if (verdict === 'fail') {
158
226
  return {
159
227
  status: 'fail',
@@ -222,24 +290,54 @@ function paintUnderImage(carrier) {
222
290
  // knows it: an image with see-through pixels shows that colour wherever the
223
291
  // ink is absent, so the judged range must include it — and without it no
224
292
  // verdict can be asserted from such an image at all.
225
- async function sampledVerdict(source, foreground, required, doc, overlays = [], under = null) {
293
+ // `extent` is the painted size of the layer in CSS pixels, when the caller
294
+ // knows it: the sampling grid is chosen from it (sampleGridFor).
295
+ async function sampledVerdict(source, foreground, required, doc, overlays = [], under = null, extent = null) {
226
296
  if (!source) return null;
227
297
  let range = null;
228
298
  let what = 'image';
299
+ const grid = sampleGridFor(extent);
229
300
  if (source.tagName === 'IMG') {
230
- range = await imageLuminanceRange(source.currentSrc || source.src, overlays);
301
+ range = await imageLuminanceRange(source.currentSrc || source.src, overlays, grid);
231
302
  } else {
232
303
  const css = source.css ?? getComputedStyle(source).backgroundImage;
233
304
  if (!css || css === 'none') return null;
234
- if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
235
- const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
305
+ // Layers are counted at the top level of the list only: a url( or
306
+ // gradient( INSIDE a data URI (an SVG whose fill references url(#p))
307
+ // is part of that one layer, not a second one. And each abstention
308
+ // names its own reason, because "several stacked layers" was being
309
+ // said of a single gradient whose stops merely failed to parse.
310
+ // (2026-08-25 overnight audit, defect 8.)
311
+ const layers = splitBackgroundLayers(css).filter((layer) => layer !== 'none');
312
+ if (layers.length > 1) return null; // stacked layers: too complex
313
+ const layer = layers[0] ?? '';
314
+ const url = backgroundLayerUrl(layer);
236
315
  if (url) {
237
316
  let absolute;
238
317
  try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
239
- range = await imageLuminanceRange(absolute, overlays);
240
- } else if (css.includes('gradient(')) {
241
- range = gradientLuminanceRange(css, overlays);
318
+ range = await imageLuminanceRange(absolute, overlays, grid);
319
+ } else if (layer.includes('gradient(')) {
242
320
  what = 'gradient';
321
+ const stops = gradientStops(layer);
322
+ if (!stops.colors.length || stops.unparsed) {
323
+ return {
324
+ status: 'incomplete',
325
+ message: 'The gradient behind this text has colour stops this engine could not parse, so its range of colours could not be sampled. Check the contrast by eye.',
326
+ };
327
+ }
328
+ if (stops.space) {
329
+ return {
330
+ status: 'incomplete',
331
+ message: `The gradient behind this text interpolates in ${stops.space}, not sRGB, so the colours between its stops cannot be computed here. Check the contrast by eye.`,
332
+ };
333
+ }
334
+ if (stops.translucent) {
335
+ return {
336
+ status: 'incomplete',
337
+ message: 'The gradient behind this text has translucent colour stops, so what shows behind the glyphs is a blend with whatever is painted beneath the gradient. Check the contrast by eye.',
338
+ };
339
+ }
340
+ range = sampledGradientRange(layer, overlays);
243
341
  } else {
244
342
  return null;
245
343
  }
@@ -299,7 +397,7 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
299
397
  message: 'The image painted behind this text can’t be read: it is cross-origin, or it failed to load, so a script can’t sample its colours. Check the contrast by eye.',
300
398
  };
301
399
  }
302
- return verdictFromRange(range, foreground, required, what);
400
+ return verdictFromRange(range, foreground, required, what, Boolean(range?.reduced));
303
401
  }
304
402
 
305
403
  /**
@@ -308,7 +406,33 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
308
406
  * 1.4.6 Contrast (Enhanced), AAA — 7:1, or 4.5:1 for large text
309
407
  */
310
408
  export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
311
- return {
409
+ // When the colour pair already clears the LARGE-scale minimum, the miss
410
+ // is about scale, not colour: name the boundary (14pt bold / 18pt), or
411
+ // a bold-but-just-under button reads as the checker ignoring its weight.
412
+ const largeScaleNoteFor = (style, required, ratio) => {
413
+ if (required !== thresholds.normal || ratio < thresholds.large) return '';
414
+ const sizePx = parseFloat(style.fontSize) || 0;
415
+ const weightNum = parseInt(style.fontWeight, 10) || 400;
416
+ // One decimal rounds 18.66px up to "18.7px", which then reads as
417
+ // clearing the 18.67px cutoff the same sentence names. Within a
418
+ // twentieth of a pixel of a cutoff the size is shown to two decimals,
419
+ // and when even that rounds onto the cutoff it is named as just under
420
+ // it. (2026-08-25 overnight audit, defect 9.)
421
+ const cutoff = weightNum >= 700 ? 56 / 3 : 24;
422
+ const cutoffText = weightNum >= 700 ? '18.67px' : '24px';
423
+ const nearCutoff = Math.abs(sizePx - cutoff) < 0.05;
424
+ let shown = nearCutoff ? `${sizePx.toFixed(2)}px` : `${Math.round(sizePx * 10) / 10}px`;
425
+ if (nearCutoff && parseFloat(shown) >= parseFloat(cutoffText)) shown = `just under ${cutoffText}`;
426
+ if (weightNum >= 700 && sizePx < 56 / 3) {
427
+ return ` This text is bold at ${shown}; bold text counts as large scale from 18.67px (14pt), where the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
428
+ }
429
+ if (weightNum < 700 && sizePx >= 56 / 3 && sizePx < 24) {
430
+ return ` At ${shown} regular weight this is not large scale; from 24px (18pt), or bold at this size, the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
431
+ }
432
+ return '';
433
+ };
434
+
435
+ const rule = {
312
436
  id,
313
437
  impact: 'serious',
314
438
  tags,
@@ -329,7 +453,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
329
453
  if (!ownText(element)) return { status: 'pass' }; // no text of its own to judge
330
454
 
331
455
  // The criterion exempts "inactive user interface components".
332
- if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
456
+ if (inactiveComponentText(element)) return { status: 'pass' };
333
457
 
334
458
  // A shadow HOST's own text renders through its shadow tree: when a
335
459
  // <slot> projects it, the text inherits styles from the slot's
@@ -357,8 +481,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
357
481
  // inline, outside code and interactive content, with rendered content
358
482
  // on BOTH sides — so Python's `>>>` prompt, code operators, maths in
359
483
  // prose and meaning-bearing brackets all keep their verdicts.
484
+ // "Inline" here means LAID inline, not computed inline: a flex or grid
485
+ // container blockifies its children, so a separator <li> in a flex-row
486
+ // footer nav computes display:block while sitting visually between its
487
+ // neighbours on one line — the layout mode does not change what the
488
+ // glyph is (wasexamprep.com's footer dots, adjudicated 2026-08-22).
489
+ const laidInline = style.display.startsWith('inline')
490
+ || /flex|grid/.test(element.parentElement
491
+ ? getComputedStyle(element.parentElement).display : '');
360
492
  if (SEPARATOR_GLYPHS.test(ownText(element))
361
- && style.display.startsWith('inline')
493
+ && laidInline
362
494
  && !element.closest('code, pre, samp, kbd, var, a[href], button, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="option"]')) {
363
495
  const hasContent = (start, dir) => {
364
496
  for (let node = start; node; node = node[dir]) {
@@ -382,8 +514,17 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
382
514
  // content someone is expected to read, and whether "temporarily
383
515
  // non-interactive" makes it an exempt inactive component under 1.4.3 is
384
516
  // a human judgement — never a silent pass.
517
+ //
518
+ // pointer-events only switches off mouse and touch hit-testing. It does
519
+ // not disable the control, drop it from the tab order or stop Enter from
520
+ // activating it, so a link styled that way is still a live component
521
+ // for the keyboard (a Tab reaches it and Enter fires its click). The
522
+ // shortcut therefore needs the control to be unreachable as well:
523
+ // tabindex="-1", inert, disabled, or a role-only widget with no tabindex
524
+ // at all. A reachable control is judged like any other text.
525
+ // (2026-08-25 overnight audit, defect 5.)
385
526
  const control = element.closest('button, a[href], input, select, [role="button"], [role="link"], [role="option"], [role="tab"]');
386
- if (control && getComputedStyle(control).pointerEvents === 'none') {
527
+ if (control && getComputedStyle(control).pointerEvents === 'none' && unreachableControl(control)) {
387
528
  if ((control.textContent || '').trim().length <= 80) return { status: 'pass' };
388
529
  return {
389
530
  status: 'incomplete',
@@ -398,7 +539,8 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
398
539
  const clipsText = /\btext\b/.test(style.webkitBackgroundClip ?? '') || /\btext\b/.test(style.backgroundClip ?? '');
399
540
  if (clipsText) {
400
541
  const behind = effectiveBackground(element.parentElement ?? element);
401
- const fgRange = style.backgroundImage !== 'none' ? gradientLuminanceRange(style.backgroundImage) : null;
542
+ const fgRange = style.backgroundImage !== 'none'
543
+ ? sampledGradientRange(splitBackgroundLayers(style.backgroundImage)[0] ?? '') : null;
402
544
  if (fgRange && behind) {
403
545
  // Symmetric bracket: text luminance spans the gradient's stops.
404
546
  const required = isLargeText(style) ? thresholds.large : thresholds.normal;
@@ -419,9 +561,39 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
419
561
  if (!foreground) {
420
562
  return { status: 'incomplete', message: 'The text colour could not be parsed — check contrast by eye.' };
421
563
  }
422
- const required = isLargeText(style) ? thresholds.large : thresholds.normal;
564
+ let required = isLargeText(style) ? thresholds.large : thresholds.normal;
423
565
  const doc = element.ownerDocument;
424
566
 
567
+ // ::first-line and ::first-letter repaint part of this same text in
568
+ // another colour, and 1.4.3 applies to the visual presentation of all
569
+ // of it: a pale first line over a dark base colour was judged by the
570
+ // base alone and passed. Every colour that paints the text is a
571
+ // candidate, each with its own large-scale threshold (a drop cap is
572
+ // usually bigger), and the one with the least margin over its threshold
573
+ // is judged. The pick is made against the walked background here so
574
+ // the image and gradient paths below see it too, and remade against
575
+ // the resolved background on the flat-colour path.
576
+ // (2026-08-25 overnight audit, defect 6.)
577
+ let foregroundOrigin = null;
578
+ const baseForeground = foreground;
579
+ const baseRequired = required;
580
+ const alternates = pseudoTextColors(element, style)
581
+ .map(({ pseudo, color, style: pseudoStyle }) => ({
582
+ origin: pseudo, color, required: isLargeText(pseudoStyle) ? thresholds.large : thresholds.normal,
583
+ }));
584
+ const worstCandidate = (candidates, backdrop) => candidates.reduce((worst, candidate) => {
585
+ const shown = candidate.color.a < 1 ? composite(candidate.color, backdrop) : candidate.color;
586
+ const margin = contrastRatio(shown, backdrop) / candidate.required;
587
+ return !worst || margin < worst.margin ? { ...candidate, margin } : worst;
588
+ }, null);
589
+ if (alternates.length) {
590
+ const estimate = effectiveBackground(styleSource) ?? { r: 255, g: 255, b: 255, a: 1 };
591
+ const pick = worstCandidate([{ origin: null, color: foreground, required }, ...alternates], estimate);
592
+ foreground = pick.color;
593
+ required = pick.required;
594
+ foregroundOrigin = pick.origin;
595
+ }
596
+
425
597
  // Judge the opacity the text RESTS at, not a transient frame. Elements
426
598
  // with a RUNNING opacity animation anywhere up the flat tree are judged
427
599
  // at the animation's landing value (a fill-forwards entrance ending at
@@ -431,20 +603,114 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
431
603
  // Near-zero resting opacity means the text isn't visually presented.
432
604
  const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
433
605
  if (opacity < 0.05) return { status: 'pass' };
434
- if (opacity < 1) foreground = { ...foreground, a: foreground.a * opacity };
606
+ if (opacity < 1) {
607
+ // Group opacity with a background INSIDE the group: the browser blends
608
+ // text and background together over what lies behind the carrier, so
609
+ // the presented pair is neither "dimmed text on the group's own
610
+ // background" nor "dimmed text on the page". opacityGroupPaint
611
+ // composites both the way the renderer does; when the backdrop behind
612
+ // the carrier is an image the pair is a human call.
613
+ // (2026-08-25 overnight audit, defect 1.)
614
+ // Text-shadow halos and unplaceable pseudo films are bracketed by the
615
+ // flat path below and not by this one, so text carrying either keeps
616
+ // that path (and its human calls) rather than a group verdict.
617
+ const groupEligible = (!style.textShadow || style.textShadow === 'none')
618
+ && !(pseudoBackdropForText(element)?.film > 0);
619
+ const group = groupEligible ? opacityGroupPaint(element, foreground) : null;
620
+ if (group?.unresolved) {
621
+ return {
622
+ status: 'incomplete',
623
+ message: `This text and its own background render together at ${Math.round(group.opacity * 100)}% opacity over an image or gradient, so the presented colours are a blend a script cannot resolve. Check the contrast by eye.`,
624
+ };
625
+ }
626
+ if (group) {
627
+ const groupRatio = contrastRatio(group.foreground, group.background);
628
+ // The backdrop behind the carrier came from the ancestor walk, which
629
+ // cannot see media or positioned panels painted where this text is
630
+ // (a promo card at opacity .999 over its own photo, BBC's carousel):
631
+ // the same hazard bracket the unverified flat path applies, before
632
+ // either verdict is asserted.
633
+ const groupRect = element.getBoundingClientRect();
634
+ const near = (r) => Math.min(groupRect.right, r.right) - Math.max(groupRect.left, r.left) >= 3
635
+ && Math.min(groupRect.bottom, r.bottom) - Math.max(groupRect.top, r.top) >= 3;
636
+ const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect }) =>
637
+ near(mediaRect) && !media.contains(element) && !element.contains(media) && textIntersects(element, mediaRect));
638
+ if (overMedia) {
639
+ return {
640
+ status: 'incomplete',
641
+ message: 'This text overlaps an image or video, so its real background can’t be computed — contrast must be checked by eye.',
642
+ };
643
+ }
644
+ const flipping = opaquePanelRects(doc).some(({ element: panel, rect, color }) =>
645
+ near(rect) && !panel.contains(element) && !element.contains(panel) && textIntersects(element, rect)
646
+ && (contrastRatio(composite(group.text, color), composite(group.paint, color)) >= required) !== (groupRatio >= required));
647
+ if (flipping) {
648
+ return {
649
+ status: 'incomplete',
650
+ message: 'This text overlaps a coloured block that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
651
+ };
652
+ }
653
+ // A translucent full-page veil (consent dimmer, modal scrim) over the
654
+ // text dims the presented pair exactly as it dims the flat path's,
655
+ // and the adjudicated overlay-state policy applies unchanged: assert
656
+ // only when the resting and the veiled states agree, otherwise ask.
657
+ // Returning before this check silenced every review under the
658
+ // OneTrust dimmer on pages whose content wrapper sits at opacity .99
659
+ // (apnews.com: 85 lost reviews in the corpus differential).
660
+ const groupPainted = paintedBackdrop(element);
661
+ let groupScrim = groupPainted?.scrim ?? null;
662
+ if (!groupScrim && groupPainted === 'offscreen') {
663
+ const veil = viewportVeil(doc);
664
+ if (veil && !veil.contains(element)) groupScrim = [veil];
665
+ }
666
+ if (groupScrim) {
667
+ const groupVeil = scrimPaint(groupScrim);
668
+ if (!groupVeil) return veiledIncomplete();
669
+ const veiledRatio = contrastRatio(applyOverlays(group.foreground, groupVeil), applyOverlays(group.background, groupVeil));
670
+ if ((veiledRatio >= required) !== (groupRatio >= required)) return veiledIncomplete(groupRatio, veiledRatio);
671
+ }
672
+ if (groupRatio >= required) return { status: 'pass' };
673
+ if (!groupRect.width || !groupRect.height) return { status: 'pass' };
674
+ const groupFg = asRgb(group.foreground);
675
+ const groupBg = asRgb(group.background);
676
+ return {
677
+ status: 'fail',
678
+ message: `Contrast is ${showRatio(groupRatio)}:1, below the ${required}:1 WCAG minimum for this text size.${largeScaleNoteFor(style, required, groupRatio)}`,
679
+ fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${groupFg} on ${groupBg}).`
680
+ + (style.color !== groupFg ? ` Your CSS writes the text as ${style.color}, so searching it for the sRGB values above won't find them.` : '')
681
+ + ` The gap is opacity: this element or an ancestor renders at ${Math.round(group.opacity * 100)}% opacity, so the text and its own background are both blended toward what lies behind them, into the sRGB values above.`,
682
+ data: {
683
+ foreground: groupFg,
684
+ background: groupBg,
685
+ ratio: Number(showRatio(groupRatio)),
686
+ required,
687
+ },
688
+ };
689
+ }
690
+ foreground = { ...foreground, a: foreground.a * opacity };
691
+ }
435
692
 
436
693
  // A background image on an ancestor: sample its pixels and bracket —
437
694
  // but only when it's actually painted under this text. Icon-in-padding
438
695
  // images (external-link arrows etc.) don't affect the text's contrast.
439
696
  const imageSource = backgroundImageSource(element);
440
697
  if (imageSource) {
441
- const { relation, intrinsic, dimensionless, isGradient, sizedSmall } = await imageVsText(imageSource, element, doc);
698
+ const { relation, intrinsic, dimensionless, isGradient, sizedSmall, paint } = await imageVsText(imageSource, element, doc);
442
699
  if (relation !== 'clear') {
443
700
  // imageSource.overlays are the translucent layers painted between the
444
701
  // image and this text (hero scrims, tint panels). Judging the raw
445
702
  // image pixels would describe a page the user never sees.
703
+ // The painted extent picks the sampling grid: the paint rect when
704
+ // it resolved, otherwise the larger of the carrier's box and the
705
+ // image's own size (tiling paints at intrinsic scale; cover and
706
+ // contain within the box), which errs toward more samples.
707
+ const carrierBox = imageSource.element.getBoundingClientRect();
708
+ const extent = paint ? extentOf(paint) : {
709
+ width: Math.max(carrierBox.width, dimensionless ? 0 : intrinsic.width),
710
+ height: Math.max(carrierBox.height, dimensionless ? 0 : intrinsic.height),
711
+ };
446
712
  let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays,
447
- paintUnderImage(imageSource.element));
713
+ paintUnderImage(imageSource.element), extent);
448
714
  // A layer with no opaque pixels changes nothing about what is behind
449
715
  // the glyphs, so it must not block the verdict: fall through and
450
716
  // judge the background that actually paints.
@@ -466,7 +732,6 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
466
732
  // can never exceed the carrier — a 512px nominal SVG painted on an
467
733
  // inline link is still an icon). Only backdrop-scale images earn a
468
734
  // definite fail from sampling.
469
- const carrierBox = imageSource.element.getBoundingClientRect();
470
735
  const iconScale = isGradient
471
736
  ? (sizedSmall || carrierBox.height <= 40)
472
737
  : (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40);
@@ -547,7 +812,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
547
812
  // background-image path has always applied.
548
813
  let pseudoIconScale = false;
549
814
  if (pseudoBack?.image?.meta && pseudoBack.image.box) {
550
- const pseudoUrl = pseudoBack.image.css.match(/url\(["']?(.*?)["']?\)/)?.[1];
815
+ const pseudoUrl = firstLayerUrl(pseudoBack.image.css);
551
816
  if (pseudoUrl && pseudoBack.image.meta.repeat === 'no-repeat') {
552
817
  let pseudoIntrinsic = null;
553
818
  try { pseudoIntrinsic = await imageLuminanceRange(new URL(pseudoUrl, doc.baseURI).href); } catch { /* unknowable */ }
@@ -598,7 +863,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
598
863
  // else, so the host's resolved colour is what shows through any
599
864
  // transparent pixels the image has.
600
865
  const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc, [],
601
- effectiveBackground(pseudoBack.image.element));
866
+ effectiveBackground(pseudoBack.image.element), extentOf(pseudoBack.image.box));
602
867
  // PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
603
868
  // it never displaced the background the walk resolved.
604
869
  if (sampled !== PAINTS_NOTHING) {
@@ -615,7 +880,11 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
615
880
  }
616
881
  }
617
882
  if (painted?.image && !pseudoBack) {
618
- const sampled = await sampledVerdict(painted.image, foreground, required, doc);
883
+ // painted.overlays: the translucent layers the hit-test walk crossed
884
+ // between the text and the image (a scrim div over a hero <img>), seen
885
+ // through exactly as the ancestor walk's overlays are.
886
+ const sampled = await sampledVerdict(painted.image, foreground, required, doc, painted.overlays ?? [],
887
+ null, extentOf(painted.image.getBoundingClientRect()));
619
888
  if (sampled !== PAINTS_NOTHING) {
620
889
  return sampled ?? {
621
890
  status: 'incomplete',
@@ -637,7 +906,8 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
637
906
  // hazards still reach a human instead of a wrong assertion.
638
907
  const obscuring = backgroundObscured(element);
639
908
  if (obscuring && obscuring !== 'unverifiable') {
640
- const sampled = await sampledVerdict(obscuring, foreground, required, doc);
909
+ const sampled = await sampledVerdict(obscuring, foreground, required, doc, [], null,
910
+ extentOf(obscuring.getBoundingClientRect()));
641
911
  if (sampled !== PAINTS_NOTHING) {
642
912
  return sampled ?? {
643
913
  status: 'incomplete',
@@ -683,6 +953,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
683
953
  return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
684
954
  }
685
955
 
956
+ // With the background now resolved, the ::first-line / ::first-letter
957
+ // pick is remade against it: the estimate above only had the walk.
958
+ if (alternates.length) {
959
+ const dim = (color) => (opacity < 1 ? { ...color, a: color.a * opacity } : color);
960
+ const base = { origin: null, color: dim(baseForeground), required: baseRequired };
961
+ const pick = worstCandidate([base, ...alternates.map((a) => ({ ...a, color: dim(a.color) }))], background);
962
+ foreground = pick.color;
963
+ required = pick.required;
964
+ foregroundOrigin = pick.origin;
965
+ }
686
966
  // Semi-transparent text is really text-colour blended into the background.
687
967
  if (foreground.a < 1) foreground = composite(foreground, background);
688
968
 
@@ -858,7 +1138,13 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
858
1138
  const parsed = parseColor(css);
859
1139
  return parsed != null && parsed.a >= 1 && asRgb(parsed) === judged;
860
1140
  };
861
- const authoredFg = style.color !== foregroundRgb ? style.color : null;
1141
+ // A colour painted by ::first-line or ::first-letter is not the
1142
+ // element's own color, so the "your CSS writes" comparison would name
1143
+ // the wrong declaration; the pseudo is named instead.
1144
+ const authoredFg = !foregroundOrigin && style.color !== foregroundRgb ? style.color : null;
1145
+ const pseudoNote = foregroundOrigin
1146
+ ? ` The judged text colour comes from a ${foregroundOrigin} rule on this element, which paints that part of the text in place of its color.`
1147
+ : '';
862
1148
  const backgroundSource = backgroundColorSource(element);
863
1149
  const authoredBg = backgroundSource && backgroundSource !== backgroundRgb ? backgroundSource : null;
864
1150
  const foregroundCss = authoredFg && sameValue(authoredFg, foregroundRgb) ? authoredFg : null;
@@ -878,28 +1164,14 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
878
1164
  : ''))
879
1165
  : '';
880
1166
 
881
- // When the colour pair already clears the LARGE-scale minimum, the miss
882
- // is about scale, not colour: name the boundary (14pt bold / 18pt), or
883
- // a bold-but-just-under button reads as the checker ignoring its weight.
884
- const largeScaleNote = (() => {
885
- if (required !== thresholds.normal || ratio < thresholds.large) return '';
886
- const sizePx = parseFloat(style.fontSize) || 0;
887
- const weightNum = parseInt(style.fontWeight, 10) || 400;
888
- const shown = `${Math.round(sizePx * 10) / 10}px`;
889
- if (weightNum >= 700 && sizePx < 56 / 3) {
890
- return ` This text is bold at ${shown}; bold text counts as large scale from 18.67px (14pt), where the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
891
- }
892
- if (weightNum < 700 && sizePx >= 56 / 3 && sizePx < 24) {
893
- return ` At ${shown} regular weight this is not large scale; from 24px (18pt), or bold at this size, the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
894
- }
895
- return '';
896
- })();
1167
+ const largeScaleNote = largeScaleNoteFor(style, required, ratio);
897
1168
  return {
898
1169
  status: 'fail',
899
1170
  message: `Contrast is ${showRatio(ratio)}:1 — below the ${required}:1 WCAG minimum for this text size.${largeScaleNote}`,
900
1171
  fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${foregroundRgb} on ${backgroundRgb}).`
901
1172
  + (authored ? ` Your CSS writes ${authored}, so searching it for the sRGB values above won't find them.` : '')
902
- + dimmedNote,
1173
+ + dimmedNote
1174
+ + pseudoNote,
903
1175
  // The exact pair, for the UIs to link out to an interactive checker.
904
1176
  data: {
905
1177
  foreground: foregroundRgb,
@@ -912,6 +1184,24 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
912
1184
  };
913
1185
  },
914
1186
  };
1187
+
1188
+ // The perennial objection, answered where it is raised: a contrast failure
1189
+ // inside aria-hidden content is still a failure, because aria-hidden only
1190
+ // removes content from the accessibility tree and 1.4.3 exists for sighted
1191
+ // low-vision readers — the one audience the attribute cannot reach. Every
1192
+ // fail path (solid, image-range, translucent blend, halo) gets the note,
1193
+ // and both criteria built from this factory inherit it. Adjudicated
1194
+ // 2026-08-22 against a challenged mockup finding; axe reaches the same
1195
+ // verdict on the same node.
1196
+ const judge = rule.evaluate.bind(rule);
1197
+ rule.evaluate = async (element, helpers) => {
1198
+ const verdict = await judge(element, helpers);
1199
+ if (verdict.status === 'fail' && element.closest('[aria-hidden="true"]')) {
1200
+ verdict.message += ' aria-hidden hides this from screen readers, not from sighted users; contrast is judged for the people who see it.';
1201
+ }
1202
+ return verdict;
1203
+ };
1204
+ return rule;
915
1205
  }
916
1206
 
917
1207
  export default createContrastRule({
@@ -31,6 +31,15 @@ export default {
31
31
  // Text rendered at font-size 0 (icon buttons whose value is a
32
32
  // screen-reader label) is not visually presented — nothing to judge.
33
33
  if (parseFloat(style.fontSize) === 0) return { status: 'pass' };
34
+ // A submit, button or reset input whose value attribute is present and
35
+ // empty renders no label at all (HTML: the button's label is its value
36
+ // when the attribute is present), the icon-button-with-aria-label
37
+ // pattern. No glyph is presented, so there is nothing for 1.4.3 to
38
+ // judge (2026-08-25 overnight audit).
39
+ if (/^(submit|button|reset)$/i.test(element.getAttribute('type') ?? '')
40
+ && element.hasAttribute('value') && element.getAttribute('value') === '') {
41
+ return { status: 'pass' };
42
+ }
34
43
  // Controls parked wholly left of or above the document are unreachable
35
44
  // by scrolling (sr-only duplicates): same exemption as the main rule.
36
45
  {
@@ -71,12 +80,13 @@ export default {
71
80
  }
72
81
  const required = isLargeText(style) ? 3 : 4.5;
73
82
 
74
- const judge = (color, what) => {
83
+ const judge = (color, what, ownOpacity = 1) => {
75
84
  const parsed = parseColor(color);
76
85
  if (!parsed || parsed.a === 0) return null;
77
86
  // Same treatment the main rule gives faded text: what reaches the eye
78
87
  // is the declared colour thinned by the opacity it is painted at.
79
- const faded = opacity < 1 ? { ...parsed, a: parsed.a * opacity } : parsed;
88
+ const painted = opacity * ownOpacity;
89
+ const faded = painted < 1 ? { ...parsed, a: parsed.a * painted } : parsed;
80
90
  const fg = faded.a < 1 ? composite(faded, background) : faded;
81
91
  const ratio = contrastRatio(fg, background);
82
92
  if (ratio >= required) return null;
@@ -90,9 +100,20 @@ export default {
90
100
  // computed colour via the ::placeholder pseudo-element.
91
101
  if (element.getAttribute('placeholder')?.trim()) {
92
102
  let placeholderColor = null;
93
- try { placeholderColor = getComputedStyle(element, '::placeholder').color; } catch { /* unsupported */ }
94
- if (placeholderColor && placeholderColor !== style.color) {
95
- const verdict = judge(placeholderColor, 'placeholder text');
103
+ let placeholderOpacity = 1;
104
+ try {
105
+ const placeholderStyle = getComputedStyle(element, '::placeholder');
106
+ placeholderColor = placeholderStyle.color;
107
+ // ::placeholder { opacity: .3 } is a widespread idiom (framework
108
+ // resets, Firefox's own UA default of 0.54), and the criterion is on
109
+ // the visual presentation of the text, which is the faded colour.
110
+ // The opacity folds into the colour's alpha exactly as element
111
+ // opacity already does for value text (2026-08-25 overnight audit).
112
+ const parsedOpacity = parseFloat(placeholderStyle.opacity);
113
+ if (Number.isFinite(parsedOpacity)) placeholderOpacity = Math.min(1, Math.max(0, parsedOpacity));
114
+ } catch { /* unsupported */ }
115
+ if (placeholderColor && (placeholderColor !== style.color || placeholderOpacity < 1)) {
116
+ const verdict = judge(placeholderColor, 'placeholder text', placeholderOpacity);
96
117
  if (verdict) failures.push(verdict);
97
118
  }
98
119
  }