pour-engine 1.8.0 → 1.10.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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.8.0',
9
+ version: '1.10.0',
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-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
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-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, 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:** 30 partially automated · 56 manual — with 74 rules.
@@ -373,9 +373,10 @@ function textSamplePoint(element) {
373
373
  * 'unresolved' — in-viewport but unanswerable (shadow retargeting hid
374
374
  * the element from the stack, blend/filter layers…)
375
375
  *
376
- * `scrim` (when present) is a translucent viewport-scale layer painted ON TOP
377
- * of the text — a modal veil, loading overlay, consent dimmer. The text is
378
- * presented through it, so the resting colours are not what the user sees.
376
+ * `scrim` (when present) is the list of translucent viewport-scale layers
377
+ * painted ON TOP of the text — a modal veil, loading overlay, consent dimmer.
378
+ * The text is presented through them, so the resting colours are not what the
379
+ * user sees; scrimPaint() turns them into the paint that dims it.
379
380
  *
380
381
  * Known approximation: pointer-events:none layers paint but never appear in
381
382
  * hit-test stacks, so a decorative tint above the resolved colour can be
@@ -564,9 +565,13 @@ export function pseudoBackdropForText(element) {
564
565
 
565
566
  /** A layer qualifies as a scrim over `element` when it is translucent paint
566
567
  * (or a backdrop-filter) covering most of the viewport and the whole
567
- * element — the shape of a modal/loading veil, not a badge or header. */
568
+ * element — the shape of a modal/loading veil, not a badge or header.
569
+ * Returns EVERY such layer, topmost first: consent flows routinely stack a
570
+ * dimmer under a banner container, and the dimmed state the user sees is
571
+ * all of them composited, not just the first one found. */
568
572
  function scrimIn(layersAbove, element, win) {
569
573
  const rect = element.getBoundingClientRect();
574
+ const found = [];
570
575
  for (const layer of layersAbove) {
571
576
  if (layer.contains(element) || element.contains(layer)) continue;
572
577
  const style = getComputedStyle(layer);
@@ -582,9 +587,33 @@ function scrimIn(layersAbove, element, win) {
582
587
  >= 0.6 * win.innerWidth * win.innerHeight;
583
588
  const coversElement = r.left <= rect.left + 1 && r.top <= rect.top + 1
584
589
  && r.right >= rect.right - 1 && r.bottom >= rect.bottom - 1;
585
- if (coversViewport && coversElement) return layer;
590
+ if (coversViewport && coversElement) found.push(layer);
586
591
  }
587
- return null;
592
+ return found.length ? found : null;
593
+ }
594
+
595
+ /**
596
+ * The flat paint of scrim layers (topmost first, as `scrimIn` returns them),
597
+ * ready for applyOverlays — or null when the dimmed state can't be computed
598
+ * as flat colour at all. A backdrop-filter resamples the pixels beneath it
599
+ * (blur, saturate, invert) and a blend mode or filter rewrites them: no
600
+ * colour arithmetic reproduces either, so those veils stay a human call.
601
+ */
602
+ export function scrimPaint(layers) {
603
+ const paints = [];
604
+ for (const layer of layers) {
605
+ const style = getComputedStyle(layer);
606
+ if (style.backdropFilter && style.backdropFilter !== 'none') return null;
607
+ if (style.filter && style.filter !== 'none') return null;
608
+ if (style.mixBlendMode && style.mixBlendMode !== 'normal') return null;
609
+ if (style.backgroundImage !== 'none') return null;
610
+ const color = parseColor(style.backgroundColor);
611
+ if (!color) return null;
612
+ const alpha = color.a * (parseFloat(style.opacity) || 1);
613
+ if (alpha <= 0) continue;
614
+ paints.push({ ...color, a: alpha });
615
+ }
616
+ return paints.length ? paints : null;
588
617
  }
589
618
 
590
619
  export function paintedBackdrop(element) {
@@ -1,26 +1,30 @@
1
- // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
- // An ARIA attribute the role does not support is INERT. Measured in Chromium:
3
- // aria-expanded on a button exposes expanded=false, and the same attribute on
4
- // a complementary landmark exposes no state at all. It is dropped, not
5
- // misreported, so the old "announces the wrong state" wording was wrong.
1
+ // ARIA 1.2 §8.6, repeated verbatim in the 1.3 draft: "authors MUST only use
2
+ // non-global states and properties on elements with a role supporting the
3
+ // state or property". An unsupported attribute breaks that MUST, so this is a
4
+ // real, provable authoring error and the rule asserts it.
6
5
  //
7
- // Which is why this REVIEWS rather than fails. ARIA 1.2 §5.2.3 says authors
8
- // MAY provide supported states and user agents MUST map them; "prohibited"
9
- // (§5.2.5) is a separate category this rule does not cover. An unsupported
10
- // attribute is therefore not a spec violation, and name, role and value are
11
- // all still conveyed correctly, so no success criterion provably fails.
6
+ // It is NOT a WCAG failure, which is why it lives here rather than under
7
+ // 4.1.2. An unsupported attribute is INERT, measured in Chromium:
8
+ // aria-expanded on a button exposes expanded=false, while the same attribute
9
+ // on a complementary landmark exposes no state at all. It is dropped, not
10
+ // misreported. So the role is still exposed, the name is still computed, and
11
+ // nothing is announced incorrectly. Name, role and value all survive, which
12
+ // is all 4.1.2 asks for.
12
13
  //
13
- // The 4.1.2 tag stays because there IS a real failure hiding here: if the
14
- // element genuinely has the state the author was reaching for, that state is
15
- // now invisible to assistive technology. Whether it does is exactly what the
16
- // DOM cannot establish, so it is a question for a person. Same reasoning that
17
- // put aria-label-misuse in best-practice.
14
+ // What CAN fail 4.1.2 is the thing underneath: if the element genuinely has
15
+ // the state the author reached for, that state is now invisible. Whether it
16
+ // does is exactly what the DOM cannot establish, so the message asks rather
17
+ // than asserts. Reporting the ARIA error as a WCAG violation would have been
18
+ // asserting a criterion failure off the back of a question.
19
+ //
20
+ // Note "unsupported" is distinct from "prohibited" (§5.2.5), a separate
21
+ // category about naming that this rule does not cover.
18
22
  //
19
23
  // Worth knowing when comparing against other tools: aria-expanded WAS
20
24
  // inherited into complementary, banner, heading, img and list under ARIA 1.1,
21
25
  // and was narrowed in 1.2 (narrowed again in the 1.3 draft). Markup written
22
26
  // against 1.1 is not conforming under 1.2, and a tool on the older baseline
23
- // stays silent on it.
27
+ // stays silent on it. Neither reading is a bug; they are different editions.
24
28
  import { GLOBAL_ARIA, ROLE_ARIA, KNOWN_ARIA, effectiveRole } from '../../lib/roles.js';
25
29
 
26
30
  // aria-label/labelledby misuse on generic elements has its own rule
@@ -30,9 +34,9 @@ const HANDLED_ELSEWHERE = new Set(['label', 'labelledby']);
30
34
  export default {
31
35
  id: 'aria-allowed-attr',
32
36
  impact: 'moderate',
33
- tags: ['wcag2a', 'wcag412'],
34
- help: 'ARIA attributes must be allowed for the element’s role',
35
- helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
37
+ tags: ['best-practice'],
38
+ help: 'ARIA attributes must be supported by the element’s role',
39
+ helpUrl: 'https://www.w3.org/TR/wai-aria-1.2/#state_property_processing',
36
40
  selector: '*',
37
41
  visibleOnly: false,
38
42
  evaluate(element) {
@@ -56,9 +60,9 @@ export default {
56
60
  if (!disallowed.length) return { status: 'pass' };
57
61
  const names = disallowed.map((n) => `aria-${n}`);
58
62
  return {
59
- status: 'incomplete',
60
- message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. If this element really does have that state, it is currently invisible: check whether it needs conveying another way.`,
61
- fix: `Move ${names.join('/')} to the element whose role supports it, usually the control that toggles this one, or remove it if the element has no such state.`,
63
+ status: 'fail',
64
+ message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. If this element really does have that state, it is currently invisible to anyone using a screen reader.`,
65
+ fix: `Move ${names.join('/')} to the element whose role supports it, usually the control that toggles this one, or remove it if the element has no such state. Adding a role to this element to make the attribute legal is rarely right: the host language restricts which roles each element may take.`,
62
66
  };
63
67
  },
64
68
  };
@@ -42,7 +42,10 @@ import audioControl from './wcag/1.4.2-audio-control.js';
42
42
  import pauseStopHide from './wcag/2.2.2-pause-stop-hide.js';
43
43
  import mediaCaptions from './wcag/1.2.2-media-captions.js';
44
44
  import ariaAttrValid from './wcag/4.1.2-aria-attr-valid.js';
45
- import ariaAllowedAttr from './wcag/4.1.2-aria-allowed-attr.js';
45
+ // Demoted to best-practice: an unsupported ARIA attribute breaks an ARIA
46
+ // author MUST (1.2 §8.6) but is INERT, so name, role and value all survive
47
+ // and no success criterion fails (see the note in the rule file).
48
+ import ariaAllowedAttr from './best-practice/aria-allowed-attr.js';
46
49
  import ariaFieldName from './wcag/4.1.2-aria-field-name.js';
47
50
  // Demoted to best-practice: prohibited-but-inert ARIA on a generic element
48
51
  // fails no success criterion (see the note in the rule file).
@@ -4,13 +4,25 @@ import {
4
4
  backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
5
5
  rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
6
6
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
7
- pseudoBackdropForText, backgroundColorSource,
7
+ pseudoBackdropForText, backgroundColorSource, scrimPaint, applyOverlays,
8
8
  } from '../../lib/contrast.js';
9
9
 
10
10
  /** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
11
11
  * because "4.50:1 — below the 4.5:1 minimum" reads as a contradiction. */
12
12
  const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
13
13
 
14
+ /** The one case a full-page veil still can't be judged for the author: the
15
+ * resting page and the dimmed one land on opposite sides of the threshold,
16
+ * so the verdict depends on whether the veil is transient or permanent. */
17
+ const veiledIncomplete = (resting, veiled) => ({
18
+ status: 'incomplete',
19
+ message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast'
20
+ + (resting && veiled
21
+ ? `: ${showRatio(resting)}:1 at rest, ${showRatio(veiled)}:1 as presented through the overlay, which straddles the threshold`
22
+ : '')
23
+ + ' — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
24
+ });
25
+
14
26
  /**
15
27
  * Is a duplicate copy of the same text painted exactly on top of this
16
28
  * element? The gradient-text pattern ships two copies — a plain-colour
@@ -388,28 +400,30 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
388
400
  // of merely flagging them for a human.
389
401
  const painted = paintedBackdrop(element);
390
402
  // A translucent viewport-scale veil is painted ON TOP of this text
391
- // (modal scrim, loading overlay, consent dimmer): the user sees the
392
- // text through it, so the resting colours are not the presented ones —
393
- // and if the veil is transient, the resting state is what should be
394
- // judged. Either way this is a human call, in both verdict directions.
395
- if (painted?.scrim) {
396
- return {
397
- status: 'incomplete',
398
- message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
399
- };
400
- }
403
+ // (modal scrim, loading overlay, consent dimmer): the user sees the text
404
+ // through it, so the resting colours are not the presented ones — and if
405
+ // the veil is transient, the resting state is what should be judged.
406
+ // TWO states, then, and the honest question is whether they disagree.
407
+ // A consent dimmer over failing body text fails dimmed AND at rest; the
408
+ // veil changes nothing a human could rule differently, so a verdict is
409
+ // owed. Abstaining on sight instead silenced contrast across every page
410
+ // that greets a visitor with a cookie modal — which is most of the web.
411
+ // Only genuine disagreement between the two states is a human call.
412
+ let veilPaint = null;
413
+ let scrimLayers = painted?.scrim ?? null;
401
414
  // Offscreen text can't be hit-tested against the veil — but a FIXED
402
415
  // translucent overlay follows the viewport, so it dims scrolled-away
403
416
  // content just the same. (The veil's own contents are fixed too, hence
404
417
  // in-viewport, hence never routed through this branch.)
405
- if (painted === 'offscreen') {
418
+ if (!scrimLayers && painted === 'offscreen') {
406
419
  const veil = viewportVeil(doc);
407
- if (veil && !veil.contains(element)) {
408
- return {
409
- status: 'incomplete',
410
- message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
411
- };
412
- }
420
+ if (veil && !veil.contains(element)) scrimLayers = [veil];
421
+ }
422
+ if (scrimLayers) {
423
+ veilPaint = scrimPaint(scrimLayers);
424
+ // A veil whose paint can't be reduced to flat colour (backdrop-filter,
425
+ // blend mode) leaves the dimmed state uncomputable: still a human call.
426
+ if (!veilPaint) return veiledIncomplete();
413
427
  }
414
428
  // Pseudo-element paint sits above everything the hit-test and the walk
415
429
  // can report, and neither can see it — so it is resolved before them and
@@ -423,6 +437,11 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
423
437
  message: 'A pseudo-element with its own background paints in this element\'s chain, but its position can\'t be computed — so whether it sits behind this text is unknown. Check the contrast by eye.',
424
438
  };
425
439
  }
440
+ // An image backdrop under a veil would have to be sampled a second time,
441
+ // dimmed, to answer whether the two states agree. Not worth a second pass
442
+ // over the pixels of every hero photo behind a cookie modal: these keep
443
+ // the human call they have always had.
444
+ if (veilPaint && (pseudoBack?.image || painted?.image)) return veiledIncomplete();
426
445
  // Gradient/image paint from a pseudo-element: bracket it by sampling,
427
446
  // exactly as an element's own background-image is bracketed.
428
447
  if (pseudoBack?.image) {
@@ -526,6 +545,17 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
526
545
  };
527
546
 
528
547
  const ratio = contrastRatio(foreground, background);
548
+ // The dimmed state, when a veil is up: it paints over the glyphs and
549
+ // their background alike, so both composite through the same layers.
550
+ // Dimming is not monotonic on ratio (it can rescue or ruin a pairing),
551
+ // which is exactly why both states are measured rather than assumed.
552
+ const veiled = (fg, bg) => contrastRatio(applyOverlays(fg, veilPaint), applyOverlays(bg, veilPaint));
553
+ if (veilPaint) {
554
+ const veiledRatio = veiled(foreground, background);
555
+ if ((veiledRatio >= required) !== (ratio >= required)) {
556
+ return veiledIncomplete(ratio, veiledRatio);
557
+ }
558
+ }
529
559
  if (ratio >= required) {
530
560
  return backdropHazard('pass', backgroundVerified) ?? { status: 'pass' };
531
561
  }
@@ -545,6 +575,14 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
545
575
  const halo = textShadowHalo(style.textShadow, fontSize);
546
576
  if (halo) {
547
577
  const haloRatio = contrastRatio(foreground, halo);
578
+ // A veil dims the glyphs and their own outline together: the halo
579
+ // verdict has to survive both states, exactly as the background one does.
580
+ if (veilPaint) {
581
+ const veiledHalo = veiled(foreground, halo);
582
+ if ((veiledHalo >= required) !== (haloRatio >= required)) {
583
+ return veiledIncomplete(haloRatio, veiledHalo);
584
+ }
585
+ }
548
586
  if (haloRatio >= required) return { status: 'pass' };
549
587
  return {
550
588
  status: 'fail',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
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",