auditmysite 1.7.3

Resource-efficient WCAG 2.2 AA Accessibility Checker in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! WCAG 2.2.2 Pause, Stop, Hide (Level A)
//!
//! "For moving, blinking, scrolling, or auto-updating information that (a)
//! starts automatically, (b) lasts more than five seconds, and (c) is
//! presented in parallel with other content, there is a mechanism for the
//! user to pause, stop, or hide it, unless the movement, blinking, scrolling,
//! or auto-updating is part of an activity where it is essential."
//!
//! **What this checks.** A single DOM+CSSOM scan (no live timing/observation
//! — no waiting to see whether something is actually still moving after 5
//! seconds), scoped to two concrete, low-false-positive anti-patterns:
//!
//! 1. **`<marquee>` elements.** This legacy tag has no native pause
//!    affordance at all — any use of it is content that starts automatically,
//!    scrolls indefinitely, and cannot be paused/stopped/hidden by the
//!    element itself. Presence alone is a strong signal.
//! 2. **CSS animations/transitions that loop indefinitely for a substantial
//!    duration** — elements whose computed style has
//!    `animation-iteration-count: infinite` and a per-iteration
//!    `animation-duration` over 5 seconds. Short infinite loops (spinners,
//!    subtle pulses well under a second per cycle) are deliberately excluded
//!    — they read as decorative micro-interactions rather than the kind of
//!    slow, attention-grabbing motion this criterion targets, and excluding
//!    them keeps false positives down.
//!
//! Both signals are suppressed if the page appears to already offer *some*
//! plausible pause/stop control (a button/role=button/link anywhere on the
//! page whose accessible text or `aria-label`/`title` mentions
//! pause/stop/animation, in German or English). This is a page-wide, not a
//! per-element, check — this project's existing per-component pattern
//! detectors (`src/patterns/`) have no carousel/slider/ticker detection to
//! reuse, and building precise DOM-proximity ("is this specific control next
//! to this specific animated element") logic from scratch was judged
//! out of scope for a single new rule. The tradeoff is explicit: a global
//! pause control for widget A can incorrectly suppress a genuine violation
//! in unrelated widget B. Kept conservative in the direction of fewer false
//! positives, consistent with this criterion landing as a heuristic warning.
//!
//! CSS-animation findings are also suppressed when the page's stylesheets
//! already contain an `@media (prefers-reduced-motion` rule (mirroring the
//! detection `reduced_motion.rs` uses for 2.3.3) — a site that already reduces
//! or disables animation for that preference has effectively provided a
//! "hide" mechanism for the users who need it most. This does not apply to
//! the `<marquee>` case: `prefers-reduced-motion` is a CSS media feature and
//! cannot affect a `<marquee>` element's built-in scrolling behavior, so a
//! marquee finding fires regardless.
//!
//! Deliberately **not** implemented:
//! - Auto-advancing carousel/slider detection. `src/patterns/` (this
//!   project's UI-pattern-detection module) has no carousel/slider/ticker
//!   pattern to reuse, and fingerprinting carousel libraries (Slick, Swiper,
//!   …) from scratch is exactly the scope-creep this rule avoids — it would
//!   need a much larger, separate effort to do reliably.
//! - Live observation of whether content is *actually* still moving after 5
//!   seconds (e.g. re-checking computed styles after a real wait, or
//!   watching for DOM mutations). This check only reads static, declared
//!   CSS/HTML, which cannot see JS-driven auto-updating regions (e.g. a
//!   `setInterval`-refreshed ticker with no CSS animation at all).
//! - CDP-level `prefers-reduced-motion` emulation/re-diffing (as
//!   `dark_mode/mod.rs` does for `prefers-color-scheme`). The static
//!   stylesheet scan already used by `reduced_motion.rs` answers the same
//!   underlying question ("does the site define reduced-motion behavior at
//!   all") without a second page load.
//!
//! Because absence of a detected pause control does not prove no such
//! mechanism exists (e.g. a keyboard shortcut, or a settings page), findings
//! are reported as [`crate::wcag::types::Outcome::Review`] (heuristic,
//! manual-review candidate) — the same treatment as `redundant_entry.rs`,
//! `meaningful_sequence.rs`, and `focus_not_obscured_minimum.rs`.

