swarm-ui: a start page at / and the hive directory at /hives
/ is now the swarm's name over the digital-rain backdrop the hive dashboard's home has, and the swarm name in the header links to it. The hive directory moves to /hives, where the nav's hives tab points. The swarm name is read once per page load by a shared hook, the header and the start page both use it. Closes #5252
This commit is contained in:
parent
be2e64fa89
commit
417125b33c
10 changed files with 240 additions and 37 deletions
116
frontend/packages/swarm-ui/src/shell/MatrixRain.tsx
Normal file
116
frontend/packages/swarm-ui/src/shell/MatrixRain.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// <MatrixRain> — the falling-glyph "digital rain" backdrop, drawn on a canvas
|
||||
// that fills its positioned parent. Purely decorative, so it is hidden from
|
||||
// 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
|
||||
// page, which is plain JS over a fixed canvas.
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import "./MatrixRain.css";
|
||||
|
||||
const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
|
||||
const CELL = 16; // glyph cell size, in px
|
||||
const STEP_MS = 55;
|
||||
|
||||
// `getComputedStyle` on a custom property returns its declared value
|
||||
// unresolved (`var(--x)`), so resolve a concrete rgb() by computing a real
|
||||
// `color` on a probe element instead.
|
||||
function resolveColor(varName: string, fallback: string): string {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.color = `var(${varName})`;
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
const c = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return /^rgb/.test(c) ? c : fallback;
|
||||
}
|
||||
|
||||
export function MatrixRain() {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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.
|
||||
let glyphColor = "";
|
||||
let fadeColor = "";
|
||||
function resolveColors() {
|
||||
glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
|
||||
// The fade trail is the background at low alpha. Its channels are
|
||||
// pulled out numerically so the trail fades whatever spacing
|
||||
// rgb()/rgba() comes back in, and never fills solid.
|
||||
const bg = resolveColor("--bg", "rgb(30, 30, 46)").match(
|
||||
/(\d+)[^\d]+(\d+)[^\d]+(\d+)/,
|
||||
);
|
||||
fadeColor = bg
|
||||
? `rgba(${bg[1]}, ${bg[2]}, ${bg[3]}, 0.09)`
|
||||
: "rgba(30, 30, 46, 0.09)";
|
||||
}
|
||||
resolveColors();
|
||||
const themeWatch = new MutationObserver(resolveColors);
|
||||
themeWatch.observe(document.documentElement, { attributes: true });
|
||||
const schemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
schemeQuery.addEventListener("change", resolveColors);
|
||||
|
||||
let cols = 0;
|
||||
let drops: number[] = [];
|
||||
|
||||
function resize() {
|
||||
if (!canvas || !ctx) return;
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
cols = Math.max(1, Math.ceil(canvas.width / CELL));
|
||||
drops = Array.from({ length: cols }, () =>
|
||||
Math.floor(Math.random() * -40),
|
||||
);
|
||||
ctx.font = `${CELL}px monospace`; // a canvas resize resets the font
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.fillStyle = fadeColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = glyphColor;
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const ch = GLYPHS[Math.floor(Math.random() * GLYPHS.length)] ?? "0";
|
||||
ctx.fillText(ch, i * CELL, (drops[i] ?? 0) * CELL);
|
||||
if ((drops[i] ?? 0) * CELL > canvas.height && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
}
|
||||
drops[i] = (drops[i] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
let timer: number | null = null;
|
||||
const play = () => {
|
||||
if (timer === null) timer = window.setInterval(tick, STEP_MS);
|
||||
};
|
||||
const pause = () => {
|
||||
if (timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const onVisibility = () => (document.hidden ? pause() : play());
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
play();
|
||||
return () => {
|
||||
pause();
|
||||
themeWatch.disconnect();
|
||||
schemeQuery.removeEventListener("change", resolveColors);
|
||||
window.removeEventListener("resize", resize);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={ref} class="matrix-rain" aria-hidden="true" />;
|
||||
}
|
||||
Loading…
Reference in a new issue