pour-engine 1.1.9 → 1.2.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 +30 -7
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +10 -4
- package/engine/rules/wcag/1.4.3-color-contrast.js +46 -5
- package/engine/rules/wcag/2.4.7-focus-visible.js +35 -11
- package/engine/rules/wcag/3.1.1-html-lang.js +10 -2
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +4 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -762,23 +762,42 @@ export function mediaRects(doc) {
|
|
|
762
762
|
* Returns { css, element } or null.
|
|
763
763
|
*/
|
|
764
764
|
export function backgroundImageSource(element) {
|
|
765
|
+
// Translucent background colours passed on the way up are painted BETWEEN
|
|
766
|
+
// the image and the text: the scrim over a hero photo is the everyday case.
|
|
767
|
+
// They are collected nearest-text first, so folding them back over a sampled
|
|
768
|
+
// image pixel means walking this list in reverse (see applyOverlays).
|
|
769
|
+
const overlays = [];
|
|
765
770
|
for (let current = element; current; current = current.parentElement) {
|
|
766
771
|
const style = getComputedStyle(current);
|
|
767
|
-
|
|
772
|
+
// An element's own background-image paints ABOVE its own background
|
|
773
|
+
// colour, so the element carrying the image contributes no overlay.
|
|
774
|
+
if (style.backgroundImage !== 'none') return { css: style.backgroundImage, element: current, overlays };
|
|
768
775
|
const color = parseColor(style.backgroundColor);
|
|
769
776
|
if (color && color.a >= 1) return null;
|
|
777
|
+
if (color && color.a > 0) overlays.push(color);
|
|
770
778
|
}
|
|
771
779
|
return null;
|
|
772
780
|
}
|
|
773
781
|
|
|
782
|
+
/** Paint `overlays` (nearest-text first) back over a base colour. */
|
|
783
|
+
export function applyOverlays(base, overlays) {
|
|
784
|
+
let result = base;
|
|
785
|
+
for (let i = overlays.length - 1; i >= 0; i--) result = composite(overlays[i], result);
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const overlayKey = (overlays) =>
|
|
790
|
+
overlays.map((c) => `${c.r},${c.g},${c.b},${c.a}`).join(';');
|
|
791
|
+
|
|
774
792
|
/**
|
|
775
793
|
* Luminance range (min/max) of an image's pixels, downscaled for speed.
|
|
776
794
|
* null when the image can't be read: cross-origin without CORS headers
|
|
777
795
|
* (canvas tainting), load failure, or timeout. Cached per URL.
|
|
778
796
|
*/
|
|
779
797
|
const imageRangeCache = new Map();
|
|
780
|
-
export function imageLuminanceRange(url) {
|
|
781
|
-
|
|
798
|
+
export function imageLuminanceRange(url, overlays = []) {
|
|
799
|
+
const cacheKey = overlays.length ? `${url}|${overlayKey(overlays)}` : url;
|
|
800
|
+
if (imageRangeCache.has(cacheKey)) return imageRangeCache.get(cacheKey);
|
|
782
801
|
const promise = new Promise((resolve) => {
|
|
783
802
|
const img = new Image();
|
|
784
803
|
img.crossOrigin = 'anonymous';
|
|
@@ -797,7 +816,11 @@ export function imageLuminanceRange(url) {
|
|
|
797
816
|
let max = 0;
|
|
798
817
|
for (let i = 0; i < data.length; i += 4) {
|
|
799
818
|
if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
|
|
800
|
-
|
|
819
|
+
// Composite each pixel through anything painted between the image
|
|
820
|
+
// and the text before measuring: a photo under a 75% black scrim is
|
|
821
|
+
// seen as the blend, never as the photo.
|
|
822
|
+
const pixel = { r: data[i], g: data[i + 1], b: data[i + 2], a: 1 };
|
|
823
|
+
const l = luminance(overlays.length ? applyOverlays(pixel, overlays) : pixel);
|
|
801
824
|
if (l < min) min = l;
|
|
802
825
|
if (l > max) max = l;
|
|
803
826
|
}
|
|
@@ -809,7 +832,7 @@ export function imageLuminanceRange(url) {
|
|
|
809
832
|
img.onerror = () => { clearTimeout(timer); resolve(null); };
|
|
810
833
|
img.src = url;
|
|
811
834
|
});
|
|
812
|
-
imageRangeCache.set(
|
|
835
|
+
imageRangeCache.set(cacheKey, promise);
|
|
813
836
|
return promise;
|
|
814
837
|
}
|
|
815
838
|
|
|
@@ -857,10 +880,10 @@ export function backgroundImagePaintRect(element, intrinsic) {
|
|
|
857
880
|
* Luminance range of a CSS gradient, from its colour stops. Interpolated
|
|
858
881
|
* colours stay close enough to their stops' luminances to bracket.
|
|
859
882
|
*/
|
|
860
|
-
export function gradientLuminanceRange(backgroundImageCss) {
|
|
883
|
+
export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
|
|
861
884
|
const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
|
|
862
885
|
if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
|
|
863
|
-
const lums = stops.map(luminance);
|
|
886
|
+
const lums = stops.map((stop) => luminance(overlays.length ? applyOverlays(stop, overlays) : stop));
|
|
864
887
|
return { min: Math.min(...lums), max: Math.max(...lums) };
|
|
865
888
|
}
|
|
866
889
|
|
|
@@ -7,12 +7,18 @@ export default {
|
|
|
7
7
|
tags: ['wcag2a', 'wcag141'],
|
|
8
8
|
help: 'Links inside text must be distinguishable by more than colour',
|
|
9
9
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html',
|
|
10
|
-
// Only running prose counts as a "text block"
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
// Only running prose counts as a "text block". <li> is included because
|
|
11
|
+
// footers and credits routinely write sentences as list items ("built by
|
|
12
|
+
// the <a>team</a> with help from <a>contributors</a>"), and those links are
|
|
13
|
+
// embedded in prose exactly as 1.4.1 means it. Pure link lists (navs,
|
|
14
|
+
// blogrolls) are still excluded, but by the ownText guard below rather than
|
|
15
|
+
// by tag: in <li><a>Home</a></li> the item has no text of its own.
|
|
16
|
+
// <div> was measured too and added nothing on any test site, so it stays
|
|
17
|
+
// out rather than widening the blast radius for no gain.
|
|
18
|
+
selector: 'p a[href], dd a[href], blockquote a[href], td a[href], li a[href]',
|
|
13
19
|
visibility: 'visual', // colour distinction is a purely visual concern
|
|
14
20
|
evaluate(element, { ownText }) {
|
|
15
|
-
const parent = element.closest('p, dd, blockquote, td');
|
|
21
|
+
const parent = element.closest('p, dd, blockquote, td, li');
|
|
16
22
|
if (!element.textContent.trim() || !parent) return { status: 'pass' };
|
|
17
23
|
// Needs real surrounding text to blend into (1.4.1 is about links
|
|
18
24
|
// embedded in prose, not a link that IS the content).
|
|
@@ -125,9 +125,38 @@ function verdictFromRange(range, foreground, required, what) {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Is there translucent paint over the background image that the ancestor walk
|
|
130
|
+
* could NOT have collected? Two shapes dominate real hero banners: a
|
|
131
|
+
* ::before/::after scrim, and an absolutely-positioned sibling laid over the
|
|
132
|
+
* photo. Neither is on the text's ancestor chain, so backgroundImageSource
|
|
133
|
+
* never sees them, and judging the raw image pixels would describe a page the
|
|
134
|
+
* user is not looking at. Ancestor scrims are excluded here because they are
|
|
135
|
+
* already composited into the sampled pixels.
|
|
136
|
+
*/
|
|
137
|
+
function unaccountedScrim(element, imageCarrier) {
|
|
138
|
+
const pseudo = pseudoBackdropForText(element);
|
|
139
|
+
if (pseudo && pseudo !== 'unresolved' && pseudo.color?.a > 0) return true;
|
|
140
|
+
const doc = element.ownerDocument;
|
|
141
|
+
if (typeof doc.elementsFromPoint !== 'function') return false;
|
|
142
|
+
const rect = element.getBoundingClientRect();
|
|
143
|
+
const win = doc.defaultView;
|
|
144
|
+
const x = rect.left + rect.width / 2;
|
|
145
|
+
const y = rect.top + rect.height / 2;
|
|
146
|
+
if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
|
|
147
|
+
const stack = doc.elementsFromPoint(x, y);
|
|
148
|
+
const start = stack.indexOf(element);
|
|
149
|
+
if (start === -1) return false;
|
|
150
|
+
return stack.slice(start + 1).some((layer) => {
|
|
151
|
+
if (layer.contains(element) || layer === imageCarrier) return false; // ancestor chain: already composited
|
|
152
|
+
const color = parseColor(getComputedStyle(layer).backgroundColor);
|
|
153
|
+
return color && color.a > 0;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
128
157
|
// Bracket the verdict by sampling what's actually painted: image pixels
|
|
129
158
|
// (same-origin/CORS only) or gradient colour stops.
|
|
130
|
-
async function sampledVerdict(source, foreground, required, doc) {
|
|
159
|
+
async function sampledVerdict(source, foreground, required, doc, overlays = []) {
|
|
131
160
|
if (!source) return null;
|
|
132
161
|
// Translucent text takes its presented colour from the very pixels
|
|
133
162
|
// beneath it — a luminance range of the backdrop cannot bracket the
|
|
@@ -140,7 +169,7 @@ async function sampledVerdict(source, foreground, required, doc) {
|
|
|
140
169
|
};
|
|
141
170
|
}
|
|
142
171
|
if (source.tagName === 'IMG') {
|
|
143
|
-
return verdictFromRange(await imageLuminanceRange(source.currentSrc || source.src), foreground, required, 'image');
|
|
172
|
+
return verdictFromRange(await imageLuminanceRange(source.currentSrc || source.src, overlays), foreground, required, 'image');
|
|
144
173
|
}
|
|
145
174
|
const css = source.css ?? getComputedStyle(source).backgroundImage;
|
|
146
175
|
if (!css || css === 'none') return null;
|
|
@@ -149,10 +178,10 @@ async function sampledVerdict(source, foreground, required, doc) {
|
|
|
149
178
|
if (url) {
|
|
150
179
|
let absolute;
|
|
151
180
|
try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
|
|
152
|
-
return verdictFromRange(await imageLuminanceRange(absolute), foreground, required, 'image');
|
|
181
|
+
return verdictFromRange(await imageLuminanceRange(absolute, overlays), foreground, required, 'image');
|
|
153
182
|
}
|
|
154
183
|
if (css.includes('gradient(')) {
|
|
155
|
-
return verdictFromRange(gradientLuminanceRange(css), foreground, required, 'gradient');
|
|
184
|
+
return verdictFromRange(gradientLuminanceRange(css, overlays), foreground, required, 'gradient');
|
|
156
185
|
}
|
|
157
186
|
return null;
|
|
158
187
|
}
|
|
@@ -248,7 +277,19 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
248
277
|
if (imageSource) {
|
|
249
278
|
const { relation, intrinsic, dimensionless } = await imageVsText(imageSource, element, doc);
|
|
250
279
|
if (relation !== 'clear') {
|
|
251
|
-
|
|
280
|
+
// imageSource.overlays are the translucent layers painted between the
|
|
281
|
+
// image and this text (hero scrims, tint panels). Judging the raw
|
|
282
|
+
// image pixels would describe a page the user never sees.
|
|
283
|
+
let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays);
|
|
284
|
+
// Scrims the ancestor walk cannot reach (pseudo-elements, positioned
|
|
285
|
+
// siblings) leave the sampled range describing the wrong pixels, so
|
|
286
|
+
// neither verdict is safe to assert from it.
|
|
287
|
+
if (sampled && unaccountedScrim(element, imageSource.element)) {
|
|
288
|
+
sampled = {
|
|
289
|
+
status: 'incomplete',
|
|
290
|
+
message: 'This text sits over a background image with a translucent overlay painted on top of it (a pseudo-element or positioned scrim), so the colour behind the glyphs is the blend of the two — check the contrast by eye.',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
252
293
|
// Icon-scale images: bullets, arrows, and decorations painted
|
|
253
294
|
// beside or beneath a corner of the text — their pixels may flag
|
|
254
295
|
// for eyes, never assert a failure. Icon-scale means: no reliable
|
|
@@ -5,19 +5,43 @@
|
|
|
5
5
|
// rule entirely — so this flags for review, it never asserts a fail.
|
|
6
6
|
function focusSuppressors(doc) {
|
|
7
7
|
const suspects = [];
|
|
8
|
+
const replacementIn = (style) => style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
|
|
9
|
+
|| style.getPropertyValue('border-color') || style.getPropertyValue('background')
|
|
10
|
+
|| style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
|
|
11
|
+
|
|
12
|
+
const inspect = (rule) => {
|
|
13
|
+
if (!rule.selectorText || !/:focus/.test(rule.selectorText)) return;
|
|
14
|
+
// `:focus:not(:focus-visible) { outline: none }` is the PUBLISHED pattern
|
|
15
|
+
// for showing the ring to keyboard users only: it strips the indicator
|
|
16
|
+
// precisely in the state where the browser has decided not to show focus,
|
|
17
|
+
// and leaves the keyboard state alone. Flagging it fails the pattern the
|
|
18
|
+
// guidance recommends, and it ships in most modern CSS resets.
|
|
19
|
+
if (/:not\(\s*:focus-visible\s*\)/.test(rule.selectorText)) return;
|
|
20
|
+
const style = rule.style;
|
|
21
|
+
if (!style) return;
|
|
22
|
+
const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
|
|
23
|
+
const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
|
|
24
|
+
if (!removesOutline) return;
|
|
25
|
+
// A replacement indicator may not sit in the rule's own style block: any
|
|
26
|
+
// declaration written AFTER a nested rule is parsed into a separate
|
|
27
|
+
// CSSNestedDeclarations child (a style-bearing rule with no selector), so
|
|
28
|
+
// reading only rule.style would report a suppressor that is in fact
|
|
29
|
+
// replaced two lines further down.
|
|
30
|
+
const nested = [...(rule.cssRules ?? [])]
|
|
31
|
+
.filter((child) => child.style && !child.selectorText);
|
|
32
|
+
if (replacementIn(style) || nested.some((child) => replacementIn(child.style))) return;
|
|
33
|
+
suspects.push(rule.selectorText);
|
|
34
|
+
};
|
|
35
|
+
// Inspect each rule, THEN descend into any children. Testing `rule.cssRules`
|
|
36
|
+
// first and skipping used to walk straight past every plain style rule:
|
|
37
|
+
// since CSS Nesting shipped, a CSSStyleRule carries its own cssRules list,
|
|
38
|
+
// and an empty CSSRuleList is still a truthy object, so the check meant for
|
|
39
|
+
// @media/@supports swallowed the whole sheet and the rule could never fire.
|
|
40
|
+
// A style rule can both declare and nest, so both halves have to run.
|
|
8
41
|
const scan = (rules) => {
|
|
9
42
|
for (const rule of rules) {
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
const style = rule.style;
|
|
13
|
-
if (!style) continue;
|
|
14
|
-
const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
|
|
15
|
-
const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
|
|
16
|
-
if (!removesOutline) continue;
|
|
17
|
-
const replaced = style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
|
|
18
|
-
|| style.getPropertyValue('border-color') || style.getPropertyValue('background')
|
|
19
|
-
|| style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
|
|
20
|
-
if (!replaced) suspects.push(rule.selectorText);
|
|
43
|
+
inspect(rule);
|
|
44
|
+
if (rule.cssRules?.length) scan(rule.cssRules); // @media/@supports/nesting
|
|
21
45
|
}
|
|
22
46
|
};
|
|
23
47
|
for (const sheet of doc.styleSheets) {
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
// WCAG SC 3.1.1 Language of Page (Level A)
|
|
2
|
-
// Rough BCP 47 shape: primary subtag + optional extras ("en", "en-GB",
|
|
3
|
-
|
|
2
|
+
// Rough BCP 47 shape: primary subtag + optional extras ("en", "en-GB",
|
|
3
|
+
// "zh-Hans"). Subtags may be a SINGLE character: BCP 47 extension and
|
|
4
|
+
// private-use singletons are exactly that ("de-DE-u-co-phonebk",
|
|
5
|
+
// "zh-CN-x-private"), so a 2-character floor rejects legal tags. The second
|
|
6
|
+
// branch covers private-use-only and grandfathered tags ("x-klingon",
|
|
7
|
+
// "i-klingon"), whose primary subtag is itself one character.
|
|
8
|
+
// A 5-8 letter primary subtag is legal BCP 47 but unassigned in the IANA
|
|
9
|
+
// registry, so it stays rejected: that is what catches lang="english".
|
|
10
|
+
// Keep this in step with 3.1.2-valid-lang-parts.js.
|
|
11
|
+
const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,8})+)$/;
|
|
4
12
|
|
|
5
13
|
export default {
|
|
6
14
|
id: 'html-lang',
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// WCAG SC 3.1.2 Language of Parts (Level AA)
|
|
2
|
-
|
|
2
|
+
// Same shape as 3.1.1-html-lang.js, and must stay in step with it: subtags
|
|
3
|
+
// may be a single character, because BCP 47 extension and private-use
|
|
4
|
+
// singletons ("-u-", "-t-", "-x-") are exactly one.
|
|
5
|
+
const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,8})+)$/;
|
|
3
6
|
|
|
4
7
|
export default {
|
|
5
8
|
id: 'valid-lang-parts',
|
package/package.json
CHANGED