From 72ed0da3b41b24f6f5ec315d4d0692ec30bc99e3 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Thu, 18 Jun 2026 21:08:23 -0700 Subject: [PATCH] feat(a11y): honor prefers-reduced-motion for video + logo, add hero pause control (#1294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three motion sources now respect the OS-level prefers-reduced-motion preference, plus a WCAG 2.2.2 (Pause, Stop, Hide) pause mechanism for the auto-moving hero. - New shared helper window.MakeLab.prefersReducedMotion() (reduced-motion.js), queried live so an OS toggle mid-session is respected; loaded before carousel.js and consulted by the makelab-logo.js module too. - Hero video: keep autoplay in the markup (progressive enhancement — the no-JS baseline still plays); carousel.js pauses the active video under reduced motion / the pause control so the poster shows. Poster comes from the banner's existing image when set (no model change). - Logo canvas: under reduced motion, pin the morph to its assembled end state (lerp = 1), skip the scroll handler, and drop "Animated" from the label. - WCAG 2.2.2: one visible, keyboard-operable pause/play toggle governing both the auto-advance and the looping video. Ships hidden so no-JS users never see a dead control; carousel.js reveals it only when motion exists. - Regression tests: hero video keeps autoplay, gets a poster only when the banner has an image, and the pause control renders. Refs #1196, #1288, #1293. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/static/website/css/carousel_fade.css | 50 ++++++ website/static/website/js/carousel.js | 162 +++++++++++++------ website/static/website/js/makelab-logo.js | 40 ++++- website/static/website/js/reduced-motion.js | 34 ++++ website/templates/website/base.html | 25 ++- website/tests/test_hero_motion.py | 87 ++++++++++ 6 files changed, 342 insertions(+), 56 deletions(-) create mode 100644 website/static/website/js/reduced-motion.js create mode 100644 website/tests/test_hero_motion.py diff --git a/website/static/website/css/carousel_fade.css b/website/static/website/css/carousel_fade.css index 16a368fd..992e6eb5 100644 --- a/website/static/website/css/carousel_fade.css +++ b/website/static/website/css/carousel_fade.css @@ -52,3 +52,53 @@ transition: none; } } + +/* --------------------------------------------------------------------------- + Motion pause/play control (WCAG 2.2.2 Pause, Stop, Hide). + A discoverable button that pauses/plays the auto-advance and the looping + hero video. Shipped `hidden`; carousel.js reveals it when motion exists. + Sits above the carousel overlay/indicators (z-index) in the bottom-right. + --------------------------------------------------------------------------- */ +.carousel-motion-toggle { + position: absolute; + bottom: 16px; + right: 16px; + z-index: 20; + display: inline-flex; + align-items: center; + justify-content: center; + /* >= 44x44 CSS px target (WCAG 2.5.5). */ + width: 44px; + height: 44px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 50%; + /* Solid-ish chip for >= 3:1 non-text contrast over video (WCAG 1.4.11). */ + background-color: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.carousel-motion-toggle[hidden] { + display: none; +} + +.carousel-motion-toggle:hover { + background-color: rgba(0, 0, 0, 0.8); +} + +/* Clearly visible keyboard focus indicator (WCAG 2.4.7), legible over video. */ +.carousel-motion-toggle:focus-visible { + outline: 3px solid #fff; + outline-offset: 2px; + box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.6); +} + +@media (prefers-reduced-motion: reduce) { + .carousel-motion-toggle { + transition: none; + } +} diff --git a/website/static/website/js/carousel.js b/website/static/website/js/carousel.js index c3ab29fb..1534ce51 100644 --- a/website/static/website/js/carousel.js +++ b/website/static/website/js/carousel.js @@ -13,7 +13,13 @@ * - data-pause="false": do NOT pause on mouse hover (default is to pause). * * Accessibility: - * - Honors prefers-reduced-motion: no autoplay and (via CSS) no crossfade. + * - Honors prefers-reduced-motion: no autoplay, no looping hero video, and + * (via CSS) no crossfade. The video keeps its `autoplay` attribute so the + * no-JS baseline still plays it; this script pauses it when reduced motion + * is set. See issue #1294. + * - WCAG 2.2.2 (Pause, Stop, Hide): a visible `.carousel-motion-toggle` + * button (if present) pauses/plays BOTH the auto-advance and the hero video + * for all users — a discoverable mechanism, not just hover/focus. * - Pauses while focus is inside the carousel (keyboard users reading links) * and while the browser tab is hidden. * - Marks the active indicator dot with aria-current. @@ -24,9 +30,13 @@ (function () { 'use strict'; - var prefersReducedMotion = window.matchMedia - ? window.matchMedia('(prefers-reduced-motion: reduce)').matches - : false; + // Shared helper (reduced-motion.js, loaded first). Query live so an OS toggle + // mid-session is respected; fall back to false if the helper is missing. + function prefersReducedMotion() { + return !!(window.MakeLab && window.MakeLab.prefersReducedMotion + ? window.MakeLab.prefersReducedMotion() + : false); + } function setupCarousel(root) { var inner = root.querySelector('.carousel-inner'); @@ -51,26 +61,42 @@ current = 0; } + // The user-facing motion state, governing BOTH the auto-advance and the + // hero video. Starts paused when reduced motion is set, so under that + // preference nothing moves and the toggle (if any) offers "Play". A user + // can still explicitly opt into motion via the toggle (#1294). + var motionPaused = prefersReducedMotion(); + + function getActiveVideo() { + var slide = slides[current]; + return slide ? slide.querySelector('video') : null; + } + + function setVideoPlayback(video, shouldPlay) { + if (!video) { + return; + } + if (shouldPlay) { + var playback = video.play(); + if (playback && playback.catch) { + playback.catch(function () { /* autoplay blocked — ignore */ }); + } + } else { + video.pause(); + } + } + /** * Shows the slide at `index`, updates the indicator dots, and plays only - * the active slide's video (if any) to avoid decoding hidden videos. + * the active slide's video (if any) to avoid decoding hidden videos. The + * active video plays only when motion is not paused (reduced motion / the + * pause control), so the poster frame shows otherwise. */ function render(index) { slides.forEach(function (slide, i) { var isActive = i === index; slide.classList.toggle('active', isActive); - - var video = slide.querySelector('video'); - if (video) { - if (isActive) { - var playback = video.play(); - if (playback && playback.catch) { - playback.catch(function () { /* autoplay blocked — ignore */ }); - } - } else { - video.pause(); - } - } + setVideoPlayback(slide.querySelector('video'), isActive && !motionPaused); }); indicators.forEach(function (dot, i) { @@ -86,28 +112,22 @@ current = index; } - render(current); - - // A single slide has nothing to rotate or navigate. - if (slides.length < 2) { - return; - } - /* ----------------------------- Autoplay ----------------------------- */ var intervalAttr = root.getAttribute('data-interval'); var interval = intervalAttr == null ? 5000 : parseInt(intervalAttr, 10); var pauseOnHover = root.getAttribute('data-pause') !== 'false'; + var multi = slides.length >= 2; var timer = null; - var paused = false; + var paused = false; // transient pause (hover / focus / tab hidden) function showNext() { render((current + 1) % slides.length); } function canPlay() { - return !prefersReducedMotion && interval > 0 && !paused && !document.hidden; + return multi && !motionPaused && interval > 0 && !paused && !document.hidden; } function start() { @@ -130,6 +150,52 @@ start(); } + /* --------------- Motion toggle (WCAG 2.2.2 Pause/Stop) --------------- */ + // A single discoverable control that pauses/plays both the auto-advance and + // the looping hero video, for ALL users. Wired up regardless of slide count + // so a single-banner looping video can still be paused. The button ships + // `hidden` in the markup so no-JS users never see a dead control; we reveal + // it only when there is actually motion to govern. + + var toggleBtn = root.querySelector('.carousel-motion-toggle'); + var hasVideo = slides.some(function (slide) { + return !!slide.querySelector('video'); + }); + + function updateToggleButton() { + if (!toggleBtn) { + return; + } + var label = motionPaused ? 'Play background motion' : 'Pause background motion'; + toggleBtn.setAttribute('aria-label', label); + var sr = toggleBtn.querySelector('.sr-only'); + if (sr) { + sr.textContent = label; + } + var icon = toggleBtn.querySelector('i'); + if (icon) { + icon.classList.toggle('fa-play', motionPaused); + icon.classList.toggle('fa-pause', !motionPaused); + } + } + + function toggleMotion() { + motionPaused = !motionPaused; + setVideoPlayback(getActiveVideo(), !motionPaused); + if (motionPaused) { + stop(); + } else { + start(); + } + updateToggleButton(); + } + + if (toggleBtn && (multi || hasVideo)) { + toggleBtn.hidden = false; + toggleBtn.addEventListener('click', toggleMotion); + updateToggleButton(); + } + /* ---------------------------- Indicators ---------------------------- */ indicators.forEach(function (dot, i) { @@ -141,7 +207,7 @@ /* -------------------------- Pause conditions ------------------------ */ - if (pauseOnHover) { + if (multi && pauseOnHover) { root.addEventListener('mouseenter', function () { paused = true; stop(); @@ -152,27 +218,31 @@ }); } - // Pause while focus is inside the carousel (keyboard users on slide links). - root.addEventListener('focusin', function () { - paused = true; - stop(); - }); - root.addEventListener('focusout', function (event) { - if (!root.contains(event.relatedTarget)) { - paused = false; - start(); - } - }); - - // Pause when the tab is hidden; resume when it becomes visible again. - document.addEventListener('visibilitychange', function () { - if (document.hidden) { + if (multi) { + // Pause while focus is inside the carousel (keyboard users on slide links). + root.addEventListener('focusin', function () { + paused = true; stop(); - } else { - start(); - } - }); + }); + root.addEventListener('focusout', function (event) { + if (!root.contains(event.relatedTarget)) { + paused = false; + start(); + } + }); + // Pause when the tab is hidden; resume when it becomes visible again. + document.addEventListener('visibilitychange', function () { + if (document.hidden) { + stop(); + } else { + start(); + } + }); + } + + // Initial paint + autoplay (start() is a no-op unless canPlay()). + render(current); start(); } diff --git a/website/static/website/js/makelab-logo.js b/website/static/website/js/makelab-logo.js index 1cbc4f4f..d7fe9a07 100644 --- a/website/static/website/js/makelab-logo.js +++ b/website/static/website/js/makelab-logo.js @@ -70,11 +70,30 @@ const canvas = document.getElementById('makelab-logo-canvas'); const ctx = canvas.getContext('2d'); const parentDiv = document.querySelector('.col-md-6.center-canvas'); +// Honor prefers-reduced-motion (#1294): when set, the logo is rendered in its +// assembled END state (lerp = 1) and the scroll-driven morph is skipped — the +// logo is "stuck" fully assembled, no animation. Uses the shared helper +// (reduced-motion.js), querying live, with a matchMedia fallback. +function prefersReducedMotion() { + if (window.MakeLab && window.MakeLab.prefersReducedMotion) { + return window.MakeLab.prefersReducedMotion(); + } + return !!(window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches); +} + let logicalWidth, logicalHeight; let morpher = null; let isReady = false; let cachedArtData = null; -let currentLerpAmt = 0; +// Reduced motion -> pin to the assembled end state; otherwise start scattered. +let currentLerpAmt = prefersReducedMotion() ? 1 : 0; + +// The canvas markup labels itself "Animated ..."; drop "Animated" when the logo +// is rendered statically so the accessible name matches what's shown. +if (prefersReducedMotion()) { + canvas.setAttribute('aria-label', 'Makeability Lab logo'); +} // ============================================================================= // Core Functions @@ -184,13 +203,18 @@ function render() { // Handlers // ============================================================================= -window.addEventListener('scroll', () => { - currentLerpAmt = Math.min(window.scrollY / SCROLL_DISTANCE, 1); - if (morpher) { - morpher.update(currentLerpAmt); - render(); - } -}, { passive: true }); +// Scroll-driven morph — skipped entirely under reduced motion, where the logo +// stays pinned in its assembled end state (currentLerpAmt = 1). The +// ResizeObserver below still runs so the static logo stays correctly sized. +if (!prefersReducedMotion()) { + window.addEventListener('scroll', () => { + currentLerpAmt = Math.min(window.scrollY / SCROLL_DISTANCE, 1); + if (morpher) { + morpher.update(currentLerpAmt); + render(); + } + }, { passive: true }); +} const resizeObserver = new ResizeObserver(() => initOrResize()); resizeObserver.observe(parentDiv); diff --git a/website/static/website/js/reduced-motion.js b/website/static/website/js/reduced-motion.js new file mode 100644 index 00000000..21ef0f70 --- /dev/null +++ b/website/static/website/js/reduced-motion.js @@ -0,0 +1,34 @@ +/*! + * Shared reduced-motion helper. + * + * A single place for the `prefers-reduced-motion: reduce` check so every motion + * source on the site agrees. Exposed as a global (a classic script, not an ES + * module) so it can be consulted by both classic scripts (e.g. carousel.js) and + * ES modules (e.g. makelab-logo.js) alike. + * + * Load this BEFORE any script that needs it (see base.html). It queries + * matchMedia LIVE on each call, so toggling the OS setting mid-session is + * respected without a reload. + * + * Usage: + * if (window.MakeLab.prefersReducedMotion()) { ...skip/stop animation... } + * + * See issue #1294. Candidates to migrate onto this helper (currently each ships + * its own copy): project-listing-filter.js, bio-expand.js, member-nav.js. + */ +(function () { + 'use strict'; + + window.MakeLab = window.MakeLab || {}; + + /** + * @returns {boolean} true if the user has requested reduced motion at the OS + * level. Falls back to false when matchMedia is unavailable. + */ + window.MakeLab.prefersReducedMotion = function prefersReducedMotion() { + return !!( + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); + }; +})(); diff --git a/website/templates/website/base.html b/website/templates/website/base.html index 7d5bbbdc..9aae34b7 100644 --- a/website/templates/website/base.html +++ b/website/templates/website/base.html @@ -137,6 +137,7 @@ + @@ -304,7 +305,13 @@ aria-label="{% if banner.alt_text %}{{ banner.alt_text }}{% elif banner.caption %}{{ banner.caption }}{% else %}{{ banner.title }}{% endif %}"> {% if banner.video %} -