swarm-ui: fix underline not showing on the initial page load

Root cause (found via a zoomed pixel-level screenshot check, not
guessed): the indicator span always exists in the DOM starting from
left:0/width:0/transparent, and the very first real position landed
via a CSS-transitioned change from that fallback rather than a snap —
so the underline visibly grew in from nothing over 140ms instead of
being there immediately, reading as entirely absent on a fast/slow
first paint alike depending on timing. Every subsequent navigation was
unaffected (always transitioning between two already-visible states).

Fixed with a one-render-only transition suppression
(indicatorSettledOnce, gated one tick behind the indicator's first
non-null commit) so the first placement snaps instead of animating in,
while every later hop still animates normally. Verified against the
exact repro: a fresh page load at the same short virtual-time-budget
that previously showed no underline now shows it immediately.
This commit is contained in:
iris 2026-08-19 19:38:29 +02:00 committed by mara
commit 6d9a2fa9f6
2 changed files with 29 additions and 1 deletions

View file

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

View file

@ -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 `<Shell>`, 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. */}
<span
class="shell-nav-indicator"
class={'shell-nav-indicator' + (indicatorSettledOnce ? '' : ' shell-nav-indicator-instant')}
style={{
left: `${indicator?.left ?? 0}px`,
top: `${indicator?.top ?? 0}px`,