feat(layout): centre the header on each page's content width, centre the playlist table

Every page's floating header (search bar + sync chip) and the toolbar band now centre on the SAME
max-width the content column uses, instead of spanning the whole column while a max-w page centres
its grid. So the search bar lines up with the left edge of the cards/table on every page, and sits
centred with the content — not against the full page width.

- PageShell takes a `maxWidth`, published as --content-mw; the header adds max-w-[--content-mw] mx-auto
  inside its left-4 / right-(1rem+scrollbar) span, so it centres on that width (`none` = full width).
- moduleRegistry PAGE_CONTENT carries each page's content max width (plex 1600px, channels 96rem,
  playlists 64rem, downloads 56rem, notifications/users 48rem; feed stays full width) — one source.
- Playlists: its content (header, filter row, table) was left-aligned max-w-5xl → now mx-auto, so the
  table is centred and the header lines up with it.

Verified at 1920px: header left edge == content left edge — feed 340, channels 213, playlists 487.
This commit is contained in:
2026-08-21 21:38:49 +02:00
parent 011acd919e
commit 1801a58f6b
6 changed files with 37 additions and 12 deletions
+2 -1
View File
@@ -313,7 +313,7 @@ export default function App() {
const me = meQuery.data!;
const pageGate = moduleDef(page).gate;
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
const { Component: PageContent, fadeTop, maxWidth } = PAGE_CONTENT[activePage];
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
@@ -379,6 +379,7 @@ export default function App() {
) : (
<PageShell
scrollKey={normalScrollKey}
maxWidth={maxWidth}
// Channels: the registry disables the top fade for its sticky-header table, but the card
// layout has no sticky header, so re-enable it there.
fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
+5 -3
View File
@@ -89,9 +89,11 @@ const Header = forwardRef<
// slim and/or no filter sidebar is present — no hard-coded offset.
<div
ref={ref}
// left-4 / right = 1rem + the scrollbar gutter: the header spans exactly the card grid's width
// (left card's left edge to right card's right edge), matching the toolbar band below it.
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
// + mx-auto then centres the header on the SAME width the page's content uses, so on a max-width page
// (Plex/Channels/Playlists) the header lines up with the centred grid, not the whole column. `none`
// (feed) = full width. Matches the toolbar band below it.
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
>
{search}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
+13
View File
@@ -52,6 +52,7 @@ export default function PageShell({
children,
scrollKey,
fadeTop = true,
maxWidth,
}: {
header?: ReactNode;
banners?: ReactNode;
@@ -60,8 +61,20 @@ export default function PageShell({
scrollKey?: string;
// Off when this page's scroll region starts with a sticky header — see PageScroller.
fadeTop?: boolean;
// The content column's max width (e.g. "1600px"); the header + toolbar centre on the same width via
// --content-mw. Omit for a full-width page (feed). See index.css --content-mw.
maxWidth?: string;
}) {
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).
useLayoutEffect(() => {
const root = document.documentElement;
root.style.setProperty("--content-mw", maxWidth ?? "none");
return () => {
root.style.removeProperty("--content-mw");
};
}, [maxWidth]);
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
// height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad
// would let the content toolbar slide up under it (E5 S7). Measure the header and write its height
+2 -2
View File
@@ -571,7 +571,7 @@ export default function Playlists() {
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-2 pb-2">
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
@@ -757,7 +757,7 @@ export default function Playlists() {
accessibility={{ screenReaderInstructions, announcements }}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl">
<div className="max-w-5xl mx-auto">
{/* table-fixed, not auto: every column but the title has a declared width, so the
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
min-content, and a nowrap title cell's min-content is the entire title. */}
+10 -6
View File
@@ -75,21 +75,25 @@ export type PageDef = {
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
fadeTop?: boolean;
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
// so the floating chrome lines up with the centred content instead of spanning the whole column.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
maxWidth?: string;
};
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false },
playlists: { Component: Playlists },
plex: { Component: PlexBrowse },
notifications: { Component: NotificationsPanel },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "1600px" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: MessagesPage },
downloads: { Component: DownloadCenter },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers },
users: { Component: AdminUsers, maxWidth: "48rem" },
audit: { Component: AuditLog, fadeTop: false },
settings: { Component: SettingsPanel },
};
+5
View File
@@ -104,6 +104,11 @@
Defaults to 0 for overlay-scrollbar platforms where the gutter is empty. */
--sbw: 0px;
/* Max width of a page's content column (cards / table). The floating header + toolbar band read it
so they centre on the SAME max-width the content uses, instead of spanning the whole column while
a max-w page centres its grid. Each page sets it via PageShell's `maxWidth`; `none` = full width. */
--content-mw: none;
/* ===== Glass tunables — every value that materially drives the look lives here so it can be
tuned in ONE place. ===== */
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome