ultra11y 5.4.0 → 5.4.1

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.4.0",
13
+ "version": "5.4.1",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.4.0",
4
+ "version": "5.4.1",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.4.0",
4
+ "version": "5.4.1",
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.4.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.4.1";
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.4.0",
3
+ "version": "5.4.1",
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.4.0";
9
+ var VERSION = "5.4.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -47435,8 +47435,19 @@ var SUBJECT_MATTER = {
47435
47435
  // Identify Input Purpose — about autocomplete on fields collecting user data.
47436
47436
  "1.3.5": hasFormControl
47437
47437
  };
47438
+ var MEDIA_HINT = /(youtube|youtu\.be|vimeo|dailymotion|soundcloud|spotify|twitch|wistia|brightcove|jwplayer|videopress|podcast|player|vid[eé]o|audio|baladodiffusion)/i;
47439
+ var MEDIA_EXT = /\.(mp4|webm|ogv|ogg|mov|m4v|avi|mpe?g|mp3|wav|flac|m4a|aac|opus|m3u8|mpd)(?:[?#]|$)/i;
47438
47440
  function hasMedia(d) {
47439
- return has(d, "audio", "video", "track", "source", "object", "embed", "iframe", "marquee", "canvas");
47441
+ if (has(d, "audio", "video", "track", "source")) return true;
47442
+ return d.elements.some((el) => {
47443
+ if (el.tag === "object" || el.tag === "embed") {
47444
+ const type = (el.attribs.type ?? "").toLowerCase();
47445
+ return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/") ? true : false;
47446
+ }
47447
+ if (el.tag !== "iframe") return false;
47448
+ const hay = `${el.attribs.src ?? ""} ${el.attribs.title ?? ""} ${el.attribs.allow ?? ""}`;
47449
+ return MEDIA_HINT.test(hay) || MEDIA_EXT.test(el.attribs.src ?? "");
47450
+ });
47440
47451
  }
47441
47452
  function hasMotionApi(d) {
47442
47453
  return /\b(?:devicemotion|deviceorientation|DeviceMotionEvent|DeviceOrientationEvent|Accelerometer|Gyroscope|requestPermission)\b/i.test(d.source);
@@ -47480,7 +47491,7 @@ function residualReason(automatability2, sc) {
47480
47491
  function subjectMatterReason(sc, files) {
47481
47492
  const scope = `nothing in the ${files} file(s) audited is concerned`;
47482
47493
  if (sc.startsWith("1.2."))
47483
- return `No time-based media in scope: ${scope} \u2014 no <audio>, <video>, <track>, <object>, <embed>, <iframe> or <canvas> element was found.`;
47494
+ return `No time-based media in scope: ${scope} \u2014 no <audio>, <video>, <track> or <source> element, no <object>/<embed>, and no <iframe> pointing at media.`;
47484
47495
  if (sc === "2.5.4") return `No motion actuation in scope: ${scope} \u2014 no device-motion or device-orientation API is used.`;
47485
47496
  if (sc === "1.3.5" || sc.startsWith("3.3.")) return `No user input in scope: ${scope} \u2014 no form control (native, ARIA or contenteditable) was found.`;
47486
47497
  return `No element in scope is concerned by this success criterion (${scope}).`;
@@ -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.4.0
8
+ version: 5.4.1
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.4.0";
9
+ var VERSION = "5.4.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -47435,8 +47435,19 @@ var SUBJECT_MATTER = {
47435
47435
  // Identify Input Purpose — about autocomplete on fields collecting user data.
47436
47436
  "1.3.5": hasFormControl
47437
47437
  };
47438
+ var MEDIA_HINT = /(youtube|youtu\.be|vimeo|dailymotion|soundcloud|spotify|twitch|wistia|brightcove|jwplayer|videopress|podcast|player|vid[eé]o|audio|baladodiffusion)/i;
47439
+ var MEDIA_EXT = /\.(mp4|webm|ogv|ogg|mov|m4v|avi|mpe?g|mp3|wav|flac|m4a|aac|opus|m3u8|mpd)(?:[?#]|$)/i;
47438
47440
  function hasMedia(d) {
47439
- return has(d, "audio", "video", "track", "source", "object", "embed", "iframe", "marquee", "canvas");
47441
+ if (has(d, "audio", "video", "track", "source")) return true;
47442
+ return d.elements.some((el) => {
47443
+ if (el.tag === "object" || el.tag === "embed") {
47444
+ const type = (el.attribs.type ?? "").toLowerCase();
47445
+ return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/") ? true : false;
47446
+ }
47447
+ if (el.tag !== "iframe") return false;
47448
+ const hay = `${el.attribs.src ?? ""} ${el.attribs.title ?? ""} ${el.attribs.allow ?? ""}`;
47449
+ return MEDIA_HINT.test(hay) || MEDIA_EXT.test(el.attribs.src ?? "");
47450
+ });
47440
47451
  }
47441
47452
  function hasMotionApi(d) {
47442
47453
  return /\b(?:devicemotion|deviceorientation|DeviceMotionEvent|DeviceOrientationEvent|Accelerometer|Gyroscope|requestPermission)\b/i.test(d.source);
@@ -47480,7 +47491,7 @@ function residualReason(automatability2, sc) {
47480
47491
  function subjectMatterReason(sc, files) {
47481
47492
  const scope = `nothing in the ${files} file(s) audited is concerned`;
47482
47493
  if (sc.startsWith("1.2."))
47483
- return `No time-based media in scope: ${scope} \u2014 no <audio>, <video>, <track>, <object>, <embed>, <iframe> or <canvas> element was found.`;
47494
+ return `No time-based media in scope: ${scope} \u2014 no <audio>, <video>, <track> or <source> element, no <object>/<embed>, and no <iframe> pointing at media.`;
47484
47495
  if (sc === "2.5.4") return `No motion actuation in scope: ${scope} \u2014 no device-motion or device-orientation API is used.`;
47485
47496
  if (sc === "1.3.5" || sc.startsWith("3.3.")) return `No user input in scope: ${scope} \u2014 no form control (native, ARIA or contenteditable) was found.`;
47486
47497
  return `No element in scope is concerned by this success criterion (${scope}).`;
@@ -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.4.0
7
+ version: 5.4.1
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.4.0";
9
+ var VERSION = "5.4.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -47435,8 +47435,19 @@ var SUBJECT_MATTER = {
47435
47435
  // Identify Input Purpose — about autocomplete on fields collecting user data.
47436
47436
  "1.3.5": hasFormControl
47437
47437
  };
47438
+ var MEDIA_HINT = /(youtube|youtu\.be|vimeo|dailymotion|soundcloud|spotify|twitch|wistia|brightcove|jwplayer|videopress|podcast|player|vid[eé]o|audio|baladodiffusion)/i;
47439
+ var MEDIA_EXT = /\.(mp4|webm|ogv|ogg|mov|m4v|avi|mpe?g|mp3|wav|flac|m4a|aac|opus|m3u8|mpd)(?:[?#]|$)/i;
47438
47440
  function hasMedia(d) {
47439
- return has(d, "audio", "video", "track", "source", "object", "embed", "iframe", "marquee", "canvas");
47441
+ if (has(d, "audio", "video", "track", "source")) return true;
47442
+ return d.elements.some((el) => {
47443
+ if (el.tag === "object" || el.tag === "embed") {
47444
+ const type = (el.attribs.type ?? "").toLowerCase();
47445
+ return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/") ? true : false;
47446
+ }
47447
+ if (el.tag !== "iframe") return false;
47448
+ const hay = `${el.attribs.src ?? ""} ${el.attribs.title ?? ""} ${el.attribs.allow ?? ""}`;
47449
+ return MEDIA_HINT.test(hay) || MEDIA_EXT.test(el.attribs.src ?? "");
47450
+ });
47440
47451
  }
47441
47452
  function hasMotionApi(d) {
47442
47453
  return /\b(?:devicemotion|deviceorientation|DeviceMotionEvent|DeviceOrientationEvent|Accelerometer|Gyroscope|requestPermission)\b/i.test(d.source);
@@ -47480,7 +47491,7 @@ function residualReason(automatability2, sc) {
47480
47491
  function subjectMatterReason(sc, files) {
47481
47492
  const scope = `nothing in the ${files} file(s) audited is concerned`;
47482
47493
  if (sc.startsWith("1.2."))
47483
- return `No time-based media in scope: ${scope} \u2014 no <audio>, <video>, <track>, <object>, <embed>, <iframe> or <canvas> element was found.`;
47494
+ return `No time-based media in scope: ${scope} \u2014 no <audio>, <video>, <track> or <source> element, no <object>/<embed>, and no <iframe> pointing at media.`;
47484
47495
  if (sc === "2.5.4") return `No motion actuation in scope: ${scope} \u2014 no device-motion or device-orientation API is used.`;
47485
47496
  if (sc === "1.3.5" || sc.startsWith("3.3.")) return `No user input in scope: ${scope} \u2014 no form control (native, ARIA or contenteditable) was found.`;
47486
47497
  return `No element in scope is concerned by this success criterion (${scope}).`;
package/src/audit.ts CHANGED
@@ -81,10 +81,12 @@ const APPLICABLE: Record<string, (d: Doc) => boolean> = {
81
81
  // Three rules keep this honest, because a wrong NA is far worse than an honest « to assess » —
82
82
  // it is a non-conformity hidden inside a report someone signs:
83
83
  //
84
- // 1. OVER-INCLUSION IS THE SAFE DIRECTION. A predicate answers "could this criterion
85
- // possibly apply here?", so it must say YES on anything ambiguous. `<object>`/`<embed>`/
86
- // `<iframe>` are usually not media, and they are all listed anyway: their presence merely
87
- // keeps a criterion open, which costs nothing but a line in the worklist.
84
+ // 1. UNCERTAINTY RESOLVES TOWARDS "APPLICABLE". A predicate answers "could this criterion
85
+ // possibly apply here?", so it says YES whenever it genuinely cannot tell — an `<object>`
86
+ // with no declared `type`, for instance. That is not the same as saying yes to everything:
87
+ // a predicate that treats every embed as a video is not cautious, it is wrong, and it
88
+ // costs a human the work of re-deriving "nothing here". Measured: one analytics `<iframe>`
89
+ // once held all twelve RGAA multimedia criteria open on a codebase with no media at all.
88
90
  // 2. APPLICABILITY IS OR-FOLDED ACROSS THE WHOLE SCOPE (see `foldDoc`). One document with a
89
91
  // `<video>` keeps the media criteria open for the entire audit. NA means "nothing,
90
92
  // anywhere in what was audited" — never "nothing in this file".
@@ -119,9 +121,47 @@ const SUBJECT_MATTER: Record<string, (d: Doc) => boolean> = {
119
121
  "1.3.5": hasFormControl,
120
122
  };
121
123
 
122
- /** Media, and everything that could be carrying media. Over-inclusive on purpose (rule 1). */
124
+ /** A URL or MIME type that looks like time-based media, or a player embedding one. */
125
+ // Accented forms included on purpose: the standards this serves are read in French, so a
126
+ // `title="Vidéo de présentation"` must count. `\bvideo\b` did not match it — the boundary falls
127
+ // inside the word — and an accent is the last thing that should decide whether a criterion is
128
+ // assessed.
129
+ const MEDIA_HINT =
130
+ /(youtube|youtu\.be|vimeo|dailymotion|soundcloud|spotify|twitch|wistia|brightcove|jwplayer|videopress|podcast|player|vid[eé]o|audio|baladodiffusion)/i;
131
+ const MEDIA_EXT = /\.(mp4|webm|ogv|ogg|mov|m4v|avi|mpe?g|mp3|wav|flac|m4a|aac|opus|m3u8|mpd)(?:[?#]|$)/i;
132
+
133
+ /**
134
+ * Is there time-based media — the subject of WCAG 1.2.x — anywhere in this document?
135
+ *
136
+ * Split in two, because "over-inclusion is the safe direction" is a rule about UNCERTAINTY, not
137
+ * a licence to treat every embed as a video. Measured on a real audit: one `<iframe>` holding an
138
+ * analytics opt-out widget kept all 12 RGAA multimedia criteria « to assess » across a codebase
139
+ * with no audio and no video anywhere. That is not caution, it is a false positive with a cost —
140
+ * twelve criteria a human now has to read through to reach the same "nothing here" the engine
141
+ * could have proved.
142
+ *
143
+ * So:
144
+ * - `<video>`, `<audio>`, `<track>`, `<source>` are media, full stop.
145
+ * - `<object>`/`<embed>` are media unless their `type` says otherwise — an unknown type stays
146
+ * applicable, since that is genuine uncertainty.
147
+ * - an `<iframe>` counts only when its `src`/`title` actually points at media (a player host,
148
+ * a media extension, the words video/audio/podcast). A widget iframe does not.
149
+ * - `<canvas>` and `<marquee>` are NOT here. Neither can carry a caption or an audio
150
+ * description, which is what 1.2.x is about; moving content is 2.2.2 and flashing is 2.3.1,
151
+ * and those have their own criteria.
152
+ */
123
153
  function hasMedia(d: Doc): boolean {
124
- return has(d, "audio", "video", "track", "source", "object", "embed", "iframe", "marquee", "canvas");
154
+ if (has(d, "audio", "video", "track", "source")) return true;
155
+ return d.elements.some((el) => {
156
+ if (el.tag === "object" || el.tag === "embed") {
157
+ const type = (el.attribs.type ?? "").toLowerCase();
158
+ // A declared non-media type rules it out; anything else is uncertain, so it counts.
159
+ return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/") ? true : false;
160
+ }
161
+ if (el.tag !== "iframe") return false;
162
+ const hay = `${el.attribs.src ?? ""} ${el.attribs.title ?? ""} ${el.attribs.allow ?? ""}`;
163
+ return MEDIA_HINT.test(hay) || MEDIA_EXT.test(el.attribs.src ?? "");
164
+ });
125
165
  }
126
166
 
127
167
  /** Device-motion input: the only thing WCAG 2.5.4 is about. */
@@ -194,7 +234,7 @@ function residualReason(automatability: string, sc?: string): string {
194
234
  function subjectMatterReason(sc: string, files: number): string {
195
235
  const scope = `nothing in the ${files} file(s) audited is concerned`;
196
236
  if (sc.startsWith("1.2."))
197
- return `No time-based media in scope: ${scope} — no <audio>, <video>, <track>, <object>, <embed>, <iframe> or <canvas> element was found.`;
237
+ return `No time-based media in scope: ${scope} — no <audio>, <video>, <track> or <source> element, no <object>/<embed>, and no <iframe> pointing at media.`;
198
238
  if (sc === "2.5.4") return `No motion actuation in scope: ${scope} — no device-motion or device-orientation API is used.`;
199
239
  if (sc === "1.3.5" || sc.startsWith("3.3.")) return `No user input in scope: ${scope} — no form control (native, ARIA or contenteditable) was found.`;
200
240
  return `No element in scope is concerned by this success criterion (${scope}).`;
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.4.0";
3
+ export const VERSION = "5.4.1";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,