style(layout): tighten the oversized vertical gaps around the page toolbars

The floating-header → toolbar → filter-chips → content stack carried ~2× the whitespace it
needed. Halve it, app-wide where the chrome is shared:
- PageShell: the --hdr-h gap below the floating header 8px → 4px (every page's header).
- Feed: toolbar wrapper pt-4 → pt-2, the toolbar's own pb-3 → pb-1.5, and the grid's top pt-2
  → pt-1. Measured on the feed: header→toolbar 24→12px, toolbar→chips 12→6px, chips→cards 12→8px.
- Plex / Playlists / ChannelDiscovery / DownloadCenter / Notifications / AdminUsers: toolbar
  wrapper pt-3 → pt-2, so every page's top band matches the tighter feed.

PageShell.dom.test updated for the 4px gap.
This commit is contained in:
2026-08-21 19:13:38 +02:00
parent 1be1dc9d06
commit ec3bbc78dc
9 changed files with 17 additions and 17 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ export default function AdminUsers() {
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
+1 -1
View File
@@ -200,7 +200,7 @@ export default function ChannelDiscovery({
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
<PageToolbar>
<div className="px-4 pt-3 max-w-384 mx-auto flex items-start justify-between gap-4">
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
+1 -1
View File
@@ -135,7 +135,7 @@ export default function DownloadCenter() {
<>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-4xl w-full mx-auto">
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
+3 -3
View File
@@ -568,7 +568,7 @@ export default function Feed({
}));
const toolbar = (
<div className="pb-3">
<div className="pb-1.5">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
@@ -673,7 +673,7 @@ export default function Feed({
);
return (
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
@@ -681,7 +681,7 @@ export default function Feed({
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
applied" when it's just the page being itself. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
{!channelScoped && (
<ActiveFilterChips
@@ -123,7 +123,7 @@ export default function NotificationsPanel() {
<>
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
@@ -43,7 +43,7 @@ describe("PageShell --hdr-h (E5 S7)", () => {
Object.defineProperty(hdr, "offsetTop", { value: 12, configurable: true });
Object.defineProperty(hdr, "offsetHeight", { value: 96, configurable: true });
roCb?.();
expect(content.style.getPropertyValue("--hdr-h")).toBe("116px"); // 12 + 96 + 8
expect(content.style.getPropertyValue("--hdr-h")).toBe("112px"); // 12 + 96 + 4
});
it("adds no `--hdr-h` pad when there is no header", () => {
@@ -55,7 +55,7 @@ describe("PageShell --hdr-h (E5 S7)", () => {
// PageShell is REUSED across feed↔channel navigation (same component position in App.tsx) — it
// never remounts, so the header DOM node is swapped under a stable PageShell. The measurement
// must re-run on that swap; a one-shot effect kept observing the FIRST node and, when it detached,
// rewrote --hdr-h to a stale 8px that stuck on Back. Regression for the channel-Back header overlap.
// rewrote --hdr-h to a stale 4px that stuck on Back. Regression for the channel-Back header overlap.
const hdr = () => <div data-testid="hdr">H</div>;
const { container, rerender } = render(<PageShell header={hdr()}>content</PageShell>);
const measure = (top: number, h: number) => {
@@ -66,11 +66,11 @@ describe("PageShell --hdr-h (E5 S7)", () => {
};
measure(12, 52);
const content = () => container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement;
expect(content().style.getPropertyValue("--hdr-h")).toBe("72px"); // 12 + 52 + 8
expect(content().style.getPropertyValue("--hdr-h")).toBe("68px"); // 12 + 52 + 4
rerender(<PageShell>content</PageShell>); // → channel page: header gone
rerender(<PageShell header={hdr()}>content</PageShell>); // → Back: header returns as a NEW node
measure(12, 52); // fires the NEW header's observer
expect(content().style.getPropertyValue("--hdr-h")).toBe("72px"); // re-measured, not the stale 8px
expect(content().style.getPropertyValue("--hdr-h")).toBe("68px"); // re-measured, not the stale 4px
});
});
+4 -4
View File
@@ -71,8 +71,8 @@ export default function PageShell({
// PageShell is reused — never remounted — across feed↔channel navigation (same component position in
// App.tsx), so the header DOM node is swapped out from under a stable PageShell. A one-shot effect keeps
// observing the FIRST header node; when that node detaches on channel-open, its ResizeObserver fires with
// a 0 box and rewrites --hdr-h to `0+0+8`=8px on the (still-mounted) content div. On Back the pad class
// returns with that stale 8px → the content toolbar slides up under the header, stuck until reload. Keying
// a 0 box and rewrites --hdr-h to `0+0+4`=4px on the (still-mounted) content div. On Back the pad class
// returns with that stale 4px → the content toolbar slides up under the header, stuck until reload. Keying
// the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct.
const [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
@@ -82,9 +82,9 @@ export default function PageShell({
contentEl?.style.removeProperty("--hdr-h");
return;
}
// `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it.
// `+ 4` = a tight gap below the floating header before the content toolbar starts.
const apply = () =>
contentEl.style.setProperty("--hdr-h", `${headerEl.offsetTop + headerEl.offsetHeight + 8}px`);
contentEl.style.setProperty("--hdr-h", `${headerEl.offsetTop + headerEl.offsetHeight + 4}px`);
apply();
const ro = new ResizeObserver(apply);
ro.observe(headerEl);
+1 -1
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-3 pb-2">
<div className="px-4 pt-2 pb-2">
<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 && (
+1 -1
View File
@@ -388,7 +388,7 @@ export default function PlexBrowse() {
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
<div className="px-4 pt-2 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
{viewToggle}
<p className="text-xs text-muted">
{browseQ.isLoading