ultra11y 5.26.0 → 5.27.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.26.0",
13
+ "version": "5.27.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.26.0",
4
+ "version": "5.27.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.26.0",
4
+ "version": "5.27.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.26.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.27.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.26.0",
3
+ "version": "5.27.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.26.0";
9
+ var VERSION = "5.27.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -45061,14 +45061,42 @@ var SUBJECTS = {
45061
45061
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45062
45062
  )
45063
45063
  ]),
45064
- // A change of context triggered by focus or by changing a value.
45065
- // RGAA 7.4 / WCAG 3.2.1-3.2.2. The handlers are half the subject; the OTHER half is what a
45066
- // script does once it runs, and that is where a context change actually happens: submitting a
45067
- // form, navigating, opening a window. Looking only for onFocus/onBlur/onChange left the
45068
- // criterion with an empty harvest on any codebase that binds its handlers in JavaScript
45069
- // rather than in markup — which is most of them — and an empty harvest is a criterion the
45070
- // adjudicator can only guess at.
45064
+ // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45065
+ //
45066
+ // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
45067
+ // CONSEQUENCES — onFocus/onBlur/onChange, `.submit(`, `location`, `router.push`,
45068
+ // `window.open` — on the theory that a context change is one of them. It is, but the list can
45069
+ // only ever be the ways we have already seen a script change context, and a criterion whose
45070
+ // subject is « each script » cannot be answered from a catalogue of endings.
45071
+ //
45072
+ // Measured on tests/fixtures/realworld, which ships LoginForm.tsx whose form carries
45073
+ // `onSubmit={(e) => { e.preventDefault(); … }}`: 7.4 reached the adjudicator with population
45074
+ // ZERO. A script was right there, doing the thing the criterion asks about, and the harvest
45075
+ // walked past it because `onSubmit` was not on the list. The adjudicator was told « no
45076
+ // evidence » and billed to guess between NA and manual — on a page it should have been handed
45077
+ // twenty lines of React to read.
45078
+ //
45079
+ // So it collects the script FIRST: any `<script>`, any `on*` binding whatever the event, and
45080
+ // then the consequences it always looked for, which stay because they are what an adjudicator
45081
+ // most needs pointed out. Two things follow. A page with a script always arrives with it, so
45082
+ // the verdict is made on the code rather than on its absence. And a page with no script at all
45083
+ // harvests nothing — which is now a FACT about the code, not a gap in a pattern list, so 7.4
45084
+ // and 3.2.1/3.2.2 close as « not applicable » with no model in the loop (EXISTENCE_SUBJECTS).
45071
45085
  contextChange: (docs) => docs.flatMap((d) => [
45086
+ // The script itself — the subject, before anything it goes on to do.
45087
+ ...elementsByTag(d, "script").map(
45088
+ (e) => h(
45089
+ d,
45090
+ e,
45091
+ `<script${attr(e, "src") ? ` src="${attr(e, "src")}"` : ""}> \u2014 does anything it runs change context, and is the user warned or in control?`,
45092
+ `script|${attr(e, "src") ?? "inline"}`
45093
+ )
45094
+ ),
45095
+ // Any handler binding, whatever the event: `onSubmit` and `onClick` change context as
45096
+ // readily as `onChange`, and a criterion about scripts has no business enumerating events.
45097
+ ...linesOf(d, /\bon[A-Z][A-Za-z]*\s*=|\son[a-z]+\s*=\s*["']/).map(
45098
+ (l) => hAt(d, l.line, "handler", `handler \u2014 does it change context (navigate, submit, open a window, move focus)? ${l.text}`, `handler|${l.text}`)
45099
+ ),
45072
45100
  ...linesOf(d, /\bon(?:Focus|Blur)\s*=/).map(
45073
45101
  (l) => hAt(d, l.line, "handler", `focus handler \u2014 does it change context (navigate, submit, move focus)? ${l.text}`, `onfocus|${l.text}`)
45074
45102
  ),
@@ -45437,9 +45465,11 @@ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set([
45437
45465
  "errors",
45438
45466
  "frames",
45439
45467
  "downloadDocs",
45440
- "newWindow"
45468
+ "newWindow",
45469
+ "contextChange"
45441
45470
  ]);
45442
45471
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
45472
+ var ownPackSubjects = (standard, id) => PACK_SUBJECTS[standard]?.[id];
45443
45473
  function subjectsForPackCriterion(standard, id, scs) {
45444
45474
  const own = PACK_SUBJECTS[standard]?.[id];
45445
45475
  if (own) return own;
@@ -54408,6 +54438,13 @@ function derivePackResults(audit2, packKey, pageId) {
54408
54438
  const overrides = pack.overrides;
54409
54439
  const seen = audit2.scope.subjectsSeen;
54410
54440
  const subjectAbsent = (pc) => seen !== void 0 && subjectsAbsent(subjectsForPackCriterion(packKey, pc.id, pc.wcag), new Set(seen));
54441
+ const ownSubjectSeen = (pc) => {
54442
+ if (seen === void 0) return false;
54443
+ const own = ownPackSubjects(packKey, pc.id);
54444
+ if (!own?.length) return false;
54445
+ const present = new Set(seen);
54446
+ return own.some((id) => present.has(id));
54447
+ };
54411
54448
  const ownRuleIds = new Set((pack.rules ?? []).map((r) => `pack:${packKey}:${r.id}`));
54412
54449
  const deriveBase = (pc) => {
54413
54450
  const outOfScope = pc.wcag.every((sc) => {
@@ -54444,6 +54481,9 @@ function derivePackResults(audit2, packKey, pageId) {
54444
54481
  return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS, findings, scs: pc.wcag, inapplicable: true };
54445
54482
  }
54446
54483
  const inapplicable = status === INAPPLICABLE_STATUS && (scResults.length === 0 || scResults.every((r) => r.inapplicable));
54484
+ if (inapplicable && ownSubjectSeen(pc)) {
54485
+ return { id: pc.id, theme: pc.theme, status: "manual", findings, scs: pc.wcag };
54486
+ }
54447
54487
  return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag, ...inapplicable ? { inapplicable: true } : {} };
54448
54488
  };
54449
54489
  const enabledSecondary = (pack.secondaryMappings ?? []).filter((m) => m.enabled === true);
@@ -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.26.0
8
+ version: 5.27.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.26.0";
9
+ var VERSION = "5.27.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -45061,14 +45061,42 @@ var SUBJECTS = {
45061
45061
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45062
45062
  )
45063
45063
  ]),
45064
- // A change of context triggered by focus or by changing a value.
45065
- // RGAA 7.4 / WCAG 3.2.1-3.2.2. The handlers are half the subject; the OTHER half is what a
45066
- // script does once it runs, and that is where a context change actually happens: submitting a
45067
- // form, navigating, opening a window. Looking only for onFocus/onBlur/onChange left the
45068
- // criterion with an empty harvest on any codebase that binds its handlers in JavaScript
45069
- // rather than in markup — which is most of them — and an empty harvest is a criterion the
45070
- // adjudicator can only guess at.
45064
+ // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45065
+ //
45066
+ // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
45067
+ // CONSEQUENCES — onFocus/onBlur/onChange, `.submit(`, `location`, `router.push`,
45068
+ // `window.open` — on the theory that a context change is one of them. It is, but the list can
45069
+ // only ever be the ways we have already seen a script change context, and a criterion whose
45070
+ // subject is « each script » cannot be answered from a catalogue of endings.
45071
+ //
45072
+ // Measured on tests/fixtures/realworld, which ships LoginForm.tsx whose form carries
45073
+ // `onSubmit={(e) => { e.preventDefault(); … }}`: 7.4 reached the adjudicator with population
45074
+ // ZERO. A script was right there, doing the thing the criterion asks about, and the harvest
45075
+ // walked past it because `onSubmit` was not on the list. The adjudicator was told « no
45076
+ // evidence » and billed to guess between NA and manual — on a page it should have been handed
45077
+ // twenty lines of React to read.
45078
+ //
45079
+ // So it collects the script FIRST: any `<script>`, any `on*` binding whatever the event, and
45080
+ // then the consequences it always looked for, which stay because they are what an adjudicator
45081
+ // most needs pointed out. Two things follow. A page with a script always arrives with it, so
45082
+ // the verdict is made on the code rather than on its absence. And a page with no script at all
45083
+ // harvests nothing — which is now a FACT about the code, not a gap in a pattern list, so 7.4
45084
+ // and 3.2.1/3.2.2 close as « not applicable » with no model in the loop (EXISTENCE_SUBJECTS).
45071
45085
  contextChange: (docs) => docs.flatMap((d) => [
45086
+ // The script itself — the subject, before anything it goes on to do.
45087
+ ...elementsByTag(d, "script").map(
45088
+ (e) => h(
45089
+ d,
45090
+ e,
45091
+ `<script${attr(e, "src") ? ` src="${attr(e, "src")}"` : ""}> \u2014 does anything it runs change context, and is the user warned or in control?`,
45092
+ `script|${attr(e, "src") ?? "inline"}`
45093
+ )
45094
+ ),
45095
+ // Any handler binding, whatever the event: `onSubmit` and `onClick` change context as
45096
+ // readily as `onChange`, and a criterion about scripts has no business enumerating events.
45097
+ ...linesOf(d, /\bon[A-Z][A-Za-z]*\s*=|\son[a-z]+\s*=\s*["']/).map(
45098
+ (l) => hAt(d, l.line, "handler", `handler \u2014 does it change context (navigate, submit, open a window, move focus)? ${l.text}`, `handler|${l.text}`)
45099
+ ),
45072
45100
  ...linesOf(d, /\bon(?:Focus|Blur)\s*=/).map(
45073
45101
  (l) => hAt(d, l.line, "handler", `focus handler \u2014 does it change context (navigate, submit, move focus)? ${l.text}`, `onfocus|${l.text}`)
45074
45102
  ),
@@ -45437,9 +45465,11 @@ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set([
45437
45465
  "errors",
45438
45466
  "frames",
45439
45467
  "downloadDocs",
45440
- "newWindow"
45468
+ "newWindow",
45469
+ "contextChange"
45441
45470
  ]);
45442
45471
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
45472
+ var ownPackSubjects = (standard, id) => PACK_SUBJECTS[standard]?.[id];
45443
45473
  function subjectsForPackCriterion(standard, id, scs) {
45444
45474
  const own = PACK_SUBJECTS[standard]?.[id];
45445
45475
  if (own) return own;
@@ -54408,6 +54438,13 @@ function derivePackResults(audit2, packKey, pageId) {
54408
54438
  const overrides = pack.overrides;
54409
54439
  const seen = audit2.scope.subjectsSeen;
54410
54440
  const subjectAbsent = (pc) => seen !== void 0 && subjectsAbsent(subjectsForPackCriterion(packKey, pc.id, pc.wcag), new Set(seen));
54441
+ const ownSubjectSeen = (pc) => {
54442
+ if (seen === void 0) return false;
54443
+ const own = ownPackSubjects(packKey, pc.id);
54444
+ if (!own?.length) return false;
54445
+ const present = new Set(seen);
54446
+ return own.some((id) => present.has(id));
54447
+ };
54411
54448
  const ownRuleIds = new Set((pack.rules ?? []).map((r) => `pack:${packKey}:${r.id}`));
54412
54449
  const deriveBase = (pc) => {
54413
54450
  const outOfScope = pc.wcag.every((sc) => {
@@ -54444,6 +54481,9 @@ function derivePackResults(audit2, packKey, pageId) {
54444
54481
  return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS, findings, scs: pc.wcag, inapplicable: true };
54445
54482
  }
54446
54483
  const inapplicable = status === INAPPLICABLE_STATUS && (scResults.length === 0 || scResults.every((r) => r.inapplicable));
54484
+ if (inapplicable && ownSubjectSeen(pc)) {
54485
+ return { id: pc.id, theme: pc.theme, status: "manual", findings, scs: pc.wcag };
54486
+ }
54447
54487
  return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag, ...inapplicable ? { inapplicable: true } : {} };
54448
54488
  };
54449
54489
  const enabledSecondary = (pack.secondaryMappings ?? []).filter((m) => m.enabled === true);
@@ -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.26.0
7
+ version: 5.27.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.26.0";
9
+ var VERSION = "5.27.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -45061,14 +45061,42 @@ var SUBJECTS = {
45061
45061
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45062
45062
  )
45063
45063
  ]),
45064
- // A change of context triggered by focus or by changing a value.
45065
- // RGAA 7.4 / WCAG 3.2.1-3.2.2. The handlers are half the subject; the OTHER half is what a
45066
- // script does once it runs, and that is where a context change actually happens: submitting a
45067
- // form, navigating, opening a window. Looking only for onFocus/onBlur/onChange left the
45068
- // criterion with an empty harvest on any codebase that binds its handlers in JavaScript
45069
- // rather than in markup — which is most of them — and an empty harvest is a criterion the
45070
- // adjudicator can only guess at.
45064
+ // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45065
+ //
45066
+ // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
45067
+ // CONSEQUENCES — onFocus/onBlur/onChange, `.submit(`, `location`, `router.push`,
45068
+ // `window.open` — on the theory that a context change is one of them. It is, but the list can
45069
+ // only ever be the ways we have already seen a script change context, and a criterion whose
45070
+ // subject is « each script » cannot be answered from a catalogue of endings.
45071
+ //
45072
+ // Measured on tests/fixtures/realworld, which ships LoginForm.tsx whose form carries
45073
+ // `onSubmit={(e) => { e.preventDefault(); … }}`: 7.4 reached the adjudicator with population
45074
+ // ZERO. A script was right there, doing the thing the criterion asks about, and the harvest
45075
+ // walked past it because `onSubmit` was not on the list. The adjudicator was told « no
45076
+ // evidence » and billed to guess between NA and manual — on a page it should have been handed
45077
+ // twenty lines of React to read.
45078
+ //
45079
+ // So it collects the script FIRST: any `<script>`, any `on*` binding whatever the event, and
45080
+ // then the consequences it always looked for, which stay because they are what an adjudicator
45081
+ // most needs pointed out. Two things follow. A page with a script always arrives with it, so
45082
+ // the verdict is made on the code rather than on its absence. And a page with no script at all
45083
+ // harvests nothing — which is now a FACT about the code, not a gap in a pattern list, so 7.4
45084
+ // and 3.2.1/3.2.2 close as « not applicable » with no model in the loop (EXISTENCE_SUBJECTS).
45071
45085
  contextChange: (docs) => docs.flatMap((d) => [
45086
+ // The script itself — the subject, before anything it goes on to do.
45087
+ ...elementsByTag(d, "script").map(
45088
+ (e) => h(
45089
+ d,
45090
+ e,
45091
+ `<script${attr(e, "src") ? ` src="${attr(e, "src")}"` : ""}> \u2014 does anything it runs change context, and is the user warned or in control?`,
45092
+ `script|${attr(e, "src") ?? "inline"}`
45093
+ )
45094
+ ),
45095
+ // Any handler binding, whatever the event: `onSubmit` and `onClick` change context as
45096
+ // readily as `onChange`, and a criterion about scripts has no business enumerating events.
45097
+ ...linesOf(d, /\bon[A-Z][A-Za-z]*\s*=|\son[a-z]+\s*=\s*["']/).map(
45098
+ (l) => hAt(d, l.line, "handler", `handler \u2014 does it change context (navigate, submit, open a window, move focus)? ${l.text}`, `handler|${l.text}`)
45099
+ ),
45072
45100
  ...linesOf(d, /\bon(?:Focus|Blur)\s*=/).map(
45073
45101
  (l) => hAt(d, l.line, "handler", `focus handler \u2014 does it change context (navigate, submit, move focus)? ${l.text}`, `onfocus|${l.text}`)
45074
45102
  ),
@@ -45437,9 +45465,11 @@ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set([
45437
45465
  "errors",
45438
45466
  "frames",
45439
45467
  "downloadDocs",
45440
- "newWindow"
45468
+ "newWindow",
45469
+ "contextChange"
45441
45470
  ]);
45442
45471
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
45472
+ var ownPackSubjects = (standard, id) => PACK_SUBJECTS[standard]?.[id];
45443
45473
  function subjectsForPackCriterion(standard, id, scs) {
45444
45474
  const own = PACK_SUBJECTS[standard]?.[id];
45445
45475
  if (own) return own;
@@ -54408,6 +54438,13 @@ function derivePackResults(audit2, packKey, pageId) {
54408
54438
  const overrides = pack.overrides;
54409
54439
  const seen = audit2.scope.subjectsSeen;
54410
54440
  const subjectAbsent = (pc) => seen !== void 0 && subjectsAbsent(subjectsForPackCriterion(packKey, pc.id, pc.wcag), new Set(seen));
54441
+ const ownSubjectSeen = (pc) => {
54442
+ if (seen === void 0) return false;
54443
+ const own = ownPackSubjects(packKey, pc.id);
54444
+ if (!own?.length) return false;
54445
+ const present = new Set(seen);
54446
+ return own.some((id) => present.has(id));
54447
+ };
54411
54448
  const ownRuleIds = new Set((pack.rules ?? []).map((r) => `pack:${packKey}:${r.id}`));
54412
54449
  const deriveBase = (pc) => {
54413
54450
  const outOfScope = pc.wcag.every((sc) => {
@@ -54444,6 +54481,9 @@ function derivePackResults(audit2, packKey, pageId) {
54444
54481
  return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS, findings, scs: pc.wcag, inapplicable: true };
54445
54482
  }
54446
54483
  const inapplicable = status === INAPPLICABLE_STATUS && (scResults.length === 0 || scResults.every((r) => r.inapplicable));
54484
+ if (inapplicable && ownSubjectSeen(pc)) {
54485
+ return { id: pc.id, theme: pc.theme, status: "manual", findings, scs: pc.wcag };
54486
+ }
54447
54487
  return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag, ...inapplicable ? { inapplicable: true } : {} };
54448
54488
  };
54449
54489
  const enabledSecondary = (pack.secondaryMappings ?? []).filter((m) => m.enabled === true);
@@ -706,15 +706,43 @@ export const SUBJECTS: Record<string, Subject> = {
706
706
  ),
707
707
  ]),
708
708
 
709
- // A change of context triggered by focus or by changing a value.
710
- // RGAA 7.4 / WCAG 3.2.1-3.2.2. The handlers are half the subject; the OTHER half is what a
711
- // script does once it runs, and that is where a context change actually happens: submitting a
712
- // form, navigating, opening a window. Looking only for onFocus/onBlur/onChange left the
713
- // criterion with an empty harvest on any codebase that binds its handlers in JavaScript
714
- // rather than in markup — which is most of them — and an empty harvest is a criterion the
715
- // adjudicator can only guess at.
709
+ // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
710
+ //
711
+ // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
712
+ // CONSEQUENCES — onFocus/onBlur/onChange, `.submit(`, `location`, `router.push`,
713
+ // `window.open` — on the theory that a context change is one of them. It is, but the list can
714
+ // only ever be the ways we have already seen a script change context, and a criterion whose
715
+ // subject is « each script » cannot be answered from a catalogue of endings.
716
+ //
717
+ // Measured on tests/fixtures/realworld, which ships LoginForm.tsx whose form carries
718
+ // `onSubmit={(e) => { e.preventDefault(); … }}`: 7.4 reached the adjudicator with population
719
+ // ZERO. A script was right there, doing the thing the criterion asks about, and the harvest
720
+ // walked past it because `onSubmit` was not on the list. The adjudicator was told « no
721
+ // evidence » and billed to guess between NA and manual — on a page it should have been handed
722
+ // twenty lines of React to read.
723
+ //
724
+ // So it collects the script FIRST: any `<script>`, any `on*` binding whatever the event, and
725
+ // then the consequences it always looked for, which stay because they are what an adjudicator
726
+ // most needs pointed out. Two things follow. A page with a script always arrives with it, so
727
+ // the verdict is made on the code rather than on its absence. And a page with no script at all
728
+ // harvests nothing — which is now a FACT about the code, not a gap in a pattern list, so 7.4
729
+ // and 3.2.1/3.2.2 close as « not applicable » with no model in the loop (EXISTENCE_SUBJECTS).
716
730
  contextChange: (docs) =>
717
731
  docs.flatMap((d) => [
732
+ // The script itself — the subject, before anything it goes on to do.
733
+ ...elementsByTag(d, "script").map((e) =>
734
+ h(
735
+ d,
736
+ e,
737
+ `<script${attr(e, "src") ? ` src="${attr(e, "src")}"` : ""}> — does anything it runs change context, and is the user warned or in control?`,
738
+ `script|${attr(e, "src") ?? "inline"}`,
739
+ ),
740
+ ),
741
+ // Any handler binding, whatever the event: `onSubmit` and `onClick` change context as
742
+ // readily as `onChange`, and a criterion about scripts has no business enumerating events.
743
+ ...linesOf(d, /\bon[A-Z][A-Za-z]*\s*=|\son[a-z]+\s*=\s*["']/).map((l) =>
744
+ hAt(d, l.line, "handler", `handler — does it change context (navigate, submit, open a window, move focus)? ${l.text}`, `handler|${l.text}`),
745
+ ),
718
746
  ...linesOf(d, /\bon(?:Focus|Blur)\s*=/).map((l) =>
719
747
  hAt(d, l.line, "handler", `focus handler — does it change context (navigate, submit, move focus)? ${l.text}`, `onfocus|${l.text}`),
720
748
  ),
@@ -1200,13 +1228,22 @@ export const PACK_SUBJECTS: Record<string, Record<string, string[]>> = {
1200
1228
  // this comment was written from, both were ruled NA on the first pass with no gate refusal,
1201
1229
  // which is the definition of a paid turn that decided nothing.
1202
1230
  //
1203
- // THE OTHER ONE, 7.4, STAYS OUT, and the asymmetry is the whole point of the rule above.
1204
- // `contextChange` does not enumerate the ways a browser CAN change context, it enumerates the
1205
- // ways we have seen a script do it — `.submit(`, `location.href`, `router.push`, a handful of
1206
- // handler attributes. A component that navigates through a router the list does not name
1207
- // (`history.push`, a custom `navigate()`, `location.reload()`) fails 7.4 and matches none of
1208
- // them. Its silence is what a failing page looks like, so admitting it would buy a cheap NA at
1209
- // the price of a hidden non-conformity.
1231
+ // `contextChange` is here too NOW, and it was not before — the earlier refusal was right about
1232
+ // the rule and wrong about the subject, so it is worth writing down which changed.
1233
+ //
1234
+ // It used to enumerate CONSEQUENCES: `.submit(`, `location.href`, `router.push`, a handful of
1235
+ // handler attributes. Read that way it is a heuristic, its silence is what a failing page looks
1236
+ // like, and admitting it would have bought a cheap NA at the price of a hidden non-conformity.
1237
+ // That refusal stood, and it also let a real defect stand: on tests/fixtures/realworld, 7.4
1238
+ // arrived with population ZERO from a page shipping `onSubmit={(e) => …}` — a script the harvest
1239
+ // simply did not look for.
1240
+ //
1241
+ // The subject is the SCRIPT now, not the endings: any `<script>`, any `on*` binding. RGAA 7.4
1242
+ // asks about « chaque script qui initie un changement de contexte », so a page that FAILS it runs
1243
+ // a script, and a scope with no script anywhere has nothing for the criterion to be about. That
1244
+ // is an element species, and its silence is a fact a reader can check — the same test
1245
+ // `downloadDocs` and `newWindow` pass. The heuristics stay in the harvest as POINTERS for
1246
+ // whoever reads it; they are no longer what decides whether the subject exists.
1210
1247
  export const EXISTENCE_SUBJECTS: ReadonlySet<string> = new Set([
1211
1248
  "images",
1212
1249
  "tables",
@@ -1218,6 +1255,7 @@ export const EXISTENCE_SUBJECTS: ReadonlySet<string> = new Set([
1218
1255
  "frames",
1219
1256
  "downloadDocs",
1220
1257
  "newWindow",
1258
+ "contextChange",
1221
1259
  ]);
1222
1260
 
1223
1261
  /** The subjects that decide a success criterion. Empty ⇒ the criterion has none declared,
@@ -1228,6 +1266,16 @@ export const subjectsForSc = (sc: string): string[] => SC_SUBJECTS[sc] ?? [];
1228
1266
  * of its mapped success criteria's. The override is what stops RGAA 11.1 (are the fields
1229
1267
  * labelled?) from being handed the page's heading outline because 1.3.1 happens to come
1230
1268
  * first in its `wcag` list. */
1269
+ /** The subjects a pack criterion declares FOR ITSELF, as opposed to the union it inherits from
1270
+ * its WCAG mapping. Undefined when it declares none.
1271
+ *
1272
+ * The distinction decides whether an INHERITED absence may close the criterion. RGAA 13.2 maps
1273
+ * onto WCAG 3.2.1, whose subject is a script; 13.2's own subject is a window opening. On a page
1274
+ * carrying `target="_blank"` and no script at all, 3.2.1 is legitimately « nothing of that kind
1275
+ * in scope » — and 13.2 inherited that closure and published a conformity over an element
1276
+ * nobody had looked at. Its own subject was right there in the harvest. */
1277
+ export const ownPackSubjects = (standard: string, id: string): string[] | undefined => PACK_SUBJECTS[standard]?.[id];
1278
+
1231
1279
  export function subjectsForPackCriterion(standard: string, id: string, scs: string[]): string[] {
1232
1280
  const own = PACK_SUBJECTS[standard]?.[id];
1233
1281
  if (own) return own;
@@ -2,7 +2,7 @@
2
2
  // presentation-only WCAG view (former src/standard.ts). For each pack criterion, gather
3
3
  // the results of the WCAG SCs it maps to and fold them with the same NC-dominates rule.
4
4
  // Presentation-only: the canonical, gated verdict lives on the WCAG core.
5
- import { subjectsAbsent, subjectsForPackCriterion } from "../adjudicate-subjects.js";
5
+ import { ownPackSubjects, subjectsAbsent, subjectsForPackCriterion } from "../adjudicate-subjects.js";
6
6
  import { coverageFor, criterionMeasuredOn, unionCoverage } from "../coverage.js";
7
7
  import type { AuditResult, CriterionResult, PageCoverage, Status, Finding, Severity } from "../types.js";
8
8
  import { CORE_KEY, loadPack } from "./registry.js";
@@ -264,6 +264,16 @@ export function derivePackResults(audit: AuditResult, packKey: string, pageId?:
264
264
  // scope that read no file.
265
265
  const seen = audit.scope.subjectsSeen;
266
266
  const subjectAbsent = (pc: PackCriterion): boolean => seen !== undefined && subjectsAbsent(subjectsForPackCriterion(packKey, pc.id, pc.wcag), new Set(seen));
267
+ // Did the criterion's OWN subject turn up? Only its own counts: a criterion that declares none
268
+ // has nothing to defend against an inherited closure, and the union it borrows from its WCAG
269
+ // mapping is exactly where that closure came from.
270
+ const ownSubjectSeen = (pc: PackCriterion): boolean => {
271
+ if (seen === undefined) return false;
272
+ const own = ownPackSubjects(packKey, pc.id);
273
+ if (!own?.length) return false;
274
+ const present = new Set(seen);
275
+ return own.some((id) => present.has(id));
276
+ };
267
277
 
268
278
  // Rules the PACK ITSELF brings, by finding id. A criterion whose `appliesTo` names one of
269
279
  // these has an instrument regardless of what its WCAG mapping is worth — which is the whole
@@ -361,6 +371,28 @@ export function derivePackResults(audit: AuditResult, packKey: string, pageId?:
361
371
  return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS as Status, findings, scs: pc.wcag, inapplicable: true };
362
372
  }
363
373
  const inapplicable = status === INAPPLICABLE_STATUS && (scResults.length === 0 || scResults.every((r) => r.inapplicable));
374
+ // AN INHERITED ABSENCE CANNOT CLOSE A CRITERION WHOSE OWN SUBJECT IS PRESENT.
375
+ //
376
+ // `inapplicable` here means every mapped success criterion came back « nothing of that kind
377
+ // is in scope ». That is a statement about THEIR subject, and a pack criterion frequently
378
+ // asks about a different one — which is the entire reason PACK_SUBJECTS exists. Publishing
379
+ // their absence as our conformity is the precise shape of « conforming because nobody
380
+ // looked », and it is worse than leaving the criterion open: in the deliverable it reads
381
+ // identically to a verified conformity.
382
+ //
383
+ // Measured on RGAA 13.2 (« l'ouverture d'une nouvelle fenêtre ne doit pas être déclenchée
384
+ // sans action de l'utilisateur »), which maps onto WCAG 3.2.1, whose subject is a script. On
385
+ // a page carrying `target="_blank"` and no script, 3.2.1 is honestly inapplicable — and 13.2
386
+ // inherited that and reported « conforme faute de sujet » over a link sitting in its own
387
+ // harvest. It surfaced the day `contextChange` became an existence subject, which is what
388
+ // made 3.2.1 closable at all; the flaw predates that and was merely unreachable.
389
+ //
390
+ // The answer is « undecided », never a conformity: our subject is here and nobody has ruled
391
+ // on it. NC is already excluded above, and a criterion declaring no subject of its own is
392
+ // untouched.
393
+ if (inapplicable && ownSubjectSeen(pc)) {
394
+ return { id: pc.id, theme: pc.theme, status: "manual" as Status, findings, scs: pc.wcag };
395
+ }
364
396
  return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag, ...(inapplicable ? { inapplicable: true } : {}) };
365
397
  };
366
398
 
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.26.0";
3
+ export const VERSION = "5.27.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,