Header round 6c (owner): the sidebar toggle owns an animated slot — no more one-frame jumps

When the sidebar toggled, the header's toggle mounted/unmounted in a
single frame, so its 48px footprint (button + gap) popped and dragged
the title and chips sideways while the sidebar was still mid-slide —
'kind of masked by the motion, but very much noticeable.'

The toggle is now ALWAYS MOUNTED, owning a slot whose width+margin
animate in lockstep with the sidebar's own width transition (same
duration-200, same easing, both gated on mount so no load flash):

- when open the slot is exactly ZERO (w-0 with -mr-3 cancelling the
  header's gap-3) — resting layouts are pixel-identical to the old
  conditional render;
- the icon fades with the slot (opacity in the same 200ms) and is
  clipped by it as the slot collapses — no popping in either direction;
  hidden state is pointer-events-none, aria-hidden, tabIndex -1;
- the mount-gating rule grows to transition-[width,margin,opacity].

rAF-verified both directions: the title's x-path is monotone from
frame one (old bug teleported 48px in one frame; the margin blip
caught on the way is also gone), icon opacity eases 0.12→1 over the
slide. Gates: typecheck, lint, 469/469.
This commit is contained in:
amosblomqvist
2026-09-17 14:04:43 +03:00
parent 9ddeb60e3f
commit 64c644f42c
+34 -9
View File
@@ -52,22 +52,47 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
return (
<SidebarContext.Provider value={{ open, toggle }}>
{/* transitions gated on mount: no slide-in flash on page load */}
<div className={cn("flex h-dvh", mounted && "[&_.sidebar-motion]:transition-[width]")}>{children}</div>
<div className={cn("flex h-dvh", mounted && "[&_.sidebar-motion]:transition-[width,margin,opacity]")}>{children}</div>
</SidebarContext.Provider>
);
}
/** Hamburger for page headers — only visible when the sidebar is closed. */
/** Hamburger for page headers — only visible when the sidebar is closed.
*
* Owner, round 6c: the toggle is ALWAYS MOUNTED, owning a slot whose
* width+margin animate in LOCKSTEP with the sidebar's own width
* transition (same duration-200, same easing, both gated on mount) —
* before, it mounted/unmounted in one frame, so its 48px footprint
* (button + gap) popped and dragged the title and chips sideways while
* the sidebar was still mid-slide. When open the slot is exactly zero
* (w-0 -mr-3: width 0, the negative margin cancels the header's gap-3),
* so resting layouts are pixel-identical to the old conditional render.
* The icon fades with the slot and is clipped by it as it collapses —
* no popping in either direction. */
export function SidebarToggle() {
const { open, toggle } = useSidebar();
if (open) return null;
return (
<Button variant="ghost" size="icon" aria-label="Open sidebar" onClick={toggle}>
{/* Owner, round 6: size-4, SAME as the close glyph — the toggle
must not grow when the sidebar is closed (it did: size-5 here).
The small look is the standard, both directions. */}
<PanelLeftOpenIcon className="size-4" />
</Button>
<span
className={cn(
"sidebar-motion relative flex h-9 shrink-0 overflow-hidden duration-200",
open ? "w-0 -mr-3" : "w-9 mr-0",
)}
>
<Button
variant="ghost"
size="icon"
aria-label="Open sidebar"
aria-hidden={open}
tabIndex={open ? -1 : 0}
onClick={toggle}
className={cn("sidebar-motion duration-200", open ? "pointer-events-none opacity-0" : "opacity-100")}
>
{/* Owner, round 6: size-4, SAME as the close glyph — the toggle
must not grow when the sidebar is closed (it did: size-5 here).
The small look is the standard, both directions. */}
<PanelLeftOpenIcon className="size-4" />
</Button>
</span>
);
}