ultra11y 5.21.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.21.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.21.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.21.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.21.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.22.1";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
@@ -306,6 +306,48 @@ 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), tagged: !!key, 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?.tagged && now.tagged && 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.tagged === true && 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
+ }
349
+ return hits;
350
+ }
309
351
  async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
310
352
  const triggers = await page.evaluate(HOVER_SETUP_PROBE);
311
353
  const hits = [];
@@ -346,7 +388,16 @@ async function runLiveProbes(page, opts = {}) {
346
388
  const canStyle = typeof page.addStyleTag === "function";
347
389
  const size = canResize ? page.viewportSize() ?? null : null;
348
390
  const restore = size ?? { width: 1280, height: 900 };
349
- const out = { focusVisible: [], hover: [], reflowZoom: [], textSpacing: [], reflow: { horizontalScroll: false }, probed: [], skipped: [] };
391
+ const out = {
392
+ focusVisible: [],
393
+ hover: [],
394
+ keyboardTrap: [],
395
+ reflowZoom: [],
396
+ textSpacing: [],
397
+ reflow: { horizontalScroll: false },
398
+ probed: [],
399
+ skipped: []
400
+ };
350
401
  const skip = (sc, why) => {
351
402
  out.skipped?.push({ sc, why });
352
403
  };
@@ -380,7 +431,7 @@ async function runLiveProbes(page, opts = {}) {
380
431
  }
381
432
  };
382
433
  if (!canResize) skip("1.4.10", "the page object cannot resize its viewport");
383
- if (!canType) skip("2.4.7", "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");
384
435
  if (!canHover) skip("1.4.13", "the page object cannot hover");
385
436
  if (!canStyle) skip("1.4.12", "the page object cannot inject a stylesheet");
386
437
  if (want("1.4.4")) {
@@ -424,6 +475,13 @@ async function runLiveProbes(page, opts = {}) {
424
475
  out.probed.push("2.4.7");
425
476
  }
426
477
  }
478
+ if (want("2.1.2") && canType) {
479
+ const r = await bounded("2.1.2", () => probeKeyboardTrap(page, limits, deadline));
480
+ if (r) {
481
+ out.keyboardTrap = r;
482
+ out.probed.push("2.1.2");
483
+ }
484
+ }
427
485
  if (want("1.4.13") && canHover && canType) {
428
486
  const r = await bounded("1.4.13", () => probeHover(page, limits, deadline));
429
487
  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.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.21.0";
9
+ var VERSION = "5.22.1";
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,92 @@ 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
+ 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
+ }
39971
+ return out2;
39972
+ }),
39879
39973
  // Content pinned over the page — what can obscure a focused element.
