pour-engine 1.2.0 → 1.3.1

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.2.0',
9
+ version: '1.3.1',
10
10
  },
11
11
  };
@@ -5,6 +5,8 @@ Generated by `node scripts/wcag-coverage.js` — do not edit by hand.
5
5
  All 86 success criteria of WCAG 2.2, and how the native engine covers them.
6
6
  "Partially automated" means our rules catch common failures but cannot prove conformance;
7
7
  "Manual review" criteria need a human (most of the spec — true for every automated tool).
8
+ No criterion is listed as fully "Automated": every one of them ends in a question about
9
+ meaning that only a person can settle, even where a rule decides most real pages.
8
10
 
9
11
  ## Perceivable
10
12
 
@@ -58,7 +60,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
58
60
  | 2.3.2 | [Three Flashes](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes.html) | AAA | Manual review | — |
59
61
  | 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Manual review | — |
60
62
  | 2.4.1 | [Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Partially automated | bypass-blocks |
61
- | 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Automated | document-title |
63
+ | 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Partially automated | document-title |
62
64
  | 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Manual review | — |
63
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 |
64
66
  | 2.4.5 | [Multiple Ways](https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html) | AA | Manual review | — |
@@ -83,7 +85,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
83
85
 
84
86
  | SC | Name | Level | Coverage | Rules |
85
87
  |---|---|---|---|---|
86
- | 3.1.1 | [Language of Page](https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html) | A | Automated | html-lang |
88
+ | 3.1.1 | [Language of Page](https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html) | A | Partially automated | html-lang |
87
89
  | 3.1.2 | [Language of Parts](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) | AA | Partially automated | valid-lang-parts |
88
90
  | 3.1.3 | [Unusual Words](https://www.w3.org/WAI/WCAG22/Understanding/unusual-words.html) | AAA | Manual review | — |
89
91
  | 3.1.4 | [Abbreviations](https://www.w3.org/WAI/WCAG22/Understanding/abbreviations.html) | AAA | Manual review | — |
@@ -112,4 +114,4 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
112
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 |
113
115
  | 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
114
116
 
115
- **Totals:** 2 automated · 27 partially automated · 57 manual — with 72 rules.
117
+ **Totals:** 29 partially automated · 57 manual — with 72 rules.
package/engine/README.md CHANGED
@@ -108,7 +108,7 @@ Nothing in the spec is silently skipped — that claim is the product.
108
108
 
109
109
  ## Honest current state
110
110
 
111
- 61 rules, hardened on real sites and the `testbeds/` pages. Known gaps:
111
+ 72 rules, hardened on real sites and the `testbeds/` pages. Known gaps:
112
112
  shadow DOM and iframe traversal, the full accname spec (ours is a close
113
113
  approximation), and the language subtag registry (BCP 47 shape check only).
114
114
  Grow it rule by rule; the test beds' `expect:` annotations are the
@@ -140,7 +140,7 @@ export function backgroundColorSource(element) {
140
140
  for (let node = element; node && node.nodeType === 1;
141
141
  node = node.parentElement ?? node.getRootNode()?.host) {
142
142
  const style = getComputedStyle(node);
143
- if (style.backgroundImage !== 'none') return null;
143
+ if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return null;
144
144
  const color = parseColor(style.backgroundColor);
145
145
  if (!color || color.a === 0) continue; // transparent: keep walking, as the resolver does
146
146
  return color.a >= 1 ? style.backgroundColor : null; // translucent: composited, no single source
@@ -148,6 +148,24 @@ export function backgroundColorSource(element) {
148
148
  return null;
149
149
  }
150
150
 
151
+ /**
152
+ * Absolute URLs of images that sampling found to have no opaque pixels.
153
+ * Populated by imageLuminanceRange (async) and read by the background walk
154
+ * (sync): by the time a rule falls back to the walk, the image over that
155
+ * text has already been sampled, so the fact is in hand. Lives as long as
156
+ * the sample cache does — it describes the image, not the page.
157
+ */
158
+ const transparentImages = new Set();
159
+
160
+ /** True when every layer of this computed background-image is a url() known
161
+ * to paint nothing — so the layer cannot change what is behind it. Any
162
+ * gradient, or any unsampled image, makes that unknowable. */
163
+ function paintsNothing(backgroundImage) {
164
+ if (backgroundImage.includes('gradient(')) return false;
165
+ const urls = [...backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)].map((m) => m[1]);
166
+ return urls.length > 0 && urls.every((url) => transparentImages.has(url));
167
+ }
168
+
151
169
  function canvasColor(doc) {
152
170
  // Pages using color-scheme: dark get a dark canvas from the browser even
153
171
  // with no author background — assuming white there inverts every result.
@@ -169,7 +187,7 @@ function resolveBackground(element, doc) {
169
187
  }
170
188
  let result;
171
189
  const style = getComputedStyle(element);
172
- if (style.backgroundImage !== 'none') {
190
+ if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) {
173
191
  result = null;
174
192
  } else {
175
193
  const color = parseColor(style.backgroundColor);
@@ -792,7 +810,10 @@ const overlayKey = (overlays) =>
792
810
  /**
793
811
  * Luminance range (min/max) of an image's pixels, downscaled for speed.
794
812
  * null when the image can't be read: cross-origin without CORS headers
795
- * (canvas tainting), load failure, or timeout. Cached per URL.
813
+ * (canvas tainting), load failure, or timeout. `{ transparent: true }` when
814
+ * the image reads fine but has no opaque pixels at all — it paints nothing,
815
+ * which is a very different fact from "unknown" and must not be reported as
816
+ * one. Cached per URL.
796
817
  */
797
818
  const imageRangeCache = new Map();
798
819
  export function imageLuminanceRange(url, overlays = []) {
@@ -814,8 +835,10 @@ export function imageLuminanceRange(url, overlays = []) {
814
835
  const data = ctx.getImageData(0, 0, size, size).data;
815
836
  let min = 1;
816
837
  let max = 0;
838
+ let opaquePixels = 0;
817
839
  for (let i = 0; i < data.length; i += 4) {
818
840
  if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
841
+ opaquePixels += 1;
819
842
  // Composite each pixel through anything painted between the image
820
843
  // and the text before measuring: a photo under a 75% black scrim is
821
844
  // seen as the blend, never as the photo.
@@ -824,6 +847,21 @@ export function imageLuminanceRange(url, overlays = []) {
824
847
  if (l < min) min = l;
825
848
  if (l > max) max = l;
826
849
  }
850
+ // Not one opaque pixel: a spacer, a cleared sprite, or a decorative
851
+ // layer left in place with nothing in it. It covers the background
852
+ // without changing a single one of its pixels.
853
+ if (!opaquePixels) {
854
+ transparentImages.add(url);
855
+ // Any background the walk already gave up on ("there's an image in
856
+ // the chain") may have been given up on because of THIS image.
857
+ // Those answers are now wrong, and which ones is not worth
858
+ // tracking: a page has a handful of transparent images at most, so
859
+ // the walk simply re-runs. Without this the verdict would depend
860
+ // on which rule happened to reach the element first.
861
+ backgroundCache = new WeakMap();
862
+ resolve({ transparent: true, width: img.naturalWidth, height: img.naturalHeight });
863
+ return;
864
+ }
827
865
  resolve(min <= max ? { min, max, width: img.naturalWidth, height: img.naturalHeight } : null);
828
866
  } catch {
829
867
  resolve(null); // tainted canvas: cross-origin image without CORS headers
@@ -8,7 +8,14 @@ export default {
8
8
  selector: 'video',
9
9
  visibleOnly: false,
10
10
  evaluate(element) {
11
- if (element.querySelector('track[kind="captions"], track[kind="subtitles"]')) return { status: 'pass' };
11
+ // `kind` is an enumerated attribute: its values are ASCII
12
+ // case-insensitive, and the MISSING value default is the subtitles
13
+ // state — so a bare <track> is a subtitle track and counts. (An invalid
14
+ // value defaults to metadata, which does not, so `kind=""` and
15
+ // `kind="banana"` are deliberately left out.)
16
+ if (element.querySelector('track[kind="captions" i], track[kind="subtitles" i], track:not([kind])')) {
17
+ return { status: 'pass' };
18
+ }
12
19
  // Captions may be burned in or provided by the player — a human must check.
13
20
  return {
14
21
  status: 'incomplete',
@@ -40,10 +40,12 @@ export default {
40
40
 
41
41
  const id = element.id ? ` (it already has id="${element.id}")` : ' (give it an id first)';
42
42
 
43
- // A title-only name is published sufficient technique H65 — conformant,
44
- // but invisible until hover and ignored by some tools, so it's flagged
45
- // for eyes rather than failed.
46
- if (element.getAttribute('title') && !element.getAttribute('placeholder')) {
43
+ // A title name is published sufficient technique H65 — conformant, but
44
+ // invisible until hover and ignored by some tools, so it's flagged for
45
+ // eyes rather than failed. A placeholder alongside it is an example
46
+ // value, not a competing label: the title still names the field, so this
47
+ // check comes first and does not care whether one is present.
48
+ if (element.getAttribute('title')?.trim()) {
47
49
  return {
48
50
  status: 'incomplete',
49
51
  message: 'This field is named only by its title attribute (technique H65) — conformant, but the label is invisible until hover and some tools skip it. Check a visible label isn’t needed here.',
@@ -51,6 +51,10 @@ export default {
51
51
  help: 'Content must work in both portrait and landscape',
52
52
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/orientation.html',
53
53
  selector: 'html',
54
+ // The evidence is in the stylesheet, not in the root's own rendering — and
55
+ // the lock hides the root exactly when it is in force, so a visibility
56
+ // filter here would silence the rule in the only state that matters.
57
+ visibleOnly: false,
54
58
  evaluate(element) {
55
59
  const findings = [];
56
60
  for (const sheet of element.ownerDocument.styleSheets) {
@@ -54,9 +54,17 @@ export default {
54
54
  // its own line (<br>-separated address blocks, one-link-per-line
55
55
  // footers) has no surrounding words to blend into — nothing is
56
56
  // conveyed by colour.
57
- const rect = element.getBoundingClientRect();
58
- if (rect.height) {
59
- const mid = rect.top + rect.height / 2;
57
+ //
58
+ // Measured per LINE FRAGMENT, never on the bounding box. A link that
59
+ // wraps has one fragment per line, and the middle of the box that
60
+ // encloses them falls in the leading BETWEEN the lines, where no text
61
+ // can ever be — so a box-midpoint test exempts every wrapped link in
62
+ // prose, which is the opposite of what the check is for. F73 scopes
63
+ // this criterion by whether a link sits within text, and says nothing
64
+ // about lines: one fragment sharing its line with words is enough.
65
+ const fragments = [...element.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
66
+ if (fragments.length) {
67
+ const mids = fragments.map((r) => r.top + r.height / 2);
60
68
  const walker = element.ownerDocument.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
61
69
  const range = element.ownerDocument.createRange();
62
70
  let sharesLine = false;
@@ -64,7 +72,7 @@ export default {
64
72
  if (!node.textContent.trim() || element.contains(node)) continue;
65
73
  range.selectNodeContents(node);
66
74
  for (const r of range.getClientRects()) {
67
- if (r.top <= mid && r.bottom >= mid && r.width > 0) { sharesLine = true; break; }
75
+ if (r.width > 0 && mids.some((mid) => r.top <= mid && r.bottom >= mid)) { sharesLine = true; break; }
68
76
  }
69
77
  }
70
78
  if (!sharesLine) return { status: 'pass' };
@@ -154,10 +154,39 @@ function unaccountedScrim(element, imageCarrier) {
154
154
  });
155
155
  }
156
156
 
157
+ /** An image layer with no opaque pixels: it covers the background without
158
+ * changing it, so the callers step over it rather than reporting on it. */
159
+ const PAINTS_NOTHING = Symbol('fully transparent image layer');
160
+
157
161
  // Bracket the verdict by sampling what's actually painted: image pixels
158
- // (same-origin/CORS only) or gradient colour stops.
162
+ // (same-origin/CORS only) or gradient colour stops. Returns an outcome,
163
+ // PAINTS_NOTHING, or null when nothing here can be sampled at all (stacked
164
+ // layers, translucent gradient stops) and the caller must say so itself.
159
165
  async function sampledVerdict(source, foreground, required, doc, overlays = []) {
160
166
  if (!source) return null;
167
+ let range = null;
168
+ let what = 'image';
169
+ if (source.tagName === 'IMG') {
170
+ range = await imageLuminanceRange(source.currentSrc || source.src, overlays);
171
+ } else {
172
+ const css = source.css ?? getComputedStyle(source).backgroundImage;
173
+ if (!css || css === 'none') return null;
174
+ if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
175
+ const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
176
+ if (url) {
177
+ let absolute;
178
+ try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
179
+ range = await imageLuminanceRange(absolute, overlays);
180
+ } else if (css.includes('gradient(')) {
181
+ range = gradientLuminanceRange(css, overlays);
182
+ what = 'gradient';
183
+ } else {
184
+ return null;
185
+ }
186
+ }
187
+ // Checked before the text's own alpha: there is no blend to reason about
188
+ // when the layer paints nothing.
189
+ if (range?.transparent) return PAINTS_NOTHING;
161
190
  // Translucent text takes its presented colour from the very pixels
162
191
  // beneath it — a luminance range of the backdrop cannot bracket the
163
192
  // blend (dimmed white over a dark photo reads mid-grey, not white).
@@ -168,22 +197,15 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [])
168
197
  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.',
169
198
  };
170
199
  }
171
- if (source.tagName === 'IMG') {
172
- return verdictFromRange(await imageLuminanceRange(source.currentSrc || source.src, overlays), foreground, required, 'image');
173
- }
174
- const css = source.css ?? getComputedStyle(source).backgroundImage;
175
- if (!css || css === 'none') return null;
176
- if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
177
- const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
178
- if (url) {
179
- let absolute;
180
- try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
181
- return verdictFromRange(await imageLuminanceRange(absolute, overlays), foreground, required, 'image');
182
- }
183
- if (css.includes('gradient(')) {
184
- return verdictFromRange(gradientLuminanceRange(css, overlays), foreground, required, 'gradient');
200
+ // An image we tried to read and couldn't: that is a specific, reportable
201
+ // fact, and the only one of these paths where cross-origin is the cause.
202
+ if (!range && what === 'image') {
203
+ return {
204
+ status: 'incomplete',
205
+ message: 'The image painted behind this text can’t be read: it is cross-origin, or it failed to load, so a script can’t sample its colours. Check the contrast by eye.',
206
+ };
185
207
  }
186
- return null;
208
+ return verdictFromRange(range, foreground, required, what);
187
209
  }
188
210
 
189
211
  /**
@@ -281,35 +303,40 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
281
303
  // image and this text (hero scrims, tint panels). Judging the raw
282
304
  // image pixels would describe a page the user never sees.
283
305
  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
- }
293
- // Icon-scale images: bullets, arrows, and decorations painted
294
- // beside or beneath a corner of the text — their pixels may flag
295
- // for eyes, never assert a failure. Icon-scale means: no reliable
296
- // intrinsic size, an intrinsically tiny image, OR a background
297
- // carrier whose own box is line-height-sized (contain/cover paint
298
- // can never exceed the carrier — a 512px nominal SVG painted on an
299
- // inline link is still an icon). Only backdrop-scale images earn a
300
- // definite fail from sampling.
301
- const carrierBox = imageSource.element.getBoundingClientRect();
302
- if (sampled?.status === 'fail'
303
- && (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40)) {
304
- return {
306
+ // A layer with no opaque pixels changes nothing about what is behind
307
+ // the glyphs, so it must not block the verdict: fall through and
308
+ // judge the background that actually paints.
309
+ if (sampled !== PAINTS_NOTHING) {
310
+ // Scrims the ancestor walk cannot reach (pseudo-elements, positioned
311
+ // siblings) leave the sampled range describing the wrong pixels, so
312
+ // neither verdict is safe to assert from it.
313
+ if (sampled && unaccountedScrim(element, imageSource.element)) {
314
+ sampled = {
315
+ status: 'incomplete',
316
+ 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.',
317
+ };
318
+ }
319
+ // Icon-scale images: bullets, arrows, and decorations painted
320
+ // beside or beneath a corner of the text — their pixels may flag
321
+ // for eyes, never assert a failure. Icon-scale means: no reliable
322
+ // intrinsic size, an intrinsically tiny image, OR a background
323
+ // carrier whose own box is line-height-sized (contain/cover paint
324
+ // can never exceed the carrier — a 512px nominal SVG painted on an
325
+ // inline link is still an icon). Only backdrop-scale images earn a
326
+ // definite fail from sampling.
327
+ const carrierBox = imageSource.element.getBoundingClientRect();
328
+ if (sampled?.status === 'fail'
329
+ && (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40)) {
330
+ return {
331
+ status: 'incomplete',
332
+ 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.',
333
+ };
334
+ }
335
+ return sampled ?? {
305
336
  status: 'incomplete',
306
- 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.',
337
+ message: 'The background here is several stacked image or gradient layers, which a script can’t sample. Contrast must be checked by eye.',
307
338
  };
308
339
  }
309
- return sampled ?? {
310
- status: 'incomplete',
311
- message: 'The background here is an image that can’t be read (cross-origin) — contrast must be checked by eye.',
312
- };
313
340
  }
314
341
  }
315
342
 
@@ -360,17 +387,23 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
360
387
  // exactly as an element's own background-image is bracketed.
361
388
  if (pseudoBack?.image) {
362
389
  const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc);
363
- return sampled ?? {
364
- status: 'incomplete',
365
- message: 'A pseudo-element paints an image or gradient behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
366
- };
390
+ // PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
391
+ // it never displaced the background the walk resolved.
392
+ if (sampled !== PAINTS_NOTHING) {
393
+ return sampled ?? {
394
+ status: 'incomplete',
395
+ message: 'A pseudo-element paints an image or gradient behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
396
+ };
397
+ }
367
398
  }
368
399
  if (painted?.image && !pseudoBack) {
369
400
  const sampled = await sampledVerdict(painted.image, foreground, required, doc);
370
- return sampled ?? {
371
- status: 'incomplete',
372
- message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
373
- };
401
+ if (sampled !== PAINTS_NOTHING) {
402
+ return sampled ?? {
403
+ status: 'incomplete',
404
+ message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
405
+ };
406
+ }
374
407
  }
375
408
  // Only a hit-test-resolved backdrop is VERIFIED paint truth. Anything
376
409
  // else (offscreen sample point, element invisible to its own hit-test —
@@ -387,10 +420,12 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
387
420
  const obscuring = backgroundObscured(element);
388
421
  if (obscuring && obscuring !== 'unverifiable') {
389
422
  const sampled = await sampledVerdict(obscuring, foreground, required, doc);
390
- return sampled ?? {
391
- status: 'incomplete',
392
- message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
393
- };
423
+ if (sampled !== PAINTS_NOTHING) {
424
+ return sampled ?? {
425
+ status: 'incomplete',
426
+ message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
427
+ };
428
+ }
394
429
  }
395
430
  }
396
431
  if (pseudoBack?.color) {
@@ -8,9 +8,23 @@ export default {
8
8
  selector: 'meta[http-equiv="refresh" i]',
9
9
  visibleOnly: false,
10
10
  evaluate(element) {
11
- const delay = parseInt(element.getAttribute('content') ?? '', 10);
12
- if (!(delay > 0)) return { status: 'pass' }; // 0 = immediate redirect, allowed
13
- if (delay >= 72000) return { status: 'pass' }; // 2.2.1 exempts limits over 20 hours
11
+ const content = element.getAttribute('content') ?? '';
12
+ const delay = parseInt(content, 10);
13
+ // A refresh with no destination reloads THIS page. At zero seconds that
14
+ // is not the allowed instant redirect — it is a loop that throws the
15
+ // user back to the top of the page over and over (failure F41).
16
+ const hasDestination = /;\s*url\s*=/i.test(content);
17
+ if (delay === 0 && !hasDestination) {
18
+ return {
19
+ status: 'fail',
20
+ message: 'The page reloads itself immediately and has no destination to move on to, so the reload repeats for as long as the page is open. Nobody can read or complete anything on it.',
21
+ fix: 'Remove the timed refresh. If this is meant to be a redirect, give it a url (content="0; url=…"); if the content needs updating, update it in place instead of reloading.',
22
+ };
23
+ }
24
+ if (!(delay > 0)) return { status: 'pass' }; // 0 with a url = immediate redirect, allowed
25
+ // 2.2.1 exempts a time limit "longer than 20 hours" — 20 hours exactly
26
+ // is still inside the criterion.
27
+ if (delay > 72000) return { status: 'pass' };
14
28
  return {
15
29
  status: 'fail',
16
30
  message: `The page refreshes/redirects after ${delay}s — slow readers lose their place (or the whole page) with no control.`,
@@ -1,25 +1,117 @@
1
1
  // WCAG SC 2.2.2 Pause, Stop, Hide (Level A)
2
+ // "For any moving, blinking or scrolling information that (1) starts
3
+ // automatically, (2) lasts more than five seconds, and (3) is presented in
4
+ // parallel with other content, there is a mechanism for the user to pause,
5
+ // stop, or hide it…"
6
+ //
7
+ // Two shapes are checkable, and they need different verdicts.
8
+ //
9
+ // <marquee> and <blink> exist only to move, ship no control of their own,
10
+ // and are obsolete: a definite failure.
11
+ //
12
+ // A CSS animation set to repeat for ever is the modern ticker. Clauses 1
13
+ // and 2 are settled by the CSS itself — it starts with the page and never
14
+ // ends — but clause 3's "mechanism" may be any button anywhere on the page,
15
+ // and no static pass can tell a pause control from any other button. So the
16
+ // finding goes to a human with the evidence attached, never asserted.
17
+ //
18
+ // Only motion carrying TEXT is reported. Spinners, drifting decorative
19
+ // blobs and shimmer effects move too, and flagging every loading indicator
20
+ // on the web would bury the finding that matters. Silence there is
21
+ // abstaining, not clearing.
22
+ const MOTION_PROPERTIES = new Set([
23
+ 'transform', 'translate', 'rotate', 'scale', 'offsetDistance', 'offsetPath', 'offsetRotate',
24
+ 'left', 'right', 'top', 'bottom',
25
+ 'marginLeft', 'marginRight', 'marginTop', 'marginBottom',
26
+ 'backgroundPosition', 'backgroundPositionX', 'backgroundPositionY', 'objectPosition',
27
+ ]);
28
+
29
+ /** Does this keyframe set move the element around, rather than merely
30
+ * restyle it? A hue cycle is not movement; a translate is. */
31
+ function keyframesMove(keyframes) {
32
+ return keyframes.some((frame) => Object.keys(frame).some((property) => MOTION_PROPERTIES.has(property)));
33
+ }
34
+
35
+ /** Blinking: the element is repeatedly taken away and put back. A gentle
36
+ * breathing pulse (0.6 → 1) is not what the criterion is about, so an
37
+ * opacity cycle only counts when it reaches near-invisible AND near-full. */
38
+ function keyframesBlink(keyframes) {
39
+ if (keyframes.some((frame) => frame.visibility === 'hidden')) return true;
40
+ const opacities = keyframes
41
+ .map((frame) => parseFloat(frame.opacity))
42
+ .filter((value) => !Number.isNaN(value));
43
+ return opacities.some((value) => value <= 0.25) && opacities.some((value) => value >= 0.75);
44
+ }
45
+
46
+ /**
47
+ * Elements that an endlessly repeating animation is currently moving,
48
+ * mapped to the evidence. One document-wide getAnimations() call, never one
49
+ * per element: per-node getAnimations() filters every animation on the page
50
+ * each time, which turns animation-heavy pages quadratic.
51
+ */
52
+ function endlesslyMoving(doc) {
53
+ const moving = new Map();
54
+ if (typeof doc.getAnimations !== 'function') return moving;
55
+ let animations;
56
+ try { animations = doc.getAnimations(); } catch { return moving; }
57
+ for (const animation of animations) {
58
+ // Paused, finished, or reversed-to-a-stop animations move nothing.
59
+ if (animation.playState !== 'running' || !animation.playbackRate) continue;
60
+ // Scroll- and view-driven animations advance only as the user scrolls,
61
+ // so they do not "start automatically" in the sense of the criterion.
62
+ if (animation.timeline && animation.timeline !== doc.timeline) continue;
63
+ const effect = animation.effect;
64
+ const target = effect?.target;
65
+ if (!target || target.nodeType !== 1) continue;
66
+ const timing = effect.getComputedTiming?.() ?? {};
67
+ if (timing.iterations !== Infinity || !(timing.duration > 0)) continue;
68
+ let keyframes;
69
+ try { keyframes = effect.getKeyframes(); } catch { continue; }
70
+ const moves = keyframesMove(keyframes);
71
+ if (!moves && !keyframesBlink(keyframes)) continue;
72
+ // A pseudo-element's motion is still the host element's motion.
73
+ if (!moving.has(target)) {
74
+ moving.set(target, { name: animation.animationName, verb: moves ? 'moves' : 'blinks' });
75
+ }
76
+ }
77
+ return moving;
78
+ }
79
+
2
80
  export default {
3
81
  id: 'pause-stop-hide',
4
82
  impact: 'serious',
5
83
  tags: ['wcag2a', 'wcag222'],
6
84
  help: 'Moving content must be pausable',
7
85
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html',
8
- selector: 'marquee, blink',
86
+ selector: '*',
9
87
  // The criterion is about VISIBLE movement — a display:none marquee moves
10
88
  // nothing. aria-hidden content still moves on screen, so the rendered
11
89
  // check (not the AT-exposure check) is the right filter.
12
90
  visibility: 'visual',
13
- evaluate(element) {
14
- // scrollamount="0" is the documented way to ship a <marquee> that
15
- // doesn't actually move.
16
- if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
17
- return { status: 'pass' };
18
- }
19
- return {
20
- status: 'fail',
21
- message: `<${element.tagName.toLowerCase()}> scrolls or blinks with no way to pause it, which is unusable for people with attention or vestibular conditions.`,
22
- fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
23
- };
91
+ // One getAnimations() call for the whole page, then a lookup per element.
92
+ evaluateAll(elements) {
93
+ if (!elements.length) return [];
94
+ const moving = endlesslyMoving(elements[0].ownerDocument);
95
+ return elements.map((element) => {
96
+ if (element.tagName === 'MARQUEE' || element.tagName === 'BLINK') {
97
+ // scrollamount="0" is the documented way to ship a <marquee> that
98
+ // doesn't actually move.
99
+ if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
100
+ return { status: 'pass' };
101
+ }
102
+ return {
103
+ status: 'fail',
104
+ message: `<${element.tagName.toLowerCase()}> scrolls or blinks with no way to pause it, which is unusable for people with attention or vestibular conditions.`,
105
+ fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
106
+ };
107
+ }
108
+ const animation = moving.get(element);
109
+ if (!animation || !element.textContent.trim()) return { status: 'pass' };
110
+ const named = animation.name ? ` (the “${animation.name}” animation)` : '';
111
+ return {
112
+ status: 'incomplete',
113
+ message: `This content ${animation.verb} continuously${named}: the animation repeats for ever, so it runs well past the five seconds at which WCAG 2.2.2 requires a way to pause, stop or hide it. Check the page offers one, and that it reaches this content.`,
114
+ };
115
+ });
24
116
  },
25
117
  };
@@ -1,4 +1,33 @@
1
1
  // WCAG SC 2.4.2 Page Titled (Level A)
2
+ // "Web pages have titles that describe topic or purpose." A title that is
3
+ // merely PRESENT satisfies the markup, not the criterion, so the two
4
+ // well-known undescriptive shapes are checked as well.
5
+ //
6
+ // Tier one, failed: strings that are a stand-in under every reading.
7
+ // "Untitled Document" is what an editor writes when the author wrote
8
+ // nothing, and no page is ever about that.
9
+ // Tier two, sent to a human: single generic words and framework scaffold
10
+ // defaults. Almost always a leftover, but a gallery page really can be about
11
+ // an artwork called "Untitled", and "Example Domain" really is the subject
12
+ // of example.com. Those are somebody's call to make, not the engine's.
13
+ //
14
+ // Deliberately NOT judged: whether a real-looking title matches the page it
15
+ // sits on. That needs someone who can read the page, and guessing at it
16
+ // would produce exactly the confident-but-wrong findings this engine avoids.
17
+ const PLACEHOLDER = new Set([
18
+ 'untitled document', 'untitled page', 'untitled-1', 'untitled 1',
19
+ 'new document', 'new page', 'new tab', 'no title',
20
+ 'insert title here', 'title here', 'page title', 'document title',
21
+ 'web page', 'webpage',
22
+ ]);
23
+ const UNDESCRIPTIVE = new Set([
24
+ 'untitled', 'document', 'page', 'title', 'default', 'index',
25
+ 'react app', 'create react app', 'vite app', 'vite + react', 'vite + vue', 'vite + svelte',
26
+ 'next app', 'create next app', 'vue app', 'nuxt app', 'svelte app', 'angular app',
27
+ 'my app', 'my site', 'my website', 'site title', 'your site title',
28
+ 'example domain', 'hello world',
29
+ ]);
30
+
2
31
  export default {
3
32
  id: 'document-title',
4
33
  impact: 'serious',
@@ -8,11 +37,29 @@ export default {
8
37
  selector: 'html',
9
38
  visibleOnly: false,
10
39
  evaluate(element) {
11
- if (element.ownerDocument.title.trim()) return { status: 'pass' };
12
- return {
13
- status: 'fail',
14
- message: 'The page has no title, so users of screen readers and browser tabs cannot tell what it is.',
15
- fix: 'Add <title>Page name — Site name</title> inside <head>.',
16
- };
40
+ const title = element.ownerDocument.title.trim();
41
+ if (!title) {
42
+ return {
43
+ status: 'fail',
44
+ message: 'The page has no title, so users of screen readers and browser tabs cannot tell what it is.',
45
+ fix: 'Add <title>Page name — Site name</title> inside <head>.',
46
+ };
47
+ }
48
+ // Collapsed and lowercased so "Untitled Document" is caught too.
49
+ const normalized = title.replace(/\s+/g, ' ').toLowerCase();
50
+ if (PLACEHOLDER.has(normalized) || /^untitled[\s-]*\d+$/.test(normalized)) {
51
+ return {
52
+ status: 'fail',
53
+ message: `“${title}” is a placeholder left by an editor or template. It gives no topic and no purpose, so a screen reader announces nothing useful when the page loads, and a row of open tabs, bookmarks or history entries becomes impossible to tell apart.`,
54
+ fix: 'Put this page\'s own subject in the title first, then the site name.',
55
+ };
56
+ }
57
+ if (UNDESCRIPTIVE.has(normalized)) {
58
+ return {
59
+ status: 'incomplete',
60
+ message: `“${title}” is a generic title, and usually a default nobody replaced. Unless the page really is about that, it describes neither topic nor purpose and 2.4.2 is not met. Check whether it was meant to be filled in.`,
61
+ };
62
+ }
63
+ return { status: 'pass' };
17
64
  },
18
65
  };
@@ -1,4 +1,6 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ import { cumulativeOpacity } from '../../lib/contrast.js';
3
+
2
4
  const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, '
3
5
  + 'audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]';
4
6
 
@@ -17,12 +19,15 @@ export default {
17
19
  // focus lands there, so the harm this rule describes (sighted keyboard
18
20
  // users interacting with SR-invisible content) doesn't materialise —
19
21
  // an invisible tab stop is a focus-order/visible-focus concern instead.
22
+ // Transparency is measured up the flat tree: opacity is not an inherited
23
+ // property, so a faded WRAPPER (the standard off-canvas drawer) leaves
24
+ // every control inside it computing opacity: 1 while none of them paint.
20
25
  const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
21
26
  (el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.disabled
22
27
  // `inert` removes focusability outright — aria-hidden + inert is
23
28
  // the CORRECT modern pattern for off-canvas content, not a defect.
24
29
  && !el.closest('[inert]')
25
- && isRendered(el) && getComputedStyle(el).opacity !== '0',
30
+ && isRendered(el) && cumulativeOpacity(el) > 0,
26
31
  );
27
32
  if (!focusable.length) return { status: 'pass' };
28
33
  // While a modal dialog is open, the page behind it is DELIBERATELY
package/engine/wcag22.js CHANGED
@@ -68,7 +68,7 @@ export default [
68
68
  sc('2.3.2', 'Three Flashes', 'AAA', 'manual'),
69
69
  sc('2.3.3', 'Animation from Interactions', 'AAA', 'manual', '2.1'),
70
70
  sc('2.4.1', 'Bypass Blocks', 'A', 'partial'), // partial: "repeated across pages" can't be established from one page
71
- sc('2.4.2', 'Page Titled', 'A', 'auto'),
71
+ sc('2.4.2', 'Page Titled', 'A', 'partial'), // partial: document-title proves a title exists and catches known placeholders; whether a real one DESCRIBES the page needs a reader
72
72
  sc('2.4.3', 'Focus Order', 'A', 'manual'), // no rule automates this yet; order judgment needs humans
73
73
  sc('2.4.4', 'Link Purpose (In Context)', 'A', 'partial'),
74
74
  sc('2.4.5', 'Multiple Ways', 'AA', 'manual'),
@@ -89,7 +89,7 @@ export default [
89
89
  sc('2.5.7', 'Dragging Movements', 'AA', 'manual', '2.2'),
90
90
  sc('2.5.8', 'Target Size (Minimum)', 'AA', 'partial', '2.2'), // partial: size/spacing are computable; the equivalent-control and essential exceptions need judgment
91
91
  // 3. Understandable
92
- sc('3.1.1', 'Language of Page', 'A', 'auto'),
92
+ sc('3.1.1', 'Language of Page', 'A', 'partial'), // partial: html-lang proves the tag is present and well-formed; whether it names the language actually written needs a reader
93
93
  sc('3.1.2', 'Language of Parts', 'AA', 'partial'), // partial: invalid lang values are provable; UNMARKED foreign passages need language identification
94
94
  sc('3.1.3', 'Unusual Words', 'AAA', 'manual'),
95
95
  sc('3.1.4', 'Abbreviations', 'AAA', 'manual'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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",