diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index a124233b7..445a84f4b 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -27,6 +27,7 @@ vi.hoisted(() => { }); vi.mock("react-native-unistyles", () => ({ + StyleSheet: { create: () => ({}) }, withUnistyles: (Component: React.ComponentType) => Component, })); @@ -152,6 +153,51 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("keeps the timeline width stable with an overlay scrollbar", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [userMessage(1)], + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef: React.createRef(), + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }), + ); + }); + + const scrollContainer = container.querySelector( + '[data-testid="agent-chat-scroll"]', + ); + expect(scrollContainer?.style.scrollbarWidth).toBe("none"); + expect(scrollContainer?.dataset.overlayScrollbar).toBe("true"); + }); + it("rerenders a stable live-head row when its revision changes", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); const viewportRef = React.createRef(); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index fb805bb01..aa5e67bd3 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -14,6 +14,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; +import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; import { estimateStreamItemHeight } from "./web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy } from "./strategy"; @@ -1134,14 +1135,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }; }, [isMobileBreakpoint]); const scrollContainerStyle = useMemo((): CSSProperties => { + const overlayScrollbarEnabled = scrollEnabled && !isMobileBreakpoint; return { - flex: 1, - minHeight: 0, + width: "100%", + height: "100%", overflowX: "hidden", overflowY: scrollEnabled ? "auto" : "hidden", overscrollBehaviorY: "contain", + scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined, }; - }, [scrollEnabled]); + }, [isMobileBreakpoint, scrollEnabled]); + const viewportStyle = useMemo( + (): CSSProperties => ({ + position: "relative", + flex: 1, + minWidth: 0, + minHeight: 0, + }), + [], + ); const virtualRowsContainerStyle = useMemo((): CSSProperties => { return { position: "relative", @@ -1202,39 +1214,52 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !liveAuxiliary; return ( -
-
- {historyStartSlot} - {shouldUseVirtualizer ? ( -
- {virtualRows.map((virtualRow) => { - const item = segments.historyVirtualized[virtualRow.index]; - if (!item) { - return null; - } - return ( -
- {renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)} -
- ); - })} -
- ) : null} - {mountedRows} - {liveAuxiliary} - {shouldRenderEmpty ? listEmptyComponent : null} +
+
+
+ {historyStartSlot} + {shouldUseVirtualizer ? ( +
+ {virtualRows.map((virtualRow) => { + const item = segments.historyVirtualized[virtualRow.index]; + if (!item) { + return null; + } + return ( +
+ {renderHistoryVirtualizedRow( + item, + virtualRow.index, + segments.historyVirtualized, + )} +
+ ); + })} +
+ ) : null} + {mountedRows} + {liveAuxiliary} + {shouldRenderEmpty ? listEmptyComponent : null} +
+ {scrollEnabled && !isMobileBreakpoint ? ( + + ) : null}
); } diff --git a/packages/app/src/components/ui/overlay-scrollbar/dom-overlay-scrollbar.tsx b/packages/app/src/components/ui/overlay-scrollbar/dom-overlay-scrollbar.tsx new file mode 100644 index 000000000..1cc673ee9 --- /dev/null +++ b/packages/app/src/components/ui/overlay-scrollbar/dom-overlay-scrollbar.tsx @@ -0,0 +1,56 @@ +import React, { useCallback, useLayoutEffect, useState, type RefObject } from "react"; +import { OverlayScrollbar, type OverlayScrollbarMetrics } from "./overlay-scrollbar"; + +export function DomOverlayScrollbar({ + scrollContainerRef, + onUserScrollUp, +}: { + scrollContainerRef: RefObject; + onUserScrollUp: () => void; +}) { + const [metrics, setMetrics] = useState({ + offset: 0, + viewportSize: 0, + contentSize: 0, + }); + const syncMetrics = useCallback(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + setMetrics({ + offset: Math.max(0, scrollContainer.scrollTop), + viewportSize: Math.max(0, scrollContainer.clientHeight), + contentSize: Math.max(0, scrollContainer.scrollHeight), + }); + }, [scrollContainerRef]); + + useLayoutEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + syncMetrics(); + scrollContainer.addEventListener("scroll", syncMetrics, { passive: true }); + const observer = new ResizeObserver(syncMetrics); + observer.observe(scrollContainer); + const content = scrollContainer.firstElementChild; + if (content) observer.observe(content); + return () => { + scrollContainer.removeEventListener("scroll", syncMetrics); + observer.disconnect(); + }; + }, [scrollContainerRef, syncMetrics]); + + const scrollToOffset = useCallback( + (offset: number) => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) scrollContainer.scrollTop = offset; + }, + [scrollContainerRef], + ); + + return ( + + ); +} diff --git a/packages/app/src/components/ui/overlay-scrollbar/overlay-scrollbar.tsx b/packages/app/src/components/ui/overlay-scrollbar/overlay-scrollbar.tsx new file mode 100644 index 000000000..0069a3ab4 --- /dev/null +++ b/packages/app/src/components/ui/overlay-scrollbar/overlay-scrollbar.tsx @@ -0,0 +1,156 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, View, type PressableStateCallbackType, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { WEB_SCROLLBAR_RESTING_OPACITY, WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry"; + +const THUMB_WIDTH = WEB_SCROLLBAR_SIZE_PX - 4; +const GRAB_WIDTH = 12; + +export interface OverlayScrollbarMetrics { + offset: number; + viewportSize: number; + contentSize: number; +} + +interface PointerLikeEvent { + clientY?: number; + nativeEvent?: { clientY?: number; preventDefault?: () => void }; + preventDefault?: () => void; + stopPropagation?: () => void; +} + +function readClientY(event: PointerLikeEvent): number | null { + const value = event.nativeEvent?.clientY ?? event.clientY; + return typeof value === "number" ? value : null; +} + +export function OverlayScrollbar({ + metrics, + onScrollToOffset, + onUserScrollUp, +}: { + metrics: OverlayScrollbarMetrics; + onScrollToOffset: (offset: number) => void; + onUserScrollUp?: () => void; +}) { + const geometry = useMemo(() => computeOverlayScrollbarGeometry(metrics), [metrics]); + const geometryRef = useRef(geometry); + const dragStartClientYRef = useRef(0); + const dragStartOffsetRef = useRef(0); + const lastRequestedOffsetRef = useRef(0); + const reportedUpwardIntentRef = useRef(false); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + geometryRef.current = geometry; + }, [geometry]); + + const startDrag = useCallback( + (event: PointerLikeEvent) => { + const clientY = readClientY(event); + if (clientY === null) return; + event.preventDefault?.(); + event.stopPropagation?.(); + event.nativeEvent?.preventDefault?.(); + dragStartClientYRef.current = clientY; + dragStartOffsetRef.current = metrics.offset; + lastRequestedOffsetRef.current = metrics.offset; + reportedUpwardIntentRef.current = false; + setIsDragging(true); + }, + [metrics.offset], + ); + + useEffect(() => { + if (!isDragging) return; + const handlePointerMove = (event: PointerEvent) => { + const current = geometryRef.current; + const nextOffset = scrollOffsetFromThumbDrag({ + startOffset: dragStartOffsetRef.current, + dragDelta: event.clientY - dragStartClientYRef.current, + maxScrollOffset: current.maxScrollOffset, + maxThumbOffset: current.maxThumbOffset, + }); + if (nextOffset < lastRequestedOffsetRef.current && !reportedUpwardIntentRef.current) { + reportedUpwardIntentRef.current = true; + onUserScrollUp?.(); + } + lastRequestedOffsetRef.current = nextOffset; + onScrollToOffset(nextOffset); + }; + const stopDragging = () => setIsDragging(false); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopDragging); + window.addEventListener("pointercancel", stopDragging); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopDragging); + window.removeEventListener("pointercancel", stopDragging); + }; + }, [isDragging, onScrollToOffset, onUserScrollUp]); + + const thumbRegionStyle = useMemo( + () => [ + styles.thumbRegion, + inlineUnistylesStyle({ + height: geometry.thumbSize, + transform: [{ translateY: geometry.thumbOffset }], + cursor: isDragging ? "grabbing" : "grab", + } as unknown as ViewStyle), + ], + [geometry.thumbOffset, geometry.thumbSize, isDragging], + ); + if (!geometry.isVisible) return null; + + return ( + + + {({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => ( + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + width: WEB_SCROLLBAR_SIZE_PX, + zIndex: 10, + }, + thumbRegion: { + position: "absolute", + top: 0, + right: -(GRAB_WIDTH - WEB_SCROLLBAR_SIZE_PX) / 2, + width: GRAB_WIDTH, + alignItems: "center", + justifyContent: "center", + touchAction: "none", + userSelect: "none", + }, + thumb: { + width: THUMB_WIDTH, + height: "100%", + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.foregroundExtraMuted, + opacity: WEB_SCROLLBAR_RESTING_OPACITY, + }, + thumbHovered: { + backgroundColor: theme.colors.foregroundMuted, + opacity: 1, + }, +})); diff --git a/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.web.tsx b/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.web.tsx index e74d3d6c6..8f0dd257e 100644 --- a/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.web.tsx +++ b/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.web.tsx @@ -1,139 +1,18 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useCallback, useState, type RefObject } from "react"; import { FlatList, - View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, - type ViewStyle, } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; -import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; -import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry"; +import { OverlayScrollbar, type OverlayScrollbarMetrics } from "./overlay-scrollbar"; import type { OverlayFlatListScrollbar } from "./use-overlay-flat-list-scrollbar"; -const THUMB_WIDTH = WEB_SCROLLBAR_SIZE_PX - 4; -const THUMB_OPACITY = 0.62; -const GRAB_WIDTH = 12; - -interface ScrollbarMetrics { - offset: number; - viewportSize: number; - contentSize: number; -} - -interface PointerLikeEvent { - clientY?: number; - nativeEvent?: { clientY?: number; preventDefault?: () => void }; - preventDefault?: () => void; - stopPropagation?: () => void; -} - -function readClientY(event: PointerLikeEvent): number | null { - const value = event.nativeEvent?.clientY ?? event.clientY; - return typeof value === "number" ? value : null; -} - -function OverlayScrollbar({ - listRef, - metrics, -}: { - listRef: RefObject | null>; - metrics: ScrollbarMetrics; -}) { - const geometry = useMemo(() => computeOverlayScrollbarGeometry(metrics), [metrics]); - const geometryRef = useRef(geometry); - const dragStartClientYRef = useRef(0); - const dragStartOffsetRef = useRef(0); - const [isDragging, setIsDragging] = useState(false); - - useEffect(() => { - geometryRef.current = geometry; - }, [geometry]); - - const scrollFromDrag = useCallback( - (dragDelta: number) => { - const current = geometryRef.current; - listRef.current?.scrollToOffset({ - offset: scrollOffsetFromThumbDrag({ - startOffset: dragStartOffsetRef.current, - dragDelta, - maxScrollOffset: current.maxScrollOffset, - maxThumbOffset: current.maxThumbOffset, - }), - animated: false, - }); - }, - [listRef], - ); - - const startDrag = useCallback( - (event: PointerLikeEvent) => { - const clientY = readClientY(event); - if (clientY === null) return; - event.preventDefault?.(); - event.stopPropagation?.(); - event.nativeEvent?.preventDefault?.(); - dragStartClientYRef.current = clientY; - dragStartOffsetRef.current = metrics.offset; - setIsDragging(true); - }, - [metrics.offset], - ); - - useEffect(() => { - if (!isDragging) return; - const handlePointerMove = (event: PointerEvent) => { - scrollFromDrag(event.clientY - dragStartClientYRef.current); - }; - const stopDragging = () => setIsDragging(false); - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", stopDragging); - window.addEventListener("pointercancel", stopDragging); - return () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", stopDragging); - window.removeEventListener("pointercancel", stopDragging); - }; - }, [isDragging, scrollFromDrag]); - - const thumbRegionStyle = useMemo( - () => [ - styles.thumbRegion, - inlineUnistylesStyle({ - height: geometry.thumbSize, - transform: [{ translateY: geometry.thumbOffset }], - cursor: isDragging ? "grabbing" : "grab", - } as unknown as ViewStyle), - ], - [geometry.thumbOffset, geometry.thumbSize, isDragging], - ); - - if (!geometry.isVisible) return null; - - return ( - - - - - - ); -} - export function useOverlayFlatListScrollbar( listRef: RefObject | null>, options: { enabled: boolean }, ): OverlayFlatListScrollbar { - const [metrics, setMetrics] = useState({ + const [metrics, setMetrics] = useState({ offset: 0, viewportSize: 0, contentSize: 0, @@ -156,6 +35,10 @@ export function useOverlayFlatListScrollbar( const onContentSizeChange = useCallback((_width: number, height: number) => { setMetrics((previous) => ({ ...previous, contentSize: Math.max(0, height) })); }, []); + const scrollToOffset = useCallback( + (offset: number) => listRef.current?.scrollToOffset({ offset, animated: false }), + [listRef], + ); return { enabled: options.enabled, @@ -163,38 +46,7 @@ export function useOverlayFlatListScrollbar( onLayout, onScroll, overlay: options.enabled ? ( - | null>} - metrics={metrics} - /> + ) : null, }; } - -const styles = StyleSheet.create((theme) => ({ - overlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - width: WEB_SCROLLBAR_SIZE_PX, - zIndex: 10, - }, - thumbRegion: { - position: "absolute", - top: 0, - right: -(GRAB_WIDTH - WEB_SCROLLBAR_SIZE_PX) / 2, - width: GRAB_WIDTH, - alignItems: "center", - justifyContent: "center", - touchAction: "none", - userSelect: "none", - }, - thumb: { - width: THUMB_WIDTH, - height: "100%", - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.scrollbarHandle, - opacity: THUMB_OPACITY, - }, -})); diff --git a/packages/app/src/styles/install-web-scrollbar-styles.web.ts b/packages/app/src/styles/install-web-scrollbar-styles.web.ts index db11698cd..4fc0a4577 100644 --- a/packages/app/src/styles/install-web-scrollbar-styles.web.ts +++ b/packages/app/src/styles/install-web-scrollbar-styles.web.ts @@ -15,7 +15,7 @@ export function installWebScrollbarStyles(): () => void { style.id = STYLE_ID; style.textContent = ` * { - scrollbar-color: ${webScrollbarColor("var(--colors-scrollbar-handle)")}; + scrollbar-color: ${webScrollbarColor("var(--colors-foreground-extra-muted)")}; scrollbar-width: ${WEB_SCROLLBAR_WIDTH}; } @@ -37,9 +37,18 @@ export function installWebScrollbarStyles(): () => void { *::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; - background: ${webScrollbarThumbColor("var(--colors-scrollbar-handle)")}; + background: ${webScrollbarThumbColor("var(--colors-foreground-extra-muted)")}; background-clip: content-box; } + +*::-webkit-scrollbar-thumb:hover { + background: ${webScrollbarThumbColor("var(--colors-foreground-muted)", 1)}; + background-clip: content-box; +} + +[data-overlay-scrollbar="true"]::-webkit-scrollbar { + display: none; +} `; document.head.append(style); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 408ed87ae..851a34086 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -221,9 +221,6 @@ const lightSemanticColors = { foregroundMuted: "#71717a", foregroundExtraMuted: "#a1a1aa", - // Controls - scrollbarHandle: "#3f3f46", // zinc-700 - // Borders - shifted one step lighter border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast) borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines @@ -300,7 +297,6 @@ interface DarkThemeConfig { surfaceSidebarHover: string; foregroundMuted: string; foregroundExtraMuted: string; - scrollbarHandle: string; border: string; borderAccent: string; accent: string; @@ -344,8 +340,6 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) { foregroundMuted: tint.foregroundMuted, foregroundExtraMuted: tint.foregroundExtraMuted, - scrollbarHandle: tint.scrollbarHandle, - border: tint.border, borderAccent: tint.borderAccent, @@ -406,7 +400,6 @@ const paseoDarkColors = buildDarkSemanticColors({ surfaceSidebarHover: "#1c1f1e", foregroundMuted: "#A1A5A4", foregroundExtraMuted: "#717574", - scrollbarHandle: "#717574", border: "#252B2A", borderAccent: "#2F3534", accent: "#20744A", @@ -428,7 +421,6 @@ const zincDarkColors = buildDarkSemanticColors({ surfaceSidebarHover: "#1b1b1e", foregroundMuted: "#a1a1aa", foregroundExtraMuted: "#71717a", - scrollbarHandle: "#71717a", border: "#27272a", borderAccent: "#303036", accent: "#e4e4e7", @@ -451,7 +443,6 @@ const midnightDarkColors = buildDarkSemanticColors({ surfaceSidebarHover: "#1a1c28", foregroundMuted: "#9a9db0", foregroundExtraMuted: "#6b6e82", - scrollbarHandle: "#6b6e82", border: "#242636", borderAccent: "#2e3040", accent: "#3b6fcf", @@ -473,7 +464,6 @@ const claudeDarkColors = buildDarkSemanticColors({ surfaceSidebarHover: "#222120", foregroundMuted: "#ada9a5", foregroundExtraMuted: "#78746f", - scrollbarHandle: "#78746f", border: "#2c2a27", borderAccent: "#36332f", accent: "#d97757", @@ -495,7 +485,6 @@ const ghosttyDarkColors = buildDarkSemanticColors({ surfaceSidebarHover: "#292d36", foregroundMuted: "#c8ccd8", foregroundExtraMuted: "#a0a4b2", - scrollbarHandle: "#a0a4b2", border: "#353a47", borderAccent: "#3f4454", accent: "#89b4fa", @@ -671,7 +660,6 @@ const pureBlackDarkColors = buildDarkSemanticColors({ surfaceSidebarHover: "#161616", foregroundMuted: "#a1a1aa", foregroundExtraMuted: "#71717a", - scrollbarHandle: "#71717a", border: "#1c1c1c", borderAccent: "#242424", accent: "#20744A", diff --git a/packages/app/src/styles/web-scrollbar.test.ts b/packages/app/src/styles/web-scrollbar.test.ts new file mode 100644 index 000000000..4f452a29d --- /dev/null +++ b/packages/app/src/styles/web-scrollbar.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { webScrollbarThumbColor } from "./web-scrollbar"; + +describe("web scrollbar color", () => { + it("uses the resting opacity by default and full opacity when requested", () => { + expect(webScrollbarThumbColor("gray")).toBe("color-mix(in srgb, gray 40%, transparent)"); + expect(webScrollbarThumbColor("gray", 1)).toBe("color-mix(in srgb, gray 100%, transparent)"); + }); +}); diff --git a/packages/app/src/styles/web-scrollbar.ts b/packages/app/src/styles/web-scrollbar.ts index 1fd4fbd80..63f36e163 100644 --- a/packages/app/src/styles/web-scrollbar.ts +++ b/packages/app/src/styles/web-scrollbar.ts @@ -1,8 +1,12 @@ export const WEB_SCROLLBAR_WIDTH = "thin"; export const WEB_SCROLLBAR_SIZE_PX = 8; +export const WEB_SCROLLBAR_RESTING_OPACITY = 0.4; -export function webScrollbarThumbColor(handleColor: string): string { - return `color-mix(in srgb, ${handleColor} 62%, transparent)`; +export function webScrollbarThumbColor( + handleColor: string, + opacity = WEB_SCROLLBAR_RESTING_OPACITY, +): string { + return `color-mix(in srgb, ${handleColor} ${opacity * 100}%, transparent)`; } export function webScrollbarColor(handleColor: string): string {