39880
39974
  stickies: (docs) => docs.flatMap((d) => [
39881
39975
  ...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 +40003,9 @@ var SC_SUBJECTS = {
39909
40003
  "1.4.10": ["readingOrder"],
39910
40004
  "1.4.11": ["colourPairs"],
39911
40005
  "1.4.12": ["readingOrder"],
39912
- "1.4.13": ["aria", "stickies"],
40006
+ // Content on Hover or Focus. Its subject IS the additional content — `aria` and `stickies`
40007
+ // were standing in for a harvester that did not exist yet.
40008
+ "1.4.13": ["additionalContent", "aria", "stickies"],
39913
40009
  "2.1.1": ["pointerHandlers", "focusables"],
39914
40010
  "2.1.2": ["focusables", "pointerHandlers"],
39915
40011
  "2.1.4": ["shortcuts"],
@@ -40002,7 +40098,7 @@ var PACK_SUBJECTS = {
40002
40098
  "12.8": ["focusOrder"],
40003
40099
  "12.9": ["focusables", "pointerHandlers"],
40004
40100
  "12.10": ["shortcuts"],
40005
- "12.11": ["pointerHandlers", "focusables"],
40101
+ "12.11": ["additionalContent", "pointerHandlers", "focusables"],
40006
40102
  // Theme 4 — multimedia. 4.10 (is automatically-triggered sound controllable?) maps onto
40007
40103
  // WCAG 1.4.2, which is `static` and therefore has no subject of its own — but the pack
40008
40104
  // flags 4.10 `judgment`, so judgmentGuard reopens it and it would arrive with nothing.
@@ -40029,7 +40125,7 @@ var PACK_SUBJECTS = {
40029
40125
  "10.11": ["readingOrder"],
40030
40126
  "10.12": ["readingOrder"],
40031
40127
  "10.13": ["aria", "stickies"],
40032
- "10.14": ["focusables", "pointerHandlers"],
40128
+ "10.14": ["additionalContent", "focusables", "pointerHandlers"],
40033
40129
  // Theme 13 — consultation.
40034
40130
  "13.1": ["timers"],
40035
40131
  "13.3": ["downloadDocs"],
@@ -48486,7 +48582,7 @@ var RESIDUAL_TRAIL = {
48486
48582
  // Rendering criteria no automated tier measures — say so, rather than pointing at `scan` and
48487
48583
  // letting the reader discover it changes nothing.
48488
48584
  "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.",
48585
+ "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
48586
  "2.3.1": "No automated tier decides this: flashing has to be observed over time on the rendered page.",
48491
48587
  "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
48588
  };
@@ -55074,6 +55170,7 @@ var NEEDS_RENDERING = [
55074
55170
  { sc: "1.4.11", label: { fr: "contraste des composants", en: "non-text contrast" } },
55075
55171
  { sc: "1.4.12", label: { fr: "espacement du texte", en: "text spacing" } },
55076
55172
  { sc: "1.4.13", label: { fr: "contenu au survol", en: "content on hover" } },
55173
+ { sc: "2.1.2", label: { fr: "pi\xE8ge au clavier", en: "keyboard trap" } },
55077
55174
  { sc: "2.4.7", label: { fr: "visibilit\xE9 du focus", en: "focus visibility" } },
55078
55175
  { sc: "4.1.3", label: { fr: "r\xE9gions live", en: "live regions" } }
55079
55176
  ];
@@ -57939,8 +58036,8 @@ function adjudicationContract() {
57939
58036
  manualReasons: [...MANUAL_REASON_VALUES],
57940
58037
  requires: {
57941
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",
57942
- NA: "a non-empty justification",
57943
- 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",
57944
58041
  manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
57945
58042
  }
57946
58043
  };
@@ -58285,12 +58382,16 @@ function applyAdjudication(audit2, adj, opts = {}) {
58285
58382
  }
58286
58383
  }
58287
58384
  } 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`);
58385
+ if (!it.findings || it.findings.length === 0)
58386
+ blame(
58387
+ it.criteriaId,
58388
+ `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`
58389
+ );
58289
58390
  for (const f of it.findings ?? []) {
58290
58391
  if (typeof f.file !== "string" || !f.file.trim()) {
58291
58392
  blame(
58292
58393
  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`
58394
+ `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
58395
  );
58295
58396
  continue;
58296
58397
  }
@@ -58317,7 +58418,10 @@ function applyAdjudication(audit2, adj, opts = {}) {
58317
58418
  }
58318
58419
  for (const rec of it.recommendations ?? []) {
58319
58420
  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`);
58421
+ blame(
58422
+ it.criteriaId,
58423
+ `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`
58424
+ );
58321
58425
  continue;
58322
58426
  }
58323
58427
  toGround(it.criteriaId, { file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
@@ -58508,6 +58612,8 @@ var T2 = {
58508
58612
  snippetLabel: "`snippet` \xE0 copier dans la citation",
58509
58613
  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
58614
  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 ")}`,
58615
+ 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 :",
58616
+ 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
58617
  incomplete: "LECTURE INCOMPL\xC8TE \u2014 un \xAB C \xBB sera refus\xE9 sur ce crit\xE8re",
58512
58618
  none: "(aucune \xE9vidence automatique \u2014 d\xE9cidez depuis la source, ou laissez `manual` avec une raison)",
58513
58619
  questions: "\xC0 v\xE9rifier manuellement",
@@ -58540,6 +58646,8 @@ var T2 = {
58540
58646
  snippetLabel: "`snippet` to copy into the citation",
58541
58647
  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
58648
  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 ")}`,
58649
+ 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:",
58650
+ 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
58651
  incomplete: "INCOMPLETE READING \u2014 a C will be refused on this criterion",
58544
58652
  none: "(no automatic evidence \u2014 decide from source, or leave `manual` with a reason)",
58545
58653
  questions: "To verify manually",
@@ -58584,14 +58692,14 @@ function formatAdjudication(items, lang = "en", standard = CORE2, opts = {}) {
58584
58692
  const s = T2[lang];
58585
58693
  const { showAlsoAt: shown } = adjudicationLimits(opts.cwd);
58586
58694
  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)))) {
58695
+ 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, ""];
58696
+ if (items.some((it) => it.evidence.some((e) => isSnapshotFile(e.file)))) {
58589
58697
  out2.push(`> ${s.renderedAvailable}`, "");
58590
58698
  }
58591
- if (opts.preamble !== false && opts.unrendered?.length) {
58699
+ if (opts.unrendered?.length) {
58592
58700
  out2.push(`> ${s.nothingRendered(opts.unrendered)}`, "");
58593
58701
  }
58594
- if (pack && opts.preamble !== false) out2.push(`> ${s.packIntro(pack.name)}`, "");
58702
+ if (pack) out2.push(`> ${s.packIntro(pack.name)}`, "");
58595
58703
  for (const it of items) {
58596
58704
  out2.push(`## ${pack ? `${pack.name} ` : ""}${it.criteriaId}${it.title ? ` \u2014 ${it.title}` : ""} _(${it.automatability})_`);
58597
58705
  const pop = it.population;
@@ -58669,8 +58777,13 @@ function writeAdjudication(items, outDir, opts) {
58669
58777
  writeFileSync10(verdictsPath, JSON.stringify({ ...file, items: slimAdjudicationItems(items) }, null, 2) + "\n");
58670
58778
  const itemsDir = join35(outDir, "adjudicate");
58671
58779
  mkdirSync9(itemsDir, { recursive: true });
58780
+ const unrendered = new Set(opts.unrendered ?? []);
58672
58781
  for (const it of items) {
58673
- writeFileSync10(join35(itemsDir, `${it.criteriaId}.md`), formatAdjudication([it], opts.lang ?? "en", opts.standard, { preamble: false }));
58782
+ const mine = unrendered.has(it.criteriaId) ? [it.criteriaId] : [];
58783
+ writeFileSync10(
58784
+ join35(itemsDir, `${it.criteriaId}.md`),
58785
+ formatAdjudication([it], opts.lang ?? "en", opts.standard, { preamble: false, ...mine.length ? { unrendered: mine } : {} })
58786
+ );
58674
58787
  }
58675
58788
  return { todoPath, mdPath, verdictsPath, itemsDir, count: items.length };
58676
58789
  }
@@ -59442,6 +59555,7 @@ var PROBE_FIELDS = [
59442
59555
  { key: "reflowZoom", engine: "reflow-zoom" },
59443
59556
  { key: "textSpacing", engine: "text-spacing" },
59444
59557
  { key: "hover", engine: "hover" },
59558
+ { key: "keyboardTrap", engine: "keyboard-trap" },
59445
59559
  { key: "inputOverflowReflow", engine: "input-overflow-reflow" },
59446
59560
  { key: "inputOverflowZoom", engine: "input-overflow-zoom" },
59447
59561
  { key: "inputOverflowSpacing", engine: "input-overflow-spacing" },
@@ -59970,6 +60084,48 @@ async function probeFocusVisible(page, scope = "", limits = PROBE_DEFAULTS, dead
59970
60084
  }
59971
60085
  return hits;
59972
60086
  }
60087
+ var FOCUS_WHERE_PROBE = `(() => { ${PRELUDE}
60088
+ const e = document.activeElement;
60089
+ if (!e || e === document.body || e === document.documentElement) return null;
60090
+ const key = e.getAttribute && e.getAttribute('data-u11y-f');
60091
+ return { key: key || __sel(e), tagged: !!key, selector: __sel(e), html: __html(e) };
60092
+ })()`;
60093
+ async function probeKeyboardTrap(page, limits = PROBE_DEFAULTS, deadline) {
60094
+ const count = await page.evaluate(focusSetupExpr("", limits.maxFocusables));
60095
+ if (!count || count < 2) return [];
60096
+ const hits = [];
60097
+ const seen = /* @__PURE__ */ new Set();
60098
+ const confirmPresses = 2;
60099
+ const limit = Math.min(count + 2, limits.maxFocusables + 10);
60100
+ let prev = null;
60101
+ for (let i2 = 0; i2 < limit; i2++) {
60102
+ if (deadline?.out()) break;
60103
+ await page.keyboard.press("Tab");
60104
+ const now = await page.evaluate(FOCUS_WHERE_PROBE);
60105
+ if (!now) break;
60106
+ if (prev?.tagged && now.tagged && now.key === prev.key) {
60107
+ let stuck = true;
60108
+ for (let k = 0; k < confirmPresses && stuck; k++) {
60109
+ if (deadline?.out()) break;
60110
+ await page.keyboard.press("Tab");
60111
+ const again = await page.evaluate(FOCUS_WHERE_PROBE);
60112
+ stuck = again !== null && again.tagged === true && again.key === now.key;
60113
+ }
60114
+ if (stuck) {
60115
+ hits.push({
60116
+ selector: now.selector,
60117
+ html: now.html,
60118
+ 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).`
60119
+ });
60120
+ break;
60121
+ }
60122
+ }
60123
+ if (seen.has(now.key)) break;
60124
+ seen.add(now.key);
60125
+ prev = now;
60126
+ }
60127
+ return hits;
60128
+ }
59973
60129
  async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
59974
60130
  const triggers = await page.evaluate(HOVER_SETUP_PROBE);
59975
60131
  const hits = [];
@@ -60002,7 +60158,7 @@ async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
60002
60158
 
60003
60159
  // src/scan-local.ts
60004
60160
  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"];
60161
+ var LOCAL_TESTED_SCS = ["1.4.4", "1.4.10", "1.4.12", "2.4.7", "1.4.13", "2.1.2"];
60006
60162
  function localTestedScs(interact) {
60007
60163
  return interact ? [...LOCAL_TESTED_SCS, "4.1.3"] : [...LOCAL_TESTED_SCS];
60008
60164
  }
@@ -60410,6 +60566,7 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
60410
60566
  }
60411
60567
  };
60412
60568
  const focusVisible = await ran("2.4.7", empty, () => probeFocusVisible(page));
60569
+ const keyboardTrap = await ran("2.1.2", empty, () => probeKeyboardTrap(page));
60413
60570
  const hover = await ran("1.4.13", empty, () => probeHover(page));
60414
60571
  const l = opts.lang;
60415
60572
  if (opts.interact) await page.evaluate(FILL_INPUTS_STEP).catch(() => {
@@ -60440,6 +60597,7 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
60440
60597
  reflow,
60441
60598
  focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
60442
60599
  hover,
60600
+ keyboardTrap,
60443
60601
  reflowZoom,
60444
60602
  textSpacing,
60445
60603
  inputOverflowReflow,
@@ -64355,8 +64513,60 @@ function phaseWorkflowScript(ph, runAbs, engineAbs, batchSize) {
64355
64513
  ``
64356
64514
  ].join("\n");
64357
64515
  }
64358
- function agentContracts(runAbs, engineAbs) {
64516
+ 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):
64517
+ - \`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.
64518
+ - \`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.
64519
+ - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
64520
+ - \`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).
64521
+ 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\`.
64522
+ 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.
64523
+ 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.`;
64524
+ function ecoAdjudicatorContract(runAbs) {
64525
+ return `# Contract: adjudicator (sequential / --eco)
64526
+
64527
+ 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.
64528
+
64529
+ There is no fan-out here and no ITEMS selection: you handle EVERY criterion, one at a time, in order.
64530
+
64531
+ ## Which files \u2014 your prompt decides, and it wins over this document
64532
+
64533
+ - **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}\`.
64534
+ - **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.
64535
+
64536
+ ## For EACH criterion
64537
+
64538
+ 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.
64539
+ ${VERDICT_RULES}
64540
+
64541
+ 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.
64542
+
64543
+ Do not edit any other file, do not touch the audited source, do not commit, and do not comment on any pull request.
64544
+ `;
64545
+ }
64546
+ function ecoRefuterContract(runAbs) {
64547
+ return `# Contract: refuter (sequential / --eco)
64548
+
64549
+ 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.
64550
+
64551
+ 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.
64552
+
64553
+ For EACH entry:
64554
+
64555
+ 1. Open \`file\` at \`line\` and read the cited element (\`selector\`) in its real context.
64556
+ 2. Judge the claim against the source:
64557
+ - \`supported\` \u2014 the cited code violates the criterion exactly as claimed.
64558
+ - \`partial\` \u2014 a real issue, but the claim overstates it (wrong element, wrong scope, exaggerated count).
64559
+ - \`unsupported\` \u2014 the source does not establish the claim.
64560
+ - \`refuted\` \u2014 the source contradicts the claim.
64561
+ When unsure, choose the HARSHER verdict \u2014 a false pass is worse than a false fail.
64562
+ 3. \`note\` is REQUIRED \u2014 one line grounded in what you read (quote or paraphrase the decisive code).
64563
+
64564
+ 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.
64565
+ `;
64566
+ }
64567
+ function agentContracts(runAbs, engineAbs, opts = {}) {
64359
64568
  const footer = ONE_WRITER_FOOTER.replaceAll("<RUN>", runAbs);
64569
+ if (opts.eco) return { adjudicator: ecoAdjudicatorContract(runAbs), refuter: ecoRefuterContract(runAbs) };
64360
64570
  return {
64361
64571
  adjudicator: `# Contract: adjudicator
64362
64572
 
@@ -64536,7 +64746,7 @@ function orchestrateRun(runDir, engineAbs, opts = {}) {
64536
64746
  mkdirSync17(agentsDir, { recursive: true });
64537
64747
  const written = [];
64538
64748
  const notices = [];
64539
- for (const [name2, content] of Object.entries(agentContracts(run2, engineAbs))) {
64749
+ for (const [name2, content] of Object.entries(agentContracts(run2, engineAbs, { eco: opts.eco === true }))) {
64540
64750
  const p = join48(agentsDir, `${name2}.md`);
64541
64751
  writeFileSync19(p, content);
64542
64752
  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.1
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code