diff --git a/frontend/packages/swarm-ui/src/lib/motion-apply.ts b/frontend/packages/swarm-ui/src/lib/motion-apply.ts index 49717c7f..42c19f7e 100644 --- a/frontend/packages/swarm-ui/src/lib/motion-apply.ts +++ b/frontend/packages/swarm-ui/src/lib/motion-apply.ts @@ -3,14 +3,15 @@ // when the override is "system" (letting `prefers-reduced-motion` // alone decide, same as today). // -// 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: +// 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.: // @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 77e59c13..615e40f7 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -8,17 +8,7 @@ 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. - - 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. */ + redesign. */ .shell-header { display: flex; align-items: center; @@ -30,34 +20,13 @@ } .shell-brand { font-weight: 600; - /* Base colour only — Shell.tsx overrides it inline to the same - currently-active-tab accent `.shell-nav-indicator` uses (falling - back to this `--purple` before the first measurement lands, which - is also the "hives" default route's own accent, so there's no - visible mismatch at first paint). Transition lives here, same - 140ms hop cadence + the same three-rule motion guard as the - indicator, so this and the indicator read as one thing changing, - not two independently-synced ones. */ color: var(--purple); letter-spacing: 0.02em; - transition: color 140ms ease; -} -@media (prefers-reduced-motion: reduce) { - .shell-brand { - transition: none; - } -} -:root[data-motion='reduce'] .shell-brand { - transition: none; -} -:root[data-motion='allow'] .shell-brand { - transition: color 140ms ease; } .shell-nav { 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 @@ -91,79 +60,15 @@ color: var(--fg); } .shell-nav-link-text { - padding-bottom: 0.25em; /* gap between the text and .shell-nav-indicator below it */ + padding-bottom: 0.25em; + border-bottom: 2px solid transparent; } .shell-nav-link-active { color: var(--fg); -} -/* 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. 140ms matches Shell.tsx's `HOP_MS` - exactly, so one hop's CSS transition finishes right as the next - hop's JS-driven style update lands, instead of the two blending into - an averaged easing curve. */ -.shell-nav-indicator { - position: absolute; - height: 2px; - border-radius: 1px; - transition: - left 140ms ease, - top 140ms ease, - width 140ms ease, - background-color 140ms 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 140ms ease, - top 140ms ease, - width 140ms ease, - background-color 140ms ease; -} -/* Suppresses the transition for exactly one render — the indicator's - very first real position, so it snaps into place instead of visibly - growing from the unmounted `left:0/width:0/transparent` fallback (see - Shell.tsx's `indicatorSettledOnce`). Two-class selector so it wins - over the plain `.shell-nav-indicator` rule above without `!important`, - same reasoning `:root[data-motion='reduce']`'s extra attribute - selector already relies on. */ -.shell-nav-indicator.shell-nav-indicator-instant { - transition: none; + border-bottom-color: var(--purple); } .shell-body { max-width: 60em; margin: 0 auto; padding: 1.5em 1.25em; - /* Plain fade, deliberately — mara's call on review: a scale+overshoot - "pop" (this rule's first cut) read as too much for a first pass. - Keeping it simple leaves room to make this fancier later without - having shipped something to walk back first. */ - animation: shell-page-enter 160ms ease; -} -@keyframes shell-page-enter { - from { - opacity: 0; - } - to { - opacity: 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 160ms ease; } diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index eb6ac85a..cdd5d22d 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -12,34 +12,20 @@ // 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. -// `.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 { useEffect, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; -import { Link, useLocation } from 'wouter-preact'; +import { Link, useRoute } 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)' }, +const NAV_ITEMS: { href: string; label: string }[] = [ + { href: '/', label: 'hives' }, + { href: '/create-agent', label: 'new agent' }, + { href: '/jobs', label: 'jobs' }, + { href: '/components', label: 'components' }, ]; // Static fallback — matches `index.html`'s `` default, so a page @@ -48,80 +34,21 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ // 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; -}) { +function NavLink({ href, label }: { href: string; label: string }) { + const [active] = useRoute(href); 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> + 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> </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 @@ -132,90 +59,6 @@ export function Shell({ children }: { children: ComponentChildren }) { 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]); - - // 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 @@ -238,43 +81,14 @@ export function Shell({ children }: { children: ComponentChildren }) { 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" style={{ color: navAccent }}> - {brand} - </span> - <nav class="shell-nav" ref={navRef}> + <span class="shell-brand">{brand}</span> + <nav class="shell-nav"> {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; - }} - /> + <NavLink key={item.href} href={item.href} label={item.label} /> ))} - {/* 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 @@ -285,11 +99,7 @@ export function Shell({ children }: { children: ComponentChildren }) { <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 class="shell-body">{children}</div> </div> ); }