ultra11y 5.27.0 → 5.28.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.
@@ -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.27.0",
13
+ "version": "5.28.0",
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.27.0",
4
+ "version": "5.28.0",
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.27.0",
4
+ "version": "5.28.0",
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.27.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.28.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -371,7 +371,7 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
371
371
  fills an input or resizes the viewport — otherwise the snapshot would record our own
372
372
  instrumentation instead of the site.
373
373
  - **Page by page** — RGAA is a per-page norm; the engine's verdict is scope-wide. `pages`
374
- bridges the two: one row per criterion, one column per page, embedded in `report` and
374
+ bridges the two: one row per criterion, one column per page URL, embedded in `report` and
375
375
  rebuilt from a committed `audit.json` alone. The per-page status is not recomputed — a
376
376
  per-page *view* of the audit runs through the very same projection the report uses, so grid
377
377
  and report agree by construction. Two rules hold the line: a finding is attributed to a page
@@ -287,14 +287,17 @@ async function probeFocusVisible(page, scope = "", limits = PROBE_DEFAULTS, dead
287
287
  if (!count) return [];
288
288
  const hits = [];
289
289
  const seen = /* @__PURE__ */ new Set();
290
- const limit = Math.min(count + 2, limits.maxFocusables + 10);
290
+ const limit = tabPressBudget(count, limits);
291
+ let prevKey = null;
291
292
  for (let i = 0; i < limit; i++) {
292
293
  if (deadline?.out()) break;
293
294
  await page.keyboard.press("Tab");
294
295
  const r = await page.evaluate(FOCUS_CHECK_PROBE);
295
296
  if (!r) continue;
297
+ if (r.key === prevKey) continue;
296
298
  if (seen.has(r.key)) break;
297
299
  seen.add(r.key);
300
+ prevKey = r.key;
298
301
  if (!r.changed) {
299
302
  hits.push({
300
303
  selector: r.selector,
@@ -306,11 +309,23 @@ async function probeFocusVisible(page, scope = "", limits = PROBE_DEFAULTS, dead
306
309
  }
307
310
  return hits;
308
311
  }
312
+ var NATIVE_SEGMENT_STOPS = {
313
+ date: 5,
314
+ time: 5,
315
+ "datetime-local": 8,
316
+ month: 4,
317
+ week: 4
318
+ };
319
+ function tabPressBudget(count, limits) {
320
+ return Math.min(count * 2 + 20, limits.maxFocusables * 2 + 20);
321
+ }
309
322
  var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
310
323
  const e = document.activeElement;
311
324
  if (!e || e === document.body || e === document.documentElement) return null;
312
325
  const key = e.getAttribute && e.getAttribute('data-u11y-f');
313
- return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e) };
326
+ const stops = ${JSON.stringify(NATIVE_SEGMENT_STOPS)};
327
+ const type = e.tagName === 'INPUT' ? (e.getAttribute('type') || 'text').toLowerCase() : '';
328
+ return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e), segments: stops[type] || 1 };
314
329
  })()`;
315
330
  async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
316
331
  const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
@@ -318,7 +333,7 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
318
333
  const hits = [];
319
334
  const seen = /* @__PURE__ */ new Set();
320
335
  const confirmPresses = 2;
321
- const limit = Math.min(count + 2, limits.maxFocusables + 10);
336
+ const limit = tabPressBudget(count, limits);
322
337
  let prev = null;
323
338
  for (let i = 0; i < limit; i++) {
324
339
  if (deadline?.out()) break;
@@ -326,8 +341,9 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
326
341
  const now = await page.evaluate(FOCUS_WHERE_PROBE);
327
342
  if (!now) break;
328
343
  if (prev?.tagged && now.tagged && now.key === prev.key) {
344
+ const budget = Math.max(confirmPresses, (now.segments ?? 1) - 1);
329
345
  let stuck = true;
330
- for (let k = 0; k < confirmPresses && stuck; k++) {
346
+ for (let k = 0; k < budget && stuck; k++) {
331
347
  if (deadline?.out()) break;
332
348
  await page.keyboard.press("Tab");
333
349
  const again = await page.evaluate(FOCUS_WHERE_PROBE);
@@ -337,13 +353,15 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
337
353
  hits.push({
338
354
  selector: now.selector,
339
355
  html: now.html,
340
- detail: `Le focus reste sur cet \xE9l\xE9ment apr\xE8s ${1 + confirmPresses} appuis sur Tab, alors que la page compte ${count} \xE9l\xE9ments focalisables \u2014 pi\xE8ge au clavier (2.1.2).`
356
+ detail: `Le focus reste sur cet \xE9l\xE9ment apr\xE8s ${1 + budget} appuis sur Tab, alors que la page compte ${count} \xE9l\xE9ments focalisables \u2014 pi\xE8ge au clavier (2.1.2).`
341
357
  });
342
358
  break;
343
359
  }
344
360
  }
345
- if (seen.has(now.key)) break;
346
- seen.add(now.key);
361
+ if (now.key !== prev?.key) {
362
+ if (seen.has(now.key)) break;
363
+ seen.add(now.key);
364
+ }
347
365
  prev = now;
348
366
  }
349
367
  return hits;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.27.0",
3
+ "version": "5.28.0",
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",
@@ -251,9 +251,16 @@ const RULE_TO_CRITERIA = {
251
251
  "error-not-associated": ["11.10"], "aria-invalid-no-description": ["11.10"],
252
252
  "axe:label": ["11.1"], "axe:form-field-multiple-labels": ["11.1"], "axe:select-name": ["11.1"], "axe:label-title-only": ["11.1"],
253
253
  "axe:autocomplete-valid": ["11.13"], "axe:fieldset": ["11.6"], "axe:input-button-name": ["11.9"], "axe:button-name": ["11.9"],
254
- // Theme 12 — navigation (12.7 skip link, 12.8 tab order)
254
+ // Theme 12 — navigation (12.7 skip link, 12.8 tab order, 12.9 keyboard trap)
255
255
  "skip-link-target-missing": ["12.7"], "axe:skip-link": ["12.7"], "axe:bypass": ["12.7"],
256
256
  "positive-tabindex": ["12.8"], "axe:tabindex": ["12.8"],
257
+ // 12.9 « la navigation ne doit pas contenir de piège au clavier ». The probe that answers it
258
+ // has existed since `probeKeyboardTrap` landed, and it was mapped to WCAG 2.1.2 alone — so a
259
+ // trap the browser had just walked into raised a blocking WCAG non-conformity and left RGAA
260
+ // 12.9 « à évaluer », to be bought from a paid adjudicator on every run. The evidence was
261
+ // measured and then never delivered to the criterion it answers. No axe rule joins it: axe
262
+ // has none for keyboard traps, which is why the probe was written in the first place.
263
+ "dyn-keyboard-trap": ["12.9"],
257
264
  // Theme 13 — consultation (13.1 time limits, 13.8 moving/blinking)
258
265
  "meta-refresh-redirect": ["13.1"], "blink-marquee": ["13.8"], "autoplay-media": ["4.10", "13.8"],
259
266
  "axe:no-autoplay-audio": ["4.10"], "axe:blink": ["13.8"], "axe:marquee": ["13.8"],
@@ -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.27.0";
9
+ var VERSION = "5.28.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -43052,7 +43052,7 @@ var rgaa_default = {
43052
43052
  techniques: ["G21", "H91", "F10"],
43053
43053
  wcag: ["2.1.1", "2.1.2"],
43054
43054
  appliesTo: {
43055
- ruleIds: []
43055
+ ruleIds: ["dyn-keyboard-trap"]
43056
43056
  }
43057
43057
  },
43058
43058
  {
@@ -44949,6 +44949,32 @@ var SUBJECTS = {
44949
44949
  )
44950
44950
  ]),
44951
44951
  // The document's own language declaration.
44952
+ // THE LANGUAGE CODE THAT IS ACTUALLY THERE — RGAA 8.4's own subject, and NOT `docLang`.
44953
+ //
44954
+ // 8.3 and 8.4 read the same element and ask opposite questions. 8.3 (« la page a-t-elle une
44955
+ // langue par défaut ? ») is about the `<html>` element, and its subject must be present even
44956
+ // when the attribute is missing — that absence IS its non-conformity. 8.4 (« POUR CHAQUE PAGE
44957
+ // AYANT UNE LANGUE PAR DÉFAUT, le code est-il pertinent ? ») is about the code, and on a page
44958
+ // that declares none the criterion has, in the referential's own words, nothing to be about.
44959
+ //
44960
+ // Sharing `docLang` made 8.4 unclosable exactly there: the subject was seen (the `<html>`
44961
+ // element is always seen), so absence could never be concluded, and 8.4 is `judgment: true`
44962
+ // so silence never earned it a `C` either. Measured on tests/fixtures/realworld: one page
44963
+ // declares no language — that is its seeded 8.3 defect — and 8.4 stayed « à évaluer » on it
44964
+ // for ever, on every run, with nothing any adjudicator could read.
44965
+ //
44966
+ // Same correction as 13.2 and 7.4 further down: the refusal was right about the rule and
44967
+ // wrong about the SUBJECT.
44968
+ declaredLang: (docs) => docs.flatMap(
44969
+ (d) => elementsByTag(d, "html").filter((e) => (attr(e, "lang") ?? attr(e, "xml:lang") ?? "").trim() !== "").map(
44970
+ (e) => h(
44971
+ d,
44972
+ e,
44973
+ `<html lang="${attr(e, "lang") ?? attr(e, "xml:lang")}"> \u2014 is this code the language the content is actually written in?`,
44974
+ `declaredlang|${attr(e, "lang") ?? attr(e, "xml:lang")}`
44975
+ )
44976
+ )
44977
+ ),
44952
44978
  docLang: (docs) => docs.flatMap(
44953
44979
  (d) => elementsByTag(d, "html").map(
44954
44980
  (e) => h(d, e, `<html lang="${attr(e, "lang") ?? ""}"> \u2014 is it present, valid, and the language of the content?`, `doclang|${attr(e, "lang") ?? ""}`)
@@ -45370,7 +45396,8 @@ var PACK_SUBJECTS = {
45370
45396
  // is the ONLY place its subject can be named.
45371
45397
  "8.1": ["doctype"],
45372
45398
  "8.3": ["docLang"],
45373
- "8.4": ["docLang"],
45399
+ // 8.4 asks about the CODE, 8.3 about the element — see `declaredLang`.
45400
+ "8.4": ["declaredLang"],
45374
45401
  "8.5": ["docTitle"],
45375
45402
  "8.6": ["docTitle"],
45376
45403
  "8.7": ["langParts"],
@@ -45466,7 +45493,10 @@ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set([
45466
45493
  "frames",
45467
45494
  "downloadDocs",
45468
45495
  "newWindow",
45469
- "contextChange"
45496
+ "contextChange",
45497
+ // A page that declares no default language has no language code for 8.4 to be about. The
45498
+ // `<html>` element is always there; the attribute is what this subject looks for.
45499
+ "declaredLang"
45470
45500
  ]);
45471
45501
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
45472
45502
  var ownPackSubjects = (standard, id) => PACK_SUBJECTS[standard]?.[id];
@@ -49513,7 +49543,8 @@ function newAccum() {
49513
49543
  renderedRan: /* @__PURE__ */ new Map(),
49514
49544
  probedScs: /* @__PURE__ */ new Map(),
49515
49545
  axeRan: /* @__PURE__ */ new Set(),
49516
- subjectsSeen: /* @__PURE__ */ new Set()
49546
+ subjectsSeen: /* @__PURE__ */ new Set(),
49547
+ pageSubjects: /* @__PURE__ */ new Map()
49517
49548
  };
49518
49549
  }
49519
49550
  function primarySubtag(lang) {
@@ -49554,6 +49585,9 @@ function foldDoc(acc, doc, graph) {
49554
49585
  const pageId = snapshotPageId(doc.file);
49555
49586
  if (pageId) {
49556
49587
  acc.pageIds.add(pageId);
49588
+ const own = acc.pageSubjects.get(pageId) ?? /* @__PURE__ */ new Set();
49589
+ for (const id of subjectsPresentIn(doc, own)) own.add(id);
49590
+ acc.pageSubjects.set(pageId, own);
49557
49591
  for (const ruleId of renderedRulesRan(doc.signals)) {
49558
49592
  const seen = acc.renderedRan.get(ruleId) ?? /* @__PURE__ */ new Set();
49559
49593
  seen.add(pageId);
@@ -49817,6 +49851,9 @@ function finalize(acc, inputs, extra = {}) {
49817
49851
  // nothing, `undefined` says this audit predates the fold. A pack derivation must be
49818
49852
  // able to tell those apart before concluding NA from silence.
49819
49853
  ...acc.fileCount > 0 ? { subjectsSeen: [...acc.subjectsSeen].sort() } : {},
49854
+ // Per page, and omitted when no page was read — same rule as above: absent means "this
49855
+ // audit predates the fold", never "nothing was found".
49856
+ ...acc.pageSubjects.size ? { pageSubjects: Object.fromEntries([...acc.pageSubjects].map(([id, set]) => [id, [...set].sort()])) } : {},
49820
49857
  // The pages this run genuinely read. Written UNCONDITIONALLY — `[]` is the whole point,
49821
49858
  // because "this audit read no page" is exactly the claim a source-only run needs to make,
49822
49859
  // and an omit-when-empty field would say nothing precisely then. See scope.pagesAudited.
@@ -54470,6 +54507,9 @@ function derivePackResults(audit2, packKey, pageId) {
54470
54507
  if (normativeFindings.length) {
54471
54508
  return { id: pc.id, theme: pc.theme, status: "NC", findings, scs: pc.wcag };
54472
54509
  }
54510
+ if (subjectAbsent(pc)) {
54511
+ return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS, findings, scs: pc.wcag, inapplicable: true };
54512
+ }
54473
54513
  if (scResults.some((r) => r.status === "NC")) {
54474
54514
  return { id: pc.id, theme: pc.theme, status: "manual", findings, scs: pc.wcag, scopedOut: true };
54475
54515
  }
@@ -55537,7 +55577,8 @@ var L3 = {
55537
55577
  source: "source",
55538
55578
  notAudited: "non audit\xE9",
55539
55579
  notAuditedNote: "Une page marqu\xE9e \xAB non audit\xE9 \xBB a bien un instantan\xE9, mais CET audit ne l'a pas lu (il ne portait que sur les sources). L'absence de constat n'y vaut donc PAS conformit\xE9 \u2014 relancez l'audit en incluant `.ultra11y/pages`.",
55540
- agentMark: "`C*` : conformit\xE9 tranch\xE9e par l'agent IA \xE0 partir des \xE9vidences cit\xE9es (gat\xE9), et non prouv\xE9e par le moteur d\xE9terministe."
55580
+ agentMark: "`C*` : conformit\xE9 tranch\xE9e par l'agent IA \xE0 partir des \xE9vidences cit\xE9es (gat\xE9), et non prouv\xE9e par le moteur d\xE9terministe.",
55581
+ originNote: (o) => `Les colonnes sont les URL des pages, relatives \xE0 \`${o}\`.`
55541
55582
  },
