Compare commits

...
Author SHA1 Message Date
iris
6d9a2fa9f6 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.
2026-08-19 19:54:52 +02:00
iris
daabb4fe6a swarm-ui: brand text follows the nav accent too
The brand/logo text now rides the same accent value the nav underline
uses, including mid-sweep, so the header reads as one accent changing
rather than the underline alone — per mara's review comment.
2026-08-19 19:54:52 +02:00
iris
a5584d0fcd 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.
2026-08-19 19:54:52 +02:00
iris
6be1bbd4f7 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.
2026-08-19 19:54:52 +02:00
3 changed files with 314 additions and 30 deletions

View file

@ -3,15 +3,14 @@
// when the override is "system" (letting `prefers-reduced-motion`
// alone decide, same as today).
//
// Currently inert: swarm-ui has zero CSS animations as of this file's
// writing (the design guide's "playful whimsy" motion — the matrix-rain
// home background — lives in the dashboard package, not swarm-ui). This
// hook exists because the settings surface itself was scoped to cover
// theme *and* motion together (both named in the same go-ahead), and the
// marginal cost of the storage/attribute plumbing is near zero riding
// alongside the theme override's real wiring — but there is genuinely
// nothing in swarm-ui for `data-motion` to gate yet. The first swarm-ui
// animation's own CSS is what makes this do anything, e.g.:
// Built ahead of swarm-ui having any CSS animation to gate — the
// settings surface itself was scoped to cover theme *and* motion
// together (both named in the same go-ahead), and the marginal cost of
// the storage/attribute plumbing was near zero riding alongside the
// theme override's real wiring. `Shell`'s page-switch animation (the
// nav indicator's slide + the page-body pop) is the first real
// consumer — see `shell/Shell.css`'s file-top comment for the
// three-rule gate shape every animation here follows:
// @media (prefers-reduced-motion: reduce) { ... }
// :root[data-motion='reduce'] { ... same rule ... }
// :root[data-motion='allow'] { /* opt back in despite OS-level reduce */ }

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;
@ -20,13 +30,34 @@
}
.shell-brand {
font-weight: 600;
/* Base colour only Shell.tsx overrides it inline to the same
currently-active-tab accent `.shell-nav-indicator` uses (falling
back to this `--purple` before the first measurement lands, which
is also the "hives" default route's own accent, so there's no
visible mismatch at first paint). Transition lives here, same
140ms hop cadence + the same three-rule motion guard as the
indicator, so this and the indicator read as one thing changing,
not two independently-synced ones. */
color: var(--purple);
letter-spacing: 0.02em;
transition: color 140ms ease;
}
@media (prefers-reduced-motion: reduce) {
.shell-brand {
transition: none;
}
}
:root[data-motion='reduce'] .shell-brand {
transition: none;
}
:root[data-motion='allow'] .shell-brand {
transition: color 140ms ease;
}
.shell-nav {
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 +91,79 @@
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. 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 140ms ease,
top 140ms ease,
width 140ms ease,
background-color 140ms 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 140ms ease,
top 140ms ease,
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;
padding: 1.5em 1.25em;
/* 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;
}
to {
opacity: 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 160ms ease;
}

View file

@ -12,20 +12,34 @@
// has exactly one place that needs to know its own nav, and this is
// it. Grows additively as real routes land (a swarm-wide agent roster
// is next); no speculative entries.
import { useEffect, useState } from 'preact/hooks';
//
// Page-switch animation, two pieces (motion-guard rules live in
// Shell.css): `.shell-body` remounts on every navigation
// (`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) 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.
// `.shell-brand` rides the identical accent value (`navAccent` below),
// so the header reads as one accent changing, not the underline alone.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { Link, useRoute } from 'wouter-preact';
import { Link, useLocation } from 'wouter-preact';
import { LinksMenu } from './LinksMenu.js';
import { SettingsMenu } from './SettingsMenu.js';
import { useApplyThemeOverride } from '../lib/theme-apply.js';
import { useApplyMotionOverride } from '../lib/motion-apply.js';
import './Shell.css';
const NAV_ITEMS: { href: string; label: string }[] = [
{ href: '/', label: 'hives' },
{ href: '/create-agent', label: 'new agent' },
{ href: '/jobs', label: 'jobs' },
{ href: '/components', label: 'components' },
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: '/', label: 'hives', accent: 'var(--purple)' },
{ href: '/create-agent', label: 'new agent', accent: 'var(--cyan)' },
{ href: '/jobs', label: 'jobs', accent: 'var(--pink)' },
{ href: '/components', label: 'components', accent: 'var(--blue)' },
];
// Static fallback — matches `index.html`'s `<title>` default, so a page
@ -34,21 +48,80 @@ const NAV_ITEMS: { href: string; label: string }[] = [
// same generic label the pre-fetch page already showed, not a blank.
const DEFAULT_BRAND = 'hyperhive swarm';
function NavLink({ href, label }: { href: string; label: string }) {
const [active] = useRoute(href);
interface IndicatorRect {
left: number;
top: number;
width: number;
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,
active,
textRef,
}: {
href: string;
label: string;
active: boolean;
textRef: (el: HTMLSpanElement | null) => void;
}) {
return (
<Link href={href} className="shell-nav-link">
{/* Touch target (min-height) lives on the <a> so the whole row is
tappable; the active-state underline lives on this inner span
so it hugs the text instead of sitting at the bottom of the
full-height box, ~0.7em away from it. */}
<span class={'shell-nav-link-text' + (active ? ' shell-nav-link-active' : '')}>{label}</span>
tappable. The underline itself no longer lives here see
`.shell-nav-indicator` this span is still what gets
measured to position that shared indicator, so it hugs just
the text rather than the full-height tappable box. */}
<span
ref={textRef}
class={'shell-nav-link-text' + (active ? ' shell-nav-link-active' : '')}
>
{label}
</span>
</Link>
);
}
export function Shell({ children }: { children: ComponentChildren }) {
const [swarmName, setSwarmName] = useState<string | null>(null);
const [location] = useLocation();
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);
// 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
@ -59,6 +132,90 @@ export function Shell({ children }: { children: ComponentChildren }) {
useApplyThemeOverride();
useApplyMotionOverride();
// 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(() => {
const targetIndex = NAV_ITEMS.findIndex((item) => item.href === location);
if (targetIndex === -1) {
settledIndexRef.current = -1;
setIndicator(null);
return;
}
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]);
// 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
// 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
@ -81,14 +238,43 @@ export function Shell({ children }: { children: ComponentChildren }) {
document.title = brand;
}, [brand]);
// Same value the indicator itself is drawn with, including mid-sweep —
// the brand text rides the identical hop sequence rather than
// computing its own, so "the header's accent" reads as one thing
// changing, not two things that happen to agree at rest.
const navAccent = indicator?.accent ?? 'var(--purple)';
return (
<div class="shell">
<header class="shell-header">
<span class="shell-brand">{brand}</span>
<nav class="shell-nav">
<span class="shell-brand" style={{ color: navAccent }}>
{brand}
</span>
<nav class="shell-nav" ref={navRef}>
{NAV_ITEMS.map((item) => (
<NavLink key={item.href} href={item.href} label={item.label} />
<NavLink
key={item.href}
href={item.href}
label={item.label}
active={item.href === location}
textRef={(el) => {
textRefs.current[item.href] = el;
}}
/>
))}
{/* Shared sliding underline see the file-top comment. Hidden
(zero width) rather than unmounted when there's no active
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' + (indicatorSettledOnce ? '' : ' shell-nav-indicator-instant')}
style={{
left: `${indicator?.left ?? 0}px`,
top: `${indicator?.top ?? 0}px`,
width: `${indicator?.width ?? 0}px`,
backgroundColor: indicator?.accent ?? 'transparent',
}}
/>
</nav>
{/* Single `margin-left: auto` on the wrapper, not on each menu
individually two adjacent flex items both set to
@ -99,7 +285,11 @@ export function Shell({ children }: { children: ComponentChildren }) {
<LinksMenu />
</div>
</header>
<div class="shell-body">{children}</div>
{/* Keyed by route so it remounts (and replays its entrance
animation) on every navigation see the file-top comment. */}
<div class="shell-body" key={location}>
{children}
</div>
</div>
);
}