From 417125b33c26445230e9313490d0668ca587299e Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 14 Oct 2026 14:49:44 +0200 Subject: [PATCH] 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 --- docs/swarm/ui.md | 3 +- frontend/packages/swarm-ui/src/App.tsx | 4 +- .../packages/swarm-ui/src/pages/HivesPage.tsx | 2 +- .../packages/swarm-ui/src/pages/StartPage.css | 23 ++++ .../packages/swarm-ui/src/pages/StartPage.tsx | 20 +++ .../swarm-ui/src/shell/MatrixRain.css | 9 ++ .../swarm-ui/src/shell/MatrixRain.tsx | 116 ++++++++++++++++++ .../packages/swarm-ui/src/shell/Shell.css | 6 + .../packages/swarm-ui/src/shell/Shell.tsx | 58 ++++----- .../swarm-ui/src/shell/useSwarmName.ts | 36 ++++++ 10 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/StartPage.css create mode 100644 frontend/packages/swarm-ui/src/pages/StartPage.tsx create mode 100644 frontend/packages/swarm-ui/src/shell/MatrixRain.css create mode 100644 frontend/packages/swarm-ui/src/shell/MatrixRain.tsx create mode 100644 frontend/packages/swarm-ui/src/shell/useSwarmName.ts diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 6275e3a7..51f30877 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -9,7 +9,8 @@ domain, covers host-level detail for one hive. | route | what | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `/` | the hive directory, each hive with its last reported status | +| `/` | the start page: the swarm's name over a digital-rain backdrop; the swarm name in the header links here | +| `/hives` | the hive directory, each hive with its last reported status | | `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents, link forge, matrix and GitHub accounts | | `/agents//terminal` | one agent's live terminal | | `/agents//subagents//terminal` | one of that agent's subagents' live terminal | diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index d6432f10..b6c34ec2 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -11,6 +11,7 @@ import { IssueReportPage } from "./pages/IssueReportPage.js"; import { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js"; import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js"; import { SchedulesPage } from "./pages/schedules/SchedulesPage.js"; +import { StartPage } from "./pages/StartPage.js"; import { Panel } from "./ui/panel/Panel.js"; function NotFound() { @@ -25,7 +26,8 @@ export function App() { return ( - + + — the hive roster, `/`'s content. Fetches +// — the hive roster, `/hives`' content. Fetches // swarm-controller's `GET /api/hives/status`, the swarm-wide status // aggregate: one row per roster hive, freshness derived at read time // from the status bucket. Supersedes the earlier `GET /api/hives` + diff --git a/frontend/packages/swarm-ui/src/pages/StartPage.css b/frontend/packages/swarm-ui/src/pages/StartPage.css new file mode 100644 index 00000000..d7d045cd --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/StartPage.css @@ -0,0 +1,23 @@ +/* The page fills the full-bleed body; the rain canvas fills the page and the + * name sits centred over it. */ +.start-page { + position: relative; + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.start-page-name { + position: relative; + margin: 0; + padding: 0 1em; + text-align: center; + font-size: clamp(2em, 8vw, 5em); + font-weight: 600; + color: var(--purple); + text-shadow: + 0 0 0.4em color-mix(in srgb, var(--purple) 55%, transparent), + 0 0 1.2em var(--bg); +} diff --git a/frontend/packages/swarm-ui/src/pages/StartPage.tsx b/frontend/packages/swarm-ui/src/pages/StartPage.tsx new file mode 100644 index 00000000..1f0162bc --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/StartPage.tsx @@ -0,0 +1,20 @@ +// — `/`, the swarm's landing page: the swarm's name over the +// digital-rain backdrop, and nothing else. The header's swarm name links +// here. The hive roster that used to be `/` is now `/hives`. +import { MatrixRain } from "../shell/MatrixRain.js"; +import { useSwarmName } from "../shell/useSwarmName.js"; +import "./StartPage.css"; + +// Same fallback as the header and the page title, so an unnamed swarm shows +// one label everywhere. +const DEFAULT_NAME = "hyperhive swarm"; + +export function StartPage() { + const name = useSwarmName() ?? DEFAULT_NAME; + return ( +
+ +

{name}

+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/shell/MatrixRain.css b/frontend/packages/swarm-ui/src/shell/MatrixRain.css new file mode 100644 index 00000000..12ef9a37 --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/MatrixRain.css @@ -0,0 +1,9 @@ +/* Fills the nearest positioned ancestor, dimmed so it reads as backdrop. */ +.matrix-rain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0.32; + pointer-events: none; +} diff --git a/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx b/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx new file mode 100644 index 00000000..7a21b4b2 --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx @@ -0,0 +1,116 @@ +// — 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(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