Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions website/static/website/css/carousel_fade.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
162 changes: 116 additions & 46 deletions website/static/website/js/carousel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
Expand All @@ -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) {
Expand All @@ -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() {
Expand All @@ -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) {
Expand All @@ -141,7 +207,7 @@

/* -------------------------- Pause conditions ------------------------ */

if (pauseOnHover) {
if (multi && pauseOnHover) {
root.addEventListener('mouseenter', function () {
paused = true;
stop();
Expand All @@ -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();
}

Expand Down
40 changes: 32 additions & 8 deletions website/static/website/js/makelab-logo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions website/static/website/js/reduced-motion.js
Original file line number Diff line number Diff line change
@@ -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
);
};
})();
Loading
Loading