use chromiumoxide::Page;

use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation};

pub(super) const PAUSE_STOP_HIDE_RULE: RuleMetadata = RuleMetadata {
    id: "2.2.2",
    name: "Pause, Stop, Hide",
    level: WcagLevel::A,
    severity: Severity::Medium,
    description: "Auto-starting moving, blinking, or scrolling content that lasts more than 5 seconds can be paused, stopped, or hidden",
    help_url: "https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html",
    axe_id: "pause-stop-hide",
    tags: &["wcag2a", "wcag222", "cat.time-and-media"],
};

/// Per-iteration duration, in seconds, above which an indefinitely-looping
/// CSS animation is treated as a candidate violation. Short infinite loops
/// (spinners, subtle pulses) are excluded to keep false positives down — see
/// module docs.
const MIN_INFINITE_ANIMATION_SECONDS: f64 = 5.0;

/// One indefinitely-looping animated element, as extracted from the live
/// DOM/CSSOM.
#[derive(Debug, Clone)]
struct AnimatedElement {
    selector: String,
    duration_seconds: f64,
}

/// Pure decision function: given the raw page scan results, decide which
/// findings (if any) fire. Mirrors the shape of `redundant_entry`'s
/// `group_and_flag` / `meaningful_sequence`'s `is_visually_reordered` — a
/// pure, unit-testable core wrapped by the `_with_page` JS-collection glue.
fn decide(
    marquee_count: usize,
    infinite_animations: &[AnimatedElement],
    has_pause_control: bool,
    respects_reduced_motion: bool,
) -> (bool, Vec<AnimatedElement>) {
    let marquee_fires = marquee_count > 0 && !has_pause_control;
    let animations_fire = if has_pause_control || respects_reduced_motion {
        Vec::new()
    } else {
        infinite_animations.to_vec()
    };
    (marquee_fires, animations_fire)
}

const PAUSE_STOP_HIDE_JS: &str = r#"
(function() {
  /*CSS_SELECTOR*/
  var PAUSE_CONTROL_PATTERN = /\b(pause|stop|anhalten|stopp|bewegung|animation)\b/i;

  function isPlausiblePauseControl(el) {
    var text = (el.getAttribute('aria-label') || el.getAttribute('title') || el.textContent || '').trim();
    return PAUSE_CONTROL_PATTERN.test(text);
  }

  var hasPauseControl = false;
  var controls = document.querySelectorAll('button, [role="button"], a[href]');
  for (var c = 0; c < controls.length && !hasPauseControl; c++) {
    if (isPlausiblePauseControl(controls[c])) hasPauseControl = true;
  }

  var marqueeCount = document.querySelectorAll('marquee').length;

  var respectsReducedMotion = false;
  try {
    for (const sheet of Array.from(document.styleSheets)) {
      let rules;
      try { rules = Array.from(sheet.cssRules || []); }
      catch (e) { continue; }
      const walk = (rs) => {
        for (const r of rs) {
          if (r.type === CSSRule.MEDIA_RULE) {
            const condition = (r.conditionText || (r.media && r.media.mediaText) || '').toLowerCase();
            if (condition.includes('prefers-reduced-motion')) {
              respectsReducedMotion = true;
            }
            if (r.cssRules) walk(Array.from(r.cssRules));
          }
        }
      };
      walk(rules);
    }
  } catch (e) {}

  function parseMaxSeconds(value) {
    var parts = (value || '').split(',');
    var max = 0;
    for (var i = 0; i < parts.length; i++) {
      var v = parts[i].trim();
      var seconds = 0;
      if (v.endsWith('ms')) seconds = parseFloat(v) / 1000;
      else if (v.endsWith('s')) seconds = parseFloat(v);
      if (!isNaN(seconds) && seconds > max) max = seconds;
    }
    return max;
  }

  var animated = [];
  var all = document.querySelectorAll('*');
  for (var i = 0; i < all.length && animated.length < 5; i++) {
    var el = all[i];
    // Excluded subtrees (#645) cannot raise this page-level finding.
    if (__amsIsExcludedEl(el)) continue;
    var cs = getComputedStyle(el);
    var iterationCount = cs.animationIterationCount || '';
    if (iterationCount.indexOf('infinite') === -1) continue;
    var duration = parseMaxSeconds(cs.animationDuration);
    if (duration <= 0) continue;
    animated.push({
      selector: __amsCssSelector(el),
      duration_seconds: duration
    });
  }

  // Only explicit autoplay/update signals are considered. Library class names
  // that merely indicate a carousel exists are deliberately insufficient.
  var autoWidgets = [];
  var widgetSelectors = '[data-autoplay="true"], [data-ride="carousel"], [data-bs-ride="carousel"], .swiper-autoplay-running, [data-update-interval]';
  for (const widget of document.querySelectorAll(widgetSelectors)) {
    const interval = parseFloat(widget.getAttribute('data-interval') || widget.getAttribute('data-bs-interval') || widget.getAttribute('data-update-interval') || '0');
    const explicitRunning = widget.matches('.swiper-autoplay-running, [data-autoplay="true"], [data-ride="carousel"], [data-bs-ride="carousel"]');
    if (!explicitRunning && interval <= 5000) continue;
    __amsPush(autoWidgets, widget, {
      selector: __amsCssSelector(widget),
      interval_ms: interval || null
    }, 5);
    if (__amsReal(autoWidgets) >= 5) break;
  }

  return {
    marquee_count: marqueeCount,
    has_pause_control: hasPauseControl,
    respects_reduced_motion: respectsReducedMotion,
    animated: animated,
    auto_widgets: autoWidgets
  };
})()
"#;

