ultra11y 5.16.0 → 5.18.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.16.0",
13
+ "version": "5.18.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.16.0",
4
+ "version": "5.18.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.16.0",
4
+ "version": "5.18.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.16.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.18.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 Chromium binary (`npx playwright install chromium`) and nothing else: `@playwright/test` and `@axe-core/playwright` are dependencies of the package, and the audited project's own copies take precedence when it has them. The engine bundle itself stays dependency-free. 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.16.0",
3
+ "version": "5.18.0",
4
4
  "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.16.0";
9
+ var VERSION = "5.18.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -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));
@@ -61016,7 +61029,7 @@ jobs:
61016
61029
  ultra11y:
61017
61030
  runs-on: ubuntu-latest
61018
61031
  steps:
61019
- - uses: actions/checkout@v4
61032
+ - uses: actions/checkout@v7
61020
61033
  with:
61021
61034
  fetch-depth: 0 # the diff gate needs the base ref
61022
61035
  - uses: maxgfr/ultra11y@v${VERSION}
@@ -62975,12 +62988,16 @@ function scoreboardBlocks(result, standard, lang, sheetHref) {
62975
62988
  const scope = pagesOf(result);
62976
62989
  if (!scope.length) return [];
62977
62990
  const derived = derivePages(result, scope);
62978
- const rows = derived.map((p) => [
62979
- { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62980
- { text: p.url, mono: true },
62981
- { text: basisLabel(p.basis, lang) },
62982
- { text: formatRate(p.conformancePct, p.decided, p.total), align: "end" }
62983
- ]);
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
+ });
62984
63001
  const out2 = [
62985
63002
  { kind: "heading", level: 2, text: t3.perPage, id: "pages" },
62986
63003
  {
@@ -67062,6 +67079,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
67062
67079
  ]);
67063
67080
  var KNOWN_FLAGS = /* @__PURE__ */ new Set([...VALUE_FLAGS2, ...BOOLEAN_FLAGS]);
67064
67081
  var LIST_FLAGS = /* @__PURE__ */ new Set(["include", "exclude", "ext"]);
