ultra11y 5.22.0 → 5.22.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.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "5.22.0",
13
+ "version": "5.22.1",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.22.0",
4
+ "version": "5.22.1",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.22.0",
4
+ "version": "5.22.1",
5
5
  "author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  // Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
28
28
  // does not carry it, so a user's own ultra11y.js is never clobbered.
29
29
  const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
30
- const ULTRA11Y_PLUGIN_VERSION = "5.22.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.22.1";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
@@ -310,7 +310,7 @@ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
310
310
  const e = document.activeElement;
311
311
  if (!e || e === document.body || e === document.documentElement) return null;
312
312
  const key = e.getAttribute && e.getAttribute('data-u11y-f');
313
- return { key: key || __sel(e), selector: __sel(e), html: __html(e) };
313
+ return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e) };
314
314
  })()`;
315
315
  async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
316
316
  const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
@@ -325,13 +325,13 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
325
325
  await page.keyboard.press("Tab");
326
326
  const now = await page.evaluate(FOCUS_WHERE_PROBE);
327
327
  if (!now) break;
328
- if (prev && now.key === prev.key) {
328
+ if (prev?.tagged && now.tagged && now.key === prev.key) {
329
329
  let stuck = true;
330
330
  for (let k = 0; k < confirmPresses && stuck; k++) {
331
331
  if (deadline?.out()) break;
332
332
  await page.keyboard.press("Tab");
333
333
  const again = await page.evaluate(FOCUS_WHERE_PROBE);
334
- stuck = again !== null && again.key === now.key;
334
+ stuck = again !== null && again.tagged === true && again.key === now.key;
335
335
  }
336
336
  if (stuck) {
337
337
  hits.push({
@@ -345,7 +345,6 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
345
345
  if (seen.has(now.key)) break;
346
346
  seen.add(now.key);
347
347
  prev = now;
348
- if (hits.length >= 4) break;
349
348
  }
350
349
  return hits;
351
350
  }
@@ -432,8 +431,7 @@ async function runLiveProbes(page, opts = {}) {
432
431
  }
433
432
  };
434
433
  if (!canResize) skip("1.4.10", "the page object cannot resize its viewport");
435
- if (!canType) skip("2.4.7", "the page object exposes no keyboard");
436
- if (!canType) skip("2.1.2", "the page object exposes no keyboard");
434
+ if (!canType) for (const sc of ["2.4.7", "2.1.2"]) skip(sc, "the page object exposes no keyboard");
437
435
  if (!canHover) skip("1.4.13", "the page object cannot hover");
438
436
  if (!canStyle) skip("1.4.12", "the page object cannot inject a stylesheet");
439
437
  if (want("1.4.4")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.22.0",
3
+ "version": "5.22.1",
4
4
  "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.22.0";
9
+ var VERSION = "5.22.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -39953,6 +39953,21 @@ var SUBJECTS = {
39953
39953
  (l) => hAt(d, l.line, "css", `CSS rule keyed on a pointer/keyboard state \u2014 does the hover half have a focus twin? ${l.text}`, `cssstate|${l.text}`)
39954
39954
  )
39955
39955
  );
39956
+ if (out2.length) {
39957
+ for (const link of elementsByTag(d, "link")) {
39958
+ const rel2 = (attr(link, "rel") ?? "").toLowerCase();
39959
+ if (!rel2.split(/\s+/).includes("stylesheet")) continue;
39960
+ const href = attr(link, "href") ?? "";
39961
+ out2.push(
39962
+ h(
39963
+ d,
39964
+ link,
39965
+ `stylesheet "${href}" \u2014 this page reveals additional content and the rule that does it is NOT in this file. Open the stylesheet and check the \`:hover\` selectors for a \`:focus\`/\`:focus-within\` twin.`,
39966
+ `stylesheet|${href}`
39967
+ )
39968
+ );
39969
+ }
39970
+ }
39956
39971
  return out2;
39957
39972
  }),
39958
39973
  // Content pinned over the page — what can obscure a focused element.
