ultra11y 5.9.1 → 5.10.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.9.1",
13
+ "version": "5.10.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.9.1",
4
+ "version": "5.10.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.9.1",
4
+ "version": "5.10.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.9.1";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.10.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.9.1",
3
+ "version": "5.10.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.9.1";
9
+ var VERSION = "5.10.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -39652,6 +39652,21 @@ var SUBJECTS = {
39652
39652
  (e) => h(d, e, `<html lang="${attr(e, "lang") ?? ""}"> \u2014 is it present, valid, and the language of the content?`, `doclang|${attr(e, "lang") ?? ""}`)
39653
39653
  )
39654
39654
  ),
39655
+ // Frames, and the title each one carries. RGAA theme 2 is about nothing else: 2.1 asks
39656
+ // whether every frame HAS a title, 2.2 whether that title is pertinent. Both used to inherit
39657
+ // `aria` from WCAG 4.1.2 and be handed every ARIA attribute in the application — measured on
39658
+ // a real run, the adjudicator answered `undecidable` on both, correctly, because nothing it
39659
+ // had been shown could settle a question about `<iframe title>`.
39660
+ frames: (docs) => docs.flatMap(
39661
+ (d) => elementsByTag(d, "iframe", "frame").map(
39662
+ (e) => h(
39663
+ d,
39664
+ e,
39665
+ `<${e.tag}> title="${attr(e, "title") ?? ""}" name="${attr(e, "name") ?? ""}" src="${(attr(e, "src") ?? "").slice(0, 60)}" \u2014 un titre de cadre est-il pr\xE9sent, et d\xE9crit-il le contenu du cadre ?`,
39666
+ `frame|${attr(e, "title") ?? ""}|${attr(e, "src") ?? ""}`
39667
+ )
39668
+ )
39669
+ ),
39655
39670
  // The document's title, with the page it belongs to, so "is it pertinent?" is answerable.
