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:
parent
e32b4172e4
commit
83b5f0bd89
3 changed files with 70 additions and 15 deletions
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue