pour-engine 1.14.1 → 1.16.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/lib/contrast.js +145 -29
- package/engine/lib/dom.js +11 -0
- package/engine/rules/wcag/1.4.12-text-spacing.js +13 -3
- package/engine/rules/wcag/1.4.3-color-contrast.js +127 -17
- package/engine/rules/wcag/1.4.4-meta-viewport.js +6 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +6 -1
- package/engine/rules/wcag/2.4.2-document-title.js +7 -0
- package/engine/rules/wcag/2.5.8-target-size.js +14 -0
- package/engine/rules/wcag/4.1.2-aria-label-misuse.js +22 -14
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -479,8 +479,9 @@ function pseudoRect(host, style) {
|
|
|
479
479
|
* on the pill, not on the track colour both other paths report.
|
|
480
480
|
*
|
|
481
481
|
* Returns { color } (opaque or translucent paint over the point),
|
|
482
|
-
*
|
|
483
|
-
*
|
|
482
|
+
* or null when no pseudo of this host paints over the point. Paint that
|
|
483
|
+
* can't be placed becomes a { film } entry — an upper bound on its alpha —
|
|
484
|
+
* so callers can bracket its effect instead of giving up on the element.
|
|
484
485
|
*/
|
|
485
486
|
/** The paint a host's pseudo-elements contribute, resolved once per audit.
|
|
486
487
|
* Two extra getComputedStyle calls per host would otherwise land on every
|
|
@@ -495,7 +496,8 @@ function pseudoLayers(host) {
|
|
|
495
496
|
for (const which of ['::before', '::after']) {
|
|
496
497
|
const style = getComputedStyle(host, which);
|
|
497
498
|
if (style.content === 'none' || style.display === 'none' || style.visibility === 'hidden') continue;
|
|
498
|
-
const
|
|
499
|
+
const color = parseColor(style.backgroundColor);
|
|
500
|
+
const paints = style.backgroundImage !== 'none' || (color?.a ?? 0) > 0;
|
|
499
501
|
if (!paints) continue;
|
|
500
502
|
// In-flow pseudos (the overwhelming majority: bullets, icons, rules,
|
|
501
503
|
// badges) occupy their own box in the content flow beside the text
|
|
@@ -503,19 +505,34 @@ function pseudoLayers(host) {
|
|
|
503
505
|
// backdrop, so everything else is simply not a layer here — treating
|
|
504
506
|
// them as unknowable would abstain on half the web.
|
|
505
507
|
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
|
508
|
+
const opacity = parseFloat(style.opacity);
|
|
509
|
+
const opacityFactor = Number.isFinite(opacity) ? opacity : 1;
|
|
506
510
|
const rect = style.position === 'absolute' ? pseudoRect(host, style) : null;
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
511
|
+
if (!rect) {
|
|
512
|
+
// Paint we can't place still can't paint MORE than its alpha allows:
|
|
513
|
+
// whatever colour it is and wherever it sits, it moves any channel
|
|
514
|
+
// beneath it by at most alpha × 255. Record that bound as a "film"
|
|
515
|
+
// so callers can bracket the verdict — a 3.5%-opacity grain overlay
|
|
516
|
+
// (position: fixed, the common full-page-texture pattern) must not
|
|
517
|
+
// send every element on the page to a human.
|
|
518
|
+
const alphaBound = Math.min(1, opacityFactor * (style.backgroundImage !== 'none' ? 1 : color.a));
|
|
519
|
+
if (alphaBound > 0) layers.push({ film: alphaBound });
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
510
522
|
if (rect.empty) continue; // placed, and covers nothing
|
|
511
|
-
let
|
|
512
|
-
|
|
513
|
-
if (color && Number.isFinite(opacity) && opacity < 1) color = { ...color, a: color.a * opacity };
|
|
523
|
+
let layerColor = color;
|
|
524
|
+
if (layerColor && opacityFactor < 1) layerColor = { ...layerColor, a: layerColor.a * opacityFactor };
|
|
514
525
|
// Gradient and image paint rides along as CSS so callers can sample it,
|
|
515
526
|
// the same way they sample an element's background-image — a gradient
|
|
516
527
|
// scrim over a card headline is a bracketable backdrop, not a mystery.
|
|
528
|
+
// Repeat/size/position ride along too: a no-repeat url() paints its
|
|
529
|
+
// intrinsic rect inside this box, not the whole of it, and only the
|
|
530
|
+
// caller (which knows where the text is) can rule on that geometry.
|
|
517
531
|
const imageCss = style.backgroundImage !== 'none' ? style.backgroundImage : null;
|
|
518
|
-
|
|
532
|
+
const imageMeta = imageCss
|
|
533
|
+
? { repeat: style.backgroundRepeat, size: style.backgroundSize, position: style.backgroundPosition }
|
|
534
|
+
: null;
|
|
535
|
+
layers.push({ rect, color: layerColor, imageCss, imageMeta });
|
|
519
536
|
}
|
|
520
537
|
pseudoCache.set(host, layers);
|
|
521
538
|
return layers;
|
|
@@ -539,28 +556,78 @@ function pseudoLayers(host) {
|
|
|
539
556
|
*
|
|
540
557
|
* Returns { color } (opaque, or translucent paint to composite over the
|
|
541
558
|
* walked background), { image } for gradient/image paint the caller should
|
|
542
|
-
* sample,
|
|
543
|
-
*
|
|
559
|
+
* sample, or null when no pseudo covers the text. Whenever the chain also
|
|
560
|
+
* holds paint that CAN'T be placed, the result carries `film`: an upper
|
|
561
|
+
* bound on that paint's alpha, accumulated across every unplaceable layer.
|
|
562
|
+
* The caller brackets the verdict against it — a film of alpha a moves any
|
|
563
|
+
* channel by at most a × 255 whether it sits behind the text or over it,
|
|
564
|
+
* and paint hidden beneath an opaque layer moves nothing, so the bound
|
|
565
|
+
* holds regardless of paint order.
|
|
544
566
|
*/
|
|
545
567
|
export function pseudoBackdropForText(element) {
|
|
546
568
|
const point = textSamplePoint(element);
|
|
547
569
|
if (!point) return null;
|
|
548
570
|
let acc = null;
|
|
571
|
+
let image = null;
|
|
572
|
+
let settled = false; // an opaque colour or an image hit: deeper layers are covered
|
|
573
|
+
let film = 0;
|
|
549
574
|
for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
575
|
+
for (const layer of pseudoLayers(node)) {
|
|
576
|
+
// Films accumulate over the WHOLE chain — even past an opaque hit,
|
|
577
|
+
// because an ancestor's ::after can paint above a descendant's pill.
|
|
578
|
+
if (layer.film) { film = 1 - (1 - film) * (1 - layer.film); continue; }
|
|
579
|
+
if (settled) continue;
|
|
580
|
+
const { rect, color, imageCss } = layer;
|
|
554
581
|
if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
|
|
555
582
|
// Gradient/image paint is bracketable by sampling, so hand it to the
|
|
556
|
-
// caller rather than giving up on the whole element.
|
|
557
|
-
|
|
583
|
+
// caller rather than giving up on the whole element. The pseudo's box
|
|
584
|
+
// and image geometry travel with it: covering the sample point is a
|
|
585
|
+
// fact about the BOX, and a no-repeat icon may still paint nowhere
|
|
586
|
+
// near the text.
|
|
587
|
+
if (imageCss) { image = { css: imageCss, element: node, box: rect, meta: layer.imageMeta }; settled = true; continue; }
|
|
558
588
|
if (!color || color.a === 0) continue;
|
|
559
589
|
acc = acc ? composite(acc, color) : color;
|
|
560
|
-
if (acc.a >= 1)
|
|
590
|
+
if (acc.a >= 1) settled = true;
|
|
561
591
|
}
|
|
562
592
|
}
|
|
563
|
-
return
|
|
593
|
+
if (image) return film > 0 ? { image, film } : { image };
|
|
594
|
+
if (acc) return film > 0 ? { color: acc, film } : { color: acc };
|
|
595
|
+
return film > 0 ? { film } : null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The range the contrast ratio can occupy once an unplaceable translucent
|
|
600
|
+
* layer (a "film" of alpha ≤ film) may be painting somewhere in the stack.
|
|
601
|
+
*
|
|
602
|
+
* Two physical scenarios bound everything: the film behind the glyphs
|
|
603
|
+
* (shifting only the background) or over the whole element (shifting text
|
|
604
|
+
* and background alike, as t3-style full-page grain overlays do). Within
|
|
605
|
+
* each, luminance is monotone per colour channel and the ratio's derivative
|
|
606
|
+
* carries one sign across channels, so the extremes sit at an all-black or
|
|
607
|
+
* all-white film — five evaluations bound the true range.
|
|
608
|
+
*
|
|
609
|
+
* `crossed` flags the one case the endpoints can't bound from below: a film
|
|
610
|
+
* behind the text whose extremes land the background's luminance on OPPOSITE
|
|
611
|
+
* sides of the text's, where an intermediate colour collapses the ratio
|
|
612
|
+
* toward 1:1. Callers must not assert a pass when it is set; a fail needs
|
|
613
|
+
* only `max`, which the endpoints do bound.
|
|
614
|
+
*/
|
|
615
|
+
export function filmedContrastBounds(foreground, background, film) {
|
|
616
|
+
const ratios = [contrastRatio(foreground, background)];
|
|
617
|
+
const textLum = luminance(foreground);
|
|
618
|
+
const sides = [];
|
|
619
|
+
for (const channel of [0, 255]) {
|
|
620
|
+
const paint = { r: channel, g: channel, b: channel, a: film };
|
|
621
|
+
const shiftedBackground = composite(paint, background);
|
|
622
|
+
ratios.push(contrastRatio(foreground, shiftedBackground)); // film behind the text
|
|
623
|
+
ratios.push(contrastRatio(composite(paint, foreground), shiftedBackground)); // film over text and backdrop alike
|
|
624
|
+
sides.push(Math.sign(textLum - luminance(shiftedBackground)));
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
min: Math.min(...ratios),
|
|
628
|
+
max: Math.max(...ratios),
|
|
629
|
+
crossed: sides[0] !== sides[1],
|
|
630
|
+
};
|
|
564
631
|
}
|
|
565
632
|
|
|
566
633
|
/** A layer qualifies as a scrim over `element` when it is translucent paint
|
|
@@ -887,7 +954,13 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
887
954
|
let minColor = null;
|
|
888
955
|
let maxColor = null;
|
|
889
956
|
let opaquePixels = 0;
|
|
957
|
+
let alphaSeen = false;
|
|
890
958
|
for (let i = 0; i < data.length; i += 4) {
|
|
959
|
+
// Any see-through pixel means part of this layer shows whatever is
|
|
960
|
+
// painted beneath it — the sampled range alone then describes only
|
|
961
|
+
// the ink, not the backdrop the eye meets. Callers widen the range
|
|
962
|
+
// with the underlying paint (rangeWithBackdrop) or abstain.
|
|
963
|
+
if (data[i + 3] < 255) alphaSeen = true;
|
|
891
964
|
if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
|
|
892
965
|
opaquePixels += 1;
|
|
893
966
|
// Composite each pixel through anything painted between the image
|
|
@@ -915,7 +988,7 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
915
988
|
return;
|
|
916
989
|
}
|
|
917
990
|
resolve(min <= max
|
|
918
|
-
? { min, max, minColor, maxColor, width: img.naturalWidth, height: img.naturalHeight }
|
|
991
|
+
? { min, max, minColor, maxColor, hasAlpha: alphaSeen, width: img.naturalWidth, height: img.naturalHeight }
|
|
919
992
|
: null);
|
|
920
993
|
} catch {
|
|
921
994
|
resolve(null); // tainted canvas: cross-origin image without CORS headers
|
|
@@ -1032,17 +1105,34 @@ function splitLayers(value) {
|
|
|
1032
1105
|
export function backgroundImagePaintRect(element, intrinsic) {
|
|
1033
1106
|
const style = getComputedStyle(element);
|
|
1034
1107
|
if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
|
|
1035
|
-
if (style.backgroundRepeat !== 'no-repeat') return null; // may tile under the text
|
|
1036
|
-
if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') return null;
|
|
1037
1108
|
const box = element.getBoundingClientRect();
|
|
1109
|
+
return imagePaintRectInBox(
|
|
1110
|
+
{ left: box.left, top: box.top, right: box.right, bottom: box.bottom },
|
|
1111
|
+
{ repeat: style.backgroundRepeat, size: style.backgroundSize, position: style.backgroundPosition },
|
|
1112
|
+
intrinsic,
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* The same paint-rect math for any painting box a caller already has —
|
|
1118
|
+
* above all a pseudo-element's resolved box, which no element rect can
|
|
1119
|
+
* describe. Returns null when the geometry is not statically knowable
|
|
1120
|
+
* (tiling, cover/contain, keyword positions).
|
|
1121
|
+
*/
|
|
1122
|
+
export function imagePaintRectInBox(box, meta, intrinsic) {
|
|
1123
|
+
if (!box || !meta) return null;
|
|
1124
|
+
if (meta.repeat !== 'no-repeat') return null; // may tile under the text
|
|
1125
|
+
if (meta.size === 'cover' || meta.size === 'contain') return null;
|
|
1126
|
+
const boxWidth = box.right - box.left;
|
|
1127
|
+
const boxHeight = box.bottom - box.top;
|
|
1038
1128
|
const dimension = (value, total, auto) => {
|
|
1039
1129
|
if (value?.endsWith('px')) return parseFloat(value);
|
|
1040
1130
|
if (value?.endsWith('%')) return (parseFloat(value) / 100) * total;
|
|
1041
1131
|
return auto;
|
|
1042
1132
|
};
|
|
1043
|
-
const size =
|
|
1044
|
-
const width = dimension(size[0],
|
|
1045
|
-
const height = dimension(size[1] ?? size[0],
|
|
1133
|
+
const size = meta.size.split(' ');
|
|
1134
|
+
const width = dimension(size[0], boxWidth, intrinsic?.width);
|
|
1135
|
+
const height = dimension(size[1] ?? size[0], boxHeight, intrinsic?.height);
|
|
1046
1136
|
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
1047
1137
|
// position N% aligns the image's N% point with the container's N% point.
|
|
1048
1138
|
// Anything else (calc() from three-value syntax, keywords) is not
|
|
@@ -1053,15 +1143,41 @@ export function backgroundImagePaintRect(element, intrinsic) {
|
|
|
1053
1143
|
if (value?.endsWith('px')) return parseFloat(value);
|
|
1054
1144
|
return null;
|
|
1055
1145
|
};
|
|
1056
|
-
const pos =
|
|
1057
|
-
const offsetX = offset(pos[0],
|
|
1058
|
-
const offsetY = offset(pos[1] ?? '50%',
|
|
1146
|
+
const pos = meta.position.split(' ');
|
|
1147
|
+
const offsetX = offset(pos[0], boxWidth, width);
|
|
1148
|
+
const offsetY = offset(pos[1] ?? '50%', boxHeight, height);
|
|
1059
1149
|
if (offsetX === null || offsetY === null || Number.isNaN(offsetX) || Number.isNaN(offsetY)) return null;
|
|
1060
1150
|
const left = box.left + offsetX;
|
|
1061
1151
|
const top = box.top + offsetY;
|
|
1062
1152
|
return { left, top, right: left + width, bottom: top + height };
|
|
1063
1153
|
}
|
|
1064
1154
|
|
|
1155
|
+
/**
|
|
1156
|
+
* Widen a sampled luminance range with the layer painted beneath the image,
|
|
1157
|
+
* for images that do not cover their box with opaque pixels (hasAlpha).
|
|
1158
|
+
* A semi-transparent pixel blends per channel between its own colour and
|
|
1159
|
+
* the underlying paint, and luminance is monotone in each channel, so a
|
|
1160
|
+
* range extended with the underlying colour bounds every blend the eye can
|
|
1161
|
+
* meet. Returns null when the underlying paint is unknown or itself
|
|
1162
|
+
* see-through: no bound exists then, and the caller must not assert either
|
|
1163
|
+
* verdict from the ink pixels alone — a mostly-transparent illustration
|
|
1164
|
+
* sampled dark was asserting failures for text that really sat on the white
|
|
1165
|
+
* showing through it.
|
|
1166
|
+
*/
|
|
1167
|
+
export function rangeWithBackdrop(range, under, overlays = []) {
|
|
1168
|
+
if (!range || !range.hasAlpha) return range;
|
|
1169
|
+
if (!under || under.a < 1) return null;
|
|
1170
|
+
const shown = overlays.length ? applyOverlays(under, overlays) : under;
|
|
1171
|
+
const underLum = luminance(shown);
|
|
1172
|
+
return {
|
|
1173
|
+
...range,
|
|
1174
|
+
min: Math.min(range.min, underLum),
|
|
1175
|
+
max: Math.max(range.max, underLum),
|
|
1176
|
+
minColor: underLum < range.min ? shown : range.minColor,
|
|
1177
|
+
maxColor: underLum > range.max ? shown : range.maxColor,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1065
1181
|
/**
|
|
1066
1182
|
* Luminance range of a CSS gradient, from its colour stops. Interpolated
|
|
1067
1183
|
* colours stay close enough to their stops' luminances to bracket.
|
package/engine/lib/dom.js
CHANGED
|
@@ -106,6 +106,17 @@ export function htmlSnippet(element, maxLength = 300) {
|
|
|
106
106
|
return html.length > maxLength ? `${html.slice(0, maxLength)}…` : html;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** True when the document renders embedded inside another page (iframe or
|
|
110
|
+
* frame). WCAG defines a "Web page" as a NON-embedded resource, so the
|
|
111
|
+
* page-level criteria (2.4.2 Page Titled, 2.4.1 Bypass Blocks) judge only
|
|
112
|
+
* the document the user actually opened: an embedded frame owes an
|
|
113
|
+
* accessible name under 4.1.2, not its own <title> or skip link. The
|
|
114
|
+
* reference comparison is safe cross-origin; frameElement is not. */
|
|
115
|
+
export function isEmbeddedDocument(doc) {
|
|
116
|
+
const win = doc?.defaultView;
|
|
117
|
+
return !!(win && win.top && win !== win.top);
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
/** The element's own (not descendant-element) text, trimmed. */
|
|
110
121
|
export function ownText(element) {
|
|
111
122
|
return [...element.childNodes]
|
|
@@ -61,10 +61,20 @@ export default {
|
|
|
61
61
|
// Only NEW clipping counts: content already truncated by design
|
|
62
62
|
// (ellipsis patterns) is judged as-authored, not under the probe.
|
|
63
63
|
if (before[i] || !after[i]) return { status: 'pass' };
|
|
64
|
+
// Review, not fail: the probe proves the box OVERFLOWS under the
|
|
65
|
+
// override, not that glyphs are lost. The genuine violation is a
|
|
66
|
+
// fixed-height overflow-hidden container that doesn't grow with its
|
|
67
|
+
// text, hiding whole lines (measured live: a 2-line card clamped at
|
|
68
|
+
// 37px whose text needs 61px spaced — the last line vanishes). But
|
|
69
|
+
// the same scroll-size delta also fires on benign overflow: the
|
|
70
|
+
// 0.12em letter-spacing lands AFTER the final glyph, so a snug box
|
|
71
|
+
// can overflow by trailing empty spacing that hides no ink, and a
|
|
72
|
+
// clamp pattern that swaps hidden words for an ellipsis is arguably
|
|
73
|
+
// truncation-by-design. Proving ink loss would need per-glyph rects
|
|
74
|
+
// against the clip box under the probe; until then, a human call.
|
|
64
75
|
return {
|
|
65
|
-
status: '
|
|
66
|
-
message: 'With the WCAG text-spacing overrides applied (line height 1.5, letter/word spacing bumps),
|
|
67
|
-
fix: 'Let the container grow (min-height instead of height, avoid overflow:hidden on text), so spacing changes reflow instead of clip.',
|
|
76
|
+
status: 'incomplete',
|
|
77
|
+
message: 'With the WCAG text-spacing overrides applied (line height 1.5, letter/word spacing bumps), this container overflows instead of growing — if that hides text rather than empty trailing spacing, users who need wider spacing lose content. Check with the overrides applied; to be safe, let the container grow (min-height instead of height, avoid overflow:hidden on text).',
|
|
68
78
|
};
|
|
69
79
|
});
|
|
70
80
|
},
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// WCAG SC 1.4.3 Contrast (Minimum) (Level AA)
|
|
2
2
|
import {
|
|
3
3
|
parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
|
|
4
|
-
backgroundImageSource, backgroundImagePaintRect,
|
|
5
|
-
|
|
4
|
+
backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
|
|
5
|
+
gradientLuminanceRange, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
|
|
6
|
+
opacityAnimating, restingOpacity, mediaRects,
|
|
6
7
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
7
|
-
pseudoBackdropForText, backgroundColorSource, scrimPaint, applyOverlays,
|
|
8
|
+
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
8
9
|
} from '../../lib/contrast.js';
|
|
9
10
|
|
|
10
11
|
/** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
|
|
@@ -165,7 +166,7 @@ function verdictFromRange(range, foreground, required, what) {
|
|
|
165
166
|
*/
|
|
166
167
|
function unaccountedScrim(element, imageCarrier) {
|
|
167
168
|
const pseudo = pseudoBackdropForText(element);
|
|
168
|
-
if (pseudo
|
|
169
|
+
if (pseudo?.color?.a > 0) return true;
|
|
169
170
|
const doc = element.ownerDocument;
|
|
170
171
|
if (typeof doc.elementsFromPoint !== 'function') return false;
|
|
171
172
|
const rect = element.getBoundingClientRect();
|
|
@@ -187,11 +188,29 @@ function unaccountedScrim(element, imageCarrier) {
|
|
|
187
188
|
* changing it, so the callers step over it rather than reporting on it. */
|
|
188
189
|
const PAINTS_NOTHING = Symbol('fully transparent image layer');
|
|
189
190
|
|
|
191
|
+
/** The flat paint directly beneath a carrier's background-image: the
|
|
192
|
+
* carrier's own background-color composited over whatever resolves behind
|
|
193
|
+
* the carrier. This is what shows through the image's transparent pixels.
|
|
194
|
+
* Null when it is itself unknowable (another image or an unresolvable
|
|
195
|
+
* chain behind) — the sampling then has no floor to extend its range with. */
|
|
196
|
+
function paintUnderImage(carrier) {
|
|
197
|
+
const color = parseColor(getComputedStyle(carrier).backgroundColor);
|
|
198
|
+
if (color && color.a >= 1) return color;
|
|
199
|
+
const parent = carrier.parentElement ?? carrier.getRootNode()?.host ?? null;
|
|
200
|
+
const behind = parent ? effectiveBackground(parent) : null;
|
|
201
|
+
if (!behind) return null;
|
|
202
|
+
return color && color.a > 0 ? composite(color, behind) : behind;
|
|
203
|
+
}
|
|
204
|
+
|
|
190
205
|
// Bracket the verdict by sampling what's actually painted: image pixels
|
|
191
206
|
// (same-origin/CORS only) or gradient colour stops. Returns an outcome,
|
|
192
207
|
// PAINTS_NOTHING, or null when nothing here can be sampled at all (stacked
|
|
193
208
|
// layers, translucent gradient stops) and the caller must say so itself.
|
|
194
|
-
|
|
209
|
+
// `under` is the colour painted beneath the sampled layer, when the caller
|
|
210
|
+
// knows it: an image with see-through pixels shows that colour wherever the
|
|
211
|
+
// ink is absent, so the judged range must include it — and without it no
|
|
212
|
+
// verdict can be asserted from such an image at all.
|
|
213
|
+
async function sampledVerdict(source, foreground, required, doc, overlays = [], under = null) {
|
|
195
214
|
if (!source) return null;
|
|
196
215
|
let range = null;
|
|
197
216
|
let what = 'image';
|
|
@@ -216,6 +235,19 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [])
|
|
|
216
235
|
// Checked before the text's own alpha: there is no blend to reason about
|
|
217
236
|
// when the layer paints nothing.
|
|
218
237
|
if (range?.transparent) return PAINTS_NOTHING;
|
|
238
|
+
// See-through pixels reveal the layer beneath: widen the range with it
|
|
239
|
+
// when it is known; when it isn't, the ink pixels alone prove nothing —
|
|
240
|
+
// a mostly-transparent illustration sampled dark was failing text that
|
|
241
|
+
// really sat on the white showing through it.
|
|
242
|
+
if (range?.hasAlpha) {
|
|
243
|
+
range = rangeWithBackdrop(range, under, overlays);
|
|
244
|
+
if (!range) {
|
|
245
|
+
return {
|
|
246
|
+
status: 'incomplete',
|
|
247
|
+
message: `The ${what} behind this text has transparent regions, so parts of the text sit on whatever is painted beneath it, and that layer could not be resolved — check the contrast by eye.`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
219
251
|
// Translucent text takes its presented colour from the very pixels
|
|
220
252
|
// beneath it, so a luminance range of the backdrop cannot be read straight
|
|
221
253
|
// off: dimmed white over a dark photo reads mid-grey, not white.
|
|
@@ -270,11 +302,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
270
302
|
tags,
|
|
271
303
|
help,
|
|
272
304
|
helpUrl,
|
|
273
|
-
//
|
|
274
|
-
//
|
|
305
|
+
// Every element except the ones whose text is judged elsewhere or isn't
|
|
306
|
+
// CSS-colour text at all: a tag allow-list silently skipped visible text
|
|
307
|
+
// in anything it forgot (form, center, font, custom elements — Hacker
|
|
308
|
+
// News writes "Search:" directly inside <form>), and the own-text check
|
|
309
|
+
// below keeps the empty wrappers out of the results either way. Form
|
|
310
|
+
// controls stay out because control-contrast judges their text; SVG/MathML
|
|
311
|
+
// stay out because their glyphs are painted by fill, not color.
|
|
275
312
|
selector:
|
|
276
|
-
'
|
|
277
|
-
'
|
|
313
|
+
'*:not(script):not(style):not(noscript):not(title):not(option):not(optgroup)' +
|
|
314
|
+
':not(input):not(textarea):not(select):not(svg):not(svg *):not(math):not(math *)',
|
|
278
315
|
visibility: 'visual', // contrast is seen by sighted users even in aria-hidden content
|
|
279
316
|
async evaluate(element, { ownText }) {
|
|
280
317
|
if (!ownText(element)) return { status: 'pass' }; // no text of its own to judge
|
|
@@ -352,7 +389,8 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
352
389
|
// imageSource.overlays are the translucent layers painted between the
|
|
353
390
|
// image and this text (hero scrims, tint panels). Judging the raw
|
|
354
391
|
// image pixels would describe a page the user never sees.
|
|
355
|
-
let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays
|
|
392
|
+
let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays,
|
|
393
|
+
paintUnderImage(imageSource.element));
|
|
356
394
|
// A layer with no opaque pixels changes nothing about what is behind
|
|
357
395
|
// the glyphs, so it must not block the verdict: fall through and
|
|
358
396
|
// judge the background that actually paints.
|
|
@@ -430,12 +468,52 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
430
468
|
// wins. Geometry-only, so it settles offscreen text too, where the
|
|
431
469
|
// hit-test is blind and the walk would otherwise assert the track colour
|
|
432
470
|
// of a control the text visibly is not sitting on.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
471
|
+
//
|
|
472
|
+
// Unplaceable pseudo paint no longer abstains on sight: it arrives as
|
|
473
|
+
// `film`, an upper bound on its alpha, and the flat-colour verdict below
|
|
474
|
+
// is bracketed against it — asserted only when black and white films,
|
|
475
|
+
// behind the text or over it, all land the same side of the threshold.
|
|
476
|
+
// A 3.5%-opacity fixed grain overlay (the full-page texture pattern)
|
|
477
|
+
// otherwise sent every element on the page to the review lane, and a
|
|
478
|
+
// genuine 4.1:1 failure drowned among 163 siblings that were fine.
|
|
479
|
+
const pseudoResolved = pseudoBackdropForText(element);
|
|
480
|
+
const film = pseudoResolved?.film ?? 0;
|
|
481
|
+
let pseudoBack = pseudoResolved?.color || pseudoResolved?.image ? pseudoResolved : null;
|
|
482
|
+
// The pseudo's BOX covering the text says nothing about its IMAGE doing
|
|
483
|
+
// so: a no-repeat url() paints its intrinsic rect inside that box (a
|
|
484
|
+
// menu item's ::before arrow icon in a box that spans the whole item).
|
|
485
|
+
// When that rect is computable and provably misses every text box, the
|
|
486
|
+
// icon sits beside the text, not behind it — the layer is not a
|
|
487
|
+
// backdrop at all. And an icon-sized image that IS under a text corner
|
|
488
|
+
// may flag for eyes, never assert: the same discipline the element
|
|
489
|
+
// background-image path has always applied.
|
|
490
|
+
let pseudoIconScale = false;
|
|
491
|
+
if (pseudoBack?.image?.meta && pseudoBack.image.box) {
|
|
492
|
+
const pseudoUrl = pseudoBack.image.css.match(/url\(["']?(.*?)["']?\)/)?.[1];
|
|
493
|
+
if (pseudoUrl && pseudoBack.image.meta.repeat === 'no-repeat') {
|
|
494
|
+
let pseudoIntrinsic = null;
|
|
495
|
+
try { pseudoIntrinsic = await imageLuminanceRange(new URL(pseudoUrl, doc.baseURI).href); } catch { /* unknowable */ }
|
|
496
|
+
const dimensionless = !pseudoIntrinsic || !pseudoIntrinsic.width
|
|
497
|
+
|| (pseudoIntrinsic.width === 300 && pseudoIntrinsic.height === 150);
|
|
498
|
+
const paint = imagePaintRectInBox(pseudoBack.image.box, pseudoBack.image.meta, dimensionless ? null : pseudoIntrinsic);
|
|
499
|
+
if (paint && !paint.empty && !textIntersects(element, paint)) {
|
|
500
|
+
pseudoBack = null; // paints clear of the glyphs — judge what's beneath
|
|
501
|
+
} else {
|
|
502
|
+
pseudoIconScale = dimensionless
|
|
503
|
+
|| (pseudoIntrinsic.width <= 32 && pseudoIntrinsic.height <= 32);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const filmIncomplete = {
|
|
508
|
+
status: 'incomplete',
|
|
509
|
+
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.',
|
|
510
|
+
};
|
|
511
|
+
// Sampled pixels, veils and text-shadow halos can't be film-bracketed
|
|
512
|
+
// (the film may sit between the sample and the glyphs): those verdicts
|
|
513
|
+
// keep the human call they had when any unplaceable paint abstained.
|
|
514
|
+
if (film > 0 && (pseudoBack?.image || painted?.image || painted === 'unresolved'
|
|
515
|
+
|| veilPaint || (style.textShadow && style.textShadow !== 'none'))) {
|
|
516
|
+
return filmIncomplete;
|
|
439
517
|
}
|
|
440
518
|
// An image backdrop under a veil would have to be sampled a second time,
|
|
441
519
|
// dimmed, to answer whether the two states agree. Not worth a second pass
|
|
@@ -445,10 +523,21 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
445
523
|
// Gradient/image paint from a pseudo-element: bracket it by sampling,
|
|
446
524
|
// exactly as an element's own background-image is bracketed.
|
|
447
525
|
if (pseudoBack?.image) {
|
|
448
|
-
|
|
526
|
+
// Beneath the pseudo's paint sits its own host's background — a
|
|
527
|
+
// ::before draws above the host's background and below everything
|
|
528
|
+
// else, so the host's resolved colour is what shows through any
|
|
529
|
+
// transparent pixels the image has.
|
|
530
|
+
const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc, [],
|
|
531
|
+
effectiveBackground(pseudoBack.image.element));
|
|
449
532
|
// PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
|
|
450
533
|
// it never displaced the background the walk resolved.
|
|
451
534
|
if (sampled !== PAINTS_NOTHING) {
|
|
535
|
+
if (sampled?.status === 'fail' && pseudoIconScale) {
|
|
536
|
+
return {
|
|
537
|
+
status: 'incomplete',
|
|
538
|
+
message: 'A small icon is painted by a pseudo-element on this element — if it sits beside the text rather than under it, judge the contrast against the plain background by eye.',
|
|
539
|
+
};
|
|
540
|
+
}
|
|
452
541
|
return sampled ?? {
|
|
453
542
|
status: 'incomplete',
|
|
454
543
|
message: 'A pseudo-element paints an image or gradient behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
|
|
@@ -515,6 +604,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
515
604
|
// gradient canvas under its hero heading is exactly this shape), so
|
|
516
605
|
// even verified verdicts must be bracketed against them.
|
|
517
606
|
const backdropHazard = (direction, blindOnly) => {
|
|
607
|
+
// An element's OWN opaque background sits directly beneath its own
|
|
608
|
+
// text: no non-descendant layer can paint between the two, and
|
|
609
|
+
// descendant media is already excluded below — so however the page
|
|
610
|
+
// stacks around it, the resolved colour is the presented one. Without
|
|
611
|
+
// this, every below-the-fold category chip painted over a card
|
|
612
|
+
// thumbnail (white-on-red at a flat 3.99:1) sat in the review lane
|
|
613
|
+
// because the offscreen hit-test could not verify what geometry
|
|
614
|
+
// already proves.
|
|
615
|
+
const ownPaint = parseColor(style.backgroundColor);
|
|
616
|
+
if (ownPaint && ownPaint.a >= 1) return null;
|
|
518
617
|
// One box read for the element, then pure rect math per candidate —
|
|
519
618
|
// this runs for every unverified element on the page.
|
|
520
619
|
const box = element.getBoundingClientRect();
|
|
@@ -545,6 +644,17 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
545
644
|
};
|
|
546
645
|
|
|
547
646
|
const ratio = contrastRatio(foreground, background);
|
|
647
|
+
// Bracket the verdict against any unplaceable pseudo paint: only when
|
|
648
|
+
// every colour and placement the film could take leaves the ratio on one
|
|
649
|
+
// side of the threshold is the verdict provable. A 3.5% grain film can
|
|
650
|
+
// never rescue 4.1:1, so that fails outright; a 50% veil could rescue or
|
|
651
|
+
// ruin anything, so it stays the human call it always was.
|
|
652
|
+
if (film > 0) {
|
|
653
|
+
const bounds = filmedContrastBounds(foreground, background, film);
|
|
654
|
+
const decided = (bounds.min >= required) === (bounds.max >= required)
|
|
655
|
+
&& !(bounds.crossed && bounds.min >= required);
|
|
656
|
+
if (!decided) return filmIncomplete;
|
|
657
|
+
}
|
|
548
658
|
// The dimmed state, when a veil is up: it paints over the glyphs and
|
|
549
659
|
// their background alike, so both composite through the same layers.
|
|
550
660
|
// Dimming is not monotonic on ratio (it can rescue or ruin a pairing),
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// WCAG SC 1.4.4 Resize Text (Level AA)
|
|
2
|
+
import { isEmbeddedDocument } from '../../lib/dom.js';
|
|
3
|
+
|
|
2
4
|
export default {
|
|
3
5
|
id: 'meta-viewport',
|
|
4
6
|
impact: 'critical',
|
|
@@ -8,6 +10,10 @@ export default {
|
|
|
8
10
|
selector: 'meta[name="viewport"]',
|
|
9
11
|
visibleOnly: false,
|
|
10
12
|
evaluate(element) {
|
|
13
|
+
// Browsers read the viewport meta of the TOP document only; one inside
|
|
14
|
+
// an embedded frame is ignored wholesale, so it cannot stop anyone
|
|
15
|
+
// zooming and must not be reported as if it did.
|
|
16
|
+
if (isEmbeddedDocument(element.ownerDocument)) return { status: 'pass' };
|
|
11
17
|
const content = element.getAttribute('content') ?? '';
|
|
12
18
|
const disablesZoom = /user-scalable\s*=\s*(no|0)/i.test(content);
|
|
13
19
|
const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// WCAG SC 2.4.1 Bypass Blocks (Level A)
|
|
2
|
-
import { collectRoots } from '../../lib/dom.js';
|
|
2
|
+
import { collectRoots, isEmbeddedDocument } from '../../lib/dom.js';
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
5
|
id: 'bypass-blocks',
|
|
@@ -10,6 +10,11 @@ export default {
|
|
|
10
10
|
selector: 'html',
|
|
11
11
|
visibleOnly: false,
|
|
12
12
|
evaluate(element) {
|
|
13
|
+
// 2.4.1 governs blocks "repeated on multiple Web pages", and a WCAG
|
|
14
|
+
// Web page is a non-embedded resource: an embedded frame's document
|
|
15
|
+
// owes no skip link of its own (the frame's title is itself the bypass
|
|
16
|
+
// the spec names in H64).
|
|
17
|
+
if (isEmbeddedDocument(element.ownerDocument)) return { status: 'pass' };
|
|
13
18
|
// 2.4.1's sufficient techniques: a skip link, landmarks (ARIA11), or
|
|
14
19
|
// headings (H69) — any one mechanism satisfies the criterion. Modern
|
|
15
20
|
// component pages keep ALL of these inside open shadow roots (the
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// Deliberately NOT judged: whether a real-looking title matches the page it
|
|
15
15
|
// sits on. That needs someone who can read the page, and guessing at it
|
|
16
16
|
// would produce exactly the confident-but-wrong findings this engine avoids.
|
|
17
|
+
import { isEmbeddedDocument } from '../../lib/dom.js';
|
|
18
|
+
|
|
17
19
|
const PLACEHOLDER = new Set([
|
|
18
20
|
'untitled document', 'untitled page', 'untitled-1', 'untitled 1',
|
|
19
21
|
'new document', 'new page', 'new tab', 'no title',
|
|
@@ -37,6 +39,11 @@ export default {
|
|
|
37
39
|
selector: 'html',
|
|
38
40
|
visibleOnly: false,
|
|
39
41
|
evaluate(element) {
|
|
42
|
+
// 2.4.2 applies to "Web pages", which the spec defines as non-embedded
|
|
43
|
+
// resources. A widget or ad iframe is part of the page, not a page:
|
|
44
|
+
// demanding a <title> of it invents failures on every embedded frame
|
|
45
|
+
// (its name comes from the iframe's title attribute, judged by 4.1.2).
|
|
46
|
+
if (isEmbeddedDocument(element.ownerDocument)) return { status: 'pass' };
|
|
40
47
|
const title = element.ownerDocument.title.trim();
|
|
41
48
|
if (!title) {
|
|
42
49
|
return {
|
|
@@ -32,6 +32,20 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
|
|
|
32
32
|
function isHiddenFromPointer(element, rect) {
|
|
33
33
|
if (rect.width <= 1 || rect.height <= 1) return true;
|
|
34
34
|
if (rect.right <= 0 || rect.bottom <= 0) return true; // parked above/left of the canvas
|
|
35
|
+
// Parked BEYOND the document's scrollable extent on the other two sides:
|
|
36
|
+
// the slide-in drawer pattern (basket/menu panels held at
|
|
37
|
+
// translateX(100%) until opened). A fixed drawer does not extend the
|
|
38
|
+
// document's scroll range, so no scroll position ever brings the pointer
|
|
39
|
+
// to it — its dozen stacked links are not pointer targets in this state,
|
|
40
|
+
// and they must not crowd the page's real targets either. In-flow content
|
|
41
|
+
// that genuinely overflows DOES extend scrollWidth/scrollHeight and keeps
|
|
42
|
+
// being judged.
|
|
43
|
+
const win = element.ownerDocument.defaultView;
|
|
44
|
+
const scroller = element.ownerDocument.scrollingElement ?? element.ownerDocument.documentElement;
|
|
45
|
+
if (win && scroller) {
|
|
46
|
+
if (rect.left + win.scrollX >= scroller.scrollWidth - 1) return true;
|
|
47
|
+
if (rect.top + win.scrollY >= scroller.scrollHeight - 1) return true;
|
|
48
|
+
}
|
|
35
49
|
const style = getComputedStyle(element);
|
|
36
50
|
if ((parseFloat(style.opacity) || 0) === 0) return true;
|
|
37
51
|
if (style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute')) return true;
|
|
@@ -2,19 +2,27 @@
|
|
|
2
2
|
// element does not support name from author. Authors MUST NOT use the
|
|
3
3
|
// aria-label or aria-labelledby attributes to name the element." A label on
|
|
4
4
|
// a plain div/span/p breaks that MUST, so it is a real, provable authoring
|
|
5
|
-
// error
|
|
6
|
-
// exactly what makes the WCAG verdict undecidable from the DOM.
|
|
5
|
+
// error.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// What browsers DO with the prohibited name was measured, not assumed
|
|
8
|
+
// (2026-08-09, Chromium accessibility tree + VoiceOver on a live product
|
|
9
|
+
// page): both expose aria-label on paragraph/generic/strong as the
|
|
10
|
+
// element's accessible name, and VoiceOver announces it. So the label is
|
|
11
|
+
// NOT reliably dropped — the earlier wording here claimed it was, and a
|
|
12
|
+
// user who tested with a screen reader rightly called it misleading. But
|
|
13
|
+
// prohibited means unguaranteed: exposure of a name the spec forbids is
|
|
14
|
+
// browser goodwill, varies across AT pairings, and may change without
|
|
15
|
+
// notice. A page can neither rely on the label being announced nor on it
|
|
16
|
+
// being ignored — which cuts in both directions and is exactly what makes
|
|
17
|
+
// the WCAG verdict undecidable from the DOM.
|
|
12
18
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// the
|
|
17
|
-
//
|
|
19
|
+
// It is not, on its own, a 4.1.2 failure. A static text container is not a
|
|
20
|
+
// "user interface component", and whatever name, role and value the element
|
|
21
|
+
// legitimately has stays untouched. Where the label IS announced, it
|
|
22
|
+
// replaces the element's visible text for AT users (sometimes deliberately:
|
|
23
|
+
// a price written for the ear over a struck-through visual price); where it
|
|
24
|
+
// is ignored, users get the visible text alone. The human question is
|
|
25
|
+
// whether either presentation is wrong for this element.
|
|
18
26
|
//
|
|
19
27
|
// This rule has lived on both sides of the line. It asserted under WCAG
|
|
20
28
|
// scope originally, was demoted to best-practice when an independent audit
|
|
@@ -36,7 +44,7 @@ export default {
|
|
|
36
44
|
id: 'aria-label-misuse',
|
|
37
45
|
impact: 'moderate',
|
|
38
46
|
tags: ['wcag2a', 'wcag412'],
|
|
39
|
-
help: 'aria-label on a
|
|
47
|
+
help: 'aria-label on a plain container is prohibited by ARIA, so its announcement is not guaranteed',
|
|
40
48
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
41
49
|
selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex])',
|
|
42
50
|
evaluate(element) {
|
|
@@ -48,8 +56,8 @@ export default {
|
|
|
48
56
|
if (element.closest(NAME_FROM_CONTENT)) return { status: 'pass' };
|
|
49
57
|
return {
|
|
50
58
|
status: 'incomplete',
|
|
51
|
-
message: `aria-label="${element.getAttribute('aria-label')}" on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA
|
|
52
|
-
fix: 'If
|
|
59
|
+
message: `aria-label="${element.getAttribute('aria-label')}" on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard.`,
|
|
60
|
+
fix: 'If this element must reliably announce something of its own, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposure is guaranteed — or move the spoken text into visible or visually-hidden real text. If the label is stray, remove it. Do not add a role solely to legalise the label.',
|
|
53
61
|
};
|
|
54
62
|
},
|
|
55
63
|
};
|
package/package.json
CHANGED