Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/shell/Shell.tsx
iris a5584d0fcd swarm-ui: plain fade + multi-hop underline sweep, per review
Content entrance was a scale+overshoot pop; mara didn't like it on
review, swapped for a plain fade. The nav underline now hops through
every nav item it passes over on its way to the new active one
(position and colour together) instead of tweening directly between
the two endpoints, matching what she actually meant by "move through
color wheel" — a->c visibly touches b's colour along the way, not a
smooth continuous hue rotation.
2026-08-19 19:54:52 +02:00

267 lines
11 KiB
TypeScript

// <Shell> — 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`, replacing what used to be a
// per-link border) 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.
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 `<title>` 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 `lib/motion-apply.ts` 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,
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);
// 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);
// 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();
// 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]);
// 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]);
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>
);
}