ultra11y 5.15.1 → 5.17.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.15.1",
13
+ "version": "5.17.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.15.1",
4
+ "version": "5.17.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.15.1",
4
+ "version": "5.17.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.15.1";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.17.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -442,7 +442,7 @@ node scripts/ultra11y.mjs scan http://localhost:3000 --runtime local --cwd . --m
442
442
  node scripts/ultra11y.mjs report --in audits/audit-latest.json --out audits
443
443
  ```
444
444
 
445
- Only the Docker runtime needs Docker; `--runtime local` needs a project with `@playwright/test` + `@axe-core/playwright`. The rest of the skill is zero-dependency. The Docker runner + Dockerfile are embedded in the engine and mirrored under `docker/` (with a `docker-compose.yml`). See `skills/ultra11y/references/dynamic.md`.
445
+ Only the Docker runtime needs Docker. `--runtime local` needs a Chromium binary (`npx playwright install chromium`) plus `@playwright/test` and `@axe-core/playwright` — which **come with the npm package**, since they are its own dependencies; the audited project's own copies take precedence when it has them. Running the engine as a **standalone skill bundle** is the exception: an installed skill has no `node_modules` beside it, so there the two packages must be in the audited project, reachable from `--cwd`. The engine bundle itself stays dependency-free either way. The Docker runner + Dockerfile are embedded in the engine and mirrored under `docker/` (with a `docker-compose.yml`). See `skills/ultra11y/references/dynamic.md`.
446
446
 
447
447
  Typical audit flow:
448
448
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.15.1",
3
+ "version": "5.17.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",
@@ -77,14 +77,14 @@
77
77
  "url": "https://github.com/maxgfr/ultra11y"
78
78
  },
79
79
  "dependencies": {
80
+ "@axe-core/playwright": "^4.13.0",
80
81
  "@babel/parser": "^8.0.0",
82
+ "@playwright/test": "^1.62.1",
81
83
  "domhandler": "^6.0.1",
82
84
  "htmlparser2": "^12.0.0"
83
85
  },
84
86
  "devDependencies": {
85
- "@axe-core/playwright": "^4.13.0",
86
87
  "@biomejs/biome": "^2.5.1",
87
- "@playwright/test": "^1.62.1",
88
88
  "@semantic-release/changelog": "^6.0.3",
89
89
  "@semantic-release/exec": "^7.1.0",
90
90
  "@semantic-release/git": "^10.0.1",
@@ -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.15.1";
9
+ var VERSION = "5.17.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -2290,7 +2290,7 @@ var wcag_universe_default = {
2290
2290
  wcagVersion: "2.2",
2291
2291
  source: "https://www.w3.org/TR/WCAG22/",
2292
2292
  criteriaSource: "https://github.com/w3c/wcag",
2293
- provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-19 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2293
+ provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-20 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2294
2294
  criteria: [
2295
2295
  {
2296
2296
  id: "1.1.1",
@@ -54623,6 +54623,17 @@ function renderRedirected(redirected, lang = "en") {
54623
54623
  }
54624
54624
  return out2;
54625
54625
  }
54626
+ function gridRate(rows, status, pageId) {
54627
+ let c2 = 0;
54628
+ let nc = 0;
54629
+ for (const row of rows) {
54630
+ const st = status.get(row.id)?.get(pageId);
54631
+ if (st === "C") c2++;
54632
+ else if (st === "NC") nc++;
54633
+ }
54634
+ const decided = c2 + nc;
54635
+ return [decided === 0 ? null : Math.round(c2 / decided * 100), decided, rows.length];
54636
+ }
54626
54637
  function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
54627
54638
  const s = L3[lang];
54628
54639
  const out2 = [];
@@ -54636,9 +54647,9 @@ function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
54636
54647
  if (derived.some((p) => p.basis === "attributed")) out2.push(`> \u26A0\uFE0F ${s.basisNote}`, "");
54637
54648
  const head = [isCore(standard) ? s.criterion : s.criterion, ...derived.map((p) => `${p.name}${p.auth ? " \u{1F512}" : ""}`)];
54638
54649
  out2.push(`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`);
54639
- out2.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(p.conformancePct, p.decided, p.total)}**`).join(" | ")} |`);
54640
- out2.push(`| _${s.snapshot}?_ | ${derived.map((p) => `_${basisLabel(p.basis, lang)}_`).join(" | ")} |`);
54641
54650
  const { rows, status } = pageGridModel(result, derived, standard, lang);
54651
+ out2.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(...gridRate(rows, status, p.id))}**`).join(" | ")} |`);
54652
+ out2.push(`| _${s.snapshot}?_ | ${derived.map((p) => `_${basisLabel(p.basis, lang)}_`).join(" | ")} |`);
54642
54653
  let group = "";
54643
54654
  for (const row of rows) {
54644
54655
  if (row.group !== group) {
@@ -59669,11 +59680,13 @@ function mergeSnapshotAudit(base, snap) {
59669
59680
  if (snap.packFindings?.length) merged.packFindings = [...merged.packFindings ?? [], ...snap.packFindings];
59670
59681
  for (const c2 of merged.criteria) {
59671
59682
  if (!c2.inapplicable) continue;
59672
- if (snapById.get(c2.id)?.status === "C") {
59673
- c2.status = "C";
59674
- delete c2.inapplicable;
59675
- delete c2.justification;
59676
- }
59683
+ const measured = snapById.get(c2.id);
59684
+ if (!measured || measured.status !== "C" || measured.inapplicable) continue;
59685
+ c2.status = "C";
59686
+ delete c2.inapplicable;
59687
+ if (measured.justification) c2.justification = measured.justification;
59688
+ else delete c2.justification;
59689
+ if (measured.decidedBy) c2.decidedBy = measured.decidedBy;
59677
59690
  }
59678
59691
  const nowNc = new Set(snap.findings.filter((f) => !f.advisory).map((f) => f.criteriaId));
59679
59692
  merged.residualRisks = merged.residualRisks.filter((r) => !nowNc.has(r.criteriaId));
@@ -59894,17 +59907,38 @@ function localTestedScs(interact) {
59894
59907
  }
59895
59908
  var PW_SPEC = "@playwright/test";
59896
59909
  var AXE_SPEC = "@axe-core/playwright";
59897
- function localTierStatus(cwd) {
59898
- const req = createRequire(resolve12(cwd, "package.json"));
59899
- for (const spec of [PW_SPEC, AXE_SPEC]) {
59910
+ function resolveAnchors(cwd) {
59911
+ return [resolve12(cwd, "package.json"), import.meta.url];
59912
+ }
59913
+ function requireRuntime(cwd, spec) {
59914
+ let last;
59915
+ for (const from of resolveAnchors(cwd)) {
59900
59916
  try {
59901
- req.resolve(spec);
59917
+ return createRequire(from)(spec);
59918
+ } catch (e) {
59919
+ last = e;
59920
+ }
59921
+ }
59922
+ throw last instanceof Error ? last : new Error(String(last));
59923
+ }
59924
+ function resolvesRuntime(cwd, spec) {
59925
+ return resolveAnchors(cwd).some((from) => {
59926
+ try {
59927
+ createRequire(from).resolve(spec);
59928
+ return true;
59902
59929
  } catch {
59903
- return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59930
+ return false;
59931
+ }
59932
+ });
59933
+ }
59934
+ function localTierStatus(cwd) {
59935
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59936
+ if (!resolvesRuntime(cwd, spec)) {
59937
+ return { ok: false, reason: `${spec} resolves neither from ${cwd} nor from ultra11y itself` };
59904
59938
  }
59905
59939
  }
59906
59940
  try {
59907
- const pw = req(PW_SPEC);
59941
+ const pw = requireRuntime(cwd, PW_SPEC);
59908
59942
  const bin = pw.chromium?.executablePath?.();
59909
59943
  if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59910
59944
  return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
@@ -59921,15 +59955,14 @@ function resolveLocalDeps(cwd) {
59921
59955
  let chromium;
59922
59956
  let AxeBuilder;
59923
59957
  try {
59924
- const req = createRequire(resolve12(cwd, "package.json"));
59925
- const pw = req(PW_SPEC);
59926
- const axeMod = req(AXE_SPEC);
59958
+ const pw = requireRuntime(cwd, PW_SPEC);
59959
+ const axeMod = requireRuntime(cwd, AXE_SPEC);
59927
59960
  chromium = pw.chromium;
59928
59961
  AxeBuilder = axeMod.default ?? axeMod;
59929
59962
  } catch (e) {
59930
59963
  const msg = e instanceof Error ? e.message : String(e);
59931
59964
  throw new Error(
59932
- `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59965
+ `Playwright resolves neither from "${cwd}" nor from ultra11y itself. Both are dependencies of this package, so the usual cause is the standalone engine bundle running with no node_modules beside it: pass --cwd <dir> at a project that has @playwright/test + @axe-core/playwright (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59933
59966
  );
59934
59967
  }
59935
59968
  if (!chromium || typeof AxeBuilder !== "function") {
@@ -60996,7 +61029,7 @@ jobs:
60996
61029
  ultra11y:
60997
61030
  runs-on: ubuntu-latest
60998
61031
  steps:
60999
- - uses: actions/checkout@v4
61032
+ - uses: actions/checkout@v7
61000
61033
  with:
61001
61034
  fetch-depth: 0 # the diff gate needs the base ref
61002
61035
  - uses: maxgfr/ultra11y@v${VERSION}
@@ -62955,12 +62988,16 @@ function scoreboardBlocks(result, standard, lang, sheetHref) {
62955
62988
  const scope = pagesOf(result);
62956
62989
  if (!scope.length) return [];
62957
62990
  const derived = derivePages(result, scope);
62958
- const rows = derived.map((p) => [
62959
- { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62960
- { text: p.url, mono: true },
62961
- { text: basisLabel(p.basis, lang) },
62962
- { text: formatRate(p.conformancePct, p.decided, p.total), align: "end" }
62963
- ]);
62991
+ const rows = derived.map((p) => {
62992
+ const criteria = pageCriterionRows(result, p, standard, lang);
62993
+ const cov = pageCoverage(criteria);
62994
+ return [
62995
+ { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62996
+ { text: p.url, mono: true },
62997
+ { text: basisLabel(p.basis, lang) },
62998
+ { text: formatRate(pageRatePct(criteria), cov.decided, cov.total), align: "end" }
62999
+ ];
63000
+ });
62964
63001
  const out2 = [
62965
63002
  { kind: "heading", level: 2, text: t3.perPage, id: "pages" },
62966
63003
  {
@@ -67042,6 +67079,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
67042
67079
  ]);
67043
67080
  var KNOWN_FLAGS = /* @__PURE__ */ new Set([...VALUE_FLAGS2, ...BOOLEAN_FLAGS]);
67044
67081
  var LIST_FLAGS = /* @__PURE__ */ new Set(["include", "exclude", "ext"]);
67082
+ function looksLikeFlag(token) {
67083
+ return token?.startsWith("--") ?? false;
67084
+ }
67045
67085
  function parseArgs(argv) {
67046
67086
  const [command, ...rest] = argv;
67047
67087
  const valueFlags = valueFlagsFor(command ?? "");
@@ -67056,7 +67096,7 @@ function parseArgs(argv) {
67056
67096
  const inlineVal = eq === -1 ? void 0 : a.slice(eq + 1);
67057
67097
  let val;
67058
67098
  if (inlineVal !== void 0) val = inlineVal;
67059
- else if (valueFlags.has(key)) val = rest[++i2] ?? "";
67099
+ else if (valueFlags.has(key) && !looksLikeFlag(rest[i2 + 1])) val = rest[++i2] ?? "";
67060
67100
  else val = true;
67061
67101
  const prev = flags2[key];
67062
67102
  if (LIST_FLAGS.has(key) && typeof prev === "string" && typeof val === "string") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "https://www.w3.org/Translations/WCAG22-fr/",
3
- "fetchedAt": "2026-08-19",
3
+ "fetchedAt": "2026-08-20",
4
4
  "principles": {
5
5
  "1": "Perceptible",
6
6
  "2": "Utilisable",
@@ -2,7 +2,7 @@
2
2
  "wcagVersion": "2.2",
3
3
  "source": "https://www.w3.org/TR/WCAG22/",
4
4
  "criteriaSource": "https://github.com/w3c/wcag",
5
- "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-19 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
5
+ "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-20 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
6
6
  "principles": [
7
7
  {
8
8
  "number": 1,
@@ -2,7 +2,7 @@
2
2
  "source": "https://github.com/w3c/wcag",
3
3
  "understanding": "https://www.w3.org/WAI/WCAG22/Understanding/",
4
4
  "license": "W3C Document License (code samples: W3C Software License)",
5
- "fetchedAt": "2026-08-19",
5
+ "fetchedAt": "2026-08-20",
6
6
  "criteria": {
7
7
  "1.1.1": {
8
8
  "slug": "non-text-content",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "source": "https://github.com/w3c/wcag",
3
3
  "license": "W3C Document License",
4
- "fetchedAt": "2026-08-19",
4
+ "fetchedAt": "2026-08-20",
5
5
  "criteria": {
6
6
  "1.1.1": "All non-text content that is presented to the user has a text alternative that serves the equivalent purpose, except for the situations listed below.\nControls, Input:\nIf non-text content is a control or accepts user input, then it has a name that describes its purpose. (Refer to Success Criterion 4.1.2 Name, Role, Value for additional requirements for controls and content that accepts user input.)\nTime-Based Media:\nIf non-text content is time-based media, then text alternatives at least provide descriptive identification of the non-text content. (Refer to Guideline 1.2 for additional requirements for media.)\nTest:\nIf non-text content is a test or exercise that would be invalid if presented in text, then text alternatives at least provide descriptive identification of the non-text content.\nSensory:\nIf non-text content is primarily intended to create a specific sensory experience, then text alternatives at least provide descriptive identification of the non-text content.\nCAPTCHA:\nIf the purpose of non-text content is to confirm that content is being accessed by a person rather than a computer, then text alternatives that identify and describe the purpose of the non-text content are provided, and alternative forms of CAPTCHA using output modes for different types of sensory perception are provided to accommodate different disabilities.\nDecoration, Formatting, Invisible:\nIf non-text content is pure decoration, is used only for visual formatting, or is not presented to users, then it is implemented in a way that it can be ignored by assistive technology.",
7
7
  "1.2.1": "For prerecorded audio-only and prerecorded video-only media, the following are true, except when the audio or video is a media alternative for text and is clearly labeled as such:\nPrerecorded Audio-only:\nAn alternative for time-based media is provided that presents equivalent information for prerecorded audio-only content.\nPrerecorded Video-only:\nEither an alternative for time-based media or an audio track is provided that presents equivalent information for prerecorded video-only content.",
@@ -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.15.1
8
+ version: 5.17.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.15.1";
9
+ var VERSION = "5.17.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -2290,7 +2290,7 @@ var wcag_universe_default = {
2290
2290
  wcagVersion: "2.2",
2291
2291
  source: "https://www.w3.org/TR/WCAG22/",
2292
2292
  criteriaSource: "https://github.com/w3c/wcag",
2293
- provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-19 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2293
+ provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-20 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2294
2294
  criteria: [
2295
2295
  {
2296
2296
  id: "1.1.1",
@@ -54623,6 +54623,17 @@ function renderRedirected(redirected, lang = "en") {
54623
54623
  }
54624
54624
  return out2;
54625
54625
  }
54626
+ function gridRate(rows, status, pageId) {
54627
+ let c2 = 0;
54628
+ let nc = 0;
54629
+ for (const row of rows) {
54630
+ const st = status.get(row.id)?.get(pageId);
54631
+ if (st === "C") c2++;
54632
+ else if (st === "NC") nc++;
54633
+ }
54634
+ const decided = c2 + nc;
54635
+ return [decided === 0 ? null : Math.round(c2 / decided * 100), decided, rows.length];
54636
+ }
54626
54637
  function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
54627
54638
  const s = L3[lang];
54628
54639
  const out2 = [];
@@ -54636,9 +54647,9 @@ function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
54636
54647
  if (derived.some((p) => p.basis === "attributed")) out2.push(`> \u26A0\uFE0F ${s.basisNote}`, "");
54637
54648
  const head = [isCore(standard) ? s.criterion : s.criterion, ...derived.map((p) => `${p.name}${p.auth ? " \u{1F512}" : ""}`)];
54638
54649
  out2.push(`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`);
54639
- out2.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(p.conformancePct, p.decided, p.total)}**`).join(" | ")} |`);
54640
- out2.push(`| _${s.snapshot}?_ | ${derived.map((p) => `_${basisLabel(p.basis, lang)}_`).join(" | ")} |`);
54641
54650
  const { rows, status } = pageGridModel(result, derived, standard, lang);
54651
+ out2.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(...gridRate(rows, status, p.id))}**`).join(" | ")} |`);
54652
+ out2.push(`| _${s.snapshot}?_ | ${derived.map((p) => `_${basisLabel(p.basis, lang)}_`).join(" | ")} |`);
54642
54653
  let group = "";
54643
54654
  for (const row of rows) {
54644
54655
  if (row.group !== group) {
@@ -59669,11 +59680,13 @@ function mergeSnapshotAudit(base, snap) {
59669
59680
  if (snap.packFindings?.length) merged.packFindings = [...merged.packFindings ?? [], ...snap.packFindings];
59670
59681
  for (const c2 of merged.criteria) {
59671
59682
  if (!c2.inapplicable) continue;
59672
- if (snapById.get(c2.id)?.status === "C") {
59673
- c2.status = "C";
59674
- delete c2.inapplicable;
59675
- delete c2.justification;
59676
- }
59683
+ const measured = snapById.get(c2.id);
59684
+ if (!measured || measured.status !== "C" || measured.inapplicable) continue;
59685
+ c2.status = "C";
59686
+ delete c2.inapplicable;
59687
+ if (measured.justification) c2.justification = measured.justification;
59688
+ else delete c2.justification;
59689
+ if (measured.decidedBy) c2.decidedBy = measured.decidedBy;
59677
59690
  }
59678
59691
  const nowNc = new Set(snap.findings.filter((f) => !f.advisory).map((f) => f.criteriaId));
59679
59692
  merged.residualRisks = merged.residualRisks.filter((r) => !nowNc.has(r.criteriaId));
@@ -59894,17 +59907,38 @@ function localTestedScs(interact) {
59894
59907
  }
59895
59908
  var PW_SPEC = "@playwright/test";
59896
59909
  var AXE_SPEC = "@axe-core/playwright";
59897
- function localTierStatus(cwd) {
59898
- const req = createRequire(resolve12(cwd, "package.json"));
59899
- for (const spec of [PW_SPEC, AXE_SPEC]) {
59910
+ function resolveAnchors(cwd) {
59911
+ return [resolve12(cwd, "package.json"), import.meta.url];
59912
+ }
59913
+ function requireRuntime(cwd, spec) {
59914
+ let last;
59915
+ for (const from of resolveAnchors(cwd)) {
59900
59916
  try {
59901
- req.resolve(spec);
59917
+ return createRequire(from)(spec);
59918
+ } catch (e) {
59919
+ last = e;
59920
+ }
59921
+ }
59922
+ throw last instanceof Error ? last : new Error(String(last));
59923
+ }
59924
+ function resolvesRuntime(cwd, spec) {
59925
+ return resolveAnchors(cwd).some((from) => {
59926
+ try {
59927
+ createRequire(from).resolve(spec);
59928
+ return true;
59902
59929
  } catch {
59903
- return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59930
+ return false;
59931
+ }
59932
+ });
59933
+ }
59934
+ function localTierStatus(cwd) {
59935
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59936
+ if (!resolvesRuntime(cwd, spec)) {
59937
+ return { ok: false, reason: `${spec} resolves neither from ${cwd} nor from ultra11y itself` };
59904
59938
  }
59905
59939
  }
59906
59940
  try {
59907
- const pw = req(PW_SPEC);
59941
+ const pw = requireRuntime(cwd, PW_SPEC);
59908
59942
  const bin = pw.chromium?.executablePath?.();
59909
59943
  if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59910
59944
  return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
@@ -59921,15 +59955,14 @@ function resolveLocalDeps(cwd) {
59921
59955
  let chromium;
59922
59956
  let AxeBuilder;
59923
59957
  try {
59924
- const req = createRequire(resolve12(cwd, "package.json"));
59925
- const pw = req(PW_SPEC);
59926
- const axeMod = req(AXE_SPEC);
59958
+ const pw = requireRuntime(cwd, PW_SPEC);
59959
+ const axeMod = requireRuntime(cwd, AXE_SPEC);
59927
59960
  chromium = pw.chromium;
59928
59961
  AxeBuilder = axeMod.default ?? axeMod;
59929
59962
  } catch (e) {
59930
59963
  const msg = e instanceof Error ? e.message : String(e);
59931
59964
  throw new Error(
59932
- `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59965
+ `Playwright resolves neither from "${cwd}" nor from ultra11y itself. Both are dependencies of this package, so the usual cause is the standalone engine bundle running with no node_modules beside it: pass --cwd <dir> at a project that has @playwright/test + @axe-core/playwright (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59933
59966
  );
59934
59967
  }
59935
59968
  if (!chromium || typeof AxeBuilder !== "function") {
@@ -60996,7 +61029,7 @@ jobs:
60996
61029
  ultra11y:
60997
61030
  runs-on: ubuntu-latest
60998
61031
  steps:
60999
- - uses: actions/checkout@v4
61032
+ - uses: actions/checkout@v7
61000
61033
  with:
61001
61034
  fetch-depth: 0 # the diff gate needs the base ref
61002
61035
  - uses: maxgfr/ultra11y@v${VERSION}
@@ -62955,12 +62988,16 @@ function scoreboardBlocks(result, standard, lang, sheetHref) {
62955
62988
  const scope = pagesOf(result);
62956
62989
  if (!scope.length) return [];
62957
62990
  const derived = derivePages(result, scope);
62958
- const rows = derived.map((p) => [
62959
- { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62960
- { text: p.url, mono: true },
62961
- { text: basisLabel(p.basis, lang) },
62962
- { text: formatRate(p.conformancePct, p.decided, p.total), align: "end" }
62963
- ]);
62991
+ const rows = derived.map((p) => {
62992
+ const criteria = pageCriterionRows(result, p, standard, lang);
62993
+ const cov = pageCoverage(criteria);
62994
+ return [
62995
+ { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62996
+ { text: p.url, mono: true },
62997
+ { text: basisLabel(p.basis, lang) },
62998
+ { text: formatRate(pageRatePct(criteria), cov.decided, cov.total), align: "end" }
62999
+ ];
63000
+ });
62964
63001
  const out2 = [
62965
63002
  { kind: "heading", level: 2, text: t3.perPage, id: "pages" },
62966
63003
  {
@@ -67042,6 +67079,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
67042
67079
  ]);
67043
67080
  var KNOWN_FLAGS = /* @__PURE__ */ new Set([...VALUE_FLAGS2, ...BOOLEAN_FLAGS]);
67044
67081
  var LIST_FLAGS = /* @__PURE__ */ new Set(["include", "exclude", "ext"]);
67082
+ function looksLikeFlag(token) {
67083
+ return token?.startsWith("--") ?? false;
67084
+ }
67045
67085
  function parseArgs(argv) {
67046
67086
  const [command, ...rest] = argv;
67047
67087
  const valueFlags = valueFlagsFor(command ?? "");
@@ -67056,7 +67096,7 @@ function parseArgs(argv) {
67056
67096
  const inlineVal = eq === -1 ? void 0 : a.slice(eq + 1);
67057
67097
  let val;
67058
67098
  if (inlineVal !== void 0) val = inlineVal;
67059
- else if (valueFlags.has(key)) val = rest[++i2] ?? "";
67099
+ else if (valueFlags.has(key) && !looksLikeFlag(rest[i2 + 1])) val = rest[++i2] ?? "";
67060
67100
  else val = true;
67061
67101
  const prev = flags2[key];
67062
67102
  if (LIST_FLAGS.has(key) && typeof prev === "string" && typeof val === "string") {
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
4
4
  when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
5
5
  license: MIT
6
6
  metadata:
7
- version: 5.15.1
7
+ version: 5.17.0
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -314,7 +314,7 @@ node scripts/ultra11y.mjs pack check ./packs/section508.json #
314
314
  ```
