pour-engine 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/project.config.js +1 -1
- package/engine/COVERAGE.md +3 -3
- package/engine/README.md +7 -6
- package/engine/index.js +1 -1
- package/engine/lib/contrast.js +26 -6
- package/engine/rules/index.js +4 -0
- 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.4.4-link-text-generic.js +80 -0
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +26 -0
- package/engine/rules/wcag/2.5.8-target-size.js +89 -4
- package/engine/wcag22.js +1 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -62,12 +62,12 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
62
62
|
| 2.4.1 | [Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Partially automated | bypass-blocks |
|
|
63
63
|
| 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Partially automated | document-title |
|
|
64
64
|
| 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Manual review | — |
|
|
65
|
-
| 2.4.4 | [Link Purpose (In Context)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html) | A | Partially automated | area-alt, link-name |
|
|
65
|
+
| 2.4.4 | [Link Purpose (In Context)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html) | A | Partially automated | area-alt, link-name, link-text-generic |
|
|
66
66
|
| 2.4.5 | [Multiple Ways](https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html) | AA | Manual review | — |
|
|
67
67
|
| 2.4.6 | [Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) | AA | Manual review | — |
|
|
68
68
|
| 2.4.7 | [Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html) | AA | Partially automated | focus-visible |
|
|
69
69
|
| 2.4.8 | [Location](https://www.w3.org/WAI/WCAG22/Understanding/location.html) | AAA | Manual review | — |
|
|
70
|
-
| 2.4.9 | [Link Purpose (Link Only)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html) | AAA |
|
|
70
|
+
| 2.4.9 | [Link Purpose (Link Only)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html) | AAA | Partially automated | link-text-generic-only |
|
|
71
71
|
| 2.4.10 | [Section Headings](https://www.w3.org/WAI/WCAG22/Understanding/section-headings.html) | AAA | Manual review | — |
|
|
72
72
|
| 2.4.11 | [Focus Not Obscured (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html) | AA | Partially automated | focus-not-obscured |
|
|
73
73
|
| 2.4.12 | [Focus Not Obscured (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html) | AAA | Manual review | — |
|
|
@@ -114,4 +114,4 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
114
114
|
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
|
|
115
115
|
| 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
|
|
116
116
|
|
|
117
|
-
**Totals:**
|
|
117
|
+
**Totals:** 30 partially automated · 56 manual — with 74 rules.
|
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. */
|
package/engine/rules/index.js
CHANGED
|
@@ -15,6 +15,8 @@ import imageAlt from './wcag/1.1.1-image-alt.js';
|
|
|
15
15
|
import svgImgAlt from './wcag/1.1.1-svg-img-alt.js';
|
|
16
16
|
import buttonName from './wcag/4.1.2-button-name.js';
|
|
17
17
|
import linkName from './wcag/2.4.4-link-name.js';
|
|
18
|
+
import linkTextGeneric from './wcag/2.4.4-link-text-generic.js';
|
|
19
|
+
import linkTextGenericOnly from './wcag/2.4.9-link-text-generic-only.js';
|
|
18
20
|
// Re-parked by product decision (2026-08-01, David, after a one-day
|
|
19
21
|
// un-parking): 2.5.3 is normative Level A but chiefly serves speech-input
|
|
20
22
|
// users, and the findings volume isn't wanted in results for now. The SC is
|
|
@@ -113,6 +115,8 @@ export default [
|
|
|
113
115
|
svgImgAlt,
|
|
114
116
|
buttonName,
|
|
115
117
|
linkName,
|
|
118
|
+
linkTextGeneric,
|
|
119
|
+
linkTextGenericOnly,
|
|
116
120
|
// labelInName, — re-parked 2026-08-01, see note at the import
|
|
117
121
|
formLabel,
|
|
118
122
|
autocompleteValid,
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// WCAG SC 2.4.4 Link Purpose (In Context) (Level A)
|
|
2
|
+
// · SC 2.4.9 Link Purpose (Link Only) (Level AAA)
|
|
3
|
+
//
|
|
4
|
+
// A link named "Read more" carries no purpose in its name. Screen reader
|
|
5
|
+
// users routinely pull up a list of every link on a page and navigate from
|
|
6
|
+
// it, and nine identical "Read more" entries in that list are worthless —
|
|
7
|
+
// the technique WCAG names for this is F63, and G91 is the fix.
|
|
8
|
+
//
|
|
9
|
+
// The two criteria differ in exactly one way, and it decides the verdict:
|
|
10
|
+
//
|
|
11
|
+
// 2.4.4 (A) purpose may come from CONTEXT — the sentence around the
|
|
12
|
+
// link, its list item, its table cell, the heading of the card
|
|
13
|
+
// it sits in. A "Read more" under a headline naming the
|
|
14
|
+
// article is a judgment call about whether that context really
|
|
15
|
+
// reaches the user, so this REVIEWS. It never asserts.
|
|
16
|
+
// 2.4.9 (AAA) purpose must come from the link text ALONE, with the only
|
|
17
|
+
// exception being links whose purpose "would be ambiguous to
|
|
18
|
+
// users in general". A link named nothing but "Read more" is
|
|
19
|
+
// ambiguous to everyone, which is the exception failing to
|
|
20
|
+
// apply rather than saving it, so this FAILS.
|
|
21
|
+
//
|
|
22
|
+
// Judged on the ACCESSIBLE NAME, not the text content: a link written as
|
|
23
|
+
// <a href="…" aria-label="Read more about the budget">Read more</a> is
|
|
24
|
+
// already fixed, and must not be flagged. That also means an aria-label
|
|
25
|
+
// which is itself generic is caught, which is the more common mistake.
|
|
26
|
+
//
|
|
27
|
+
// English only, deliberately. A phrase list cannot be quietly extended to
|
|
28
|
+
// other languages without a speaker of each checking it, and a wrong entry
|
|
29
|
+
// would fail correct links. Non-English pages get no findings here rather
|
|
30
|
+
// than bad ones.
|
|
31
|
+
|
|
32
|
+
const GENERIC = new Set([
|
|
33
|
+
'read more', 'read more about this', 'more', 'much more', 'learn more',
|
|
34
|
+
'click here', 'click', 'click this', 'tap here', 'press here',
|
|
35
|
+
'here', 'this', 'this link', 'link', 'this page', 'go', 'go here',
|
|
36
|
+
'more info', 'more information', 'further information',
|
|
37
|
+
'see more', 'view more', 'show more', 'find out more', 'discover more',
|
|
38
|
+
'continue', 'continue reading', 'keep reading', 'full story', 'full article',
|
|
39
|
+
'details', 'see details', 'view details', 'more details',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Lowercased, punctuation and arrows dropped, spaces collapsed, so
|
|
43
|
+
* "Read more →", "Click here!" and "READ MORE" all compare equal. */
|
|
44
|
+
export function normalizeName(name) {
|
|
45
|
+
return name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict }) {
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
impact,
|
|
52
|
+
tags,
|
|
53
|
+
help,
|
|
54
|
+
helpUrl,
|
|
55
|
+
// role="link" claims the link role explicitly; an <a> without href has no
|
|
56
|
+
// link role at all and is correctly out of scope. Links with NO name are
|
|
57
|
+
// link-name's finding, not this one.
|
|
58
|
+
selector: 'a[href], [role="link"]',
|
|
59
|
+
evaluate(element, { accessibleName }) {
|
|
60
|
+
const name = accessibleName(element);
|
|
61
|
+
if (!name) return { status: 'pass' }; // nameless: link-name reports it
|
|
62
|
+
const normalized = normalizeName(name);
|
|
63
|
+
if (!GENERIC.has(normalized)) return { status: 'pass' };
|
|
64
|
+
return verdict(name);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default createLinkPurposeRule({
|
|
70
|
+
id: 'link-text-generic',
|
|
71
|
+
impact: 'moderate',
|
|
72
|
+
tags: ['wcag2a', 'wcag244'],
|
|
73
|
+
help: 'Link text should describe where the link goes',
|
|
74
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html',
|
|
75
|
+
verdict: (name) => ({
|
|
76
|
+
status: 'incomplete',
|
|
77
|
+
message: `“${name}” does not say where this link goes. 2.4.4 lets the surrounding text supply that, so this passes only if the context a screen reader reaches — the same sentence, list item, table cell, or the heading of the card it sits in — makes the destination obvious. Check it does, and remember that anyone navigating by a list of the page's links sees this name on its own.`,
|
|
78
|
+
fix: 'Put the destination in the link text itself, e.g. "Read more about the 2026 budget", or extend the name with aria-label while keeping the visible words at the start of it.',
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// WCAG SC 2.4.9 Link Purpose (Link Only) (Level AAA)
|
|
2
|
+
//
|
|
3
|
+
// Same phrase list and same name computation as 2.4.4's rule, at the AAA
|
|
4
|
+
// threshold: the link text ALONE has to identify the purpose, and context
|
|
5
|
+
// is not allowed to make up the difference.
|
|
6
|
+
//
|
|
7
|
+
// This one asserts where the AA rule reviews. The criterion's only escape is
|
|
8
|
+
// links "where the purpose of the link would be ambiguous to users in
|
|
9
|
+
// general", and a link named nothing but "Read more" is ambiguous to
|
|
10
|
+
// everyone — which is the exception failing to apply rather than rescuing
|
|
11
|
+
// it. Nothing about the surrounding page can change that reading, so there
|
|
12
|
+
// is nothing here for a human to settle.
|
|
13
|
+
import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
|
|
14
|
+
|
|
15
|
+
export default createLinkPurposeRule({
|
|
16
|
+
id: 'link-text-generic-only',
|
|
17
|
+
impact: 'moderate',
|
|
18
|
+
tags: ['wcag2aaa', 'wcag249'],
|
|
19
|
+
help: 'Link text alone must identify where the link goes (AAA)',
|
|
20
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html',
|
|
21
|
+
verdict: (name) => ({
|
|
22
|
+
status: 'fail',
|
|
23
|
+
message: `“${name}” gives no purpose on its own. 2.4.9 asks that the link text alone identify where a link goes, without relying on the sentence or heading around it, so anyone reading the page's links as a list can tell them apart.`,
|
|
24
|
+
fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
@@ -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/engine/wcag22.js
CHANGED
|
@@ -75,7 +75,7 @@ export default [
|
|
|
75
75
|
sc('2.4.6', 'Headings and Labels', 'AA', 'manual'),
|
|
76
76
|
sc('2.4.7', 'Focus Visible', 'AA', 'partial'), // partial: focus-visible flags outline suppression; the indicator itself needs eyes
|
|
77
77
|
sc('2.4.8', 'Location', 'AAA', 'manual'),
|
|
78
|
-
sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', '
|
|
78
|
+
sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', 'partial'), // partial: contentless link names are provable; whether a real name describes its destination needs a reader
|
|
79
79
|
sc('2.4.10', 'Section Headings', 'AAA', 'manual'),
|
|
80
80
|
sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), // partial: focus-not-obscured catches fully-covered targets at rest
|
|
81
81
|
sc('2.4.12', 'Focus Not Obscured (Enhanced)', 'AAA', 'manual', '2.2'),
|
package/package.json
CHANGED