@surea11y/core 1.6.0 → 1.7.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +24 -38
  3. package/docs/ACT_RULE_MAPPING.md +8 -6
  4. package/docs/API_STABILITY.md +51 -3
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/DESIGN_CHALLENGES.md +66 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +28 -2
  9. package/docs/INTEGRATION.md +4 -2
  10. package/docs/LIMITATIONS.md +3 -1
  11. package/docs/OUTPUT_SCHEMA.md +44 -6
  12. package/docs/POLICY.md +1 -1
  13. package/docs/RULE_AUTHORING.md +11 -12
  14. package/docs/RULE_CATALOG.md +76 -26
  15. package/docs/RULE_HELPERS.md +333 -0
  16. package/docs/RULE_TAXONOMY.md +25 -4
  17. package/docs/SARIF.md +21 -2
  18. package/docs/WCAG_CONFORMANCE.md +9 -1
  19. package/package.json +9 -3
  20. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  21. package/src/checks/automatic/aria-allowed-role.js +32 -23
  22. package/src/checks/automatic/aria-braille-equivalent.js +18 -10
  23. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  24. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  25. package/src/checks/automatic/aria-hidden-body.js +1 -1
  26. package/src/checks/automatic/aria-prohibited-attr.js +5 -0
  27. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  28. package/src/checks/automatic/aria-required-attr.js +59 -12
  29. package/src/checks/automatic/aria-required-children.js +33 -16
  30. package/src/checks/automatic/aria-required-parent.js +32 -6
  31. package/src/checks/automatic/aria-role-name-present.js +1 -1
  32. package/src/checks/automatic/aria-roles-valid.js +52 -21
  33. package/src/checks/automatic/aria-valid-attr-value.js +74 -21
  34. package/src/checks/automatic/aria-valid-attr.js +14 -9
  35. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  36. package/src/checks/automatic/contrast-computable.js +10 -0
  37. package/src/checks/automatic/contrast-enhanced.js +12 -0
  38. package/src/checks/automatic/contrast-minimum.js +12 -0
  39. package/src/checks/automatic/css-orientation-lock.js +42 -5
  40. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  41. package/src/checks/automatic/duplicate-id.js +13 -8
  42. package/src/checks/automatic/form-control-single-label.js +9 -0
  43. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  44. package/src/checks/automatic/iframe-focusable-content.js +5 -0
  45. package/src/checks/automatic/label-in-name.js +38 -56
  46. package/src/checks/automatic/link-in-text-block.js +279 -23
  47. package/src/checks/automatic/target-size-minimum.js +84 -5
  48. package/src/checks/automatic/td-has-header.js +19 -18
  49. package/src/checks/manual/form-control-label-quality-manual.js +134 -24
  50. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  51. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  52. package/src/core.js +3863 -44184
  53. package/src/earl.js +144 -0
  54. package/src/sarif.js +22 -2
  55. package/surea11y.browser.js +10 -41039
  56. package/surea11y.i18n.de.js +2 -21
  57. package/surea11y.i18n.es.js +2 -21
  58. package/surea11y.i18n.fr.js +2 -21
  59. /package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +0 -0
@@ -31,13 +31,13 @@
31
31
  * of the actual adjacent text node(s), a deliberate scope-down, since
32
32
  * plain text nodes inherit their rendering from the parent in the
33
33
  * overwhelming majority of real markup.
34
- * - When contrast is not confidently computable (background image/
35
- * gradient, blend mode, filter, non-opaque ancestor, same blockers
36
- * `contrast-minimum`/`contrast-computable` use), the link is silently
37
- * skipped rather than flagged or reported as cantTell, to keep `fail`
38
- * reserved for deterministic, high-confidence violations. This means
39
- * the rule never emits cantTell, outcome is notApplicable/pass/fail
40
- * only, matching this repo's other Tier 2 mechanical rules.
34
+ * - A candidate that cannot be evaluated reports cantTell, not pass:
35
+ * contrast not confidently computable (the blockers `contrast-minimum`/
36
+ * `contrast-computable` use), or `text-decoration` unreadable in both
37
+ * the computed style and the CSSOM (see `decorationInfo`). `fail` stays
38
+ * reserved for deterministic violations; this is the computability gate
39
+ * RULE_TAXONOMY.md §1.1 allows automatic rules, as in `contrast-minimum`
40
+ * and `target-size-minimum`.
41
41
  * - Reuses the shared `helpers.contrast` subsystem (same
42
42
  * computeEffectiveForeground/Background, getComputabilityBlocker,
43
43
  * contrastRatio helpers as `contrast-minimum`), rather than re-deriving