@@ -58021,8 +58036,8 @@ function adjudicationContract() {
58021
58036
  manualReasons: [...MANUAL_REASON_VALUES],
58022
58037
  requires: {
58023
58038
  C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence, and about the same kind of element the harvest recorded there \u2014 copy the evidence's own `snippet` rather than retyping the element); a criterion with no harvested evidence cannot be C at all",
58024
- NA: "a non-empty justification",
58025
- NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
58039
+ NA: "a non-empty justification, and citations[] whenever evidence was presented \u2014 a criterion whose subject exists NOWHERE in the audited scope is NA, never NC",
58040
+ NC: "at least one groundable finding, each naming the `file` it was observed in (an NC with no location is refused as flatly as an uncited C \u2014 and a non-conformity that rests on an ABSENCE is still observed on an element of a page, so cite that element) and each citing a normativeRef that resolves against the active standard",
58026
58041
  manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
58027
58042
  }
58028
58043
  };
@@ -60073,7 +60088,7 @@ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
60073
60088
  const e = document.activeElement;
60074
60089
  if (!e || e === document.body || e === document.documentElement) return null;
60075
60090
  const key = e.getAttribute && e.getAttribute('data-u11y-f');
60076
- return { key: key || __sel(e), selector: __sel(e), html: __html(e) };
60091
+ return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e) };
60077
60092
  })()`;
60078
60093
  async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60079
60094
  const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
@@ -60088,13 +60103,13 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60088
60103
  await page.keyboard.press("Tab");
60089
60104
  const now = await page.evaluate(FOCUS_WHERE_PROBE);
60090
60105
  if (!now) break;
60091
- if (prev && now.key === prev.key) {
60106
+ if (prev?.tagged && now.tagged && now.key === prev.key) {
60092
60107
  let stuck = true;
60093
60108
  for (let k = 0; k < confirmPresses && stuck; k++) {
60094
60109
  if (deadline?.out()) break;
60095
60110
  await page.keyboard.press("Tab");
60096
60111
  const again = await page.evaluate(FOCUS_WHERE_PROBE);
60097
- stuck = again !== null && again.key === now.key;
60112
+ stuck = again !== null && again.tagged === true && again.key === now.key;
60098
60113
  }
60099
60114
  if (stuck) {
60100
60115
  hits.push({
@@ -60108,7 +60123,6 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60108
60123
  if (seen.has(now.key)) break;
60109
60124
  seen.add(now.key);
60110
60125
  prev = now;
60111
- if (hits.length >= 4) break;
60112
60126
  }
60113
60127
  return hits;
60114
60128
  }
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
5
5
  allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
6
6
  license: MIT
7
7
  metadata:
8
- version: 5.22.0
8
+ version: 5.22.1
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.22.0";
9
+ var VERSION = "5.22.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -39953,6 +39953,21 @@ var SUBJECTS = {
39953
39953
  (l) => hAt(d, l.line, "css", `CSS rule keyed on a pointer/keyboard state \u2014 does the hover half have a focus twin? ${l.text}`, `cssstate|${l.text}`)
39954
39954
  )
39955
39955
  );
39956
+ if (out2.length) {
39957
+ for (const link of elementsByTag(d, "link")) {
39958
+ const rel2 = (attr(link, "rel") ?? "").toLowerCase();
39959
+ if (!rel2.split(/\s+/).includes("stylesheet")) continue;
39960
+ const href = attr(link, "href") ?? "";
39961
+ out2.push(
39962
+ h(
39963
+ d,
39964
+ link,
39965
+ `stylesheet "${href}" \u2014 this page reveals additional content and the rule that does it is NOT in this file. Open the stylesheet and check the \`:hover\` selectors for a \`:focus\`/\`:focus-within\` twin.`,
39966
+ `stylesheet|${href}`
39967
+ )
39968
+ );
39969
+ }
39970
+ }
39956
39971
  return out2;
39957
39972
  }),
39958
39973
  // Content pinned over the page — what can obscure a focused element.
@@ -58021,8 +58036,8 @@ function adjudicationContract() {
58021
58036
  manualReasons: [...MANUAL_REASON_VALUES],
58022
58037
  requires: {
58023
58038
  C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence, and about the same kind of element the harvest recorded there \u2014 copy the evidence's own `snippet` rather than retyping the element); a criterion with no harvested evidence cannot be C at all",
58024
- NA: "a non-empty justification",
58025
- NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
58039
+ NA: "a non-empty justification, and citations[] whenever evidence was presented \u2014 a criterion whose subject exists NOWHERE in the audited scope is NA, never NC",
58040
+ NC: "at least one groundable finding, each naming the `file` it was observed in (an NC with no location is refused as flatly as an uncited C \u2014 and a non-conformity that rests on an ABSENCE is still observed on an element of a page, so cite that element) and each citing a normativeRef that resolves against the active standard",
58026
58041
  manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
58027
58042
  }
58028
58043
  };
