pour-engine 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.3.1',
9
+ version: '1.4.0',
10
10
  },
11
11
  };
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, rendered as a checklist in the extension.
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. Known gaps:
112
- shadow DOM and iframe traversal, the full accname spec (ours is a close
113
- approximation), and the language subtag registry (BCP 47 shape check only).
114
- Grow it rule by rule; the test beds' `expect:` annotations are the
115
- regression suite.
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
- // DY Engine runner: evaluates every applicable rule against the page and
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';
@@ -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 l = luminance(overlays.length ? applyOverlays(pixel, overlays) : pixel);
847
- if (l < min) min = l;
848
- if (l > max) max = 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 ? { min, max, width: img.naturalWidth, height: img.naturalHeight } : null);
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 lums = stops.map((stop) => luminance(overlays.length ? applyOverlays(stop, overlays) : stop));
925
- return { min: Math.min(...lums), max: Math.max(...lums) };
932
+ const composited = stops.map((stop) => (overlays.length ? applyOverlays(stop, overlays) : stop));
933
+ const lums = composited.map(luminance);
934
+ const min = Math.min(...lums);
935
+ const max = Math.max(...lums);
936
+ // The COLOURS at the extremes, not just their luminances: translucent text
937
+ // takes its presented colour from the pixels beneath, so bracketing that
938
+ // blend needs the actual rgb to composite against, and luminance alone
939
+ // cannot be un-gamma'd back into one.
940
+ return {
941
+ min,
942
+ max,
943
+ minColor: composited[lums.indexOf(min)],
944
+ maxColor: composited[lums.indexOf(max)],
945
+ };
926
946
  }
927
947
 
928
948
  /** Contrast ratio from two luminances. */
@@ -79,9 +79,14 @@ export default {
79
79
  }
80
80
  return {
81
81
  status: 'fail',
82
+ // The measured fact is the ratio against G183's threshold, and that is
83
+ // all this says. Naming a group who "can't see it" overclaims: a
84
+ // saturated red against near-white misses 3:1 on luminance while
85
+ // staying perfectly distinct to most people, including most red-green
86
+ // colour blindness, where the red darkens and the gap widens.
82
87
  message: ratio < 1.01
83
- ? 'This link has no underline and the same colour as the surrounding text — sighted users can’t tell it’s a link.'
84
- : `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text — colour-blind users can’t spot it.`,
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 { relation: textIntersects(element, paint) ? 'under' : 'clear', intrinsic, dimensionless };
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 — a luminance range of the backdrop cannot bracket the
192
- // blend (dimmed white over a dark photo reads mid-grey, not white).
193
- // Asserting either way from sampling would be a coin toss.
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
- if (sampled?.status === 'fail'
329
- && (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40)) {
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: 'fail',
44
- message: 'This region scrolls, but contains nothing focusable and isn’t focusable itself — keyboard users can never see the overflowed content.',
45
- fix: 'Add tabindex="0" to the scrollable element (plus role="region" and an aria-label describing it).',
59
+ status: 'incomplete',
60
+ message: 'This region scrolls and holds nothing focusable, so reaching its overflowed content depends on the browser. Chromium gives such containers keyboard focus automatically; not every engine does. Tab to it in the browsers you support, and add tabindex="0" if it cannot be reached.',
61
+ fix: 'If you support browsers that do not focus scroll containers, add tabindex="0" (plus role="region" and an aria-label describing it).',
46
62
  };
47
63
  },
48
64
  };
@@ -38,6 +38,68 @@ function isHiddenFromPointer(element, rect) {
38
38
  return false;
39
39
  }
40
40
 
