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);
+21
View File
@@ -41,6 +41,20 @@ body {
opacity: 1;
}
/* Built and removed by the wash animation. Sits between the poster and the
stills, and blends rather than covers, so the poster stays legible through
whatever colour is passing over it. The gradient itself is written per frame
from JS. */
#wash {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
/* Takes the poster's luminance and only replaces its hue, so the wash reads
as a colour change rather than as a sheet of colour laid over the top. */
mix-blend-mode: color;
}
/* --- frozen screenshots ------------------------------------------------ */
#stills {
@@ -108,6 +122,13 @@ body {
0 40px 100px rgba(0, 0, 0, 0.85);
}
/* The wash animation hands the frame the colour currently passing behind it. */
#frameMover.washing #frameSkin {
box-shadow:
0 0 0 4px var(--wash, rgba(255, 79, 195, 0.55)),
0 24px 70px rgba(0, 0, 0, 0.7);
}
/* --- the drawn pointer -------------------------------------------------- */
/* Built and removed by the drag animation; nothing else on the page has one. */