From d99258cdd00a7de5077ad18ee99c90c22ffb52c8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 16 Aug 2026 11:58:36 +0200 Subject: [PATCH] fix(app): keep web scrollbars from shifting content Use an overlay scrollbar for desktop chat timelines so scrollbar gutters cannot move centered content. Share its behavior with existing workspace scrollbars and derive resting and hover colors from semantic foreground tokens. --- .../src/agent-stream/strategy-web.test.tsx | 46 +++++ .../app/src/agent-stream/strategy-web.tsx | 95 ++++++---- .../dom-overlay-scrollbar.tsx | 56 ++++++ .../overlay-scrollbar/overlay-scrollbar.tsx | 156 +++++++++++++++++ .../use-overlay-flat-list-scrollbar.web.tsx | 164 +----------------- .../install-web-scrollbar-styles.web.ts | 13 +- packages/app/src/styles/theme.ts | 12 -- packages/app/src/styles/web-scrollbar.test.ts | 9 + packages/app/src/styles/web-scrollbar.ts | 8 +- 9 files changed, 352 insertions(+), 207 deletions(-) create mode 100644 packages/app/src/components/ui/overlay-scrollbar/dom-overlay-scrollbar.tsx create mode 100644 packages/app/src/components/ui/overlay-scrollbar/overlay-scrollbar.tsx create mode 100644 packages/app/src/styles/web-scrollbar.test.ts 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 {