From 6fe085cd91773880f270ddd407b11c501f57ca40 Mon Sep 17 00:00:00 2001 From: ketan0 Date: Thu, 15 Oct 2026 21:07:17 -0700 Subject: [PATCH] Overlay the side panel's scrollbar so tour and peek cards reach the column edge The side panel body hides its native scrollbar and draws an overlay thumb over its right edge that takes no layout width. It shows while the body scrolls, while the pointer is over its lane, and while dragged, then fades, as Code OSS's overlay scrollbars do. Tour and peek code cards now run to the column's right edge instead of stopping at a 10px scrollbar gutter. Co-Authored-By: Claude Opus 5.5 Agent-Session: 01a0d12e-51ca-7230-9991-0ce7a73018b2 --- packages/review/app/src/review-components.tsx | 33 ++- .../app/src/review-panel.browser.test.tsx | 38 +++ packages/review/app/src/side-panel-styles.ts | 12 +- .../review/app/src/ui/overlay-scrollbar.tsx | 217 ++++++++++++++++++ 4 files changed, 287 insertions(+), 13 deletions(-) create mode 100644 packages/review/app/src/ui/overlay-scrollbar.tsx diff --git a/packages/review/app/src/review-components.tsx b/packages/review/app/src/review-components.tsx index 1bda332a7a..e43ebb1b9a 100644 --- a/packages/review/app/src/review-components.tsx +++ b/packages/review/app/src/review-components.tsx @@ -1,6 +1,10 @@ import { documentType } from "@canvas/document-type.stylex"; import { fontSize, fontWeight } from "@canvas/scale.stylex"; import { IconButton } from "@canvas/ui/button"; +import { + OverlayScrollbar, + overlayScrollbarStyles, +} from "@canvas/ui/overlay-scrollbar"; import { textStyles } from "@canvas/ui/text"; import { extractTraceEventText } from "@dev.fast/trace-protocol"; import * as stylex from "@stylexjs/stylex"; @@ -10,6 +14,7 @@ import type { ReactElement, ReactNode, Ref, + RefObject, } from "react"; import { useCallback, @@ -124,7 +129,7 @@ function ReviewPanelFrame({ /** Buttons beside the close button. */ headerActions?: ReactNode; floatingFooter?: ReactNode; - bodyRef?: Ref; + bodyRef?: RefObject; onBodyScroll?: () => void; tour?: boolean; docked?: boolean; @@ -134,6 +139,22 @@ function ReviewPanelFrame({ }) { const appRef = useReviewRoots()?.appRef; const panelMotion = useReviewPanel((state) => state.motion); + const [body, setBody] = useState(null); + + const attachBody = useCallback( + (node: HTMLDivElement | null) => { + setBody(node); + + if (bodyRef) bodyRef.current = node; + + return () => { + setBody(null); + + if (bodyRef) bodyRef.current = null; + }; + }, + [bodyRef], + ); const sheet = useBottomSheetResize({ stateKey: "bottomSheetFraction", @@ -166,7 +187,6 @@ function ReviewPanelFrame({ "side-panel", panelStyles.panel, panelMotion === "restored" && panelStyles.restored, - tour && panelStyles.tour, docked && panelStyles.docked, tray && panelStyles.tray, )} @@ -207,12 +227,17 @@ function ReviewPanelFrame({
{children}
+ {floatingFooter} ); diff --git a/packages/review/app/src/review-panel.browser.test.tsx b/packages/review/app/src/review-panel.browser.test.tsx index 7be9163fb0..7fe160685e 100644 --- a/packages/review/app/src/review-panel.browser.test.tsx +++ b/packages/review/app/src/review-panel.browser.test.tsx @@ -268,6 +268,44 @@ describe("Guided tour panel", () => { }); }); +describe("Side panel scrollbar", () => { + it("overlays the scrollbar so tour content runs to the column's right edge", async () => { + const container = document.createElement("div"); + container.style.cssText = "display: flex; width: 360px; height: 160px"; + document.body.append(container); + root = createRoot(container); + + await act(async () => { + renderWithSession( + + + + + + + , + ); + await Promise.resolve(); + }); + + const panel = container.querySelector(".side-panel")!; + const body = container.querySelector(panelBodySelector)!; + + const stopContent = container.querySelector( + "section[data-review-anchor-id] > :last-child > :last-child", + )!; + + // Headless Chromium hides native scrollbars, so a gutter would not show + // in the widths here; check the scroller opts out of the native bar. + expect(body.scrollHeight).toBeGreaterThan(body.clientHeight); + expect(getComputedStyle(body).scrollbarWidth).toBe("none"); + expect(body.offsetWidth - body.clientWidth).toBe(0); + expect(stopContent.getBoundingClientRect().right).toBe( + panel.getBoundingClientRect().right, + ); + }); +}); + function OpenReplacingPanel() { const openPeek = useReviewPanel((state) => state.openPeek); useEffect(() => { diff --git a/packages/review/app/src/side-panel-styles.ts b/packages/review/app/src/side-panel-styles.ts index 8c80419b15..c73e6f6fad 100644 --- a/packages/review/app/src/side-panel-styles.ts +++ b/packages/review/app/src/side-panel-styles.ts @@ -36,8 +36,9 @@ export const panelStyles = stylex.create({ display: "flex", flexDirection: "column", minWidth: 0, + // Positioned at every width: the body's overlay scrollbar sits in it. position: { - default: null, + default: "relative", [narrowCanvas]: "absolute", [narrow]: "absolute", }, @@ -78,16 +79,9 @@ export const panelStyles = stylex.create({ animationDuration: motion.instant, animationTimingFunction: "ease", }, - tour: { - position: { - default: "relative", - [narrowCanvas]: "absolute", - [narrow]: "absolute", - }, - }, // Inside a diagram tour the panel keeps its column at every width. docked: { - position: "static", + position: "relative", inset: "auto", width: "100%", height: "100%", diff --git a/packages/review/app/src/ui/overlay-scrollbar.tsx b/packages/review/app/src/ui/overlay-scrollbar.tsx new file mode 100644 index 0000000000..928486e15c --- /dev/null +++ b/packages/review/app/src/ui/overlay-scrollbar.tsx @@ -0,0 +1,217 @@ +import { motion, radius } from "@canvas/scale.stylex"; +import { tokens } from "@canvas/tokens.stylex"; +import * as stylex from "@stylexjs/stylex"; +import { + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState, +} from "react"; + +// How long the thumb stays after the last scroll, as Code OSS's overlay +// scrollbars do. +const HIDE_AFTER_MS = 800; + +const MIN_THUMB = 32; + +const reducedMotion = "@media (prefers-reduced-motion: reduce)"; + +/** + * A vertical scrollbar laid over `scroller`'s right edge that takes no layout + * width: it shows while the scroller scrolls, while the pointer is over its + * lane, and while it is dragged, then fades. Render it as a sibling of the + * scroller inside a positioned parent, and give the scroller + * `overlayScrollbarStyles.scroller` to hide its native bar. + */ +export function OverlayScrollbar({ + scroller, +}: { + scroller: HTMLElement | null; +}) { + const laneRef = useRef(null); + const thumbRef = useRef(null); + const [scrolling, setScrolling] = useState(false); + const [hovered, setHovered] = useState(false); + const [dragging, setDragging] = useState(false); + const [scrollable, setScrollable] = useState(false); + const drag = useRef<{ pointerY: number; scrollTop: number } | null>(null); + + useEffect(() => { + if (!scroller) return; + + let hideTimer: ReturnType | undefined; + + const layout = () => { + const lane = laneRef.current; + const thumb = thumbRef.current; + + if (!lane || !thumb) return; + const { scrollHeight, clientHeight, scrollTop } = scroller; + const overflows = scrollHeight > clientHeight + 1; + setScrollable(overflows); + lane.style.top = `${scroller.offsetTop}px`; + lane.style.height = `${clientHeight}px`; + lane.style.left = `${scroller.offsetLeft + scroller.offsetWidth}px`; + + if (!overflows) return; + + const thumbHeight = Math.max( + MIN_THUMB, + (clientHeight * clientHeight) / scrollHeight, + ); + + const travel = clientHeight - thumbHeight; + const offset = (scrollTop / (scrollHeight - clientHeight)) * travel; + thumb.style.height = `${thumbHeight}px`; + thumb.style.transform = `translateY(${offset}px)`; + }; + + const onScroll = () => { + layout(); + setScrolling(true); + clearTimeout(hideTimer); + hideTimer = setTimeout(() => setScrolling(false), HIDE_AFTER_MS); + }; + + // Content grows and shrinks without scrolling (code cards load, stops + // expand), so watch the scroller and everything directly in it. + const resize = new ResizeObserver(layout); + + const observeChildren = () => { + resize.disconnect(); + resize.observe(scroller); + + for (const child of scroller.children) resize.observe(child); + }; + + const children = new MutationObserver(observeChildren); + observeChildren(); + children.observe(scroller, { childList: true }); + scroller.addEventListener("scroll", onScroll, { passive: true }); + layout(); + + return () => { + clearTimeout(hideTimer); + resize.disconnect(); + children.disconnect(); + scroller.removeEventListener("scroll", onScroll); + }; + }, [scroller]); + + const travelRatio = () => { + const thumb = thumbRef.current; + + if (!scroller || !thumb) return 0; + const travel = scroller.clientHeight - thumb.offsetHeight; + + return travel > 0 + ? (scroller.scrollHeight - scroller.clientHeight) / travel + : 0; + }; + + const startDrag = (event: ReactPointerEvent) => { + if (!scroller || event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { pointerY: event.clientY, scrollTop: scroller.scrollTop }; + setDragging(true); + }; + + const moveDrag = (event: ReactPointerEvent) => { + if (!scroller || !drag.current) return; + scroller.scrollTop = + drag.current.scrollTop + + (event.clientY - drag.current.pointerY) * travelRatio(); + }; + + const endDrag = () => { + drag.current = null; + setDragging(false); + }; + + // A click in the lane pages toward it, as a native track does. + const pageToward = (event: ReactPointerEvent) => { + const thumb = thumbRef.current; + + if (!scroller || !thumb || event.button !== 0) return; + event.preventDefault(); + const { top, bottom } = thumb.getBoundingClientRect(); + const direction = event.clientY < top ? -1 : event.clientY > bottom ? 1 : 0; + scroller.scrollBy({ top: direction * scroller.clientHeight * 0.9 }); + }; + + return ( +