pour-engine 1.1.8 → 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.8',
9
+ version: '1.2.0',
10
10
  },
11
11
  };
@@ -1,17 +1,79 @@
1
1
  // WCAG contrast math (WCAG 2.x, relative luminance per sRGB).
2
2
 
3
- /** Parse a computed CSS colour (`rgb()`/`rgba()`) → {r,g,b,a} or null.
3
+ // Computed colours do not all come back as `rgb()` any more. A colour the
4
+ // author wrote in a modern syntax is returned in THAT syntax: Chrome and
5
+ // Safari hand back `color(srgb 0.98 0.95 0.91)` verbatim, and Tailwind 4
6
+ // emits `oklch()` for its whole palette. Matching only the legacy comma form
7
+ // made every such colour parse as null, which resolveBackground reads as
8
+ // "nothing painted here" — so contrast got measured against whatever sat
9
+ // BEHIND the element instead of the element's own background, and a real
10
+ // failure could be reported as a pass or waved through as unreadable.
11
+ //
12
+ // Two tiers. The syntaxes we can convert exactly are converted here; anything
13
+ // else (display-p3, oklch, lab, color-mix output…) is handed to the browser,
14
+ // which paints one pixel and tells us the sRGB it produced. sRGB is the space
15
+ // WCAG's luminance maths is defined in, so that conversion is the right one.
16
+ const LEGACY_RGB = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)$/;
17
+ const MODERN_RGB = /^rgba?\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+%?)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/;
18
+ const SRGB_COLOR = /^color\(\s*srgb\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/;
19
+
20
+ const clamp255 = (value) => Math.min(255, Math.max(0, value));
21
+ /** A channel token: `128`, `50%`, or (in color()) `0.5` on a 0-1 scale. */
22
+ const channel = (token, unitScale) =>
23
+ clamp255(token.endsWith('%') ? (parseFloat(token) / 100) * 255 : parseFloat(token) * unitScale);
24
+ const alphaOf = (token) => {
25
+ if (token === undefined) return 1;
26
+ const value = parseFloat(token);
27
+ return Math.min(1, Math.max(0, token.endsWith('%') ? value / 100 : value));
28
+ };
29
+
30
+ /** Colour spaces we convert ourselves, exactly and with no DOM. */
31
+ function parseKnownSyntax(cssColor) {
32
+ const rgb = LEGACY_RGB.exec(cssColor) ?? MODERN_RGB.exec(cssColor);
33
+ if (rgb) return { r: channel(rgb[1], 1), g: channel(rgb[2], 1), b: channel(rgb[3], 1), a: alphaOf(rgb[4]) };
34
+ const srgb = SRGB_COLOR.exec(cssColor);
35
+ if (srgb) return { r: channel(srgb[1], 255), g: channel(srgb[2], 255), b: channel(srgb[3], 255), a: alphaOf(srgb[4]) };
36
+ return null;
37
+ }
38
+
39
+ // One reused 1×1 canvas. Only unknown syntaxes reach it, and the result is
40
+ // cached by string, so a page pays for it once per distinct colour.
41
+ let probe;
42
+ let probeUnavailable = false;
43
+ function paintToSrgb(cssColor) {
44
+ if (probeUnavailable) return null;
45
+ try {
46
+ probe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
47
+ if (!probe) { probeUnavailable = true; return null; }
48
+ // An invalid value leaves fillStyle untouched, so two different sentinels
49
+ // tell "the browser rejected it" apart from "it really is that colour".
50
+ probe.fillStyle = '#000000';
51
+ probe.fillStyle = cssColor;
52
+ const asBlack = probe.fillStyle;
53
+ probe.fillStyle = '#ffffff';
54
+ probe.fillStyle = cssColor;
55
+ if (asBlack === '#000000' && probe.fillStyle === '#ffffff') return null; // rejected by the browser
56
+ probe.clearRect(0, 0, 1, 1);
57
+ probe.fillRect(0, 0, 1, 1);
58
+ const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
59
+ // getImageData un-premultiplies, which loses precision as alpha falls;
60
+ // recover the colour from the alpha the browser reported.
61
+ return { r, g, b, a: a / 255 };
62
+ } catch {
63
+ probeUnavailable = true; // no canvas (or readback blocked): stop trying
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /** Parse a computed CSS colour → {r,g,b,a}, or null when it cannot be read.
4
69
  * Cached by string: pages use a handful of distinct colours across
5
- * thousands of elements, and the regex was a top cost in contrast audits.
70
+ * thousands of elements, and parsing was a top cost in contrast audits.
6
71
  * Callers must not mutate the returned object. */
7
72
  const colorCache = new Map();
8
73
  export function parseColor(cssColor) {
9
74
  if (cssColor == null) return null;
10
75
  if (colorCache.has(cssColor)) return colorCache.get(cssColor);
11
- const match = cssColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
12
- const color = match
13
- ? { r: +match[1], g: +match[2], b: +match[3], a: match[4] === undefined ? 1 : +match[4] }
14
- : null;
76
+ const color = parseKnownSyntax(cssColor) ?? paintToSrgb(cssColor);
15
77
  if (colorCache.size < 10_000) colorCache.set(cssColor, color); // bounded: computed colors are few
16
78
  return color;
17
79
  }
@@ -64,6 +126,28 @@ export function resetAuditCaches() {
64
126
  pseudoCache = new WeakMap();
65
127
  }
66
128
 
129
+ /**
130
+ * The computed `background-color` STRING that ended the background walk, as
131
+ * the author's stylesheet produced it. Only when one opaque colour settled
132
+ * it: translucent layers composite into something no single declaration in
133
+ * the CSS holds, and there is nothing honest to point at.
134
+ *
135
+ * Reported so a failure names a colour the developer can actually find. A
136
+ * page written in oklch() or color(srgb …) gets told its ratio in sRGB, and
137
+ * searching the stylesheet for that rgb() value finds nothing at all.
138
+ */
139
+ export function backgroundColorSource(element) {
140
+ for (let node = element; node && node.nodeType === 1;
141
+ node = node.parentElement ?? node.getRootNode()?.host) {
142
+ const style = getComputedStyle(node);
143
+ if (style.backgroundImage !== 'none') return null;
144
+ const color = parseColor(style.backgroundColor);
145
+ if (!color || color.a === 0) continue; // transparent: keep walking, as the resolver does
146
+ return color.a >= 1 ? style.backgroundColor : null; // translucent: composited, no single source
147
+ }
148
+ return null;
149
+ }
150
+
67
151
  function canvasColor(doc) {
68
152
  // Pages using color-scheme: dark get a dark canvas from the browser even
69
153
  // with no author background — assuming white there inverts every result.
@@ -678,23 +762,42 @@ export function mediaRects(doc) {
678
762
  * Returns { css, element } or null.
679
763
  */
680
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 = [];
681
770
  for (let current = element; current; current = current.parentElement) {
682
771
  const style = getComputedStyle(current);
683
- 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 };
684
775
  const color = parseColor(style.backgroundColor);
685
776
  if (color && color.a >= 1) return null;
777
+ if (color && color.a > 0) overlays.push(color);
686
778
  }
687
779
  return null;
688
780
  }
689
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
+
690
792
  /**
691
793
  * Luminance range (min/max) of an image's pixels, downscaled for speed.
692
794
  * null when the image can't be read: cross-origin without CORS headers
693
795
  * (canvas tainting), load failure, or timeout. Cached per URL.
694
796
  */
695
797
  const imageRangeCache = new Map();
696
- export function imageLuminanceRange(url) {
697
- 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);
698
801
  const promise = new Promise((resolve) => {
699
802
  const img = new Image();
700
803
  img.crossOrigin = 'anonymous';
@@ -713,7 +816,11 @@ export function imageLuminanceRange(url) {
713
816
  let max = 0;
714
817
  for (let i = 0; i < data.length; i += 4) {
715
818
  if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
716
- 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);
717
824
  if (l < min) min = l;
718
825
  if (l > max) max = l;
719
826
  }
@@ -725,7 +832,7 @@ export function imageLuminanceRange(url) {
725
832
  img.onerror = () => { clearTimeout(timer); resolve(null); };
726
833
  img.src = url;
727
834
  });
728
- imageRangeCache.set(url, promise);
835
+ imageRangeCache.set(cacheKey, promise);
729
836
  return promise;
730
837
  }
731
838
 
@@ -773,10 +880,10 @@ export function backgroundImagePaintRect(element, intrinsic) {
773
880
  * Luminance range of a CSS gradient, from its colour stops. Interpolated
774
881
  * colours stay close enough to their stops' luminances to bracket.
775
882
  */
776
- export function gradientLuminanceRange(backgroundImageCss) {
883
+ export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
777
884
  const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
778
885
  if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
779
- const lums = stops.map(luminance);
886
+ const lums = stops.map((stop) => luminance(overlays.length ? applyOverlays(stop, overlays) : stop));
780
887
  return { min: Math.min(...lums), max: Math.max(...lums) };
781
888
  }
782
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).
@@ -4,7 +4,7 @@ import {
4
4
  backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
5
5
  rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
6
6
  paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
7
- pseudoBackdropForText,
7
+ pseudoBackdropForText, backgroundColorSource,
8
8
  } from '../../lib/contrast.js';
9
9
 
10
10
  /** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
@@ -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
@@ -468,14 +509,33 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
468
509
  const hazard = backdropHazard('fail', backgroundVerified);
469
510
  if (hazard) return hazard;
470
511
  }
512
+ // Both colours in sRGB, which is the space the ratio was computed in and
513
+ // the one an interactive checker accepts. The author's own syntax is
514
+ // carried alongside, never instead: a page written in oklch() or
515
+ // color(srgb …) would otherwise be handed an rgb() value that appears
516
+ // nowhere in its stylesheet.
517
+ const asRgb = (color) => `rgb(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)})`;
518
+ const foregroundRgb = asRgb(foreground);
519
+ const backgroundRgb = asRgb(background);
520
+ const foregroundCss = style.color !== foregroundRgb ? style.color : null;
521
+ const backgroundSource = backgroundColorSource(element);
522
+ const backgroundCss = backgroundSource && backgroundSource !== backgroundRgb ? backgroundSource : null;
523
+ const authored = [
524
+ foregroundCss && `the text as ${foregroundCss}`,
525
+ backgroundCss && `the background as ${backgroundCss}`,
526
+ ].filter(Boolean).join(' and ');
527
+
471
528
  return {
472
529
  status: 'fail',
473
530
  message: `Contrast is ${showRatio(ratio)}:1 — below the ${required}:1 WCAG minimum for this text size.`,
474
- fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${style.color} on this background).`,
531
+ fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${foregroundRgb} on ${backgroundRgb}).`
532
+ + (authored ? ` Your CSS writes ${authored}, so searching it for the sRGB values above won't find them.` : ''),
475
533
  // The exact pair, for the UIs to link out to an interactive checker.
476
534
  data: {
477
- foreground: style.color,
478
- background: `rgb(${Math.round(background.r)}, ${Math.round(background.g)}, ${Math.round(background.b)})`,
535
+ foreground: foregroundRgb,
536
+ background: backgroundRgb,
537
+ ...(foregroundCss ? { foregroundCss } : {}),
538
+ ...(backgroundCss ? { backgroundCss } : {}),
479
539
  ratio: Number(showRatio(ratio)),
480
540
  required,
481
541
  },
@@ -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.8",
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",