Add a colour wash animation

The frame parks in the middle and the colour changes instead: a gradient
creeps round the colour wheel behind the poster, and the frame's glow
travels with it. Every other animation moves the frame, so the still one
reads as a rest between them rather than as a dead spot.

The wash layer blends with mix-blend-mode: color, which keeps the
poster's luminance and replaces only its hue. soft-light was the first
try and barely shifted the colour at all against a poster already knocked
back to 45% brightness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
This commit is contained in:
simon
2026-08-09 23:11:42 +02:00
co-authored by Claude Opus 5
parent 6d306d1b98
commit 223292708f
5 changed files with 120 additions and 2 deletions
+76
View File
@@ -19,6 +19,9 @@ import {
TRAIL_SPACING,
TRAIL_LENGTH,
TRAIL_FADE_SECONDS,
WASH_CYCLE_SECONDS,
WASH_SPREAD,
WASH_STRENGTH,
AUTO_ANIMATION,
} from '/config.js';
@@ -903,6 +906,78 @@ export function startDragResize({ element }) {
};
}
// --- colour wash --------------------------------------------------------
// The frame parks in the middle and the room changes colour around it: a wash
// creeps round the colour wheel behind the poster, and the frame's glow travels
// with it, so the whole screen shifts as one. It is the quiet one — everything
// else in the registry moves the frame, and after a few of those a still frame
// with a slow colour behind it is a relief rather than a dead spot.
/**
* The two ends of the wash gradient at `elapsed` seconds, as hues in degrees.
* One trip round the wheel takes WASH_CYCLE_SECONDS, and the second hue trails
* the first by WASH_SPREAD so the gradient always has somewhere to go. Pure, so
* the cycle can be exercised without a browser.
*/
export function washHues(elapsed) {
const turn = ((elapsed / WASH_CYCLE_SECONDS) * 360) % 360;
return [turn, (turn + WASH_SPREAD) % 360];
}
/** A wash stop as a colour: saturated and mid-bright, before the blend mode. */
function washStop(hue, alpha = 1) {
return `hsl(${hue.toFixed(1)} 85% 55% / ${alpha})`;
}
// The frame's glow is the wash colour held back to the same weight the fixed
// pink had, so a hue with more punch to it does not brighten the whole frame.
const GLOW_ALPHA = 0.55;
export function startWash({ element }) {
const wash = document.createElement('div');
wash.id = 'wash';
wash.style.opacity = String(WASH_STRENGTH);
// Behind the stills and the frame, in front of the poster it tints.
document.body.appendChild(wash);
element.classList.add('washing');
function centre() {
const size = applyFrameSize(element);
place(element, (window.innerWidth - size.width) / 2, (window.innerHeight - size.height) / 2);
}
centre();
window.addEventListener('resize', centre);
const started = performance.now();
let handle = 0;
let stopped = false;
function frame(now) {
if (stopped) return;
const [from, to] = washHues((now - started) / 1000);
wash.style.background = `linear-gradient(135deg, ${washStop(from)}, ${washStop(to)})`;
// The frame's glow rides the leading hue, so it belongs to the wash instead
// of sitting on top of it in a fixed pink.
element.style.setProperty('--wash', washStop(from, GLOW_ALPHA));
handle = requestAnimationFrame(frame);
}
handle = requestAnimationFrame(frame);
return function stop() {
stopped = true;
cancelAnimationFrame(handle);
window.removeEventListener('resize', centre);
wash.remove();
element.classList.remove('washing');
element.style.removeProperty('--wash');
};
}
// --- registry -----------------------------------------------------------
// Keyed by the names in config.js, which is what travels over the socket.
@@ -911,6 +986,7 @@ export const ANIMATIONS = {
corners: startCorners,
drag: startDragResize,
trail: startTrail,
wash: startWash,
};
const NAMES = Object.keys(ANIMATIONS);