315
315
  Add `--standard rgaa` to `report`/`prd`/`criteria`/`check`/`verify` for a country standard,
316
316
  `--pack ./pack.json` to load one at runtime, and `--json` anywhere for machine output.
317
- `--lang` follows the conversation (pass it explicitly — Core rule 5).
317
+ `--lang` follows the conversation (pass it explicitly — Core rule 7).
318
318
 
319
319
  ## The loop: audit → render → judge → fix → re-audit
320
320
 
@@ -347,7 +347,7 @@ drive the judgment and content stages:
347
347
  per NC criterion) and `prd` (the same blocks as an actionable backlog; `tickets`
348
348
  filing one GitHub issue per criterion with that identical block) are two views of the
349
349
  ONE building block, in the language of this conversation (pass `--lang` explicitly —
350
- Core rule 5).
350
+ Core rule 7).
351
351
 
352
352
  6. **Hand the change over to `review-a11y`, in a subagent.** Once the deliverable is out,
353
353
  the code you just fixed still has to be reviewed as a *change*, and that is the other
@@ -5,18 +5,28 @@ contrast (1.4.3), focus visible (2.4.7), reflow/zoom (1.4.4/1.4.10), text spacin
5
5
  content on hover (1.4.13), target size (2.5.8). The dynamic tier decides them by running
