From a0323245136d86b9879b8b86313366009356d347 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 17 Jul 2026 03:14:37 +0200 Subject: [PATCH] improvement(scroll): fade the page scroller's clipped edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subdued edge-fade the rail and side panels use now hints at more content on the page scroller too, which the feed, the channel page and Plex all scroll inside. Both
s move into one PageScroller so the invariants they share — the load-bearing scrollbar gutter, and the bar staying visible because hiding it would zero that gutter — are stated once. Two fixes the hook needed first: it set fresh state on every scroll event, which above a page tree would re-render it per frame; and it watched a single child captured at mount, so a lazy page swapping out its Suspense fallback left the observer on a detached node and the fade frozen at its pre-load value (measured on the Plex page: no bottom fade over a 3119px list). --- frontend/src/App.tsx | 11 ++----- frontend/src/components/ChannelPage.tsx | 8 ++--- frontend/src/components/PageScroller.tsx | 35 ++++++++++++++++++++ frontend/src/lib/useScrollFade.ts | 42 +++++++++++++++++------- 4 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/PageScroller.tsx 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) */}
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? ( @@ -470,6 +468,6 @@ export default function ChannelPage({ )}
)} -
+ ); } 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]);