ultra11y 5.21.0 → 5.22.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.21.0",
13
+ "version": "5.22.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.21.0",
4
+ "version": "5.22.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.21.0",
4
+ "version": "5.22.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.21.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.22.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
@@ -306,6 +306,49 @@ async function probeFocusVisible(page, scope = "", limits = PROBE_DEFAULTS, dead
306
306
  }
307
307
  return hits;
308
308
  }
309
+ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
310
+ const e = document.activeElement;
311
+ if (!e || e === document.body || e === document.documentElement) return null;
312
+ const key = e.getAttribute && e.getAttribute('data-u11y-f');
313
+ return { key: key || __sel(e), selector: __sel(e), html: __html(e) };
314
+ })()`;
315
+ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
316
+ const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
317
+ if (!count || count < 2) return [];
318
+ const hits = [];
319
+ const seen = /* @__PURE__ */ new Set();
320
+ const confirmPresses = 2;
321
+ const limit = Math.min(count + 2, limits.maxFocusables + 10);
322
+ let prev = null;
323
+ for (let i = 0; i < limit; i++) {
324
+ if (deadline?.out()) break;
325
+ await page.keyboard.press("Tab");
326
+ const now = await page.evaluate(FOCUS_WHERE_PROBE);
327
+ if (!now) break;
328
+ if (prev && now.key === prev.key) {
329
+ let stuck = true;
330
+ for (let k = 0; k < confirmPresses && stuck; k++) {
331
+ if (deadline?.out()) break;
332
+ await page.keyboard.press("Tab");
333
+ const again = await page.evaluate(FOCUS_WHERE_PROBE);
334
+ stuck = again !== null && again.key === now.key;
335
+ }
336
+ if (stuck) {
337
+ hits.push({
338
+ selector: now.selector,
339
+ 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).`
341
+ });
342
+ break;
343
+ }
344
+ }
345
+ if (seen.has(now.key)) break;
346
+ seen.add(now.key);
347
+ prev = now;
348
+ if (hits.length >= 4) break;
349
+ }
350
+ return hits;
351
+ }
309
352
  async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
310
353
  const triggers = await page.evaluate(HOVER_SETUP_PROBE);
311
354
  const hits = [];
