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.
- package/config/project.config.js +1 -1
- package/engine/COVERAGE.md +1 -1
- package/engine/lib/contrast.js +35 -6
- package/engine/rules/{wcag/4.1.2-aria-allowed-attr.js → best-practice/aria-allowed-attr.js} +26 -22
- package/engine/rules/index.js +4 -1
- package/engine/rules/wcag/1.4.3-color-contrast.js +56 -18
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -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-
|
|
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.
|
package/engine/lib/contrast.js
CHANGED
|
@@ -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
|
|
377
|
-
* of the text — a modal veil, loading overlay, consent dimmer.
|
|
378
|
-
* presented through
|
|
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)
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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: ['
|
|
34
|
-
help: 'ARIA attributes must be
|
|
35
|
-
helpUrl: 'https://www.w3.org/
|
|
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: '
|
|
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
|
|
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
|
};
|
package/engine/rules/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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