55542
55583
  en: {
55543
55584
  title: "Per-page grid",
@@ -55552,7 +55593,8 @@ var L3 = {
55552
55593
  source: "source",
55553
55594
  notAudited: "not audited",
55554
55595
  notAuditedNote: 'A page marked "not audited" does have a snapshot, but THIS audit never read it (it covered sources only). Absence of a finding there does NOT mean conforming \u2014 re-run the audit with `.ultra11y/pages` in scope.',
55555
- agentMark: "`C*`: conformity ruled by the AI agent from the evidence it cited (gated), not proven by the deterministic engine."
55596
+ agentMark: "`C*`: conformity ruled by the AI agent from the evidence it cited (gated), not proven by the deterministic engine.",
55597
+ originNote: (o) => `Columns are the pages' URLs, relative to \`${o}\`.`
55556
55598
  }
55557
55599
  };
55558
55600
  function formatRate(rate, decided, total) {
@@ -55572,9 +55614,37 @@ function basisLabel(basis, lang) {
55572
55614
  function unattributedNote(n, lang) {
55573
55615
  return L3[lang].unattributed(n);
55574
55616
  }
55617
+ function pageColumnLabel(page, origin) {
55618
+ let label = page.url;
55619
+ if (origin && page.url.startsWith(origin)) {
55620
+ const rest = page.url.slice(origin.length);
55621
+ label = rest === "" ? "/" : rest;
55622
+ }
55623
+ return `${label}${page.auth ? " \u{1F512}" : ""}`;
55624
+ }
55625
+ function commonOrigin(pages) {
55626
+ if (!pages.length) return void 0;
55627
+ let origin;
55628
+ for (const p of pages) {
55629
+ let o;
55630
+ try {
55631
+ o = new URL(p.url).origin;
55632
+ } catch {
55633
+ return void 0;
55634
+ }
55635
+ if (origin === void 0) origin = o;
55636
+ else if (origin !== o) return void 0;
55637
+ }
55638
+ return origin;
55639
+ }
55640
+ function pageOriginNote(origin, lang) {
55641
+ return origin ? L3[lang].originNote(origin) : void 0;
55642
+ }
55575
55643
  function pageView(result, page) {
55644
+ const own = result.scope.pageSubjects?.[page.id];
55576
55645
  return {
55577
55646
  ...result,
55647
+ ...own ? { scope: { ...result.scope, subjectsSeen: own } } : {},
55578
55648
  criteria: page.criteria,
55579
55649
  findings: page.findings,
55580
55650
  ...result.packFindings ? { packFindings: result.packFindings.filter((f) => f.page === page.id) } : {}
@@ -55606,7 +55676,7 @@ function renderRedirected(redirected, lang = "en") {
55606
55676
  "| --- | --- | --- | --- |"
55607
55677
  ];
55608
55678
  for (const r of redirected) {
55609
- const why = r.reason === "http-status" ? fr ? `HTTP ${r.status ?? "\u2265 400"} \u2014 page d'erreur rendue \xE0 la m\xEAme adresse` : `HTTP ${r.status ?? "\u2265 400"} \u2014 error page served at the same address` : fr ? "redirection" : "redirect";
55679
+ const why = r.reason === "http-status" ? fr ? `HTTP ${r.status ?? "\u2265 400"} \u2014 page d'erreur rendue \xE0 la m\xEAme adresse` : `HTTP ${r.status ?? "\u2265 400"} \u2014 error page served at the same address` : r.reason === "error" ? fr ? `le navigateur a \xE9chou\xE9 sur cette page${r.detail ? ` \u2014 ${r.detail}` : ""}` : `the browser failed on this page${r.detail ? ` \u2014 ${r.detail}` : ""}` : fr ? "redirection" : "redirect";
55610
55680
  out2.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
55611
55681
  }
55612
55682
  return out2;
@@ -55633,7 +55703,10 @@ function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
55633
55703
  }
55634
55704
  out2.push(`> ${s.note}`, "");
55635
55705
  if (derived.some((p) => p.basis === "attributed")) out2.push(`> \u26A0\uFE0F ${s.basisNote}`, "");
55636
- const head = [isCore(standard) ? s.criterion : s.criterion, ...derived.map((p) => `${p.name}${p.auth ? " \u{1F512}" : ""}`)];
55706
+ const origin = commonOrigin(derived);
55707
+ const originNote = pageOriginNote(origin, lang);
55708
+ if (originNote) out2.push(`> ${originNote}`, "");
55709
+ const head = [s.criterion, ...derived.map((p) => pageColumnLabel(p, origin))];
55637
55710
  out2.push(`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`);
55638
55711
  const { rows, status } = pageGridModel(result, derived, standard, lang);
55639
55712
  out2.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(...gridRate(rows, status, p.id))}**`).join(" | ")} |`);
@@ -56202,7 +56275,7 @@ function render(r, lang, opts) {
56202
56275
  for (const pg of derived) {
56203
56276
  const nc = pg.findings.filter((f) => !f.advisory);
56204
56277
  const adv = pg.findings.filter((f) => f.advisory);
56205
- out2.push(`### ${pg.name} \u2014 \`${pg.url}\` \u2014 ${pg.auth ? s.authYes : s.authNo}`, "");
56278
+ out2.push("<details>", `<summary>${pg.name} \u2014 <code>${pg.url}</code> \u2014 ${pg.auth ? s.authYes : s.authNo}</summary>`, "");
56206
56279
  out2.push(`- ${nc.length} ${s.ncCount}${adv.length ? ` \xB7 ${adv.length} ${s.advCount}` : ""}`);
56207
56280
  const notes = pageScope.find((x) => x.id === pg.id)?.notes;
56208
56281
  if (notes) out2.push(`- _${notes}_`);
@@ -56225,7 +56298,7 @@ function render(r, lang, opts) {
56225
56298
  out2.push(` - [${label}] \`${f.selectorHint}\` \u2014 ${mdText(resolveMessage(f, lang))}`);
56226
56299
  }
56227
56300
  if (nc.length > PER_PAGE_MAX) out2.push(` - _${s.perPageMore(nc.length - PER_PAGE_MAX, nc.length)}_`);
56228
- out2.push("");
56301
+ out2.push("", "</details>", "");
56229
56302
  }
56230
56303
  }
56231
56304
  out2.push(`## ${s.cTitle}`, "");
@@ -60153,6 +60226,15 @@ function parseSitemapUrls(xml) {
60153
60226
  while ((m = re.exec(xml)) !== null) out2.push(m[1]);
60154
60227
  return out2;
60155
60228
  }
60229
+ var DOWNLOAD_EXT = /\.(?:pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|tar|epub|dmg|exe|pkg|mp3|mp4|m4a|m4v|avi|mov|wmv|webm|ogg|wav|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf)$/i;
60230
+ function isDownloadUrl(url) {
60231
+ let path = url;
60232
+ try {
60233
+ path = new URL(url).pathname;
60234
+ } catch {
60235
+ }
60236
+ return DOWNLOAD_EXT.test(path);
60237
+ }
60156
60238
  function extractLinks(html, baseUrl) {
60157
60239
  const origin = new URL(baseUrl).origin;
60158
60240
  const seen = /* @__PURE__ */ new Set();
@@ -60170,6 +60252,7 @@ function extractLinks(html, baseUrl) {
60170
60252
  }
60171
60253
  if (abs.protocol !== "http:" && abs.protocol !== "https:") continue;
60172
60254
  if (abs.origin !== origin) continue;
60255
+ if (isDownloadUrl(abs.pathname)) continue;
60173
60256
  abs.hash = "";
60174
60257
  const url = abs.href;
60175
60258
  if (seen.has(url)) continue;
@@ -60519,10 +60602,11 @@ function writeRunnerSnapshot(root, out2, target, page) {
60519
60602
  if (!collected?.dom) return void 0;
60520
60603
  const url = page?.url ?? hostPageOf(out2.url ?? collected.url, target);
60521
60604
  const id = page?.id ?? pageIdFor(url);
60605
+ const name2 = page?.name?.trim() || collected.title?.trim() || nameFromUrl(url);
60522
60606
  const meta2 = {
60523
60607
  v: SNAPSHOT_VERSION,
60524
60608
  id,
60525
- name: page?.name ?? collected.title ?? id,
60609
+ name: name2,
60526
60610
  url,
60527
60611
  runner: "scan",
60528
60612
  ...collected.viewport ? { viewport: collected.viewport } : {},
@@ -61083,14 +61167,17 @@ async function probeFocusVisible(page, scope = "", limits = PROBE_DEFAULTS, dead
61083
61167
  if (!count) return [];
61084
61168
  const hits = [];
61085
61169
  const seen = /* @__PURE__ */ new Set();
61086
- const limit = Math.min(count + 2, limits.maxFocusables + 10);
61170
+ const limit = tabPressBudget(count, limits);
61171
+ let prevKey = null;
61087
61172
  for (let i2 = 0; i2 < limit; i2++) {
61088
61173
  if (deadline?.out()) break;
61089
61174
  await page.keyboard.press("Tab");
61090
61175
  const r = await page.evaluate(FOCUS_CHECK_PROBE);
61091
61176
  if (!r) continue;
61177
+ if (r.key === prevKey) continue;
61092
61178
  if (seen.has(r.key)) break;
61093
61179
  seen.add(r.key);
61180
+ prevKey = r.key;
61094
61181
  if (!r.changed) {
61095
61182
  hits.push({
61096
61183
  selector: r.selector,
@@ -61102,11 +61189,23 @@ async function probeFocusVisible(page, scope = "", limits = PROBE_DEFAULTS, dead
61102
61189
  }
61103
61190
  return hits;
61104
61191
  }
61192
+ var NATIVE_SEGMENT_STOPS = {
61193
+ date: 5,
61194
+ time: 5,
61195
+ "datetime-local": 8,
61196
+ month: 4,
61197
+ week: 4
61198
+ };
61199
+ function tabPressBudget(count, limits) {
61200
+ return Math.min(count * 2 + 20, limits.maxFocusables * 2 + 20);
61201
+ }
61105
61202
  var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
61106
61203
  const e = document.activeElement;
61107
61204
  if (!e || e === document.body || e === document.documentElement) return null;
61108
61205
  const key = e.getAttribute && e.getAttribute('data-u11y-f');
61109
- return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e) };
61206
+ const stops = ${JSON.stringify(NATIVE_SEGMENT_STOPS)};
61207
+ const type = e.tagName === 'INPUT' ? (e.getAttribute('type') || 'text').toLowerCase() : '';
61208
+ return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e), segments: stops[type] || 1 };
61110
61209
  })()`;
61111
61210
  async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
61112
61211
  const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
@@ -61114,7 +61213,7 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
61114
61213
  const hits = [];
61115
61214
  const seen = /* @__PURE__ */ new Set();
61116
61215
  const confirmPresses = 2;
61117
- const limit = Math.min(count + 2, limits.maxFocusables + 10);
61216
+ const limit = tabPressBudget(count, limits);
61118
61217
  let prev = null;
61119
61218
  for (let i2 = 0; i2 < limit; i2++) {
61120
61219
  if (deadline?.out()) break;
@@ -61122,8 +61221,9 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
61122
61221
  const now = await page.evaluate(FOCUS_WHERE_PROBE);
61123
61222
  if (!now) break;
61124
61223
  if (prev?.tagged && now.tagged && now.key === prev.key) {
61224
+ const budget = Math.max(confirmPresses, (now.segments ?? 1) - 1);
61125
61225
  let stuck = true;
61126
- for (let k = 0; k < confirmPresses && stuck; k++) {
61226
+ for (let k = 0; k < budget && stuck; k++) {
61127
61227
  if (deadline?.out()) break;
61128
61228
  await page.keyboard.press("Tab");
61129
61229
  const again = await page.evaluate(FOCUS_WHERE_PROBE);
@@ -61133,13 +61233,15 @@ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
61133
61233
  hits.push({
61134
61234
  selector: now.selector,
61135
61235
  html: now.html,
61136
- detail: `Le focus reste sur cet \xE9l\xE9ment apr\xE8s ${1 + confirmPresses} appuis sur Tab, alors que la page compte ${count} \xE9l\xE9ments focalisables \u2014 pi\xE8ge au clavier (2.1.2).`
61236
+ detail: `Le focus reste sur cet \xE9l\xE9ment apr\xE8s ${1 + budget} appuis sur Tab, alors que la page compte ${count} \xE9l\xE9ments focalisables \u2014 pi\xE8ge au clavier (2.1.2).`
61137
61237
  });
61138
61238
  break;
61139
61239
  }
61140
61240
  }
61141
- if (seen.has(now.key)) break;
61142
- seen.add(now.key);
61241
+ if (now.key !== prev?.key) {
61242
+ if (seen.has(now.key)) break;
61243
+ seen.add(now.key);
61244
+ }
61143
61245
  prev = now;
61144
61246
  }
61145
61247
  return hits;
@@ -61666,13 +61768,26 @@ async function runScanManyLocal(urls, opts) {
61666
61768
  const redirected = [];
61667
61769
  try {
61668
61770
  for (const url of urls) {
61669
- const out2 = await runOnPage(browser, AxeBuilder, url, false, {
61670
- storageState: opts.storageState,
61671
- interact,
61672
- allowClicks: clicksAllowed(opts.storageState, opts.interactClicks),
61673
- lang,
61674
- snapshot: Boolean(opts.snapshotRoot)
61675
- });
61771
+ let out2;
61772
+ try {
61773
+ out2 = await runOnPage(browser, AxeBuilder, url, false, {
61774
+ storageState: opts.storageState,
61775
+ interact,
61776
+ allowClicks: clicksAllowed(opts.storageState, opts.interactClicks),
61777
+ lang,
61778
+ snapshot: Boolean(opts.snapshotRoot)
61779
+ });
61780
+ } catch (e) {
61781
+ redirected.push({
61782
+ id: slugifyPageId(url),
61783
+ name: url,
61784
+ requested: url,
61785
+ landed: url,
61786
+ reason: "error",
61787
+ detail: String(e?.message ?? e).slice(0, 200)
61788
+ });
61789
+ continue;
61790
+ }
61676
61791
  if (out2.httpStatus !== void 0 && out2.httpStatus >= 400) {
61677
61792
  redirected.push({
61678
61793
  id: slugifyPageId(url),
@@ -61714,13 +61829,26 @@ async function runSampleScanLocal(pages, opts) {
61714
61829
  for (const page of pages) {
61715
61830
  const storageState = page.storageState ?? opts.storageState;
61716
61831
  const isFile = !/^https?:\/\//i.test(page.url);
61717
- const out2 = await runOnPage(browser, AxeBuilder, page.url, isFile, {
61718
- storageState,
61719
- interact,
61720
- allowClicks: clicksAllowed(storageState, opts.interactClicks),
61721
- lang,
61722
- snapshot: Boolean(opts.snapshotRoot)
61723
- });
61832
+ let out2;
61833
+ try {
61834
+ out2 = await runOnPage(browser, AxeBuilder, page.url, isFile, {
61835
+ storageState,
61836
+ interact,
61837
+ allowClicks: clicksAllowed(storageState, opts.interactClicks),
61838
+ lang,
61839
+ snapshot: Boolean(opts.snapshotRoot)
61840
+ });
61841
+ } catch (e) {
61842
+ redirected.push({
61843
+ id: page.id,
61844
+ name: page.name,
61845
+ requested: page.url,
61846
+ landed: page.url,
61847
+ reason: "error",
61848
+ detail: String(e?.message ?? e).slice(0, 200)
61849
+ });
61850
+ continue;
61851
+ }
61724
61852
  const requestedUrl = isFile ? "file://" + resolve12(page.url) : page.url;
61725
61853
  const landedUrl = out2.landedUrl ?? out2.url;
61726
61854
  if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
@@ -63443,6 +63571,8 @@ function perPageTable(result, standard = CORE2, lang = "en") {
63443
63571
  ...scoreboardTable(result, derived, standard, s, lang),
63444
63572
  "",
63445
63573
  ...basisCaveats(result, derived, s, lang),
63574
+ ...fullGridBlock(result, derived, standard, s, lang),
63575
+ "",
63446
63576
  ...derived.flatMap((pg) => [...namedCriteriaBlock(result, pg, standard, s, lang), ""])
63447
63577
  ].join("\n");
63448
63578
  }
@@ -63568,7 +63698,9 @@ function fullGridBlock(result, derived, standard, s, lang) {
63568
63698
  const { rows, status } = pageGridModel(result, derived, standard, lang);
63569
63699
  if (!rows.length || !derived.length) return [];
63570
63700
  const cell = (v) => v.replace(/\|/g, "\\|");
63571
- const head = [s.criterion, ...derived.map((p) => `${cell(p.name)}${p.auth ? " \u{1F512}" : ""}`)];
63701
+ const origin = commonOrigin(derived);
63702
+ const originNote = pageOriginNote(origin, lang);
63703
+ const head = [s.criterion, ...derived.map((p) => cell(pageColumnLabel(p, origin)))];
63572
63704
  const out2 = [
63573
63705
  "<details>",
63574
63706
  `<summary><b>${s.fullGrid}</b> \u2014 ${rows.length} \xD7 ${derived.length}</summary>`,
@@ -63576,6 +63708,7 @@ function fullGridBlock(result, derived, standard, s, lang) {
63576
63708
  "",
63577
63709
  `> ${s.gridLegend}`,
63578
63710
  "",
63711
+ ...originNote ? [`> ${originNote}`, ""] : [],
63579
63712
  `| ${head.join(" | ")} |`,
63580
63713
  `| ${head.map(() => "---").join(" | ")} |`
63581
63714
  ];
@@ -64394,10 +64527,12 @@ function crossGridBlocks(result, derived, standard, lang) {
64394
64527
  if (!derived.length) return [];
64395
64528
  const { rows, status } = pageGridModel(result, derived, standard, lang);
64396
64529
  if (!rows.length) return [];
64530
+ const origin = commonOrigin(derived);
64531
+ const originNote = pageOriginNote(origin, lang);
64397
64532
  const table = {
64398
64533
  kind: "table",
64399
64534
  caption: t3.crossGridCaption,
64400
- columns: [{ text: t3.criterion }, ...derived.map((p) => ({ text: `${p.name}${p.auth ? " \u{1F512}" : ""}` }))],
64535
+ columns: [{ text: t3.criterion }, ...derived.map((p) => ({ text: pageColumnLabel(p, origin) }))],
64401
64536
  rows: []
64402
64537
  };
64403
64538
  let group = "";
@@ -64408,7 +64543,12 @@ function crossGridBlocks(result, derived, standard, lang) {
64408
64543
  }
64409
64544
  table.rows.push([{ text: row.label }, ...derived.map((p) => ({ status: status.get(row.id)?.get(p.id) ?? "manual", text: "" }))]);
64410
64545
  }
64411
- return [{ kind: "heading", level: 2, text: t3.grid, id: "grid" }, { kind: "note", tone: "info", runs: ticks(t3.legend) }, table];
64546
+ return [
64547
+ { kind: "heading", level: 2, text: t3.grid, id: "grid" },
64548
+ { kind: "note", tone: "info", runs: ticks(t3.legend) },
64549
+ ...originNote ? [{ kind: "note", tone: "info", runs: ticks(originNote) }] : [],
64550
+ table
64551
+ ];
64412
64552
  }
64413
64553
  function pageGridBlocks(result, page, standard, lang) {
64414
64554
  const t3 = T4[lang];
@@ -68164,7 +68304,7 @@ Commands:
68164
68304
  SKIP_A11Y=1. Called by hooks/pre-tool-use.mjs, not by hand.
68165
68305
  pages The per-page criterion grid \u2014 RGAA is a per-page norm, the engine's
68166
68306
  verdict is scope-wide, and this bridges the two. One row per criterion
68167
- (the pack's own under --standard), one column per page. Rebuilt from a
68307
+ (the pack's own under --standard), one column per page URL. Rebuilt from a
68168
68308
  committed audit.json alone: no snapshots on disk, no browser.
68169
68309
  Two honesty rules: a finding is attributed to a page only when something
68170
68310
  SAYS so (the snapshot it was raised on, the scanned URL, the sample page
@@ -70602,7 +70742,12 @@ async function cmdScan(p) {
70602
70742
  }
70603
70743
  if (dynamic.redirected?.length) {
70604
70744
  for (const r of dynamic.redirected) {
70605
- const why = r.reason === "http-status" ? lang === "fr" ? `a r\xE9pondu HTTP ${r.status} \xE0 la m\xEAme adresse` : `answered HTTP ${r.status} at the same address` : lang === "fr" ? `a redirig\xE9 vers ${r.landed}` : `redirected to ${r.landed}`;
70745
+ const why = r.reason === "http-status" ? lang === "fr" ? `a r\xE9pondu HTTP ${r.status} \xE0 la m\xEAme adresse` : `answered HTTP ${r.status} at the same address` : r.reason === "error" ? (
70746
+ // Named rather than folded into the redirect wording: « a redirigé vers » its own
70747
+ // address is a sentence that explains nothing, and the browser's message is the
70748
+ // one fact that says which page to go and look at.
70749
+ lang === "fr" ? `a fait \xE9chouer le navigateur${r.detail ? ` \u2014 ${r.detail}` : ""}` : `made the browser fail${r.detail ? ` \u2014 ${r.detail}` : ""}`
70750
+ ) : lang === "fr" ? `a redirig\xE9 vers ${r.landed}` : `redirected to ${r.landed}`;
70606
70751
  console.error(
70607
70752
  lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} ${why}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom demand\xE9.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} ${why}. Recording it would have described that screen under the requested name.`
70608
70753
  );
@@ -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.27.0
8
+ version: 5.28.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code