diff --git a/frontend/packages/swarm-ui/src/shell/Shell.css b/frontend/packages/swarm-ui/src/shell/Shell.css index 6eafd5c1..77e59c13 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -128,6 +128,16 @@ 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; +} .shell-body { max-width: 60em; margin: 0 auto; diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 6117eae3..eb6ac85a 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -114,6 +114,14 @@ export function Shell({ children }: { children: ComponentChildren }) { // 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 ``, so the override takes effect regardless @@ -185,6 +193,16 @@ export function Shell({ children }: { children: ComponentChildren }) { }; }, [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 @@ -249,7 +267,7 @@ export function Shell({ children }: { children: ComponentChildren }) { nav item (404), so it doesn't pop in with a stale position the next time a real nav item becomes active. */}