diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index b8e98b59..35e84fa6 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -628,6 +628,7 @@ const MobileDiffDetail: React.FC<{ ) : ( = ({ onClose - + {directoryError ? ( ) : query.trim() ? ( diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index a64d94f0..99392c46 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -1455,7 +1455,7 @@ export const MobileSessionsSheet: React.FC = ({ open, // clipped overflow swallowed the footer. const surfaceContent = (
- + {/* The search bar scrolls WITH the list (iOS-style): the open-time auto-scroll to the current session naturally tucks it away, and scrolling to the very top brings it back. */} diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index d1d828e5..e7541c2c 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -327,7 +327,8 @@ const ChatViewport = React.memo(({ >
diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx index af097a23..faf8e531 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx @@ -242,6 +242,7 @@ export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible = ({ ? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE) : 0; stickyFadeSizeRef.current = fadeSize; - scroller.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`); - scroller.style.setProperty( + const fadeRoot = scroller.closest('.oc-sticky-fade-root'); + fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`); + fadeRoot?.style.setProperty( '--scroll-shadow-top-clear-size', `${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`, ); @@ -876,24 +877,23 @@ export const ModelPickerList: React.FC = ({
- syncStickyFade(event.currentTarget) : undefined} - > -
+ syncStickyFade(event.currentTarget) : undefined} + > +
{includeNotSelected ? ( <>
)) )} -
-
- {stickyHeaders && leadingSectionKey ? ( - - ) : null} +
+ + {stickyHeaders && leadingSectionKey ? ( + + ) : null}
diff --git a/packages/ui/src/components/multirun/ModelMultiSelect.tsx b/packages/ui/src/components/multirun/ModelMultiSelect.tsx index dcc62dfb..faeb131c 100644 --- a/packages/ui/src/components/multirun/ModelMultiSelect.tsx +++ b/packages/ui/src/components/multirun/ModelMultiSelect.tsx @@ -152,7 +152,7 @@ export const ModelMultiSelect: React.FC = ({ // Find the nearest dialog or overflow ancestor to constrain within let container: HTMLElement | null = triggerRef.current.parentElement; while (container) { - if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) { + if (container.matches('[role="dialog"], [data-scroll-shadow-scroller]')) { break; } const style = getComputedStyle(container); diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 54716085..a5e3c636 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -447,7 +447,7 @@ export const MultiRunLauncher: React.FC = ({ ) : null} - +
diff --git a/packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx b/packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx index 10d0b2b3..d5430256 100644 --- a/packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx +++ b/packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx @@ -1782,6 +1782,7 @@ export function ScheduledTaskEditorDialog(props: { { const hasTopScroll = scroller.scrollTop > 1; @@ -163,8 +163,9 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { ? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE) : 0; topFadeSizeRef.current = topFadeSize; - scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`); - scroller.style.setProperty( + const fadeRoot = scroller.closest('.oc-sticky-fade-root'); + fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`); + fadeRoot?.style.setProperty( '--scroll-shadow-top-clear-size', `${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`, ); @@ -192,7 +193,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { } } // The IntersectionObserver reports the stuck header asynchronously, a frame or - // two after the (synchronous) mask has already hidden the real header — which + // two after the synchronous fade has already hidden the real header — which // otherwise leaves a one-frame gap where the title blinks out with no crisp // replacement. Seed the overlay with the topmost rendered project so it is // ready in the same frame; the observer then corrects it. When shared sessions @@ -202,11 +203,11 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null; if (model.projectSections.length === 0) { - return {model.topContent}{model.emptyState}; + return {model.topContent}{model.emptyState}; } if (model.sectionsForRender.length === 0) { - return {model.searchEmptyState}; + return {model.searchEmptyState}; } return ( @@ -217,21 +218,21 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { // rows appear below naturally.
- syncTopFade(event.currentTarget) : undefined} - > + syncTopFade(event.currentTarget) : undefined} + > {model.topContent} {view.showOnlyMainWorkspace ? (
@@ -372,7 +373,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { )} - + {enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
{ + let windowInstance: Window; + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + windowInstance = new Window(); + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + HTMLElement: windowInstance.HTMLElement, + Element: windowInstance.Element, + Node: windowInstance.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + windowInstance.close(); + }); + + test('keeps edge effects outside the forwarded scroll element', async () => { + let scrollElement: HTMLElement | null = null; + let scrollTop = 0; + + await act(async () => { + root.render( + { scrollElement = element; }} + viewportClassName="absolute inset-0" + observeMutations={false} + > +
content
+
, + ); + }); + + const scroller = host.querySelector('[data-scroll-shadow-scroller]'); + if (!scroller) throw new Error('ScrollShadow did not render its scroll element'); + expect(scrollElement).toBe(scroller); + Object.defineProperties(scroller, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => 500 }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = value; }, + }, + }); + + const viewport = scroller.parentElement; + expect(viewport?.hasAttribute('data-scroll-shadow-viewport')).toBe(true); + expect(viewport?.classList.contains('absolute')).toBe(true); + expect(viewport?.classList.contains('relative')).toBe(false); + expect(scroller.hasAttribute('data-scroll-shadow-scroller')).toBe(true); + expect(viewport?.style.getPropertyValue('--scroll-shadow-size')).toBe('48px'); + + await act(async () => scroller.dispatchEvent(new window.Event('scroll'))); + expect(viewport?.getAttribute('data-bottom-scroll')).toBe('true'); + + scrollTop = 200; + await act(async () => scroller.dispatchEvent(new window.Event('scroll'))); + expect(viewport?.getAttribute('data-top-bottom-scroll')).toBe('true'); + expect(viewport?.hasAttribute('data-bottom-scroll')).toBe(false); + + let attributeWrites = 0; + if (!viewport) throw new Error('ScrollShadow did not render its viewport'); + const setAttribute = viewport.setAttribute.bind(viewport); + viewport.setAttribute = (name, value) => { + attributeWrites += 1; + setAttribute(name, value); + }; + await act(async () => scroller.dispatchEvent(new window.Event('scroll'))); + expect(attributeWrites).toBe(0); + }); +}); diff --git a/packages/ui/src/components/ui/ScrollShadow.tsx b/packages/ui/src/components/ui/ScrollShadow.tsx index 65b29d68..c258ea12 100644 --- a/packages/ui/src/components/ui/ScrollShadow.tsx +++ b/packages/ui/src/components/ui/ScrollShadow.tsx @@ -1,7 +1,9 @@ import React from "react"; +import { cn } from "@/lib/utils"; export type ScrollShadowProps = React.HTMLAttributes & { as?: React.ElementType; + viewportClassName?: string; orientation?: "vertical" | "horizontal"; offset?: number; size?: number; @@ -12,22 +14,23 @@ export type ScrollShadowProps = React.HTMLAttributes & { onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void; }; -function mergeRefs(...refs: Array>): React.RefCallback { - return (value) => { - refs.forEach((ref) => { - if (typeof ref === "function") { - ref(value); - } else if (ref && typeof ref === "object") { - (ref as React.MutableRefObject).current = value; - } - }); - }; -} +type EdgeState = "both" | "none" | "top" | "bottom" | "left" | "right"; +type ScrollShadowViewportStyle = React.CSSProperties & { "--scroll-shadow-size": string }; + +const EDGE_ATTRIBUTES = [ + "data-top-scroll", + "data-bottom-scroll", + "data-top-bottom-scroll", + "data-left-scroll", + "data-right-scroll", + "data-left-right-scroll", +] as const; export const ScrollShadow = React.forwardRef( - ( + ( { as: Component = "div", + viewportClassName, orientation = "vertical", offset = 0, size = 48, @@ -40,52 +43,46 @@ export const ScrollShadow = React.forwardRef( className, children, ...rest - }, + }, ref, ) => { const internalRef = React.useRef(null); - const visibleRef = React.useRef<"both" | "none" | "top" | "bottom" | "left" | "right">("none"); + const viewportRef = React.useRef(null); + const visibleRef = React.useRef("none"); + const edgeStateRef = React.useRef(""); + React.useImperativeHandle(ref, () => { + const element = internalRef.current; + if (!element) throw new Error("ScrollShadow scroll element is unavailable"); + return element; + }, []); - const dataScrollShadow = (rest as Record)["data-scroll-shadow"]; - delete (rest as Record)["data-scroll-shadow"]; - - const mergedStyle = React.useMemo(() => { - const next: React.CSSProperties = { - ...(style as React.CSSProperties), - }; - (next as Record)["--scroll-shadow-size"] = `${size}px`; - return next; - }, [size, style]); - - const setAttributes = React.useCallback( - (el: HTMLElement, hasBefore: boolean, hasAfter: boolean, prefix: "top" | "left", suffix: "bottom" | "right") => { - const bothKey = `${prefix}${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}Scroll` as const; - - if (hasBefore && hasAfter) { - (el.dataset as Record)[bothKey] = "true"; - el.removeAttribute(`data-${prefix}-scroll`); - el.removeAttribute(`data-${suffix}-scroll`); - } else { - el.dataset[`${prefix}Scroll`] = String(hasBefore); - el.dataset[`${suffix}Scroll`] = String(hasAfter); - el.removeAttribute(`data-${prefix}-${suffix}-scroll`); - } - }, - [], + const viewportStyle = React.useMemo( + () => ({ "--scroll-shadow-size": `${size}px` }), + [size], ); const clearAttributes = React.useCallback((el: HTMLElement) => { - ["top", "bottom", "top-bottom", "left", "right", "left-right"].forEach((attr) => { - el.removeAttribute(`data-${attr}-scroll`); - }); + EDGE_ATTRIBUTES.forEach((attribute) => el.removeAttribute(attribute)); }, []); + const setEdgeAttribute = React.useCallback((el: HTMLElement, state: EdgeState) => { + clearAttributes(el); + if (state === "none") return; + const attribute = state === "both" + ? orientation === "vertical" ? "data-top-bottom-scroll" : "data-left-right-scroll" + : `data-${state}-scroll`; + el.setAttribute(attribute, "true"); + }, [clearAttributes, orientation]); + const checkOverflow = React.useCallback(() => { const el = internalRef.current; - if (!el) return; + const viewport = viewportRef.current; + if (!el || !viewport) return; if (!isEnabled) { - clearAttributes(el); + clearAttributes(viewport); + edgeStateRef.current = ""; + visibleRef.current = "none"; return; } @@ -97,25 +94,29 @@ export const ScrollShadow = React.forwardRef( orientation === "vertical" ? el.scrollTop > offset + SUBPIXEL_TOLERANCE : el.scrollLeft > offset + SUBPIXEL_TOLERANCE; - let hasAfter = + const hasAfter = orientation === "vertical" ? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE : el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE; - const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore; - - if (hideBottomShadow && orientation === "vertical") { - hasAfter = false; + const effectiveHasBefore = hasBefore && !(orientation === "vertical" && hideTopShadow); + const effectiveHasAfter = hasAfter && !(orientation === "vertical" && hideBottomShadow); + const beforeEdge = orientation === "vertical" ? "top" : "left"; + const afterEdge = orientation === "vertical" ? "bottom" : "right"; + let next: EdgeState = "none"; + if (effectiveHasBefore && effectiveHasAfter) next = "both"; + else if (effectiveHasBefore) next = beforeEdge; + else if (effectiveHasAfter) next = afterEdge; + const edgeState = `${orientation}:${next}`; + if (edgeState !== edgeStateRef.current) { + edgeStateRef.current = edgeState; + setEdgeAttribute(viewport, next); } - - setAttributes(el, effectiveHasBefore, hasAfter, orientation === "vertical" ? "top" : "left", orientation === "vertical" ? "bottom" : "right"); - - const next = effectiveHasBefore && hasAfter ? "both" : effectiveHasBefore ? (orientation === "vertical" ? "top" : "left") : hasAfter ? (orientation === "vertical" ? "bottom" : "right") : "none"; if (next !== visibleRef.current) { visibleRef.current = next; onVisibilityChange?.(next); } - }, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setAttributes]); + }, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setEdgeAttribute]); React.useEffect(() => { const el = internalRef.current; @@ -132,9 +133,9 @@ export const ScrollShadow = React.forwardRef( }; const handleScroll = () => checkOverflow(); // Scroll should be immediate - const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null; + const resizeObserver = "ResizeObserver" in globalThis ? new ResizeObserver(throttledCheck) : null; const mutationObserver = - observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null; + observeMutations && "MutationObserver" in globalThis ? new MutationObserver(throttledCheck) : null; checkOverflow(); @@ -157,16 +158,23 @@ export const ScrollShadow = React.forwardRef( }, [checkOverflow, observeMutations]); return ( - - {children} - + + {children} + +
); }, ); diff --git a/packages/ui/src/components/ui/ScrollableOverlay.tsx b/packages/ui/src/components/ui/ScrollableOverlay.tsx index befb1e68..b13564f8 100644 --- a/packages/ui/src/components/ui/ScrollableOverlay.tsx +++ b/packages/ui/src/components/ui/ScrollableOverlay.tsx @@ -47,6 +47,16 @@ export const ScrollableOverlay = React.forwardRef { const containerRef = React.useRef(null); + const containerSizeClassName = fillContainer + ? "flex-1 min-h-0 w-full" + : "flex-none w-full h-auto"; + const containerClassName = cn( + "overlay-scrollbar-target overlay-scrollbar-container", + preventOverscroll && "overscroll-none", + containerSizeClassName, + disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto", + className, + ); React.useImperativeHandle(ref, () => containerRef.current as HTMLElement, []); @@ -62,16 +72,11 @@ export const ScrollableOverlay = React.forwardRef} + viewportClassName={containerSizeClassName} size={scrollShadowSize} hideTopShadow={hideTopScrollShadow} hideBottomShadow={hideBottomScrollShadow} - className={cn( - "overlay-scrollbar-target overlay-scrollbar-container", - preventOverscroll && "overscroll-none", - fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto", - disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto", - className - )} + className={containerClassName} style={style as React.CSSProperties} observeMutations={observeMutations} {...rest} @@ -81,13 +86,7 @@ export const ScrollableOverlay = React.forwardRef} - className={cn( - "overlay-scrollbar-target overlay-scrollbar-container", - preventOverscroll && "overscroll-none", - fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto", - disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto", - className - )} + className={containerClassName} style={style} {...rest} > diff --git a/packages/ui/src/components/views/git/ChangesPanel.tsx b/packages/ui/src/components/views/git/ChangesPanel.tsx index 23a48105..e7262d03 100644 --- a/packages/ui/src/components/views/git/ChangesPanel.tsx +++ b/packages/ui/src/components/views/git/ChangesPanel.tsx @@ -488,6 +488,7 @@ export const ChangesPanel: React.FC = ({
{shouldVirtualize ? ( diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 4de298cd..2f32f4be 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -256,86 +256,81 @@ div[data-chat-input-footer="true"] { -webkit-appearance: none; } -/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */ -[data-scroll-shadow="true"] { - --scroll-shadow-size: var(--scroll-shadow-size, 48px); +/* Scroll edge fades live on the viewport, never on the scrolling content. */ +[data-scroll-shadow-viewport]::before, +[data-scroll-shadow-viewport]::after { + content: ""; + pointer-events: none; + position: absolute; + z-index: 10; + opacity: 0; } -[data-scroll-shadow="true"][data-orientation="vertical"] { - mask-mode: alpha; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-size: 100% 100%; - mask-size: 100% 100%; +[data-scroll-shadow-viewport][data-orientation="vertical"]::before, +[data-scroll-shadow-viewport][data-orientation="vertical"]::after { + inset-inline: 0; } -[data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] { - mask-image: linear-gradient( +[data-scroll-shadow-viewport][data-orientation="vertical"]::before { + top: 0; + height: var(--scroll-shadow-top-size, var(--scroll-shadow-size)); + background: linear-gradient( to bottom, - transparent 0%, - transparent var(--scroll-shadow-top-clear-size, 0px), - #000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)), - #000 calc(100% - var(--scroll-shadow-size)), - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to bottom, - transparent 0%, - transparent var(--scroll-shadow-top-clear-size, 0px), - #000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)), - #000 calc(100% - var(--scroll-shadow-size)), + var(--scroll-shadow-color, var(--surface-background)) 0, + var(--scroll-shadow-color, var(--surface-background)) var(--scroll-shadow-top-clear-size, 0px), transparent 100% ); } -[data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] { - mask-image: linear-gradient( - to bottom, - transparent 0%, - transparent var(--scroll-shadow-top-clear-size, 0px), - #000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)), - #000 100% - ); - -webkit-mask-image: linear-gradient( - to bottom, - transparent 0%, - transparent var(--scroll-shadow-top-clear-size, 0px), - #000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)), - #000 100% - ); +[data-scroll-shadow-viewport][data-orientation="vertical"]::after { + bottom: 0; + height: var(--scroll-shadow-size); + background: linear-gradient(to top, var(--scroll-shadow-color, var(--surface-background)), transparent); +} + +[data-scroll-shadow-viewport][data-orientation="horizontal"]::before, +[data-scroll-shadow-viewport][data-orientation="horizontal"]::after { + inset-block: 0; + width: var(--scroll-shadow-size); +} + +[data-scroll-shadow-viewport][data-orientation="horizontal"]::before { + left: 0; + background: linear-gradient(to right, var(--scroll-shadow-color, var(--surface-background)), transparent); +} + +[data-scroll-shadow-viewport][data-orientation="horizontal"]::after { + right: 0; + background: linear-gradient(to left, var(--scroll-shadow-color, var(--surface-background)), transparent); +} + +[data-scroll-shadow-viewport][data-top-scroll="true"]::before, +[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::before, +[data-scroll-shadow-viewport][data-bottom-scroll="true"]::after, +[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::after, +[data-scroll-shadow-viewport][data-left-scroll="true"]::before, +[data-scroll-shadow-viewport][data-left-right-scroll="true"]::before, +[data-scroll-shadow-viewport][data-right-scroll="true"]::after, +[data-scroll-shadow-viewport][data-left-right-scroll="true"]::after { + opacity: 1; } /* Sticky-fade overlay: the crisp duplicate identity stays - mounted and its visibility is driven synchronously by the scroller's own - top-scroll data attribute — the same signal that engages the mask — so the - overlay appears in the exact frame the mask hides the real header (no blink). + mounted and its visibility is driven synchronously by the viewport's + top-scroll data attribute, so the overlay appears in the exact frame the + edge fade covers the real header (no blink). ScrollShadow sets data-top-scroll when nothing is below the viewport and data-top-bottom-scroll when content is both above and below. */ .oc-sticky-fade-overlay { opacity: 0; } -.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-scroll="true"]) .oc-sticky-fade-overlay, -.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-bottom-scroll="true"]) .oc-sticky-fade-overlay { +.oc-sticky-fade-root:has( + [data-scroll-shadow-viewport]:is([data-top-scroll="true"], [data-top-bottom-scroll="true"]) +) .oc-sticky-fade-overlay { opacity: 1; } -[data-scroll-shadow="true"][data-orientation="vertical"][data-bottom-scroll="true"] { - mask-image: linear-gradient( - to bottom, - #000 0%, - #000 calc(100% - var(--scroll-shadow-size)), - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to bottom, - #000 0%, - #000 calc(100% - var(--scroll-shadow-size)), - transparent 100% - ); -} - - @keyframes spin-once { from { transform: rotate(0deg);