@surea11y/core 1.7.0 → 1.8.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +2 -2
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +26 -11
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14285 -2219
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -24,18 +24,22 @@
24
24
  * offscreen positioning do NOT exempt text, per ACT's own failed
25
25
  * examples for both, only actual non-rendering does.
26
26
  * @expectation
27
- * The lang value matches a valid BCP47 language-tag syntax. WCAG 3.1.2
28
- * (Language of Parts) requires that when a passage's language differs
29
- * from the page's default, it is identified programmatically. An
30
- * invalid tag fails to identify a real language at all.
27
+ * The primary language subtag of the lang value (the part before the
28
+ * first hyphen) is a registered language subtag, as ACT de46e4 requires.
29
+ * WCAG 3.1.2 (Language of Parts)
30
+ * requires that when a passage's language differs from the page's
31
+ * default, it is identified programmatically. A tag whose primary subtag
32
+ * is unknown fails to identify a real language at all; a malformed later
33
+ * subtag (lang="en-US_x") still identifies English and passes here, since
34
+ * it is a markup validity error rather than a missing language.
31
35
  * @implementation-notes
32
36
  * - Distinct, atomic decision from html-lang-attr-present (that
33
37
  * rule covers the root <html> element only, for SC 3.1.1); this rule
34
38
  * covers every other element, for SC 3.1.2.
35
- * - Same minimal BCP47 *syntax* check as html-lang-attr-present (primary
36
- * subtag + optional subtags), not IANA Language Subtag Registry
37
- * validation, same documented scope limitation (syntactically
38
- * well-formed but unregistered tags like "xx-ZZ" are not flagged).
39
+ * - Same primary-subtag check as html-lang-attr-present: the shared
40
+ * helper checks the subtag's shape and that the IANA Language Subtag
41
+ * Registry lists it, so unregistered tags such as "xx-ZZ", "eng" or
42
+ * "qaa" fail. Later subtags (region, script, variants) are not checked.
39
43
  */
40
44
 
41
45
  const id = 'valid-lang';