6
6
  **axe-core in a real headless browser** (Playwright). Two runtimes, same finding shape:
7
7
 
8
- - **`--runtime local`** (recommended): resolves a host/target Playwright + `@axe-core/playwright`
9
- **at runtime** from `--cwd` (no Docker, no global install). Most projects with Playwright
10
- e2e tests already have both deps. Adds the residual-criteria **probes** (below).
8
+ - **`--runtime local`** (recommended): resolves Playwright + `@axe-core/playwright`
9
+ **at runtime**, from `--cwd` first and from ultra11y's own install second (no Docker, no
10
+ global install). A project that pins its own Playwright keeps it, because `--cwd` is tried
11
+ first. Adds the residual-criteria **probes** (below).
11
12
  - **`--runtime docker`**: runs axe-core in a Docker image auto-built on first use (runner +
12
13
  Dockerfile embedded in the bundle). No host deps beyond Docker. Axe + 320px reflow only.
13
- - **`--runtime auto`** (default): local if Playwright resolves from `--cwd`, else Docker, else
14
- an actionable error.
14
+ - **`--runtime auto`** (default): local if Playwright resolves **and** a browser binary is on
15
+ disk, else Docker, else an actionable error.
15
16
 
16
17
  ## Prerequisites
17
18
 
18
- - **local**: a project reachable from `--cwd` with `@playwright/test` + `@axe-core/playwright`
19
- installed, and a Chromium browser (`npx playwright install chromium`).
19
+ - **local**: a Chromium browser (`npx playwright install chromium`), plus the two npm packages
20
+ — and **where they have to live depends on how you run the engine**:
21
+
22
+ | Channel | `@playwright/test` + `@axe-core/playwright` |
23
+ |---|---|
24
+ | ultra11y as an npm dependency (`ultra11y/playwright`, `pnpm exec ultra11y`) | **come with it** — they are its own `dependencies`. Nothing to install. |
25
+ | the standalone skill bundle (`node scripts/ultra11y.mjs` from an installed skill) | **must be in the audited project**, reachable from `--cwd`. An installed skill is a directory of files with no `node_modules` beside it, so the second anchor finds nothing there. |
26
+
27
+ Either way, a project that installs them itself wins: `--cwd` is tried first, which is what a
28
+ repository with its own pinned Playwright wants. When neither anchor resolves, `auto` degrades
29
+ to Docker and says which package it could not find.
20
30
  - **docker**: Docker running.
