From 0e03e561eb2f547df84b2491d2aaad2e9b500954 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 04:45:11 +0200 Subject: [PATCH] feat(shell): sticky tabs+toolbar on the channel page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalise the fixed-chrome pattern to the channel detail page: the banner + identity now scroll away, while the tab row and the channel feed's toolbar stick to the top as a sticky sub-header — only the video grid scrolls. Mechanism: PageToolbarSlot (the toolbar portal target) is now an exported, overridable context. ChannelPage provides its own sticky sub-header node as the target, so the channelScoped Feed portals its toolbar up next to the tabs instead of into the shell's top band. Feed always portals its toolbar now (the slot decides where it lands). Also: the page scroller now fades only its BOTTOM edge — the top is bounded by the fixed/sticky chrome, so a top fade only bit into it. E2E: adds a lock that the channel tabs/toolbar stay pinned while the banner scrolls away; suite 15/15 on :5173, tsc + knip clean. --- frontend/e2e/channel.spec.ts | 24 ++++++++++++++++++ frontend/src/components/ChannelPage.tsx | 17 +++++++++++-- frontend/src/components/Feed.tsx | 31 +++++++++++------------- frontend/src/components/PageScroller.tsx | 4 ++- frontend/src/components/PageShell.tsx | 14 ++++++----- frontend/src/lib/useScrollFade.ts | 15 +++++++++--- 6 files changed, 75 insertions(+), 30 deletions(-) diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index 74dd3d7..957d4cd 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -55,4 +55,28 @@ test.describe("channel view", () => { .poll(() => page.getByRole("main").evaluate((el) => el.scrollTop), { timeout: 5000 }) .toBeGreaterThan(saved - 500); }); + + // Platform S4b: the channel page's banner/identity scroll away, but the tabs + feed toolbar stick + // to the top (a sticky sub-header) so only the video grid scrolls. + test("tabs + toolbar stay pinned while the channel videos scroll", async ({ page }) => { + await openApp(page); + await page.getByTestId("video-card-channel").first().click(); + const count = page.getByTestId("feed-result-count"); + await expect(count).toBeVisible(); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + await expect(page.getByTestId("channel-title")).toBeInViewport(); + + // Keep nudging until the grid is tall enough to actually scroll (it may still be filling in). + await expect + .poll(async () => { + await page.getByRole("main").evaluate((el) => el.scrollBy(0, 1500)); + return page.getByRole("main").evaluate((el) => el.scrollTop); + }) + .toBeGreaterThan(400); + + // Banner/identity scrolled away, but the tabs+toolbar stuck near the top. + await expect(page.getByTestId("channel-title")).not.toBeInViewport(); + await expect(count).toBeInViewport(); + expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBeLessThan(130); + }); }); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index a4e1104..c3cefd8 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -5,6 +5,7 @@ import { useNavigationActions } from "./NavigationProvider"; import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react"; import Avatar from "./Avatar"; import Feed from "./Feed"; +import { PageToolbarSlot } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -76,6 +77,10 @@ export default function ChannelPage({ const qc = useQueryClient(); const confirm = useConfirm(); const [tab, setTab] = useState<"videos" | "about">("videos"); + // The sticky sub-header hosts the tabs AND the channel feed's toolbar: the Feed portals its + // toolbar into this node (via the overridden PageToolbarSlot below) so both ride together and + // stay put while the banner/identity scroll away above them. + const [chromeSlot, setChromeSlot] = useState(null); // The banner URL is valid, but googleusercontent intermittently throttles it when the channel // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the // browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would @@ -294,11 +299,16 @@ export default function ChannelPage({ {t("channel.loadingVideos")} )} + + {/* Sticky sub-header: the tabs and (on the Videos tab) the feed toolbar ride here and stay put + while the banner + identity scroll away above them. The toolbar portals into `chromeSlot` + below via the overridden PageToolbarSlot; only the video grid scrolls. */} +
{/* Tabs — the channel actions ride along here rather than off in the header's top-right corner: there are only ever two tabs, so there's room, and it keeps the things you click within a few pixels of each other instead of across the page. */} -
+
@@ -365,9 +375,11 @@ export default function ChannelPage({ ))}
+
- {/* Tab content */} + {/* Tab content — the feed's toolbar portals up into the sticky sub-header via this override. */} + {tab === "videos" ? ( <> )} + ); } diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index c47b574..8997516 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -655,27 +655,24 @@ export default function Feed({ ); return ( -
- {/* On the feed page the toolbar + active-filter chips are FIXED chrome: they pin in the - shell's toolbar band (above the scroller) so they stay put — and reachable — while the - cards scroll (bug 4). On a channel page the Feed is embedded below the channel's own - banner/tabs, which scroll, so there the toolbar rides inline with the content as before. - Channel pages get no chips: their Feed starts from its OWN baseline (channel-scoped, - whole-catalog, show-all), so each would read as a "filter you applied" when it's just the - page being itself. */} - {channelScoped ? ( - toolbar - ) : ( - -
- {toolbar} +
+ {/* 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 + the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN + baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you + applied" when it's just the page being itself. */} + +
+ {toolbar} + {!channelScoped && ( setFilters(clearedFilters(filters))} /> -
-
- )} + )} +
+ {items.length === 0 ? (

{t("feed.noMatches")}

diff --git a/frontend/src/components/PageScroller.tsx b/frontend/src/components/PageScroller.tsx index 25d34ed..0118d1d 100644 --- a/frontend/src/components/PageScroller.tsx +++ b/frontend/src/components/PageScroller.tsx @@ -67,7 +67,9 @@ export default function PageScroller({ children: ReactNode; scrollKey?: string; }) { - const fade = useScrollFade(); + // Only the bottom edge fades: the top is bounded by the shell's fixed chrome (header / toolbar + // band) or a module's sticky sub-header, so a top fade would just eat into that chrome. + const fade = useScrollFade(20, { top: false }); const fadeRef = fade.ref; const elRef = useRef(null); // Stable identity: an inline ref callback changes every render, which makes React detach and diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index 1ff0b2f..3833b27 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -2,9 +2,11 @@ import { createContext, useContext, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import PageScroller from "./PageScroller"; -// The DOM node of the shell's fixed toolbar band, published so a module deep inside the scroller -// can render its own toolbar UP into the fixed region (see PageToolbar below). -const ToolbarSlot = createContext(null); +// The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its +// fixed band as the default target; a module can override it with its own +// to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the +// toolbar rides with the tabs instead of the shell's top band. +export const PageToolbarSlot = createContext(null); // A module renders its per-module fixed band (filter chips / sort / counts) through this. The // content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content @@ -13,7 +15,7 @@ const ToolbarSlot = createContext(null); // if it were rendered inline). Renders nothing until the band mounts (one tick on first mount) and // nothing when there's no shell — so a module works with or without one. export function PageToolbar({ children }: { children: ReactNode }) { - const slot = useContext(ToolbarSlot); + const slot = useContext(PageToolbarSlot); return slot ? createPortal(children, slot) : null; } @@ -48,13 +50,13 @@ export default function PageShell({ }) { const [toolbarSlot, setToolbarSlot] = useState(null); return ( - + {header}
{banners}
{children}
- + ); } diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index 642e787..dfdd995 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -7,10 +7,17 @@ import { useEffect, useState, type CSSProperties } from "react"; // to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback // ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that // mounts collapsed and only renders its scroll body once expanded still wires up correctly. -export function useScrollFade(fadePx = 20): { +// `edges` gates which ends fade. The page scroller passes `{ top: false }`: its top is delimited by +// the fixed/sticky module chrome (header, toolbar band, a sticky sub-header), so a top fade there +// would only bite into that chrome instead of hinting at more content. +export function useScrollFade( + fadePx = 20, + edges: { top?: boolean; bottom?: boolean } = {}, +): { ref: (node: HTMLElement | null) => void; style: CSSProperties; } { + const { top: fadeTop = true, bottom: fadeBottom = true } = edges; const [el, setEl] = useState(null); const [fade, setFade] = useState({ up: false, down: false }); @@ -53,8 +60,8 @@ export function useScrollFade(fadePx = 20): { }; }, [el]); - const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${ - fade.down ? fadePx : 0 - }px), transparent)`; + const topPx = fadeTop && fade.up ? fadePx : 0; + const bottomPx = fadeBottom && fade.down ? fadePx : 0; + const mask = `linear-gradient(to bottom, transparent, #000 ${topPx}px, #000 calc(100% - ${bottomPx}px), transparent)`; return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; }