feat(shell): sticky tabs+toolbar on the channel page

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.
This commit is contained in:
2026-07-18 04:45:11 +02:00
parent 10f4baab3b
commit 0e03e561eb
6 changed files with 75 additions and 30 deletions
+14 -17
View File
@@ -655,27 +655,24 @@ export default function Feed({
);
return (
<div className={channelScoped ? "p-4" : "px-4 pb-4 pt-2"}>
{/* 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
) : (
<PageToolbar>
<div className="px-4 pt-4 pb-1">
{toolbar}
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "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
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. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6 pb-2" : "px-4 pt-4 pb-1"}>
{toolbar}
{!channelScoped && (
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
</div>
</PageToolbar>
)}
)}
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>