swarm-ui: page-switch animation (pop entrance + nav underline travel)

Shell's page content now remounts on every navigation and plays a
scale+fade pop-in entrance. The active nav link's underline is now a
single shared element that slides to its new position instead of
snapping, re-colouring to a per-tab accent — a discrete cycle through
the existing base16 chromatic slots, not a continuous hue rotation.
Both are gated on the data-motion/prefers-reduced-motion plumbing
lib/motion-apply.ts already had wired and waiting for a first real
consumer.
This commit is contained in:
iris 2026-08-19 19:16:53 +02:00 committed by mara
commit 6be1bbd4f7
3 changed files with 179 additions and 29 deletions

View file

@ -8,7 +8,17 @@
menu: a narrow viewport (phone or a tiled desktop window) wraps onto
a second line instead of overflowing "don't break, don't
over-invest" is the explicit scope here, not full mobile navigation
redesign. */
redesign.
Page-switch animation (see Shell.tsx's file-top comment for the JS
half): both the nav indicator's slide and the page-body pop follow
the same three-rule motion-guard shape `lib/motion-apply.ts`'s own
doc comment specifies a base rule,
`@media (prefers-reduced-motion: reduce)` to respect the OS default,
`:root[data-motion='reduce']` as the explicit override (same
specificity as the media rule, so source order after it wins), then
`:root[data-motion='allow']` last to re-enable despite an OS-level
reduce preference. */
.shell-header {
display: flex;
align-items: center;
@ -27,6 +37,7 @@
display: flex;
flex-wrap: wrap;
gap: 1em;
position: relative; /* anchor for .shell-nav-indicator */
}
/* Holds `SettingsMenu` + `LinksMenu` one `margin-left: auto` on the
wrapper, not one on each child (see Shell.tsx's comment for why two
@ -60,15 +71,64 @@
color: var(--fg);
}
.shell-nav-link-text {
padding-bottom: 0.25em;
border-bottom: 2px solid transparent;
padding-bottom: 0.25em; /* gap between the text and .shell-nav-indicator below it */
}
.shell-nav-link-active {
color: var(--fg);
border-bottom-color: var(--purple);
}
/* 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. */
.shell-nav-indicator {
position: absolute;
height: 2px;
border-radius: 1px;
transition:
left 200ms ease,
top 200ms ease,
width 200ms ease,
background-color 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.shell-nav-indicator {
transition: none;
}
}
:root[data-motion='reduce'] .shell-nav-indicator {
transition: none;
}
:root[data-motion='allow'] .shell-nav-indicator {
transition:
left 200ms ease,
top 200ms ease,
width 200ms ease,
background-color 200ms 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);
}
@keyframes shell-page-enter {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.shell-body {
animation: none;
}
}
:root[data-motion='reduce'] .shell-body {
animation: none;
}
:root[data-motion='allow'] .shell-body {
animation: shell-page-enter 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}