@@ -103,14 +103,176 @@ function runInPage(ctx) {
103
103
  return null;
104
104
  }
105
105
 
106
- function decorationTokens(cs) {
107
- const raw =
108
- `${(cs && cs.textDecorationLine) || ''} ${(cs && cs.textDecoration) || ''}`.toLowerCase();
109
- return raw.split(/\s+/).filter(Boolean);
106
+ // Whether the element is underlined, and whether the computed style can be
107
+ // trusted to say so. A conforming CSSOM serialises the `text-decoration`
108
+ // shorthand with the line value first, so it and `text-decoration-line`
109
+ // always agree on whether `underline` is present. jsdom does not cascade
110
+ // the property at all: the shorthand reads back as the UA's "underline"
111
+ // for every <a> whatever the author CSS says, and the longhand as "none"
112
+ // unless the author used the longhand. Either one taken alone is wrong in
113
+ // one direction, so disagreement is the signal to stop trusting both.
114
+ function decorationInfo(cs) {
115
+ if (!cs) return { underlined: false, trustworthy: false };
116
+
117
+ const lineRaw = String(cs.textDecorationLine || '')
118
+ .trim()
119
+ .toLowerCase();
120
+ const shortRaw = String(cs.textDecoration || '')
121
+ .trim()
122
+ .toLowerCase();
123
+
124
+ const lineTokens = lineRaw.split(/\s+/).filter(Boolean);
125
+ const shortTokens = shortRaw.split(/\s+/).filter(Boolean);
126
+
127
+ // Only one of the two exposed: nothing to cross-check against, take it.
128
+ if (!lineTokens.length) {
129
+ return { underlined: shortTokens.includes('underline'), trustworthy: shortTokens.length > 0 };
130
+ }
131
+ if (!shortTokens.length) {
132
+ return { underlined: lineTokens.includes('underline'), trustworthy: true };
133
+ }
134
+
135
+ const byLine = lineTokens.includes('underline');
136
+ const byShort = shortTokens.includes('underline');
137
+ return { underlined: byLine, trustworthy: byLine === byShort };
138
+ }
139
+
140
+ // Resolves `text-decoration` from the author stylesheets when the computed
141
+ // style is untrustworthy, reading the CSSOM as `css-orientation-lock` and
142
+ // `css-focus-indicator-suppressed` do. Without it the rule could not decide
143
+ // anything under a DOM emulator, which is how the CLI scans static HTML.
144
+ //
145
+ // A narrow cascade is enough: `text-decoration-line` is not inherited, so
146
+ // only declarations matching the element itself and its inline style apply,
147
+ // ordered by specificity. With no author declaration the UA default stands,
148
+ // and for a link that is an underline. Anything that would make the answer a
149
+ // guess yields `resolved: false` and the caller reports cantTell.
150
+ const CSS_STYLE_RULE = 1;
151
+ const MAX_NESTED_DEPTH = 8;
152
+
153
+ function splitSelectorList(selectorText) {
154
+ const parts = [];
155
+ let depth = 0;
156
+ let current = '';
157
+ for (const ch of String(selectorText || '')) {
158
+ if (ch === '(') depth += 1;
159
+ if (ch === ')') depth = Math.max(0, depth - 1);
160
+ if (ch === ',' && depth === 0) {
161
+ parts.push(current);
162
+ current = '';
163
+ continue;
164
+ }
165
+ current += ch;
166
+ }
167
+ parts.push(current);
168
+ return parts.map((p) => p.trim()).filter(Boolean);
110
169
  }
111
170
 
112
- function hasUnderline(cs) {
113
- return decorationTokens(cs).includes('underline');
171
+ // Approximate CSS specificity as a single sortable integer. Exactness is
172
+ // not required: this only orders declarations of one property against each
173
+ // other, and near-ties are broken by document order as the cascade does.
174
+ function specificityOf(selector) {
175
+ const s = String(selector || '');
176
+ const ids = (s.match(/#[\w-]+/g) || []).length;
177
+ const classesEtc = (s.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length;
178
+ const types = (s.match(/(^|[\s>+~])[a-z][\w-]*/gi) || []).length;
179
+ return ids * 10000 + classesEtc * 100 + types;
180
+ }
181
+
182
+ // A declaration wins if it is the last one, in (specificity, order), whose
183
+ // selector matches. `!important` outranks everything non-important.
184
+ function underlineFromDeclaration(style) {
185
+ if (!style || typeof style.getPropertyValue !== 'function') return null;
186
+ for (const prop of ['text-decoration-line', 'text-decoration']) {
187
+ const raw = String(style.getPropertyValue(prop) || '')
188
+ .trim()
189
+ .toLowerCase();
190
+ if (!raw) continue;
191
+ const important = String(style.getPropertyPriority(prop) || '') === 'important';
192
+ return { underlined: /\bunderline\b/.test(raw), important };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function resolveUnderlineFromCssom(el) {
198
+ const doc = el && el.ownerDocument ? el.ownerDocument : null;
199
+ if (!doc || typeof el.matches !== 'function') return { underlined: false, resolved: false };
200
+
201
+ let best = null; // { rank, order, underlined }
202
+ let order = 0;
203
+ let unreadableSheet = false;
204
+ let unparsableSelector = false;
205
+
206
+ function consider(cssRule) {
207
+ const decl = underlineFromDeclaration(cssRule.style);
208
+ if (!decl) return;
209
+ for (const part of splitSelectorList(cssRule.selectorText)) {
210
+ // A pseudo-element rule paints a box other than the link's own text.
211
+ if (/::[a-z-]+/i.test(part)) continue;
212
+ // A state the static DOM is not in (:hover/:focus/...) does not
213
+ // describe the link's resting appearance, which is what this rule is
214
+ // about.
215
+ if (/:(hover|focus|focus-visible|focus-within|active|target|visited)\b/i.test(part)) {
216
+ continue;
217
+ }
218
+ let matched;
219
+ try {
220
+ matched = el.matches(part);
221
+ } catch {
222
+ unparsableSelector = true;
223
+ continue;
224
+ }
225
+ if (!matched) continue;
226
+ order += 1;
227
+ const rank = (decl.important ? 1e9 : 0) + specificityOf(part);
228
+ if (!best || rank >= best.rank) best = { rank, order, underlined: decl.underlined };
229
+ }
230
+ }
231
+
232
+ function walk(rules, depth) {
233
+ if (!rules || depth > MAX_NESTED_DEPTH) return;
234
+ for (const cssRule of rules) {
235
+ if (!cssRule) continue;
236
+ if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
237
+ consider(cssRule);
238
+ continue;
239
+ }
240
+ let nested;
241
+ try {
242
+ nested = cssRule.cssRules || null;
243
+ } catch {
244
+ nested = null;
245
+ }
246
+ if (nested) walk(nested, depth + 1);
247
+ }
248
+ }
249
+
250
+ try {
251
+ for (const sheet of doc.styleSheets || []) {
252
+ let rules = null;
253
+ try {
254
+ rules = sheet && sheet.cssRules ? sheet.cssRules : null;
255
+ } catch {
256
+ unreadableSheet = true; // cross-origin, not inspectable
257
+ continue;
258
+ }
259
+ if (rules) walk(rules, 0);
260
+ }
261
+ } catch {
262
+ return { underlined: false, resolved: false };
263
+ }
264
+
265
+ // The inline style attribute outranks every stylesheet declaration.
266
+ const inline = underlineFromDeclaration(el.style);
267
+ if (inline) return { underlined: inline.underlined, resolved: true };
268
+
269
+ if (best) return { underlined: best.underlined, resolved: true };
270
+
271
+ // No author declaration reached this element. If a sheet or selector was
272
+ // unreadable, one of them might have, so the answer is unknown; otherwise
273
+ // the UA default stands, and for a link that means underlined.
274
+ if (unreadableSheet || unparsableSelector) return { underlined: false, resolved: false };
275
+ return { underlined: true, resolved: true };
114
276
  }
115
277
 
116
278
  function hasSurroundingText(el, parent) {
@@ -141,7 +303,15 @@ function runInPage(ctx) {
141
303
  : helpers.queryAll(selector);
142
304
 
143
305
  const occurrences = [];
306
+ const undecided = [];
144
307
  let applicableCount = 0;
308
+ let decidedCount = 0;
309
+
310
+ // Applicable, but not evaluable. Held separately so the outcome below can
311
+ // tell "checked and sound" apart from "never decided".
312
+ function markUndecided(el, reasonCode) {
313
+ undecided.push({ el, reasonCode });
314
+ }
145
315
 
146
316
  for (const el of nodes) {
147
317
  if (!el || !el.getAttribute) continue;
@@ -159,27 +329,40 @@ function runInPage(ctx) {
159
329
  const linkCs = safeComputedStyle(el);
160
330
  const parentCs = safeComputedStyle(parent);
161
331
 
162
- if (hasUnderline(linkCs)) continue;
163
-
332
+ // Cues that do not depend on `text-decoration` come first, so a link
333
+ // carrying one is decided even where decoration is unreadable.
164
334
  const linkWeight = c && linkCs ? c.normalizeFontWeight(linkCs.fontWeight) : 400;
165
335
  const parentWeight = c && parentCs ? c.normalizeFontWeight(parentCs.fontWeight) : 400;
166
- if (linkWeight !== parentWeight) continue;
336
+ if (linkWeight !== parentWeight) {
337
+ decidedCount += 1;
338
+ continue;
339
+ }
167
340
 
168
341
  const linkStyle = (linkCs && linkCs.fontStyle) || 'normal';
169
342
  const parentStyle = (parentCs && parentCs.fontStyle) || 'normal';
170
- if (linkStyle !== parentStyle) continue;
343
+ if (linkStyle !== parentStyle) {
344
+ decidedCount += 1;
345
+ continue;
346
+ }
171
347
 
172
- if (!c) continue;
348
+ if (!c) {
349
+ markUndecided(el, 'CONTRAST_HELPERS_UNAVAILABLE');
350
+ continue;
351
+ }
173
352
 
174
353
  let flagged = false;
354
+ let computed = false;
175
355
  let ratio = null;
176
356
  let fgLinkHex = '';
177
357
  let fgParentHex = '';
358
+ let undecidedReason = 'COLOR_NOT_COMPUTABLE';
178
359
 
179
360
  try {
180
361
  const blocker = c.getComputabilityBlocker(el);
181
362
  if (blocker && blocker.ok === false) {
182
- // Not confidently computable, skip (benefit of the doubt).
363
+ // Not confidently computable: recorded below rather than skipped, so
364
+ // it cannot be mistaken for a clean result.
365
+ if (blocker.reasonCode) undecidedReason = String(blocker.reasonCode);
183
366
  } else {
184
367
  const bg = c.computeEffectiveBackground(el, {
185
368
  contrast: { mode, rootCanvasFallback },
@@ -202,15 +385,49 @@ function runInPage(ctx) {
202
385
  fgLinkHex = c.rgbToHex ? c.rgbToHex(fgLinkOpaque) : '';
203
386
  fgParentHex = c.rgbToHex ? c.rgbToHex(fgParentOpaque) : '';
204
387
 
388
+ computed = true;
205
389
  if (!(ratio >= 3)) flagged = true;
206
390
  }
207
- // else: not confidently computable, skip.
391
+ // else: not confidently computable, recorded below.
208
392
  }
209
393
  } catch {
210
- // no-throw: treat as not computable, skip.
394
+ // No-throw: treat as not computable and record it.
395
+ undecidedReason = 'ENGINE_EXCEPTION';
396
+ }
397
+
398
+ if (!computed) {
399
+ markUndecided(el, undecidedReason);
400
+ continue;
401
+ }
402
+
403
+ // Contrast alone is an accepted alternative to an underline (G183), so a
404
+ // link clearing 3:1 is distinguishable regardless of decoration.
405
+ if (!flagged) {
406
+ decidedCount += 1;
407
+ continue;
408
+ }
409
+
410
+ // Below 3:1, an underline is the last remaining cue -- and only now does
411
+ // it matter whether this environment can actually report one.
412
+ const decoration = decorationInfo(linkCs);
413
+ let underlined;
414
+ if (decoration.trustworthy) {
415
+ underlined = decoration.underlined;
416
+ } else {
417
+ const fromCssom = resolveUnderlineFromCssom(el);
418
+ if (!fromCssom.resolved) {
419
+ markUndecided(el, 'TEXT_DECORATION_NOT_RESOLVABLE');
420
+ continue;
421
+ }
422
+ underlined = fromCssom.underlined;
423
+ }
424
+
425
+ if (underlined) {
426
+ decidedCount += 1;
427
+ continue;
211
428
  }
212
429
 
213
- if (!flagged) continue;
430
+ decidedCount += 1;
214
431
 
215
432
  const eligInfo = helpers.getEligibilityInfo
216
433
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
@@ -243,6 +460,9 @@ function runInPage(ctx) {
243
460
  if (applicableCount === 0) {
244
461
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
245
462
  }
463
+
464
+ // A proven violation outranks an undecided candidate, which outranks a clean
465
+ // one. The middle step keeps an unevaluable link out of `pass`.
246
466
  if (occurrences.length) {
247
467
  return {
248
468
  ruleId: rule.ruleId,
@@ -251,7 +471,43 @@ function runInPage(ctx) {
251
471
  occurrences
252
472
  };
253
473
  }
254
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
474
+
475
+ if (undecided.length) {
476
+ return {
477
+ ruleId: rule.ruleId,
478
+ outcome: 'cantTell',
479
+ severity: rule.defaultSeverity || 'serious',
480
+ confidence: 'low',
481
+ occurrences: undecided.map(({ el, reasonCode }) =>
482
+ helpers.reportOccurrence(el, {
483
+ summary:
484
+ 'Whether this link is distinguishable from the surrounding text by non-color means could not be determined.',
485
+ hint: 'Confirm by eye that the link carries an underline, a font-weight or font-style difference, or at least 3:1 contrast against the surrounding text. Running the engine in a real browser rather than a DOM emulator resolves most cases automatically.',
486
+ i18n: {
487
+ summaryKey: 'linkInTextBlock_summary_cantTell',
488
+ hintKey: 'linkInTextBlock_hint_cantTell'
489
+ },
490
+ uncertainty: {
491
+ code: 'not-computable',
492
+ needed:
493
+ 'Whether the link carries an underline, weight or style difference, or 3:1 contrast against its surrounding text.',
494
+ evidence: { reasonCode }
495
+ },
496
+ data: {
497
+ visibilityFilter: helpers.getEligibilityInfo
498
+ ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
499
+ : { targetSet: 'acc', accEligible: null, reasons: [] },
500
+ details: { reasonCode }
501
+ }
502
+ })
503
+ )
504
+ };
505
+ }
506
+
507
+ if (decidedCount > 0) {
508
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
509
+ }
510
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
255
511
  }
256
512
 
257
513
  module.exports = { id, meta, runInPage };
@@ -293,14 +293,29 @@ function runInPage(ctx) {
293
293
  }
294
294
  }
295
295
 
296
+ // Per-run memoization: getComputedStyle is a real, non-trivial cost in an
297
+ // actual browser (unlike jsdom, which no-ops it), and hasSpacingConflict
298
+ // below calls getStyle on the SAME candidate element repeatedly -- once
299
+ // per undersized target it's compared against, via
300
+ // isInlineTextExceptionTarget/isInlineLinkTarget -- so with U undersized
301
+ // targets and N total candidates this was an uncached O(U * N)
302
+ // getComputedStyle call count. The DOM is read-only for the rest of this
303
+ // rule's run (no writes between reads), so caching per element here is
304
+ // safe -- style cannot change mid-run.
305
+ const __styleCache = new WeakMap();
296
306
  function getStyle(el) {
307
+ if (__styleCache.has(el)) return __styleCache.get(el);
308
+ let cs;
297
309
  try {
298
- return document && document.defaultView && document.defaultView.getComputedStyle
299
- ? document.defaultView.getComputedStyle(el)
300
- : null;
310
+ cs =
311
+ document && document.defaultView && document.defaultView.getComputedStyle
312
+ ? document.defaultView.getComputedStyle(el)
313
+ : null;
301
314
  } catch {
302
- return null;
315
+ cs = null;
303
316
  }
317
+ __styleCache.set(el, cs);
318
+ return cs;
304
319
  }
305
320
 
306
321
  function isPointerReachable(el) {
@@ -431,6 +446,46 @@ function runInPage(ctx) {
431
446
 
432
447
  const undersized = items.filter((it) => it.rect.width < MIN || it.rect.height < MIN);
433
448
 
449
+ // Spatial grid over ALL items, cell size = MIN (24px), so hasSpacingConflict's
450
+ // proximity check below doesn't have to compare every undersized target
451
+ // against every other item -- an O(items^2) cost that dominates real-browser
452
+ // (not jsdom -- see this rule's own perf note further down) runtime on a
453
+ // page with many small targets. A point can only be within MIN of another
454
+ // point that shares its grid cell or one of the 8 adjacent cells: cell size
455
+ // equals the search radius, so two points in cells 2+ apart on either axis
456
+ // are, on that axis alone, already >= MIN apart. Restricting the candidate
457
+ // set to that 3x3 neighborhood is therefore never a false negative -- the
458
+ // exact same dist() < MIN check still runs on every candidate it returns,
459
+ // just skipping candidates that are geometrically guaranteed too far away.
460
+ // This changes performance only, never which targets conflict.
461
+ const grid = new Map(); // "cx,cy" -> item[]
462
+ function cellKeyFor(cx, cy) {
463
+ return cx + ',' + cy;
464
+ }
465
+ for (const it of items) {
466
+ const cx = Math.floor(it.center.cx / MIN);
467
+ const cy = Math.floor(it.center.cy / MIN);
468
+ const key = cellKeyFor(cx, cy);
469
+ let bucket = grid.get(key);
470
+ if (!bucket) {
471
+ bucket = [];
472
+ grid.set(key, bucket);
473
+ }
474
+ bucket.push(it);
475
+ }
476
+ function nearbyItems(center) {
477
+ const cx = Math.floor(center.cx / MIN);
478
+ const cy = Math.floor(center.cy / MIN);
479
+ const out = [];
480
+ for (let dx = -1; dx <= 1; dx++) {
481
+ for (let dy = -1; dy <= 1; dy++) {
482
+ const bucket = grid.get(cellKeyFor(cx + dx, cy + dy));
483
+ if (bucket) for (const it of bucket) out.push(it);
484
+ }
485
+ }
486
+ return out;
487
+ }
488
+
434
489
  // --- spacing/occlusion evaluation ---
435
490
  function hasSpacingConflict(target) {
436
491
  // 0) Pure geometry: deterministic center-distance check against ANY
@@ -438,7 +493,7 @@ function runInPage(ctx) {
438
493
  // spacing exception depends on proximity to any adjacent target, so an
439
494
  // undersized target sitting flush against an adequately-sized one still
440
495
  // fails the exception, which an undersized-only comparison would miss.
441
- for (const other of items) {
496
+ for (const other of nearbyItems(target.center)) {
442
497
  if (!other || !other.el || isRelated(target.el, other.el)) continue;
443
498
 
444
499
  // Ignore inline-text exception targets when evaluating spacing conflicts.
@@ -614,6 +669,14 @@ function runInPage(ctx) {
614
669
  hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
615
670
  params: {}
616
671
  },
672
+ uncertainty: {
673
+ code: 'not-computable',
674
+ needed: 'A reliable measurement of the spacing between this target and its neighbour.',
675
+ evidence: {
676
+ measured: { width: it.rect.width, height: it.rect.height },
677
+ conflictHitCount: info.hitCount
678
+ }
679
+ },
617
680
  data: {
618
681
  details: {
619
682
  measured: { width: it.rect.width, height: it.rect.height },
@@ -643,6 +706,14 @@ function runInPage(ctx) {
643
706
  hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
644
707
  params: {}
645
708
  },
709
+ uncertainty: {
710
+ code: 'judgement-required',
711
+ needed: 'Whether this target’s size is essential, which WCAG exempts.',
712
+ evidence: {
713
+ measured: { width: it.rect.width, height: it.rect.height },
714
+ conflictHitCount: info.hitCount
715
+ }
716
+ },
646
717
  data: {
647
718
  details: {
648
719
  measured: { width: it.rect.width, height: it.rect.height },
@@ -671,6 +742,14 @@ function runInPage(ctx) {
671
742
  hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
672
743
  params: {}
673
744
  },
745
+ uncertainty: {
746
+ code: 'judgement-required',
747
+ needed: 'Whether this target is a link in a sentence, which WCAG exempts.',
748
+ evidence: {
749
+ measured: { width: it.rect.width, height: it.rect.height },
750
+ conflictHitCount: info.hitCount
751
+ }
752
+ },
674
753
  data: {
675
754
  details: {
676
755
  measured: { width: it.rect.width, height: it.rect.height },
@@ -118,27 +118,28 @@ function runInPage(ctx) {
118
118
  return !!(cell && cell.tagName && cell.tagName.toLowerCase() === 'th' && isEligible(cell));
119
119
  }
120
120
 
121
- function hasColumnHeaderAbove(r, c) {
122
- for (let ri = 0; ri < r; ri++) {
123
- const cell = rowCells[ri] && rowCells[ri][c];
124
- if (isHeaderCell(cell)) return true;
125
- }
126
- return false;
127
- }
128
-
129
- function hasRowHeaderBefore(r, c) {
130
- const cells = rowCells[r] || [];
131
- for (let ci = 0; ci < c; ci++) {
132
- if (isHeaderCell(cells[ci])) return true;
133
- }
134
- return false;
135
- }
121
+ // "Was there a <th> above this cell's column" and "was there a <th>
122
+ // earlier in this cell's row" are both prefix questions over a scan
123
+ // already in progress (rows top to bottom, cells left to right within a
124
+ // row), so each is tracked incrementally instead of rescanning the
125
+ // rows/columns already passed for every cell: colHasHeaderAbove[c]
126
+ // carries forward across rows, rowHasHeaderBefore resets at the start
127
+ // of each row. One pass over every cell, not one rescan per cell.
128
+ const colHasHeaderAbove = new Array(maxCols).fill(false);
136
129
 
137
130
  for (let r = 0; r < rowCells.length; r++) {
138
131
  const cells = rowCells[r];
132
+ let rowHasHeaderBefore = false;
133
+
139
134
  for (let c = 0; c < cells.length; c++) {
140
135
  const cell = cells[c];
141
- if (!cell || isHeaderCell(cell)) continue;
136
+ if (!cell) continue;
137
+
138
+ if (isHeaderCell(cell)) {
139
+ colHasHeaderAbove[c] = true;
140
+ rowHasHeaderBefore = true;
141
+ continue;
142
+ }
142
143
 
143
144
  // An aria-hidden data cell isn't exposed to AT either, so it has
144
145
  // no need for an accessible header association.
@@ -147,8 +148,8 @@ function runInPage(ctx) {
147
148
  const headersAttr = trim(cell.getAttribute('headers'));
148
149
  if (headersAttr) continue;
149
150
 
150
- if (hasColumnHeaderAbove(r, c)) continue;
151
- if (hasRowHeaderBefore(r, c)) continue;
151
+ if (colHasHeaderAbove[c]) continue;
152
+ if (rowHasHeaderBefore) continue;
152
153
 
153
154
  occurrences.push(
154
155
  helpers.reportOccurrence(cell, {