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 %}
-