From 6be1bbd4f7eefd1a0aa13cfcfefa81cf95a2be98 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 19 Aug 2026 19:16:53 +0200 Subject: [PATCH 1/4] swarm-ui: page-switch animation (pop entrance + nav underline travel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../packages/swarm-ui/src/lib/motion-apply.ts | 17 ++- .../packages/swarm-ui/src/shell/Shell.css | 68 +++++++++- .../packages/swarm-ui/src/shell/Shell.tsx | 123 +++++++++++++++--- 3 files changed, 179 insertions(+), 29 deletions(-) diff --git a/frontend/packages/swarm-ui/src/lib/motion-apply.ts b/frontend/packages/swarm-ui/src/lib/motion-apply.ts index 42c19f7e..49717c7f 100644 --- a/frontend/packages/swarm-ui/src/lib/motion-apply.ts +++ b/frontend/packages/swarm-ui/src/lib/motion-apply.ts @@ -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 */ } diff --git a/frontend/packages/swarm-ui/src/shell/Shell.css b/frontend/packages/swarm-ui/src/shell/Shell.css index 615e40f7..2c7c4e86 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -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); } diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index cdd5d22d..6e82a09d 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -12,20 +12,30 @@ // 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 pop-in entrance, 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 +// 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. +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 `` default, so a page @@ -34,21 +44,47 @@ 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; +} + +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); // Applied once here, not inside `SettingsMenu` — every route mounts // through this one `<Shell>`, so the override takes effect regardless @@ -59,6 +95,36 @@ 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). + 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, + }); + } + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, [location]); + // 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 @@ -85,10 +151,31 @@ export function Shell({ children }: { children: ComponentChildren }) { <div class="shell"> <header class="shell-header"> <span class="shell-brand">{brand}</span> - <nav class="shell-nav"> + <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" + 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 +186,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> ); } From a5584d0fcdfd2116b477dcdc782b057509188939 Mon Sep 17 00:00:00 2001 From: iris <iris@hyperhive.local> Date: Wed, 19 Aug 2026 19:21:19 +0200 Subject: [PATCH 2/4] swarm-ui: plain fade + multi-hop underline sweep, per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../packages/swarm-ui/src/shell/Shell.css | 31 +++-- .../packages/swarm-ui/src/shell/Shell.tsx | 129 ++++++++++++++---- 2 files changed, 118 insertions(+), 42 deletions(-) diff --git a/frontend/packages/swarm-ui/src/shell/Shell.css b/frontend/packages/swarm-ui/src/shell/Shell.css index 2c7c4e86..aff85761 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -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; } diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 6e82a09d..c24eada0 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -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 From daabb4fe6a7d617a76fb00a2c953738de5205470 Mon Sep 17 00:00:00 2001 From: iris <iris@hyperhive.local> Date: Wed, 19 Aug 2026 19:26:04 +0200 Subject: [PATCH 3/4] swarm-ui: brand text follows the nav accent too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../packages/swarm-ui/src/shell/Shell.css | 20 +++++++++++++++++++ .../packages/swarm-ui/src/shell/Shell.tsx | 12 ++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/packages/swarm-ui/src/shell/Shell.css b/frontend/packages/swarm-ui/src/shell/Shell.css index aff85761..6eafd5c1 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -30,8 +30,28 @@ } .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; diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index c24eada0..6117eae3 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -24,6 +24,8 @@ // 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, useLocation } from 'wouter-preact'; @@ -218,10 +220,18 @@ 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> + <span class="shell-brand" style={{ color: navAccent }}> + {brand} + </span> <nav class="shell-nav" ref={navRef}> {NAV_ITEMS.map((item) => ( <NavLink From 6d9a2fa9f66d691e9f1754e94f25a1d2b0825672 Mon Sep 17 00:00:00 2001 From: iris <iris@hyperhive.local> Date: Wed, 19 Aug 2026 19:38:29 +0200 Subject: [PATCH 4/4] swarm-ui: fix underline not showing on the initial page load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../packages/swarm-ui/src/shell/Shell.css | 10 ++++++++++ .../packages/swarm-ui/src/shell/Shell.tsx | 20 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) 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 `<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`,