diff --git a/frontend/packages/swarm-ui/src/shell/Shell.css b/frontend/packages/swarm-ui/src/shell/Shell.css index 2c7c4e86..aff85761 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -79,16 +79,19 @@ /* 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. */ + 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 200ms ease, - top 200ms ease, - width 200ms ease, - background-color 200ms ease; + left 140ms ease, + top 140ms ease, + width 140ms ease, + background-color 140ms ease; } @media (prefers-reduced-motion: reduce) { .shell-nav-indicator { @@ -100,25 +103,27 @@ } :root[data-motion='allow'] .shell-nav-indicator { transition: - left 200ms ease, - top 200ms ease, - width 200ms ease, - background-color 200ms ease; + left 140ms ease, + top 140ms ease, + width 140ms ease, + background-color 140ms 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); + /* 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; - transform: scale(0.97); } to { opacity: 1; - transform: scale(1); } } @media (prefers-reduced-motion: reduce) { @@ -130,5 +135,5 @@ animation: none; } :root[data-motion='allow'] .shell-body { - animation: shell-page-enter 220ms cubic-bezier(0.34, 1.56, 0.64, 1); + 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 6e82a09d..c24eada0 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -15,10 +15,12 @@ // // 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 +// (`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) slides to the active link's measured position and -// re-colours to that tab's own accent. Accent is a discrete cycle +// 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. @@ -51,6 +53,26 @@ interface IndicatorRect { 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, @@ -85,6 +107,11 @@ export function Shell({ children }: { children: ComponentChildren }) { const navRef = useRef(null); const textRefs = useRef>({}); const [indicator, setIndicator] = useState(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 ``, so the override takes effect regardless @@ -95,36 +122,80 @@ 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). + // 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(() => { - 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, - }); + const targetIndex = NAV_ITEMS.findIndex((item) => item.href === location); + if (targetIndex === -1) { + settledIndexRef.current = -1; + setIndicator(null); + return; } - measure(); - window.addEventListener('resize', measure); - return () => window.removeEventListener('resize', measure); + 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 // ``, and the swarm's name doesn't change within a page // visit. A fetch failure is silently ignored — `swarmName` just stays