feat(shell): channel chrome is fully fixed, only the grid scrolls

Reworks the channel detail per UAT: instead of the banner scrolling away
and a sticky sub-header (which felt odd to scroll and needed an opaque bar
that clashed with the design even at rest), the ENTIRE chrome — banner,
identity, tabs, toolbar — is fixed and only the video grid scrolls.

Mechanism: ChannelPage wraps its whole chrome in <PageToolbar> so it
portals into the shell's fixed band (like the feed toolbar); the
channelScoped Feed portals its toolbar into a slot at the bottom of that
chrome. No sticky, no opaque bar — the chrome is transparent over the page
bg because nothing scrolls under it (the band sits above the scroller).

E2E updated: the channel chrome (title + toolbar) stays at a fixed
viewport position while the grid scrolls. Suite 15/15, tsc clean.
This commit is contained in:
2026-07-18 04:53:49 +02:00
parent 0e03e561eb
commit ec807c8b31
3 changed files with 26 additions and 23 deletions
+9 -8
View File
@@ -56,15 +56,17 @@ test.describe("channel view", () => {
.toBeGreaterThan(saved - 500); .toBeGreaterThan(saved - 500);
}); });
// Platform S4b: the channel page's banner/identity scroll away, but the tabs + feed toolbar stick // Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
// to the top (a sticky sub-header) so only the video grid scrolls. // put while only the video grid scrolls.
test("tabs + toolbar stay pinned while the channel videos scroll", async ({ page }) => { test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
await openApp(page); await openApp(page);
await page.getByTestId("video-card-channel").first().click(); await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("feed-result-count"); const count = page.getByTestId("feed-result-count");
const title = page.getByTestId("channel-title");
await expect(count).toBeVisible(); await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
await expect(page.getByTestId("channel-title")).toBeInViewport(); const titleTop = await title.evaluate((el) => el.getBoundingClientRect().top);
const countTop = await count.evaluate((el) => el.getBoundingClientRect().top);
// Keep nudging until the grid is tall enough to actually scroll (it may still be filling in). // Keep nudging until the grid is tall enough to actually scroll (it may still be filling in).
await expect await expect
@@ -74,9 +76,8 @@ test.describe("channel view", () => {
}) })
.toBeGreaterThan(400); .toBeGreaterThan(400);
// Banner/identity scrolled away, but the tabs+toolbar stuck near the top. // The chrome didn't move — banner title and toolbar are in exactly the same place.
await expect(page.getByTestId("channel-title")).not.toBeInViewport(); expect(await title.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop);
await expect(count).toBeInViewport(); expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBe(countTop);
expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBeLessThan(130);
}); });
}); });
+16 -14
View File
@@ -5,7 +5,7 @@ import { useNavigationActions } from "./NavigationProvider";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react"; import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import Feed from "./Feed"; import Feed from "./Feed";
import { PageToolbarSlot } from "./PageShell"; import { PageToolbar, PageToolbarSlot } from "./PageShell";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -77,9 +77,10 @@ export default function ChannelPage({
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const [tab, setTab] = useState<"videos" | "about">("videos"); const [tab, setTab] = useState<"videos" | "about">("videos");
// The sticky sub-header hosts the tabs AND the channel feed's toolbar: the Feed portals its // The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
// toolbar into this node (via the overridden PageToolbarSlot below) so both ride together and // fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
// stay put while the banner/identity scroll away above them. // where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null); const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
// The banner URL is valid, but googleusercontent intermittently throttles it when the channel // 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 // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the
@@ -210,10 +211,12 @@ export default function ChannelPage({
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg" ? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent"; : "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
// The channel page owns no scroller of its own: it's a PageShell config (no shared header, no // The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// rail, its banner scrolls from the top) so there's ONE page scroller in the app, not two. // whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
return ( return (
<> <>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */} {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3"> <div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? ( {ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
@@ -299,16 +302,11 @@ export default function ChannelPage({
{t("channel.loadingVideos")} {t("channel.loadingVideos")}
</div> </div>
)} )}
</div>
{/* 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. */}
<div className="sticky top-0 z-20 bg-surface border-b border-border">
{/* Tabs — the channel actions ride along here rather than off in the header's top-right {/* 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 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. */} within a few pixels of each other instead of across the page. */}
<div className="px-4 sm:px-6 pt-2 flex items-center gap-4"> <div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}> <button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")} {t("channel.tabVideos")}
</button> </button>
@@ -375,10 +373,14 @@ export default function ChannelPage({
))} ))}
</div> </div>
</div> </div>
<div ref={setChromeSlot} />
</div> </div>
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
just under the tabs. */}
<div ref={setChromeSlot} />
</PageToolbar>
{/* Tab content — the feed's toolbar portals up into the sticky sub-header via this override. */} {/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
only the grid (and About) render here, in the scroller. */}
<PageToolbarSlot.Provider value={chromeSlot}> <PageToolbarSlot.Provider value={chromeSlot}>
{tab === "videos" ? ( {tab === "videos" ? (
<> <>
+1 -1
View File
@@ -655,7 +655,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 pt-3" : "px-4 pb-4 pt-2"}>
{/* 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