Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the start page's rain follows the app's motion setting

The rain read only the OS reduced-motion query, once, at mount. The settings
menu's motion override (data-motion on the document) wins over the OS in
either direction everywhere else, and the shell applies it from an effect
that runs after this one. Whether the rain may move is now rainAllowed(), and
the component re-evaluates it on mount, on every document attribute change
and on the media query's change, starting or stopping the interval and
clearing the canvas.
This commit is contained in:
iris 2026-10-14 17:28:36 +02:00 • committed by mara
commit 83b5f0bd89
3 changed files with 70 additions and 15 deletions

View file

@ -3,8 +3,9 @@
// assistive tech and takes no pointer events. The glyphs take the live
// `--green` and the fade trail the live `--bg`, so a theme swap recolours it.
// Stepped on an interval (the steppy cadence is the look, and it costs
// little), paused while the tab is hidden, and not drawn at all under
// `prefers-reduced-motion`. The same effect as the hive dashboard's home
// little), paused while the tab is hidden, and stopped (and cleared) when
// motion is reduced: the settings menu's `data-motion` override wins over
// `prefers-reduced-motion`, and is followed live. The same effect as the hive dashboard's home
// page, which is plain JS over a fixed canvas.
import { useEffect, useRef } from "preact/hooks";
import {
@ -12,6 +13,7 @@ import {
columnsFor,
fadeColorOf,
glyphFor,
rainAllowed,
startDrops,
stepDrops,
} from "./rain.js";
@ -39,11 +41,11 @@ export function MatrixRain() {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Resolved again whenever the theme can have changed: the shell applies
// its theme override from an effect that runs after this one, and a
// later switch from the settings menu changes it too.
// Resolved again whenever the theme or motion setting can have changed:
// the shell applies its overrides (`data-theme`, `data-motion`) from an
// effect that runs after this one, and a later switch from the settings
// menu changes them too.
let glyphColor = "";
let fadeColor = "";
function resolveColors() {
@ -51,10 +53,8 @@ export function MatrixRain() {
fadeColor = fadeColorOf(resolveColor("--bg", "rgb(30, 30, 46)"));
}
resolveColors();
const themeWatch = new MutationObserver(resolveColors);
themeWatch.observe(document.documentElement, { attributes: true });
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const schemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
schemeQuery.addEventListener("change", resolveColors);
let cols = 0;
let drops: number[] = [];
@ -79,6 +79,9 @@ export function MatrixRain() {
drops = stepDrops(drops, canvas.height, Math.random);
}
const allowed = () =>
rainAllowed(document.documentElement.dataset.motion, motionQuery.matches);
let timer: number | null = null;
const play = () => {
if (timer === null) timer = window.setInterval(tick, STEP_MS);
@ -89,18 +92,40 @@ export function MatrixRain() {
timer = null;
}
};
const onVisibility = () => (document.hidden ? pause() : play());
// Runs the rain when motion is allowed and the tab is visible; otherwise
// stops it and clears what was drawn. Safe to call on any change.
function sync() {
if (allowed() && !document.hidden) {
play();
} else {
pause();
if (!allowed() && canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
}
const onSettingsChange = () => {
resolveColors();
sync();
};
const watch = new MutationObserver(onSettingsChange);
watch.observe(document.documentElement, { attributes: true });
schemeQuery.addEventListener("change", onSettingsChange);
motionQuery.addEventListener("change", onSettingsChange);
resize();
window.addEventListener("resize", resize);
document.addEventListener("visibilitychange", onVisibility);
play();
document.addEventListener("visibilitychange", sync);
sync();
return () => {
pause();
themeWatch.disconnect();
schemeQuery.removeEventListener("change", resolveColors);
watch.disconnect();
schemeQuery.removeEventListener("change", onSettingsChange);
motionQuery.removeEventListener("change", onSettingsChange);
window.removeEventListener("resize", resize);
document.removeEventListener("visibilitychange", onVisibility);
document.removeEventListener("visibilitychange", sync);
};
}, []);

View file

@ -19,6 +19,19 @@ const RESTART_ABOVE = 0.975;
/** The fade trail's alpha. */
const FADE_ALPHA = 0.09;
/** Whether the rain may move. The app's own motion setting (`data-motion` on
* the document, from the settings menu) wins over the OS preference in either
* direction: `reduce` stops it, `allow` lets it run on a reduce-motion OS, and
* anything else follows the OS. */
export function rainAllowed(
dataMotion: string | undefined,
osReduces: boolean,
): boolean {
if (dataMotion === "reduce") return false;
if (dataMotion === "allow") return true;
return !osReduces;
}
/** How many columns fill a canvas this wide. */
export function columnsFor(width: number): number {
return Math.max(1, Math.ceil(width / CELL));

View file

@ -9,12 +9,29 @@ import {
columnsFor,
fadeColorOf,
glyphFor,
rainAllowed,
startDrops,
stepDrops,
} from "../src/shell/rain.ts";
const always = (n) => () => n;
test("the app's reduce setting stops the rain whatever the OS says", () => {
assert.equal(rainAllowed("reduce", false), false);
assert.equal(rainAllowed("reduce", true), false);
});
test("the app's allow setting runs the rain even on a reduce-motion OS", () => {
assert.equal(rainAllowed("allow", true), true);
assert.equal(rainAllowed("allow", false), true);
});
test("with no app setting the rain follows the OS", () => {
assert.equal(rainAllowed(undefined, false), true);
assert.equal(rainAllowed(undefined, true), false);
assert.equal(rainAllowed("", true), false);
});
test("a canvas gets one column per cell, rounded up, and at least one", () => {
assert.equal(columnsFor(CELL * 10), 10);
assert.equal(columnsFor(CELL * 10 + 1), 11);