@@ -346,7 +389,16 @@ async function runLiveProbes(page, opts = {}) {
346
389
  const canStyle = typeof page.addStyleTag === "function";
347
390
  const size = canResize ? page.viewportSize() ?? null : null;
348
391
  const restore = size ?? { width: 1280, height: 900 };
349
- const out = { focusVisible: [], hover: [], reflowZoom: [], textSpacing: [], reflow: { horizontalScroll: false }, probed: [], skipped: [] };
392
+ const out = {
393
+ focusVisible: [],
394
+ hover: [],
395
+ keyboardTrap: [],
396
+ reflowZoom: [],
397
+ textSpacing: [],
398
+ reflow: { horizontalScroll: false },
399
+ probed: [],
400
+ skipped: []
401
+ };
350
402
  const skip = (sc, why) => {
351
403
  out.skipped?.push({ sc, why });
352
404
  };
@@ -381,6 +433,7 @@ async function runLiveProbes(page, opts = {}) {
381
433
  };
382
434
  if (!canResize) skip("1.4.10", "the page object cannot resize its viewport");
383
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");
384
437
  if (!canHover) skip("1.4.13", "the page object cannot hover");
385
438
  if (!canStyle) skip("1.4.12", "the page object cannot inject a stylesheet");
386
439
  if (want("1.4.4")) {
@@ -424,6 +477,13 @@ async function runLiveProbes(page, opts = {}) {
424
477
  out.probed.push("2.4.7");
425
478
  }
426
479
  }
480
+ if (want("2.1.2") && canType) {
481
+ const r = await bounded("2.1.2", () => probeKeyboardTrap(page, limits, deadline));
482
+ if (r) {
483
+ out.keyboardTrap = r;
484
+ out.probed.push("2.1.2");
485
+ }
486
+ }
427
487
  if (want("1.4.13") && canHover && canType) {
428
488
  const r = await bounded("1.4.13", () => probeHover(page, limits, deadline));
429
489
  if (r) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.21.0",
3
+ "version": "5.22.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",
@@ -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.21.0";
9
+ var VERSION = "5.22.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -39175,6 +39175,11 @@ var PROBE_WCAG = {
39175
39175
  // Text Spacing — clipping/overlap under the WCAG spacing override
39176
39176
  hover: "1.4.13",
39177
39177
  // Content on Hover or Focus — not dismissible/hoverable/persistent
39178
+ // No Keyboard Trap. Until this probe existed, 2.1.2 was one of the five needs-rendering
39179
+ // criteria NO tier measured (src/report.ts NEEDS_RENDERING) — so RGAA 12.9 reached a paid
39180
+ // adjudicator on every run, carrying whatever `preventDefault(` happened to be in the source.
39181
+ // A trap is a property of the tab ring, and the tab ring only exists in a browser.
39182
+ "keyboard-trap": "2.1.2",
39178
39183
  // Stateful input-overflow probes — a FILLED input whose typed value is clipped/unreadable
39179
39184
  // under a stress. Same defect, one SC per stress (so each folds onto the SC that stress
39180
39185
  // actually evidences), so they stay unambiguous cross-standard (RGAA 10.11/10.4/10.12).
@@ -39192,6 +39197,9 @@ var PROBE_SEVERITY = {
39192
39197
  "reflow-zoom": "majeur",
39193
39198
  "text-spacing": "mineur",
39194
39199
  hover: "mineur",
39200
+ // Not heuristic and not minor: the probe pressed Tab three times and focus did not move on a
39201
+ // page with other focusables. A keyboard user cannot get out of it — that is a blocker.
39202
+ "keyboard-trap": "bloquant",
39195
39203
  // Input-overflow is a directly OBSERVED loss of content (the value the user typed is no
39196
39204
  // longer visible), not a heuristic — the human auditor confirmed the real instance class
39197
39205
  // — so it is majeur on every stress (incl. text-spacing, unlike the generic text-spacing
@@ -39876,6 +39884,77 @@ var SUBJECTS = {
39876
39884
  )
39877
39885
  )
39878
39886
  ),
39887
+ // ADDITIONAL CONTENT — the thing that appears when a component is hovered, focused or
39888
+ // activated, and the subject RGAA 10.14 and 12.11 are entirely about.
39889
+ //
39890
+ // It had no harvester at all, and the two criteria were mapped onto `focusables` +
39891
+ // `pointerHandlers` instead. On a page whose tooltip is pure CSS that harvests exactly
39892
+ // nothing to do with the question: measured on tests/fixtures/realworld, both criteria
39893
+ // reached a paid adjudicator carrying one anchor between them — `e.preventDefault();` from a
39894
+ // React submit handler — and were asked whether the site's hover content is keyboard
39895
+ // reachable. No model can answer that, and the honest verdict (`undecidable`) leaves the
39896
+ // criterion « à évaluer » forever.
39897
+ //
39898
+ // So harvest the CANDIDATES, from the four shapes additional content actually takes, and let
39899
+ // the adjudicator read the rendered page for the rest:
39900
+ // - an explicit `role="tooltip"`, and whatever describes a control (`aria-describedby`,
39901
+ // `aria-details`) — the trigger/target pair `probeHover` also keys on;
39902
+ // - a disclosure: `aria-expanded` on the trigger, `aria-controls` naming the panel;
39903
+ // - the native pair, `<details>`/`<summary>`;
39904
+ // - the popover API (`popover`, `popovertarget`).
39905
+ // The TRIGGER goes in beside its target, because 12.11 asks whether the component can be
39906
+ // reached at all and 10.14 asks whether activating it reveals the content — both questions
39907
+ // about the trigger, neither answerable from the panel alone.
39908
+ additionalContent: (docs) => docs.flatMap((d) => {
39909
+ const byId2 = /* @__PURE__ */ new Map();
39910
+ for (const e of d.elements) {
39911
+ const id = attr(e, "id");
39912
+ if (id && !byId2.has(id)) byId2.set(id, e);
39913
+ }
39914
+ const out2 = [];
39915
+ const seen = /* @__PURE__ */ new Set();
39916
+ const push = (e, note, cls) => {
39917
+ if (seen.has(e.start)) return;
39918
+ seen.add(e.start);
39919
+ out2.push(h(d, e, note, cls));
39920
+ };
39921
+ for (const e of d.elements) {
39922
+ if (attr(e, "role") === "tooltip")
39923
+ push(e, `<${e.tag}> role="tooltip" text="${t(e, 60)}" \u2014 revealed how, and reachable with the keyboard?`, `tooltip|${t(e, 40)}`);
39924
+ if (attr(e, "popover") !== void 0) push(e, `<${e.tag}> popover="${attr(e, "popover") ?? ""}" text="${t(e, 60)}"`, `popover|${t(e, 40)}`);
39925
+ }
39926
+ for (const e of d.elements) {
39927
+ for (const rel2 of ["aria-describedby", "aria-details", "aria-controls", "popovertarget"]) {
39928
+ const ref = (attr(e, rel2) ?? "").trim().split(/\s+/)[0];
39929
+ if (!ref) continue;
39930
+ const target = byId2.get(ref);
39931
+ if (!target) continue;
39932
+ const expanded = attr(e, "aria-expanded");
39933
+ push(
39934
+ e,
39935
+ `<${e.tag}> ${rel2}="${ref}"${expanded !== void 0 ? ` aria-expanded="${expanded}"` : ""} text="${t(e, 40)}" \u2014 the component whose hover/focus/activation reveals #${ref}`,
39936
+ `trigger|${e.tag}|${rel2}|${ref}`
39937
+ );
39938
+ push(
39939
+ target,
39940
+ `<${target.tag}> #${ref} \u2014 additional content pointed at by a ${rel2} on <${e.tag}>: visible on hover only, or on focus too?`,
39941
+ `revealed|${ref}`
39942
+ );
39943
+ }
39944
+ if (attr(e, "aria-expanded") !== void 0) {
39945
+ push(e, `<${e.tag}> aria-expanded="${attr(e, "aria-expanded") ?? ""}" text="${t(e, 40)}" \u2014 disclosure trigger`, `disclosure|${e.tag}|${t(e, 40)}`);
39946
+ }
39947
+ }
39948
+ for (const e of elementsByTag(d, "details", "summary")) {
39949
+ push(e, `<${e.tag}> text="${t(e, 60)}" \u2014 native disclosure`, `details|${e.tag}|${t(e, 40)}`);
39950
+ }
39951
+ out2.push(
39952
+ ...linesOf(d, /:(?:hover|focus|focus-within|focus-visible)\b/).map(
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
+ )
39955
+ );
39956
+ return out2;
39957
+ }),
39879
39958
  // Content pinned over the page — what can obscure a focused element.
39880
39959
  stickies: (docs) => docs.flatMap((d) => [
39881
39960
  ...d.elements.filter((e) => /position\s*:\s*(?:fixed|sticky)/.test(attr(e, "style") ?? "")).map((e) => h(d, e, `pinned element (inline): ${(attr(e, "style") ?? "").slice(0, 60)} \u2014 can it cover a focused element?`, `sticky|${selectorFor(e)}`)),
@@ -39909,7 +39988,9 @@ var SC_SUBJECTS = {
39909
39988
  "1.4.10": ["readingOrder"],
39910
39989
  "1.4.11": ["colourPairs"],
39911
39990
  "1.4.12": ["readingOrder"],
39912
- "1.4.13": ["aria", "stickies"],
39991
+ // Content on Hover or Focus. Its subject IS the additional content — `aria` and `stickies`
39992
+ // were standing in for a harvester that did not exist yet.
39993
+ "1.4.13": ["additionalContent", "aria", "stickies"],
39913
39994
  "2.1.1": ["pointerHandlers", "focusables"],
39914
39995
  "2.1.2": ["focusables", "pointerHandlers"],
39915
39996
  "2.1.4": ["shortcuts"],
@@ -40002,7 +40083,7 @@ var PACK_SUBJECTS = {
40002
40083
  "12.8": ["focusOrder"],
40003
40084
  "12.9": ["focusables", "pointerHandlers"],
40004
40085
  "12.10": ["shortcuts"],
40005
- "12.11": ["pointerHandlers", "focusables"],
40086
+ "12.11": ["additionalContent", "pointerHandlers", "focusables"],
40006
40087
  // Theme 4 — multimedia. 4.10 (is automatically-triggered sound controllable?) maps onto
40007
40088
  // WCAG 1.4.2, which is `static` and therefore has no subject of its own — but the pack
40008
40089
  // flags 4.10 `judgment`, so judgmentGuard reopens it and it would arrive with nothing.
@@ -40029,7 +40110,7 @@ var PACK_SUBJECTS = {
40029
40110
  "10.11": ["readingOrder"],
40030
40111
  "10.12": ["readingOrder"],
40031
40112
  "10.13": ["aria", "stickies"],
40032
- "10.14": ["focusables", "pointerHandlers"],
40113
+ "10.14": ["additionalContent", "focusables", "pointerHandlers"],
40033
40114
  // Theme 13 — consultation.
40034
40115
  "13.1": ["timers"],
40035
40116
  "13.3": ["downloadDocs"],
@@ -48486,7 +48567,7 @@ var RESIDUAL_TRAIL = {
48486
48567
  // Rendering criteria no automated tier measures — say so, rather than pointing at `scan` and
48487
48568
  // letting the reader discover it changes nothing.
48488
48569
  "1.4.5": "No automated tier decides this: whether text is presented as an image is a reading of each image's content. Adjudicate it (`verify --manual`) against the images the audit lists.",
48489
- "2.1.2": "No automated tier decides this: escaping a keyboard trap has to be attempted by hand, on each focusable region.",
48570
+ "2.1.2": "Needs a live browser: the tab ring is walked and every focusable is checked for one Tab cannot move off \u2014 `scan <url> --runtime local --merge <audit.json>`. A region only a pointer can open (a custom widget behind a click) still has to be attempted by hand.",
48490
48571
  "2.3.1": "No automated tier decides this: flashing has to be observed over time on the rendered page.",
48491
48572
  "2.4.11": "No automated tier decides this: whether a focused element stays unobscured depends on the sticky headers and overlays in play on each screen."
48492
48573
  };
@@ -55074,6 +55155,7 @@ var NEEDS_RENDERING = [
55074
55155
  { sc: "1.4.11", label: { fr: "contraste des composants", en: "non-text contrast" } },
55075
55156
  { sc: "1.4.12", label: { fr: "espacement du texte", en: "text spacing" } },
55076
55157
  { sc: "1.4.13", label: { fr: "contenu au survol", en: "content on hover" } },
55158
+ { sc: "2.1.2", label: { fr: "pi\xE8ge au clavier", en: "keyboard trap" } },
55077
55159
  { sc: "2.4.7", label: { fr: "visibilit\xE9 du focus", en: "focus visibility" } },
55078
55160
  { sc: "4.1.3", label: { fr: "r\xE9gions live", en: "live regions" } }
55079
55161
  ];
@@ -58285,12 +58367,16 @@ function applyAdjudication(audit2, adj, opts = {}) {
58285
58367
  }
58286
58368
  }
58287
58369
  } else if (v === "NC") {
58288
- if (!it.findings || it.findings.length === 0) blame(it.criteriaId, `criterion ${it.criteriaId}: an NC verdict requires at least one groundable finding`);
58370
+ if (!it.findings || it.findings.length === 0)
58371
+ blame(
58372
+ it.criteriaId,
58373
+ `criterion ${it.criteriaId}: an NC verdict requires at least one groundable finding \u2014 { file, line, message, snippet, normativeRef } pointing at a real anchor from this criterion's own evidence`
58374
+ );
58289
58375
  for (const f of it.findings ?? []) {
58290
58376
  if (typeof f.file !== "string" || !f.file.trim()) {
58291
58377
  blame(
58292
58378
  it.criteriaId,
58293
- `criterion ${it.criteriaId}: an NC finding must name the file it was observed in \u2014 nobody can act on a non-conformity with no location`
58379
+ `criterion ${it.criteriaId}: an NC finding must name the file it was observed in \u2014 nobody can act on a non-conformity with no location. An absence is still observed somewhere: cite the element and the page you observed it on, with the file, line and snippet copied from this criterion's own evidence. If the subject exists nowhere in scope, the verdict is NA with a justification, not NC.`
58294
58380
  );
58295
58381
  continue;
58296
58382
  }
@@ -58317,7 +58403,10 @@ function applyAdjudication(audit2, adj, opts = {}) {
58317
58403
  }
58318
58404
  for (const rec of it.recommendations ?? []) {
58319
58405
  if (typeof rec.file !== "string" || !rec.file.trim()) {
58320
- blame(it.criteriaId, `criterion ${it.criteriaId}: a recommendation must name the file it was observed in`);
58406
+ blame(
58407
+ it.criteriaId,
58408
+ `criterion ${it.criteriaId}: a recommendation must name the file it was observed in \u2014 copy a file and line from this criterion's own evidence, or drop the recommendation`
58409
+ );
58321
58410
  continue;
58322
58411
  }
58323
58412
  toGround(it.criteriaId, { file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
@@ -58508,6 +58597,8 @@ var T2 = {
58508
58597
  snippetLabel: "`snippet` \xE0 copier dans la citation",
58509
58598
  renderedAvailable: "**RENDU DISPONIBLE.** Cet audit a ing\xE9r\xE9 des captures de page : le rendu de la page est sur le disque, sous `.ultra11y/pages/<id>/` \u2014 `dom.html` (le DOM s\xE9rialis\xE9 par le navigateur), `styles.json` (les styles calcul\xE9s), `boxes.json` (les bo\xEEtes et positions), `axtree.json` (l'arbre d'accessibilit\xE9) et `screen.png`. Un crit\xE8re \xAB \xE0 restituer \xBB \u2014 information par la couleur, op\xE9rabilit\xE9 clavier d'un script, geste au pointeur \u2014 se tranche DEPUIS CES FICHIERS : lisez-les comme vous liriez la source. `needs-rendered-dom` reste la bonne r\xE9ponse pour un crit\xE8re dont aucune capture ne porte le sujet, et pour lui seul.",
58510
58599
  nothingRendered: (ids) => `**AUCUN RENDU DANS CETTE PORT\xC9E.** ${ids.length} crit\xE8re(s) exigent une page rendue, et aucune page n'a \xE9t\xE9 instantan\xE9e ici : personne ne peut les trancher depuis la source, et \`needs-rendered-dom\` est pour eux la seule r\xE9ponse honn\xEAte. Rendez AVANT d'adjuger \u2014 \`ultra11y scan <url> --merge <audit.json>\` (ou \`scan --sample\`) \u2014 puis reconstruisez cette liste : la mesure en ferme la plupart sans mod\xE8le, donc sans facture. Concern\xE9s : ${ids.map((id) => `\`${id}\``).join(" \xB7 ")}`,
58600
+ briefContract: "> **CONTRAT DE VERDICT** \u2014 le pli est FERM\xC9 : un verdict auquel il manque son champ obligatoire est refus\xE9, et son crit\xE8re retourne \xAB \xE0 \xE9valuer \xBB en portant le refus. Renseignez le verdict de CE crit\xE8re, ici :",
58601
+ absenceRule: "> **UNE NC EN FORME D'ABSENCE S'ANCRE QUAND M\xCAME.** \xAB Il n'y a pas de second syst\xE8me de navigation \xBB, \xAB il n'y a pas de moteur de recherche \xBB, \xAB aucun message d'erreur ne sugg\xE8re le format attendu \xBB : une absence se CONSTATE quelque part. Citez l'\xE9l\xE9ment et la page o\xF9 vous l'avez constat\xE9e \u2014 le `<nav>`, le `<header>`, le formulaire \u2014 avec son `file`, sa `line` et son `snippet`. Une NC sans `file` est refus\xE9e aussi s\xFBrement qu'un `C` sans citations. Et si le sujet du crit\xE8re n'existe nulle part dans le p\xE9rim\xE8tre audit\xE9, le verdict n'est pas `NC` : c'est `NA`, avec sa justification.",
58511
58602
  incomplete: "LECTURE INCOMPL\xC8TE \u2014 un \xAB C \xBB sera refus\xE9 sur ce crit\xE8re",
58512
58603
  none: "(aucune \xE9vidence automatique \u2014 d\xE9cidez depuis la source, ou laissez `manual` avec une raison)",
58513
58604
  questions: "\xC0 v\xE9rifier manuellement",
@@ -58540,6 +58631,8 @@ var T2 = {
58540
58631
  snippetLabel: "`snippet` to copy into the citation",
58541
58632
  renderedAvailable: "**THE RENDERED PAGE IS AVAILABLE.** This audit ingested page captures: the rendered page is on disk under `.ultra11y/pages/<id>/` \u2014 `dom.html` (the DOM the browser serialized), `styles.json` (computed styles), `boxes.json` (boxes and positions), `axtree.json` (the accessibility tree) and `screen.png`. A needs-rendering criterion \u2014 information by colour, keyboard operability of a script, a pointer gesture \u2014 is decided FROM THOSE FILES: read them as you would read the source. `needs-rendered-dom` stays the right answer for a criterion no capture carries the subject of, and for that alone.",
58542
58633
  nothingRendered: (ids) => `**NOTHING WAS RENDERED IN THIS SCOPE.** ${ids.length} criteria need a rendered page, and no page was snapshotted here: nobody can settle them from source, and \`needs-rendered-dom\` is the only honest answer for them. Render BEFORE adjudicating \u2014 \`ultra11y scan <url> --merge <audit.json>\` (or \`scan --sample\`) \u2014 then rebuild this worklist: the measurement closes most of them with no model in the loop, and so with no bill. Affected: ${ids.map((id) => `\`${id}\``).join(" \xB7 ")}`,
58634
+ briefContract: "> **VERDICT CONTRACT** \u2014 the fold is FAIL-CLOSED: a verdict missing its required field is refused, and its criterion goes back to \xAB to assess \xBB carrying the refusal. Record THIS criterion's verdict, here:",
58635
+ absenceRule: "> **AN NC SHAPED LIKE AN ABSENCE STILL HAS TO BE ANCHORED.** \xAB There is no second navigation system \xBB, \xAB there is no search engine \xBB, \xAB no error message suggests the expected format \xBB: an absence is OBSERVED somewhere. Cite the element and the page you observed it on \u2014 the `<nav>`, the `<header>`, the form \u2014 with its `file`, `line` and `snippet`. An NC with no `file` is refused exactly as surely as a `C` with no citations. And when the criterion's subject exists nowhere in the audited scope, the verdict is not `NC`: it is `NA`, with its justification.",
58543
58636
  incomplete: "INCOMPLETE READING \u2014 a C will be refused on this criterion",
58544
58637
  none: "(no automatic evidence \u2014 decide from source, or leave `manual` with a reason)",
58545
58638
  questions: "To verify manually",
@@ -58584,14 +58677,14 @@ function formatAdjudication(items, lang = "en", standard = CORE2, opts = {}) {
58584
58677
  const s = T2[lang];
58585
58678
  const { showAlsoAt: shown } = adjudicationLimits(opts.cwd);
58586
58679
  const pack = isCore(standard) ? void 0 : loadPack(standard);
58587
- const out2 = opts.preamble === false ? [] : [s.title, "", s.intro, "", ...s.verdicts, "", s.rule, "", s.then, ""];
58588
- if (opts.preamble !== false && items.some((it) => it.evidence.some((e) => isSnapshotFile(e.file)))) {
58680
+ const out2 = opts.preamble === false ? [s.briefContract, "", ...s.verdicts, "", s.rule, "", s.absenceRule, ""] : [s.title, "", s.intro, "", ...s.verdicts, "", s.rule, "", s.absenceRule, "", s.then, ""];
58681
+ if (items.some((it) => it.evidence.some((e) => isSnapshotFile(e.file)))) {
58589
58682
  out2.push(`> ${s.renderedAvailable}`, "");
58590
58683
  }
58591
- if (opts.preamble !== false && opts.unrendered?.length) {
58684
+ if (opts.unrendered?.length) {
58592
58685
  out2.push(`> ${s.nothingRendered(opts.unrendered)}`, "");
58593
58686
  }
58594
- if (pack && opts.preamble !== false) out2.push(`> ${s.packIntro(pack.name)}`, "");
58687
+ if (pack) out2.push(`> ${s.packIntro(pack.name)}`, "");
58595
58688
  for (const it of items) {
58596
58689
  out2.push(`## ${pack ? `${pack.name} ` : ""}${it.criteriaId}${it.title ? ` \u2014 ${it.title}` : ""} _(${it.automatability})_`);
58597
58690
  const pop = it.population;
@@ -58669,8 +58762,13 @@ function writeAdjudication(items, outDir, opts) {
58669
58762
  writeFileSync10(verdictsPath, JSON.stringify({ ...file, items: slimAdjudicationItems(items) }, null, 2) + "\n");
58670
58763
  const itemsDir = join35(outDir, "adjudicate");
58671
58764
  mkdirSync9(itemsDir, { recursive: true });
58765
+ const unrendered = new Set(opts.unrendered ?? []);
58672
58766
  for (const it of items) {
58673
- writeFileSync10(join35(itemsDir, `${it.criteriaId}.md`), formatAdjudication([it], opts.lang ?? "en", opts.standard, { preamble: false }));
58767
+ const mine = unrendered.has(it.criteriaId) ? [it.criteriaId] : [];
58768
+ writeFileSync10(
58769
+ join35(itemsDir, `${it.criteriaId}.md`),
58770
+ formatAdjudication([it], opts.lang ?? "en", opts.standard, { preamble: false, ...mine.length ? { unrendered: mine } : {} })
58771
+ );
58674
58772
  }
58675
58773
  return { todoPath, mdPath, verdictsPath, itemsDir, count: items.length };
58676
58774
  }
@@ -59442,6 +59540,7 @@ var PROBE_FIELDS = [
59442
59540
  { key: "reflowZoom", engine: "reflow-zoom" },
59443
59541
  { key: "textSpacing", engine: "text-spacing" },
59444
59542
  { key: "hover", engine: "hover" },
59543
+ { key: "keyboardTrap", engine: "keyboard-trap" },
59445
59544
  { key: "inputOverflowReflow", engine: "input-overflow-reflow" },
59446
59545
  { key: "inputOverflowZoom", engine: "input-overflow-zoom" },
59447
59546
  { key: "inputOverflowSpacing", engine: "input-overflow-spacing" },
@@ -59970,6 +60069,49 @@ async function probeFocusVisible(page, scope = "", limits = PROBE_DEFAULTS, dead
59970
60069
  }
59971
60070
  return hits;
59972
60071
  }
60072
+ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
60073
+ const e = document.activeElement;
60074
+ if (!e || e === document.body || e === document.documentElement) return null;
60075
+ const key = e.getAttribute && e.getAttribute('data-u11y-f');
60076
+ return { key: key || __sel(e), selector: __sel(e), html: __html(e) };
60077
+ })()`;
60078
+ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60079
+ const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
60080
+ if (!count || count < 2) return [];
60081
+ const hits = [];
60082
+ const seen = /* @__PURE__ */ new Set();
60083
+ const confirmPresses = 2;
60084
+ const limit = Math.min(count + 2, limits.maxFocusables + 10);
60085
+ let prev = null;
60086
+ for (let i2 = 0; i2 < limit; i2++) {
60087
+ if (deadline?.out()) break;
60088
+ await page.keyboard.press("Tab");
60089
+ const now = await page.evaluate(FOCUS_WHERE_PROBE);
60090
+ if (!now) break;
60091
+ if (prev && now.key === prev.key) {
60092
+ let stuck = true;
60093
+ for (let k = 0; k < confirmPresses && stuck; k++) {
60094
+ if (deadline?.out()) break;
60095
+ await page.keyboard.press("Tab");
60096
+ const again = await page.evaluate(FOCUS_WHERE_PROBE);
60097
+ stuck = again !== null && again.key === now.key;
60098
+ }
60099
+ if (stuck) {
60100
+ hits.push({
60101
+ selector: now.selector,
60102
+ html: now.html,
60103
+ 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).`
60104
+ });
60105
+ break;
60106
+ }
60107
+ }
60108
+ if (seen.has(now.key)) break;
60109
+ seen.add(now.key);
60110
+ prev = now;
60111
+ if (hits.length >= 4) break;
60112
+ }
60113
+ return hits;
60114
+ }
59973
60115
  async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
