Scroll round 2 (owner): the scrollbar stays visible + wheel works over the dock

Two round-1 follow-ups (owner):

- The overlay painted over the scroller's scrollbar track. The scroller
  reserves symmetric gutters (scrollbar-gutter: stable both-edges), so the
  overlay's right edge is now inset by the measured gutter — half of
  offsetWidth − clientWidth; zero on macOS overlay scrollbars, the real
  width on classic platforms. Re-measured on window resize. The headless
  shell renders classic scrollbars (11px), so the path is live-verified.

- Wheel over the dock did nothing: the overlay is a SIBLING of the
  scroller, so native scroll chaining stops at the overlay (the old
  in-flow dock chained for free — a real regression, not a preference).
  A non-passive wheel listener on the dock block forwards the delta to
  the transcript ONLY when nothing under the cursor can take it: the
  question area, textarea overflow, and the model menus keep their
  native scrolling, including their own edge chaining; an unhandled
  wheel (over a fully-visible box, or at an internal scroller's edge)
  scrolls the transcript — so wheel-up over the quiz itself now folds
  the box under the cursor.

Verified live: wheel-up over the question box scrolled the transcript
(9460→9100) and folded to the bar; wheel-down handled cleanly; the
overlay inset matches the measured gutter exactly.
This commit is contained in:
amosblomqvist
2026-09-14 08:31:46 +03:00
parent 07415048df
commit 2e20bc6298
+42 -1
View File
@@ -774,6 +774,47 @@ function DockOverlay({
const revealedRef = useRef(revealed);
revealedRef.current = revealed;
// The overlay must not paint over the scroller's scrollbar (owner, scroll
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
// stable both-edges), so inset the overlay's right edge by the gutter —
// zero on macOS overlay scrollbars, the real width on classic platforms.
const [sbInset, setSbInset] = useState(0);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const measure = () => setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2));
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [scrollRef]);
// Wheel over the dock must still scroll the transcript (owner, scroll
// round 2): the overlay is a SIBLING of the scroller, so the browser's
// native scroll chaining stops at the overlay (the old in-flow dock
// chained for free). Internal scrollers — the question area, textarea
// overflow, the model menus — keep native scrolling, including their
// own edge chaining; only when nothing under the cursor can take the
// delta do we forward it to the transcript.
useEffect(() => {
const root = blockRef.current;
const el = scrollRef.current;
if (!root || !el) return;
const canScroll = (n: HTMLElement, dir: number) => {
if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable
return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5;
};
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native
}
e.preventDefault();
el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1);
};
root.addEventListener("wheel", onWheel, { passive: false });
return () => root.removeEventListener("wheel", onWheel);
}, [scrollRef]);
// ── The reserve: the spacer's height, reported upward ──
// Grows immediately (typing, new variants). NEVER drops while a question
// is open — a shrinking reserved tail is scroll geometry and the browser
@@ -879,7 +920,7 @@ function DockOverlay({
});
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0">
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div ref={blockRef} className="bg-background pb-4">
<div className={DOCK}>