feat(layout): a pinned filter rail no longer pushes content when there's room for it

On a page that centres its content at a max width, if the window is wide enough for the rail to fit
in the content's LEFT margin without overlapping it, pinning the rail now leaves it OVERLAY — the
content doesn't shift. It reverts to the old in-flow "push" only when there isn't room: a full-width
page (feed) or a window too narrow for the rail to clear the centred content.

PageShell computes --rail-pinned-w from the page's maxWidth vs the viewport (innerWidth >= maxWidth +
~576px → overlay 2.25rem, else in-flow 16.75rem) on a resize listener; the SidePanel slot reads it.

Verified at 1920px: pinning the playlists rail leaves the table at left 487 (moved 0); the rail sits
256px wide ending at 312 — 175px clear of the content. Feed (full width) still pins in-flow.
This commit is contained in:
2026-08-21 21:53:24 +02:00
parent 1801a58f6b
commit 53ca4e2849
2 changed files with 23 additions and 3 deletions
+19 -1
View File
@@ -67,12 +67,30 @@ export default function PageShell({
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
// Publish this page's content max-width so the floating header (and toolbar band) can centre on the
// same width the content column uses. Global (one page shell renders at a time).
// same width the content column uses. Global (one page shell renders at a time). Also decide whether
// a PINNED filter rail should stay OVERLAY (keep the toolbar's fixed leading gutter, don't push the
// centred content) or go in-flow: overlay only when the centred content leaves a wide-enough left
// margin for the rail to sit in — else pinning would overlap the content, so it pushes (in-flow).
useLayoutEffect(() => {
const root = document.documentElement;
root.style.setProperty("--content-mw", maxWidth ?? "none");
// Room the pinned rail needs: its own width (~16rem) on each side of the centred content, plus the
// nav rail (~4rem). Below that, or on a full-width page (no maxWidth), pin in-flow (w-67).
const apply = () => {
const mwPx = maxWidth
? maxWidth.endsWith("rem")
? parseFloat(maxWidth) * 16
: parseFloat(maxWidth)
: null;
const roomy = mwPx != null && window.innerWidth >= mwPx + 576;
root.style.setProperty("--rail-pinned-w", roomy ? "2.25rem" : "16.75rem");
};
apply();
window.addEventListener("resize", apply);
return () => {
window.removeEventListener("resize", apply);
root.style.removeProperty("--content-mw");
root.style.removeProperty("--rail-pinned-w");
};
}, [maxWidth]);
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
+4 -2
View File
@@ -44,14 +44,16 @@ export default function SidePanel({
return (
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
// content instead of pushing it. Pinned reserves the full width (in-flow), as before.
// content instead of pushing it. Pinned reserves --rail-pinned-w: 16.75rem (in-flow, pushes) on a
// full-width page or a narrow window, but 2.25rem (overlay, doesn't push) when the centred content
// leaves room for the rail to sit in its left margin — see PageShell. Default 16.75rem = old w-67.
<div
// Focus is tracked container-wide (keyboard: tab anywhere inside holds it open). HOVER is NOT
// on this full-height slot — that made the whole column next to the nav rail a live target, so
// merely approaching the rail popped the panel open. Hover now lives only on the visible tab
// handle + the expanded card below, so only pointing AT the tab opens it.
{...peek.focusHandlers}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-67" : "w-9"}`}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9"}`}
>
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
mounted while the card is open — swapping it out from under the pointer would drop the very