pub async fn check_pause_stop_hide_with_page(page: &Page) -> Vec<Violation> {
    let val = match crate::wcag::types::evaluate_or_fail(
        page,
        &PAUSE_STOP_HIDE_RULE,
        &PAUSE_STOP_HIDE_JS.replace(
            "/*CSS_SELECTOR*/",
            crate::accessibility::js_helpers::CSS_SELECTOR_JS,
        ),
    )
    .await
    {
        Ok(v) => v,
        Err(violations) => return violations,
    };

    let marquee_count = val
        .get("marquee_count")
        .and_then(|v| v.as_u64())
        .unwrap_or(0) as usize;
    let has_pause_control = val
        .get("has_pause_control")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let respects_reduced_motion = val
        .get("respects_reduced_motion")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    let infinite_animations: Vec<AnimatedElement> = val
        .get("animated")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|entry| {
                    let selector = entry.get("selector").and_then(|v| v.as_str())?.to_string();
                    let duration_seconds =
                        entry.get("duration_seconds").and_then(|v| v.as_f64())?;
                    if duration_seconds <= MIN_INFINITE_ANIMATION_SECONDS {
                        return None;
                    }
                    Some(AnimatedElement {
                        selector,
                        duration_seconds,
                    })
                })
                .collect()
        })
        .unwrap_or_default();

    let (marquee_fires, animations_fire) = decide(
        marquee_count,
        &infinite_animations,
        has_pause_control,
        respects_reduced_motion,
    );

    let mut violations = Vec::new();

    if !has_pause_control {
        if let Some(widgets) = val.get("auto_widgets").and_then(|value| value.as_array()) {
            for widget in widgets {
                let Some(selector) = widget.get("selector").and_then(|value| value.as_str()) else {
                    continue;
                };
                violations.push(
                    Violation::new(
                        PAUSE_STOP_HIDE_RULE.id,
                        PAUSE_STOP_HIDE_RULE.name,
                        PAUSE_STOP_HIDE_RULE.level,
                        PAUSE_STOP_HIDE_RULE.severity,
                        "An explicitly auto-running carousel or updating widget was detected without a recognizable pause, stop, or hide control.",
                        selector,
                    )
                    .with_selector(selector)
                    .with_rule_id(PAUSE_STOP_HIDE_RULE.axe_id)
                    .with_kind(crate::wcag::types::Outcome::Review)
                    .with_fix("Provide a visible pause, stop, or hide control next to the auto-running content")
                    .with_help_url(PAUSE_STOP_HIDE_RULE.help_url),
                );
            }
        }
    }

    if marquee_fires {
        violations.push(
            Violation::new(
                PAUSE_STOP_HIDE_RULE.id,
                PAUSE_STOP_HIDE_RULE.name,
                PAUSE_STOP_HIDE_RULE.level,
                PAUSE_STOP_HIDE_RULE.severity,
                format!(
                    "Page uses {} legacy `<marquee>` element(s), which scroll automatically and \
                     indefinitely with no native pause, stop, or hide control, and no other \
                     pause control was detected on the page.",
                    marquee_count
                ),
                "marquee",
            )
            .with_selector("marquee")
            .with_fix(
                "Replace `<marquee>` with a modern implementation (CSS animation or a small \
                 script) that includes a visible pause/stop button, or remove the automatic \
                 scrolling entirely.",
            )
            .with_rule_id(PAUSE_STOP_HIDE_RULE.axe_id)
            .with_help_url(PAUSE_STOP_HIDE_RULE.help_url)
            .as_warning(),
        );
    }

    if !animations_fire.is_empty() {
        let sample = animations_fire
            .iter()
            .take(3)
            .map(|a| format!("{} ({:.1}s/cycle)", a.selector, a.duration_seconds))
            .collect::<Vec<_>>()
            .join(", ");
        violations.push(
            Violation::new(
                PAUSE_STOP_HIDE_RULE.id,
                PAUSE_STOP_HIDE_RULE.name,
                PAUSE_STOP_HIDE_RULE.level,
                PAUSE_STOP_HIDE_RULE.severity,
                format!(
                    "{} element(s) run a CSS animation with `animation-iteration-count: infinite` \
                     and a per-cycle duration over {}s ({}), with no pause/stop control and no \
                     `prefers-reduced-motion` handling detected on the page.",
                    animations_fire.len(),
                    MIN_INFINITE_ANIMATION_SECONDS,
                    sample
                ),
                &animations_fire[0].selector,
            )
            .with_selector(&animations_fire[0].selector)
            .with_fix(
                "Add a visible pause/stop control for the animation, or wrap it in \
                 `@media (prefers-reduced-motion: reduce) { animation: none; }` so users who need \
                 to stop it can.",
            )
            .with_rule_id(PAUSE_STOP_HIDE_RULE.axe_id)
            .with_help_url(PAUSE_STOP_HIDE_RULE.help_url)
            .as_warning(),
        );
    }

    violations
}

