@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
@@ -240,6 +240,7 @@ function runInPage(ctx) {
240
240
 
241
241
  const findings = [];
242
242
  let sheetCount = 0;
243
+ let unreadableSheetCount = 0;
243
244
 
244
245
  try {
245
246
  const sheets = document.styleSheets || [];
@@ -248,7 +249,10 @@ function runInPage(ctx) {
248
249
  try {
249
250
  rules = sheet && sheet.cssRules ? sheet.cssRules : null;
250
251
  } catch {
251
- continue; // cross-origin stylesheet, not inspectable
252
+ // Cross-origin, not inspectable. Counted, since a lock could be
253
+ // declared there and a `pass` would claim more than was checked.
254
+ unreadableSheetCount += 1;
255
+ continue;
252
256
  }
253
257
  if (!rules) continue;
254
258
  sheetCount += 1;
@@ -264,15 +268,48 @@ function runInPage(ctx) {
264
268
  // no-throw: treat as no accessible stylesheets
265
269
  }
266
270
 
267
- if (sheetCount === 0) {
268
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
269
- }
271
+ const scanTarget = document.documentElement || document.body || null;
270
272
 
273
+ // A lock found in a readable sheet is still a lock, so `fail` outranks the
274
+ // uncertainty below.
271
275
  if (!findings.length) {
276
+ if (unreadableSheetCount > 0) {
277
+ return {
278
+ ruleId: rule.ruleId,
279
+ outcome: 'cantTell',
280
+ severity: rule.defaultSeverity || 'serious',
281
+ confidence: 'low',
282
+ occurrences: [
283
+ helpers.reportOccurrence(scanTarget, {
284
+ summary: `${unreadableSheetCount} stylesheet(s) could not be read, so whether this page locks its orientation could not be determined.`,
285
+ hint: 'Cross-origin stylesheets are not inspectable from the page. Check any third-party CSS for an orientation media query containing a rotate() transform, or re-run the scan with those stylesheets served same-origin.',
286
+ i18n: {
287
+ summaryKey: 'cssOrientationLock_summary_cantTell_unreadableSheets',
288
+ hintKey: 'cssOrientationLock_hint_cantTell_unreadableSheets',
289
+ params: { count: String(unreadableSheetCount) }
290
+ },
291
+ uncertainty: {
292
+ code: 'not-computable',
293
+ needed: 'The contents of the stylesheets this scan could not read.',
294
+ evidence: { unreadableSheetCount, reasonCode: 'STYLESHEETS_NOT_READABLE' }
295
+ },
296
+ data: {
297
+ details: {
298
+ reasonCode: 'STYLESHEETS_NOT_READABLE',
299
+ unreadableSheetCount
300
+ }
301
+ }
302
+ })
303
+ ]
304
+ };
305
+ }
306
+ if (sheetCount === 0) {
307
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
308
+ }
272
309
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
273
310
  }
274
311
 
275
- const target = document.documentElement || document.body || null;
312
+ const target = scanTarget;
276
313
  const occurrences = findings.map((f) =>
277
314
  helpers.reportOccurrence(target, {
278
315
  summary: f.selectorText
@@ -128,6 +128,11 @@ function runInPage(ctx) {
128
128
  hintKey: 'duplicateIdAria_hint_cantTell',
129
129
  params: { id: refId, duplicateCount: String(els.length) }
130
130
  },
131
+ uncertainty: {
132
+ code: 'judgement-required',
133
+ needed: 'Whether the first element carrying this id is the intended target.',
134
+ evidence: { id: refId, duplicateCount: els.length, resolvesTo: 'first' }
135
+ },
131
136
  data: {
132
137
  details: {
133
138
  reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
@@ -19,14 +19,19 @@
19
19
  * two different shadow roots is not a duplicate.
20
20
  * @implementation-notes
21
21
  * - WCAG-VERSION SCOPED. SC 4.1.1 Parsing was removed in WCAG 2.2, so this
22
- * rule is tagged `wcag2a` (its 2.0/2.1 origin) plus `wcag22-removed`. A
23
- * consumer targeting WCAG 2.2 excludes it with
24
- * `excludeTags: ['wcag22-removed']`; one targeting 2.0 or 2.1 keeps it
25
- * and gets a real 4.1.1 result. The alternative, dropping the SC
26
- * mapping entirely, would have made a genuine 2.0/2.1 failure
27
- * invisible to anyone conformance-testing against those versions. See
28
- * `docs/ENGINE_OPTIONS.md` for the tag, and `docs/DESIGN_CHALLENGES.md`
29
- * for the decision this reverses.
22
+ * rule is tagged `wcag2a` (its 2.0/2.1 origin) plus `wcag22-removed`.
23
+ * The engine acts on that tag itself: under a 2.2 target, which is the
24
+ * default, this rule still runs and still reports every duplicate it
25
+ * finds, but its fail is coerced to `cantTell` with a `wcagVersionScope`
26
+ * field saying why (see scopeOutcomeToWcagVersion in
27
+ * `src/core/dom-runner.js`). A consumer targeting 2.0 or 2.1
28
+ * (`engineOptions.wcagVersion`, or a tag set that implies it) gets the
29
+ * real 4.1.1 failure; one that would rather not see the rule at all
30
+ * under 2.2 still excludes it with `excludeTags: ['wcag22-removed']`.
31
+ * The alternative, dropping the SC mapping entirely, would have made a
32
+ * genuine 2.0/2.1 failure invisible to anyone conformance-testing
33
+ * against those versions. See `docs/ENGINE_OPTIONS.md` for the option
34
+ * and the tag, and `docs/DESIGN_CHALLENGES.md` for the decision history.
30
35
  * - The defect outlives its Success Criterion: a duplicate id breaks
31
36
  * `<label for>` association, fragment navigation, `getElementById`, and
32
37
  * every ID-reference attribute, none of which stopped mattering when
@@ -173,6 +173,15 @@ function runInPage(ctx) {
173
173
  hintKey: 'formControlSingleLabel_hint_cantTell',
174
174
  params: { element: tag, labelCount: String(eligibleLabels.size) }
175
175
  },
176
+ uncertainty: {
177
+ code: 'spec-only',
178
+ needed: 'Whether the empty label is filled in at runtime or is simply redundant.',
179
+ evidence: {
180
+ element: tag,
181
+ labelCount: eligibleLabels.size,
182
+ contributingLabelCount: contributing.length
183
+ }
184
+ },
176
185
  data: {
177
186
  details: {
178
187
  reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
@@ -0,0 +1,229 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check identical-iframes-same-purpose
7
+ * @atomic true
8
+ * @summary Frames sharing an accessible name must embed the same resource
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to each set of two or more <iframe>/<frame> elements that are
13
+ * included in the accessibility tree and share the same non-empty
14
+ * accessible name, compared with whitespace collapsed. A frame named
15
+ * only by a mechanism that names nothing, or hidden from the
16
+ * accessibility tree, is not part of a set; a set needs two surviving
17
+ * members to exist at all.
18
+ * @expectation
19
+ * Every frame in a set resolves to the same resource. A shared name
20
+ * describes one resource, so two frames answering to it must embed the
21
+ * same one.
22
+ * @implementation-notes
23
+ * - Distinct from iframe-title-unique, which asks the stricter question of
24
+ * whether the title ATTRIBUTE repeats at all, and answers it from static
25
+ * markup. This rule keys on the computed accessible name and judges the
26
+ * resource behind it.
27
+ * - src values are compared as resolved absolute URLs with the fragment
28
+ * removed and a trailing slash normalised away, so a directory written
29
+ * both with and without one is a single resource.
30
+ * - Frames that resolve to different URLs are reported cantTell, never
31
+ * fail. Different resources can still be equivalent — differently worded
32
+ * copies of one page, or two adverts serving the same purpose — and
33
+ * nothing in the markup settles it. Comparing the embedded documents
34
+ * would not settle it either, since content differing is exactly what
35
+ * those equivalent cases look like.
36
+ */
37
+
38
+ const id = 'identical-iframes-same-purpose';
39
+
40
+ const meta = {
41
+ title: 'Frames with the same name embed the same resource',
42
+ description:
43
+ 'Checks that <iframe>/<frame> elements sharing an accessible name embed the same resource, since one name can only describe one resource.',
44
+ i18n: {
45
+ titleKey: 'identicalIframesSamePurpose_title',
46
+ descriptionKey: 'identicalIframesSamePurpose_description'
47
+ },
48
+ helpUrl: null,
49
+ tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
50
+ wcagSc: ['4.1.2'],
51
+ normativeMappings: [
52
+ {
53
+ standard: 'WCAG',
54
+ version: '2.2',
55
+ requirement: '4.1.2',
56
+ title: 'Name, Role, Value',
57
+ conformanceLevel: 'A'
58
+ }
59
+ ],
60
+ defaultSeverity: 'moderate',
61
+ category: 'robust',
62
+ type: 'automatic',
63
+ defaultConfidence: 'medium',
64
+ coverage: { facetsBySc: { '4.1.2': ['identical-iframes-same-purpose'] } }
65
+ };
66
+
67
+ function runInPage(ctx) {
68
+ const { helpers, rule } = ctx;
69
+
70
+ const nodes = helpers.queryAllSmart
71
+ ? helpers.queryAllSmart('iframe, frame')
72
+ : helpers.queryAll('iframe, frame');
73
+
74
+ function normalizedName(el) {
75
+ if (!helpers.getAccessibleNameInfo) return '';
76
+ let info;
77
+ try {
78
+ info = helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 });
79
+ } catch {
80
+ return '';
81
+ }
82
+ if (!info || !info.present || !info.value) return '';
83
+ return String(info.value).replace(/\s+/g, ' ').trim();
84
+ }
85
+
86
+ // A light-DOM child of a shadow host with no slot to land in is absent from
87
+ // the flat tree and so renders nowhere, which the shared eligibility helper
88
+ // does not model.
89
+ function isUnslotted(el) {
90
+ try {
91
+ let cur = el;
92
+ let guard = 0;
93
+ while (cur && cur.nodeType === 1 && guard++ < 100) {
94
+ const parent = cur.parentNode;
95
+ if (!parent || parent.nodeType !== 1) return false;
96
+ if (parent.shadowRoot && cur.assignedSlot == null) return true;
97
+ cur = parent;
98
+ }
99
+ return false;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function inAccessibilityTree(el) {
106
+ if (isUnslotted(el)) return false;
107
+ if (!helpers.isIncludedInAccessibilityTree) return true;
108
+ try {
109
+ return !!helpers.isIncludedInAccessibilityTree(el);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ // A directory written with and without its trailing slash is one resource,
116
+ // and a fragment selects within a resource rather than naming another.
117
+ function resourceKey(el) {
118
+ let raw;
119
+ try {
120
+ raw = el.getAttribute('src');
121
+ } catch {
122
+ return null;
123
+ }
124
+ if (raw == null || !String(raw).trim()) return null;
125
+
126
+ const doc = (ctx && ctx.document) || (el.ownerDocument ? el.ownerDocument : null);
127
+ const base = doc && doc.baseURI ? doc.baseURI : undefined;
128
+ try {
129
+ const u = new URL(String(raw).trim(), base);
130
+ let pathname = u.pathname;
131
+ if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
132
+ pathname = pathname.slice(0, -1);
133
+ }
134
+ return u.protocol + '//' + u.host + pathname + u.search;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ const groups = new Map();
141
+
142
+ for (const el of nodes) {
143
+ if (!el || !el.tagName) continue;
144
+ if (!inAccessibilityTree(el)) continue;
145
+
146
+ const name = normalizedName(el);
147
+ if (!name) continue;
148
+
149
+ const list = groups.get(name);
150
+ if (list) list.push(el);
151
+ else groups.set(name, [el]);
152
+ }
153
+
154
+ const sets = [];
155
+ for (const [name, els] of groups) {
156
+ if (els.length >= 2) sets.push([name, els]);
157
+ }
158
+
159
+ if (!sets.length) {
160
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
161
+ }
162
+
163
+ const occurrences = [];
164
+
165
+ for (const [name, els] of sets) {
166
+ const keys = els.map(resourceKey);
167
+ const resolved = keys.filter((k) => k != null);
168
+ const allResolved = resolved.length === keys.length;
169
+ const allSame = allResolved && resolved.every((k) => k === resolved[0]);
170
+ if (allSame) continue;
171
+
172
+ for (let i = 0; i < els.length; i++) {
173
+ const el = els[i];
174
+ const tag = el.tagName.toLowerCase();
175
+ occurrences.push(
176
+ helpers.reportOccurrence(el, {
177
+ summary:
178
+ 'This frame shares its accessible name with another frame that embeds a different resource.',
179
+ hint: 'Give each frame a name describing the resource it embeds, or point them at the same resource.',
180
+ i18n: {
181
+ summaryKey: 'identicalIframesSamePurpose_summary_cantTell',
182
+ hintKey: 'identicalIframesSamePurpose_hint_cantTell',
183
+ params: { element: tag, name }
184
+ },
185
+ uncertainty:
186
+ keys[i] == null
187
+ ? {
188
+ code: 'not-computable',
189
+ needed: 'A resolvable src for this frame.',
190
+ evidence: { element: tag, name, setSize: els.length }
191
+ }
192
+ : {
193
+ code: 'equivalence-unknown',
194
+ needed: 'Whether the two resources serve the same purpose despite differing.',
195
+ evidence: {
196
+ element: tag,
197
+ name,
198
+ resource: keys[i],
199
+ otherResources: resolved.filter((k) => k !== keys[i]),
200
+ setSize: els.length
201
+ }
202
+ },
203
+ data: {
204
+ details: {
205
+ reasonCode:
206
+ keys[i] == null ? 'IFRAME_RESOURCE_UNRESOLVED' : 'IFRAME_RESOURCE_DIFFERS',
207
+ element: tag,
208
+ name,
209
+ resource: keys[i],
210
+ setSize: els.length
211
+ }
212
+ }
213
+ })
214
+ );
215
+ }
216
+ }
217
+
218
+ if (occurrences.length) {
219
+ return {
220
+ ruleId: rule.ruleId,
221
+ outcome: 'cantTell',
222
+ severity: rule.defaultSeverity || 'moderate',
223
+ occurrences
224
+ };
225
+ }
226
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
227
+ }
228
+
229
+ module.exports = { id, meta, runInPage };
@@ -390,6 +390,11 @@ function runInPage(ctx) {
390
390
  'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
391
391
  hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
392
392
  i18n: null,
393
+ uncertainty: {
394
+ code: 'runtime-dependent',
395
+ needed: 'Whether a keyboard user can reach this frame’s content in a real browser.',
396
+ evidence: { element: tag, focusRedirected: true }
397
+ },
393
398
  data: {
394
399
  details: {
395
400
  reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
@@ -254,55 +254,22 @@ function runInPage(ctx) {
254
254
  return parts.join(' ').replace(/\s+/g, ' ').trim();
255
255
  }
256
256
 
257
+ // Real <label> elements associated with a native form control -- the
258
+ // shared dom-helpers.js implementation (a `for`-attribute index plus a
259
+ // bounded closest('label') walk), not the native `.labels`/`.control`
260
+ // pair: jsdom implements those as a whole-document walk on every access
261
+ // (see that function's own header comment for the full explanation),
262
+ // real cost this engine's Node/jsdom runtime pays, not just a browser
263
+ // realm this code happens to also run in.
257
264
  function getAssociatedLabelElements(control) {
258
- const labels = [];
259
- try {
260
- if (control && control.labels && typeof control.labels.length === 'number') {
261
- for (const l of control.labels) labels.push(l);
262
- }
263
- } catch {
264
- // ignore
265
- }
266
-
267
- // Fallback: wrapped label
268
- try {
269
- const w = control && control.closest ? control.closest('label') : null;
270
- if (w) labels.push(w);
271
- } catch {
272
- // ignore
273
- }
274
-
275
- // Fallback: label[for=id]. Uses `document` directly rather than
276
- // ctx.root -- label[for] association is a document-wide relationship
277
- // (IDs are document-unique), not bounded by whatever contextSelector
278
- // region happens to be scanned, and ctx.root is an array (multi-region
279
- // contextSelector support), not a single element with its own
280
- // .querySelector to call directly.
281
- try {
282
- const idAttribute = control && control.getAttribute ? control.getAttribute('id') || '' : '';
283
- const key = String(idAttribute || '').trim();
284
- if (key && document && document.querySelector) {
285
- const l = document.querySelector('label[for="' + CSS.escape(key) + '"]');
286
- if (l) labels.push(l);
287
- }
288
- } catch {
289
- // ignore
290
- }
291
-
292
- // De-dupe in document order
293
- const seen = new Set();
294
- const out = [];
295
- for (const l of labels) {
265
+ if (helpers && typeof helpers.getAssociatedLabelElements === 'function') {
296
266
  try {
297
- if (!l || !l.tagName) continue;
298
- if (seen.has(l)) continue;
299
- seen.add(l);
300
- out.push(l);
267
+ return helpers.getAssociatedLabelElements(control) || [];
301
268
  } catch {
302
- // ignore
269
+ return [];
303
270
  }
304
271
  }
305
- return out;
272
+ return [];
306
273
  }
307
274
 
308
275
  function getVisibleTextLabelInfo(el) {
@@ -490,44 +457,45 @@ function runInPage(ctx) {
490
457
  // character) is not something markup settles: the author may have meant
491
458
  // either. Report without asserting a defect instead of failing or
492
459
  // staying silent.
493
- let uncertainty = '';
460
+ let uncertainReason = '';
494
461
  if (!contains) {
495
462
  if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
496
- uncertainty = 'HYPHENATION_DIFFERS';
463
+ uncertainReason = 'HYPHENATION_DIFFERS';
497
464
  } else {
498
465
  const abbreviated = abbreviatedWords(visibleLabel);
499
466
  if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated)) {
500
- uncertainty = 'POSSIBLE_ABBREVIATION';
467
+ uncertainReason = 'POSSIBLE_ABBREVIATION';
501
468
  } else if ((labelInfo.sourceElements || []).some(isIconFontElement)) {
502
- uncertainty = 'POSSIBLE_ICON_FONT_GLYPH';
469
+ uncertainReason = 'POSSIBLE_ICON_FONT_GLYPH';
503
470
  } else if (isSingleSymbolicCharacter(visibleLabel, accNorm)) {
504
- uncertainty = 'POSSIBLE_SYMBOLIC_CHARACTER';
471
+ uncertainReason = 'POSSIBLE_SYMBOLIC_CHARACTER';
505
472
  }
506
473
  }
507
474
  }
508
475
 
509
476
  const isSymbolicUncertainty =
510
- uncertainty === 'POSSIBLE_ICON_FONT_GLYPH' || uncertainty === 'POSSIBLE_SYMBOLIC_CHARACTER';
477
+ uncertainReason === 'POSSIBLE_ICON_FONT_GLYPH' ||
478
+ uncertainReason === 'POSSIBLE_SYMBOLIC_CHARACTER';
511
479
 
512
480
  if (!contains) {
513
481
  occurrences.push(
514
482
  helpers.reportOccurrence(el, {
515
- ...(uncertainty ? { outcome: 'cantTell' } : null),
516
- summary: uncertainty
483
+ ...(uncertainReason ? { outcome: 'cantTell' } : null),
484
+ summary: uncertainReason
517
485
  ? 'Accessible name may not contain the visible label text.'
518
486
  : 'Accessible name does not contain the visible label text.',
519
- hint: !uncertainty
487
+ hint: !uncertainReason
520
488
  ? 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).'
521
489
  : isSymbolicUncertainty
522
490
  ? 'Check by hand: the visible text may render as an icon or symbol rather than literal words, which markup cannot settle.'
523
491
  : 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.',
524
492
  i18n: {
525
- summaryKey: !uncertainty
493
+ summaryKey: !uncertainReason
526
494
  ? 'labelInName_summary_fail'
527
495
  : isSymbolicUncertainty
528
496
  ? 'labelInName_summary_cantTell_symbolic'
529
497
  : 'labelInName_summary_cantTell',
530
- hintKey: !uncertainty
498
+ hintKey: !uncertainReason
531
499
  ? 'labelInName_hint_fail'
532
500
  : isSymbolicUncertainty
533
501
  ? 'labelInName_hint_cantTell_symbolic'
@@ -539,9 +507,23 @@ function runInPage(ctx) {
539
507
  nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
540
508
  }
541
509
  },
510
+ ...(uncertainReason
511
+ ? {
512
+ uncertainty: {
513
+ code: 'equivalence-unknown',
514
+ needed:
515
+ 'Whether the accessible name and the visible label say the same thing to a user.',
516
+ evidence: {
517
+ visibleLabel,
518
+ accessibleName: accName,
519
+ difference: uncertainReason
520
+ }
521
+ }
522
+ }
523
+ : null),
542
524
  data: {
543
525
  details: {
544
- reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
526
+ reasonCode: uncertainReason || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
545
527
  visibleLabel,
546
528
  accessibleName: accName,
547
529
  normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },