// — 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 pop-in entrance, and a single shared // underline (`.shell-nav-indicator`, replacing what used to be a // per-link border) slides to the active link's measured position and // re-colours to that tab's own accent. 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. import { useEffect, useRef, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { Link, useLocation } from 'wouter-preact'; import { LinksMenu } from './LinksMenu.js'; import { SettingsMenu } from './SettingsMenu.js'; import { useApplyThemeOverride } from '../lib/theme-apply.js'; import { useApplyMotionOverride } from '../lib/motion-apply.js'; import './Shell.css'; const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: '/', label: 'hives', accent: 'var(--purple)' }, { href: '/create-agent', label: 'new agent', accent: 'var(--cyan)' }, { href: '/jobs', label: 'jobs', accent: 'var(--pink)' }, { href: '/components', label: 'components', accent: 'var(--blue)' }, ]; // 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; } function NavLink({ href, label, active, textRef, }: { href: string; label: string; active: boolean; 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 itself no longer lives here — see `.shell-nav-indicator` — this span is still what gets measured to position that shared indicator, 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' : '')} > {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); // 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 (settings-storage.ts) // means `SettingsMenu` changing the stored value re-runs these // effects without either component needing a reference to the other. useApplyThemeOverride(); useApplyMotionOverride(); // Re-measures on every navigation and on resize (the nav's own // `flex-wrap` means a link's position genuinely changes at narrow // widths, not just its route). `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. No indicator (hidden via // `left: 0; width: 0`) on a route with no matching nav item (404). useEffect(() => { function measure() { const navEl = navRef.current; const activeItem = NAV_ITEMS.find((item) => item.href === location); const textEl = activeItem ? textRefs.current[activeItem.href] : null; if (!navEl || !activeItem || !textEl) { setIndicator(null); return; } const navRect = navEl.getBoundingClientRect(); const textRect = textEl.getBoundingClientRect(); setIndicator({ left: textRect.left - navRect.left, top: textRect.bottom - navRect.top, width: textRect.width, accent: activeItem.accent, }); } measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, [location]); // 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]); return ( <div class="shell"> <header class="shell-header"> <span class="shell-brand">{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} 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" 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 /> <LinksMenu /> </div> </header> {/* Keyed by route so it remounts (and replays its entrance animation) on every navigation — see the file-top comment. */} <div class="shell-body" key={location}> {children} </div> </div> ); }