diff --git a/frontend/packages/swarm-ui/src/lib/motion-apply.ts b/frontend/packages/swarm-ui/src/lib/motion-apply.ts index 42c19f7e..49717c7f 100644 --- a/frontend/packages/swarm-ui/src/lib/motion-apply.ts +++ b/frontend/packages/swarm-ui/src/lib/motion-apply.ts @@ -3,15 +3,14 @@ // when the override is "system" (letting `prefers-reduced-motion` // alone decide, same as today). // -// Currently inert: swarm-ui has zero CSS animations as of this file's -// writing (the design guide's "playful whimsy" motion — the matrix-rain -// home background — lives in the dashboard package, not swarm-ui). This -// hook exists because the settings surface itself was scoped to cover -// theme *and* motion together (both named in the same go-ahead), and the -// marginal cost of the storage/attribute plumbing is near zero riding -// alongside the theme override's real wiring — but there is genuinely -// nothing in swarm-ui for `data-motion` to gate yet. The first swarm-ui -// animation's own CSS is what makes this do anything, e.g.: +// Built ahead of swarm-ui having any CSS animation to gate — the +// settings surface itself was scoped to cover theme *and* motion +// together (both named in the same go-ahead), and the marginal cost of +// the storage/attribute plumbing was near zero riding alongside the +// theme override's real wiring. `Shell`'s page-switch animation (the +// nav indicator's slide + the page-body pop) is the first real +// consumer — see `shell/Shell.css`'s file-top comment for the +// three-rule gate shape every animation here follows: // @media (prefers-reduced-motion: reduce) { ... } // :root[data-motion='reduce'] { ... same rule ... } // :root[data-motion='allow'] { /* opt back in despite OS-level reduce */ } diff --git a/frontend/packages/swarm-ui/src/shell/Shell.css b/frontend/packages/swarm-ui/src/shell/Shell.css index 615e40f7..2c7c4e86 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -8,7 +8,17 @@ menu: a narrow viewport (phone or a tiled desktop window) wraps onto a second line instead of overflowing — "don't break, don't over-invest" is the explicit scope here, not full mobile navigation - redesign. */ + redesign. + + Page-switch animation (see Shell.tsx's file-top comment for the JS + half): both the nav indicator's slide and the page-body pop follow + the same three-rule motion-guard shape `lib/motion-apply.ts`'s own + doc comment specifies — a base rule, + `@media (prefers-reduced-motion: reduce)` to respect the OS default, + `:root[data-motion='reduce']` as the explicit override (same + specificity as the media rule, so source order after it wins), then + `:root[data-motion='allow']` last to re-enable despite an OS-level + reduce preference. */ .shell-header { display: flex; align-items: center; @@ -27,6 +37,7 @@ display: flex; flex-wrap: wrap; gap: 1em; + position: relative; /* anchor for .shell-nav-indicator */ } /* Holds `SettingsMenu` + `LinksMenu` — one `margin-left: auto` on the wrapper, not one on each child (see Shell.tsx's comment for why two @@ -60,15 +71,64 @@ color: var(--fg); } .shell-nav-link-text { - padding-bottom: 0.25em; - border-bottom: 2px solid transparent; + padding-bottom: 0.25em; /* gap between the text and .shell-nav-indicator below it */ } .shell-nav-link-active { color: var(--fg); - border-bottom-color: var(--purple); +} +/* Shared underline, one element instead of one border-bottom per link + — see Shell.tsx's file-top comment. `left`/`top`/`width` are set + inline per-render from a real measurement; only the *transition* + between those values lives here. */ +.shell-nav-indicator { + position: absolute; + height: 2px; + border-radius: 1px; + transition: + left 200ms ease, + top 200ms ease, + width 200ms ease, + background-color 200ms ease; +} +@media (prefers-reduced-motion: reduce) { + .shell-nav-indicator { + transition: none; + } +} +:root[data-motion='reduce'] .shell-nav-indicator { + transition: none; +} +:root[data-motion='allow'] .shell-nav-indicator { + transition: + left 200ms ease, + top 200ms ease, + width 200ms ease, + background-color 200ms ease; } .shell-body { max-width: 60em; margin: 0 auto; padding: 1.5em 1.25em; + animation: shell-page-enter 220ms cubic-bezier(0.34, 1.56, 0.64, 1); +} +@keyframes shell-page-enter { + from { + opacity: 0; + transform: scale(0.97); + } + to { + opacity: 1; + transform: scale(1); + } +} +@media (prefers-reduced-motion: reduce) { + .shell-body { + animation: none; + } +} +:root[data-motion='reduce'] .shell-body { + animation: none; +} +:root[data-motion='allow'] .shell-body { + animation: shell-page-enter 220ms cubic-bezier(0.34, 1.56, 0.64, 1); } diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index cdd5d22d..6e82a09d 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -12,20 +12,30 @@ // 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. -import { useEffect, useState } from 'preact/hooks'; +// +// 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, useRoute } from 'wouter-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 }[] = [ - { href: '/', label: 'hives' }, - { href: '/create-agent', label: 'new agent' }, - { href: '/jobs', label: 'jobs' }, - { href: '/components', label: 'components' }, +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 @@ -34,21 +44,47 @@ const NAV_ITEMS: { href: string; label: string }[] = [ // same generic label the pre-fetch page already showed, not a blank. const DEFAULT_BRAND = 'hyperhive swarm'; -function NavLink({ href, label }: { href: string; label: string }) { - const [active] = useRoute(href); +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 active-state underline lives on this inner span - so it hugs the text instead of sitting at the bottom of the - full-height box, ~0.7em away from it. */} - <span class={'shell-nav-link-text' + (active ? ' shell-nav-link-active' : '')}>{label}</span> + 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 @@ -59,6 +95,36 @@ export function Shell({ children }: { children: ComponentChildren }) { 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 @@ -85,10 +151,31 @@ export function Shell({ children }: { children: ComponentChildren }) { <div class="shell"> <header class="shell-header"> <span class="shell-brand">{brand}</span> - <nav class="shell-nav"> + <nav class="shell-nav" ref={navRef}> {NAV_ITEMS.map((item) => ( - <NavLink key={item.href} href={item.href} label={item.label} /> + <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 @@ -99,7 +186,11 @@ export function Shell({ children }: { children: ComponentChildren }) { <LinksMenu /> </div> </header> - <div class="shell-body">{children}</div> + {/* 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> ); }