#[cfg(test)]
mod tests {
    use super::*;

    fn animation(selector: &str, seconds: f64) -> AnimatedElement {
        AnimatedElement {
            selector: selector.to_string(),
            duration_seconds: seconds,
        }
    }

    #[test]
    fn fires_for_marquee_with_no_pause_control() {
        let (marquee_fires, animations_fire) = decide(1, &[], false, false);
        assert!(marquee_fires);
        assert!(animations_fire.is_empty());
    }

    #[test]
    fn fires_for_long_infinite_animation_with_no_pause_control() {
        let animations = vec![animation("div.ticker", 8.0)];
        let (marquee_fires, animations_fire) = decide(0, &animations, false, false);
        assert!(!marquee_fires);
        assert_eq!(animations_fire.len(), 1);
    }

    #[test]
    fn does_not_fire_when_pause_control_detected() {
        let animations = vec![animation("div.ticker", 8.0)];
        let (marquee_fires, animations_fire) = decide(1, &animations, true, false);
        assert!(!marquee_fires);
        assert!(animations_fire.is_empty());
    }

    #[test]
    fn does_not_fire_for_animation_when_reduced_motion_is_respected() {
        let animations = vec![animation("div.ticker", 8.0)];
        let (marquee_fires, animations_fire) = decide(0, &animations, false, true);
        assert!(!marquee_fires);
        assert!(animations_fire.is_empty());
    }

    #[test]
    fn marquee_still_fires_when_reduced_motion_is_respected() {
        // prefers-reduced-motion is a CSS media feature and has no effect on
        // a <marquee> element's native scrolling behavior, so it must not
        // suppress the marquee finding.
        let (marquee_fires, animations_fire) = decide(1, &[], false, true);
        assert!(marquee_fires);
        assert!(animations_fire.is_empty());
    }
}