41
+ /**
42
+ * Is this element the thing a click at that point would actually reach, or
43
+ * is something painted over it there?
44
+ *
45
+ * The spacing exception is about targets COMPETING for pointer space, and
46
+ * two targets that can never be hit at the same place do not compete. On any
47
+ * page with a fixed header, scrolling slides ordinary content underneath it:
48
+ * a footer link can end up geometrically 3px from a header button while
49
+ * being completely unclickable there, and counting it as a crowder invents a
50
+ * failure out of the scroll position. That was reported on pour's own site.
51
+ *
52
+ * Only asked about targets that are ALREADY crowding an undersized one, so
53
+ * the hit-test cost is paid on the handful of candidate failures rather than
54
+ * on every target of the page.
55
+ *
56
+ * Out-of-viewport points can't be hit-tested at all: elementsFromPoint is
57
+ * viewport-relative and returns nothing outside it. There the honest answer
58
+ * is "don't know", and don't-know must not silence a finding, so it counts
59
+ * as reachable.
60
+ */
61
+ function reachableRects(element, rect) {
62
+ const doc = element.ownerDocument;
63
+ const win = doc.defaultView;
64
+ if (!win || typeof doc.elementsFromPoint !== 'function') return [rect];
65
+ // Per LINE FRAGMENT, never the bounding box. An inline link that wraps has
66
+ // one fragment per line, and the box enclosing them is mostly space the
67
+ // element never paints: its middle falls in the leading BETWEEN the lines,
68
+ // where elementsFromPoint does not contain the element at all. A
69
+ // box-centre probe therefore reads "not hit-testable" for every wrapped
70
+ // link. (Same geometry trap as link-in-text-block's own-line test.)
71
+ //
72
+ // Fragments also make the answer usefully partial. A footer link scrolled
73
+ // half under a fixed header has its first line buried and its second line
74
+ // perfectly clickable, so the honest result is not "reachable" or
75
+ // "covered" but WHICH PART is reachable — and only that part can compete
76
+ // for pointer space with anything nearby.
77
+ const fragments = [...element.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
78
+ const probes = fragments.length ? fragments : [rect];
79
+ const reachable = [];
80
+ let testable = false;
81
+ for (const fragment of probes) {
82
+ const x = fragment.left + fragment.width / 2;
83
+ const y = fragment.top + fragment.height / 2;
84
+ if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) continue;
85
+ const stack = doc.elementsFromPoint(x, y);
86
+ const index = stack.indexOf(element);
87
+ if (index < 0) continue; // this fragment isn't hit-testable; try the next
88
+ testable = true;
89
+ // Anything above it that is part of the same control is not an
90
+ // obstruction: a button's own icon paints over the button, an overlay
91
+ // label over its input. Only a genuinely separate element blocks it.
92
+ if (stack.slice(0, index).every((layer) => layer.contains(element) || element.contains(layer))) {
93
+ reachable.push(fragment);
94
+ }
95
+ }
96
+ // Nothing could be probed at all (entirely out of viewport,
97
+ // pointer-events: none, nothing laid out): don't know, and don't-know must
98
+ // not silence a finding, so the whole rect stands.
99
+ if (!testable) return [rect];
100
+ return reachable;
101
+ }
102
+
41
103
  /** Text this element contributes that is NOT inside a target — i.e. does it
42
104
  * read as prose beside the link, or is it just another control in a list? */
43
105
  function textOutsideTargets(element) {
@@ -146,14 +208,37 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
146
208
  // Spacing exception: the min-sized circle around this target must
147
209
  // clear every other target's rect and every other undersized
148
210
  // target's circle.
149
- const crowded = elements.some((other, j) => {
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
- if (undersized[j] && Math.hypot(centers[j].x - centers[i].x, centers[j].y - centers[i].y) < min) return true;
154
- return pointToRectDistance(centers[i], rects[j]) < min / 2;
155
- });
215
+ const within = (box) => {
216
+ if (undersized[j]) {
217
+ const c = { x: box.left + box.width / 2, y: box.top + box.height / 2 };
218
+ if (Math.hypot(c.x - centers[i].x, c.y - centers[i].y) < min) return true;
219
+ }
220
+ return pointToRectDistance(centers[i], box) < min / 2;
221
+ };
222
+ // Whole-rect test first, as a cheap superset: no part of a target
223
+ // can be closer than the box enclosing it, so a box that doesn't
224
+ // reach rules the target out without any hit-testing. Only the
225
+ // survivors pay for the paint-order queries, which on a page of
226
+ // hundreds of targets is a handful.
227
+ if (!within(rects[j])) return false;
228
+ // Then the honest test: only the parts a pointer can actually
229
+ // reach compete for pointer space. A link whose first line is
230
+ // buried under a fixed header and whose second line is clear is
231
+ // crowding from its second line or not at all.
232
+ return reachableRects(other, rects[j]).some(within);
233
+ };
234
+ const crowded = elements.some(crowds);
156
235
  if (!crowded) return { status: 'pass' };
236
+ // Deliberately NOT applied to the target itself. A crowder that
237
+ // can't be clicked breaks the premise of the exception, because it
238
+ // isn't competing for anything. A target that happens to be under a
239
+ // cookie banner right now is still 20×20 the moment that banner is
240
+ // dismissed, and suppressing it would hide a real defect behind a
241
+ // transient overlay, which is the wrong way round.
157
242
  }
158
243
  const rect = rects[i];
159
244
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.3.1",
3
+ "version": "1.4.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",