ultra11y 5.35.1 → 5.35.2

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.35.1",
13
+ "version": "5.35.2",
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.35.1",
4
+ "version": "5.35.2",
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.35.1",
4
+ "version": "5.35.2",
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.35.1";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.35.2";
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.35.1",
3
+ "version": "5.35.2",
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 join53, 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.35.1";
9
+ var VERSION = "5.35.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -44713,10 +44713,18 @@ var PRESENTATIONAL_MARKUP_ATTRS = /* @__PURE__ */ new Set([
44713
44713
  ]);
44714
44714
  var DOWNLOAD_HREF = /\.(pdf|docx?|xlsx?)(?:[?#]|$)/i;
44715
44715
  var STATUS_CLASS = /(error|status|message|alert|notif|toast|feedback|live)/i;
44716
+ var TEMPORAL_MEDIA_VALUE = /(?:^|[/.?=&_-])(?:audio|video|player|podcast|mp[34]|webm|ogg|ogv|wav|m4a|mov|avi|mpeg)(?:$|[/.?=&_-])/i;
44716
44717
  var ROUTER_IMPORT = /['"](?:react-router(?:-dom)?|next\/(?:router|navigation)|vue-router|@remix-run\/[\w-]+|@tanstack\/[\w-]*router|@sveltejs\/kit|\$app\/(?:navigation|stores))['"]/;
44717
44718
  var FOREIGN_LEXICON = /\b(?:the|and|with|your|about|please|download|settings|welcome|dashboard|overview|read more|learn more|sign in|sign up|log in|get started|coming soon|powered by)\b/i;
44718
44719
  var CONTROL_TAGS = ["input", "select", "textarea"];
44719
44720
  var INTERACTIVE_TAGS = ["a", "button", "input", "select", "textarea", "summary", "details", "label"];
44721
+ function embeddedMediaKind(el) {
44722
+ const type = (attr(el, "type") ?? "").toLowerCase();
44723
+ const resource = `${attr(el, "src") ?? ""} ${attr(el, "data") ?? ""}`.toLowerCase();
44724
+ if (/^(?:audio|video)\//.test(type) || TEMPORAL_MEDIA_VALUE.test(`${type} ${resource}`)) return "temporal";
44725
+ if (/^image\//.test(type) || /\.(?:svg|png|jpe?g|webp|avif)(?:[?#]|$)/i.test(resource)) return "non-temporal";
44726
+ return "unknown";
44727
+ }
44720
44728
  function labelFor(doc, el) {
44721
44729
  const id = attr(el, "id");
44722
44730
  const lbl = id ? elementsByTag(doc, "label").find((l) => attr(l, "for") === id) : void 0;
@@ -45099,6 +45107,66 @@ var SUBJECTS = {
45099
45107
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45100
45108
  )
45101
45109
  ]),
45110
+ // RGAA 4.11 — temporal players and the code that controls their consultation. This must
45111
+ // not inherit WCAG 2.1.1/2.1.2's page-wide union of pointer handlers and focusables: an
45112
+ // unrelated clickable card says nothing about whether an <audio> can be paused.
45113
+ temporalMediaControls: (docs) => docs.flatMap((d) => [
45114
+ ...d.elements.filter((e) => e.tag === "audio" || e.tag === "video" || ["object", "embed"].includes(e.tag) && embeddedMediaKind(e) !== "non-temporal").map(
45115
+ (e) => h(
45116
+ d,
45117
+ e,
45118
+ `<${e.tag}> temporal media \u2014 controls=${attr(e, "controls") !== void 0}, autoplay=${attr(e, "autoplay") !== void 0}; can play/pause/stop and sound be operated by keyboard and pointer?`,
45119
+ `temporal-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? ""}|controls=${attr(e, "controls") !== void 0}`
45120
+ )
45121
+ ),
45122
+ ...linesOf(d, /\.(?:play|pause)\s*\(|\b(?:currentTime|muted|volume|playbackRate)\s*=|\bHTMLMediaElement\b/).map(
45123
+ (l) => hAt(d, l.line, "media-control", `custom temporal-media control: ${l.text}`, `temporal-control|${l.text}`)
45124
+ )
45125
+ ]),
45126
+ // RGAA 4.12 — non-temporal plug-in/SVG/canvas media. Static instances are included too:
45127
+ // the adjudicator must first establish whether they expose consultation controls before it
45128
+ // can legitimately answer NA. Temporal players and generic event handlers stay out.
45129
+ nonTemporalMediaControls: (docs) => docs.flatMap((d) => [
45130
+ ...d.elements.filter((e) => e.tag === "svg" || e.tag === "canvas" || ["object", "embed"].includes(e.tag) && embeddedMediaKind(e) !== "temporal").map(
45131
+ (e) => h(
45132
+ d,
45133
+ e,
45134
+ `<${e.tag}> non-temporal media \u2014 inspect any pan/zoom/navigation/control function for keyboard and pointer operation`,
45135
+ `non-temporal-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? attr(e, "id") ?? ""}`
45136
+ )
45137
+ ),
45138
+ ...linesOf(d, /\b(?:svgPanZoom|panzoom|zoomIn|zoomOut|getContext)\b/i).map(
45139
+ (l) => hAt(d, l.line, "media-control", `non-temporal media/control code: ${l.text}`, `non-temporal-control|${l.text}`)
45140
+ )
45141
+ ]),
45142
+ // WCAG 2.3.1 / RGAA 13.7 — only things capable of a luminance flash. The old `motion`
45143
+ // subject mixed autoplay audio and <marquee> into this criterion while omitting ordinary
45144
+ // images, canvas, scripts and stylesheet animation rules. That gave the adjudicator lots of
45145
+ // evidence and none of the evidence the three-flash test actually asks for.
45146
+ flashes: (docs) => docs.flatMap((d) => [
45147
+ ...d.elements.filter((e) => ["video", "img", "svg", "canvas", "embed", "object", "blink"].includes(e.tag)).map(
45148
+ (e) => h(
45149
+ d,
45150
+ e,
45151
+ `<${e.tag}> visual/animated content \u2014 does it flash, and if so is it at most 3 flashes/s or below the area threshold?`,
45152
+ `flash-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? attr(e, "id") ?? ""}`
45153
+ )
45154
+ ),
45155
+ ...elementsByTag(d, "script").map(
45156
+ (e) => h(d, e, `<script> \u2014 inspect for luminance/colour states toggled rapidly`, `flash-script|${attr(e, "src") ?? t(e, 80)}`)
45157
+ ),
45158
+ ...d.elements.filter((e) => /animation|transition/i.test(attr(e, "style") ?? "")).map((e) => h(d, e, `inline visual animation: ${(attr(e, "style") ?? "").slice(0, 100)}`, `flash-inline|${attr(e, "style") ?? ""}`)),
45159
+ ...(d.signals?.css?.rules ?? []).filter((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0).map(
45160
+ (r) => hAt(
45161
+ d,
45162
+ 1,
45163
+ `css:${r.selector}`,
45164
+ `stylesheet animation: ${r.selector.slice(0, 60)} { ${declsText(r.decls)} } \u2014 inspect keyframes for rapid opposing luminance changes`,
45165
+ `flash-css|${r.selector}|${declsText(r.decls)}`
45166
+ )
45167
+ ),
45168
+ ...linesOf(d, /\b(?:flash|flashing|strobe|setInterval|requestAnimationFrame|classList\.(?:add|remove|replace|toggle))\b|animation(?:-name)?\s*:/i).filter((l) => !/^\s*(?:<!--|\/\/|\/\*)/.test(l.text)).map((l) => hAt(d, l.line, "flash-code", `possible scripted/CSS flash mechanism: ${l.text}`, `flash-code|${l.text}`))
45169
+ ]),
45102
45170
  // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45103
45171
  //
45104
45172
  // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
@@ -45358,7 +45426,7 @@ var SC_SUBJECTS = {
45358
45426
  "2.1.4": ["shortcuts"],
45359
45427
  "2.2.1": ["timers"],
45360
45428
  "2.2.2": ["motion", "timers"],
45361
- "2.3.1": ["motion"],
45429
+ "2.3.1": ["flashes"],
45362
45430
  "2.4.1": ["landmarks"],
45363
45431
  "2.4.3": ["focusOrder"],
45364
45432
  "2.4.4": ["links"],
@@ -45451,6 +45519,10 @@ var PACK_SUBJECTS = {
45451
45519
  // WCAG 1.4.2, which is `static` and therefore has no subject of its own — but the pack
45452
45520
  // flags 4.10 `judgment`, so judgmentGuard reopens it and it would arrive with nothing.
45453
45521
  "4.10": ["motion"],
45522
+ // 4.11/4.12 map to the same keyboard SCs but ask about disjoint media populations.
45523
+ // Inheriting the union handed both criteria every pointer handler and focusable in scope.
45524
+ "4.11": ["temporalMediaControls"],
45525
+ "4.12": ["nonTemporalMediaControls"],
45454
45526
  // Theme 3 — colour. The question is about what colour alone conveys, not the outline.
45455
45527
  "3.1": ["colourPairs", "links"],
45456
45528
  "3.2": ["colourPairs"],
@@ -45487,7 +45559,7 @@ var PACK_SUBJECTS = {
45487
45559
  "13.2": ["newWindow"],
45488
45560
  "13.3": ["downloadDocs"],
45489
45561
  "13.4": ["downloadDocs"],
45490
- "13.7": ["motion"],
45562
+ "13.7": ["flashes"],
45491
45563
  "13.8": ["motion", "timers"],
45492
45564
  "13.9": ["readingOrder"],
45493
45565
  "13.10": ["pointerHandlers"],
@@ -49414,7 +49486,7 @@ var SUBJECT_MATTER = {
49414
49486
  "2.1.4": hasCharShortcut,
49415
49487
  "2.2.1": hasTimeLimit,
49416
49488
  "2.2.2": hasMovingContent,
49417
- "2.3.1": hasMovingContent,
49489
+ "2.3.1": hasFlashCandidate,
49418
49490
  "2.5.1": hasGesture,
49419
49491
  "2.5.2": hasDownEventAction,
49420
49492
  "2.5.7": hasDrag
@@ -49459,6 +49531,14 @@ function hasMovingContent(d) {
49459
49531
  if ((d.signals?.css?.rules ?? []).some((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0)) return true;
49460
49532
  return /\b(?:carousel|autoplay|requestAnimationFrame|animation-iteration-count|\.gif["']|\bswiper\b)/i.test(d.source);
49461
49533
  }
49534
+ function hasFlashCandidate(d) {
49535
+ if (has(d, "video", "img", "svg", "canvas", "embed", "object", "blink", "script")) return true;
49536
+ if (d.elements.some((e) => /animation|transition/i.test(e.attribs.style ?? ""))) return true;
49537
+ if ((d.signals?.css?.rules ?? []).some((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0)) return true;
49538
+ return /\b(?:flash|flashing|strobe|blink|setInterval|requestAnimationFrame|classList\.(?:add|remove|replace|toggle))\b|animation(?:-name)?\s*:/i.test(
49539
+ d.source
49540
+ );
49541
+ }
49462
49542
  function hasFormControl(d) {
49463
49543
  if (has(d, "input", "select", "textarea", "form", "fieldset", "output", "datalist")) return true;
49464
49544
  return d.elements.some((e) => {
@@ -3,7 +3,7 @@ name: review-a11y
3
3
  description: "Use to REVIEW changed frontend code for accessibility: staged files, a working diff, branch, or PR; prompts such as 'review a11y', 'is this accessible?', or 'anything to fix before merge?'; and hook or CI findings on a change. Run the bundled install-free engine with cross-file JSX/TSX analysis, then adjudicate candidates and return a severity-ranked WCAG 2.2 AA review with file:line fixes, residual rendering risks, and a verdict. Use `ultra11y` instead for whole-repo audits, dated conformance reports, country standards, PRD backlogs, or authoring accessible markup."
4
4
  license: MIT
5
5
  metadata:
6
- version: 5.35.1
6
+ version: 5.35.2
7
7
  ---
8
8
 
9
9
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join53, 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.35.1";
9
+ var VERSION = "5.35.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -44713,10 +44713,18 @@ var PRESENTATIONAL_MARKUP_ATTRS = /* @__PURE__ */ new Set([
44713
44713
  ]);
44714
44714
  var DOWNLOAD_HREF = /\.(pdf|docx?|xlsx?)(?:[?#]|$)/i;
44715
44715
  var STATUS_CLASS = /(error|status|message|alert|notif|toast|feedback|live)/i;
44716
+ var TEMPORAL_MEDIA_VALUE = /(?:^|[/.?=&_-])(?:audio|video|player|podcast|mp[34]|webm|ogg|ogv|wav|m4a|mov|avi|mpeg)(?:$|[/.?=&_-])/i;
44716
44717
  var ROUTER_IMPORT = /['"](?:react-router(?:-dom)?|next\/(?:router|navigation)|vue-router|@remix-run\/[\w-]+|@tanstack\/[\w-]*router|@sveltejs\/kit|\$app\/(?:navigation|stores))['"]/;
44717
44718
  var FOREIGN_LEXICON = /\b(?:the|and|with|your|about|please|download|settings|welcome|dashboard|overview|read more|learn more|sign in|sign up|log in|get started|coming soon|powered by)\b/i;
44718
44719
  var CONTROL_TAGS = ["input", "select", "textarea"];
44719
44720
  var INTERACTIVE_TAGS = ["a", "button", "input", "select", "textarea", "summary", "details", "label"];
44721
+ function embeddedMediaKind(el) {
44722
+ const type = (attr(el, "type") ?? "").toLowerCase();
44723
+ const resource = `${attr(el, "src") ?? ""} ${attr(el, "data") ?? ""}`.toLowerCase();
44724
+ if (/^(?:audio|video)\//.test(type) || TEMPORAL_MEDIA_VALUE.test(`${type} ${resource}`)) return "temporal";
44725
+ if (/^image\//.test(type) || /\.(?:svg|png|jpe?g|webp|avif)(?:[?#]|$)/i.test(resource)) return "non-temporal";
44726
+ return "unknown";
44727
+ }
44720
44728
  function labelFor(doc, el) {
44721
44729
  const id = attr(el, "id");
44722
44730
  const lbl = id ? elementsByTag(doc, "label").find((l) => attr(l, "for") === id) : void 0;
@@ -45099,6 +45107,66 @@ var SUBJECTS = {
45099
45107
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45100
45108
  )
45101
45109
  ]),
45110
+ // RGAA 4.11 — temporal players and the code that controls their consultation. This must
45111
+ // not inherit WCAG 2.1.1/2.1.2's page-wide union of pointer handlers and focusables: an
45112
+ // unrelated clickable card says nothing about whether an <audio> can be paused.
45113
+ temporalMediaControls: (docs) => docs.flatMap((d) => [
45114
+ ...d.elements.filter((e) => e.tag === "audio" || e.tag === "video" || ["object", "embed"].includes(e.tag) && embeddedMediaKind(e) !== "non-temporal").map(
45115
+ (e) => h(
45116
+ d,
45117
+ e,
45118
+ `<${e.tag}> temporal media \u2014 controls=${attr(e, "controls") !== void 0}, autoplay=${attr(e, "autoplay") !== void 0}; can play/pause/stop and sound be operated by keyboard and pointer?`,
45119
+ `temporal-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? ""}|controls=${attr(e, "controls") !== void 0}`
45120
+ )
45121
+ ),
45122
+ ...linesOf(d, /\.(?:play|pause)\s*\(|\b(?:currentTime|muted|volume|playbackRate)\s*=|\bHTMLMediaElement\b/).map(
45123
+ (l) => hAt(d, l.line, "media-control", `custom temporal-media control: ${l.text}`, `temporal-control|${l.text}`)
45124
+ )
45125
+ ]),
45126
+ // RGAA 4.12 — non-temporal plug-in/SVG/canvas media. Static instances are included too:
45127
+ // the adjudicator must first establish whether they expose consultation controls before it
45128
+ // can legitimately answer NA. Temporal players and generic event handlers stay out.
45129
+ nonTemporalMediaControls: (docs) => docs.flatMap((d) => [
45130
+ ...d.elements.filter((e) => e.tag === "svg" || e.tag === "canvas" || ["object", "embed"].includes(e.tag) && embeddedMediaKind(e) !== "temporal").map(
45131
+ (e) => h(
45132
+ d,
45133
+ e,
45134
+ `<${e.tag}> non-temporal media \u2014 inspect any pan/zoom/navigation/control function for keyboard and pointer operation`,
45135
+ `non-temporal-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? attr(e, "id") ?? ""}`
45136
+ )
45137
+ ),
45138
+ ...linesOf(d, /\b(?:svgPanZoom|panzoom|zoomIn|zoomOut|getContext)\b/i).map(
45139
+ (l) => hAt(d, l.line, "media-control", `non-temporal media/control code: ${l.text}`, `non-temporal-control|${l.text}`)
45140
+ )
45141
+ ]),
45142
+ // WCAG 2.3.1 / RGAA 13.7 — only things capable of a luminance flash. The old `motion`
45143
+ // subject mixed autoplay audio and <marquee> into this criterion while omitting ordinary
45144
+ // images, canvas, scripts and stylesheet animation rules. That gave the adjudicator lots of
45145
+ // evidence and none of the evidence the three-flash test actually asks for.
45146
+ flashes: (docs) => docs.flatMap((d) => [
45147
+ ...d.elements.filter((e) => ["video", "img", "svg", "canvas", "embed", "object", "blink"].includes(e.tag)).map(
45148
+ (e) => h(
45149
+ d,
45150
+ e,
45151
+ `<${e.tag}> visual/animated content \u2014 does it flash, and if so is it at most 3 flashes/s or below the area threshold?`,
45152
+ `flash-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? attr(e, "id") ?? ""}`
45153
+ )
45154
+ ),
45155
+ ...elementsByTag(d, "script").map(
45156
+ (e) => h(d, e, `<script> \u2014 inspect for luminance/colour states toggled rapidly`, `flash-script|${attr(e, "src") ?? t(e, 80)}`)
45157
+ ),
45158
+ ...d.elements.filter((e) => /animation|transition/i.test(attr(e, "style") ?? "")).map((e) => h(d, e, `inline visual animation: ${(attr(e, "style") ?? "").slice(0, 100)}`, `flash-inline|${attr(e, "style") ?? ""}`)),
45159
+ ...(d.signals?.css?.rules ?? []).filter((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0).map(
45160
+ (r) => hAt(
45161
+ d,
45162
+ 1,
45163
+ `css:${r.selector}`,
45164
+ `stylesheet animation: ${r.selector.slice(0, 60)} { ${declsText(r.decls)} } \u2014 inspect keyframes for rapid opposing luminance changes`,
45165
+ `flash-css|${r.selector}|${declsText(r.decls)}`
45166
+ )
45167
+ ),
45168
+ ...linesOf(d, /\b(?:flash|flashing|strobe|setInterval|requestAnimationFrame|classList\.(?:add|remove|replace|toggle))\b|animation(?:-name)?\s*:/i).filter((l) => !/^\s*(?:<!--|\/\/|\/\*)/.test(l.text)).map((l) => hAt(d, l.line, "flash-code", `possible scripted/CSS flash mechanism: ${l.text}`, `flash-code|${l.text}`))
45169
+ ]),
45102
45170
  // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45103
45171
  //
45104
45172
  // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
@@ -45358,7 +45426,7 @@ var SC_SUBJECTS = {
45358
45426
  "2.1.4": ["shortcuts"],
45359
45427
  "2.2.1": ["timers"],
45360
45428
  "2.2.2": ["motion", "timers"],
45361
- "2.3.1": ["motion"],
45429
+ "2.3.1": ["flashes"],
45362
45430
  "2.4.1": ["landmarks"],
45363
45431
  "2.4.3": ["focusOrder"],
45364
45432
  "2.4.4": ["links"],
@@ -45451,6 +45519,10 @@ var PACK_SUBJECTS = {
45451
45519
  // WCAG 1.4.2, which is `static` and therefore has no subject of its own — but the pack
45452
45520
  // flags 4.10 `judgment`, so judgmentGuard reopens it and it would arrive with nothing.
45453
45521
  "4.10": ["motion"],
45522
+ // 4.11/4.12 map to the same keyboard SCs but ask about disjoint media populations.
45523
+ // Inheriting the union handed both criteria every pointer handler and focusable in scope.
45524
+ "4.11": ["temporalMediaControls"],
45525
+ "4.12": ["nonTemporalMediaControls"],
45454
45526
  // Theme 3 — colour. The question is about what colour alone conveys, not the outline.
45455
45527
  "3.1": ["colourPairs", "links"],
45456
45528
  "3.2": ["colourPairs"],
@@ -45487,7 +45559,7 @@ var PACK_SUBJECTS = {
45487
45559
  "13.2": ["newWindow"],
45488
45560
  "13.3": ["downloadDocs"],
45489
45561
  "13.4": ["downloadDocs"],
45490
- "13.7": ["motion"],
45562
+ "13.7": ["flashes"],
45491
45563
  "13.8": ["motion", "timers"],
45492
45564
  "13.9": ["readingOrder"],
45493
45565
  "13.10": ["pointerHandlers"],
@@ -49414,7 +49486,7 @@ var SUBJECT_MATTER = {
49414
49486
  "2.1.4": hasCharShortcut,
49415
49487
  "2.2.1": hasTimeLimit,
49416
49488
  "2.2.2": hasMovingContent,
49417
- "2.3.1": hasMovingContent,
49489
+ "2.3.1": hasFlashCandidate,
49418
49490
  "2.5.1": hasGesture,
49419
49491
  "2.5.2": hasDownEventAction,
49420
49492
  "2.5.7": hasDrag
@@ -49459,6 +49531,14 @@ function hasMovingContent(d) {
49459
49531
  if ((d.signals?.css?.rules ?? []).some((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0)) return true;
49460
49532
  return /\b(?:carousel|autoplay|requestAnimationFrame|animation-iteration-count|\.gif["']|\bswiper\b)/i.test(d.source);
49461
49533
  }
49534
+ function hasFlashCandidate(d) {
49535
+ if (has(d, "video", "img", "svg", "canvas", "embed", "object", "blink", "script")) return true;
49536
+ if (d.elements.some((e) => /animation|transition/i.test(e.attribs.style ?? ""))) return true;
49537
+ if ((d.signals?.css?.rules ?? []).some((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0)) return true;
49538
+ return /\b(?:flash|flashing|strobe|blink|setInterval|requestAnimationFrame|classList\.(?:add|remove|replace|toggle))\b|animation(?:-name)?\s*:/i.test(
49539
+ d.source
49540
+ );
49541
+ }
49462
49542
  function hasFormControl(d) {
49463
49543
  if (has(d, "input", "select", "textarea", "form", "fieldset", "output", "datalist")) return true;
49464
49544
  return d.elements.some((e) => {
@@ -3,7 +3,7 @@ name: ultra11y
3
3
  description: "Use to AUDIT a repository, site, or page against WCAG 2.2 AA or a country standard such as RGAA; produce a dated conformance report, criterion grid, PRD backlog, or tickets; AUTHOR accessible markup; fix accessibility; or wire a repo gate and rendered-DOM capture pipeline. The bundled install-free engine runs 81 static checks tied to success criteria, while the agent adjudicates judgment criteria and routes rendering criteria to browser scans; check/verify reject invented non-conformities. Use `review-a11y` instead for a review scoped only to a diff, branch, or PR."
4
4
  license: MIT
5
5
  metadata:
6
- version: 5.35.1
6
+ version: 5.35.2
7
7
  ---
8
8
 
9
9
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -6,7 +6,7 @@ import { join as join53, 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.35.1";
9
+ var VERSION = "5.35.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -44713,10 +44713,18 @@ var PRESENTATIONAL_MARKUP_ATTRS = /* @__PURE__ */ new Set([
44713
44713
  ]);
44714
44714
  var DOWNLOAD_HREF = /\.(pdf|docx?|xlsx?)(?:[?#]|$)/i;
44715
44715
  var STATUS_CLASS = /(error|status|message|alert|notif|toast|feedback|live)/i;
44716
+ var TEMPORAL_MEDIA_VALUE = /(?:^|[/.?=&_-])(?:audio|video|player|podcast|mp[34]|webm|ogg|ogv|wav|m4a|mov|avi|mpeg)(?:$|[/.?=&_-])/i;
44716
44717
  var ROUTER_IMPORT = /['"](?:react-router(?:-dom)?|next\/(?:router|navigation)|vue-router|@remix-run\/[\w-]+|@tanstack\/[\w-]*router|@sveltejs\/kit|\$app\/(?:navigation|stores))['"]/;
44717
44718
  var FOREIGN_LEXICON = /\b(?:the|and|with|your|about|please|download|settings|welcome|dashboard|overview|read more|learn more|sign in|sign up|log in|get started|coming soon|powered by)\b/i;
44718
44719
  var CONTROL_TAGS = ["input", "select", "textarea"];
44719
44720
  var INTERACTIVE_TAGS = ["a", "button", "input", "select", "textarea", "summary", "details", "label"];
44721
+ function embeddedMediaKind(el) {
44722
+ const type = (attr(el, "type") ?? "").toLowerCase();
44723
+ const resource = `${attr(el, "src") ?? ""} ${attr(el, "data") ?? ""}`.toLowerCase();
44724
+ if (/^(?:audio|video)\//.test(type) || TEMPORAL_MEDIA_VALUE.test(`${type} ${resource}`)) return "temporal";
44725
+ if (/^image\//.test(type) || /\.(?:svg|png|jpe?g|webp|avif)(?:[?#]|$)/i.test(resource)) return "non-temporal";
44726
+ return "unknown";
44727
+ }
44720
44728
  function labelFor(doc, el) {
44721
44729
  const id = attr(el, "id");
44722
44730
  const lbl = id ? elementsByTag(doc, "label").find((l) => attr(l, "for") === id) : void 0;
@@ -45099,6 +45107,66 @@ var SUBJECTS = {
45099
45107
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45100
45108
  )
45101
45109
  ]),
45110
+ // RGAA 4.11 — temporal players and the code that controls their consultation. This must
45111
+ // not inherit WCAG 2.1.1/2.1.2's page-wide union of pointer handlers and focusables: an
45112
+ // unrelated clickable card says nothing about whether an <audio> can be paused.
45113
+ temporalMediaControls: (docs) => docs.flatMap((d) => [
45114
+ ...d.elements.filter((e) => e.tag === "audio" || e.tag === "video" || ["object", "embed"].includes(e.tag) && embeddedMediaKind(e) !== "non-temporal").map(
45115
+ (e) => h(
45116
+ d,
45117
+ e,
45118
+ `<${e.tag}> temporal media \u2014 controls=${attr(e, "controls") !== void 0}, autoplay=${attr(e, "autoplay") !== void 0}; can play/pause/stop and sound be operated by keyboard and pointer?`,
45119
+ `temporal-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? ""}|controls=${attr(e, "controls") !== void 0}`
45120
+ )
45121
+ ),
45122
+ ...linesOf(d, /\.(?:play|pause)\s*\(|\b(?:currentTime|muted|volume|playbackRate)\s*=|\bHTMLMediaElement\b/).map(
45123
+ (l) => hAt(d, l.line, "media-control", `custom temporal-media control: ${l.text}`, `temporal-control|${l.text}`)
45124
+ )
45125
+ ]),
45126
+ // RGAA 4.12 — non-temporal plug-in/SVG/canvas media. Static instances are included too:
45127
+ // the adjudicator must first establish whether they expose consultation controls before it
45128
+ // can legitimately answer NA. Temporal players and generic event handlers stay out.
45129
+ nonTemporalMediaControls: (docs) => docs.flatMap((d) => [
45130
+ ...d.elements.filter((e) => e.tag === "svg" || e.tag === "canvas" || ["object", "embed"].includes(e.tag) && embeddedMediaKind(e) !== "temporal").map(
45131
+ (e) => h(
45132
+ d,
45133
+ e,
45134
+ `<${e.tag}> non-temporal media \u2014 inspect any pan/zoom/navigation/control function for keyboard and pointer operation`,
45135
+ `non-temporal-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? attr(e, "id") ?? ""}`
45136
+ )
45137
+ ),
45138
+ ...linesOf(d, /\b(?:svgPanZoom|panzoom|zoomIn|zoomOut|getContext)\b/i).map(
45139
+ (l) => hAt(d, l.line, "media-control", `non-temporal media/control code: ${l.text}`, `non-temporal-control|${l.text}`)
45140
+ )
45141
+ ]),
45142
+ // WCAG 2.3.1 / RGAA 13.7 — only things capable of a luminance flash. The old `motion`
45143
+ // subject mixed autoplay audio and <marquee> into this criterion while omitting ordinary
45144
+ // images, canvas, scripts and stylesheet animation rules. That gave the adjudicator lots of
45145
+ // evidence and none of the evidence the three-flash test actually asks for.
45146
+ flashes: (docs) => docs.flatMap((d) => [
45147
+ ...d.elements.filter((e) => ["video", "img", "svg", "canvas", "embed", "object", "blink"].includes(e.tag)).map(
45148
+ (e) => h(
45149
+ d,
45150
+ e,
45151
+ `<${e.tag}> visual/animated content \u2014 does it flash, and if so is it at most 3 flashes/s or below the area threshold?`,
45152
+ `flash-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? attr(e, "id") ?? ""}`
45153
+ )
45154
+ ),
45155
+ ...elementsByTag(d, "script").map(
45156
+ (e) => h(d, e, `<script> \u2014 inspect for luminance/colour states toggled rapidly`, `flash-script|${attr(e, "src") ?? t(e, 80)}`)
45157
+ ),
45158
+ ...d.elements.filter((e) => /animation|transition/i.test(attr(e, "style") ?? "")).map((e) => h(d, e, `inline visual animation: ${(attr(e, "style") ?? "").slice(0, 100)}`, `flash-inline|${attr(e, "style") ?? ""}`)),
45159
+ ...(d.signals?.css?.rules ?? []).filter((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0).map(
45160
+ (r) => hAt(
45161
+ d,
45162
+ 1,
45163
+ `css:${r.selector}`,
45164
+ `stylesheet animation: ${r.selector.slice(0, 60)} { ${declsText(r.decls)} } \u2014 inspect keyframes for rapid opposing luminance changes`,
45165
+ `flash-css|${r.selector}|${declsText(r.decls)}`
45166
+ )
45167
+ ),
45168
+ ...linesOf(d, /\b(?:flash|flashing|strobe|setInterval|requestAnimationFrame|classList\.(?:add|remove|replace|toggle))\b|animation(?:-name)?\s*:/i).filter((l) => !/^\s*(?:<!--|\/\/|\/\*)/.test(l.text)).map((l) => hAt(d, l.line, "flash-code", `possible scripted/CSS flash mechanism: ${l.text}`, `flash-code|${l.text}`))
45169
+ ]),
45102
45170
  // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45103
45171
  //
45104
45172
  // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
@@ -45358,7 +45426,7 @@ var SC_SUBJECTS = {
45358
45426
  "2.1.4": ["shortcuts"],
45359
45427
  "2.2.1": ["timers"],
45360
45428
  "2.2.2": ["motion", "timers"],
45361
- "2.3.1": ["motion"],
45429
+ "2.3.1": ["flashes"],
45362
45430
  "2.4.1": ["landmarks"],
45363
45431
  "2.4.3": ["focusOrder"],
45364
45432
  "2.4.4": ["links"],
@@ -45451,6 +45519,10 @@ var PACK_SUBJECTS = {
45451
45519
  // WCAG 1.4.2, which is `static` and therefore has no subject of its own — but the pack
45452
45520
  // flags 4.10 `judgment`, so judgmentGuard reopens it and it would arrive with nothing.
45453
45521
  "4.10": ["motion"],
45522
+ // 4.11/4.12 map to the same keyboard SCs but ask about disjoint media populations.
45523
+ // Inheriting the union handed both criteria every pointer handler and focusable in scope.
45524
+ "4.11": ["temporalMediaControls"],
45525
+ "4.12": ["nonTemporalMediaControls"],
45454
45526
  // Theme 3 — colour. The question is about what colour alone conveys, not the outline.
45455
45527
  "3.1": ["colourPairs", "links"],
45456
45528
  "3.2": ["colourPairs"],
@@ -45487,7 +45559,7 @@ var PACK_SUBJECTS = {
45487
45559
  "13.2": ["newWindow"],
45488
45560
  "13.3": ["downloadDocs"],
45489
45561
  "13.4": ["downloadDocs"],
45490
- "13.7": ["motion"],
45562
+ "13.7": ["flashes"],
45491
45563
  "13.8": ["motion", "timers"],
45492
45564
  "13.9": ["readingOrder"],
45493
45565
  "13.10": ["pointerHandlers"],
@@ -49414,7 +49486,7 @@ var SUBJECT_MATTER = {
49414
49486
  "2.1.4": hasCharShortcut,
49415
49487
  "2.2.1": hasTimeLimit,
49416
49488
  "2.2.2": hasMovingContent,
49417
- "2.3.1": hasMovingContent,
49489
+ "2.3.1": hasFlashCandidate,
49418
49490
  "2.5.1": hasGesture,
49419
49491
  "2.5.2": hasDownEventAction,
49420
49492
  "2.5.7": hasDrag
@@ -49459,6 +49531,14 @@ function hasMovingContent(d) {
49459
49531
  if ((d.signals?.css?.rules ?? []).some((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0)) return true;
49460
49532
  return /\b(?:carousel|autoplay|requestAnimationFrame|animation-iteration-count|\.gif["']|\bswiper\b)/i.test(d.source);
49461
49533
  }
49534
+ function hasFlashCandidate(d) {
49535
+ if (has(d, "video", "img", "svg", "canvas", "embed", "object", "blink", "script")) return true;
49536
+ if (d.elements.some((e) => /animation|transition/i.test(e.attribs.style ?? ""))) return true;
49537
+ if ((d.signals?.css?.rules ?? []).some((r) => r.decls.animation !== void 0 || r.decls.animationName !== void 0)) return true;
49538
+ return /\b(?:flash|flashing|strobe|blink|setInterval|requestAnimationFrame|classList\.(?:add|remove|replace|toggle))\b|animation(?:-name)?\s*:/i.test(
49539
+ d.source
49540
+ );
49541
+ }
49462
49542
  function hasFormControl(d) {
49463
49543
  if (has(d, "input", "select", "textarea", "form", "fieldset", "output", "datalist")) return true;
49464
49544
  return d.elements.some((e) => {
@@ -214,6 +214,7 @@ const PRESENTATIONAL_MARKUP_ATTRS = new Set([
214
214
 
215
215
  const DOWNLOAD_HREF = /\.(pdf|docx?|xlsx?)(?:[?#]|$)/i;
216
216
  const STATUS_CLASS = /(error|status|message|alert|notif|toast|feedback|live)/i;
217
+ const TEMPORAL_MEDIA_VALUE = /(?:^|[/.?=&_-])(?:audio|video|player|podcast|mp[34]|webm|ogg|ogv|wav|m4a|mov|avi|mpeg)(?:$|[/.?=&_-])/i;
217
218
  const ROUTER_IMPORT =
218
219
  /['"](?:react-router(?:-dom)?|next\/(?:router|navigation)|vue-router|@remix-run\/[\w-]+|@tanstack\/[\w-]*router|@sveltejs\/kit|\$app\/(?:navigation|stores))['"]/;
219
220
 
@@ -226,6 +227,17 @@ const FOREIGN_LEXICON =
226
227
  const CONTROL_TAGS = ["input", "select", "textarea"] as const;
227
228
  const INTERACTIVE_TAGS = ["a", "button", "input", "select", "textarea", "summary", "details", "label"];
228
229
 
230
+ /** An object/embed is ambiguous until its declared type or resource says what it carries.
231
+ * Unknown plug-in content stays in BOTH multimedia populations: narrowing uncertainty to one
232
+ * side can hide the exact temporal/non-temporal player the criterion was meant to inspect. */
233
+ function embeddedMediaKind(el: El): "temporal" | "non-temporal" | "unknown" {
234
+ const type = (attr(el, "type") ?? "").toLowerCase();
235
+ const resource = `${attr(el, "src") ?? ""} ${attr(el, "data") ?? ""}`.toLowerCase();
236
+ if (/^(?:audio|video)\//.test(type) || TEMPORAL_MEDIA_VALUE.test(`${type} ${resource}`)) return "temporal";
237
+ if (/^image\//.test(type) || /\.(?:svg|png|jpe?g|webp|avif)(?:[?#]|$)/i.test(resource)) return "non-temporal";
238
+ return "unknown";
239
+ }
240
+
229
241
  function labelFor(doc: Doc, el: El): string {
230
242
  const id = attr(el, "id");
231
243
  const lbl = id ? elementsByTag(doc, "label").find((l) => attr(l, "for") === id) : undefined;
@@ -736,6 +748,84 @@ export const SUBJECTS: Record<string, Subject> = {
736
748
  ),
737
749
  ]),
738
750
 
751
+ // RGAA 4.11 — temporal players and the code that controls their consultation. This must
752
+ // not inherit WCAG 2.1.1/2.1.2's page-wide union of pointer handlers and focusables: an
753
+ // unrelated clickable card says nothing about whether an <audio> can be paused.
754
+ temporalMediaControls: (docs) =>
755
+ docs.flatMap((d) => [
756
+ ...d.elements
757
+ .filter((e) => e.tag === "audio" || e.tag === "video" || (["object", "embed"].includes(e.tag) && embeddedMediaKind(e) !== "non-temporal"))
758
+ .map((e) =>
759
+ h(
760
+ d,
761
+ e,
762
+ `<${e.tag}> temporal media — controls=${attr(e, "controls") !== undefined}, autoplay=${attr(e, "autoplay") !== undefined}; can play/pause/stop and sound be operated by keyboard and pointer?`,
763
+ `temporal-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? ""}|controls=${attr(e, "controls") !== undefined}`,
764
+ ),
765
+ ),
766
+ ...linesOf(d, /\.(?:play|pause)\s*\(|\b(?:currentTime|muted|volume|playbackRate)\s*=|\bHTMLMediaElement\b/).map((l) =>
767
+ hAt(d, l.line, "media-control", `custom temporal-media control: ${l.text}`, `temporal-control|${l.text}`),
768
+ ),
769
+ ]),
770
+
771
+ // RGAA 4.12 — non-temporal plug-in/SVG/canvas media. Static instances are included too:
772
+ // the adjudicator must first establish whether they expose consultation controls before it
773
+ // can legitimately answer NA. Temporal players and generic event handlers stay out.
774
+ nonTemporalMediaControls: (docs) =>
775
+ docs.flatMap((d) => [
776
+ ...d.elements
777
+ .filter((e) => e.tag === "svg" || e.tag === "canvas" || (["object", "embed"].includes(e.tag) && embeddedMediaKind(e) !== "temporal"))
778
+ .map((e) =>
779
+ h(
780
+ d,
781
+ e,
782
+ `<${e.tag}> non-temporal media — inspect any pan/zoom/navigation/control function for keyboard and pointer operation`,
783
+ `non-temporal-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? attr(e, "id") ?? ""}`,
784
+ ),
785
+ ),
786
+ ...linesOf(d, /\b(?:svgPanZoom|panzoom|zoomIn|zoomOut|getContext)\b/i).map((l) =>
787
+ hAt(d, l.line, "media-control", `non-temporal media/control code: ${l.text}`, `non-temporal-control|${l.text}`),
788
+ ),
789
+ ]),
790
+
791
+ // WCAG 2.3.1 / RGAA 13.7 — only things capable of a luminance flash. The old `motion`
792
+ // subject mixed autoplay audio and <marquee> into this criterion while omitting ordinary
793
+ // images, canvas, scripts and stylesheet animation rules. That gave the adjudicator lots of
794
+ // evidence and none of the evidence the three-flash test actually asks for.
795
+ flashes: (docs) =>
796
+ docs.flatMap((d) => [
797
+ ...d.elements
798
+ .filter((e) => ["video", "img", "svg", "canvas", "embed", "object", "blink"].includes(e.tag))
799
+ .map((e) =>
800
+ h(
801
+ d,
802
+ e,
803
+ `<${e.tag}> visual/animated content — does it flash, and if so is it at most 3 flashes/s or below the area threshold?`,
804
+ `flash-media|${e.tag}|${attr(e, "src") ?? attr(e, "data") ?? attr(e, "id") ?? ""}`,
805
+ ),
806
+ ),
807
+ ...elementsByTag(d, "script").map((e) =>
808
+ h(d, e, `<script> — inspect for luminance/colour states toggled rapidly`, `flash-script|${attr(e, "src") ?? t(e, 80)}`),
809
+ ),
810
+ ...d.elements
811
+ .filter((e) => /animation|transition/i.test(attr(e, "style") ?? ""))
812
+ .map((e) => h(d, e, `inline visual animation: ${(attr(e, "style") ?? "").slice(0, 100)}`, `flash-inline|${attr(e, "style") ?? ""}`)),
813
+ ...(d.signals?.css?.rules ?? [])
814
+ .filter((r) => r.decls.animation !== undefined || r.decls.animationName !== undefined)
815
+ .map((r) =>
816
+ hAt(
817
+ d,
818
+ 1,
819
+ `css:${r.selector}`,
820
+ `stylesheet animation: ${r.selector.slice(0, 60)} { ${declsText(r.decls)} } — inspect keyframes for rapid opposing luminance changes`,
821
+ `flash-css|${r.selector}|${declsText(r.decls)}`,
822
+ ),
823
+ ),
824
+ ...linesOf(d, /\b(?:flash|flashing|strobe|setInterval|requestAnimationFrame|classList\.(?:add|remove|replace|toggle))\b|animation(?:-name)?\s*:/i)
825
+ .filter((l) => !/^\s*(?:<!--|\/\/|\/\*)/.test(l.text))
826
+ .map((l) => hAt(d, l.line, "flash-code", `possible scripted/CSS flash mechanism: ${l.text}`, `flash-code|${l.text}`)),
827
+ ]),
828
+
739
829
  // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
740
830
  //
741
831
  // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
@@ -1056,7 +1146,7 @@ export const SC_SUBJECTS: Record<string, string[]> = {
1056
1146
  "2.1.4": ["shortcuts"],
1057
1147
  "2.2.1": ["timers"],
1058
1148
  "2.2.2": ["motion", "timers"],
1059
- "2.3.1": ["motion"],
1149
+ "2.3.1": ["flashes"],
1060
1150
  "2.4.1": ["landmarks"],
1061
1151
  "2.4.3": ["focusOrder"],
1062
1152
  "2.4.4": ["links"],
@@ -1156,6 +1246,10 @@ export const PACK_SUBJECTS: Record<string, Record<string, string[]>> = {
1156
1246
  // WCAG 1.4.2, which is `static` and therefore has no subject of its own — but the pack
1157
1247
  // flags 4.10 `judgment`, so judgmentGuard reopens it and it would arrive with nothing.
1158
1248
  "4.10": ["motion"],
1249
+ // 4.11/4.12 map to the same keyboard SCs but ask about disjoint media populations.
1250
+ // Inheriting the union handed both criteria every pointer handler and focusable in scope.
1251
+ "4.11": ["temporalMediaControls"],
1252
+ "4.12": ["nonTemporalMediaControls"],
1159
1253
  // Theme 3 — colour. The question is about what colour alone conveys, not the outline.
1160
1254
  "3.1": ["colourPairs", "links"],
1161
1255
  "3.2": ["colourPairs"],
@@ -1192,7 +1286,7 @@ export const PACK_SUBJECTS: Record<string, Record<string, string[]>> = {
1192
1286
  "13.2": ["newWindow"],
1193
1287
  "13.3": ["downloadDocs"],
1194
1288
  "13.4": ["downloadDocs"],
1195
- "13.7": ["motion"],
1289
+ "13.7": ["flashes"],
1196
1290
  "13.8": ["motion", "timers"],
1197
1291
  "13.9": ["readingOrder"],
1198
1292
  "13.10": ["pointerHandlers"],
package/src/audit.ts CHANGED
@@ -143,7 +143,7 @@ const SUBJECT_MATTER: Record<string, (d: Doc) => boolean> = {
143
143
  "2.1.4": hasCharShortcut,
144
144
  "2.2.1": hasTimeLimit,
145
145
  "2.2.2": hasMovingContent,
146
- "2.3.1": hasMovingContent,
146
+ "2.3.1": hasFlashCandidate,
147
147
  "2.5.1": hasGesture,
148
148
  "2.5.2": hasDownEventAction,
149
149
  "2.5.7": hasDrag,
@@ -235,6 +235,21 @@ function hasMovingContent(d: Doc): boolean {
235
235
  return /\b(?:carousel|autoplay|requestAnimationFrame|animation-iteration-count|\.gif["']|\bswiper\b)/i.test(d.source);
236
236
  }
237
237
 
238
+ /** Anything that can produce opposing luminance changes over time (WCAG 2.3.1).
239
+ *
240
+ * Sound-only media and scrolling text are motion, not flashes. Conversely, an image, canvas,
241
+ * visual media, script or CSS animation can flash even when no carousel/autoplay keyword is
242
+ * present. Keep this deliberately wider than a detector: it decides applicability only; the
243
+ * agent or a dedicated analyser still decides the frequency and area thresholds. */
244
+ function hasFlashCandidate(d: Doc): boolean {
245
+ if (has(d, "video", "img", "svg", "canvas", "embed", "object", "blink", "script")) return true;
246
+ if (d.elements.some((e) => /animation|transition/i.test(e.attribs.style ?? ""))) return true;
247
+ if ((d.signals?.css?.rules ?? []).some((r) => r.decls.animation !== undefined || r.decls.animationName !== undefined)) return true;
248
+ return /\b(?:flash|flashing|strobe|blink|setInterval|requestAnimationFrame|classList\.(?:add|remove|replace|toggle))\b|animation(?:-name)?\s*:/i.test(
249
+ d.source,
250
+ );
251
+ }
252
+
238
253
  /** Any control that takes user input, native or delegated. Over-inclusive on purpose. */
239
254
  function hasFormControl(d: Doc): boolean {
240
255
  if (has(d, "input", "select", "textarea", "form", "fieldset", "output", "datalist")) return true;
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.35.1";
3
+ export const VERSION = "5.35.2";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,