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.
This commit is contained in:
Mohamed Boudra
2026-08-16 12:45:06 +02:00
parent d4435bd016
commit d99258cdd0
9 changed files with 352 additions and 207 deletions
@@ -27,6 +27,7 @@ vi.hoisted(() => {
}); });
vi.mock("react-native-unistyles", () => ({ vi.mock("react-native-unistyles", () => ({
StyleSheet: { create: () => ({}) },
withUnistyles: (Component: React.ComponentType) => Component, withUnistyles: (Component: React.ComponentType) => Component,
})); }));
@@ -152,6 +153,51 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); 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<StreamViewportHandle>(),
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<HTMLElement>(
'[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", () => { it("rerenders a stable live-head row when its revision changes", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>(); const viewportRef = React.createRef<StreamViewportHandle>();
+60 -35
View File
@@ -14,6 +14,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; 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 { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy"; import { createStreamStrategy } from "./strategy";
@@ -1134,14 +1135,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}; };
}, [isMobileBreakpoint]); }, [isMobileBreakpoint]);
const scrollContainerStyle = useMemo((): CSSProperties => { const scrollContainerStyle = useMemo((): CSSProperties => {
const overlayScrollbarEnabled = scrollEnabled && !isMobileBreakpoint;
return { return {
flex: 1, width: "100%",
minHeight: 0, height: "100%",
overflowX: "hidden", overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden", overflowY: scrollEnabled ? "auto" : "hidden",
overscrollBehaviorY: "contain", 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 => { const virtualRowsContainerStyle = useMemo((): CSSProperties => {
return { return {
position: "relative", position: "relative",
@@ -1202,39 +1214,52 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!liveAuxiliary; !liveAuxiliary;
return ( return (
<div <div style={viewportStyle}>
ref={handleScrollContainerRef} <div
data-testid="agent-chat-scroll" ref={handleScrollContainerRef}
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`} data-testid="agent-chat-scroll"
style={scrollContainerStyle} data-overlay-scrollbar={scrollEnabled && !isMobileBreakpoint ? "true" : undefined}
> id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
<div ref={handleContentRef} style={contentContainerStyle}> style={scrollContainerStyle}
{historyStartSlot} >
{shouldUseVirtualizer ? ( <div ref={handleContentRef} style={contentContainerStyle}>
<div style={virtualRowsContainerStyle}> {historyStartSlot}
{virtualRows.map((virtualRow) => { {shouldUseVirtualizer ? (
const item = segments.historyVirtualized[virtualRow.index]; <div style={virtualRowsContainerStyle}>
if (!item) { {virtualRows.map((virtualRow) => {
return null; const item = segments.historyVirtualized[virtualRow.index];
} if (!item) {
return ( return null;
<div }
key={virtualRow.key} return (
data-index={virtualRow.index} <div
data-history-row-id={item.id} key={virtualRow.key}
ref={measureVirtualizedRowElement} data-index={virtualRow.index}
style={renderVirtualRowStyle(virtualRow.start)} data-history-row-id={item.id}
> ref={measureVirtualizedRowElement}
{renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)} style={renderVirtualRowStyle(virtualRow.start)}
</div> >
); {renderHistoryVirtualizedRow(
})} item,
</div> virtualRow.index,
) : null} segments.historyVirtualized,
{mountedRows} )}
{liveAuxiliary} </div>
{shouldRenderEmpty ? listEmptyComponent : null} );
})}
</div>
) : null}
{mountedRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div> </div>
{scrollEnabled && !isMobileBreakpoint ? (
<DomOverlayScrollbar
scrollContainerRef={scrollContainerRef}
onUserScrollUp={stopFollowingOutputFromUserIntent}
/>
) : null}
</div> </div>
); );
} }
@@ -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<HTMLElement | null>;
onUserScrollUp: () => void;
}) {
const [metrics, setMetrics] = useState<OverlayScrollbarMetrics>({
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 (
<OverlayScrollbar
metrics={metrics}
onScrollToOffset={scrollToOffset}
onUserScrollUp={onUserScrollUp}
/>
);
}
@@ -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 (
<View style={styles.overlay} pointerEvents="box-none" testID="workspace-overlay-scrollbar">
<Pressable
style={thumbRegionStyle}
{...({ onPointerDown: startDrag } as object)}
testID="workspace-overlay-scrollbar-grab"
>
{({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => (
<View
style={[styles.thumb, hovered || isDragging ? styles.thumbHovered : null]}
pointerEvents="none"
testID="workspace-overlay-scrollbar-thumb"
/>
)}
</Pressable>
</View>
);
}
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,
},
}));
@@ -1,139 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useCallback, useState, type RefObject } from "react";
import { import {
FlatList, FlatList,
View,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeScrollEvent, type NativeScrollEvent,
type NativeSyntheticEvent, type NativeSyntheticEvent,
type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { OverlayScrollbar, type OverlayScrollbarMetrics } from "./overlay-scrollbar";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry";
import type { OverlayFlatListScrollbar } from "./use-overlay-flat-list-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<FlatList<unknown> | 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 (
<View style={styles.overlay} pointerEvents="box-none" testID="workspace-overlay-scrollbar">
<View
style={thumbRegionStyle}
{...({ onPointerDown: startDrag } as object)}
testID="workspace-overlay-scrollbar-grab"
>
<View
style={styles.thumb}
pointerEvents="none"
testID="workspace-overlay-scrollbar-thumb"
/>
</View>
</View>
);
}
export function useOverlayFlatListScrollbar<ItemT>( export function useOverlayFlatListScrollbar<ItemT>(
listRef: RefObject<FlatList<ItemT> | null>, listRef: RefObject<FlatList<ItemT> | null>,
options: { enabled: boolean }, options: { enabled: boolean },
): OverlayFlatListScrollbar { ): OverlayFlatListScrollbar {
const [metrics, setMetrics] = useState<ScrollbarMetrics>({ const [metrics, setMetrics] = useState<OverlayScrollbarMetrics>({
offset: 0, offset: 0,
viewportSize: 0, viewportSize: 0,
contentSize: 0, contentSize: 0,
@@ -156,6 +35,10 @@ export function useOverlayFlatListScrollbar<ItemT>(
const onContentSizeChange = useCallback((_width: number, height: number) => { const onContentSizeChange = useCallback((_width: number, height: number) => {
setMetrics((previous) => ({ ...previous, contentSize: Math.max(0, height) })); setMetrics((previous) => ({ ...previous, contentSize: Math.max(0, height) }));
}, []); }, []);
const scrollToOffset = useCallback(
(offset: number) => listRef.current?.scrollToOffset({ offset, animated: false }),
[listRef],
);
return { return {
enabled: options.enabled, enabled: options.enabled,
@@ -163,38 +46,7 @@ export function useOverlayFlatListScrollbar<ItemT>(
onLayout, onLayout,
onScroll, onScroll,
overlay: options.enabled ? ( overlay: options.enabled ? (
<OverlayScrollbar <OverlayScrollbar metrics={metrics} onScrollToOffset={scrollToOffset} />
listRef={listRef as RefObject<FlatList<unknown> | null>}
metrics={metrics}
/>
) : null, ) : 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,
},
}));
@@ -15,7 +15,7 @@ export function installWebScrollbarStyles(): () => void {
style.id = STYLE_ID; style.id = STYLE_ID;
style.textContent = ` style.textContent = `
* { * {
scrollbar-color: ${webScrollbarColor("var(--colors-scrollbar-handle)")}; scrollbar-color: ${webScrollbarColor("var(--colors-foreground-extra-muted)")};
scrollbar-width: ${WEB_SCROLLBAR_WIDTH}; scrollbar-width: ${WEB_SCROLLBAR_WIDTH};
} }
@@ -37,9 +37,18 @@ export function installWebScrollbarStyles(): () => void {
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
border: 2px solid transparent; border: 2px solid transparent;
border-radius: 999px; border-radius: 999px;
background: ${webScrollbarThumbColor("var(--colors-scrollbar-handle)")}; background: ${webScrollbarThumbColor("var(--colors-foreground-extra-muted)")};
background-clip: content-box; 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); document.head.append(style);
-12
View File
@@ -221,9 +221,6 @@ const lightSemanticColors = {
foregroundMuted: "#71717a", foregroundMuted: "#71717a",
foregroundExtraMuted: "#a1a1aa", foregroundExtraMuted: "#a1a1aa",
// Controls
scrollbarHandle: "#3f3f46", // zinc-700
// Borders - shifted one step lighter // Borders - shifted one step lighter
border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast) border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines
@@ -300,7 +297,6 @@ interface DarkThemeConfig {
surfaceSidebarHover: string; surfaceSidebarHover: string;
foregroundMuted: string; foregroundMuted: string;
foregroundExtraMuted: string; foregroundExtraMuted: string;
scrollbarHandle: string;
border: string; border: string;
borderAccent: string; borderAccent: string;
accent: string; accent: string;
@@ -344,8 +340,6 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
foregroundMuted: tint.foregroundMuted, foregroundMuted: tint.foregroundMuted,
foregroundExtraMuted: tint.foregroundExtraMuted, foregroundExtraMuted: tint.foregroundExtraMuted,
scrollbarHandle: tint.scrollbarHandle,
border: tint.border, border: tint.border,
borderAccent: tint.borderAccent, borderAccent: tint.borderAccent,
@@ -406,7 +400,6 @@ const paseoDarkColors = buildDarkSemanticColors({
surfaceSidebarHover: "#1c1f1e", surfaceSidebarHover: "#1c1f1e",
foregroundMuted: "#A1A5A4", foregroundMuted: "#A1A5A4",
foregroundExtraMuted: "#717574", foregroundExtraMuted: "#717574",
scrollbarHandle: "#717574",
border: "#252B2A", border: "#252B2A",
borderAccent: "#2F3534", borderAccent: "#2F3534",
accent: "#20744A", accent: "#20744A",
@@ -428,7 +421,6 @@ const zincDarkColors = buildDarkSemanticColors({
surfaceSidebarHover: "#1b1b1e", surfaceSidebarHover: "#1b1b1e",
foregroundMuted: "#a1a1aa", foregroundMuted: "#a1a1aa",
foregroundExtraMuted: "#71717a", foregroundExtraMuted: "#71717a",
scrollbarHandle: "#71717a",
border: "#27272a", border: "#27272a",
borderAccent: "#303036", borderAccent: "#303036",
accent: "#e4e4e7", accent: "#e4e4e7",
@@ -451,7 +443,6 @@ const midnightDarkColors = buildDarkSemanticColors({
surfaceSidebarHover: "#1a1c28", surfaceSidebarHover: "#1a1c28",
foregroundMuted: "#9a9db0", foregroundMuted: "#9a9db0",
foregroundExtraMuted: "#6b6e82", foregroundExtraMuted: "#6b6e82",
scrollbarHandle: "#6b6e82",
border: "#242636", border: "#242636",
borderAccent: "#2e3040", borderAccent: "#2e3040",
accent: "#3b6fcf", accent: "#3b6fcf",
@@ -473,7 +464,6 @@ const claudeDarkColors = buildDarkSemanticColors({
surfaceSidebarHover: "#222120", surfaceSidebarHover: "#222120",
foregroundMuted: "#ada9a5", foregroundMuted: "#ada9a5",
foregroundExtraMuted: "#78746f", foregroundExtraMuted: "#78746f",
scrollbarHandle: "#78746f",
border: "#2c2a27", border: "#2c2a27",
borderAccent: "#36332f", borderAccent: "#36332f",
accent: "#d97757", accent: "#d97757",
@@ -495,7 +485,6 @@ const ghosttyDarkColors = buildDarkSemanticColors({
surfaceSidebarHover: "#292d36", surfaceSidebarHover: "#292d36",
foregroundMuted: "#c8ccd8", foregroundMuted: "#c8ccd8",
foregroundExtraMuted: "#a0a4b2", foregroundExtraMuted: "#a0a4b2",
scrollbarHandle: "#a0a4b2",
border: "#353a47", border: "#353a47",
borderAccent: "#3f4454", borderAccent: "#3f4454",
accent: "#89b4fa", accent: "#89b4fa",
@@ -671,7 +660,6 @@ const pureBlackDarkColors = buildDarkSemanticColors({
surfaceSidebarHover: "#161616", surfaceSidebarHover: "#161616",
foregroundMuted: "#a1a1aa", foregroundMuted: "#a1a1aa",
foregroundExtraMuted: "#71717a", foregroundExtraMuted: "#71717a",
scrollbarHandle: "#71717a",
border: "#1c1c1c", border: "#1c1c1c",
borderAccent: "#242424", borderAccent: "#242424",
accent: "#20744A", accent: "#20744A",
@@ -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)");
});
});
+6 -2
View File
@@ -1,8 +1,12 @@
export const WEB_SCROLLBAR_WIDTH = "thin"; export const WEB_SCROLLBAR_WIDTH = "thin";
export const WEB_SCROLLBAR_SIZE_PX = 8; export const WEB_SCROLLBAR_SIZE_PX = 8;
export const WEB_SCROLLBAR_RESTING_OPACITY = 0.4;
export function webScrollbarThumbColor(handleColor: string): string { export function webScrollbarThumbColor(
return `color-mix(in srgb, ${handleColor} 62%, transparent)`; handleColor: string,
opacity = WEB_SCROLLBAR_RESTING_OPACITY,
): string {
return `color-mix(in srgb, ${handleColor} ${opacity * 100}%, transparent)`;
} }
export function webScrollbarColor(handleColor: string): string { export function webScrollbarColor(handleColor: string): string {