// — page chrome every swarm-ui route mounts inside: a header // bar (swarm branding) plus a nav row linking the app's real routes. // Preact-native styling — `./Shell.css` imported right here, not // wired centrally, so the component and its styles travel together // (esbuild folds every imported `.css` reachable from `main.tsx` into // one `main.css` companion output, see build.mjs) — deliberately not // @hive/shared's chrome.css page-header pattern, which is the per-hive // MPA dashboard's visual language. This package is a clean field, not // an inheritor of that look. // // Route list lives here, not prop-drilled from App — one small SPA // has exactly one place that needs to know its own nav, and this is // it. Grows additively as real routes land (a swarm-wide agent roster // is next); no speculative entries. // // Page-switch animation, two pieces (motion-guard rules live in // Shell.css): `.shell-body` remounts on every navigation // (`key={location}`) to replay a plain fade-in, and a single shared // underline (`.shell-nav-indicator`) hops through every nav item it // passes over on its way to the new active one, not just a straight // two-point tween — a nav hives→components jump visibly touches "new // agent" and "jobs" in between, position and colour together. Accent // is a discrete cycle through the existing base16 chromatic slots // (`NAV_ITEMS`' `accent` field), not a continuous hue rotation — // everything here still derives from base16, same rule the rest of // the palette follows. `.shell-brand` rides the identical accent value // (`navAccent` below), so the header reads as one accent changing, not // the underline alone. import { useEffect, useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; import { Link, useLocation } from "wouter-preact"; import { LinksMenu } from "./LinksMenu.js"; import { UserMenu } from "./UserMenu.js"; import { SettingsMenu } from "@hive/shared/settings-menu.js"; import { useApplyThemeOverride } from "@hive/shared/theme-apply.js"; import { useApplyMotionOverride } from "@hive/shared/motion-apply.js"; import "./Shell.css"; // Storage keys this app owns for `@hive/shared`'s settings mechanism — // kept here, not derived, so the two mount points below (the // `useApply*` effects and ``) always agree on which // browser-local key they're reading/writing. Theme defaults to `'dark'`, // not `'system'` — see `@hive/shared/theme-apply.js`'s file comment. 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: "/agents", label: "agents", accent: "var(--green)" }, { href: "/jobs", label: "jobs", accent: "var(--pink)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/components", label: "components", accent: "var(--blue)" }, ]; // Routes that opt out of `.shell-body`'s 60em readable-line-length cap // — mara screenshotted the issue report's 8-column table getting // clipped; that width is deliberate for the rest of the UI's cards/ // forms, but too narrow for a wide table. `.shell-body-wide` (Shell.css) is a // modifier alongside the base class, not a replacement, so every other // route's layout is untouched. `/agents` joined for the same reason — // its own table view (plus the list+detail split) hits the same cap. const WIDE_BODY_ROUTES = new Set(["/issues", "/agents"]); // Static fallback — matches `index.html`'s `` default, so a page // never flashes something else before the fetch below resolves, and an // operator who never set `services.hyperhive.swarm.name` sees the exact // same generic label the pre-fetch page already showed, not a blank. const DEFAULT_BRAND = "hyperhive swarm"; interface IndicatorRect { left: number; top: number; width: number; accent: string; } // One hop's travel time — also the interval between hops, so each leg // finishes (per `.shell-nav-indicator`'s own CSS transition duration, // kept equal to this) before the next one starts rather than the two // blending into a single averaged easing curve. const HOP_MS = 140; // `data-motion`/`prefers-reduced-motion` gate for the JS-driven hop // sequence — the CSS-only pieces (the fade, the indicator's own // transition) gate via the three-rule pattern in Shell.css, but a // multi-step `setTimeout` sequence has no CSS equivalent to hook, so it // checks the same two sources directly. `data-motion` takes precedence // over the OS preference either direction (reduce *or* allow), same // override semantics `@hive/shared/motion-apply.js` establishes. function prefersReducedMotion(): boolean { const override = document.documentElement.dataset.motion; if (override === "reduce") return true; if (override === "allow") return false; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function NavLink({ href, label, active, accent, textRef, }: { href: string; label: string; active: boolean; accent: string; textRef: (el: HTMLSpanElement | null) => void; }) { return ( <Link href={href} className="shell-nav-link"> {/* Touch target (min-height) lives on the <a> so the whole row is tappable. The underline lives on the shared `.shell-nav-indicator`, not here — this span is still what gets measured to position it, so it hugs just the text rather than the full-height tappable box. */} <span ref={textRef} class={"shell-nav-link-text" + (active ? " shell-nav-link-active" : "")} // Inline, not a CSS rule: the glow colour is this item's own // `accent` (NAV_ITEMS), a different value per nav item, the // same reason `.shell-nav-indicator`'s own colour is set this // way rather than in Shell.css. style={ active ? { textShadow: `0 0 8px color-mix(in srgb, ${accent} 45%, transparent)`, } : undefined } > {label} </span> </Link> ); } export function Shell({ children }: { children: ComponentChildren }) { const [swarmName, setSwarmName] = useState<string | null>(null); const [location] = useLocation(); const navRef = useRef<HTMLElement | null>(null); const textRefs = useRef<Record<string, HTMLSpanElement | null>>({}); const [indicator, setIndicator] = useState<IndicatorRect | null>(null); // The nav-item index the indicator is currently sitting at (or mid-hop // from) — distinct from `location`'s own index, since the two only // agree once every intermediate hop has landed. `-1` = not settled // anywhere yet (first mount) or on a route with no nav item (404). const settledIndexRef = useRef(-1); // False until the indicator's very first real position has committed — // gates `.shell-nav-indicator-instant` (see Shell.css) so that first // placement snaps instead of *transitioning in* from the unmounted // `left:0/width:0/transparent` fallback, which read as "no underline at // all" on a fresh page load (reported live by mara, reproduced here with // a zoomed pixel-level screenshot check — real, not a rendering fluke). // Every subsequent change (a real navigation) still animates normally. const [indicatorSettledOnce, setIndicatorSettledOnce] = useState(false); // Applied once here, not inside `SettingsMenu` — every route mounts // through this one `<Shell>`, so the override takes effect regardless // of which page is showing or whether the menu's ever been opened, // and `useLocalSetting`'s same-tab subscription // (`@hive/shared/settings-storage.js`) means `SettingsMenu` changing // the stored value re-runs these effects without either component // needing a reference to the other. useApplyThemeOverride(THEME_KEY); useApplyMotionOverride(MOTION_KEY); // Measures nav item `index`'s rect relative to `.shell-nav`. // `getBoundingClientRect()` on both elements at the same tick keeps // this scroll-position-agnostic — a difference of two // viewport-relative rects is scroll-invariant, no separate // scroll-offset bookkeeping needed. function measureIndex(index: number): IndicatorRect | null { const navEl = navRef.current; const item = NAV_ITEMS[index]; const textEl = item ? textRefs.current[item.href] : null; if (!navEl || !item || !textEl) return null; const navRect = navEl.getBoundingClientRect(); const textRect = textEl.getBoundingClientRect(); return { left: textRect.left - navRect.left, top: textRect.bottom - navRect.top, width: textRect.width, accent: item.accent, }; } // Drives the indicator through every nav item between where it was // and the new active one — see the file-top comment for why (mara: // "navigating from a to c animates [a's colour] -> [b's colour] -> // [c's colour]"). Re-runs on navigation; a separate resize listener // below re-measures the already-settled position without re-hopping // (a reflow isn't a navigation). useEffect(() => { const targetIndex = NAV_ITEMS.findIndex((item) => item.href === location); if (targetIndex === -1) { settledIndexRef.current = -1; setIndicator(null); return; } const fromIndex = settledIndexRef.current; // First mount, a route with no nav item just left, or reduced // motion: land directly, no intermediate hops to sweep through. if (fromIndex === -1 || prefersReducedMotion()) { settledIndexRef.current = targetIndex; setIndicator(measureIndex(targetIndex)); return; } const step = targetIndex > fromIndex ? 1 : -1; let cancelled = false; let timeoutId: number | undefined; function hop(current: number) { const next = current + step; setIndicator(measureIndex(next)); settledIndexRef.current = next; if (next !== targetIndex) { timeoutId = window.setTimeout(() => { if (!cancelled) hop(next); }, HOP_MS); } } hop(fromIndex); return () => { cancelled = true; if (timeoutId !== undefined) window.clearTimeout(timeoutId); }; }, [location]); // Fires the render *after* the indicator's first non-null commit — // deliberately one tick behind, not folded into the effect above, // because the instant/animated class has to still read "instant" on // the very render where `indicator` itself first goes non-null (that's // the snap this exists for); only from the render after that should // transitions be armed. useEffect(() => { if (indicator && !indicatorSettledOnce) setIndicatorSettledOnce(true); }, [indicator, indicatorSettledOnce]); // Re-measures the current (already-settled) position on resize — the // nav's own `flex-wrap` means a link's position genuinely changes at // narrow widths, not just its route. Not part of the hop effect above // since a reflow shouldn't restart the sweep animation. useEffect(() => { function onResize() { if (settledIndexRef.current === -1) return; setIndicator(measureIndex(settledIndexRef.current)); } window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); // 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 */ }); }, []); const brand = swarmName ?? DEFAULT_BRAND; useEffect(() => { document.title = brand; }, [brand]); // Same value the indicator itself is drawn with, including mid-sweep — // the brand text rides the identical hop sequence rather than // computing its own, so "the header's accent" reads as one thing // changing, not two things that happen to agree at rest. const navAccent = indicator?.accent ?? "var(--purple)"; return ( <div class="shell"> <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> <nav class="shell-nav" ref={navRef}> {NAV_ITEMS.map((item) => ( <NavLink key={item.href} href={item.href} label={item.label} active={item.href === location} accent={item.accent} textRef={(el) => { textRefs.current[item.href] = el; }} /> ))} {/* Shared sliding underline — see the file-top comment. Hidden (zero width) rather than unmounted when there's no active nav item (404), so it doesn't pop in with a stale position the next time a real nav item becomes active. */} <span class={ "shell-nav-indicator" + (indicatorSettledOnce ? "" : " shell-nav-indicator-instant") } style={{ left: `${indicator?.left ?? 0}px`, top: `${indicator?.top ?? 0}px`, width: `${indicator?.width ?? 0}px`, backgroundColor: indicator?.accent ?? "transparent", }} /> </nav> {/* Single `margin-left: auto` on the wrapper, not on each menu individually — two adjacent flex items both set to `margin-left: auto` split the available space between them instead of sitting flush together at the right edge. */} <div class="shell-header-actions"> <SettingsMenu themeKey={THEME_KEY} motionKey={MOTION_KEY} /> <LinksMenu /> <UserMenu /> </div> </header> {/* Keyed by route so it remounts (and replays its entrance animation) on every navigation — see the file-top comment. `shell-body-wide` is additive (see `WIDE_BODY_ROUTES` above), not a swap — `.shell-body`'s padding/animation rules still apply, only the max-width cap changes. */} <div class={ "shell-body" + (WIDE_BODY_ROUTES.has(location) ? " shell-body-wide" : "") } key={location} > {children} </div> </div> ); }