Files
siftlode/frontend/src/components/PageScroller.tsx
T
peter 70bda98868 build(deps): migrate Tailwind CSS v3.4 → v4
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.

- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
  JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
  classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
  ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
  autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
  runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
  site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
  "rounded-sm" inside a releaseNotes.ts prose sentence.

Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
2026-08-08 00:50:05 +02:00

116 lines
5.2 KiB
TypeScript

import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade";
// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
// the same view returns. Keyed by the shell's `scrollKey`.
const savedScroll = new Map<string, number>();
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now).
function restoreScroll(el: HTMLElement, top: number): () => void {
if (top <= 0) {
el.scrollTop = 0;
return () => {};
}
let raf = 0;
let frames = 0;
let cancelled = false;
const tick = () => {
if (cancelled) return;
if (el.scrollHeight - el.clientHeight >= top) el.scrollTop = top;
if (el.scrollTop >= top - 1 || frames++ > 90) return; // reached, or gave up after ~1.5s
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
};
}
// The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the
// channel page and Plex browse all scroll inside it rather than owning a scroller of their own,
// which is why the edge-fade and the scroll restore/reset policy live 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.
//
// Scroll restore/reset policy (`scrollKey` identifies the logical view):
// - returning to a view with the SAME key — feed → open a channel → Back, feed filters unchanged —
// restores where you were (bug 1). The channel page is a different scroller, so the feed's
// scroller unmounts and remounts; the saved offset survives in `savedScroll` and is re-applied.
// - a NEW key — the feed's filters changed, i.e. a fresh result set — starts at the top (bug 3).
// The offset is recorded on every scroll rather than at unmount: by the time an unmount cleanup
// runs the scroller's scrollTop has already been zeroed by the teardown, so a cleanup-time read
// saves 0 and the restore is lost. Recording live sidesteps that (and StrictMode's synthetic
// unmount, which would otherwise persist a 0 too).
//
// 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,
scrollKey,
fadeTop = true,
}: {
children: ReactNode;
scrollKey?: string;
// Off when the scroller's top is a sticky element (a table's sticky header) — see useScrollFade.
fadeTop?: boolean;
}) {
const fade = useScrollFade(20, fadeTop);
const fadeRef = fade.ref;
const elRef = useRef<HTMLElement | null>(null);
// Stable identity: an inline ref callback changes every render, which makes React detach and
// re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it
// into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
// once here is safe.
const setRef = useCallback(
(node: HTMLElement | null) => {
elRef.current = node;
fadeRef(node);
},
[fadeRef],
);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || scrollKey == null) return;
const key = scrollKey;
const want = savedScroll.get(key) ?? 0;
const cancelRestore = restoreScroll(el, want);
// Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the
// time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last.
const onScroll = () => {
savedScroll.set(key, el.scrollTop);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelRestore();
el.removeEventListener("scroll", onScroll);
};
}, [scrollKey]);
return (
<main
ref={setRef}
style={fade.style}
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable"
>
{children}
</main>
);
}