ultra11y 5.27.0 → 5.29.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +1 -1
- package/dist/playwright.mjs +25 -7
- package/package.json +2 -1
- package/scripts/build-pack-rgaa.mjs +8 -1
- package/scripts/build-standards.mjs +42 -4
- package/scripts/ultra11y.mjs +284 -115
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +284 -115
- package/skills/ultra11y/SKILL.md +3 -3
- package/skills/ultra11y/references/ci.md +1 -1
- package/skills/ultra11y/references/devtools.md +1 -1
- package/skills/ultra11y/references/judgment.md +1 -1
- package/skills/ultra11y/references/mcp.md +2 -2
- package/skills/ultra11y/references/pages.md +8 -1
- package/skills/ultra11y/scripts/ultra11y.mjs +284 -115
- package/src/adjudicate-subjects.ts +35 -1
- package/src/annotate.ts +178 -84
- package/src/audit.ts +15 -0
- package/src/cli.ts +11 -4
- package/src/crawl.ts +34 -1
- package/src/data/standards/rgaa.json +1 -1
- package/src/html-report.ts +12 -2
- package/src/pages.ts +83 -4
- package/src/probes.ts +84 -8
- package/src/report.ts +17 -2
- package/src/scan-local.ts +56 -14
- package/src/scan.ts +18 -2
- package/src/standards/derive.ts +19 -0
- package/src/types.ts +11 -3
|
@@ -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.
|
|
13
|
+
"version": "5.29.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.
|
|
4
|
+
"version": "5.29.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.
|
|
4
|
+
"version": "5.29.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.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "5.29.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
|
package/dist/playwright.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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 <
|
|
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 +
|
|
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 (
|
|
346
|
-
|
|
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.
|
|
3
|
+
"version": "5.29.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",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"verify:bundle": "node scripts/verify-skill-bundle.mjs",
|
|
58
58
|
"check:build": "tsup && node scripts/copy-bundle.mjs && git diff --exit-code -- scripts/ultra11y.mjs skills/ultra11y/scripts/ultra11y.mjs skills/review-a11y/scripts/ultra11y.mjs dist && node scripts/build-extension-icons.mjs --check && node scripts/verify-skill-bundle.mjs && node scripts/sync-engine.mjs --check",
|
|
59
59
|
"build:wcag": "node scripts/build-standards.mjs",
|
|
60
|
+
"check:standards": "node scripts/build-standards.mjs --check",
|
|
60
61
|
"build:pack:rgaa": "node scripts/build-pack-rgaa.mjs",
|
|
61
62
|
"check:pack:rgaa": "node scripts/build-pack-rgaa.mjs --check",
|
|
62
63
|
"build:guidance:wcag": "node scripts/build-guidance-wcag.mjs",
|
|
@@ -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"],
|
|
@@ -42,6 +42,29 @@ function biomeFormat(text, relPath) {
|
|
|
42
42
|
return execFileSync(BIOME, ["format", `--stdin-file-path=${relPath}`], { input: text, encoding: "utf8" });
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// CHECK MODE — the gate this generator was the only one of the three to lack.
|
|
46
|
+
//
|
|
47
|
+
// scripts/build-pack-rgaa.mjs and scripts/build-guidance-wcag.mjs both compare their
|
|
48
|
+
// generated text to the committed file and exit 1 on drift; this one only ever wrote. So a
|
|
49
|
+
// hand edit to src/data/wcag.json passed every gate and was found by the NIGHTLY refresh
|
|
50
|
+
// instead — on main, a day later, in a workflow nobody watches. Measured: SC 1.3.1 carried
|
|
51
|
+
// three presentational-* rules that RULE_SC_COVERAGE below never cited, and
|
|
52
|
+
// standards-refresh went red every night for two days before anyone read the log.
|
|
53
|
+
//
|
|
54
|
+
// Every write goes through `emit` so the check compares EXACTLY what a build would write —
|
|
55
|
+
// a check that recomputed the text a second way could agree with itself and still be wrong.
|
|
56
|
+
const CHECK = process.argv.includes("--check");
|
|
57
|
+
const drift = [];
|
|
58
|
+
function emit(relPath, text) {
|
|
59
|
+
const abs = join(root, relPath);
|
|
60
|
+
if (CHECK) {
|
|
61
|
+
if (!existsSync(abs) || readFileSync(abs, "utf8") !== text) drift.push(relPath);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
65
|
+
writeFileSync(abs, text);
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
// ---------------------------------------------------------------------------
|
|
46
69
|
// Refresh mode: parse a w3c/wcag checkout into the vendored SC snapshot.
|
|
47
70
|
// SC numbers are positional within guidelines/index.html (principle → guideline →
|
|
@@ -623,6 +646,7 @@ const RULE_SC_COVERAGE = {
|
|
|
623
646
|
"sortable-header-no-aria-sort", "nav-landmark-missing", "nav-landmark-unnamed", "aria-required-parent",
|
|
624
647
|
"headers-attr-dangling", "th-no-data-cells",
|
|
625
648
|
"radio-checkbox-group-ungrouped", "table-empty-data-cell", "css-generated-content-informative",
|
|
649
|
+
"presentational-element", "presentational-attribute", "presentational-spacing",
|
|
626
650
|
],
|
|
627
651
|
"1.3.5": ["field-purpose-incomplete", "autocomplete-token-invalid"],
|
|
628
652
|
"1.4.2": ["autoplay-media"],
|
|
@@ -708,8 +732,7 @@ function buildUniverse(shipped) {
|
|
|
708
732
|
provenance: univ.provenance,
|
|
709
733
|
criteria,
|
|
710
734
|
};
|
|
711
|
-
|
|
712
|
-
writeFileSync(join(DATA, "wcag-universe.json"), biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag-universe.json"));
|
|
735
|
+
emit("src/data/wcag-universe.json", biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag-universe.json"));
|
|
713
736
|
|
|
714
737
|
const coreIds = new Set(criteria.filter((c) => c.status === "core-AA").map((c) => c.id));
|
|
715
738
|
const shippedIds = new Set(shipped.map((c) => c.sc));
|
|
@@ -724,6 +747,7 @@ function buildUniverse(shipped) {
|
|
|
724
747
|
}
|
|
725
748
|
const tally = { "core-AA": 0, "out-of-core": 0, removed: 0 };
|
|
726
749
|
for (const c of criteria) tally[c.status]++;
|
|
750
|
+
if (CHECK) return;
|
|
727
751
|
console.log(
|
|
728
752
|
`build-standards: ${criteria.length} WCAG 2.x SCs classified — core-AA ${tally["core-AA"]}, out-of-core ${tally["out-of-core"]}, removed ${tally.removed} → src/data/wcag-universe.json`,
|
|
729
753
|
);
|
|
@@ -839,8 +863,7 @@ function build() {
|
|
|
839
863
|
// resolve in either language off one list.
|
|
840
864
|
glossaryFr: frGlossary,
|
|
841
865
|
};
|
|
842
|
-
|
|
843
|
-
writeFileSync(join(DATA, "wcag.json"), biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag.json"));
|
|
866
|
+
emit("src/data/wcag.json", biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag.json"));
|
|
844
867
|
|
|
845
868
|
// --- guards
|
|
846
869
|
const all = new Set(criteria.map((c) => c.sc));
|
|
@@ -863,6 +886,7 @@ function build() {
|
|
|
863
886
|
|
|
864
887
|
const tally = { static: 0, "needs-rendering": 0, judgment: 0 };
|
|
865
888
|
for (const c of criteria) tally[c.automatability]++;
|
|
889
|
+
if (CHECK) return void buildUniverse(criteria);
|
|
866
890
|
console.log(`build-standards: ${criteria.length} WCAG 2.2 A/AA criteria → src/data/wcag.json`);
|
|
867
891
|
console.log(`build-standards: automatability — static ${tally.static}, needs-rendering ${tally["needs-rendering"]}, judgment ${tally.judgment}`);
|
|
868
892
|
console.log(`build-standards: seeded techniques from ${sources.length ? sources.map((s) => s.replace(root + "/", "")).join(", ") : "(no pack found — empty)"}`);
|
|
@@ -872,12 +896,26 @@ function build() {
|
|
|
872
896
|
|
|
873
897
|
async function main() {
|
|
874
898
|
const refreshIdx = process.argv.indexOf("--refresh");
|
|
899
|
+
// A check that first goes and fetches is not a check: it would compare the committed
|
|
900
|
+
// dataset against a source that moved under it, and call an upstream change a local drift.
|
|
901
|
+
// `--check` reads the vendored snapshots and nothing else.
|
|
902
|
+
const refreshing = refreshIdx !== -1 || process.argv.some((a) => a.startsWith("--refresh-"));
|
|
903
|
+
if (CHECK && refreshing) {
|
|
904
|
+
console.error("build-standards --check: --check is offline by construction; it cannot be combined with a --refresh flag.");
|
|
905
|
+
process.exit(1);
|
|
906
|
+
}
|
|
875
907
|
if (refreshIdx !== -1) deriveSnapshot(process.argv[refreshIdx + 1]);
|
|
876
908
|
if (process.argv.includes("--refresh-universe")) await deriveUniverse();
|
|
877
909
|
if (process.argv.includes("--refresh-core")) refreshCore();
|
|
878
910
|
if (process.argv.includes("--refresh-fr")) await deriveFr();
|
|
879
911
|
if (process.argv.includes("--refresh-text")) await deriveText();
|
|
880
912
|
build();
|
|
913
|
+
if (!CHECK) return;
|
|
914
|
+
if (drift.length) {
|
|
915
|
+
console.error(`build-standards --check: OUT OF DATE vs the vendored source — re-run \`pnpm run build:wcag\`: ${drift.join(", ")}`);
|
|
916
|
+
process.exit(1);
|
|
917
|
+
}
|
|
918
|
+
console.log("build-standards --check: src/data/wcag.json and src/data/wcag-universe.json match the vendored source.");
|
|
881
919
|
}
|
|
882
920
|
|
|
883
921
|
main().catch((e) => {
|