pour-engine 1.15.0 → 1.17.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 +70 -12
- package/engine/lib/dom.js +11 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +22 -1
- package/engine/rules/wcag/1.4.3-color-contrast.js +94 -10
- 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-field-name.js +7 -3
- 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
|
@@ -525,8 +525,14 @@ function pseudoLayers(host) {
|
|
|
525
525
|
// Gradient and image paint rides along as CSS so callers can sample it,
|
|
526
526
|
// the same way they sample an element's background-image — a gradient
|
|
527
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.
|
|
528
531
|
const imageCss = style.backgroundImage !== 'none' ? style.backgroundImage : null;
|
|
529
|
-
|
|
532
|
+
const imageMeta = imageCss
|
|
533
|
+
? { repeat: style.backgroundRepeat, size: style.backgroundSize, position: style.backgroundPosition }
|
|
534
|
+
: null;
|
|
535
|
+
layers.push({ rect, color: layerColor, imageCss, imageMeta });
|
|
530
536
|
}
|
|
531
537
|
pseudoCache.set(host, layers);
|
|
532
538
|
return layers;
|
|
@@ -574,8 +580,11 @@ export function pseudoBackdropForText(element) {
|
|
|
574
580
|
const { rect, color, imageCss } = layer;
|
|
575
581
|
if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
|
|
576
582
|
// Gradient/image paint is bracketable by sampling, so hand it to the
|
|
577
|
-
// caller rather than giving up on the whole element.
|
|
578
|
-
|
|
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; }
|
|
579
588
|
if (!color || color.a === 0) continue;
|
|
580
589
|
acc = acc ? composite(acc, color) : color;
|
|
581
590
|
if (acc.a >= 1) settled = true;
|
|
@@ -945,7 +954,13 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
945
954
|
let minColor = null;
|
|
946
955
|
let maxColor = null;
|
|
947
956
|
let opaquePixels = 0;
|
|
957
|
+
let alphaSeen = false;
|
|
948
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;
|
|
949
964
|
if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
|
|
950
965
|
opaquePixels += 1;
|
|
951
966
|
// Composite each pixel through anything painted between the image
|
|
@@ -973,7 +988,7 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
973
988
|
return;
|
|
974
989
|
}
|
|
975
990
|
resolve(min <= max
|
|
976
|
-
? { min, max, minColor, maxColor, width: img.naturalWidth, height: img.naturalHeight }
|
|
991
|
+
? { min, max, minColor, maxColor, hasAlpha: alphaSeen, width: img.naturalWidth, height: img.naturalHeight }
|
|
977
992
|
: null);
|
|
978
993
|
} catch {
|
|
979
994
|
resolve(null); // tainted canvas: cross-origin image without CORS headers
|
|
@@ -1090,17 +1105,34 @@ function splitLayers(value) {
|
|
|
1090
1105
|
export function backgroundImagePaintRect(element, intrinsic) {
|
|
1091
1106
|
const style = getComputedStyle(element);
|
|
1092
1107
|
if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
|
|
1093
|
-
if (style.backgroundRepeat !== 'no-repeat') return null; // may tile under the text
|
|
1094
|
-
if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') return null;
|
|
1095
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;
|
|
1096
1128
|
const dimension = (value, total, auto) => {
|
|
1097
1129
|
if (value?.endsWith('px')) return parseFloat(value);
|
|
1098
1130
|
if (value?.endsWith('%')) return (parseFloat(value) / 100) * total;
|
|
1099
1131
|
return auto;
|
|
1100
1132
|
};
|
|
1101
|
-
const size =
|
|
1102
|
-
const width = dimension(size[0],
|
|
1103
|
-
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);
|
|
1104
1136
|
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
1105
1137
|
// position N% aligns the image's N% point with the container's N% point.
|
|
1106
1138
|
// Anything else (calc() from three-value syntax, keywords) is not
|
|
@@ -1111,15 +1143,41 @@ export function backgroundImagePaintRect(element, intrinsic) {
|
|
|
1111
1143
|
if (value?.endsWith('px')) return parseFloat(value);
|
|
1112
1144
|
return null;
|
|
1113
1145
|
};
|
|
1114
|
-
const pos =
|
|
1115
|
-
const offsetX = offset(pos[0],
|
|
1116
|
-
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);
|
|
1117
1149
|
if (offsetX === null || offsetY === null || Number.isNaN(offsetX) || Number.isNaN(offsetY)) return null;
|
|
1118
1150
|
const left = box.left + offsetX;
|
|
1119
1151
|
const top = box.top + offsetY;
|
|
1120
1152
|
return { left, top, right: left + width, bottom: top + height };
|
|
1121
1153
|
}
|
|
1122
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
|
+
|
|
1123
1181
|
/**
|
|
1124
1182
|
* Luminance range of a CSS gradient, from its colour stops. Interpolated
|
|
1125
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]
|
|
@@ -52,7 +52,28 @@ export default {
|
|
|
52
52
|
return !hasStrayText && effectiveChildren(child).every(isValidChild);
|
|
53
53
|
};
|
|
54
54
|
const invalid = effectiveChildren(element).filter((child) => !isValidChild(child));
|
|
55
|
-
if (!invalid.length)
|
|
55
|
+
if (!invalid.length) {
|
|
56
|
+
// Every rendered item neutralised: role="none" on each <li> strips
|
|
57
|
+
// its listitem semantics while the <ul> keeps announcing "list" —
|
|
58
|
+
// Chromium exposes the list role over the none children (measured
|
|
59
|
+
// 2026-08-09), so readers hear a list that owns zero items around
|
|
60
|
+
// content they can plainly see. That is the same broken announcement
|
|
61
|
+
// as <ul><div>, reached via a different door. A PARTIAL
|
|
62
|
+
// neutralisation stays valid: hiding a decorative separator item
|
|
63
|
+
// from the count is what role="none" on an <li> is for.
|
|
64
|
+
const renderedItems = effectiveChildren(element).filter((child) =>
|
|
65
|
+
child.tagName === 'LI' && (!isRendered || isRendered(child)));
|
|
66
|
+
const neutralised = renderedItems.filter((child) =>
|
|
67
|
+
['presentation', 'none'].includes(child.getAttribute('role') ?? ''));
|
|
68
|
+
if (renderedItems.length && neutralised.length === renderedItems.length) {
|
|
69
|
+
return {
|
|
70
|
+
status: 'fail',
|
|
71
|
+
message: `Every item in this list carries role="${neutralised[0].getAttribute('role')}", so the list announces itself with zero items while ${renderedItems.length} are visible — screen readers lose the count and the positions.`,
|
|
72
|
+
fix: 'Remove the role from the <li> elements, or neutralise the whole structure by putting role="presentation" (or the intended widget role) on the list element itself.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { status: 'pass' };
|
|
76
|
+
}
|
|
56
77
|
const tags = [...new Set(invalid.map((child) => {
|
|
57
78
|
const role = child.getAttribute('role');
|
|
58
79
|
return `<${child.tagName.toLowerCase()}${role ? ` role="${role}"` : ''}>`;
|
|
@@ -1,8 +1,9 @@
|
|
|
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
8
|
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
8
9
|
} from '../../lib/contrast.js';
|
|
@@ -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.
|
|
@@ -440,7 +478,32 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
440
478
|
// genuine 4.1:1 failure drowned among 163 siblings that were fine.
|
|
441
479
|
const pseudoResolved = pseudoBackdropForText(element);
|
|
442
480
|
const film = pseudoResolved?.film ?? 0;
|
|
443
|
-
|
|
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
|
+
}
|
|
444
507
|
const filmIncomplete = {
|
|
445
508
|
status: 'incomplete',
|
|
446
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.',
|
|
@@ -460,10 +523,21 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
460
523
|
// Gradient/image paint from a pseudo-element: bracket it by sampling,
|
|
461
524
|
// exactly as an element's own background-image is bracketed.
|
|
462
525
|
if (pseudoBack?.image) {
|
|
463
|
-
|
|
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));
|
|
464
532
|
// PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
|
|
465
533
|
// it never displaced the background the walk resolved.
|
|
466
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
|
+
}
|
|
467
541
|
return sampled ?? {
|
|
468
542
|
status: 'incomplete',
|
|
469
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.',
|
|
@@ -530,6 +604,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
530
604
|
// gradient canvas under its hero heading is exactly this shape), so
|
|
531
605
|
// even verified verdicts must be bracketed against them.
|
|
532
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;
|
|
533
617
|
// One box read for the element, then pure rect math per candidate —
|
|
534
618
|
// this runs for every unverified element on the page.
|
|
535
619
|
const box = element.getBoundingClientRect();
|
|
@@ -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;
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
// naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
|
|
8
8
|
import { labelledByName } from '../../lib/accessible-name.js';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// progressbar and meter belong here too: ARIA 1.2 marks both roles
|
|
11
|
+
// "Accessible Name Required: True", they never name from content, and a
|
|
12
|
+
// nameless one announces a bare value — "25%, progress bar" — of nothing
|
|
13
|
+
// in particular (found live: unlabelled ant-progress widgets).
|
|
14
|
+
const AUTHOR_ONLY = ['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'slider', 'progressbar', 'meter'];
|
|
11
15
|
const FROM_CONTENT = ['checkbox', 'radio', 'switch'];
|
|
12
16
|
|
|
13
17
|
function authorName(element) {
|
|
@@ -21,7 +25,7 @@ export default {
|
|
|
21
25
|
id: 'aria-field-name',
|
|
22
26
|
impact: 'serious',
|
|
23
27
|
tags: ['wcag2a', 'wcag412'],
|
|
24
|
-
help: 'ARIA
|
|
28
|
+
help: 'ARIA fields and value widgets must have an accessible name',
|
|
25
29
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
26
30
|
selector: [...AUTHOR_ONLY, ...FROM_CONTENT]
|
|
27
31
|
.map((role) => `[role="${role}"]:not(input):not(select):not(textarea)`)
|
|
@@ -42,7 +46,7 @@ export default {
|
|
|
42
46
|
}
|
|
43
47
|
return {
|
|
44
48
|
status: 'fail',
|
|
45
|
-
message: `This role="${role}"
|
|
49
|
+
message: `This role="${role}" control has no accessible name — screen readers announce the role and value with no idea what it's for.${
|
|
46
50
|
AUTHOR_ONLY.includes(role) ? ' (Content inside it does not count as a name for this role.)' : ''}`,
|
|
47
51
|
fix: 'Add aria-label="…" or aria-labelledby pointing at its visible label.',
|
|
48
52
|
};
|
|
@@ -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