@@ -72,7 +76,8 @@ function runInPage(ctx) {
72
76
 
73
77
  // Shape alone accepts unregistered tags such as "eng" and "em-US", so the
74
78
  // primary subtag is checked against the IANA registry via the shared helper.
75
- const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
79
+ // Only the primary subtag is judged (see @expectation).
80
+ const BCP47_RE = /^[a-zA-Z]{2,3}$/;
76
81
  const isValidTag =
77
82
  typeof helpers.isValidLanguageTag === 'function'
78
83
  ? helpers.isValidLanguageTag
@@ -174,7 +179,7 @@ function runInPage(ctx) {
174
179
 
175
180
  // A whitespace-only value is in scope and has no primary language tag.
176
181
  const raw = String(rawAttr).trim();
177
- if (isValidTag(raw)) continue;
182
+ if (isValidTag(raw.split('-')[0])) continue;
178
183
 
179
184
  const tag = el.tagName.toLowerCase();
180
185
 
@@ -10,20 +10,33 @@
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
12
  * @applicability
13
- * Applies to <area> elements whose alt attribute is present and non-empty.
14
- * The <area> must belong to a <map> that an <img usemap> actually
15
- * references, and both that <img> and the <area> itself must be included
16
- * in the accessibility tree; an <area> in an unused map is out of scope.
17
- * role="presentation"/"none" takes an element out unless it is focusable.
13
+ * Applies to <area> elements that get a non-empty text alternative from any
14
+ * source: aria-labelledby (resolving to text), aria-label, alt or title.
15
+ * The <area> must carry a non-empty href (otherwise it is not a hyperlink
16
+ * at all per the HTML spec) and belong to a <map> that an <img usemap>
17
+ * actually references; an <area> in an unused map is out of scope. The
18
+ * referencing <img> must actually be rendered (hidden/display:none/
19
+ * visibility exclude it; aria-hidden does not, since <area> is not a DOM
20
+ * descendant of <img>), and the <area> itself must be eligible: hidden,
21
+ * display:none and inert on the <area> or its <map> do not exclude it,
22
+ * since neither generates a box and a real browser's image-map
23
+ * hit-testing ignores all three there (verified against Chromium and
24
+ * Firefox); only those mechanisms on a genuine ancestor of the whole
25
+ * <img>+<map> pairing do. role="presentation"/"none" takes an element out
26
+ * unless it is focusable.
18
27
  * @expectation
19
- * Human review is required to confirm that the provided text alternative is accurate and appropriate.
28
+ * Human review is required to confirm that the provided text alternative is
29
+ * accurate and appropriate. Each occurrence lists every source present
30
+ * (data.details.sources), so the reviewer checks each one: a title or
31
+ * aria-label that is not the name still reaches some users.
20
32
  */
21
33
 
22
34
  const id = 'area-alt-quality';
23
35
 
24
36
  const meta = {
25
- title: '<area> alt text must be appropriate (manual review)',
26
- description: 'Flags <area> elements with non-empty alt text for human review of appropriateness.',
37
+ title: '<area> text alternative must be appropriate (manual review)',
38
+ description:
39
+ 'Flags <area> elements with a non-empty text alternative (alt, aria-label, aria-labelledby or title) for human review of appropriateness.',
27
40
  i18n: {
28
41
  titleKey: 'area_altQuality_title',
29
42
  descriptionKey: 'area_altQuality_description'
@@ -76,6 +89,11 @@ function runInPage(ctx) {
76
89
  const isAccTreeEligible =
77
90
  helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
78
91
 
92
+ const isDomVisibleEligible =
93
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
94
+ ? helpers.isDomVisibleEligible
95
+ : null;
96
+
79
97
  const __accEligCache = new WeakMap();
80
98
  function accEligibleCached(node) {
81
99
  if (!isAccTreeEligible) return { eligible: true, reasons: [] };
@@ -93,6 +111,23 @@ function runInPage(ctx) {
93
111
  return r;
94
112
  }
95
113
 
114
+ const __domVisCache = new WeakMap();
115
+ function domVisibleCached(node) {
116
+ if (!isDomVisibleEligible) return { eligible: true, reasons: [] };
117
+ if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
118
+ const c = __domVisCache.get(node);
119
+ if (c) return c;
120
+ let r;
121
+ try {
122
+ r = isDomVisibleEligible(node, ctx, { visibilityMode: 'styleOnly', disableGeometry: true });
123
+ } catch {
124
+ r = { eligible: true, reasons: [] };
125
+ }
126
+ r = r && typeof r === 'object' ? r : { eligible: !!r, reasons: [] };
127
+ __domVisCache.set(node, r);
128
+ return r;
129
+ }
130
+
96
131
  // --- image-map semantics (rule-local; match automatic <area> applicability) ---
97
132
  function normUsemap(val) {
98
133
  try {
@@ -149,6 +184,53 @@ function runInPage(ctx) {
149
184
  return !focusable;
150
185
  }
151
186
 
187
+ const getAriaNameInfo =
188
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
189
+
190
+ // Every non-empty text-alternative source on the element, in accessible-name
191
+ // order: aria-labelledby (when it resolves to text), aria-label, alt, title.
192
+ // aria-labelledby wins over aria-label in the name, but a present aria-label
193
+ // is still listed, so each attribute present is asked about.
194
+ function collectTextAlternativeSources(el) {
195
+ const attr = (name) => {
196
+ try {
197
+ const v = el.getAttribute(name);
198
+ return v == null ? '' : String(v).trim();
199
+ } catch {
200
+ return '';
201
+ }
202
+ };
203
+ const sources = [];
204
+ let name = '';
205
+ let aria = null;
206
+ if (getAriaNameInfo) {
207
+ try {
208
+ aria = getAriaNameInfo(el, ctx);
209
+ } catch {
210
+ aria = null;
211
+ }
212
+ }
213
+ if (aria && aria.present && aria.value) {
214
+ name = String(aria.value).trim();
215
+ sources.push(aria.mechanism);
216
+ if (aria.mechanism === 'aria-labelledby' && attr('aria-label')) sources.push('aria-label');
217
+ } else if (!getAriaNameInfo && attr('aria-label')) {
218
+ name = attr('aria-label');
219
+ sources.push('aria-label');
220
+ }
221
+ const altText = attr('alt');
222
+ if (altText) {
223
+ sources.push('alt');
224
+ if (!name) name = altText;
225
+ }
226
+ const titleText = attr('title');
227
+ if (titleText) {
228
+ sources.push('title');
229
+ if (!name) name = titleText;
230
+ }
231
+ return { sources, name, alt: altText };
232
+ }
233
+
152
234
  const els = (() => {
153
235
  try {
154
236
  return Array.from((queryAllSmart ? queryAllSmart('area') : queryAll('area')) || []);
@@ -188,10 +270,18 @@ function runInPage(ctx) {
188
270
  }
189
271
  if (!img) continue;
190
272
 
191
- // The referencing <img> must be eligible in the accessibility tree.
192
- if (isAccTreeEligible) {
193
- const imgElig = accEligibleCached(img);
194
- if (imgElig && imgElig.eligible === false) continue;
273
+ // Without href an <area> is not a hyperlink at all per the HTML spec,
274
+ // so there is nothing here for this rule to review.
275
+ const hrefRaw = el.getAttribute('href');
276
+ if (!hrefRaw || !hrefRaw.trim()) continue;
277
+
278
+ // The referencing <img> must actually be rendered. <area> is not a DOM
279
+ // descendant of <img>, so aria-hidden on the img has nothing to
280
+ // propagate along; hidden/display:none/visibility on the img still
281
+ // excludes it, since that removes the box the hotspot depends on.
282
+ if (isDomVisibleEligible) {
283
+ const imgVis = domVisibleCached(img);
284
+ if (imgVis && imgVis.eligible === false) continue;
195
285
  }
196
286
 
197
287
  if (isAccTreeEligible) {
@@ -201,38 +291,30 @@ function runInPage(ctx) {
201
291
 
202
292
  if (isRolePresentationExcluded(el)) continue;
203
293
 
204
- // Rule-specific applicability (only elements that already have a text alternative mechanism)
205
- let alt;
206
- try {
207
- alt = el.getAttribute('alt');
208
- } catch {
209
- alt = null;
210
- }
211
- if (alt === null) continue;
212
- if (String(alt).trim() === '') continue; // only non-empty alt is applicable here
294
+ // Applies when any text-alternative source gives the area a non-empty
295
+ // name; each present source is listed so the reviewer checks all of them.
296
+ const alt = collectTextAlternativeSources(el);
297
+ if (!alt.sources.length) continue;
213
298
 
214
299
  applicableCount += 1;
215
300
 
216
301
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
302
+ const sourcesText = alt.sources.join(', ');
217
303
 
218
- let altVal;
219
- try {
220
- altVal = String(el.getAttribute('alt') || '');
221
- } catch {
222
- altVal = '';
223
- }
304
+ const details = { name: alt.name, sources: alt.sources.slice() };
305
+ if (alt.alt) details.alt = alt.alt;
224
306
 
225
307
  const baseOccurrence = {
226
- summary: 'Review alt text on <area> for accuracy and appropriateness.',
227
- hint: 'Ensure the alt text identifies the destination/action of the image map area in context.',
308
+ summary: `Review the text alternative of this <area> (${sourcesText}) for accuracy and appropriateness.`,
309
+ hint: 'Ensure each listed text alternative identifies the destination/action of the image map area in context.',
228
310
  i18n: {
229
311
  summaryKey: 'area_altQuality_summary_cantTell',
230
312
  hintKey: 'area_altQuality_hint_cantTell',
231
- params: { element: (el.tagName || '').toLowerCase() }
313
+ params: { element: (el.tagName || '').toLowerCase(), sources: sourcesText }
232
314
  },
233
315
  data: {
234
316
  visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
235
- details: { alt: altVal.trim() } // optional but useful for manual review
317
+ details
236
318
  }
237
319
  };
238
320
 
@@ -165,8 +165,6 @@ function runInPage(ctx) {
165
165
  ? helpers.getTextAlternativeInfo
166
166
  : null;
167
167
 
168
- const trim = (v) => (v == null ? '' : String(v)).trim();
169
-
170
168
  // Rule-specific applicability (only elements that already have a text alternative mechanism)
171
169
  let textAltInfo = null;
172
170
  if (getTextAlternativeInfo) {
@@ -10,15 +10,19 @@
10
10
  * @sc 2.4.7
11
11
  * @applicability
12
12
  * Elements in sequential focus navigation (tabbable and rendered) on a
13
- * page whose accessible stylesheets contain at least one `:focus` or
14
- * `:focus-visible` rule. With no focus rule anywhere, every element
15
- * keeps the user agent's own indicator and there is nothing to check.
13
+ * page whose accessible stylesheets contain at least one rule that
14
+ * removes the outline. With no such rule anywhere, every element keeps
15
+ * the user agent's own indicator and there is nothing to check.
16
16
  * @expectation
17
- * No element is matched by a `:focus`/`:focus-visible` rule that removes
18
- * the outline (`outline: none`, `outline: 0`, `outline-color:
19
- * transparent`, ...) unless some other focus rule matching it draws a
20
- * replacement: a border, box-shadow, background, color change, a
21
- * positive outline of its own, or a `::before`/`::after` decoration.
17
+ * No element is matched by a rule that removes the outline (`outline:
18
+ * none`, `outline: 0`, `outline-color: transparent`, ...) unless some
19
+ * focus rule matching it draws a replacement: a border, box-shadow,
20
+ * background, color change, a positive outline of its own, or a
21
+ * `::before`/`::after` decoration. The removing rule is either a
22
+ * `:focus`/`:focus-visible` rule, or a rule with no state at all
23
+ * (`a { outline: none }`, `* { outline: 0 }`): an author declaration
24
+ * outranks the user agent's focus outline whatever its specificity, so
25
+ * it removes the indicator in the focused state too (WCAG F78).
22
26
  * @implementation-notes
23
27
  * - Authored as `type: 'manual'` (cantTell-capped, never fail). CSS is
24
28
  * only one of the ways a page can indicate focus: ACT oj04fd's own
@@ -54,7 +58,7 @@ const id = 'css-focus-indicator-suppressed';
54
58
  const meta = {
55
59
  title: 'Focus indicator must not be removed without a replacement',
56
60
  description:
57
- 'Flags elements in the tab order whose focus outline is removed by a :focus/:focus-visible rule with no replacement indicator (border, box-shadow, background, ...) in any other focus rule matching them.',
61
+ 'Flags elements in the tab order whose focus outline is removed, by a :focus/:focus-visible rule or by a rule with no state such as a { outline: none }, with no replacement indicator (border, box-shadow, background, ...) in any focus rule matching them.',
58
62
  i18n: {
59
63
  titleKey: 'cssFocusIndicatorSuppressed_title',
60
64
  descriptionKey: 'cssFocusIndicatorSuppressed_description'
@@ -272,7 +276,16 @@ function runInPage(ctx) {
272
276
  if (!suppresses && !provides) return;
273
277
 
274
278
  for (const part of splitSelectorList(cssRule.selectorText)) {
275
- if (!hasFocusPseudo(part)) continue;
279
+ if (!hasFocusPseudo(part)) {
280
+ // A rule with no focus state still applies while the element has
281
+ // focus, and an author declaration beats the user agent's focus
282
+ // outline. Other states (:hover, :active) never match the static
283
+ // element, so el.matches() leaves them out below.
284
+ if (suppresses && !hasPseudoElement(part)) {
285
+ suppressors.push({ selector: trim(part), base: trim(part) });
286
+ }
287
+ continue;
288
+ }
276
289
 
277
290
  const compounds = splitCompounds(part);
278
291
  let focusIndex = -1;
@@ -13,11 +13,25 @@
13
13
  * via CSS techniques that can leave them in the tab order.
14
14
  * @expectation
15
15
  * No element should be tabbable while visually hidden (e.g., opacity:0, clipped, off-screen).
16
+ * An element that CSS brings back into view when it takes focus is not
17
+ * hidden while focused, and is not flagged: the usual skip-link pattern
18
+ * (`.skip { position: absolute; left: -9999px } .skip:focus { left: 0 }`),
19
+ * or a hiding rule that stops applying on focus
20
+ * (`.visually-hidden-focusable:not(:focus) { clip: rect(0 0 0 0) }`).
16
21
  *
17
22
  * Notes:
18
23
  * - This rule intentionally targets CSS techniques that *can* keep an element focusable.
19
24
  * - Elements removed from rendering (display:none, visibility:hidden, [hidden]) are excluded.
20
25
  * - The rule uses deterministic heuristics (computed style parsing) and does not rely on layout geometry.
26
+ * - The focused style is worked out from the stylesheets, not by focusing the
27
+ * element: a DOM emulator does not restyle `:focus`. Rules whose subject
28
+ * carries `:focus`, `:focus-visible` or `:focus-within` (or an ancestor
29
+ * carries `:focus-within`) are laid over the computed style in document
30
+ * order; a rule written `:not(:focus)` / `:not(:focus-within)` /
31
+ * `:not(:focus-visible)` has its declarations reset to their initial values.
32
+ * Inline style outranks a stylesheet rule unless the rule is `!important`.
33
+ * The overlay ignores specificity among the focus rules, and cross-origin
34
+ * stylesheets cannot be read, so their focus rules are not seen.
21
35
  */
22
36
 
23
37
  const id = 'css-hidden-focus';
@@ -106,9 +120,14 @@ function runInPage(ctx) {
106
120
 
107
121
  // Returns deterministic "visually hidden but can remain focusable" hints.
108
122
  function getVisibilityHints(el) {
123
+ if (!el) return [];
124
+ return hintsFromStyle(getComputedStyleSafe(el));
125
+ }
126
+
127
+ // The same hints, read off any object carrying the computed-style fields
128
+ // used below (a CSSStyleDeclaration, or the focused-state overlay).
129
+ function hintsFromStyle(cs) {
109
130
  const out = [];
110
- if (!el) return out;
111
- const cs = getComputedStyleSafe(el);
112
131
 
113
132
  // opacity:0
114
133
  try {
@@ -187,6 +206,177 @@ function runInPage(ctx) {
187
206
  return uniq;
188
207
  }
189
208
 
209
+ // ---- Focused state, worked out from the stylesheets ----
210
+ const CSS_STYLE_RULE = 1;
211
+ const MAX_RULE_DEPTH = 10;
212
+ const FOCUS_STATE = /:focus(?:-visible|-within)?(?![-\w])/g;
213
+ const OWN_FOCUS_STATE = /:focus(?:-visible)?(?![-\w])/;
214
+ const NOT_FOCUS_STATE = /:not\(\s*:focus(?:-visible|-within)?\s*\)/g;
215
+ // Longhand name -> computed-style field read by hintsFromStyle.
216
+ const HINT_PROPS = {
217
+ opacity: 'opacity',
218
+ clip: 'clip',
219
+ 'clip-path': 'clipPath',
220
+ width: 'width',
221
+ height: 'height',
222
+ overflow: 'overflow',
223
+ position: 'position',
224
+ left: 'left',
225
+ top: 'top',
226
+ 'text-indent': 'textIndent'
227
+ };
228
+ const INITIAL_VALUES = {
229
+ opacity: '1',
230
+ clip: 'auto',
231
+ 'clip-path': 'none',
232
+ width: 'auto',
233
+ height: 'auto',
234
+ overflow: 'visible',
235
+ position: 'static',
236
+ left: 'auto',
237
+ top: 'auto',
238
+ 'text-indent': '0px'
239
+ };
240
+
241
+ function splitTopLevel(text, separators) {
242
+ const parts = [];
243
+ let depth = 0;
244
+ let current = '';
245
+ for (const ch of String(text || '')) {
246
+ if (ch === '(') depth += 1;
247
+ if (ch === ')') depth = Math.max(0, depth - 1);
248
+ if (depth === 0 && separators.indexOf(ch) !== -1) {
249
+ parts.push(current);
250
+ current = '';
251
+ continue;
252
+ }
253
+ current += ch;
254
+ }
255
+ parts.push(current);
256
+ return parts.map(trim).filter(Boolean);
257
+ }
258
+
259
+ function hasPseudoElement(part) {
260
+ return /::[a-z-]+/i.test(part) || /:(before|after)\b/i.test(part);
261
+ }
262
+
263
+ // Selector for the element while it has focus, or null when the rule
264
+ // cannot apply to it then (`.a:focus .b`: .a cannot have focus while .b
265
+ // does).
266
+ function focusedBase(part) {
267
+ const compounds = splitTopLevel(part, [' ', '>', '+', '~']);
268
+ for (let i = 0; i < compounds.length - 1; i++) {
269
+ if (OWN_FOCUS_STATE.test(compounds[i])) return null;
270
+ }
271
+ // `:focus` standing alone in a compound becomes `*`, then every focus
272
+ // state is dropped: while focused, the element matches them all.
273
+ return trim(part.replace(/(^|[\s>+~(])(?=:focus)/g, '$1*').replace(FOCUS_STATE, '')) || '*';
274
+ }
275
+
276
+ let focusRules = null;
277
+ function getFocusRules() {
278
+ if (focusRules) return focusRules;
279
+ focusRules = [];
280
+ function consider(cssRule) {
281
+ const style = cssRule.style;
282
+ if (!style) return;
283
+ const props = Object.keys(HINT_PROPS).filter((p) => trim(style.getPropertyValue(p)));
284
+ if (!props.length) return;
285
+ for (const part of splitTopLevel(cssRule.selectorText, [','])) {
286
+ if (hasPseudoElement(part)) continue;
287
+ NOT_FOCUS_STATE.lastIndex = 0;
288
+ if (NOT_FOCUS_STATE.test(part)) {
289
+ const base = trim(part.replace(NOT_FOCUS_STATE, '')) || '*';
290
+ FOCUS_STATE.lastIndex = 0;
291
+ if (!FOCUS_STATE.test(base)) focusRules.push({ kind: 'notFocus', base, style, props });
292
+ continue;
293
+ }
294
+ FOCUS_STATE.lastIndex = 0;
295
+ if (!FOCUS_STATE.test(part)) continue;
296
+ const base = focusedBase(part);
297
+ if (base) focusRules.push({ kind: 'focus', base, style, props });
298
+ }
299
+ }
300
+ function walk(rules, depth) {
301
+ if (!rules || depth > MAX_RULE_DEPTH) return;
302
+ for (const cssRule of rules) {
303
+ if (!cssRule) continue;
304
+ if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
305
+ consider(cssRule);
306
+ continue;
307
+ }
308
+ let nested;
309
+ try {
310
+ nested = cssRule.cssRules || null;
311
+ } catch {
312
+ nested = null;
313
+ }
314
+ if (nested) walk(nested, depth + 1);
315
+ }
316
+ }
317
+ try {
318
+ for (const sheet of document.styleSheets || []) {
319
+ let rules = null;
320
+ try {
321
+ rules = sheet && sheet.cssRules ? sheet.cssRules : null;
322
+ } catch {
323
+ continue; // cross-origin, not inspectable
324
+ }
325
+ if (rules) walk(rules, 0);
326
+ }
327
+ } catch {
328
+ // no readable stylesheets
329
+ }
330
+ return focusRules;
331
+ }
332
+
333
+ function matchesSafe(el, selector) {
334
+ try {
335
+ return typeof el.matches === 'function' && el.matches(selector);
336
+ } catch {
337
+ return false;
338
+ }
339
+ }
340
+
341
+ // The visibility hints the element would have while focused, or null when
342
+ // no focus-dependent rule reaches it.
343
+ function focusedVisibilityHints(el) {
344
+ const rules = getFocusRules().filter((r) => matchesSafe(el, r.base));
345
+ if (!rules.length) return null;
346
+ const inline = el.style || null;
347
+ const inlineHas = (p) => {
348
+ try {
349
+ return !!(inline && trim(inline.getPropertyValue(p)));
350
+ } catch {
351
+ return false;
352
+ }
353
+ };
354
+ const overlay = {};
355
+ for (const r of rules) {
356
+ if (r.kind !== 'notFocus') continue;
357
+ for (const p of r.props) if (!inlineHas(p)) overlay[p] = INITIAL_VALUES[p];
358
+ }
359
+ for (const r of rules) {
360
+ if (r.kind !== 'focus') continue;
361
+ for (const p of r.props) {
362
+ const important = String(r.style.getPropertyPriority(p) || '') === 'important';
363
+ if (inlineHas(p) && !important) continue;
364
+ overlay[p] = trim(r.style.getPropertyValue(p));
365
+ }
366
+ }
367
+ const cs = getComputedStyleSafe(el);
368
+ const focused = {};
369
+ for (const p of Object.keys(HINT_PROPS)) {
370
+ const field = HINT_PROPS[p];
371
+ focused[field] = Object.prototype.hasOwnProperty.call(overlay, p)
372
+ ? overlay[p]
373
+ : cs
374
+ ? cs[field]
375
+ : '';
376
+ }
377
+ return hintsFromStyle(focused);
378
+ }
379
+
190
380
  function getFocusableInfoSafe(el) {
191
381
  if (!getFocusableInfo) return null;
192
382
  try {
@@ -400,6 +590,10 @@ function runInPage(ctx) {
400
590
  const hints = getVisibilityHints(el);
401
591
  if (!hints.length) continue; // <-- applicability gate
402
592
 
593
+ // Brought back into view when it takes focus: visible while focused.
594
+ const whenFocused = focusedVisibilityHints(el);
595
+ if (whenFocused && !whenFocused.length) continue;
596
+
403
597
  const tagName = (() => {
404
598
  try {
405
599
  return lower(el.tagName || '');
@@ -419,17 +613,31 @@ function runInPage(ctx) {
419
613
 
420
614
  const baseOccurrence = {
421
615
  summary: downgradedToRedirectReview
422
- ? `Focusable ${tagName} appears visually hidden but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`
423
- : `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
616
+ ? `Focusable ${tagName} appears visually hidden, but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`
617
+ : `Focusable ${tagName} is visually hidden (${hintsArr.join(',')}).`,
424
618
  hint: downgradedToRedirectReview
425
619
  ? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on visually hidden focus targets.'
426
- : 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
620
+ : 'Make the element visible when it can receive keyboard focus, or remove it from the tab order until it is visible.',
427
621
  i18n: downgradedToRedirectReview
428
- ? null
622
+ ? {
623
+ summaryKey: 'cssHidden_focus_summary_cantTell_redirect',
624
+ hintKey: 'cssHidden_focus_hint_cantTell_redirect',
625
+ params: { element: tagName }
626
+ }
429
627
  : {
430
628
  summaryKey: 'cssHidden_focus_summary_cantTell',
431
629
  hintKey: 'cssHidden_focus_hint_cantTell',
432
- params: { element: tagName, visibilityHints: hintsArr.join(',') }
630
+ // One flag per technique, so each locale words them as sentences
631
+ // instead of showing the internal codes. visibilityHints stays
632
+ // for callers that read params directly.
633
+ params: {
634
+ element: tagName,
635
+ visibilityHints: hintsArr.join(','),
636
+ opacityZero: hintsArr.includes('opacityZero'),
637
+ offscreen: hintsArr.includes('offscreen'),
638
+ clipped: hintsArr.includes('clipped'),
639
+ zeroSizeOverflowHidden: hintsArr.includes('zeroSizeOverflowHidden')
640
+ }
433
641
  },
434
642
  data: {
435
643
  details: {