pour-engine 1.3.1 → 1.4.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/README.md +7 -6
- package/engine/index.js +1 -1
- package/engine/lib/contrast.js +26 -6
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +7 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +48 -8
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +19 -3
- package/engine/rules/wcag/2.5.8-target-size.js +89 -4
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/README.md
CHANGED
|
@@ -102,14 +102,15 @@ pour engine therefore reports on **every** in-scope criterion, one of two ways:
|
|
|
102
102
|
1. **Automated/partial** — the rules in `rules/`.
|
|
103
103
|
2. **Manual review** — every audit's results include `manualReview`: the
|
|
104
104
|
in-scope criteria (filtered by the requested WCAG version and level)
|
|
105
|
-
that automation can't judge,
|
|
105
|
+
that automation can't judge, for a host to render however it wants.
|
|
106
106
|
|
|
107
107
|
Nothing in the spec is silently skipped — that claim is the product.
|
|
108
108
|
|
|
109
109
|
## Honest current state
|
|
110
110
|
|
|
111
|
-
72 rules, hardened on real sites and the `testbeds/` pages.
|
|
112
|
-
shadow
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
72 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
|
|
112
|
+
open shadow roots (targets come back as `host >>> inner` selector chains);
|
|
113
|
+
iframe traversal is the host's job, and the extension does it. Known gaps:
|
|
114
|
+
the full accname spec (ours is a close approximation) and the language
|
|
115
|
+
subtag registry (BCP 47 shape check only). Grow it rule by rule; the test
|
|
116
|
+
beds' `expect:` annotations and the spec cases are the regression suite.
|
package/engine/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Engine runner: evaluates every applicable rule against the page and
|
|
2
2
|
// returns structured results (violations / passes / incomplete /
|
|
3
3
|
// inapplicable, plus the manual-review criteria and per-rule timings).
|
|
4
4
|
import config from '../config/project.config.js';
|
package/engine/lib/contrast.js
CHANGED
|
@@ -835,6 +835,11 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
835
835
|
const data = ctx.getImageData(0, 0, size, size).data;
|
|
836
836
|
let min = 1;
|
|
837
837
|
let max = 0;
|
|
838
|
+
// The colours at those extremes, kept for the same reason the
|
|
839
|
+
// gradient range keeps them: translucent text blends with the actual
|
|
840
|
+
// pixels, and a luminance cannot be un-gamma'd back into an rgb.
|
|
841
|
+
let minColor = null;
|
|
842
|
+
let maxColor = null;
|
|
838
843
|
let opaquePixels = 0;
|
|
839
844
|
for (let i = 0; i < data.length; i += 4) {
|
|
840
845
|
if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
|
|
@@ -843,9 +848,10 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
843
848
|
// and the text before measuring: a photo under a 75% black scrim is
|
|
844
849
|
// seen as the blend, never as the photo.
|
|
845
850
|
const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a: 1 };
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
if (l
|
|
851
|
+
const shown = overlays.length ? applyOverlays(pixel, overlays) : pixel;
|
|
852
|
+
const l = luminance(shown);
|
|
853
|
+
if (l < min) { min = l; minColor = shown; }
|
|
854
|
+
if (l > max) { max = l; maxColor = shown; }
|
|
849
855
|
}
|
|
850
856
|
// Not one opaque pixel: a spacer, a cleared sprite, or a decorative
|
|
851
857
|
// layer left in place with nothing in it. It covers the background
|
|
@@ -862,7 +868,9 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
862
868
|
resolve({ transparent: true, width: img.naturalWidth, height: img.naturalHeight });
|
|
863
869
|
return;
|
|
864
870
|
}
|
|
865
|
-
resolve(min <= max
|
|
871
|
+
resolve(min <= max
|
|
872
|
+
? { min, max, minColor, maxColor, width: img.naturalWidth, height: img.naturalHeight }
|
|
873
|
+
: null);
|
|
866
874
|
} catch {
|
|
867
875
|
resolve(null); // tainted canvas: cross-origin image without CORS headers
|
|
868
876
|
}
|
|
@@ -921,8 +929,20 @@ export function backgroundImagePaintRect(element, intrinsic) {
|
|
|
921
929
|
export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
|
|
922
930
|
const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
|
|
923
931
|
if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
|
|
924
|
-
const
|
|
925
|
-
|
|
932
|
+
const composited = stops.map((stop) => (overlays.length ? applyOverlays(stop, overlays) : stop));
|
|
933
|
+
const lums = composited.map(luminance);
|
|
934
|
+
const min = Math.min(...lums);
|
|
935
|
+
const max = Math.max(...lums);
|
|
936
|
+
// The COLOURS at the extremes, not just their luminances: translucent text
|
|
937
|
+
// takes its presented colour from the pixels beneath, so bracketing that
|
|
938
|
+
// blend needs the actual rgb to composite against, and luminance alone
|
|
939
|
+
// cannot be un-gamma'd back into one.
|
|
940
|
+
return {
|
|
941
|
+
min,
|
|
942
|
+
max,
|
|
943
|
+
minColor: composited[lums.indexOf(min)],
|
|
944
|
+
maxColor: composited[lums.indexOf(max)],
|
|
945
|
+
};
|
|
926
946
|
}
|
|
927
947
|
|
|
928
948
|
/** Contrast ratio from two luminances. */
|
|
@@ -79,9 +79,14 @@ export default {
|
|
|
79
79
|
}
|
|
80
80
|
return {
|
|
81
81
|
status: 'fail',
|
|
82
|
+
// The measured fact is the ratio against G183's threshold, and that is
|
|
83
|
+
// all this says. Naming a group who "can't see it" overclaims: a
|
|
84
|
+
// saturated red against near-white misses 3:1 on luminance while
|
|
85
|
+
// staying perfectly distinct to most people, including most red-green
|
|
86
|
+
// colour blindness, where the red darkens and the gap widens.
|
|
82
87
|
message: ratio < 1.01
|
|
83
|
-
? 'This link has no underline and the same colour as the surrounding text
|
|
84
|
-
: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text
|
|
88
|
+
? 'This link has no underline and the same colour as the surrounding text, so there is nothing at all to mark it as a link.'
|
|
89
|
+
: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text, below the 3:1 WCAG technique G183 asks for when colour is the only thing marking a link.`,
|
|
85
90
|
fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
|
|
86
91
|
};
|
|
87
92
|
},
|
|
@@ -78,9 +78,26 @@ async function imageVsText(imageSource, element, doc) {
|
|
|
78
78
|
// paint rect must not use it (icon SVGs would "cover" all the text).
|
|
79
79
|
const dimensionless = !intrinsic || !intrinsic.width
|
|
80
80
|
|| (intrinsic.width === 300 && intrinsic.height === 150);
|
|
81
|
+
// A gradient has no intrinsic size and never will, but that is not the
|
|
82
|
+
// same as an unknown paint area: with the default background-size it fills
|
|
83
|
+
// its carrier's painting box exactly. Lumping it in with "we cannot tell
|
|
84
|
+
// how big this is" meant a gradient could never earn a definite verdict,
|
|
85
|
+
// however large the panel it covers. Its scale is the carrier's, unless
|
|
86
|
+
// background-size names something small, which is the one way a gradient
|
|
87
|
+
// really is icon-scale (a tiny repeated swatch).
|
|
88
|
+
const isGradient = !url && imageSource.css.includes('gradient(');
|
|
89
|
+
let sizedSmall = false;
|
|
90
|
+
if (isGradient) {
|
|
91
|
+
const size = getComputedStyle(imageSource.element).backgroundSize ?? '';
|
|
92
|
+
const px = [...size.matchAll(/(\d+(?:\.\d+)?)px/g)].map((m) => parseFloat(m[1]));
|
|
93
|
+
sizedSmall = px.length > 0 && px.every((value) => value <= 40);
|
|
94
|
+
}
|
|
81
95
|
const paint = backgroundImagePaintRect(imageSource.element, dimensionless ? null : intrinsic);
|
|
82
|
-
if (!paint) return { relation: 'unknown', intrinsic, dimensionless };
|
|
83
|
-
return {
|
|
96
|
+
if (!paint) return { relation: 'unknown', intrinsic, dimensionless, isGradient, sizedSmall };
|
|
97
|
+
return {
|
|
98
|
+
relation: textIntersects(element, paint) ? 'under' : 'clear',
|
|
99
|
+
intrinsic, dimensionless, isGradient, sizedSmall,
|
|
100
|
+
};
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
/**
|
|
@@ -188,10 +205,31 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [])
|
|
|
188
205
|
// when the layer paints nothing.
|
|
189
206
|
if (range?.transparent) return PAINTS_NOTHING;
|
|
190
207
|
// Translucent text takes its presented colour from the very pixels
|
|
191
|
-
// beneath it
|
|
192
|
-
//
|
|
193
|
-
//
|
|
208
|
+
// beneath it, so a luminance range of the backdrop cannot be read straight
|
|
209
|
+
// off: dimmed white over a dark photo reads mid-grey, not white.
|
|
210
|
+
//
|
|
211
|
+
// It CAN still be bracketed, by compositing the text over each end of the
|
|
212
|
+
// backdrop's range and judging each blend against the backdrop it was
|
|
213
|
+
// blended with. That bracket is sound in one direction only. Ratio is not
|
|
214
|
+
// monotonic in backdrop luminance — as the composited text passes through
|
|
215
|
+
// the backdrop's own luminance the ratio collapses toward 1:1 — so an
|
|
216
|
+
// interior point can be WORSE than both ends but never better. Both ends
|
|
217
|
+
// failing therefore proves the whole range fails; both ends passing proves
|
|
218
|
+
// nothing about the middle, and stays a human call.
|
|
194
219
|
if (foreground.a < 1) {
|
|
220
|
+
const ends = [range?.minColor, range?.maxColor].filter(Boolean);
|
|
221
|
+
if (ends.length === 2) {
|
|
222
|
+
const ratios = ends.map((backdrop) => contrastRatio(composite(foreground, backdrop), backdrop));
|
|
223
|
+
if (ratios.every((ratio) => ratio < required)) {
|
|
224
|
+
const best = Math.max(...ratios);
|
|
225
|
+
return {
|
|
226
|
+
status: 'fail',
|
|
227
|
+
message: `This text is translucent, so it blends with what is behind it: against every part of that backdrop the blend reaches at best ${showRatio(best)}:1, below the ${required}:1 minimum.`,
|
|
228
|
+
fix: 'Raise the text opacity, change its colour, or put a solid layer between the text and the backdrop.',
|
|
229
|
+
data: { ratio: Number(showRatio(best)), required },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
195
233
|
return {
|
|
196
234
|
status: 'incomplete',
|
|
197
235
|
message: 'This text is translucent over an image or gradient, so its presented colour depends on the pixels beneath — contrast must be checked by eye.',
|
|
@@ -297,7 +335,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
297
335
|
// images (external-link arrows etc.) don't affect the text's contrast.
|
|
298
336
|
const imageSource = backgroundImageSource(element);
|
|
299
337
|
if (imageSource) {
|
|
300
|
-
const { relation, intrinsic, dimensionless } = await imageVsText(imageSource, element, doc);
|
|
338
|
+
const { relation, intrinsic, dimensionless, isGradient, sizedSmall } = await imageVsText(imageSource, element, doc);
|
|
301
339
|
if (relation !== 'clear') {
|
|
302
340
|
// imageSource.overlays are the translucent layers painted between the
|
|
303
341
|
// image and this text (hero scrims, tint panels). Judging the raw
|
|
@@ -325,8 +363,10 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
325
363
|
// inline link is still an icon). Only backdrop-scale images earn a
|
|
326
364
|
// definite fail from sampling.
|
|
327
365
|
const carrierBox = imageSource.element.getBoundingClientRect();
|
|
328
|
-
|
|
329
|
-
|
|
366
|
+
const iconScale = isGradient
|
|
367
|
+
? (sizedSmall || carrierBox.height <= 40)
|
|
368
|
+
: (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40);
|
|
369
|
+
if (sampled?.status === 'fail' && iconScale) {
|
|
330
370
|
return {
|
|
331
371
|
status: 'incomplete',
|
|
332
372
|
message: 'A small background icon is painted on this element — if it sits beside the text rather than under it, judge the contrast against the plain background by eye.',
|
|
@@ -39,10 +39,26 @@ export default {
|
|
|
39
39
|
// including content slotted in from the light DOM and content inside
|
|
40
40
|
// open shadow roots (carousels built as web components).
|
|
41
41
|
if (hasFocusableContent(element)) return { status: 'pass' };
|
|
42
|
+
// NOT a failure, because the browser may already have solved it.
|
|
43
|
+
//
|
|
44
|
+
// Chromium puts a scroll container in the tab order by itself when the
|
|
45
|
+
// container holds nothing focusable — and that condition is exactly the
|
|
46
|
+
// one this rule fires on, so in Chrome every element reaching this line
|
|
47
|
+
// IS keyboard reachable. Measured, not assumed: Chrome 151 tabs
|
|
48
|
+
// before → the scroller → after, while `element.tabIndex` still reports
|
|
49
|
+
// -1, which is why the check above cannot see it.
|
|
50
|
+
//
|
|
51
|
+
// It is not settled across engines though. MDN still advises the
|
|
52
|
+
// tabindex ("in some browsers, scrolling content areas are not
|
|
53
|
+
// keyboard-focusable"), and WebKit did not take focus here. So the
|
|
54
|
+
// honest report is the fact plus the split, not a verdict: asserting a
|
|
55
|
+
// Level A failure would be wrong for anyone on Chrome, and asserting a
|
|
56
|
+
// pass would be wrong for anyone who has to support the browsers that
|
|
57
|
+
// don't.
|
|
42
58
|
return {
|
|
43
|
-
status: '
|
|
44
|
-
message: 'This region scrolls
|
|
45
|
-
fix: '
|
|
59
|
+
status: 'incomplete',
|
|
60
|
+
message: 'This region scrolls and holds nothing focusable, so reaching its overflowed content depends on the browser. Chromium gives such containers keyboard focus automatically; not every engine does. Tab to it in the browsers you support, and add tabindex="0" if it cannot be reached.',
|
|
61
|
+
fix: 'If you support browsers that do not focus scroll containers, add tabindex="0" (plus role="region" and an aria-label describing it).',
|
|
46
62
|
};
|
|
47
63
|
},
|
|
48
64
|
};
|
|
@@ -38,6 +38,68 @@ function isHiddenFromPointer(element, rect) {
|
|
|
38
38
|
return false;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Is this element the thing a click at that point would actually reach, or
|
|
43
|
+
* is something painted over it there?
|
|
44
|
+
*
|
|
45
|
+
* The spacing exception is about targets COMPETING for pointer space, and
|
|
46
|
+
* two targets that can never be hit at the same place do not compete. On any
|
|
47
|
+
* page with a fixed header, scrolling slides ordinary content underneath it:
|
|
48
|
+
* a footer link can end up geometrically 3px from a header button while
|
|
49
|
+
* being completely unclickable there, and counting it as a crowder invents a
|
|
50
|
+
* failure out of the scroll position. That was reported on pour's own site.
|
|
51
|
+
*
|
|
52
|
+
* Only asked about targets that are ALREADY crowding an undersized one, so
|
|
53
|
+
* the hit-test cost is paid on the handful of candidate failures rather than
|
|
54
|
+
* on every target of the page.
|
|
55
|
+
*
|
|
56
|
+
* Out-of-viewport points can't be hit-tested at all: elementsFromPoint is
|
|
57
|
+
* viewport-relative and returns nothing outside it. There the honest answer
|
|
58
|
+
* is "don't know", and don't-know must not silence a finding, so it counts
|
|
59
|
+
* as reachable.
|
|
60
|
+
*/
|
|
61
|
+
function reachableRects(element, rect) {
|
|
62
|
+
const doc = element.ownerDocument;
|
|
63
|
+
const win = doc.defaultView;
|
|
64
|
+
if (!win || typeof doc.elementsFromPoint !== 'function') return [rect];
|
|
65
|
+
// Per LINE FRAGMENT, never the bounding box. An inline link that wraps has
|
|
66
|
+
// one fragment per line, and the box enclosing them is mostly space the
|
|
67
|
+
// element never paints: its middle falls in the leading BETWEEN the lines,
|
|
68
|
+
// where elementsFromPoint does not contain the element at all. A
|
|
69
|
+
// box-centre probe therefore reads "not hit-testable" for every wrapped
|
|
70
|
+
// link. (Same geometry trap as link-in-text-block's own-line test.)
|
|
71
|
+
//
|
|
72
|
+
// Fragments also make the answer usefully partial. A footer link scrolled
|
|
73
|
+
// half under a fixed header has its first line buried and its second line
|
|
74
|
+
// perfectly clickable, so the honest result is not "reachable" or
|
|
75
|
+
// "covered" but WHICH PART is reachable — and only that part can compete
|
|
76
|
+
// for pointer space with anything nearby.
|
|
77
|
+
const fragments = [...element.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
|
78
|
+
const probes = fragments.length ? fragments : [rect];
|
|
79
|
+
const reachable = [];
|
|
80
|
+
let testable = false;
|
|
81
|
+
for (const fragment of probes) {
|
|
82
|
+
const x = fragment.left + fragment.width / 2;
|
|
83
|
+
const y = fragment.top + fragment.height / 2;
|
|
84
|
+
if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) continue;
|
|
85
|
+
const stack = doc.elementsFromPoint(x, y);
|
|
86
|
+
const index = stack.indexOf(element);
|
|
87
|
+
if (index < 0) continue; // this fragment isn't hit-testable; try the next
|
|
88
|
+
testable = true;
|
|
89
|
+
// Anything above it that is part of the same control is not an
|
|
90
|
+
// obstruction: a button's own icon paints over the button, an overlay
|
|
91
|
+
// label over its input. Only a genuinely separate element blocks it.
|
|
92
|
+
if (stack.slice(0, index).every((layer) => layer.contains(element) || element.contains(layer))) {
|
|
93
|
+
reachable.push(fragment);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Nothing could be probed at all (entirely out of viewport,
|
|
97
|
+
// pointer-events: none, nothing laid out): don't know, and don't-know must
|
|
98
|
+
// not silence a finding, so the whole rect stands.
|
|
99
|
+
if (!testable) return [rect];
|
|
100
|
+
return reachable;
|
|
101
|
+
}
|
|
102
|
+
|
|
41
103
|
/** Text this element contributes that is NOT inside a target — i.e. does it
|
|
42
104
|
* read as prose beside the link, or is it just another control in a list? */
|
|
43
105
|
function textOutsideTargets(element) {
|
|
@@ -146,14 +208,37 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
146
208
|
// Spacing exception: the min-sized circle around this target must
|
|
147
209
|
// clear every other target's rect and every other undersized
|
|
148
210
|
// target's circle.
|
|
149
|
-
const
|
|
211
|
+
const crowds = (other, j) => {
|
|
150
212
|
if (j === i || !laidOut[j]) return false;
|
|
151
213
|
if (other.contains(element) || element.contains(other)) return false; // same control, nested markup
|
|
152
214
|
if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) return false;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
215
|
+
const within = (box) => {
|
|
216
|
+
if (undersized[j]) {
|
|
217
|
+
const c = { x: box.left + box.width / 2, y: box.top + box.height / 2 };
|
|
218
|
+
if (Math.hypot(c.x - centers[i].x, c.y - centers[i].y) < min) return true;
|
|
219
|
+
}
|
|
220
|
+
return pointToRectDistance(centers[i], box) < min / 2;
|
|
221
|
+
};
|
|
222
|
+
// Whole-rect test first, as a cheap superset: no part of a target
|
|
223
|
+
// can be closer than the box enclosing it, so a box that doesn't
|
|
224
|
+
// reach rules the target out without any hit-testing. Only the
|
|
225
|
+
// survivors pay for the paint-order queries, which on a page of
|
|
226
|
+
// hundreds of targets is a handful.
|
|
227
|
+
if (!within(rects[j])) return false;
|
|
228
|
+
// Then the honest test: only the parts a pointer can actually
|
|
229
|
+
// reach compete for pointer space. A link whose first line is
|
|
230
|
+
// buried under a fixed header and whose second line is clear is
|
|
231
|
+
// crowding from its second line or not at all.
|
|
232
|
+
return reachableRects(other, rects[j]).some(within);
|
|
233
|
+
};
|
|
234
|
+
const crowded = elements.some(crowds);
|
|
156
235
|
if (!crowded) return { status: 'pass' };
|
|
236
|
+
// Deliberately NOT applied to the target itself. A crowder that
|
|
237
|
+
// can't be clicked breaks the premise of the exception, because it
|
|
238
|
+
// isn't competing for anything. A target that happens to be under a
|
|
239
|
+
// cookie banner right now is still 20×20 the moment that banner is
|
|
240
|
+
// dismissed, and suppressing it would hide a real defect behind a
|
|
241
|
+
// transient overlay, which is the wrong way round.
|
|
157
242
|
}
|
|
158
243
|
const rect = rects[i];
|
|
159
244
|
return {
|
package/package.json
CHANGED