21
31
 
22
32
  The rest of the skill (static audit) needs neither.
@@ -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.15.1";
9
+ var VERSION = "5.17.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -2290,7 +2290,7 @@ var wcag_universe_default = {
2290
2290
  wcagVersion: "2.2",
2291
2291
  source: "https://www.w3.org/TR/WCAG22/",
2292
2292
  criteriaSource: "https://github.com/w3c/wcag",
2293
- provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-19 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2293
+ provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-20 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2294
2294
  criteria: [
2295
2295
  {
2296
2296
  id: "1.1.1",
@@ -54623,6 +54623,17 @@ function renderRedirected(redirected, lang = "en") {
54623
54623
  }
54624
54624
  return out2;
54625
54625
  }
54626
+ function gridRate(rows, status, pageId) {
54627
+ let c2 = 0;
54628
+ let nc = 0;
54629
+ for (const row of rows) {
54630
+ const st = status.get(row.id)?.get(pageId);
54631
+ if (st === "C") c2++;
54632
+ else if (st === "NC") nc++;
54633
+ }
54634
+ const decided = c2 + nc;
54635
+ return [decided === 0 ? null : Math.round(c2 / decided * 100), decided, rows.length];
54636
+ }
54626
54637
  function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
54627
54638
  const s = L3[lang];
54628
54639
  const out2 = [];
@@ -54636,9 +54647,9 @@ function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
54636
54647
  if (derived.some((p) => p.basis === "attributed")) out2.push(`> \u26A0\uFE0F ${s.basisNote}`, "");
54637
54648
  const head = [isCore(standard) ? s.criterion : s.criterion, ...derived.map((p) => `${p.name}${p.auth ? " \u{1F512}" : ""}`)];
54638
54649
  out2.push(`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`);
54639
- out2.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(p.conformancePct, p.decided, p.total)}**`).join(" | ")} |`);
54640
- out2.push(`| _${s.snapshot}?_ | ${derived.map((p) => `_${basisLabel(p.basis, lang)}_`).join(" | ")} |`);
54641
54650
  const { rows, status } = pageGridModel(result, derived, standard, lang);
