refactor(shell): introduce PageShell for the normal-page chrome

Collapse App's hand-assembled normal-page chrome (floating Header +
top-padded wrapper + PageScroller + banners) into a single PageShell
contract with declared fixed vs scrollable regions. No behaviour change;
the channel page still renders its own scroller (dissolved next).

Groundwork for the S4 fixed-chrome/scroll-ownership seam.
This commit is contained in:
2026-07-18 03:15:41 +02:00
parent 91691c150d
commit dc6e62e822
2 changed files with 72 additions and 32 deletions
+30 -32
View File
@@ -53,7 +53,7 @@ import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import PlaylistsRail from "./components/PlaylistsRail";
import BackToTop from "./components/BackToTop";
import PageScroller from "./components/PageScroller";
import PageShell from "./components/PageShell";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
@@ -715,34 +715,34 @@ export default function App() {
/>
</Suspense>
) : (
<>
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
playlistsSearch={playlistsSearch}
setPlaylistsSearch={setPlaylistsSearch}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
{/* The header floats (fixed) over the content, so it no longer occupies flow space; this
wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level
floating panels beside the nav — none sit inside this content column anymore.) */}
<div
className="flex-1 min-w-0 min-h-0 flex flex-col pt-[var(--hdr-h)]"
>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<PageScroller>
<PageShell
header={
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
playlistsSearch={playlistsSearch}
setPlaylistsSearch={setPlaylistsSearch}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
}
banners={
<>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
</>
}
>
<Suspense fallback={pageFallback}>
{page === "channels" ? (
<Channels
@@ -823,9 +823,7 @@ export default function App() {
/>
)}
</Suspense>
</PageScroller>
</div>
</>
</PageShell>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically
+42
View File
@@ -0,0 +1,42 @@
import { type ReactNode } from "react";
import PageScroller from "./PageScroller";
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
// branch that rendered its OWN scroller) collapses to one contract and one scroller.
//
// Regions, top to bottom:
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
// and no fixed top band, so the module's own banner can scroll from the very top.
// - `banners` — in-flow, fixed notices (demo/version) above the scroll region.
// - `toolbar` — in-flow, fixed per-module band (filter chips / sort / counts) that must NOT scroll
// with the content.
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
// invariants (`scrollbar-gutter: stable`, edge fade); see that file.
//
// The shell renders a fragment so it drops straight into App's `relative` content column — the
// floating header keeps resolving its `absolute` position against that same ancestor.
export default function PageShell({
header,
banners,
toolbar,
children,
}: {
header?: ReactNode;
banners?: ReactNode;
toolbar?: ReactNode;
children: ReactNode;
}) {
return (
<>
{header}
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
{banners}
{toolbar}
<PageScroller>{children}</PageScroller>
</div>
</>
);
}