@@ -60073,7 +60088,7 @@ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
60073
60088
  const e = document.activeElement;
60074
60089
  if (!e || e === document.body || e === document.documentElement) return null;
60075
60090
  const key = e.getAttribute && e.getAttribute('data-u11y-f');
60076
- return { key: key || __sel(e), selector: __sel(e), html: __html(e) };
60091
+ return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e) };
60077
60092
  })()`;
60078
60093
  async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60079
60094
  const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
@@ -60088,13 +60103,13 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60088
60103
  await page.keyboard.press("Tab");
60089
60104
  const now = await page.evaluate(FOCUS_WHERE_PROBE);
60090
60105
  if (!now) break;
60091
- if (prev && now.key === prev.key) {
60106
+ if (prev?.tagged && now.tagged && now.key === prev.key) {
60092
60107
  let stuck = true;
60093
60108
  for (let k = 0; k < confirmPresses && stuck; k++) {
60094
60109
  if (deadline?.out()) break;
60095
60110
  await page.keyboard.press("Tab");
60096
60111
  const again = await page.evaluate(FOCUS_WHERE_PROBE);
60097
- stuck = again !== null && again.key === now.key;
60112
+ stuck = again !== null && again.tagged === true && again.key === now.key;
60098
60113
  }
60099
60114
  if (stuck) {
60100
60115
  hits.push({
@@ -60108,7 +60123,6 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60108
60123
  if (seen.has(now.key)) break;
60109
60124
  seen.add(now.key);
60110
60125
  prev = now;
60111
- if (hits.length >= 4) break;
60112
60126
  }
60113
60127
  return hits;
60114
60128
  }
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
4
4
  when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
5
5
  license: MIT
6
6
  metadata:
7
- version: 5.22.0
7
+ version: 5.22.1
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.22.0";
9
+ var VERSION = "5.22.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -39953,6 +39953,21 @@ var SUBJECTS = {
39953
39953
  (l) => hAt(d, l.line, "css", `CSS rule keyed on a pointer/keyboard state \u2014 does the hover half have a focus twin? ${l.text}`, `cssstate|${l.text}`)
39954
39954
  )
39955
39955
  );
39956
+ if (out2.length) {
39957
+ for (const link of elementsByTag(d, "link")) {
39958
+ const rel2 = (attr(link, "rel") ?? "").toLowerCase();
39959
+ if (!rel2.split(/\s+/).includes("stylesheet")) continue;
39960
+ const href = attr(link, "href") ?? "";
39961
+ out2.push(
39962
+ h(
39963
+ d,
39964
+ link,
39965
+ `stylesheet "${href}" \u2014 this page reveals additional content and the rule that does it is NOT in this file. Open the stylesheet and check the \`:hover\` selectors for a \`:focus\`/\`:focus-within\` twin.`,
39966
+ `stylesheet|${href}`
39967
+ )
39968
+ );
39969
+ }
39970
+ }
39956
39971
  return out2;
39957
39972
  }),
39958
39973
  // Content pinned over the page — what can obscure a focused element.
@@ -58021,8 +58036,8 @@ function adjudicationContract() {
58021
58036
  manualReasons: [...MANUAL_REASON_VALUES],
58022
58037
  requires: {
58023
58038
  C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence, and about the same kind of element the harvest recorded there \u2014 copy the evidence's own `snippet` rather than retyping the element); a criterion with no harvested evidence cannot be C at all",
58024
- NA: "a non-empty justification",
58025
- NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
58039
+ NA: "a non-empty justification, and citations[] whenever evidence was presented \u2014 a criterion whose subject exists NOWHERE in the audited scope is NA, never NC",
58040
+ NC: "at least one groundable finding, each naming the `file` it was observed in (an NC with no location is refused as flatly as an uncited C \u2014 and a non-conformity that rests on an ABSENCE is still observed on an element of a page, so cite that element) and each citing a normativeRef that resolves against the active standard",
58026
58041
  manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
58027
58042
  }
58028
58043
  };
@@ -60073,7 +60088,7 @@ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
60073
60088
  const e = document.activeElement;
