diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b5fc3f1..88b7af9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -54,6 +54,7 @@ import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import PlaylistsRail from "./components/PlaylistsRail";
import BackToTop from "./components/BackToTop";
+import PageScroller from "./components/PageScroller";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
@@ -898,13 +899,7 @@ export default function App() {
>
{meQuery.data!.is_demo && }
openReleaseNotes(CURRENT_VERSION)} />
- {/* scrollbar-gutter: stable is load-bearing, not cosmetic. The feed derives its column
- count from this element's clientWidth and its thumbnails are aspect-ratio boxes, so
- when the content lands within a scrollbar's width of the viewport height the two feed
- each other: scrollbar appears -> 15px narrower -> shorter thumbs -> content fits ->
- scrollbar goes -> wider -> taller -> repeat, every frame. Reserving the gutter keeps
- clientWidth constant and breaks the loop. */}
-
+
{page === "channels" ? (
)}
-
+
>
)}
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index 50ac1bd..b4762a6 100644
--- a/frontend/src/components/ChannelPage.tsx
+++ b/frontend/src/components/ChannelPage.tsx
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
+import PageScroller from "./PageScroller";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -208,10 +209,7 @@ export default function ChannelPage({
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
return (
- // scrollbar-gutter:stable reserves the scrollbar track on BOTH tabs, so switching between the
- // tall Videos tab (scrollbar) and the short About tab (no scrollbar) no longer shifts the
- // header/logo/buttons horizontally as the scrollbar appears/disappears.
-
+
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
)}
-
+
);
}
diff --git a/frontend/src/components/PageScroller.tsx b/frontend/src/components/PageScroller.tsx
new file mode 100644
index 0000000..90840b0
--- /dev/null
+++ b/frontend/src/components/PageScroller.tsx
@@ -0,0 +1,35 @@
+import { type ReactNode } from "react";
+import { useScrollFade } from "../lib/useScrollFade";
+
+// The page's scroll container. Two of these exist and are never both mounted: App renders one for
+// the normal pages, ChannelPage renders its own (it's a separate branch of App's tree, not a page
+// inside the usual one). The feed, the channel page and Plex browse all scroll inside it rather
+// than owning a scroller — which is why the edge-fade lives here once instead of three times.
+//
+// Two invariants this element carries, both learned the hard way:
+//
+// - `scrollbar-gutter: stable` is load-bearing, not cosmetic. The feed derives its column count
+// from this element's clientWidth and its thumbnails are aspect-ratio boxes, so when the content
+// lands within a scrollbar's width of the viewport height the two feed each other: scrollbar
+// appears -> 15px narrower -> shorter thumbs -> content fits -> scrollbar goes -> wider ->
+// taller -> repeat, every frame. Reserving the gutter keeps clientWidth constant and breaks the
+// loop. On the channel page it also stops the header/logo/buttons shifting sideways when you
+// switch from the tall Videos tab to the short About tab.
+// - Consequently the bar STAYS visible here: `.no-scrollbar` (scrollbar-width: none) would zero
+// the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the
+// fade next to its scrollbar, not instead of it.
+//
+// The fade state lives in here rather than in App so that a flip re-renders this component alone —
+// `children` arrives as an already-built element, so React skips the page subtree underneath.
+export default function PageScroller({ children }: { children: ReactNode }) {
+ const fade = useScrollFade();
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts
index 9bfc5ec..749021b 100644
--- a/frontend/src/lib/useScrollFade.ts
+++ b/frontend/src/lib/useScrollFade.ts
@@ -1,34 +1,54 @@
import { useEffect, useState, type CSSProperties } from "react";
-// Scroll-affordance for a scroll container whose native scrollbar is hidden (`.no-scrollbar`):
+// Scroll-affordance for a scroll container (typically one whose native scrollbar is hidden with
+// `.no-scrollbar`, but the page scroller keeps its bar and takes the fade anyway):
// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge
// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref
// 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): {
- ref: (node: HTMLDivElement | null) => void;
+ ref: (node: HTMLElement | null) => void;
style: CSSProperties;
} {
- const [el, setEl] = useState(null);
+ const [el, setEl] = useState(null);
const [fade, setFade] = useState({ up: false, down: false });
useEffect(() => {
if (!el) return;
- const update = () =>
- setFade({
- up: el.scrollTop > 1,
- down: el.scrollTop + el.clientHeight < el.scrollHeight - 1,
- });
+ // Bail out when neither edge changed: `scroll` fires per frame, and the page scroller's
+ // hook sits above the whole page tree, so a new object here would re-render on every frame
+ // of every scroll. The two booleans only flip at the ends of the range.
+ const update = () => {
+ const up = el.scrollTop > 1;
+ const down = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
+ setFade((prev) => (prev.up === up && prev.down === down ? prev : { up, down }));
+ };
update();
el.addEventListener("scroll", update, { passive: true });
+ // Watch the scroller AND its content. Content that grows or shrinks — a lazy page loading in,
+ // a thread's messages decrypting, a list being filtered — changes neither the scroller's own
+ // box nor its scrollTop, so without the children nothing would re-evaluate the edges.
const ro = new ResizeObserver(update);
- ro.observe(el);
- // Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates.
- if (el.firstElementChild) ro.observe(el.firstElementChild);
+ const observeAll = () => {
+ ro.disconnect();
+ ro.observe(el);
+ for (const child of Array.from(el.children)) ro.observe(child);
+ };
+ observeAll();
+ // Children get SWAPPED, and a ResizeObserver holding a detached node simply never fires
+ // again. Measured: the Plex page opened with no bottom fade over a 3119px list, because the
+ // node observed at mount was the Suspense fallback the real page then replaced. (Same
+ // lazy-page race that broke BackToTop.) Re-observe whenever the child list changes.
+ const mo = new MutationObserver(() => {
+ observeAll();
+ update();
+ });
+ mo.observe(el, { childList: true });
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
+ mo.disconnect();
};
}, [el]);