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);
});
// 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 }) => {
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
// put while only the video grid scrolls.
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("feed-result-count");
const title = page.getByTestId("channel-title");
await expect(count).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).
await expect
@@ -74,9 +76,8 @@ test.describe("channel view", () => {
})
.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);
// The chrome didn't move — banner title and toolbar are in exactly the same place.
expect(await title.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop);
expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBe(countTop);
});
});
+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 Avatar from "./Avatar";
import Feed from "./Feed";
import { PageToolbarSlot } from "./PageShell";
import { PageToolbar, PageToolbarSlot } from "./PageShell";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -77,9 +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.
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
// 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);
// 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
@@ -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 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
// rail, its banner scrolls from the top) so there's ONE page scroller in the app, not two.
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// 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 (
<>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
@@ -299,16 +302,11 @@ export default function ChannelPage({
{t("channel.loadingVideos")}
</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
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. */}
<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")}>
{t("channel.tabVideos")}
</button>
@@ -375,10 +373,14 @@ export default function ChannelPage({
))}
</div>
</div>
<div ref={setChromeSlot} />
</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}>
{tab === "videos" ? (
<>
+1 -1
View File
@@ -655,7 +655,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 pt-3" : "px-4 pb-4 pt-2"}>
{/* 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