67082
+ function looksLikeFlag(token) {
67083
+ return token?.startsWith("--") ?? false;
67084
+ }
67065
67085
  function parseArgs(argv) {
67066
67086
  const [command, ...rest] = argv;
67067
67087
  const valueFlags = valueFlagsFor(command ?? "");
@@ -67076,7 +67096,7 @@ function parseArgs(argv) {
67076
67096
  const inlineVal = eq === -1 ? void 0 : a.slice(eq + 1);
67077
67097
  let val;
67078
67098
  if (inlineVal !== void 0) val = inlineVal;
67079
- else if (valueFlags.has(key)) val = rest[++i2] ?? "";
67099
+ else if (valueFlags.has(key) && !looksLikeFlag(rest[i2 + 1])) val = rest[++i2] ?? "";
67080
67100
  else val = true;
67081
67101
  const prev = flags2[key];
67082
67102
  if (LIST_FLAGS.has(key) && typeof prev === "string" && typeof val === "string") {
@@ -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.16.0
8
+ version: 5.18.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.16.0";
9
+ var VERSION = "5.18.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -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));
@@ -61016,7 +61029,7 @@ jobs:
61016
61029
  ultra11y:
61017
61030
  runs-on: ubuntu-latest
61018
61031
  steps:
61019
- - uses: actions/checkout@v4
61032
+ - uses: actions/checkout@v7
61020
61033
  with:
61021
61034
  fetch-depth: 0 # the diff gate needs the base ref
61022
61035
  - uses: maxgfr/ultra11y@v${VERSION}
@@ -62975,12 +62988,16 @@ function scoreboardBlocks(result, standard, lang, sheetHref) {
62975
62988
  const scope = pagesOf(result);
62976
62989
  if (!scope.length) return [];
62977
62990
  const derived = derivePages(result, scope);
62978
- const rows = derived.map((p) => [
62979
- { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62980
- { text: p.url, mono: true },
62981
- { text: basisLabel(p.basis, lang) },
62982
- { text: formatRate(p.conformancePct, p.decided, p.total), align: "end" }
62983
- ]);
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
+ });
62984
63001
  const out2 = [
62985
63002
  { kind: "heading", level: 2, text: t3.perPage, id: "pages" },
62986
63003
  {
@@ -67062,6 +67079,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
67062
67079
  ]);
67063
67080
  var KNOWN_FLAGS = /* @__PURE__ */ new Set([...VALUE_FLAGS2, ...BOOLEAN_FLAGS]);
67064
67081
  var LIST_FLAGS = /* @__PURE__ */ new Set(["include", "exclude", "ext"]);
67082
+ function looksLikeFlag(token) {
67083
+ return token?.startsWith("--") ?? false;
67084
+ }
67065
67085
  function parseArgs(argv) {
67066
67086
  const [command, ...rest] = argv;
67067
67087
  const valueFlags = valueFlagsFor(command ?? "");
@@ -67076,7 +67096,7 @@ function parseArgs(argv) {
67076
67096
  const inlineVal = eq === -1 ? void 0 : a.slice(eq + 1);
67077
67097
  let val;
67078
67098
  if (inlineVal !== void 0) val = inlineVal;
67079
- else if (valueFlags.has(key)) val = rest[++i2] ?? "";
67099
+ else if (valueFlags.has(key) && !looksLikeFlag(rest[i2 + 1])) val = rest[++i2] ?? "";
67080
67100
  else val = true;
67081
67101
  const prev = flags2[key];
67082
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.16.0
7
+ version: 5.18.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
@@ -7,21 +7,26 @@ content on hover (1.4.13), target size (2.5.8). The dynamic tier decides them by
7
7
 
8
8
  - **`--runtime local`** (recommended): resolves Playwright + `@axe-core/playwright`
9
9
  **at runtime**, from `--cwd` first and from ultra11y's own install second (no Docker, no
10
- global install). Both are dependencies of this package, so a project that installed ultra11y
11
- alone still gets the tier; a project that pins its own Playwright keeps it, because `--cwd`
12
- is tried first. Adds the residual-criteria **probes** (below).
10
+ global install). A project that pins its own Playwright keeps it, because `--cwd` is tried
11
+ first. Adds the residual-criteria **probes** (below).
13
12
  - **`--runtime docker`**: runs axe-core in a Docker image auto-built on first use (runner +
14
13
  Dockerfile embedded in the bundle). No host deps beyond Docker. Axe + 320px reflow only.
15
14
  - **`--runtime auto`** (default): local if Playwright resolves **and** a browser binary is on
16
- disk, else Docker, else an actionable error. Since the packages ship with ultra11y, what
17
- decides `auto` in practice is the browser, not the modules.
15
+ disk, else Docker, else an actionable error.
18
16
 
19
17
  ## Prerequisites
20
18
 
21
- - **local**: a Chromium browser — `npx playwright install chromium`. The two npm packages
22
- (`@playwright/test`, `@axe-core/playwright`) come with ultra11y and need no separate install;
23
- installing them in the audited project is still supported and takes precedence, which is what
24
- a repository with its own pinned Playwright wants.
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.
25
30
  - **docker**: Docker running.
26
31
 
27
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.16.0";
9
+ var VERSION = "5.18.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -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));
@@ -61016,7 +61029,7 @@ jobs:
61016
61029
  ultra11y:
61017
61030
  runs-on: ubuntu-latest
61018
61031
  steps:
61019
- - uses: actions/checkout@v4
61032
+ - uses: actions/checkout@v7
61020
61033
  with:
61021
61034
  fetch-depth: 0 # the diff gate needs the base ref
61022
61035
  - uses: maxgfr/ultra11y@v${VERSION}
@@ -62975,12 +62988,16 @@ function scoreboardBlocks(result, standard, lang, sheetHref) {
62975
62988
  const scope = pagesOf(result);
62976
62989
  if (!scope.length) return [];
62977
62990
  const derived = derivePages(result, scope);
62978
- const rows = derived.map((p) => [
62979
- { text: `${p.name}${p.auth ? " \u{1F512}" : ""}`, ...sheetHref ? { href: sheetHref(p.id) } : {} },
62980
- { text: p.url, mono: true },
62981
- { text: basisLabel(p.basis, lang) },
62982
- { text: formatRate(p.conformancePct, p.decided, p.total), align: "end" }
62983
- ]);
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
+ });
62984
63001
  const out2 = [
62985
63002
  { kind: "heading", level: 2, text: t3.perPage, id: "pages" },
62986
63003
  {
@@ -67062,6 +67079,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
67062
67079
  ]);
67063
67080
  var KNOWN_FLAGS = /* @__PURE__ */ new Set([...VALUE_FLAGS2, ...BOOLEAN_FLAGS]);
67064
67081
  var LIST_FLAGS = /* @__PURE__ */ new Set(["include", "exclude", "ext"]);
67082
+ function looksLikeFlag(token) {
67083
+ return token?.startsWith("--") ?? false;
67084
+ }
67065
67085
  function parseArgs(argv) {
67066
67086
  const [command, ...rest] = argv;
67067
67087
  const valueFlags = valueFlagsFor(command ?? "");
@@ -67076,7 +67096,7 @@ function parseArgs(argv) {
67076
67096
  const inlineVal = eq === -1 ? void 0 : a.slice(eq + 1);
67077
67097
  let val;
67078
67098
  if (inlineVal !== void 0) val = inlineVal;
67079
- else if (valueFlags.has(key)) val = rest[++i2] ?? "";
67099
+ else if (valueFlags.has(key) && !looksLikeFlag(rest[i2 + 1])) val = rest[++i2] ?? "";
67080
67100
  else val = true;
67081
67101
  const prev = flags2[key];
67082
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") {
@@ -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.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.16.0";
3
+ export const VERSION = "5.18.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 };