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
|
|
@ -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 (
|
||||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/" component={HivesPage} />
|
||||
<Route path="/" component={StartPage} />
|
||||
<Route path="/hives" component={HivesPage} />
|
||||
<Route path="/agents" component={AgentsPage} />
|
||||
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// <HivesPage> — the hive roster, `/`'s content. Fetches
|
||||
// <HivesPage> — 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` +
|
||||
|
|
|
|||
23
frontend/packages/swarm-ui/src/pages/StartPage.css
Normal file
23
frontend/packages/swarm-ui/src/pages/StartPage.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
20
frontend/packages/swarm-ui/src/pages/StartPage.tsx
Normal file
20
frontend/packages/swarm-ui/src/pages/StartPage.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// <StartPage> — `/`, 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 (
|
||||
<div class="start-page">
|
||||
<MatrixRain />
|
||||
<h1 class="start-page-name">{name}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/packages/swarm-ui/src/shell/MatrixRain.css
Normal file
9
frontend/packages/swarm-ui/src/shell/MatrixRain.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
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" />;
|
||||
}
|
||||
|
|
@ -55,6 +55,12 @@
|
|||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
}
|
||||
/* The swarm name links to the start page; the link adds no look of its own,
|
||||
so the brand's accent and glow (on the span inside) are all that show. */
|
||||
.shell-brand-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.shell-brand {
|
||||
font-weight: 600;
|
||||
/* Base colour only — Shell.tsx overrides it inline to the same
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
|
|||
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
||||
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
||||
import { useDynamicTabs } from "./useDynamicTabs.js";
|
||||
import { useSwarmName } from "./useSwarmName.js";
|
||||
import "./Shell.css";
|
||||
|
||||
// Storage keys this app owns for `@hive/shared`'s settings mechanism —
|
||||
|
|
@ -47,7 +48,7 @@ const THEME_KEY = "swarm-ui:theme-override";
|
|||
const MOTION_KEY = "swarm-ui:motion-override";
|
||||
|
||||
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||
{ href: "/", label: "hives", accent: "var(--purple)" },
|
||||
{ href: "/hives", label: "hives", accent: "var(--purple)" },
|
||||
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
||||
|
|
@ -79,8 +80,10 @@ const WIDE_BODY_ROUTES = new Set([
|
|||
const FULL_BLEED_ROUTE_PATTERN =
|
||||
/^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/;
|
||||
|
||||
// The start page (`/`) is full-bleed too: its rain backdrop fills the whole
|
||||
// body below the header.
|
||||
function isFullBleedRoute(location: string): boolean {
|
||||
return FULL_BLEED_ROUTE_PATTERN.test(location);
|
||||
return location === "/" || FULL_BLEED_ROUTE_PATTERN.test(location);
|
||||
}
|
||||
|
||||
function isWideBodyRoute(location: string): boolean {
|
||||
|
|
@ -206,7 +209,7 @@ function DynamicTabLink({
|
|||
}
|
||||
|
||||
export function Shell({ children }: { children: ComponentChildren }) {
|
||||
const [swarmName, setSwarmName] = useState<string | null>(null);
|
||||
const swarmName = useSwarmName();
|
||||
const [location] = useLocation();
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
const textRefs = useRef<Record<string, HTMLSpanElement | null>>({});
|
||||
|
|
@ -340,23 +343,8 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
setIndicator(measureIndex(settledIndexRef.current));
|
||||
}, [tabs.length]);
|
||||
|
||||
// Fetched once here, not per-page: every route mounts inside one
|
||||
// `<Shell>`, and the swarm's name doesn't change within a page
|
||||
// visit. A fetch failure is silently ignored — `swarmName` just stays
|
||||
// `null` and the header/title fall back to `DEFAULT_BRAND`, same as
|
||||
// an operator who never configured a name; this label is cosmetic,
|
||||
// not worth an `ApiErrorPanel`.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const r = await fetch("/api/swarm");
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { name: string | null };
|
||||
if (data.name) setSwarmName(data.name);
|
||||
})().catch(() => {
|
||||
/* cosmetic only — see comment above */
|
||||
});
|
||||
}, []);
|
||||
|
||||
// `swarmName` stays `null` on a failed fetch or an unconfigured name, and
|
||||
// the header/title fall back to `DEFAULT_BRAND`; see `useSwarmName`.
|
||||
const brand = swarmName ?? DEFAULT_BRAND;
|
||||
// Per-route label so browser tabs of different pages tell apart: a
|
||||
// top-level route takes its nav label, a terminal route takes the
|
||||
|
|
@ -378,20 +366,22 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
return (
|
||||
<div class={"shell" + (isFullBleedRoute(location) ? " shell-full" : "")}>
|
||||
<header class="shell-header">
|
||||
<span
|
||||
class="shell-brand"
|
||||
// Same glow recipe as the active nav tab / panel titles, riding
|
||||
// `navAccent` — the same value `color` already uses here — so
|
||||
// the brand's glow tracks the identical hop sequence as its own
|
||||
// text colour instead of introducing a second, disagreeing
|
||||
// accent source.
|
||||
style={{
|
||||
color: navAccent,
|
||||
textShadow: `0 0 8px color-mix(in srgb, ${navAccent} 45%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{brand}
|
||||
</span>
|
||||
<Link href="/" class="shell-brand-link">
|
||||
<span
|
||||
class="shell-brand"
|
||||
// Same glow recipe as the active nav tab / panel titles, riding
|
||||
// `navAccent` — the same value `color` already uses here — so
|
||||
// the brand's glow tracks the identical hop sequence as its own
|
||||
// text colour instead of introducing a second, disagreeing
|
||||
// accent source.
|
||||
style={{
|
||||
color: navAccent,
|
||||
textShadow: `0 0 8px color-mix(in srgb, ${navAccent} 45%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{brand}
|
||||
</span>
|
||||
</Link>
|
||||
<nav class="shell-nav" ref={navRef}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
|
|
|
|||
36
frontend/packages/swarm-ui/src/shell/useSwarmName.ts
Normal file
36
frontend/packages/swarm-ui/src/shell/useSwarmName.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// useSwarmName — the swarm's configured display name from `GET /api/swarm`,
|
||||
// or `null` until it arrives, when none is configured, or when the fetch
|
||||
// fails. The label is cosmetic, so a failure is swallowed rather than shown:
|
||||
// callers fall back to their own default, as an operator who never set a
|
||||
// name sees. One request per page load, shared by every caller, because the
|
||||
// header and the start page both ask for it and the name does not change
|
||||
// within a visit.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
let pending: Promise<string | null> | null = null;
|
||||
|
||||
function fetchSwarmName(): Promise<string | null> {
|
||||
if (pending === null) {
|
||||
pending = (async () => {
|
||||
const r = await fetch("/api/swarm");
|
||||
if (!r.ok) return null;
|
||||
const data = (await r.json()) as { name: string | null };
|
||||
return data.name || null;
|
||||
})().catch(() => null);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function useSwarmName(): string | null {
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchSwarmName().then((n) => {
|
||||
if (!cancelled && n !== null) setName(n);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return name;
|
||||
}
|
||||
Loading…
Reference in a new issue