54651
+ out2.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(...gridRate(rows, status, p.id))}**`).join(" | ")} |`);
54652
+ out2.push(`| _${s.snapshot}?_ | ${derived.map((p) => `_${basisLabel(p.basis, lang)}_`).join(" | ")} |`);
54642
54653
  let group = "";
54643
54654
  for (const row of rows) {
54644
54655
  if (row.group !== group) {
@@ -59669,11 +59680,13 @@ function mergeSnapshotAudit(base, snap) {
59669
59680
  if (snap.packFindings?.length) merged.packFindings = [...merged.packFindings ?? [], ...snap.packFindings];
59670
59681
  for (const c2 of merged.criteria) {
59671
59682
  if (!c2.inapplicable) continue;
59672
- if (snapById.get(c2.id)?.status === "C") {
59673
- c2.status = "C";
59674
- delete c2.inapplicable;
59675
- delete c2.justification;
59676
- }
59683
+ const measured = snapById.get(c2.id);
59684
+ if (!measured || measured.status !== "C" || measured.inapplicable) continue;
59685
+ c2.status = "C";
59686
+ delete c2.inapplicable;
59687
+ if (measured.justification) c2.justification = measured.justification;
59688
+ else delete c2.justification;
59689
+ if (measured.decidedBy) c2.decidedBy = measured.decidedBy;
59677
59690
  }
59678
59691
  const nowNc = new Set(snap.findings.filter((f) => !f.advisory).map((f) => f.criteriaId));
59679
59692
  merged.residualRisks = merged.residualRisks.filter((r) => !nowNc.has(r.criteriaId));
@@ -59894,17 +59907,38 @@ function localTestedScs(interact) {
59894
59907
  }
59895
59908
  var PW_SPEC = "@playwright/test";
59896
59909
  var AXE_SPEC = "@axe-core/playwright";
59897
- function localTierStatus(cwd) {
59898
- const req = createRequire(resolve12(cwd, "package.json"));
59899
- for (const spec of [PW_SPEC, AXE_SPEC]) {
59910
+ function resolveAnchors(cwd) {
59911
+ return [resolve12(cwd, "package.json"), import.meta.url];
59912
+ }
59913
+ function requireRuntime(cwd, spec) {
59914
+ let last;
59915
+ for (const from of resolveAnchors(cwd)) {
59900
59916
  try {
59901
- req.resolve(spec);
59917
+ return createRequire(from)(spec);
59918
+ } catch (e) {
59919
+ last = e;
59920
+ }
59921
+ }
59922
+ throw last instanceof Error ? last : new Error(String(last));
59923
+ }
59924
+ function resolvesRuntime(cwd, spec) {
59925
+ return resolveAnchors(cwd).some((from) => {
59926
+ try {
59927
+ createRequire(from).resolve(spec);
59928
+ return true;
59902
59929
  } catch {
59903
- return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59930
+ return false;
59931
+ }
59932
+ });
59933
+ }
59934
+ function localTierStatus(cwd) {
59935
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59936
+ if (!resolvesRuntime(cwd, spec)) {
59937
+ return { ok: false, reason: `${spec} resolves neither from ${cwd} nor from ultra11y itself` };
59904
59938
  }
59905
59939
  }
59906
59940
  try {
59907
- const pw = req(PW_SPEC);
59941
+ const pw = requireRuntime(cwd, PW_SPEC);
59908
59942
  const bin = pw.chromium?.executablePath?.();
59909
59943
  if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59910
59944
  return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
@@ -59921,15 +59955,14 @@ function resolveLocalDeps(cwd) {
59921
59955
  let chromium;
59922
59956
  let AxeBuilder;
59923
59957
  try {
59924
- const req = createRequire(resolve12(cwd, "package.json"));
59925
- const pw = req(PW_SPEC);
59926
- const axeMod = req(AXE_SPEC);
59958
+ const pw = requireRuntime(cwd, PW_SPEC);
59959
+ const axeMod = requireRuntime(cwd, AXE_SPEC);
59927
59960
  chromium = pw.chromium;
59928
59961
  AxeBuilder = axeMod.default ?? axeMod;
59929
59962
  } catch (e) {
59930
59963
  const msg = e instanceof Error ? e.message : String(e);
59931
59964
  throw new Error(
59932
- `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59965
+ `Playwright resolves neither from "${cwd}" nor from ultra11y itself. Both are dependencies of this package, so the usual cause is the standalone engine bundle running with no node_modules beside it: pass --cwd <dir> at a project that has @playwright/test + @axe-core/playwright (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59933
59966
  );
59934
59967
  }
59935
59968
  if (!chromium || typeof AxeBuilder !== "function") {
@@ -60996,7 +61029,7 @@ jobs:
60996
61029
  ultra11y:
60997
61030
  runs-on: ubuntu-latest
60998
61031
  steps:
60999
- - uses: actions/checkout@v4
61032
+ - uses: actions/checkout@v7
61000
61033
  with:
61001
61034
  fetch-depth: 0 # the diff gate needs the base ref
61002
61035
  - uses: maxgfr/ultra11y@v${VERSION}
@@ -62955,12 +62988,16 @@ function scoreboardBlocks(result, standard, lang, sheetHref) {
62955
62988
  const scope = pagesOf(result);
62956
62989
  if (!scope.length) return [];
62957
62990
  const derived = derivePages(result, scope);
62958
- const rows = derived.map((p) => [
62959
- { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62960
- { text: p.url, mono: true },
62961
- { text: basisLabel(p.basis, lang) },
62962
- { text: formatRate(p.conformancePct, p.decided, p.total), align: "end" }
62963
- ]);
62991
+ const rows = derived.map((p) => {
62992
+ const criteria = pageCriterionRows(result, p, standard, lang);
62993
+ const cov = pageCoverage(criteria);
62994
+ return [
62995
+ { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62996
+ { text: p.url, mono: true },
62997
+ { text: basisLabel(p.basis, lang) },
62998
+ { text: formatRate(pageRatePct(criteria), cov.decided, cov.total), align: "end" }
62999
+ ];
63000
+ });
62964
63001
  const out2 = [
62965
63002
  { kind: "heading", level: 2, text: t3.perPage, id: "pages" },
62966
63003
  {
@@ -67042,6 +67079,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
67042
67079
  ]);
67043
67080
  var KNOWN_FLAGS = /* @__PURE__ */ new Set([...VALUE_FLAGS2, ...BOOLEAN_FLAGS]);
67044
67081
  var LIST_FLAGS = /* @__PURE__ */ new Set(["include", "exclude", "ext"]);
67082
+ function looksLikeFlag(token) {
67083
+ return token?.startsWith("--") ?? false;
67084
+ }
67045
67085
  function parseArgs(argv) {
67046
67086
  const [command, ...rest] = argv;
67047
67087
  const valueFlags = valueFlagsFor(command ?? "");
@@ -67056,7 +67096,7 @@ function parseArgs(argv) {
67056
67096
  const inlineVal = eq === -1 ? void 0 : a.slice(eq + 1);
67057
67097
  let val;
67058
67098
  if (inlineVal !== void 0) val = inlineVal;
67059
- else if (valueFlags.has(key)) val = rest[++i2] ?? "";
67099
+ else if (valueFlags.has(key) && !looksLikeFlag(rest[i2 + 1])) val = rest[++i2] ?? "";
67060
67100
  else val = true;
67061
67101
  const prev = flags2[key];
67062
67102
  if (LIST_FLAGS.has(key) && typeof prev === "string" && typeof val === "string") {
package/src/cli.ts CHANGED
@@ -691,6 +691,23 @@ export interface ParsedArgs {
691
691
  unknown: string[];
692
692
  }
693
693
 
694
+ /** Is this token the NEXT FLAG rather than the previous flag's value?
695
+ *
696
+ * Several value flags take an OPTIONAL value — `--ledger` bare means the standard's default
697
+ * path, `--require-decided` bare means the run's grid — and the bare form is almost always
698
+ * followed by another flag. Consumed blindly, the next flag became the value:
699
+ * `verify --apply … --ledger --lang fr` wrote a 33 KB verdict ledger to a file literally
700
+ * named `--lang`, never wrote `.ultra11y/verdicts/rgaa.json`, and — `--lang` having been
701
+ * eaten — printed the run in the wrong language. Nothing failed and nothing warned; the next
702
+ * run simply paid for the whole adjudication again, which is the cost the ledger exists to
703
+ * remove.
704
+ *
705
+ * Only `--` counts. A single dash is the stdin positional, and a value may legitimately be
706
+ * negative or start with one. */
707
+ function looksLikeFlag(token: string | undefined): boolean {
708
+ return token?.startsWith("--") ?? false;
709
+ }
710
+
694
711
  export function parseArgs(argv: string[]): ParsedArgs {
695
712
  const [command, ...rest] = argv;
696
713
  const valueFlags = valueFlagsFor(command ?? "");
@@ -708,7 +725,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
708
725
  const inlineVal = eq === -1 ? undefined : a.slice(eq + 1);
709
726
  let val: string | boolean;
710
727
  if (inlineVal !== undefined) val = inlineVal;
711
- else if (valueFlags.has(key)) val = rest[++i] ?? "";
728
+ else if (valueFlags.has(key) && !looksLikeFlag(rest[i + 1])) val = rest[++i] ?? "";
712
729
  else val = true;
713
730
  const prev = flags[key];
714
731
  if (LIST_FLAGS.has(key) && typeof prev === "string" && typeof val === "string") {
@@ -2,7 +2,7 @@
2
2
  "wcagVersion": "2.2",
3
3
  "source": "https://www.w3.org/TR/WCAG22/",
4
4
  "criteriaSource": "https://github.com/w3c/wcag",
5
- "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-19 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
5
+ "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-20 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
6
6
  "criteria": [
7
7
  {
8
8
  "id": "1.1.1",
@@ -301,12 +301,19 @@ export function scoreboardBlocks(result: AuditResult, standard: StandardId, lang
301
301
  const scope = pagesOf(result);
302
302
  if (!scope.length) return [];
303
303
  const derived = derivePages(result, scope);
304
- const rows = derived.map((p) => [
305
- { text: `${p.name}${p.auth ? " 🔒" : ""}`, ...(sheetHref ? { href: sheetHref(p.id) } : {}) },
306
- { text: p.url, mono: true },
307
- { text: basisLabel(p.basis, lang) },
308
- { text: formatRate(p.conformancePct, p.decided, p.total), align: "end" as const },
309
- ]);
304
+ // Through the standard-aware helpers, exactly like `renderPageRates` in src/report.ts:
305
+ // `PageResult.conformancePct` is the WCAG core projection, so under a pack this cell used to
306
+ // quote a rate out of 55 beside a document counting 106.
307
+ const rows = derived.map((p) => {
308
+ const criteria = pageCriterionRows(result, p, standard, lang);
309
+ const cov = pageCoverage(criteria);
310
+ return [
311
+ { text: `${p.name}${p.auth ? " 🔒" : ""}`, ...(sheetHref ? { href: sheetHref(p.id) } : {}) },
312
+ { text: p.url, mono: true },
313
+ { text: basisLabel(p.basis, lang) },
314
+ { text: formatRate(pageRatePct(criteria), cov.decided, cov.total), align: "end" as const },
315
+ ];
316
+ });
310
317
  const out: Block[] = [
311
318
  { kind: "heading", level: 2, text: t.perPage, id: "pages" },
312
319
  {
package/src/init.ts CHANGED
@@ -129,7 +129,7 @@ jobs:
129
129
  ultra11y:
130
130
  runs-on: ubuntu-latest
131
131
  steps:
132
- - uses: actions/checkout@v4
132
+ - uses: actions/checkout@v7
133
133
  with:
134
134
  fetch-depth: 0 # the diff gate needs the base ref
135
135
  - uses: maxgfr/ultra11y@v${VERSION}
package/src/pages.ts CHANGED
@@ -489,6 +489,25 @@ export function renderRedirected(redirected: ScanRedirect[], lang: Lang = "en"):
489
489
  return out;
490
490
  }
491
491
 
492
+ /** One page's standing over the rows of the grid it is printed on — same C ÷ (C + NC) basis
493
+ * as everywhere else, and the same NULL for "nothing decided". Taking it from the grid model
494
+ * rather than from `PageResult` is what makes the header a summary of the body under a pack
495
+ * as well as under the core: `PageResult` only ever carries the WCAG projection.
496
+ *
497
+ * Returned as the argument tuple `formatRate` takes, so no caller can pair a rate with
498
+ * somebody else's denominator. */
499
+ export function gridRate(rows: { id: string }[], status: Map<string, Map<string, Status>>, pageId: string): [number | null, number, number] {
500
+ let c = 0;
501
+ let nc = 0;
502
+ for (const row of rows) {
503
+ const st = status.get(row.id)?.get(pageId);
504
+ if (st === "C") c++;
505
+ else if (st === "NC") nc++;
506
+ }
507
+ const decided = c + nc;
508
+ return [decided === 0 ? null : Math.round((c / decided) * 100), decided, rows.length];
509
+ }
510
+
492
511
  /** The Markdown grid: one row per criterion, one column per page. */
493
512
  export function renderPageGrid(result: AuditResult, pages: PageScope[], standard: StandardId = CORE, lang: Lang = "en"): string {
494
513
  const s = L[lang];
@@ -504,10 +523,17 @@ export function renderPageGrid(result: AuditResult, pages: PageScope[], standard
504
523
 
505
524
  const head = [isCore(standard) ? s.criterion : s.criterion, ...derived.map((p) => `${p.name}${p.auth ? " 🔒" : ""}`)];
506
525
  out.push(`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`);
507
- out.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(p.conformancePct, p.decided, p.total)}**`).join(" | ")} |`);
526
+ // THE MODEL FIRST, because the rate is a summary OF THIS GRID and has to be computed from
527
+ // the rows it is printed above. It was read off `PageResult` instead — which is always the
528
+ // WCAG core projection — so under a pack the header and the body answered different
529
+ // questions: measured on a two-page RGAA run, the grid listed all 106 criteria and then
530
+ // announced « 89 % (9/55) », while the same page's own dossier said 47/106. Same command,
531
+ // same page, two denominators; and this grid is what `comment-kind: pages` pastes onto a
532
+ // pull request, so the wrong one was the one people read.
533
+ const { rows, status } = pageGridModel(result, derived, standard, lang);
534
+ out.push(`| **${s.rate}** | ${derived.map((p) => `**${formatRate(...gridRate(rows, status, p.id))}**`).join(" | ")} |`);
508
535
  out.push(`| _${s.snapshot}?_ | ${derived.map((p) => `_${basisLabel(p.basis, lang)}_`).join(" | ")} |`);
509
536
 
510
- const { rows, status } = pageGridModel(result, derived, standard, lang);
511
537
  let group = "";
512
538
  for (const row of rows) {
513
539
  if (row.group !== group) {
package/src/scan-local.ts CHANGED
@@ -1,6 +1,7 @@
1
- // `scan --runtime local` — the dynamic tier WITHOUT Docker. Resolves a host/target
2
- // Playwright + @axe-core/playwright AT RUNTIME (createRequire, a node: builtin) from
3
- // `--cwd`, so the zero-dep static engine bundle never gains a static browser import.
1
+ // `scan --runtime local` — the dynamic tier WITHOUT Docker. Resolves Playwright +
2
+ // @axe-core/playwright AT RUNTIME (createRequire, a node: builtin) from `--cwd` first and
3
+ // from this package second, so the zero-dep static engine bundle never gains a static
4
+ // browser import while a project that installed ultra11y alone still gets the tier.
4
5
  // It runs the SAME axe-core pass as the Docker RUNNER, then the residual-criteria
5
6
  // probes axe cannot decide (focus visibility, 200% zoom, text spacing, content on
6
7
  // hover, target size). The Docker RUNNER / docker/* files are untouched (docker-sync).
@@ -59,6 +60,46 @@ interface LocalDeps {
59
60
  const PW_SPEC = "@playwright/test";
60
61
  const AXE_SPEC = "@axe-core/playwright";
61
62
 
63
+ /** Where a runtime dependency may live, in the order that must be tried.
64
+ *
65
+ * THE TARGET PROJECT FIRST, then this package — the same order `src/integrations/playwright.ts`
66
+ * uses, and for the same two reasons. A repository that pins its own Playwright must keep it:
67
+ * two copies in one process hand out `Page` objects the other one's fixtures do not recognise.
68
+ * And a repository that has none must still get the tier, now that `@playwright/test` and
69
+ * `@axe-core/playwright` are dependencies of ultra11y instead of something every caller had to
70
+ * duplicate into its own manifest. Anchoring on `cwd` ALONE was the older contract, and it made
71
+ * `--runtime local` unavailable to exactly the projects that had not read the prerequisites. */
72
+ function resolveAnchors(cwd: string): string[] {
73
+ return [resolve(cwd, "package.json"), import.meta.url];
74
+ }
75
+
76
+ /** Load `spec` from the target project, else from this package. Rethrows the LAST failure, so
77
+ * the message names the spec the caller asked for rather than the first anchor that missed. */
78
+ function requireRuntime(cwd: string, spec: string): Any {
79
+ let last: unknown;
80
+ for (const from of resolveAnchors(cwd)) {
81
+ try {
82
+ return createRequire(from)(spec);
83
+ } catch (e) {
84
+ last = e;
85
+ }
86
+ }
87
+ throw last instanceof Error ? last : new Error(String(last));
88
+ }
89
+
90
+ /** Can `spec` be resolved from either anchor? Resolution only — never loads the module, so
91
+ * probing the tier stays as cheap as it was when it only looked at `cwd`. */
92
+ function resolvesRuntime(cwd: string, spec: string): boolean {
93
+ return resolveAnchors(cwd).some((from) => {
94
+ try {
95
+ createRequire(from).resolve(spec);
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ });
101
+ }
102
+
62
103
  /** Why the LOCAL tier is (un)usable from `cwd` — the one line a reader of a
63
104
  * `runtime: auto` log gets before the Docker fallback takes over. A silent degrade is
64
105
  * indistinguishable from a working fallback, so the reason must NAME what to install. */
@@ -68,16 +109,13 @@ export type LocalTierStatus = { ok: true } | { ok: false; reason: string };
68
109
  * boolean. Drives the `runtime: auto` decision AND the message printed when it degrades
69
110
  * to Docker. */
70
111
  export function localTierStatus(cwd: string): LocalTierStatus {
71
- const req = createRequire(resolve(cwd, "package.json"));
72
112
  for (const spec of [PW_SPEC, AXE_SPEC]) {
73
- try {
74
- req.resolve(spec);
75
- } catch {
76
- return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
113
+ if (!resolvesRuntime(cwd, spec)) {
114
+ return { ok: false, reason: `${spec} resolves neither from ${cwd} nor from ultra11y itself` };
77
115
  }
78
116
  }
79
117
  try {
80
- const pw = req(PW_SPEC) as { chromium?: { executablePath?: () => string } };
118
+ const pw = requireRuntime(cwd, PW_SPEC) as { chromium?: { executablePath?: () => string } };
81
119
  const bin = pw.chromium?.executablePath?.();
82
120
  // No path at all ⇒ a Playwright that cannot tell us; treat as available and let the
83
121
  // launch report its own failure, rather than refusing a tier that might work.
@@ -112,15 +150,14 @@ export function resolveLocalDeps(cwd: string): LocalDeps {
112
150
  let chromium: Any;
113
151
  let AxeBuilder: Any;
114
152
  try {
115
- const req = createRequire(resolve(cwd, "package.json"));
116
- const pw = req(PW_SPEC);
117
- const axeMod = req(AXE_SPEC);
153
+ const pw = requireRuntime(cwd, PW_SPEC);
154
+ const axeMod = requireRuntime(cwd, AXE_SPEC);
118
155
  chromium = pw.chromium;
119
156
  AxeBuilder = axeMod.default ?? axeMod;
120
157
  } catch (e) {
121
158
  const msg = e instanceof Error ? e.message : String(e);
122
159
  throw new Error(
123
- `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`,
160
+ `Playwright resolves neither from "${cwd}" nor from ultra11y itself. Both are dependencies of this package, so the usual cause is the standalone engine bundle running with no node_modules beside it: pass --cwd <dir> at a project that has @playwright/test + @axe-core/playwright (e.g. --cwd packages/app), or use --runtime docker. (${msg})`,
124
161
  );
125
162
  }
126
163
  if (!chromium || typeof AxeBuilder !== "function") {
package/src/scan.ts CHANGED
@@ -741,15 +741,33 @@ export function mergeSnapshotAudit(base: AuditResult, snap: AuditResult): AuditR
741
741
  if (snap.packFindings?.length) merged.packFindings = [...(merged.packFindings ?? []), ...snap.packFindings];
742
742
 
743
743
  // A criterion the static pass closed for want of a subject, which the snapshot then MEASURED.
744
- // Both read `C`, so the discriminator is the flag, not the status: what has to go is the
745
- // "nothing of that kind here" justification, which a rendered page has just falsified.
744
+ // Both read `C`, so the discriminator is the FLAG, not the status — and testing the status
745
+ // is exactly what this loop used to do. The snapshot pages have no time-based media either,
746
+ // so they closed 1.2.1 the same way the source did, `C` + `inapplicable`; reading only the
747
+ // status, the merge took that for a measurement and stripped the flag from every criterion
748
+ // both halves had closed for want of a subject.
749
+ //
750
+ // Two things then broke, both silently. The `C` lost the justification that made it
751
+ // falsifiable, becoming the bare uncited conformity this engine refuses everywhere else. And
752
+ // `pageStatus` reads `inapplicable` to hold "a conformity reached for want of a subject
753
+ // holds on every page" — without the flag those criteria fell through to the honesty rule
754
+ // that only `static` criteria earn a verdict by silence, and came back « à évaluer » on
755
+ // EVERY page. Measured on a two-page RGAA crawl: the run reported a complete 106/106 grid
756
+ // while each page carried 10 criteria nobody could ever adjudicate, because there was
757
+ // nothing there to adjudicate.
746
758
  for (const c of merged.criteria) {
747
759
  if (!c.inapplicable) continue;
748
- if (snapById.get(c.id)?.status === "C") {
749
- c.status = "C";
750
- delete c.inapplicable;
751
- delete c.justification; // "no relevant element in scope" no longer holds
752
- }
760
+ const measured = snapById.get(c.id);
761
+ // Only a snapshot that reached `C` WITHOUT the flag has falsified "nothing of that kind
762
+ // here" — that one looked at the rendered page and found the subject.
763
+ if (!measured || measured.status !== "C" || measured.inapplicable) continue;
764
+ c.status = "C";
765
+ delete c.inapplicable;
766
+ // Keep the claim citable: adopt what the measuring half said, rather than leaving a `C`
767
+ // with nothing behind it.
768
+ if (measured.justification) c.justification = measured.justification;
769
+ else delete c.justification;
770
+ if (measured.decidedBy) c.decidedBy = measured.decidedBy;
753
771
  }
754
772
 
755
773
  const nowNc = new Set(snap.findings.filter((f) => !f.advisory).map((f) => f.criteriaId));
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Single source of truth for shared types + the embedded version. sync-version.mjs
2
2
  // rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
3
- export const VERSION = "5.15.1";
3
+ export const VERSION = "5.17.0";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,
@@ -1,87 +0,0 @@
1
- interface FindingLike {
2
- ruleId: string;
3
- criteriaId: string;
4
- file: string;
5
- line: number;
6
- severity: string;
7
- message: string;
8
- advisory?: boolean;
9
- origin?: {
10
- sourceFile?: string;
11
- sourceLine?: number;
12
- };
13
- }
14
- interface AuditLike {
15
- findings?: FindingLike[];
16
- }
17
- interface SnapshotPayload {
18
- meta: Record<string, unknown>;
19
- dom: string;
20
- styles?: unknown;
21
- boxes?: unknown;
22
- css?: unknown;
23
- screenshot?: string;
24
- /** What the live probes measured, when the caller ran them. */
25
- probes?: unknown;
26
- }
27
- /** Same slug rule as the engine (src/snapshot.ts slugifyPageId): accent-folded URL path. */
28
- declare function slugify(url: string): string;
29
- interface CheckOptions {
30
- /** The page id (directory name). Defaults to the slugified URL path. */
31
- as?: string;
32
- /** Human page name for the report. Defaults to the document title. */
33
- name?: string;
34
- /** Severity at which the check fails, or `false` to record without ever failing. */
35
- failOn?: "blocking" | "major" | "minor" | false;
36
- /** Mark the page as sitting behind authentication. */
37
- auth?: boolean;
38
- /** Source files that rendered it — findings are then reported against YOUR code. */
39
- sources?: string[];
40
- /** Reproduction notes carried into the auditor ticket. */
41
- notes?: string;
42
- /** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
43
- screenshot?: boolean;
44
- /** The path you NAVIGATED to. Given it, the fixture refuses to record a page the browser
45
- * did not stay on — a guarded route that redirected, a session that expired — instead of
46
- * filing that other screen under this page's `as`/`name`. Nothing about such a snapshot
47
- * looks wrong, which is what makes it the worst thing an accessibility report can carry.
48
- * Compared by path (a query or fragment the app appends to its own route is the same
49
- * page); a full URL works too. `scan --sample` applies the same rule. */
50
- expectPath?: string;
51
- /** Run the LIVE PROBES on this page after collecting it: 200% zoom, 320px reflow, the
52
- * text-spacing override, focus visibility and content-on-hover.
53
- *
54
- * These are the measurements a recorded snapshot can never settle, because they are
55
- * properties of a page being acted on rather than of a page as it stands. `scan` runs them
56
- * in its own browser, which is exactly why they were undecidable on any screen behind a
57
- * login or a state machine — the scanner arrives cold and gets a redirect. Your suite has
58
- * the page in the right state; this is how it says so.
59
- *
60
- * Off by default: the probes press Tab, hover, resize the viewport and inject a stylesheet,
61
- * and a suite should opt into that. Everything is restored before it returns, and the
62
- * snapshot has already been collected, so what they measure cannot alter what was recorded.
63
- * Pass an array to run only some of them (by criterion: "1.4.4", "1.4.10", "1.4.12",
64
- * "1.4.13", "2.4.7"), or an object to also set the bounds — how wide the reflow probe
65
- * narrows to, how many focusables are worth tabbing through, how many hits are worth
66
- * recording. 320px is normative and stays the default; the other two are judgements about
67
- * YOUR pages, and a screen with 400 focusables should be able to say so rather than being
68
- * quietly cut off at 120. */
69
- probes?: boolean | string[] | ProbeTuning;
70
- }
71
- /** The bounds a repository may put on the live probes. Stated once, here, because the same
72
- * list is read from `.ultra11yrc.json`, accepted at the call site and handed to the engine —
73
- * three copies of a field list is three chances for one of them to fall behind.
74
- *
75
- * `budgetMs` and `actionTimeoutMs` are not tuning in the same sense as the others: they are
76
- * what stops a probe from spending SOMEBODY ELSE'S test timeout. See ProbeLimits. */
77
- interface ProbeTuning {
78
- only?: string[];
79
- reflowWidth?: number;
80
- maxFocusables?: number;
81
- maxHits?: number;
82
- maxTriggers?: number;
83
- actionTimeoutMs?: number;
84
- budgetMs?: number;
85
- }
86
-
87
- export { type AuditLike as A, type CheckOptions as C, type FindingLike as F, type SnapshotPayload as S, slugify as s };