60074
60089
  if (!e || e === document.body || e === document.documentElement) return null;
60075
60090
  const key = e.getAttribute && e.getAttribute('data-u11y-f');
60076
- return { key: key || __sel(e), selector: __sel(e), html: __html(e) };
60091
+ return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e) };
60077
60092
  })()`;
60078
60093
  async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60079
60094
  const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
@@ -60088,13 +60103,13 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60088
60103
  await page.keyboard.press("Tab");
60089
60104
  const now = await page.evaluate(FOCUS_WHERE_PROBE);
60090
60105
  if (!now) break;
60091
- if (prev && now.key === prev.key) {
60106
+ if (prev?.tagged && now.tagged && now.key === prev.key) {
60092
60107
  let stuck = true;
60093
60108
  for (let k = 0; k < confirmPresses && stuck; k++) {
60094
60109
  if (deadline?.out()) break;
60095
60110
  await page.keyboard.press("Tab");
60096
60111
  const again = await page.evaluate(FOCUS_WHERE_PROBE);
60097
- stuck = again !== null && again.key === now.key;
60112
+ stuck = again !== null && again.tagged === true && again.key === now.key;
60098
60113
  }
60099
60114
  if (stuck) {
60100
60115
  hits.push({
@@ -60108,7 +60123,6 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60108
60123
  if (seen.has(now.key)) break;
60109
60124
  seen.add(now.key);
60110
60125
  prev = now;
60111
- if (hits.length >= 4) break;
60112
60126
  }
60113
60127
  return hits;
60114
60128
  }
@@ -783,6 +783,27 @@ export const SUBJECTS: Record<string, Subject> = {
783
783
  hAt(d, l.line, "css", `CSS rule keyed on a pointer/keyboard state — does the hover half have a focus twin? ${l.text}`, `cssstate|${l.text}`),
784
784
  ),
785
785
  );
786
+ // …AND WHERE TO GO WHEN THE RULE IS NOT IN THIS FILE. An external stylesheet is not a
787
+ // document this engine parses (`.css` is absent from glob.ts DEFAULT_EXT — engine scope is
788
+ // markup), so a site whose tooltip lives in `styles.css` hands the adjudicator the trigger
789
+ // and the panel and NOTHING about how either becomes visible. It has Read and Grep; what
790
+ // it lacked was the address. Only emitted when this document really carries additional
791
+ // content, so a page with a stylesheet and no tooltip stays silent.
792
+ if (out.length) {
793
+ for (const link of elementsByTag(d, "link")) {
794
+ const rel = (attr(link, "rel") ?? "").toLowerCase();
795
+ if (!rel.split(/\s+/).includes("stylesheet")) continue;
796
+ const href = attr(link, "href") ?? "";
797
+ out.push(
798
+ h(
799
+ d,
800
+ link,
801
+ `stylesheet "${href}" — this page reveals additional content and the rule that does it is NOT in this file. Open the stylesheet and check the \`:hover\` selectors for a \`:focus\`/\`:focus-within\` twin.`,
802
+ `stylesheet|${href}`,
803
+ ),
804
+ );
805
+ }
806
+ }
786
807
  return out;
787
808
  }),
788
809
 
package/src/adjudicate.ts CHANGED
@@ -185,8 +185,8 @@ export function adjudicationContract(): AdjudicationContract {
185
185
  manualReasons: [...MANUAL_REASON_VALUES],
186
186
  requires: {
187
187
  C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence, and about the same kind of element the harvest recorded there — copy the evidence's own `snippet` rather than retyping the element); a criterion with no harvested evidence cannot be C at all",
188
- NA: "a non-empty justification",
189
- NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
188
+ NA: "a non-empty justification, and citations[] whenever evidence was presented — a criterion whose subject exists NOWHERE in the audited scope is NA, never NC",
189
+ NC: "at least one groundable finding, each naming the `file` it was observed in (an NC with no location is refused as flatly as an uncited C — and a non-conformity that rests on an ABSENCE is still observed on an element of a page, so cite that element) and each citing a normativeRef that resolves against the active standard",
190
190
  manual: `a reason ∈ {${MANUAL_REASON_VALUES.join(", ")}}`,
191
191
  },
192
192
  };
