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.
This commit is contained in:
iris 2026-08-19 19:21:19 +02:00 committed by mara
commit a5584d0fcd
2 changed files with 117 additions and 41 deletions

View file

@ -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;
}

View file

@ -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<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
@ -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
// `<Shell>`, and the swarm's name doesn't change within a page
// visit. A fetch failure is silently ignored — `swarmName` just stays