From a5584d0fcdfd2116b477dcdc782b057509188939 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 19 Aug 2026 19:21:19 +0200 Subject: [PATCH] swarm-ui: plain fade + multi-hop underline sweep, per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../packages/swarm-ui/src/shell/Shell.css | 31 +++-- .../packages/swarm-ui/src/shell/Shell.tsx | 129 ++++++++++++++---- 2 files changed, 118 insertions(+), 42 deletions(-) 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