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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.1.9',
9
+ version: '1.2.0',
10
10
  },
11
11
  };
@@ -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
- if (style.backgroundImage !== 'none') return { css: style.backgroundImage, element: current };
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
- if (imageRangeCache.has(url)) return imageRangeCache.get(url);
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
- const l = luminance({ r: data[i], g: data[i + 1], b: data[i + 2] });
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(url, promise);
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" — lists of links (navs,
11
- // blogrolls) are identified by their structure, not by colour.
12
- selector: 'p a[href], dd a[href], blockquote a[href], td a[href]',
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
- const sampled = await sampledVerdict(imageSource, foreground, required, doc);
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
- if (rule.cssRules) { scan(rule.cssRules); continue; } // @media/@supports
11
- if (!rule.selectorText || !/:focus/.test(rule.selectorText)) continue;
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", "zh-Hans").
3
- const LANG_PATTERN = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
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
- const LANG_PATTERN = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.1.9",
3
+ "version": "1.2.0",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",