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. */} {/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar> <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} /> <Tabs tabs={tabs} active={active} onChange={setTab} />
</div> </div>
</PageToolbar> </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). */} {/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
<PageToolbar> <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> <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). */} {/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && ( {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. */} {/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
<PageToolbar> <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"> <div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1> <h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button <button
+3 -3
View File
@@ -568,7 +568,7 @@ export default function Feed({
})); }));
const toolbar = ( const toolbar = (
<div className="pb-3"> <div className="pb-1.5">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap"> <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 {/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */} multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
@@ -673,7 +673,7 @@ export default function Feed({
); );
return ( 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 {/* 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 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 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 baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
applied" when it's just the page being itself. */} applied" when it's just the page being itself. */}
<PageToolbar> <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} {toolbar}
{!channelScoped && ( {!channelScoped && (
<ActiveFilterChips <ActiveFilterChips
@@ -123,7 +123,7 @@ export default function NotificationsPanel() {
<> <>
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */} {/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar> <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"> <div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" /> <Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-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, "offsetTop", { value: 12, configurable: true });
Object.defineProperty(hdr, "offsetHeight", { value: 96, configurable: true }); Object.defineProperty(hdr, "offsetHeight", { value: 96, configurable: true });
roCb?.(); 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", () => { 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 // 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 // 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, // 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 hdr = () => <div data-testid="hdr">H</div>;
const { container, rerender } = render(<PageShell header={hdr()}>content</PageShell>); const { container, rerender } = render(<PageShell header={hdr()}>content</PageShell>);
const measure = (top: number, h: number) => { const measure = (top: number, h: number) => {
@@ -66,11 +66,11 @@ describe("PageShell --hdr-h (E5 S7)", () => {
}; };
measure(12, 52); measure(12, 52);
const content = () => container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement; 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>content</PageShell>); // → channel page: header gone
rerender(<PageShell header={hdr()}>content</PageShell>); // → Back: header returns as a NEW node rerender(<PageShell header={hdr()}>content</PageShell>); // → Back: header returns as a NEW node
measure(12, 52); // fires the NEW header's observer 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 // 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 // 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 // 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 // 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 8px → the content toolbar slides up under the header, stuck until reload. Keying // 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. // 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 [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null); const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
@@ -82,9 +82,9 @@ export default function PageShell({
contentEl?.style.removeProperty("--hdr-h"); contentEl?.style.removeProperty("--hdr-h");
return; 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 = () => 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(); apply();
const ro = new ResizeObserver(apply); const ro = new ResizeObserver(apply);
ro.observe(headerEl); 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 {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */} fixed band, so only the video list scrolls. */}
<PageToolbar> <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="flex gap-3 items-start mb-4">
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0"> <div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && ( {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 {/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */} while only the poster grid scrolls. */}
<PageToolbar> <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} {viewToggle}
<p className="text-xs text-muted"> <p className="text-xs text-muted">
{browseQ.isLoading {browseQ.isLoading