hyperhive/frontend/packages/dashboard/src/home.js
iris 1bc26429f2 dashboard(home): harden matrix-rain fade-trail colour parsing
Follow-up to the matrix-rain backdrop (merged): address argus's review nit
on the fadeColor construction. It string-rewrote `rgb(...)`→`rgba(...)`,
which silently fills the trail solid if getComputedStyle ever returns a
non-`rgb(r, g, b)` form. Extract r,g,b numerically and build the rgba
explicitly so the trail always fades regardless of the returned format
(same rgb fallback end-to-end). CSS/JS-only, home page.
2026-06-17 21:59:12 +02:00

126 lines
4.9 KiB
JavaScript

// H0M3 page script. The page is static markup; this only does
// a few small things off a single `/api/state` read:
// 1. reveal the Matrix tile when the matrix GUI is enabled (same
// gating as the dashboard's M4TR1X tab — no dead link otherwise);
// 2. fill the swarm/hive identity line when configured;
// 3. render the shared server-warnings banner (top of every page).
// No SSE — a portal doesn't need live updates.
//
// It also drives the decorative matrix-rain backdrop (#matrix-rain) —
// see startMatrixRain() at the foot.
import { renderServerWarnings } from './common.js';
const $ = (id) => document.getElementById(id);
async function init() {
let state;
try {
const resp = await fetch('/api/state');
if (!resp.ok) return;
state = await resp.json();
} catch {
return; // best-effort: the tiles still work without it
}
renderServerWarnings(state.server_warnings);
if (state.matrix_gui_enabled) {
const tile = $('home-tile-matrix');
if (tile) tile.hidden = false;
}
// Forge tile: reveal + point at the live forge only when the
// hive-forge container is up. Prefer the gateway-served public URL
// (set when forge.behindGateway=true), fall back to the direct :3000
// port — same precedence the dashboard uses for forge links.
if (state.forge_present) {
const tile = $('home-tile-forge');
if (tile) {
tile.href = state.forge_public_url || `http://${location.hostname}:3000`;
tile.hidden = false;
}
}
const ident = $('hive-identity');
if (ident && (state.swarm_name || state.hive_name)) {
const parts = [state.swarm_name, state.hive_name].filter(Boolean);
ident.textContent = parts.join(' / ');
ident.hidden = false;
}
}
init();
// ── matrix-rain backdrop ─────────────────────────────────────────────
// Classic falling-glyph "digital rain" on the #matrix-rain canvas, purely
// decorative. Colours are resolved from the live stylix palette (--green
// glyphs, --bg fade-trail) so a theme swap re-colours it; the CSS already
// dims it + parks it behind the content. Stepped via setInterval (the
// steppy cadence is the look and costs little CPU); paused while the tab is
// hidden, and skipped entirely under prefers-reduced-motion.
function startMatrixRain() {
const canvas = $('matrix-rain');
if (!canvas || !canvas.getContext) return;
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const ctx = canvas.getContext('2d');
// getComputedStyle on a custom property returns its *declared* value
// (e.g. "var(--base0B)") unresolved, so resolve to a concrete rgb() by
// probing a real `color` computation instead.
function resolveColor(varName, fallback) {
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;
}
const glyphColor = resolveColor('--green', 'rgb(166, 227, 161)');
// Fade-trail = the bg at low alpha. Pull r,g,b numerically rather than
// string-rewriting the rgb() — robust to whatever rgb()/rgba() spacing
// getComputedStyle hands back, so the trail always fades (never fills
// solid) even if the format shifts.
const bgRGB = resolveColor('--bg', 'rgb(30, 30, 46)').match(/(\d+)[^\d]+(\d+)[^\d]+(\d+)/);
const fadeColor = bgRGB
? `rgba(${bgRGB[1]}, ${bgRGB[2]}, ${bgRGB[3]}, 0.09)`
: 'rgba(30, 30, 46, 0.09)';
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦';
const CELL = 16; // glyph cell size (px)
let cols = 0;
let drops = [];
function resize() {
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`; // canvas reset on resize clears this
}
function tick() {
// Translucent bg wash leaves a fading trail behind each glyph.
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.random() * GLYPHS.length) | 0];
ctx.fillText(ch, i * CELL, drops[i] * CELL);
if (drops[i] * CELL > canvas.height && Math.random() > 0.975) drops[i] = 0;
drops[i]++;
}
}
let timer = null;
const play = () => { if (!timer) timer = setInterval(tick, 55); };
const pause = () => { if (timer) { clearInterval(timer); timer = null; } };
resize();
window.addEventListener('resize', resize);
document.addEventListener('visibilitychange', () => (document.hidden ? pause() : play()));
play();
}
startMatrixRain();