package/src/probes.ts CHANGED
@@ -279,11 +279,16 @@ export async function probeFocusVisible(page: Any, scope = "", limits: ProbeLimi
279
279
  // `null` means focus has left the ring — body, documentElement, or nothing at all, which is
280
280
  // what Playwright reports once Tab hands focus back to the browser chrome. That is the NORMAL
281
281
  // end of a tab ring, never a trap, and conflating the two would report every well-behaved page.
282
+ //
283
+ // `tagged` decides whether the caller may COMPARE two observations. An element the setup pass
284
+ // never tagged is identified by its selector alone, and a selector is not an identity: two links
285
+ // in the same list share one, so a walk that compared them would report a trap on a page whose
286
+ // focus was moving perfectly well. Untagged means « cannot accuse ».
282
287
  export const FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
283
288
  const e = document.activeElement;
284
289
  if (!e || e === document.body || e === document.documentElement) return null;
285
290
  const key = e.getAttribute && e.getAttribute('data-u11y-f');
286
- return { key: key || __sel(e), selector: __sel(e), html: __html(e) };
291
+ return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e) };
287
292
  })()`;
288
293
 
289
294
  /** RGAA 12.9 / WCAG 2.1.2 — can the keyboard always LEAVE?
@@ -308,23 +313,24 @@ export async function probeKeyboardTrap(page: Any, limits: ProbeLimits = PROBE_D
308
313
  const seen = new Set<string>();
309
314
  const confirmPresses = 2;
310
315
  const limit = Math.min(count + 2, limits.maxFocusables + 10);
311
- let prev: { key: string; selector: string; html: string } | null = null;
316
+ type Where = { key: string; tagged?: boolean; selector: string; html: string };
317
+ let prev: Where | null = null;
312
318
  for (let i = 0; i < limit; i++) {
313
319
  if (deadline?.out()) break;
314
320
  await page.keyboard.press("Tab");
315
- const now = (await page.evaluate(FOCUS_WHERE_PROBE)) as { key: string; selector: string; html: string } | null;
321
+ const now = (await page.evaluate(FOCUS_WHERE_PROBE)) as Where | null;
316
322
  // Focus left the page. The ring ended the way it should; nothing to report and nothing
317
323
  // left to walk.
318
324
  if (!now) break;
319
- if (prev && now.key === prev.key) {
325
+ if (prev?.tagged && now.tagged && now.key === prev.key) {
320
326
  // Stuck for one press. Confirm before accusing: press again, and only call it a trap if
321
327
  // focus is STILL on the same element every time.
322
328
  let stuck = true;
323
329
  for (let k = 0; k < confirmPresses && stuck; k++) {
324
330
  if (deadline?.out()) break;
325
331
  await page.keyboard.press("Tab");
326
- const again = (await page.evaluate(FOCUS_WHERE_PROBE)) as { key: string; selector: string } | null;
327
- stuck = again !== null && again.key === now.key;
332
+ const again = (await page.evaluate(FOCUS_WHERE_PROBE)) as Where | null;
333
+ stuck = again !== null && again.tagged === true && again.key === now.key;
328
334
  }
329
335
  if (stuck) {
330
336
  hits.push({
@@ -340,7 +346,6 @@ export async function probeKeyboardTrap(page: Any, limits: ProbeLimits = PROBE_D
340
346
  if (seen.has(now.key)) break;
341
347
  seen.add(now.key);
342
348
  prev = now;
343
- if (hits.length >= 4) break;
344
349
  }
345
350
  return hits;
346
351
  }
@@ -460,8 +465,7 @@ export async function runLiveProbes(page: Any, opts: { only?: string[]; limits?:
460
465
  }
461
466
  };
462
467
  if (!canResize) skip("1.4.10", "the page object cannot resize its viewport");
463
- if (!canType) skip("2.4.7", "the page object exposes no keyboard");
464
- if (!canType) skip("2.1.2", "the page object exposes no keyboard");
468
+ if (!canType) for (const sc of ["2.4.7", "2.1.2"]) skip(sc, "the page object exposes no keyboard");
465
469
  if (!canHover) skip("1.4.13", "the page object cannot hover");
466
470
  if (!canStyle) skip("1.4.12", "the page object cannot inject a stylesheet");
467
471
  // ORDER IS DELIBERATE: cheap and deterministic first, interactive last.
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Single source of truth for shared types + the embedded version. sync-version.mjs
2
2
  // rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
3
- export const VERSION = "5.22.0";
3
+ export const VERSION = "5.22.1";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,