39656
39671
  docTitle: (docs) => docs.flatMap(
39657
39672
  (d) => elementsByTag(d, "title").map((e) => h(d, e, `page="${pageOfDoc(d.file) ?? "source"}" <title>${t(e, 80)}</title>`, `doctitle|${t(e, 80)}`))
@@ -39880,6 +39895,9 @@ var PACK_SUBJECTS = {
39880
39895
  // Theme 6 — links.
39881
39896
  "6.1": ["links"],
39882
39897
  "6.2": ["links"],
39898
+ // Theme 2 — frames. Both criteria are about the frame and its title, and nothing else.
39899
+ "2.1": ["frames"],
39900
+ "2.2": ["frames"],
39883
39901
  // Theme 8 — the document itself. 8.1 maps onto no WCAG 2.2 criterion at all, so the pack
39884
39902
  // is the ONLY place its subject can be named.
39885
39903
  "8.1": ["doctype"],
@@ -39960,7 +39978,7 @@ var PACK_SUBJECTS = {
39960
39978
  "13.11": ["pointerHandlers"]
39961
39979
  }
39962
39980
  };
39963
- var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set(["images", "tables", "lists", "links", "controls", "autocomplete", "errors"]);
39981
+ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set(["images", "tables", "lists", "links", "controls", "autocomplete", "errors", "frames"]);
39964
39982
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
39965
39983
  function subjectsForPackCriterion(standard, id, scs) {
39966
39984
  const own = PACK_SUBJECTS[standard]?.[id];
@@ -58189,10 +58207,12 @@ function writeAdjudication(items, outDir, opts) {
58189
58207
  }
58190
58208
  return { todoPath, mdPath, verdictsPath, itemsDir, count: items.length };
58191
58209
  }
58210
+ var PREFILLED_CITATIONS = 3;
58192
58211
  function slimAdjudicationItems(items) {
58193
58212
  return items.map((it) => {
58194
58213
  const { evidence: _evidence, evidenceTruncated: _truncated, ...rest } = it;
58195
- return { ...rest, evidence: [] };
58214
+ const citations = it.evidence.slice(0, PREFILLED_CITATIONS).map((e) => ({ ...e }));
58215
+ return { ...rest, evidence: [], ...citations.length ? { citations } : {} };
58196
58216
  });
58197
58217
  }
58198
58218
  function hydrateAdjudication(adj, audit2, opts = {}) {
@@ -61397,6 +61417,9 @@ var S = {
61397
61417
  pagesClamped: (n) => `_Le d\xE9tail de ${n} page(s) a \xE9t\xE9 retir\xE9 de ce commentaire pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61398
61418
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61399
61419
  pageDefects: "D\xE9fauts",
61420
+ fullGrid: "Grille compl\xE8te \u2014 chaque crit\xE8re du r\xE9f\xE9rentiel, page par page",
61421
+ gridLegend: "`C` conforme \xB7 `NC` non conforme \xB7 `\u2014` non applicable \xB7 `?` \xE0 \xE9valuer",
61422
+ gridDropped: "_La grille compl\xE8te ne tient pas dans un commentaire GitHub (64 Kio) \u2014 elle est dans la fiche par page du livrable._",
61400
61423
  pageMoreDefects: (n) => `_\u2026 et ${n} autre(s) d\xE9faut(s) distinct(s) sur cette page \u2014 voir la fiche de page dans l'artefact._`,
61401
61424
  noCriterionForFindings: (n) => `${n} constat(s) sur cette page ne rendent aucun crit\xE8re du r\xE9f\xE9rentiel non conforme : leur r\xE8gle sort du p\xE9rim\xE8tre d'application de chacun. Ils comptent dans les colonnes ci-dessus, et sont d\xE9taill\xE9s dans l'artefact.`
61402
61425
  },
@@ -61444,6 +61467,9 @@ var S = {
61444
61467
  pagesClamped: (n) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit \u2014 the artifact carries them all._`,
61445
61468
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61446
61469
  pageDefects: "Defects",
61470
+ fullGrid: "Full grid \u2014 every criterion of the standard, page by page",
61471
+ gridLegend: "`C` conforming \xB7 `NC` non-conforming \xB7 `\u2014` not applicable \xB7 `?` to assess",
61472
+ gridDropped: "_The full grid does not fit in a GitHub comment (64 KiB) \u2014 it is in the deliverable's per-page sheet._",
61447
61473
  pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61448
61474
  noCriterionForFindings: (n) => `${n} finding(s) on this page make no criterion of the standard non-conforming: their rule falls outside every criterion's applicability. They are counted in the columns above, and detailed in the artifact.`
61449
61475
  }
@@ -61632,6 +61658,28 @@ function pageBlock(result, page, standard, lang, baseDir) {
61632
61658
  out2.push("", "</details>");
61633
61659
  return out2.join("\n");
61634
61660
  }
61661
+ function fullGridBlock(result, derived, standard, s, lang) {
61662
+ const { rows, status } = pageGridModel(result, derived, standard, lang);
61663
+ if (!rows.length || !derived.length) return [];
61664
+ const cell = (v) => v.replace(/\|/g, "\\|");
61665
+ const head = [s.criterion, ...derived.map((p) => `${cell(p.name)}${p.auth ? " \u{1F512}" : ""}`)];
61666
+ const out2 = [
61667
+ "<details>",
61668
+ `<summary><b>${s.fullGrid}</b> \u2014 ${rows.length} \xD7 ${derived.length}</summary>`,
61669
+ // GFM only renders Markdown inside <details> after a blank line.
61670
+ "",
61671
+ `> ${s.gridLegend}`,
61672
+ "",
61673
+ `| ${head.join(" | ")} |`,
61674
+ `| ${head.map(() => "---").join(" | ")} |`
61675
+ ];
61676
+ for (const row of rows) {
61677
+ out2.push(`| ${cell(row.label)} | ${derived.map((p) => GRID_MARK[status.get(row.id)?.get(p.id) ?? "manual"]).join(" | ")} |`);
61678
+ }
61679
+ out2.push("", "</details>");
61680
+ return out2;
61681
+ }
61682
+ var GRID_MARK = { C: "C", NC: "NC", NA: "\u2014", manual: "?" };
61635
61683
  function pagesComment(result, opts = {}) {
61636
61684
  const standard = opts.standard ?? CORE2;
61637
61685
  const lang = opts.lang ?? "en";
@@ -61660,11 +61708,13 @@ function pagesComment(result, opts = {}) {
61660
61708
  const blocks = [...derived].sort(
61661
61709
  (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
61662
61710
  ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61663
- const assemble = (nBlocks2, nRows2) => {
61711
+ const assemble = (nBlocks2, nRows2, withGrid = true) => {
61664
61712
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61665
61713
  if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
61666
61714
  body3.push(...basisCaveats(result, derived, s, lang));
61667
61715
  if (redirected.length) body3.push(...renderRedirected(redirected, lang), "");
61716
+ if (withGrid) body3.push(...fullGridBlock(result, derived, standard, s, lang), "");
61717
+ else body3.push(s.gridDropped, "");
61668
61718
  if (blocks.length) {
61669
61719
  body3.push(`> ${s.pagesDetailNote}`, "");
61670
61720
  body3.push(...blocks.slice(0, nBlocks2).flatMap((b) => [b, ""]));
@@ -61674,9 +61724,10 @@ function pagesComment(result, opts = {}) {
61674
61724
  };
61675
61725
  let nBlocks = blocks.length;
61676
61726
  let nRows = derived.length;
61677
- while (nBlocks > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nBlocks--;
61678
- while (nRows > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nRows--;
61679
- return assemble(nBlocks, nRows);
61727
+ if (assemble(nBlocks, nRows).length <= COMMENT_LIMIT) return assemble(nBlocks, nRows);
61728
+ while (nBlocks > 0 && assemble(nBlocks, nRows, false).length > COMMENT_LIMIT) nBlocks--;
61729
+ while (nRows > 0 && assemble(nBlocks, nRows, false).length > COMMENT_LIMIT) nRows--;
61730
+ return assemble(nBlocks, nRows, false);
61680
61731
  }
61681
61732
 
61682
61733
  // src/evidence.ts
@@ -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.9.1
8
+ version: 5.10.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.9.1";
9
+ var VERSION = "5.10.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -39652,6 +39652,21 @@ var SUBJECTS = {
39652
39652
  (e) => h(d, e, `<html lang="${attr(e, "lang") ?? ""}"> \u2014 is it present, valid, and the language of the content?`, `doclang|${attr(e, "lang") ?? ""}`)
39653
39653
  )
39654
39654
  ),
39655
+ // Frames, and the title each one carries. RGAA theme 2 is about nothing else: 2.1 asks
39656
+ // whether every frame HAS a title, 2.2 whether that title is pertinent. Both used to inherit
39657
+ // `aria` from WCAG 4.1.2 and be handed every ARIA attribute in the application — measured on
39658
+ // a real run, the adjudicator answered `undecidable` on both, correctly, because nothing it
39659
+ // had been shown could settle a question about `<iframe title>`.
39660
+ frames: (docs) => docs.flatMap(
39661
+ (d) => elementsByTag(d, "iframe", "frame").map(
39662
+ (e) => h(
39663
+ d,
39664
+ e,
39665
+ `<${e.tag}> title="${attr(e, "title") ?? ""}" name="${attr(e, "name") ?? ""}" src="${(attr(e, "src") ?? "").slice(0, 60)}" \u2014 un titre de cadre est-il pr\xE9sent, et d\xE9crit-il le contenu du cadre ?`,
39666
+ `frame|${attr(e, "title") ?? ""}|${attr(e, "src") ?? ""}`
39667
+ )
39668
+ )
39669
+ ),
39655
39670
  // The document's title, with the page it belongs to, so "is it pertinent?" is answerable.
39656
39671
  docTitle: (docs) => docs.flatMap(
39657
39672
  (d) => elementsByTag(d, "title").map((e) => h(d, e, `page="${pageOfDoc(d.file) ?? "source"}" <title>${t(e, 80)}</title>`, `doctitle|${t(e, 80)}`))
@@ -39880,6 +39895,9 @@ var PACK_SUBJECTS = {
39880
39895
  // Theme 6 — links.
39881
39896
  "6.1": ["links"],
39882
39897
  "6.2": ["links"],
39898
+ // Theme 2 — frames. Both criteria are about the frame and its title, and nothing else.
39899
+ "2.1": ["frames"],
39900
+ "2.2": ["frames"],
39883
39901
  // Theme 8 — the document itself. 8.1 maps onto no WCAG 2.2 criterion at all, so the pack
39884
39902
  // is the ONLY place its subject can be named.
39885
39903
  "8.1": ["doctype"],
@@ -39960,7 +39978,7 @@ var PACK_SUBJECTS = {
39960
39978
  "13.11": ["pointerHandlers"]
39961
39979
  }
39962
39980
  };
39963
- var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set(["images", "tables", "lists", "links", "controls", "autocomplete", "errors"]);
39981
+ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set(["images", "tables", "lists", "links", "controls", "autocomplete", "errors", "frames"]);
39964
39982
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
39965
39983
  function subjectsForPackCriterion(standard, id, scs) {
39966
39984
  const own = PACK_SUBJECTS[standard]?.[id];
@@ -58189,10 +58207,12 @@ function writeAdjudication(items, outDir, opts) {
58189
58207
  }
58190
58208
  return { todoPath, mdPath, verdictsPath, itemsDir, count: items.length };
58191
58209
  }
58210
+ var PREFILLED_CITATIONS = 3;
58192
58211
  function slimAdjudicationItems(items) {
58193
58212
  return items.map((it) => {
58194
58213
  const { evidence: _evidence, evidenceTruncated: _truncated, ...rest } = it;
58195
- return { ...rest, evidence: [] };
58214
+ const citations = it.evidence.slice(0, PREFILLED_CITATIONS).map((e) => ({ ...e }));
58215
+ return { ...rest, evidence: [], ...citations.length ? { citations } : {} };
58196
58216
  });
58197
58217
  }
58198
58218
  function hydrateAdjudication(adj, audit2, opts = {}) {
@@ -61397,6 +61417,9 @@ var S = {
61397
61417
  pagesClamped: (n) => `_Le d\xE9tail de ${n} page(s) a \xE9t\xE9 retir\xE9 de ce commentaire pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61398
61418
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61399
61419
  pageDefects: "D\xE9fauts",
61420
+ fullGrid: "Grille compl\xE8te \u2014 chaque crit\xE8re du r\xE9f\xE9rentiel, page par page",
61421
+ gridLegend: "`C` conforme \xB7 `NC` non conforme \xB7 `\u2014` non applicable \xB7 `?` \xE0 \xE9valuer",
61422
+ gridDropped: "_La grille compl\xE8te ne tient pas dans un commentaire GitHub (64 Kio) \u2014 elle est dans la fiche par page du livrable._",
61400
61423
  pageMoreDefects: (n) => `_\u2026 et ${n} autre(s) d\xE9faut(s) distinct(s) sur cette page \u2014 voir la fiche de page dans l'artefact._`,
61401
61424
  noCriterionForFindings: (n) => `${n} constat(s) sur cette page ne rendent aucun crit\xE8re du r\xE9f\xE9rentiel non conforme : leur r\xE8gle sort du p\xE9rim\xE8tre d'application de chacun. Ils comptent dans les colonnes ci-dessus, et sont d\xE9taill\xE9s dans l'artefact.`
61402
61425
  },
@@ -61444,6 +61467,9 @@ var S = {
61444
61467
  pagesClamped: (n) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit \u2014 the artifact carries them all._`,
61445
61468
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61446
61469
  pageDefects: "Defects",
61470
+ fullGrid: "Full grid \u2014 every criterion of the standard, page by page",
61471
+ gridLegend: "`C` conforming \xB7 `NC` non-conforming \xB7 `\u2014` not applicable \xB7 `?` to assess",
61472
+ gridDropped: "_The full grid does not fit in a GitHub comment (64 KiB) \u2014 it is in the deliverable's per-page sheet._",
61447
61473
  pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61448
61474
  noCriterionForFindings: (n) => `${n} finding(s) on this page make no criterion of the standard non-conforming: their rule falls outside every criterion's applicability. They are counted in the columns above, and detailed in the artifact.`
61449
61475
  }
@@ -61632,6 +61658,28 @@ function pageBlock(result, page, standard, lang, baseDir) {
61632
61658
  out2.push("", "</details>");
61633
61659
  return out2.join("\n");
61634
61660
  }
61661
+ function fullGridBlock(result, derived, standard, s, lang) {
61662
+ const { rows, status } = pageGridModel(result, derived, standard, lang);
61663
+ if (!rows.length || !derived.length) return [];
61664
+ const cell = (v) => v.replace(/\|/g, "\\|");
61665
+ const head = [s.criterion, ...derived.map((p) => `${cell(p.name)}${p.auth ? " \u{1F512}" : ""}`)];
61666
+ const out2 = [
61667
+ "<details>",
61668
+ `<summary><b>${s.fullGrid}</b> \u2014 ${rows.length} \xD7 ${derived.length}</summary>`,
61669
+ // GFM only renders Markdown inside <details> after a blank line.
61670
+ "",
61671
+ `> ${s.gridLegend}`,
61672
+ "",
61673
+ `| ${head.join(" | ")} |`,
61674
+ `| ${head.map(() => "---").join(" | ")} |`
61675
+ ];
61676
+ for (const row of rows) {
61677
+ out2.push(`| ${cell(row.label)} | ${derived.map((p) => GRID_MARK[status.get(row.id)?.get(p.id) ?? "manual"]).join(" | ")} |`);
61678
+ }
61679
+ out2.push("", "</details>");
61680
+ return out2;
61681
+ }
61682
+ var GRID_MARK = { C: "C", NC: "NC", NA: "\u2014", manual: "?" };
61635
61683
  function pagesComment(result, opts = {}) {
61636
61684
  const standard = opts.standard ?? CORE2;
61637
61685
  const lang = opts.lang ?? "en";
@@ -61660,11 +61708,13 @@ function pagesComment(result, opts = {}) {
61660
61708
  const blocks = [...derived].sort(
61661
61709
  (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
61662
61710
  ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61663
- const assemble = (nBlocks2, nRows2) => {
61711
+ const assemble = (nBlocks2, nRows2, withGrid = true) => {
61664
61712
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61665
61713
  if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
61666
61714
  body3.push(...basisCaveats(result, derived, s, lang));
61667
61715
  if (redirected.length) body3.push(...renderRedirected(redirected, lang), "");
61716
+ if (withGrid) body3.push(...fullGridBlock(result, derived, standard, s, lang), "");
61717
+ else body3.push(s.gridDropped, "");
61668
61718
  if (blocks.length) {
61669
61719
  body3.push(`> ${s.pagesDetailNote}`, "");
61670
61720
  body3.push(...blocks.slice(0, nBlocks2).flatMap((b) => [b, ""]));
@@ -61674,9 +61724,10 @@ function pagesComment(result, opts = {}) {
61674
61724
  };
61675
61725
  let nBlocks = blocks.length;
61676
61726
  let nRows = derived.length;
61677
- while (nBlocks > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nBlocks--;
61678
- while (nRows > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nRows--;
61679
- return assemble(nBlocks, nRows);
61727
+ if (assemble(nBlocks, nRows).length <= COMMENT_LIMIT) return assemble(nBlocks, nRows);
61728
+ while (nBlocks > 0 && assemble(nBlocks, nRows, false).length > COMMENT_LIMIT) nBlocks--;
61729
+ while (nRows > 0 && assemble(nBlocks, nRows, false).length > COMMENT_LIMIT) nRows--;
61730
+ return assemble(nBlocks, nRows, false);
61680
61731
  }
61681
61732
 
61682
61733
  // src/evidence.ts
@@ -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.9.1
7
+ version: 5.10.0
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -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.9.1";
9
+ var VERSION = "5.10.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -39652,6 +39652,21 @@ var SUBJECTS = {
39652
39652
  (e) => h(d, e, `<html lang="${attr(e, "lang") ?? ""}"> \u2014 is it present, valid, and the language of the content?`, `doclang|${attr(e, "lang") ?? ""}`)
39653
39653
  )
39654
39654
  ),
39655
+ // Frames, and the title each one carries. RGAA theme 2 is about nothing else: 2.1 asks
39656
+ // whether every frame HAS a title, 2.2 whether that title is pertinent. Both used to inherit
39657
+ // `aria` from WCAG 4.1.2 and be handed every ARIA attribute in the application — measured on
39658
+ // a real run, the adjudicator answered `undecidable` on both, correctly, because nothing it
39659
+ // had been shown could settle a question about `<iframe title>`.
39660
+ frames: (docs) => docs.flatMap(
39661
+ (d) => elementsByTag(d, "iframe", "frame").map(
39662
+ (e) => h(
39663
+ d,
39664
+ e,
39665
+ `<${e.tag}> title="${attr(e, "title") ?? ""}" name="${attr(e, "name") ?? ""}" src="${(attr(e, "src") ?? "").slice(0, 60)}" \u2014 un titre de cadre est-il pr\xE9sent, et d\xE9crit-il le contenu du cadre ?`,
39666
+ `frame|${attr(e, "title") ?? ""}|${attr(e, "src") ?? ""}`
39667
+ )
39668
+ )
39669
+ ),
39655
39670
  // The document's title, with the page it belongs to, so "is it pertinent?" is answerable.
39656
39671
  docTitle: (docs) => docs.flatMap(
39657
39672
  (d) => elementsByTag(d, "title").map((e) => h(d, e, `page="${pageOfDoc(d.file) ?? "source"}" <title>${t(e, 80)}</title>`, `doctitle|${t(e, 80)}`))
@@ -39880,6 +39895,9 @@ var PACK_SUBJECTS = {
39880
39895
  // Theme 6 — links.
39881
39896
  "6.1": ["links"],
39882
39897
  "6.2": ["links"],
39898
+ // Theme 2 — frames. Both criteria are about the frame and its title, and nothing else.
39899
+ "2.1": ["frames"],
39900
+ "2.2": ["frames"],
39883
39901
  // Theme 8 — the document itself. 8.1 maps onto no WCAG 2.2 criterion at all, so the pack
39884
39902
  // is the ONLY place its subject can be named.
39885
39903
  "8.1": ["doctype"],
@@ -39960,7 +39978,7 @@ var PACK_SUBJECTS = {
39960
39978
  "13.11": ["pointerHandlers"]
39961
39979
  }
39962
39980
  };
39963
- var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set(["images", "tables", "lists", "links", "controls", "autocomplete", "errors"]);
39981
+ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set(["images", "tables", "lists", "links", "controls", "autocomplete", "errors", "frames"]);
39964
39982
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
39965
39983
  function subjectsForPackCriterion(standard, id, scs) {
39966
39984
  const own = PACK_SUBJECTS[standard]?.[id];
@@ -58189,10 +58207,12 @@ function writeAdjudication(items, outDir, opts) {
58189
58207
  }
58190
58208
  return { todoPath, mdPath, verdictsPath, itemsDir, count: items.length };
58191
58209
  }
58210
+ var PREFILLED_CITATIONS = 3;
58192
58211
  function slimAdjudicationItems(items) {
58193
58212
  return items.map((it) => {
58194
58213
  const { evidence: _evidence, evidenceTruncated: _truncated, ...rest } = it;
58195
- return { ...rest, evidence: [] };
58214
+ const citations = it.evidence.slice(0, PREFILLED_CITATIONS).map((e) => ({ ...e }));
58215
+ return { ...rest, evidence: [], ...citations.length ? { citations } : {} };
58196
58216
  });
58197
58217
  }
58198
58218
  function hydrateAdjudication(adj, audit2, opts = {}) {
@@ -61397,6 +61417,9 @@ var S = {
61397
61417
  pagesClamped: (n) => `_Le d\xE9tail de ${n} page(s) a \xE9t\xE9 retir\xE9 de ce commentaire pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61398
61418
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61399
61419
  pageDefects: "D\xE9fauts",
61420
+ fullGrid: "Grille compl\xE8te \u2014 chaque crit\xE8re du r\xE9f\xE9rentiel, page par page",
61421
+ gridLegend: "`C` conforme \xB7 `NC` non conforme \xB7 `\u2014` non applicable \xB7 `?` \xE0 \xE9valuer",
61422
+ gridDropped: "_La grille compl\xE8te ne tient pas dans un commentaire GitHub (64 Kio) \u2014 elle est dans la fiche par page du livrable._",
61400
61423
  pageMoreDefects: (n) => `_\u2026 et ${n} autre(s) d\xE9faut(s) distinct(s) sur cette page \u2014 voir la fiche de page dans l'artefact._`,
61401
61424
  noCriterionForFindings: (n) => `${n} constat(s) sur cette page ne rendent aucun crit\xE8re du r\xE9f\xE9rentiel non conforme : leur r\xE8gle sort du p\xE9rim\xE8tre d'application de chacun. Ils comptent dans les colonnes ci-dessus, et sont d\xE9taill\xE9s dans l'artefact.`
61402
61425
  },
@@ -61444,6 +61467,9 @@ var S = {
61444
61467
  pagesClamped: (n) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit \u2014 the artifact carries them all._`,
61445
61468
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61446
61469
  pageDefects: "Defects",
61470
+ fullGrid: "Full grid \u2014 every criterion of the standard, page by page",
61471
+ gridLegend: "`C` conforming \xB7 `NC` non-conforming \xB7 `\u2014` not applicable \xB7 `?` to assess",
61472
+ gridDropped: "_The full grid does not fit in a GitHub comment (64 KiB) \u2014 it is in the deliverable's per-page sheet._",
61447
61473
  pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61448
61474
  noCriterionForFindings: (n) => `${n} finding(s) on this page make no criterion of the standard non-conforming: their rule falls outside every criterion's applicability. They are counted in the columns above, and detailed in the artifact.`
61449
61475
  }
@@ -61632,6 +61658,28 @@ function pageBlock(result, page, standard, lang, baseDir) {
61632
61658
  out2.push("", "</details>");
61633
61659
  return out2.join("\n");
61634
61660
  }
61661
+ function fullGridBlock(result, derived, standard, s, lang) {
61662
+ const { rows, status } = pageGridModel(result, derived, standard, lang);
61663
+ if (!rows.length || !derived.length) return [];
61664
+ const cell = (v) => v.replace(/\|/g, "\\|");
61665
+ const head = [s.criterion, ...derived.map((p) => `${cell(p.name)}${p.auth ? " \u{1F512}" : ""}`)];
61666
+ const out2 = [
61667
+ "<details>",
61668
+ `<summary><b>${s.fullGrid}</b> \u2014 ${rows.length} \xD7 ${derived.length}</summary>`,
61669
+ // GFM only renders Markdown inside <details> after a blank line.
61670
+ "",
61671
+ `> ${s.gridLegend}`,
61672
+ "",
61673
+ `| ${head.join(" | ")} |`,
61674
+ `| ${head.map(() => "---").join(" | ")} |`
61675
+ ];
61676
+ for (const row of rows) {
61677
+ out2.push(`| ${cell(row.label)} | ${derived.map((p) => GRID_MARK[status.get(row.id)?.get(p.id) ?? "manual"]).join(" | ")} |`);
61678
+ }
61679
+ out2.push("", "</details>");
61680
+ return out2;
61681
+ }
61682
+ var GRID_MARK = { C: "C", NC: "NC", NA: "\u2014", manual: "?" };
61635
61683
  function pagesComment(result, opts = {}) {
61636
61684
  const standard = opts.standard ?? CORE2;
61637
61685
  const lang = opts.lang ?? "en";
@@ -61660,11 +61708,13 @@ function pagesComment(result, opts = {}) {
61660
61708
  const blocks = [...derived].sort(
61661
61709
  (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
61662
61710
  ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61663
- const assemble = (nBlocks2, nRows2) => {
61711
+ const assemble = (nBlocks2, nRows2, withGrid = true) => {
61664
61712
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61665
61713
  if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
61666
61714
  body3.push(...basisCaveats(result, derived, s, lang));
61667
61715
  if (redirected.length) body3.push(...renderRedirected(redirected, lang), "");
61716
+ if (withGrid) body3.push(...fullGridBlock(result, derived, standard, s, lang), "");
61717
+ else body3.push(s.gridDropped, "");
61668
61718
  if (blocks.length) {
61669
61719
  body3.push(`> ${s.pagesDetailNote}`, "");
61670
61720
  body3.push(...blocks.slice(0, nBlocks2).flatMap((b) => [b, ""]));
@@ -61674,9 +61724,10 @@ function pagesComment(result, opts = {}) {
61674
61724
  };
61675
61725
  let nBlocks = blocks.length;
61676
61726
  let nRows = derived.length;
61677
- while (nBlocks > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nBlocks--;
61678
- while (nRows > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nRows--;
61679
- return assemble(nBlocks, nRows);
61727
+ if (assemble(nBlocks, nRows).length <= COMMENT_LIMIT) return assemble(nBlocks, nRows);
61728
+ while (nBlocks > 0 && assemble(nBlocks, nRows, false).length > COMMENT_LIMIT) nBlocks--;
61729
+ while (nRows > 0 && assemble(nBlocks, nRows, false).length > COMMENT_LIMIT) nRows--;
61730
+ return assemble(nBlocks, nRows, false);
61680
61731
  }
61681
61732
 
61682
61733
  // src/evidence.ts
@@ -496,6 +496,23 @@ export const SUBJECTS: Record<string, Subject> = {
496
496
  ),
497
497
  ),
498
498
 
499
+ // Frames, and the title each one carries. RGAA theme 2 is about nothing else: 2.1 asks
500
+ // whether every frame HAS a title, 2.2 whether that title is pertinent. Both used to inherit
501
+ // `aria` from WCAG 4.1.2 and be handed every ARIA attribute in the application — measured on
502
+ // a real run, the adjudicator answered `undecidable` on both, correctly, because nothing it
503
+ // had been shown could settle a question about `<iframe title>`.
504
+ frames: (docs) =>
505
+ docs.flatMap((d) =>
506
+ elementsByTag(d, "iframe", "frame").map((e) =>
507
+ h(
508
+ d,
509
+ e,
510
+ `<${e.tag}> title="${attr(e, "title") ?? ""}" name="${attr(e, "name") ?? ""}" src="${(attr(e, "src") ?? "").slice(0, 60)}" — un titre de cadre est-il présent, et décrit-il le contenu du cadre ?`,
511
+ `frame|${attr(e, "title") ?? ""}|${attr(e, "src") ?? ""}`,
512
+ ),
513
+ ),
514
+ ),
515
+
499
516
  // The document's title, with the page it belongs to, so "is it pertinent?" is answerable.
500
517
  docTitle: (docs) =>
501
518
  docs.flatMap((d) =>
@@ -790,6 +807,9 @@ export const PACK_SUBJECTS: Record<string, Record<string, string[]>> = {
790
807
  // Theme 6 — links.
791
808
  "6.1": ["links"],
792
809
  "6.2": ["links"],
810
+ // Theme 2 — frames. Both criteria are about the frame and its title, and nothing else.
811
+ "2.1": ["frames"],
812
+ "2.2": ["frames"],
793
813
  // Theme 8 — the document itself. 8.1 maps onto no WCAG 2.2 criterion at all, so the pack
794
814
  // is the ONLY place its subject can be named.
795
815
  "8.1": ["doctype"],
@@ -907,7 +927,7 @@ export const PACK_SUBJECTS: Record<string, Record<string, string[]>> = {
907
927
  // hand-written applicability predicates in src/audit.ts, which are more careful than any
908
928
  // subject here and which win outright where they exist. The pack criteria that map onto them
909
929
  // inherit the NA through the derivation, so nothing is lost by leaving them out.
910
- export const EXISTENCE_SUBJECTS: ReadonlySet<string> = new Set(["images", "tables", "lists", "links", "controls", "autocomplete", "errors"]);
930
+ export const EXISTENCE_SUBJECTS: ReadonlySet<string> = new Set(["images", "tables", "lists", "links", "controls", "autocomplete", "errors", "frames"]);
911
931
 
912
932
  /** The subjects that decide a success criterion. Empty ⇒ the criterion has none declared,
913
933
  * which `tests/harvest-coverage.test.ts` refuses for any criterion the engine hands over. */
package/src/adjudicate.ts CHANGED
@@ -1446,12 +1446,33 @@ export function writeAdjudication(
1446
1446
  * bulk it only has to READ. Every field the fold requires — verdict, justification, reason,
1447
1447
  * findings, citations, recommendations — is kept, so a filled slim file carries a complete
1448
1448
  * decision. The evidence comes back at fold time via `hydrateAdjudication`. */
1449
+ /** Representatives pre-cited in the skeleton. Three is enough to show the shape and to stand
1450
+ * for the population; the whole set is in the brief, and `occurrences` travels with each. */
1451
+ const PREFILLED_CITATIONS = 3;
1452
+
1449
1453
  export function slimAdjudicationItems(items: AdjudicationItem[]): AdjudicationItem[] {
1450
1454
  return items.map((it) => {
1451
1455
  const { evidence: _evidence, evidenceTruncated: _truncated, ...rest } = it;
1452
- // `population` and `evidenceComplete` are kept: they are two numbers, not bulk, and they
1453
- // tell the adjudicator how much of the subject it is actually looking at.
1454
- return { ...rest, evidence: [] } as AdjudicationItem;
1456
+ // THE CITATIONS ARRIVE FILLED IN.
1457
+ //
1458
+ // This file is the only one a CI adjudicator writes, and it used to arrive with no evidence
1459
+ // and no citations: clearing a criterion meant authoring
1460
+ // `citations: [{file, line, selector, snippet}]` from scratch, by hand, cross-referencing a
1461
+ // separate brief, once per criterion. Measured on a real run: thirty criteria came back `C`
1462
+ // with a considered justification and no citations at all, and the fold refused every one
1463
+ // — « a C verdict must cite at least one of the N evidence item(s) it was shown ». The
1464
+ // agent had read the evidence. It had simply not copied an anchor into a second file.
1465
+ //
1466
+ // Pre-filling decides nothing. The verdict and the justification are still the agent's, and
1467
+ // a `C` still has to survive grounding, the coverage check and the complete-evidence rule.
1468
+ // What it removes is a transcription step that was costing correct verdicts — the same
1469
+ // failure, one level up, as an adjudicator retyping a snippet instead of copying it.
1470
+ //
1471
+ // `population` and `evidenceComplete` are kept for the same reason they always were: they
1472
+ // are two numbers, not bulk, and they tell the adjudicator how much of the subject it is
1473
+ // actually looking at.
1474
+ const citations = it.evidence.slice(0, PREFILLED_CITATIONS).map((e) => ({ ...e }));
1475
+ return { ...rest, evidence: [], ...(citations.length ? { citations } : {}) } as AdjudicationItem;
1455
1476
  });
1456
1477
  }
1457
1478
 
package/src/annotate.ts CHANGED
@@ -23,7 +23,7 @@ import { resolveMessage, resolveRemediation } from "./messages.js";
23
23
  import { findingsForStandard, packCriteriaForFinding } from "./standards/derive.js";
24
24
  import { CORE, type StandardId, isCore, loadPack } from "./standards/index.js";
25
25
  import { packReportGroups, reportCoverage, reportGroups } from "./report.js";
26
- import type { AuditResult, Finding, Lang, PageResult, Severity } from "./types.js";
26
+ import type { AuditResult, Finding, Lang, PageResult, Severity, Status } from "./types.js";
27
27
  import { isUrlPath, repoRelative } from "./util.js";
28
28
  import {
29
29
  agentMarkNote,
@@ -32,6 +32,7 @@ import {
32
32
  derivePages,
33
33
  formatRate,
34
34
  pageBasisWarning,
35
+ pageGridModel,
35
36
  pagesOf,
36
37
  renderRedirected,
37
38
  unattributedFindings,
@@ -142,6 +143,9 @@ const S = {
142
143
  `_Le détail de ${n} page(s) a été retiré de ce commentaire pour tenir dans la limite de GitHub — l'artefact les porte toutes._`,
143
144
  scoreboardClamped: (n: number) => `_${n} page(s) retirée(s) du tableau pour tenir dans la limite de GitHub — l'artefact les porte toutes._`,
144
145
  pageDefects: "Défauts",
146
+ fullGrid: "Grille complète — chaque critère du référentiel, page par page",
147
+ gridLegend: "`C` conforme · `NC` non conforme · `—` non applicable · `?` à évaluer",
148
+ gridDropped: "_La grille complète ne tient pas dans un commentaire GitHub (64 Kio) — elle est dans la fiche par page du livrable._",
145
149
  pageMoreDefects: (n: number) => `_… et ${n} autre(s) défaut(s) distinct(s) sur cette page — voir la fiche de page dans l'artefact._`,
146
150
  noCriterionForFindings: (n: number) =>
147
151
  `${n} constat(s) sur cette page ne rendent aucun critère du référentiel non conforme : leur règle sort du périmètre d'application de chacun. Ils comptent dans les colonnes ci-dessus, et sont détaillés dans l'artefact.`,
@@ -196,6 +200,9 @@ const S = {
196
200
  pagesClamped: (n: number) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit — the artifact carries them all._`,
197
201
  scoreboardClamped: (n: number) => `_${n} page(s) dropped from the table to fit GitHub's limit — the artifact carries them all._`,
198
202
  pageDefects: "Defects",
203
+ fullGrid: "Full grid — every criterion of the standard, page by page",
204
+ gridLegend: "`C` conforming · `NC` non-conforming · `—` not applicable · `?` to assess",
205
+ gridDropped: "_The full grid does not fit in a GitHub comment (64 KiB) — it is in the deliverable's per-page sheet._",
199
206
  pageMoreDefects: (n: number) => `_… and ${n} more distinct defect(s) on this page — see its sheet in the artifact._`,
200
207
  noCriterionForFindings: (n: number) =>
201
208
  `${n} finding(s) on this page make no criterion of the standard non-conforming: their rule falls outside every criterion's applicability. They are counted in the columns above, and detailed in the artifact.`,
@@ -525,6 +532,41 @@ function pageBlock(result: AuditResult, page: PageResult, standard: StandardId,
525
532
  return out.join("\n");
526
533
  }
527
534
 
535
+ /** The whole criterion × page grid, collapsed — every criterion of the standard, and where
536
+ * each page stands on it.
537
+ *
538
+ * The scoreboard says HOW MANY criteria a page conforms to and the per-page blocks say which
539
+ * ones it FAILS; between the two, the criteria a page CONFORMS to were never named. Under a
540
+ * per-page norm that is most of the deliverable, and a reviewer asking « which of the 106 does
541
+ * this page pass? » had to download the artifact to find out.
542
+ *
543
+ * Drawn from `pageGridModel`, the same projection the artifact and the HTML use. A surface
544
+ * that recomputes a status is a surface that will eventually disagree with the report. */
545
+ function fullGridBlock(result: AuditResult, derived: PageResult[], standard: StandardId, s: (typeof S)[Lang], lang: Lang): string[] {
546
+ const { rows, status } = pageGridModel(result, derived, standard, lang);
547
+ if (!rows.length || !derived.length) return [];
548
+ const cell = (v: string): string => v.replace(/\|/g, "\\|");
549
+ const head = [s.criterion, ...derived.map((p) => `${cell(p.name)}${p.auth ? " 🔒" : ""}`)];
550
+ const out: string[] = [
551
+ "<details>",
552
+ `<summary><b>${s.fullGrid}</b> — ${rows.length} × ${derived.length}</summary>`,
553
+ // GFM only renders Markdown inside <details> after a blank line.
554
+ "",
555
+ `> ${s.gridLegend}`,
556
+ "",
557
+ `| ${head.join(" | ")} |`,
558
+ `| ${head.map(() => "---").join(" | ")} |`,
559
+ ];
560
+ for (const row of rows) {
561
+ out.push(`| ${cell(row.label)} | ${derived.map((p) => GRID_MARK[status.get(row.id)?.get(p.id) ?? "manual"]).join(" | ")} |`);
562
+ }
563
+ out.push("", "</details>");
564
+ return out;
565
+ }
566
+
567
+ /** The marks the grid draws. Same vocabulary as the per-page sheet's. */
568
+ const GRID_MARK: Record<Status, string> = { C: "C", NC: "NC", NA: "—", manual: "?" };
569
+
528
570
  /** The PAGE-BY-PAGE pull-request comment.
529
571
  *
530
572
  * A second document under a second marker, for a second tier. The digest above answers
@@ -584,11 +626,16 @@ export function pagesComment(result: AuditResult, opts: AnnotateOptions & { runU
584
626
  // document at a byte offset lands mid-row and GFM then renders a broken table. The detail
585
627
  // goes first because the scoreboard is the half that cannot be reconstructed from the
586
628
  // artifact link alone.
587
- const assemble = (nBlocks: number, nRows: number): string => {
629
+ const assemble = (nBlocks: number, nRows: number, withGrid = true): string => {
588
630
  const body: string[] = [...scoreboardTable(result, derived.slice(0, nRows), standard, s, lang), ""];
589
631
  if (nRows < derived.length) body.push(s.scoreboardClamped(derived.length - nRows), "");
590
632
  body.push(...basisCaveats(result, derived, s, lang));
591
633
  if (redirected.length) body.push(...renderRedirected(redirected, lang), "");
634
+ // The full grid, folded away so the scoreboard stays what a reviewer reads first. It is
635
+ // the biggest thing in the document — 106 criteria × 35 pages — so it is also the first
636
+ // thing the size clamp gives up, WHOLE and with a line saying where to find it.
637
+ if (withGrid) body.push(...fullGridBlock(result, derived, standard, s, lang), "");
638
+ else body.push(s.gridDropped, "");
592
639
  if (blocks.length) {
593
640
  body.push(`> ${s.pagesDetailNote}`, "");
594
641
  body.push(...blocks.slice(0, nBlocks).flatMap((b) => [b, ""]));
@@ -599,7 +646,10 @@ export function pagesComment(result: AuditResult, opts: AnnotateOptions & { runU
599
646
 
600
647
  let nBlocks = blocks.length;
601
648
  let nRows = derived.length;
602
- while (nBlocks > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nBlocks--;
603
- while (nRows > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nRows--;
604
- return assemble(nBlocks, nRows);
649
+ // The grid goes first when it does not fit: it is the one part that is reproducible in full
650
+ // from the artifact, while the scoreboard and the defect blocks are not.
651
+ if (assemble(nBlocks, nRows).length <= COMMENT_LIMIT) return assemble(nBlocks, nRows);
652
+ while (nBlocks > 0 && assemble(nBlocks, nRows, false).length > COMMENT_LIMIT) nBlocks--;
653
+ while (nRows > 0 && assemble(nBlocks, nRows, false).length > COMMENT_LIMIT) nRows--;
654
+ return assemble(nBlocks, nRows, false);
605
655
  }
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.9.1";
3
+ export const VERSION = "5.10.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,