59974
60116
  const triggers = await page.evaluate(HOVER_SETUP_PROBE);
59975
60117
  const hits = [];
@@ -60002,7 +60144,7 @@ async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
60002
60144
 
60003
60145
  // src/scan-local.ts
60004
60146
  var LOCAL_ENGINE = "axe-core@playwright (local)";
60005
- var LOCAL_TESTED_SCS = ["1.4.4", "1.4.10", "1.4.12", "2.4.7", "1.4.13"];
60147
+ var LOCAL_TESTED_SCS = ["1.4.4", "1.4.10", "1.4.12", "2.4.7", "1.4.13", "2.1.2"];
60006
60148
  function localTestedScs(interact) {
60007
60149
  return interact ? [...LOCAL_TESTED_SCS, "4.1.3"] : [...LOCAL_TESTED_SCS];
60008
60150
  }
@@ -60410,6 +60552,7 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
60410
60552
  }
60411
60553
  };
60412
60554
  const focusVisible = await ran("2.4.7", empty, () => probeFocusVisible(page));
60555
+ const keyboardTrap = await ran("2.1.2", empty, () => probeKeyboardTrap(page));
60413
60556
  const hover = await ran("1.4.13", empty, () => probeHover(page));
60414
60557
  const l = opts.lang;
60415
60558
  if (opts.interact) await page.evaluate(FILL_INPUTS_STEP).catch(() => {
@@ -60440,6 +60583,7 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
60440
60583
  reflow,
60441
60584
  focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
60442
60585
  hover,
60586
+ keyboardTrap,
60443
60587
  reflowZoom,
60444
60588
  textSpacing,
60445
60589
  inputOverflowReflow,
@@ -64355,8 +64499,60 @@ function phaseWorkflowScript(ph, runAbs, engineAbs, batchSize) {
64355
64499
  ``
64356
64500
  ].join("\n");
64357
64501
  }
64358
- function agentContracts(runAbs, engineAbs) {
64502
+ var VERDICT_RULES = `2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold, and its criterion goes back to \xAB to assess \xBB carrying the refusal):
64503
+ - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this criterion's own evidence; an anchor that is not in that list is treated as fabricated). A criterion presented with NO evidence at all cannot be \`C\` \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
64504
+ - \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected, and so is a finding with no \`file\` at all. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the criterion's OWN numbered tests, listed in its brief under \xAB tests to rule on \xBB. A WCAG id looks alike, denotes an unrelated test, and is rejected.
64505
+ - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
64506
+ - \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide it, and no capture in this run carries its subject) or \`undecidable\` (the evidence cannot settle it either way).
64507
+ 3. AN NC SHAPED LIKE AN ABSENCE IS STILL ANCHORED. \xAB No second navigation system \xBB, \xAB no search engine \xBB, \xAB no error message suggests the expected format \xBB \u2014 an absence is OBSERVED somewhere: cite the element and the page you observed it on. And when the criterion's subject exists nowhere in the audited scope, the verdict is \`NA\` with its justification, never \`NC\`.
64508
+ 4. THE RENDERED PAGE MAY BE ON DISK. When a criterion's evidence is anchored under \`.ultra11y/pages/<id>/\`, the browser already ran: \`dom.html\`, \`styles.json\`, \`boxes.json\`, \`axtree.json\` and \`screen.png\` are there to read. \`needs-rendered-dom\` is refused on such a criterion \u2014 decide it from those files, or answer \`undecidable\` and say what the capture does not settle.
64509
+ 5. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with its reason \u2014 that is a valid, honest verdict, and it is worth more than a verdict the gate throws away.`;
64510
+ function ecoAdjudicatorContract(runAbs) {
64511
+ return `# Contract: adjudicator (sequential / --eco)
64512
+
64513
+ You adjudicate the residual judgment criteria of an ultra11y audit \u2014 the ones the deterministic engine could not decide (alt-text relevance, link purpose in context, reading order\u2026). The ACTIVE STANDARD is recorded in the worklist's \`standard\` field: under a country standard (e.g. \`rgaa\`) the items are that standard's OWN criteria, each carrying its numbered tests \u2014 not WCAG success criteria.
64514
+
64515
+ There is no fan-out here and no ITEMS selection: you handle EVERY criterion, one at a time, in order.
64516
+
64517
+ ## Which files \u2014 your prompt decides, and it wins over this document
64518
+
64519
+ - **With a shell.** Read \`${join47(runAbs, "ADJUDICATE.todo.json")}\`, fill each item's verdict in place, then fold: \`ultra11y verify --apply ${join47(runAbs, "ADJUDICATE.todo.json")} --in ${join47(runAbs, "audit-latest.json")} --out ${runAbs}\`.
64520
+ - **Without a shell** (CI: Read, Grep, Glob, Edit, Write only). Do NOT open \`ADJUDICATE.todo.json\` or \`ADJUDICATE.md\` \u2014 they run to hundreds of kilobytes and will swamp your context. Read \`${join47(runAbs, "adjudicate")}/<criteriaId>.md\`, one small brief per criterion carrying its evidence, its decision protocol, its numbered tests and this contract in short form. Write your verdicts into \`${join47(runAbs, "ADJUDICATE.verdicts.json")}\` \u2014 the ONLY file you write. Someone else folds; you never run the engine.
64521
+
64522
+ ## For EACH criterion
64523
+
64524
+ 1. Read its brief. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide. Copy the \`snippet\` from the brief rather than retyping it.
64525
+ ${VERDICT_RULES}
64526
+
64527
+ Every item comes back with a verdict. Each one stands or falls on its own: a refusal costs THAT criterion and leaves every other verdict standing \u2014 so work through the list steadily, and never guess to fill a gap.
64528
+
64529
+ Do not edit any other file, do not touch the audited source, do not commit, and do not comment on any pull request.
64530
+ `;
64531
+ }
64532
+ function ecoRefuterContract(runAbs) {
64533
+ return `# Contract: refuter (sequential / --eco)
64534
+
64535
+ You are an adversarial skeptic verifying the non-conformities of an ultra11y report. Your job is to try to REFUTE each claim: assume it is wrong until the source proves it.
64536
+
64537
+ There is no fan-out here and no ITEMS selection: you handle EVERY entry of \`${join47(runAbs, "VERIFY.todo.json")}\` (a JSON array; each entry has \`n\`, \`criteriaId\`, \`file\`, \`line\`, \`selector\`, \`claim\`), one at a time, writing your verdict into that same file.
64538
+
64539
+ For EACH entry:
64540
+
64541
+ 1. Open \`file\` at \`line\` and read the cited element (\`selector\`) in its real context.
64542
+ 2. Judge the claim against the source:
64543
+ - \`supported\` \u2014 the cited code violates the criterion exactly as claimed.
64544
+ - \`partial\` \u2014 a real issue, but the claim overstates it (wrong element, wrong scope, exaggerated count).
64545
+ - \`unsupported\` \u2014 the source does not establish the claim.
64546
+ - \`refuted\` \u2014 the source contradicts the claim.
64547
+ When unsure, choose the HARSHER verdict \u2014 a false pass is worse than a false fail.
64548
+ 3. \`note\` is REQUIRED \u2014 one line grounded in what you read (quote or paraphrase the decisive code).
64549
+
64550
+ Then fold: \`ultra11y verify --apply ${join47(runAbs, "VERIFY.todo.json")} --report <the report .md>\`. Without a shell, leave the fold to whoever gave you the file.
64551
+ `;
64552
+ }
64553
+ function agentContracts(runAbs, engineAbs, opts = {}) {
64359
64554
  const footer = ONE_WRITER_FOOTER.replaceAll("<RUN>", runAbs);
64555
+ if (opts.eco) return { adjudicator: ecoAdjudicatorContract(runAbs), refuter: ecoRefuterContract(runAbs) };
64360
64556
  return {
64361
64557
  adjudicator: `# Contract: adjudicator
64362
64558
 
@@ -64536,7 +64732,7 @@ function orchestrateRun(runDir, engineAbs, opts = {}) {
64536
64732
  mkdirSync17(agentsDir, { recursive: true });
64537
64733
  const written = [];
64538
64734
  const notices = [];
64539
- for (const [name2, content] of Object.entries(agentContracts(run2, engineAbs))) {
64735
+ for (const [name2, content] of Object.entries(agentContracts(run2, engineAbs, { eco: opts.eco === true }))) {
64540
64736
  const p = join48(agentsDir, `${name2}.md`);
64541
64737
  writeFileSync19(p, content);
64542
64738
  written.push(p);
@@ -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.21.0
8
+ version: 5.22.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code