From 98eca610a308ddd3b658cb6a3ab0a215473184f3 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Wed, 25 Mar 2026 08:47:26 -0400 Subject: [PATCH 01/18] Add SmartScrollbar component to ui-next --- .../SmartScrollbar/SmartScrollbar.tsx | 231 ++++++++++++++++++ .../SmartScrollbarEndpoints.tsx | 63 +++++ .../SmartScrollbar/SmartScrollbarFill.tsx | 50 ++++ .../SmartScrollbarIndicator.tsx | 70 ++++++ .../SmartScrollbar/SmartScrollbarTrack.tsx | 59 +++++ .../src/components/SmartScrollbar/index.ts | 6 + .../src/components/SmartScrollbar/utils.ts | 64 +++++ platform/ui-next/src/components/index.ts | 17 +- 8 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx create mode 100644 platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx create mode 100644 platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx create mode 100644 platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx create mode 100644 platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx create mode 100644 platform/ui-next/src/components/SmartScrollbar/index.ts create mode 100644 platform/ui-next/src/components/SmartScrollbar/utils.ts diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx new file mode 100644 index 00000000000..41bbe14d2f7 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -0,0 +1,231 @@ +import { + createContext, + useContext, + useState, + useEffect, + useRef, + useCallback, + useId, + type RefObject, +} from 'react'; +import { getIndicatorLayout } from './utils'; + +// ── Baked Design 27 constants ────────────────────────────────── +const TRACK_WIDTH = 8; +const RESTING_WIDTH = 4; +const FILL_PADDING = 3; +const INDICATOR_SIZE = 8; +const INDICATOR_BORDER_WIDTH = 1; +const SETTLE_DELAY = 600; + +// ── Context ──────────────────────────────────────────────────── +export interface SmartScrollbarContextValue { + value: number; + totalSlices: number; + trackHeight: number; + isLoading: boolean; + effectiveWidth: number; + trackWidth: number; + fillPadding: number; + svgIdPrefix: string; + stableLayerRef: RefObject; +} + +const SmartScrollbarContext = createContext(null); + +export function useSmartScrollbarContext(): SmartScrollbarContextValue { + const ctx = useContext(SmartScrollbarContext); + if (!ctx) throw new Error('SmartScrollbar compound components must be used inside '); + return ctx; +} + +// ── Props ────────────────────────────────────────────────────── +interface SmartScrollbarProps { + value: number; + totalSlices: number; + onValueChange: (index: number) => void; + isLoading?: boolean; + 'aria-label'?: string; + className?: string; + children: React.ReactNode; +} + +// ── Component ────────────────────────────────────────────────── +export function SmartScrollbar({ + value, + totalSlices, + onValueChange, + isLoading = false, + 'aria-label': ariaLabel = 'Scroll position', + className, + children, +}: SmartScrollbarProps) { + const svgIdPrefix = useId(); + + // ── ResizeObserver for trackHeight ─────────────────────────── + const containerRef = useRef(null); + const [trackHeight, setTrackHeight] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setTrackHeight(entry.contentRect.height); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // ── Contraction state ──────────────────────────────────────── + const [isHovered, setIsHovered] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const isDraggingRef = useRef(false); + const trackTopRef = useRef(0); + + // Settle delay — only contract after a real loading→done transition + const [hasSettled, setHasSettled] = useState(false); + const wasEverLoading = useRef(false); + + useEffect(() => { + if (isLoading) { + wasEverLoading.current = true; + setHasSettled(false); + } else if (wasEverLoading.current) { + const timer = setTimeout(() => setHasSettled(true), SETTLE_DELAY); + return () => clearTimeout(timer); + } + }, [isLoading]); + + const isExpanded = !hasSettled || isHovered || isDragging; + const effectiveWidth = isExpanded ? TRACK_WIDTH : RESTING_WIDTH; + + // ── Hit zone extension ─────────────────────────────────────── + const { leftPos } = getIndicatorLayout(TRACK_WIDTH, INDICATOR_SIZE, INDICATOR_BORDER_WIDTH); + const hitZoneLeftExtension = Math.max(0, -leftPos); + + // ── Stable layer ref (for elements that shouldn't move during contraction) ── + const stableLayerRef = useRef(null); + + // ── Pointer helpers ────────────────────────────────────────── + const clamp = useCallback( + (val: number) => Math.max(0, Math.min(totalSlices - 1, val)), + [totalSlices] + ); + + const indexFromPointerY = useCallback( + (clientY: number) => { + const ratio = Math.max(0, Math.min(1, (clientY - trackTopRef.current) / trackHeight)); + return Math.round(ratio * (totalSlices - 1)); + }, + [trackHeight, totalSlices] + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + const trackEl = + (e.currentTarget as HTMLElement).querySelector('[data-scrollbar-track]') as HTMLElement + ?? e.currentTarget as HTMLElement; + trackTopRef.current = trackEl.getBoundingClientRect().top; + + isDraggingRef.current = true; + setIsDragging(true); + e.currentTarget.setPointerCapture(e.pointerId); + + onValueChange(clamp(indexFromPointerY(e.clientY))); + }, + [clamp, indexFromPointerY, onValueChange] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isDraggingRef.current) return; + onValueChange(clamp(indexFromPointerY(e.clientY))); + }, + [clamp, indexFromPointerY, onValueChange] + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + isDraggingRef.current = false; + setIsDragging(false); + e.currentTarget.releasePointerCapture(e.pointerId); + }, + [] + ); + + // ── Context value ──────────────────────────────────────────── + const ctx: SmartScrollbarContextValue = { + value, + totalSlices, + trackHeight, + isLoading, + effectiveWidth, + trackWidth: TRACK_WIDTH, + fillPadding: FILL_PADDING, + svgIdPrefix, + stableLayerRef, + }; + + return ( + +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + > + {trackHeight > 0 && ( +
+
+ {children} +
+ {/* Stable layer — always TRACK_WIDTH, never contracts. For elements like + endpoints that must not jitter during width transitions. Children + render here via createPortal using stableLayerRef from context. */} +
+
+ )} +
+ + ); +} diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx new file mode 100644 index 00000000000..467ec524645 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -0,0 +1,63 @@ +import { createPortal } from 'react-dom'; +import { useSmartScrollbarContext } from './SmartScrollbar'; + +// ── Baked Design 27 constants ────────────────────────────────── +const CAP_SIZE = 4; +const CAP_HEIGHT = CAP_SIZE / 2 + 1; // 3 +const CAP_COLOR = 'hsl(var(--neutral) / 1.0)'; + +interface SmartScrollbarEndpointsProps { + slices: Set; + className?: string; +} + +export function SmartScrollbarEndpoints({ slices, className }: SmartScrollbarEndpointsProps) { + const { totalSlices, trackHeight, trackWidth, fillPadding, stableLayerRef } = useSmartScrollbarContext(); + + if (slices.size === 0 || trackHeight === 0 || !stableLayerRef.current) return null; + + const fillAreaTop = fillPadding; + const fillAreaHeight = trackHeight - fillPadding * 2; + + let minSlice = Infinity; + let maxSlice = -Infinity; + for (const s of slices) { + if (s < minSlice) minSlice = s; + if (s > maxSlice) maxSlice = s; + } + + // Use trackWidth (always 8px) not effectiveWidth — endpoints must stay + // stationary during contraction/expansion transitions. + const cx = trackWidth / 2; + const halfCap = CAP_SIZE / 2; + const topEdge = fillAreaTop + (minSlice / totalSlices) * fillAreaHeight; + const bottomEdge = fillAreaTop + ((maxSlice + 1) / totalSlices) * fillAreaHeight; + + // Portal into the stable layer so position isn't affected by the + // contracting track div's width transition. + return createPortal( + + {/* Top cap */} + + {/* Bottom cap */} + + , + stableLayerRef.current, + ); +} diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx new file mode 100644 index 00000000000..49db96f0fb3 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { useSmartScrollbarContext } from './SmartScrollbar'; +import { getContiguousRuns } from './utils'; + +interface SmartScrollbarFillProps { + slices: Set; + className?: string; + loadingClassName?: string; +} + +export function SmartScrollbarFill({ slices, className, loadingClassName }: SmartScrollbarFillProps) { + const { totalSlices, trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarContext(); + + // slices is a mutated Set (same reference), so depend on .size to bust memo + const slicesSize = slices.size; + const runs = useMemo( + () => getContiguousRuns(slices, totalSlices), + // eslint-disable-next-line react-hooks/exhaustive-deps + [slicesSize, totalSlices] + ); + + if (runs.length === 0 || trackHeight === 0) return null; + + const fillAreaTop = fillPadding; + const fillAreaHeight = trackHeight - fillPadding * 2; + const activeClass = isLoading && loadingClassName ? loadingClassName : className; + + return ( + <> + {runs.map((run) => { + const top = fillAreaTop + (run.start / totalSlices) * fillAreaHeight; + const height = (run.length / totalSlices) * fillAreaHeight; + + return ( +
+ ); + })} + + ); +} diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx new file mode 100644 index 00000000000..5d500b19b5e --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx @@ -0,0 +1,70 @@ +import { useSmartScrollbarContext } from './SmartScrollbar'; +import { getIndicatorLayout } from './utils'; + +// ── Baked Design 27 constants ────────────────────────────────── +const INDICATOR_SIZE = 8; +const BORDER_WIDTH = 1; +const INDICATOR_COLOR = 'hsl(var(--foreground) / 0.9)'; +const BORDER_COLOR = 'hsl(var(--neutral) / 0.9)'; + +interface SmartScrollbarIndicatorProps { + className?: string; +} + +export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorProps) { + const { value, totalSlices, trackHeight, effectiveWidth, fillPadding } = useSmartScrollbarContext(); + + if (trackHeight === 0) return null; + + const { totalWidth, totalHeight, fillWidth, fillHeight, leftPos } = getIndicatorLayout( + effectiveWidth, + INDICATOR_SIZE, + BORDER_WIDTH, + ); + + const offsetY = (totalHeight - INDICATOR_SIZE) / 2; + const fillAreaTop = fillPadding; + const fillAreaHeight = trackHeight - fillPadding * 2; + const maxY = fillAreaHeight - INDICATOR_SIZE; + const y = fillAreaTop + (totalSlices <= 1 ? 0 : (value / (totalSlices - 1)) * maxY); + + return ( +
+ + {/* Border rect */} + + {/* Fill rect */} + + +
+ ); +} diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx new file mode 100644 index 00000000000..3156e5350bb --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx @@ -0,0 +1,59 @@ +import { useId } from 'react'; +import { useSmartScrollbarContext } from './SmartScrollbar'; + +// ── Baked Design 27 constants ────────────────────────────────── +const DOT_SIZE = 2; +const DOT_GAP = 4; +const DOT_STEP = DOT_SIZE + DOT_GAP; // 6px +const DOT_RADIUS = DOT_SIZE / 2; + +interface SmartScrollbarTrackProps { + className?: string; + children?: React.ReactNode; +} + +export function SmartScrollbarTrack({ className, children }: SmartScrollbarTrackProps) { + const { trackHeight, effectiveWidth, isLoading } = useSmartScrollbarContext(); + const patternId = useId(); + + if (trackHeight === 0) return null; + + const w = effectiveWidth; + const h = trackHeight; + const dotColor = `hsl(var(--neutral) / 0.5)`; + + return ( +
+ {/* Dot-grid background — visible only during loading */} +
+ + + + + + + + + + + +
+ {children} +
+ ); +} diff --git a/platform/ui-next/src/components/SmartScrollbar/index.ts b/platform/ui-next/src/components/SmartScrollbar/index.ts new file mode 100644 index 00000000000..8562c730bb3 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/index.ts @@ -0,0 +1,6 @@ +export { SmartScrollbar, useSmartScrollbarContext } from './SmartScrollbar'; +export type { SmartScrollbarContextValue } from './SmartScrollbar'; +export { SmartScrollbarTrack } from './SmartScrollbarTrack'; +export { SmartScrollbarFill } from './SmartScrollbarFill'; +export { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; +export { SmartScrollbarEndpoints } from './SmartScrollbarEndpoints'; diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.ts b/platform/ui-next/src/components/SmartScrollbar/utils.ts new file mode 100644 index 00000000000..9af69086bfb --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/utils.ts @@ -0,0 +1,64 @@ +export interface ContiguousRun { + start: number; + length: number; + isFirst: boolean; + isLast: boolean; +} + +/** + * Given a Set of indices and a total count, returns contiguous runs + * sorted by start index. Each run includes metadata about whether + * it's the first/last run for border-radius decisions. + */ +export function getContiguousRuns( + indices: Set, + totalSlices: number +): ContiguousRun[] { + if (indices.size === 0) return []; + + const sorted = Array.from(indices).sort((a, b) => a - b); + const runs: ContiguousRun[] = []; + let runStart = sorted[0]; + let runLength = 1; + + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] === sorted[i - 1] + 1) { + runLength++; + } else { + runs.push({ start: runStart, length: runLength, isFirst: false, isLast: false }); + runStart = sorted[i]; + runLength = 1; + } + } + runs.push({ start: runStart, length: runLength, isFirst: false, isLast: false }); + + // Mark first and last + if (runs.length > 0) { + runs[0].isFirst = true; + runs[runs.length - 1].isLast = true; + } + + // Filter to valid range + return runs.filter(r => r.start >= 0 && r.start < totalSlices); +} + +/** + * Compute the indicator's total visual dimensions and horizontal position. + * Design 27: pill shape, center position, 1px border. + */ +export function getIndicatorLayout( + trackWidth: number, + indicatorSize: number, + borderWidth: number, +): { totalWidth: number; totalHeight: number; fillWidth: number; fillHeight: number; leftPos: number } { + const visualSize = indicatorSize * 1.25; + const fillWidth = visualSize; + const fillHeight = Math.round(visualSize / 2); // pill = half height + const totalWidth = fillWidth + borderWidth * 2; + const totalHeight = fillHeight + borderWidth * 2; + + const centerX = trackWidth / 2; + const leftPos = centerX - totalWidth / 2; + + return { totalWidth, totalHeight, fillWidth, fillHeight, leftPos }; +} diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 5fc7af79029..a93e0b8a874 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -1,4 +1,13 @@ import { Button, buttonVariants } from './Button'; +import { + SmartScrollbar, + useSmartScrollbarContext, + SmartScrollbarTrack, + SmartScrollbarFill, + SmartScrollbarIndicator, + SmartScrollbarEndpoints, +} from './SmartScrollbar'; +import type { SmartScrollbarContextValue } from './SmartScrollbar'; import { ThemeWrapper } from './ThemeWrapper'; import { Command, @@ -269,5 +278,11 @@ export { ProgressLoadingBar, ViewportDialog, CinePlayer, - LayoutSelector + LayoutSelector, + SmartScrollbar, + useSmartScrollbarContext, + SmartScrollbarTrack, + SmartScrollbarFill, + SmartScrollbarIndicator, + SmartScrollbarEndpoints, }; From 455256e56968636b6df32e866bfc064d2141a2d5 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 26 Mar 2026 07:19:02 -0400 Subject: [PATCH 02/18] Added warning when indicator is missing --- .../SmartScrollbar/SmartScrollbar.tsx | 29 ++++++++++++++++++- .../SmartScrollbarEndpoints.tsx | 2 +- .../SmartScrollbarIndicator.tsx | 2 +- .../SmartScrollbar/SmartScrollbarTrack.tsx | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index 41bbe14d2f7..ee916712f63 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -6,11 +6,36 @@ import { useRef, useCallback, useId, + Children, + isValidElement, type RefObject, } from 'react'; import { getIndicatorLayout } from './utils'; +import { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; -// ── Baked Design 27 constants ────────────────────────────────── +// ── Child validation ──────────────────────────────────────────── +let _warnedNoIndicator = false; + +function validateChildren(children: React.ReactNode): void { + if (process.env.NODE_ENV === 'production') return; + + let hasIndicator = false; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + if (child.type === SmartScrollbarIndicator) hasIndicator = true; + }); + + if (!hasIndicator && !_warnedNoIndicator) { + _warnedNoIndicator = true; + console.warn( + 'SmartScrollbar: no found. ' + + 'The user will not see their current scroll position.' + ); + } +} + +// ── Layout and timing constants ───────────────────────────────── const TRACK_WIDTH = 8; const RESTING_WIDTH = 4; const FILL_PADDING = 3; @@ -60,6 +85,8 @@ export function SmartScrollbar({ className, children, }: SmartScrollbarProps) { + validateChildren(children); + const svgIdPrefix = useId(); // ── ResizeObserver for trackHeight ─────────────────────────── diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx index 467ec524645..e276403f494 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -1,7 +1,7 @@ import { createPortal } from 'react-dom'; import { useSmartScrollbarContext } from './SmartScrollbar'; -// ── Baked Design 27 constants ────────────────────────────────── +// ── Endpoint cap dimensions and color ─────────────────────────── const CAP_SIZE = 4; const CAP_HEIGHT = CAP_SIZE / 2 + 1; // 3 const CAP_COLOR = 'hsl(var(--neutral) / 1.0)'; diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx index 5d500b19b5e..78bdd6548d9 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx @@ -1,7 +1,7 @@ import { useSmartScrollbarContext } from './SmartScrollbar'; import { getIndicatorLayout } from './utils'; -// ── Baked Design 27 constants ────────────────────────────────── +// ── Indicator dimensions and colors ───────────────────────────── const INDICATOR_SIZE = 8; const BORDER_WIDTH = 1; const INDICATOR_COLOR = 'hsl(var(--foreground) / 0.9)'; diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx index 3156e5350bb..0e653f11cfb 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx @@ -1,7 +1,7 @@ import { useId } from 'react'; import { useSmartScrollbarContext } from './SmartScrollbar'; -// ── Baked Design 27 constants ────────────────────────────────── +// ── Dot-grid pattern constants ────────────────────────────────── const DOT_SIZE = 2; const DOT_GAP = 4; const DOT_STEP = DOT_SIZE + DOT_GAP; // 6px From 12c062a403ff2a571e56f3ec3e8d3d3bbffb64b2 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 26 Mar 2026 15:57:41 -0400 Subject: [PATCH 03/18] remove unused svgIdPrefix from context --- .../ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index ee916712f63..a1ec1e59895 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -5,7 +5,6 @@ import { useEffect, useRef, useCallback, - useId, Children, isValidElement, type RefObject, @@ -52,7 +51,6 @@ export interface SmartScrollbarContextValue { effectiveWidth: number; trackWidth: number; fillPadding: number; - svgIdPrefix: string; stableLayerRef: RefObject; } @@ -87,8 +85,6 @@ export function SmartScrollbar({ }: SmartScrollbarProps) { validateChildren(children); - const svgIdPrefix = useId(); - // ── ResizeObserver for trackHeight ─────────────────────────── const containerRef = useRef(null); const [trackHeight, setTrackHeight] = useState(0); @@ -189,7 +185,6 @@ export function SmartScrollbar({ effectiveWidth, trackWidth: TRACK_WIDTH, fillPadding: FILL_PADDING, - svgIdPrefix, stableLayerRef, }; From ca2e29b4d0e21b325aa6884eb3202221073fbccf Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 26 Mar 2026 16:00:36 -0400 Subject: [PATCH 04/18] clamp contiguous run lengths to prevent fill overflow --- platform/ui-next/src/components/SmartScrollbar/utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.ts b/platform/ui-next/src/components/SmartScrollbar/utils.ts index 9af69086bfb..006969f3af9 100644 --- a/platform/ui-next/src/components/SmartScrollbar/utils.ts +++ b/platform/ui-next/src/components/SmartScrollbar/utils.ts @@ -38,8 +38,10 @@ export function getContiguousRuns( runs[runs.length - 1].isLast = true; } - // Filter to valid range - return runs.filter(r => r.start >= 0 && r.start < totalSlices); + // Filter to valid range and clamp lengths that extend past totalSlices + return runs + .filter(r => r.start >= 0 && r.start < totalSlices) + .map(r => ({ ...r, length: Math.min(r.length, totalSlices - r.start) })); } /** From 5154700359e2f393976625970e60db86a19c3a8e Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 26 Mar 2026 16:05:30 -0400 Subject: [PATCH 05/18] use callback ref for stable layer to fix first-render timing --- .../components/SmartScrollbar/SmartScrollbar.tsx | 13 +++++++------ .../SmartScrollbar/SmartScrollbarEndpoints.tsx | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index a1ec1e59895..292f2bc84dc 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -7,7 +7,6 @@ import { useCallback, Children, isValidElement, - type RefObject, } from 'react'; import { getIndicatorLayout } from './utils'; import { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; @@ -51,7 +50,7 @@ export interface SmartScrollbarContextValue { effectiveWidth: number; trackWidth: number; fillPadding: number; - stableLayerRef: RefObject; + stableLayerEl: HTMLDivElement | null; } const SmartScrollbarContext = createContext(null); @@ -126,8 +125,10 @@ export function SmartScrollbar({ const { leftPos } = getIndicatorLayout(TRACK_WIDTH, INDICATOR_SIZE, INDICATOR_BORDER_WIDTH); const hitZoneLeftExtension = Math.max(0, -leftPos); - // ── Stable layer ref (for elements that shouldn't move during contraction) ── - const stableLayerRef = useRef(null); + // ── Stable layer (for elements that shouldn't move during contraction) ── + // Uses useState + callback ref so React triggers a re-render when the + // DOM node mounts — ensuring endpoints render on the first valid pass. + const [stableLayerEl, setStableLayerEl] = useState(null); // ── Pointer helpers ────────────────────────────────────────── const clamp = useCallback( @@ -185,7 +186,7 @@ export function SmartScrollbar({ effectiveWidth, trackWidth: TRACK_WIDTH, fillPadding: FILL_PADDING, - stableLayerRef, + stableLayerEl, }; return ( @@ -242,7 +243,7 @@ export function SmartScrollbar({ endpoints that must not jitter during width transitions. Children render here via createPortal using stableLayerRef from context. */}
diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx index e276403f494..212bdef635a 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -12,9 +12,9 @@ interface SmartScrollbarEndpointsProps { } export function SmartScrollbarEndpoints({ slices, className }: SmartScrollbarEndpointsProps) { - const { totalSlices, trackHeight, trackWidth, fillPadding, stableLayerRef } = useSmartScrollbarContext(); + const { totalSlices, trackHeight, trackWidth, fillPadding, stableLayerEl } = useSmartScrollbarContext(); - if (slices.size === 0 || trackHeight === 0 || !stableLayerRef.current) return null; + if (slices.size === 0 || trackHeight === 0 || !stableLayerEl) return null; const fillAreaTop = fillPadding; const fillAreaHeight = trackHeight - fillPadding * 2; @@ -58,6 +58,6 @@ export function SmartScrollbarEndpoints({ slices, className }: SmartScrollbarEnd fill={CAP_COLOR} /> , - stableLayerRef.current, + stableLayerEl, ); } From fd2cf1b4760c4204a07f6f2be9b48f2eb2638892 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 26 Mar 2026 16:11:32 -0400 Subject: [PATCH 06/18] add keyboard navigation for WAI-ARIA slider compliance --- .../SmartScrollbar/SmartScrollbar.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index 292f2bc84dc..0f237964ea3 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -177,6 +177,44 @@ export function SmartScrollbar({ [] ); + // ── Keyboard interaction (WAI-ARIA slider spec) ──────────── + const PAGE_STEP = 10; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + let next: number | null = null; + + switch (e.key) { + case 'ArrowUp': + case 'ArrowLeft': + next = value - 1; + break; + case 'ArrowDown': + case 'ArrowRight': + next = value + 1; + break; + case 'PageUp': + next = value - PAGE_STEP; + break; + case 'PageDown': + next = value + PAGE_STEP; + break; + case 'Home': + next = 0; + break; + case 'End': + next = totalSlices - 1; + break; + default: + return; + } + + e.preventDefault(); + onValueChange(clamp(next)); + }, + [value, totalSlices, clamp, onValueChange] + ); + // ── Context value ──────────────────────────────────────────── const ctx: SmartScrollbarContextValue = { value, @@ -215,6 +253,7 @@ export function SmartScrollbar({ onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} + onKeyDown={handleKeyDown} > {trackHeight > 0 && (
Date: Fri, 27 Mar 2026 16:26:19 -0400 Subject: [PATCH 07/18] Added missing React imports. --- .../ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx | 2 +- .../src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx | 1 + .../src/components/SmartScrollbar/SmartScrollbarFill.tsx | 2 +- .../src/components/SmartScrollbar/SmartScrollbarIndicator.tsx | 1 + .../src/components/SmartScrollbar/SmartScrollbarTrack.tsx | 2 +- 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index 0f237964ea3..2a454df2312 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -1,4 +1,4 @@ -import { +import React, { createContext, useContext, useState, diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx index 212bdef635a..5bb0598aa6d 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { createPortal } from 'react-dom'; import { useSmartScrollbarContext } from './SmartScrollbar'; diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx index 49db96f0fb3..d19626f598a 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { useSmartScrollbarContext } from './SmartScrollbar'; import { getContiguousRuns } from './utils'; diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx index 78bdd6548d9..fca848354d4 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { useSmartScrollbarContext } from './SmartScrollbar'; import { getIndicatorLayout } from './utils'; diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx index 0e653f11cfb..35a4d2b6c0e 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx @@ -1,4 +1,4 @@ -import { useId } from 'react'; +import React, { useId } from 'react'; import { useSmartScrollbarContext } from './SmartScrollbar'; // ── Dot-grid pattern constants ────────────────────────────────── From 838929645b3cb568387d408b9c8484eb8a0be57a Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Fri, 27 Mar 2026 18:02:24 -0400 Subject: [PATCH 08/18] For SmartScrollbarTrack, after the loading pattern is faded out, instead of leaving the dot grid mounted, unmount it entirely. --- .../SmartScrollbar/SmartScrollbarTrack.tsx | 81 ++++++++++++------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx index 35a4d2b6c0e..e30a12ac70f 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx @@ -1,4 +1,4 @@ -import React, { useId } from 'react'; +import React, { useId, useState, useEffect } from 'react'; import { useSmartScrollbarContext } from './SmartScrollbar'; // ── Dot-grid pattern constants ────────────────────────────────── @@ -6,53 +6,72 @@ const DOT_SIZE = 2; const DOT_GAP = 4; const DOT_STEP = DOT_SIZE + DOT_GAP; // 6px const DOT_RADIUS = DOT_SIZE / 2; +const FADE_DURATION_MS = 500; interface SmartScrollbarTrackProps { className?: string; children?: React.ReactNode; } +function DotGrid({ w, h, patternId }: { w: number; h: number; patternId: string }) { + const dotColor = `hsl(var(--neutral) / 0.5)`; + return ( + + + + + + + + + + + + ); +} + export function SmartScrollbarTrack({ className, children }: SmartScrollbarTrackProps) { const { trackHeight, effectiveWidth, isLoading } = useSmartScrollbarContext(); const patternId = useId(); + // Keep the dot grid mounted long enough to fade out, then unmount entirely. + const [dotGridMounted, setDotGridMounted] = useState(isLoading); + useEffect(() => { + if (isLoading) { + setDotGridMounted(true); + return; + } + const t = setTimeout(() => setDotGridMounted(false), FADE_DURATION_MS); + return () => clearTimeout(t); + }, [isLoading]); + if (trackHeight === 0) return null; const w = effectiveWidth; const h = trackHeight; - const dotColor = `hsl(var(--neutral) / 0.5)`; return (
- {/* Dot-grid background — visible only during loading */} -
- - - - - - - - - - - -
+ {dotGridMounted && ( +
+ +
+ )} {children}
); From e33d47e9914cb1618b81e0262d0e6ca0d9632db3 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Fri, 27 Mar 2026 18:53:58 -0400 Subject: [PATCH 09/18] Made keyboard navigation optional. --- .../ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index 2a454df2312..c896518e958 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -67,6 +67,7 @@ interface SmartScrollbarProps { totalSlices: number; onValueChange: (index: number) => void; isLoading?: boolean; + enableKeyboardNavigation?: boolean; 'aria-label'?: string; className?: string; children: React.ReactNode; @@ -78,6 +79,7 @@ export function SmartScrollbar({ totalSlices, onValueChange, isLoading = false, + enableKeyboardNavigation = false, 'aria-label': ariaLabel = 'Scroll position', className, children, @@ -253,7 +255,7 @@ export function SmartScrollbar({ onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} - onKeyDown={handleKeyDown} + onKeyDown={enableKeyboardNavigation ? handleKeyDown : undefined} > {trackHeight > 0 && (
Date: Sat, 28 Mar 2026 23:34:39 -0400 Subject: [PATCH 10/18] =?UTF-8?q?Remove=20redundant=20data-scrollbar-track?= =?UTF-8?q?=20attribute=20=E2=80=94=20all=20track=20divs=20share=20the=20s?= =?UTF-8?q?ame=20top.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/SmartScrollbar/SmartScrollbar.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index c896518e958..36882a90a22 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -148,10 +148,7 @@ export function SmartScrollbar({ const handlePointerDown = useCallback( (e: React.PointerEvent) => { - const trackEl = - (e.currentTarget as HTMLElement).querySelector('[data-scrollbar-track]') as HTMLElement - ?? e.currentTarget as HTMLElement; - trackTopRef.current = trackEl.getBoundingClientRect().top; + trackTopRef.current = e.currentTarget.getBoundingClientRect().top; isDraggingRef.current = true; setIsDragging(true); @@ -270,7 +267,6 @@ export function SmartScrollbar({ }} >
Date: Mon, 30 Mar 2026 15:08:29 -0400 Subject: [PATCH 11/18] Split SmartScrollbar context into layout and scroll contexts to avoid unnecessary re-renders. Memoize SmartScrollbar components to prevent unnecessary re-renders. --- .../SmartScrollbar/SmartScrollbar.tsx | 156 +++++++++--------- .../SmartScrollbarEndpoints.tsx | 17 +- .../SmartScrollbar/SmartScrollbarFill.tsx | 16 +- .../SmartScrollbarIndicator.tsx | 11 +- .../SmartScrollbar/SmartScrollbarTrack.tsx | 44 ++++- .../src/components/SmartScrollbar/index.ts | 4 +- platform/ui-next/src/components/index.ts | 8 +- 7 files changed, 149 insertions(+), 107 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index 36882a90a22..a060535f930 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -5,6 +5,7 @@ import React, { useEffect, useRef, useCallback, + useMemo, Children, isValidElement, } from 'react'; @@ -19,7 +20,7 @@ function validateChildren(children: React.ReactNode): void { let hasIndicator = false; - Children.forEach(children, (child) => { + Children.forEach(children, child => { if (!isValidElement(child)) return; if (child.type === SmartScrollbarIndicator) hasIndicator = true; }); @@ -28,7 +29,7 @@ function validateChildren(children: React.ReactNode): void { _warnedNoIndicator = true; console.warn( 'SmartScrollbar: no found. ' + - 'The user will not see their current scroll position.' + 'The user will not see their current scroll position.' ); } } @@ -41,9 +42,8 @@ const INDICATOR_SIZE = 8; const INDICATOR_BORDER_WIDTH = 1; const SETTLE_DELAY = 600; -// ── Context ──────────────────────────────────────────────────── -export interface SmartScrollbarContextValue { - value: number; +// ── Contexts ─────────────────────────────────────────────────── +export interface SmartScrollbarLayoutContextValue { totalSlices: number; trackHeight: number; isLoading: boolean; @@ -53,14 +53,23 @@ export interface SmartScrollbarContextValue { stableLayerEl: HTMLDivElement | null; } -const SmartScrollbarContext = createContext(null); +const SmartScrollbarLayoutContext = createContext(null); +const SmartScrollbarScrollContext = createContext(null); -export function useSmartScrollbarContext(): SmartScrollbarContextValue { - const ctx = useContext(SmartScrollbarContext); - if (!ctx) throw new Error('SmartScrollbar compound components must be used inside '); +export function useSmartScrollbarLayoutContext(): SmartScrollbarLayoutContextValue { + const ctx = useContext(SmartScrollbarLayoutContext); + if (!ctx) + throw new Error('SmartScrollbar compound components must be used inside '); return ctx; } +export function useSmartScrollbarScrollContext(): number { + const value = useContext(SmartScrollbarScrollContext); + if (value === null) + throw new Error('SmartScrollbar compound components must be used inside '); + return value; +} + // ── Props ────────────────────────────────────────────────────── interface SmartScrollbarProps { value: number; @@ -167,14 +176,11 @@ export function SmartScrollbar({ [clamp, indexFromPointerY, onValueChange] ); - const handlePointerUp = useCallback( - (e: React.PointerEvent) => { - isDraggingRef.current = false; - setIsDragging(false); - e.currentTarget.releasePointerCapture(e.pointerId); - }, - [] - ); + const handlePointerUp = useCallback((e: React.PointerEvent) => { + isDraggingRef.current = false; + setIsDragging(false); + e.currentTarget.releasePointerCapture(e.pointerId); + }, []); // ── Keyboard interaction (WAI-ARIA slider spec) ──────────── const PAGE_STEP = 10; @@ -214,9 +220,8 @@ export function SmartScrollbar({ [value, totalSlices, clamp, onValueChange] ); - // ── Context value ──────────────────────────────────────────── - const ctx: SmartScrollbarContextValue = { - value, + // ── Context values ─────────────────────────────────────────── + const layoutCtx = useMemo(() => ({ totalSlices, trackHeight, isLoading, @@ -224,68 +229,69 @@ export function SmartScrollbar({ trackWidth: TRACK_WIDTH, fillPadding: FILL_PADDING, stableLayerEl, - }; - + }), [totalSlices, trackHeight, isLoading, effectiveWidth, stableLayerEl]); return ( - -
setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - onKeyDown={enableKeyboardNavigation ? handleKeyDown : undefined} - > - {trackHeight > 0 && ( -
+ + +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + onKeyDown={enableKeyboardNavigation ? handleKeyDown : undefined} + > + {trackHeight > 0 && (
- {children} -
- {/* Stable layer — always TRACK_WIDTH, never contracts. For elements like +
+ {children} +
+ {/* Stable layer — always TRACK_WIDTH, never contracts. For elements like endpoints that must not jitter during width transitions. Children render here via createPortal using stableLayerRef from context. */} -
-
- )} -
- +
+
+ )} +
+ + ); } diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx index 5bb0598aa6d..d4f5b06dc19 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import { useSmartScrollbarContext } from './SmartScrollbar'; +import { useSmartScrollbarLayoutContext } from './SmartScrollbar'; // ── Endpoint cap dimensions and color ─────────────────────────── const CAP_SIZE = 4; @@ -12,8 +12,12 @@ interface SmartScrollbarEndpointsProps { className?: string; } -export function SmartScrollbarEndpoints({ slices, className }: SmartScrollbarEndpointsProps) { - const { totalSlices, trackHeight, trackWidth, fillPadding, stableLayerEl } = useSmartScrollbarContext(); +export const SmartScrollbarEndpoints = React.memo(function SmartScrollbarEndpoints({ + slices, + className, +}: SmartScrollbarEndpointsProps) { + const { totalSlices, trackHeight, trackWidth, fillPadding, stableLayerEl } = + useSmartScrollbarLayoutContext(); if (slices.size === 0 || trackHeight === 0 || !stableLayerEl) return null; @@ -33,14 +37,13 @@ export function SmartScrollbarEndpoints({ slices, className }: SmartScrollbarEnd const halfCap = CAP_SIZE / 2; const topEdge = fillAreaTop + (minSlice / totalSlices) * fillAreaHeight; const bottomEdge = fillAreaTop + ((maxSlice + 1) / totalSlices) * fillAreaHeight; - // Portal into the stable layer so position isn't affected by the // contracting track div's width transition. return createPortal( {/* Top cap */} , - stableLayerEl, + stableLayerEl ); -} +}); diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx index d19626f598a..d8f80af4458 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { useSmartScrollbarContext } from './SmartScrollbar'; +import { useSmartScrollbarLayoutContext } from './SmartScrollbar'; import { getContiguousRuns } from './utils'; interface SmartScrollbarFillProps { @@ -8,8 +8,13 @@ interface SmartScrollbarFillProps { loadingClassName?: string; } -export function SmartScrollbarFill({ slices, className, loadingClassName }: SmartScrollbarFillProps) { - const { totalSlices, trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarContext(); +export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ + slices, + className, + loadingClassName, +}: SmartScrollbarFillProps) { + const { totalSlices, trackHeight, effectiveWidth, fillPadding, isLoading } = + useSmartScrollbarLayoutContext(); // slices is a mutated Set (same reference), so depend on .size to bust memo const slicesSize = slices.size; @@ -24,10 +29,9 @@ export function SmartScrollbarFill({ slices, className, loadingClassName }: Smar const fillAreaTop = fillPadding; const fillAreaHeight = trackHeight - fillPadding * 2; const activeClass = isLoading && loadingClassName ? loadingClassName : className; - return ( <> - {runs.map((run) => { + {runs.map(run => { const top = fillAreaTop + (run.start / totalSlices) * fillAreaHeight; const height = (run.length / totalSlices) * fillAreaHeight; @@ -47,4 +51,4 @@ export function SmartScrollbarFill({ slices, className, loadingClassName }: Smar })} ); -} +}); diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx index fca848354d4..ee43d0b889f 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useSmartScrollbarContext } from './SmartScrollbar'; +import { useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar'; import { getIndicatorLayout } from './utils'; // ── Indicator dimensions and colors ───────────────────────────── @@ -13,14 +13,16 @@ interface SmartScrollbarIndicatorProps { } export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorProps) { - const { value, totalSlices, trackHeight, effectiveWidth, fillPadding } = useSmartScrollbarContext(); + const { totalSlices, trackHeight, effectiveWidth, fillPadding } = + useSmartScrollbarLayoutContext(); + const value = useSmartScrollbarScrollContext(); if (trackHeight === 0) return null; const { totalWidth, totalHeight, fillWidth, fillHeight, leftPos } = getIndicatorLayout( effectiveWidth, INDICATOR_SIZE, - BORDER_WIDTH, + BORDER_WIDTH ); const offsetY = (totalHeight - INDICATOR_SIZE) / 2; @@ -28,10 +30,9 @@ export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorPr const fillAreaHeight = trackHeight - fillPadding * 2; const maxY = fillAreaHeight - INDICATOR_SIZE; const y = fillAreaTop + (totalSlices <= 1 ? 0 : (value / (totalSlices - 1)) * maxY); - return (
+ - + - + - +
)} {children}
); -} +}); diff --git a/platform/ui-next/src/components/SmartScrollbar/index.ts b/platform/ui-next/src/components/SmartScrollbar/index.ts index 8562c730bb3..36bdd03e456 100644 --- a/platform/ui-next/src/components/SmartScrollbar/index.ts +++ b/platform/ui-next/src/components/SmartScrollbar/index.ts @@ -1,5 +1,5 @@ -export { SmartScrollbar, useSmartScrollbarContext } from './SmartScrollbar'; -export type { SmartScrollbarContextValue } from './SmartScrollbar'; +export { SmartScrollbar, useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar'; +export type { SmartScrollbarLayoutContextValue } from './SmartScrollbar'; export { SmartScrollbarTrack } from './SmartScrollbarTrack'; export { SmartScrollbarFill } from './SmartScrollbarFill'; export { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index a93e0b8a874..8353e1ebb0e 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -1,13 +1,14 @@ import { Button, buttonVariants } from './Button'; import { SmartScrollbar, - useSmartScrollbarContext, + useSmartScrollbarLayoutContext, + useSmartScrollbarScrollContext, SmartScrollbarTrack, SmartScrollbarFill, SmartScrollbarIndicator, SmartScrollbarEndpoints, } from './SmartScrollbar'; -import type { SmartScrollbarContextValue } from './SmartScrollbar'; +import type { SmartScrollbarLayoutContextValue } from './SmartScrollbar'; import { ThemeWrapper } from './ThemeWrapper'; import { Command, @@ -280,7 +281,8 @@ export { CinePlayer, LayoutSelector, SmartScrollbar, - useSmartScrollbarContext, + useSmartScrollbarLayoutContext, + useSmartScrollbarScrollContext, SmartScrollbarTrack, SmartScrollbarFill, SmartScrollbarIndicator, From a3880846114e5839f8ff1930fc5a6f40b5090921 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Mon, 30 Mar 2026 21:57:36 -0400 Subject: [PATCH 12/18] Eliminate use of slices in SmartScrollbar property and variable names. --- .../SmartScrollbar/SmartScrollbar.tsx | 24 +++++++++---------- .../SmartScrollbarEndpoints.tsx | 14 +++++------ .../SmartScrollbar/SmartScrollbarFill.tsx | 17 +++++++------ .../SmartScrollbarIndicator.tsx | 4 ++-- .../src/components/SmartScrollbar/utils.ts | 8 +++---- 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index a060535f930..6fc0a7b0f88 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -44,7 +44,7 @@ const SETTLE_DELAY = 600; // ── Contexts ─────────────────────────────────────────────────── export interface SmartScrollbarLayoutContextValue { - totalSlices: number; + total: number; trackHeight: number; isLoading: boolean; effectiveWidth: number; @@ -73,7 +73,7 @@ export function useSmartScrollbarScrollContext(): number { // ── Props ────────────────────────────────────────────────────── interface SmartScrollbarProps { value: number; - totalSlices: number; + total: number; onValueChange: (index: number) => void; isLoading?: boolean; enableKeyboardNavigation?: boolean; @@ -85,7 +85,7 @@ interface SmartScrollbarProps { // ── Component ────────────────────────────────────────────────── export function SmartScrollbar({ value, - totalSlices, + total, onValueChange, isLoading = false, enableKeyboardNavigation = false, @@ -143,16 +143,16 @@ export function SmartScrollbar({ // ── Pointer helpers ────────────────────────────────────────── const clamp = useCallback( - (val: number) => Math.max(0, Math.min(totalSlices - 1, val)), - [totalSlices] + (val: number) => Math.max(0, Math.min(total - 1, val)), + [total] ); const indexFromPointerY = useCallback( (clientY: number) => { const ratio = Math.max(0, Math.min(1, (clientY - trackTopRef.current) / trackHeight)); - return Math.round(ratio * (totalSlices - 1)); + return Math.round(ratio * (total - 1)); }, - [trackHeight, totalSlices] + [trackHeight, total] ); const handlePointerDown = useCallback( @@ -208,7 +208,7 @@ export function SmartScrollbar({ next = 0; break; case 'End': - next = totalSlices - 1; + next = total - 1; break; default: return; @@ -217,19 +217,19 @@ export function SmartScrollbar({ e.preventDefault(); onValueChange(clamp(next)); }, - [value, totalSlices, clamp, onValueChange] + [value, total, clamp, onValueChange] ); // ── Context values ─────────────────────────────────────────── const layoutCtx = useMemo(() => ({ - totalSlices, + total, trackHeight, isLoading, effectiveWidth, trackWidth: TRACK_WIDTH, fillPadding: FILL_PADDING, stableLayerEl, - }), [totalSlices, trackHeight, isLoading, effectiveWidth, stableLayerEl]); + }), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]); return ( @@ -238,7 +238,7 @@ export function SmartScrollbar({ role="slider" aria-valuenow={value} aria-valuemin={0} - aria-valuemax={totalSlices - 1} + aria-valuemax={total - 1} aria-orientation="vertical" aria-label={ariaLabel} tabIndex={0} diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx index d4f5b06dc19..2bcead18166 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -8,25 +8,25 @@ const CAP_HEIGHT = CAP_SIZE / 2 + 1; // 3 const CAP_COLOR = 'hsl(var(--neutral) / 1.0)'; interface SmartScrollbarEndpointsProps { - slices: Set; + marked: Set; className?: string; } export const SmartScrollbarEndpoints = React.memo(function SmartScrollbarEndpoints({ - slices, + marked, className, }: SmartScrollbarEndpointsProps) { - const { totalSlices, trackHeight, trackWidth, fillPadding, stableLayerEl } = + const { total, trackHeight, trackWidth, fillPadding, stableLayerEl } = useSmartScrollbarLayoutContext(); - if (slices.size === 0 || trackHeight === 0 || !stableLayerEl) return null; + if (marked.size === 0 || trackHeight === 0 || !stableLayerEl) return null; const fillAreaTop = fillPadding; const fillAreaHeight = trackHeight - fillPadding * 2; let minSlice = Infinity; let maxSlice = -Infinity; - for (const s of slices) { + for (const s of marked) { if (s < minSlice) minSlice = s; if (s > maxSlice) maxSlice = s; } @@ -35,8 +35,8 @@ export const SmartScrollbarEndpoints = React.memo(function SmartScrollbarEndpoin // stationary during contraction/expansion transitions. const cx = trackWidth / 2; const halfCap = CAP_SIZE / 2; - const topEdge = fillAreaTop + (minSlice / totalSlices) * fillAreaHeight; - const bottomEdge = fillAreaTop + ((maxSlice + 1) / totalSlices) * fillAreaHeight; + const topEdge = fillAreaTop + (minSlice / total) * fillAreaHeight; + const bottomEdge = fillAreaTop + ((maxSlice + 1) / total) * fillAreaHeight; // Portal into the stable layer so position isn't affected by the // contracting track div's width transition. return createPortal( diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx index d8f80af4458..1e73d5733f1 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -3,25 +3,24 @@ import { useSmartScrollbarLayoutContext } from './SmartScrollbar'; import { getContiguousRuns } from './utils'; interface SmartScrollbarFillProps { - slices: Set; + marked: Set; className?: string; loadingClassName?: string; } export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ - slices, + marked, className, loadingClassName, }: SmartScrollbarFillProps) { - const { totalSlices, trackHeight, effectiveWidth, fillPadding, isLoading } = + const { total, trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarLayoutContext(); - // slices is a mutated Set (same reference), so depend on .size to bust memo - const slicesSize = slices.size; + // marked is a mutated Set (same reference), so depend on .size to bust memo const runs = useMemo( - () => getContiguousRuns(slices, totalSlices), + () => getContiguousRuns(marked, total), // eslint-disable-next-line react-hooks/exhaustive-deps - [slicesSize, totalSlices] + [marked.size, total] ); if (runs.length === 0 || trackHeight === 0) return null; @@ -32,8 +31,8 @@ export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ return ( <> {runs.map(run => { - const top = fillAreaTop + (run.start / totalSlices) * fillAreaHeight; - const height = (run.length / totalSlices) * fillAreaHeight; + const top = fillAreaTop + (run.start / total) * fillAreaHeight; + const height = (run.length / total) * fillAreaHeight; return (
, - totalSlices: number + total: number ): ContiguousRun[] { if (indices.size === 0) return []; @@ -38,10 +38,10 @@ export function getContiguousRuns( runs[runs.length - 1].isLast = true; } - // Filter to valid range and clamp lengths that extend past totalSlices + // Filter to valid range and clamp lengths that extend past total return runs - .filter(r => r.start >= 0 && r.start < totalSlices) - .map(r => ({ ...r, length: Math.min(r.length, totalSlices - r.start) })); + .filter(r => r.start >= 0 && r.start < total) + .map(r => ({ ...r, length: Math.min(r.length, total - r.start) })); } /** From f4ab07356bad42b2e609ca54519bd77933693db8 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 31 Mar 2026 08:06:49 -0400 Subject: [PATCH 13/18] Throw error instead of console.warn when SmartScrollbarIndicator is missing --- .../components/SmartScrollbar/SmartScrollbar.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index 36882a90a22..58e51aa4ef0 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -12,11 +12,7 @@ import { getIndicatorLayout } from './utils'; import { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; // ── Child validation ──────────────────────────────────────────── -let _warnedNoIndicator = false; - function validateChildren(children: React.ReactNode): void { - if (process.env.NODE_ENV === 'production') return; - let hasIndicator = false; Children.forEach(children, (child) => { @@ -24,11 +20,10 @@ function validateChildren(children: React.ReactNode): void { if (child.type === SmartScrollbarIndicator) hasIndicator = true; }); - if (!hasIndicator && !_warnedNoIndicator) { - _warnedNoIndicator = true; - console.warn( - 'SmartScrollbar: no found. ' + - 'The user will not see their current scroll position.' + if (!hasIndicator) { + throw new Error( + 'SmartScrollbar: is a required child. ' + + 'Users will not see their current scroll position without it.' ); } } From 778affab8e1b259da95b9b1c166a2d964461f2d2 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Tue, 31 Mar 2026 15:12:24 -0400 Subject: [PATCH 14/18] Replace Set with Uint8Array in SmartScrollbar components. Use useByteArray hook to manage the Uint8Array. --- .../SmartScrollbarEndpoints.tsx | 34 ++++-- .../SmartScrollbar/SmartScrollbarFill.tsx | 14 ++- .../src/components/SmartScrollbar/index.ts | 2 + .../components/SmartScrollbar/useByteArray.ts | 106 ++++++++++++++++++ .../src/components/SmartScrollbar/utils.ts | 41 +++---- platform/ui-next/src/components/index.ts | 3 +- 6 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 platform/ui-next/src/components/SmartScrollbar/useByteArray.ts diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx index 2bcead18166..1d38e1c92e8 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -8,35 +8,51 @@ const CAP_HEIGHT = CAP_SIZE / 2 + 1; // 3 const CAP_COLOR = 'hsl(var(--neutral) / 1.0)'; interface SmartScrollbarEndpointsProps { - marked: Set; + marked: Uint8Array; + version: number; className?: string; } export const SmartScrollbarEndpoints = React.memo(function SmartScrollbarEndpoints({ marked, + // `marked` is mutated in-place (stable reference). We accept `version` only to + // invalidate React.memo and force a re-render when the bytes change. The + // leading underscore indicates the value is intentionally unused in this component. + version: _version, className, }: SmartScrollbarEndpointsProps) { const { total, trackHeight, trackWidth, fillPadding, stableLayerEl } = useSmartScrollbarLayoutContext(); - if (marked.size === 0 || trackHeight === 0 || !stableLayerEl) return null; + // Scan for the first and last set byte in O(n) — much cheaper than a Set + // iteration and naturally gives us the two endpoints we need. + let minSlice = -1; + let maxSlice = -1; + for (let i = 0; i < marked.length; i++) { + if (marked[i]) { + minSlice = i; + break; + } + } + for (let i = marked.length - 1; i >= 0; i--) { + if (marked[i]) { + maxSlice = i; + break; + } + } + + if (minSlice === -1 || trackHeight === 0 || !stableLayerEl) return null; const fillAreaTop = fillPadding; const fillAreaHeight = trackHeight - fillPadding * 2; - let minSlice = Infinity; - let maxSlice = -Infinity; - for (const s of marked) { - if (s < minSlice) minSlice = s; - if (s > maxSlice) maxSlice = s; - } - // Use trackWidth (always 8px) not effectiveWidth — endpoints must stay // stationary during contraction/expansion transitions. const cx = trackWidth / 2; const halfCap = CAP_SIZE / 2; const topEdge = fillAreaTop + (minSlice / total) * fillAreaHeight; const bottomEdge = fillAreaTop + ((maxSlice + 1) / total) * fillAreaHeight; + // Portal into the stable layer so position isn't affected by the // contracting track div's width transition. return createPortal( diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx index 1e73d5733f1..1e390df275e 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -1,26 +1,27 @@ import React, { useMemo } from 'react'; import { useSmartScrollbarLayoutContext } from './SmartScrollbar'; -import { getContiguousRuns } from './utils'; +import { computeContiguousRuns } from './utils'; interface SmartScrollbarFillProps { - marked: Set; + marked: Uint8Array; + version: number; className?: string; loadingClassName?: string; } export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ marked, + version, className, loadingClassName, }: SmartScrollbarFillProps) { const { total, trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarLayoutContext(); - // marked is a mutated Set (same reference), so depend on .size to bust memo + // marked is a stable ref; version changing is what drives recomputation. const runs = useMemo( - () => getContiguousRuns(marked, total), - // eslint-disable-next-line react-hooks/exhaustive-deps - [marked.size, total] + () => computeContiguousRuns(marked), + [marked, version] ); if (runs.length === 0 || trackHeight === 0) return null; @@ -28,6 +29,7 @@ export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ const fillAreaTop = fillPadding; const fillAreaHeight = trackHeight - fillPadding * 2; const activeClass = isLoading && loadingClassName ? loadingClassName : className; + return ( <> {runs.map(run => { diff --git a/platform/ui-next/src/components/SmartScrollbar/index.ts b/platform/ui-next/src/components/SmartScrollbar/index.ts index 36bdd03e456..1137a80c54c 100644 --- a/platform/ui-next/src/components/SmartScrollbar/index.ts +++ b/platform/ui-next/src/components/SmartScrollbar/index.ts @@ -4,3 +4,5 @@ export { SmartScrollbarTrack } from './SmartScrollbarTrack'; export { SmartScrollbarFill } from './SmartScrollbarFill'; export { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; export { SmartScrollbarEndpoints } from './SmartScrollbarEndpoints'; +export { useByteArray } from './useByteArray'; +export type { ByteArrayHandle } from './useByteArray'; diff --git a/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts b/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts new file mode 100644 index 00000000000..84024fce095 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts @@ -0,0 +1,106 @@ +import debounce from 'lodash.debounce'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export interface ByteArrayHandle { + bytes: Uint8Array; + version: number; + /** True when every byte in the array is set (all positions marked). */ + isFull: boolean; + setByte: (index: number) => void; + clearByte: (index: number) => void; + resetWith: (populate: (bytes: Uint8Array) => void) => void; +} + +/** + * Manages a mutable Uint8Array (one byte per position) with React change + * detection via an incrementing version counter. + * + * @param size - Number of positions (e.g. total slices in a viewport). + * @param debounceMs - When > 0, version bumps are debounced by this many + * milliseconds. Byte writes are always immediate. Use for + * high-frequency sources (cache prefetch) to batch renders. + * Omit or pass 0 for immediate re-renders (e.g. viewed tracking). + */ +export function useByteArray(size: number, debounceMs = 0): ByteArrayHandle { + const bytesRef = useRef(new Uint8Array(size)); + const countRef = useRef(0); + const [version, setVersion] = useState(0); + + // Debounced bump — recreated when debounceMs changes; cancelled on unmount + // or when debounceMs changes, following the lodash.debounce pattern used + // throughout ui-next (InputFilter, CinePlayer). + const debouncedBump = useMemo( + () => (debounceMs > 0 ? debounce(() => setVersion(v => v + 1), debounceMs) : null), + [debounceMs] + ); + + useEffect(() => { + return () => debouncedBump?.cancel(); + }, [debouncedBump]); + + // Reset array only when size actually changes — skip on initial mount since + // bytesRef is already initialised to the correct size via useRef. + useEffect(() => { + if (bytesRef.current.length === size) return; + debouncedBump?.cancel(); + bytesRef.current = new Uint8Array(size); + countRef.current = 0; + setVersion(v => v + 1); + }, [size, debouncedBump]); + + const bump = useCallback(() => { + if (debouncedBump) { + debouncedBump(); + } else { + setVersion(v => v + 1); + } + }, [debouncedBump]); + + const setByte = useCallback( + (index: number) => { + const bytes = bytesRef.current; + if (index < 0 || index >= bytes.length || bytes[index] === 1) return; + bytes[index] = 1; + countRef.current++; + bump(); + }, + [bump] + ); + + const clearByte = useCallback( + (index: number) => { + const bytes = bytesRef.current; + if (index < 0 || index >= bytes.length || bytes[index] === 0) return; + bytes[index] = 0; + countRef.current--; + bump(); + }, + [bump] + ); + + const resetWith = useCallback( + (populate: (bytes: Uint8Array) => void) => { + const bytes = bytesRef.current; + bytes.fill(0); + populate(bytes); + let count = 0; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i]) count++; + } + countRef.current = count; + bump(); + }, + [bump] + ); + + return { + bytes: bytesRef.current, + version, + // countRef.current is read at render time (triggered by version bump) so + // it is always up to date when this value is consumed. + isFull: size > 0 && countRef.current === size, + setByte, + clearByte, + resetWith, + }; +} diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.ts b/platform/ui-next/src/components/SmartScrollbar/utils.ts index 97f2aae3a77..865999a091c 100644 --- a/platform/ui-next/src/components/SmartScrollbar/utils.ts +++ b/platform/ui-next/src/components/SmartScrollbar/utils.ts @@ -6,42 +6,31 @@ export interface ContiguousRun { } /** - * Given a Set of indices and a total count, returns contiguous runs - * sorted by start index. Each run includes metadata about whether - * it's the first/last run for border-radius decisions. + * Given a Uint8Array where each non-zero byte represents a set position, + * returns contiguous runs in a single O(n) pass. No sorting or heap + * allocations inside the loop. */ -export function getContiguousRuns( - indices: Set, - total: number -): ContiguousRun[] { - if (indices.size === 0) return []; - - const sorted = Array.from(indices).sort((a, b) => a - b); +export function computeContiguousRuns(bytes: Uint8Array): ContiguousRun[] { const runs: ContiguousRun[] = []; - let runStart = sorted[0]; - let runLength = 1; + const n = bytes.length; + let i = 0; + + while (i < n) { + while (i < n && bytes[i] === 0) i++; + if (i >= n) break; + + const start = i; + while (i < n && bytes[i] !== 0) i++; - for (let i = 1; i < sorted.length; i++) { - if (sorted[i] === sorted[i - 1] + 1) { - runLength++; - } else { - runs.push({ start: runStart, length: runLength, isFirst: false, isLast: false }); - runStart = sorted[i]; - runLength = 1; - } + runs.push({ start, length: i - start, isFirst: false, isLast: false }); } - runs.push({ start: runStart, length: runLength, isFirst: false, isLast: false }); - // Mark first and last if (runs.length > 0) { runs[0].isFirst = true; runs[runs.length - 1].isLast = true; } - // Filter to valid range and clamp lengths that extend past total - return runs - .filter(r => r.start >= 0 && r.start < total) - .map(r => ({ ...r, length: Math.min(r.length, total - r.start) })); + return runs; } /** diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 8353e1ebb0e..699d2a14a87 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -7,8 +7,8 @@ import { SmartScrollbarFill, SmartScrollbarIndicator, SmartScrollbarEndpoints, + useByteArray, } from './SmartScrollbar'; -import type { SmartScrollbarLayoutContextValue } from './SmartScrollbar'; import { ThemeWrapper } from './ThemeWrapper'; import { Command, @@ -287,4 +287,5 @@ export { SmartScrollbarFill, SmartScrollbarIndicator, SmartScrollbarEndpoints, + useByteArray, }; From bc80fae7be65cbdb5a1effcbd6ade5b0fee5b87b Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Wed, 1 Apr 2026 10:08:04 -0400 Subject: [PATCH 15/18] Render SmartScrollbar fill/endpoints in pixel space and align indicator to pixel buckets. --- .../SmartScrollbarEndpoints.tsx | 36 ++++++------ .../SmartScrollbar/SmartScrollbarFill.tsx | 23 ++++---- .../SmartScrollbarIndicator.tsx | 15 +++-- .../src/components/SmartScrollbar/utils.ts | 56 ++++++++++++++++--- 4 files changed, 91 insertions(+), 39 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx index 1d38e1c92e8..44c7bc82812 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; import { useSmartScrollbarLayoutContext } from './SmartScrollbar'; +import { computePixelFilledFromMarked } from './utils'; // ── Endpoint cap dimensions and color ─────────────────────────── const CAP_SIZE = 4; @@ -21,37 +22,38 @@ export const SmartScrollbarEndpoints = React.memo(function SmartScrollbarEndpoin version: _version, className, }: SmartScrollbarEndpointsProps) { - const { total, trackHeight, trackWidth, fillPadding, stableLayerEl } = + const { trackHeight, trackWidth, fillPadding, stableLayerEl } = useSmartScrollbarLayoutContext(); - // Scan for the first and last set byte in O(n) — much cheaper than a Set - // iteration and naturally gives us the two endpoints we need. - let minSlice = -1; - let maxSlice = -1; - for (let i = 0; i < marked.length; i++) { - if (marked[i]) { - minSlice = i; + const fillAreaTop = fillPadding; + const pixelCount = trackHeight - fillPadding * 2; + const pixelFilled = computePixelFilledFromMarked(marked, pixelCount); + + // Scan for the first and last filled pixel row in O(n) so endpoints align + // exactly with the fill rendering in pixel space. + let firstFilledPixel = -1; + let lastFilledPixel = -1; + for (let pixel = 0; pixel < pixelFilled.length; pixel++) { + if (pixelFilled[pixel]) { + firstFilledPixel = pixel; break; } } - for (let i = marked.length - 1; i >= 0; i--) { - if (marked[i]) { - maxSlice = i; + for (let pixel = pixelFilled.length - 1; pixel >= 0; pixel--) { + if (pixelFilled[pixel]) { + lastFilledPixel = pixel; break; } } - if (minSlice === -1 || trackHeight === 0 || !stableLayerEl) return null; - - const fillAreaTop = fillPadding; - const fillAreaHeight = trackHeight - fillPadding * 2; + if (firstFilledPixel === -1 || trackHeight === 0 || !stableLayerEl) return null; // Use trackWidth (always 8px) not effectiveWidth — endpoints must stay // stationary during contraction/expansion transitions. const cx = trackWidth / 2; const halfCap = CAP_SIZE / 2; - const topEdge = fillAreaTop + (minSlice / total) * fillAreaHeight; - const bottomEdge = fillAreaTop + ((maxSlice + 1) / total) * fillAreaHeight; + const topEdge = fillAreaTop + firstFilledPixel; + const bottomEdge = fillAreaTop + (lastFilledPixel + 1); // Portal into the stable layer so position isn't affected by the // contracting track div's width transition. diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx index 1e390df275e..18132125fa1 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { useSmartScrollbarLayoutContext } from './SmartScrollbar'; -import { computeContiguousRuns } from './utils'; +import { computeContiguousRuns, computePixelFilledFromMarked } from './utils'; interface SmartScrollbarFillProps { marked: Uint8Array; @@ -15,26 +15,27 @@ export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ className, loadingClassName, }: SmartScrollbarFillProps) { - const { total, trackHeight, effectiveWidth, fillPadding, isLoading } = + const { trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarLayoutContext(); - // marked is a stable ref; version changing is what drives recomputation. - const runs = useMemo( - () => computeContiguousRuns(marked), - [marked, version] - ); + const runs = useMemo(() => { + // Render fill in pixel space so the fill never overstates coverage when + // many indices map into a single pixel row (subpixel heights). + const pixelCount = trackHeight - fillPadding * 2; + const pixelFilled = computePixelFilledFromMarked(marked, pixelCount); + return computeContiguousRuns(pixelFilled); + }, [marked, version, trackHeight, fillPadding]); if (runs.length === 0 || trackHeight === 0) return null; const fillAreaTop = fillPadding; - const fillAreaHeight = trackHeight - fillPadding * 2; const activeClass = isLoading && loadingClassName ? loadingClassName : className; return ( <> {runs.map(run => { - const top = fillAreaTop + (run.start / total) * fillAreaHeight; - const height = (run.length / total) * fillAreaHeight; + const top = fillAreaTop + run.start; + const height = run.length; return (
diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx index af0dbb1ca2d..08fc375772b 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx @@ -17,7 +17,7 @@ export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorPr useSmartScrollbarLayoutContext(); const value = useSmartScrollbarScrollContext(); - if (trackHeight === 0) return null; + if (trackHeight === 0 || total <= 1) return null; const { totalWidth, totalHeight, fillWidth, fillHeight, leftPos } = getIndicatorLayout( effectiveWidth, @@ -27,9 +27,16 @@ export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorPr const offsetY = (totalHeight - INDICATOR_SIZE) / 2; const fillAreaTop = fillPadding; - const fillAreaHeight = trackHeight - fillPadding * 2; - const maxY = fillAreaHeight - INDICATOR_SIZE; - const y = fillAreaTop + (total <= 1 ? 0 : (value / (total - 1)) * maxY); + const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2)); + if (pixelCount === 0) return null; + + // Align the indicator with the item’s pixel bucket(s) so it sits “over” the + // same pixel-space mapping used by fill/endpoints. + const clampedValue = Math.max(0, Math.min(total - 1, value)); + const itemStartPx = Math.floor((clampedValue * pixelCount) / total); + const maxTopInFill = Math.max(0, pixelCount - INDICATOR_SIZE); + const topInFill = Math.max(0, Math.min(maxTopInFill, itemStartPx)); + const y = fillAreaTop + topInFill; return (
0) { - runs[0].isFirst = true; - runs[runs.length - 1].isLast = true; + return runs; +} + +/** + * Convert marked items (0/1 bytes) into a per-pixel fill mask (0/1 bytes). + * The result is conservative in the sense that a pixel row is filled only when + * its mapped items are all marked, so the fill never overstates coverage. + * + * - If `total >= pixelCount`: each pixel row maps to a disjoint item-index + * range; a pixel row is filled only if all items in that range are marked. + * - If `pixelCount > total`: each item spans multiple pixel rows; if the item + * is marked, its entire pixel span is filled. + */ +export function computePixelFilledFromMarked( + marked: Uint8Array, + pixelCount: number +): Uint8Array { + const total = marked.length; + const count = Math.max(0, Math.floor(pixelCount)); + if (count === 0 || total <= 0) return new Uint8Array(0); + + const pixelFilled = new Uint8Array(count); + + if (total >= count) { + for (let pixelIndex = 0; pixelIndex < count; pixelIndex++) { + const start = Math.floor((pixelIndex * total) / count); + const end = Math.floor(((pixelIndex + 1) * total) / count); + if (end <= start) continue; + + let filled = 1; + for (let itemIndex = start; itemIndex < end; itemIndex++) { + if (marked[itemIndex] === 0) { + filled = 0; + break; + } + } + pixelFilled[pixelIndex] = filled; + } + } else { + for (let itemIndex = 0; itemIndex < total; itemIndex++) { + if (marked[itemIndex] === 0) continue; + const topPx = Math.floor((itemIndex * count) / total); + const bottomPx = Math.floor(((itemIndex + 1) * count) / total); + for (let pixel = topPx; pixel < bottomPx; pixel++) { + pixelFilled[pixel] = 1; + } + } } - return runs; + return pixelFilled; } /** From cd702618d78f12627085c6b4f2941c99eb8957c9 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Wed, 1 Apr 2026 10:39:55 -0400 Subject: [PATCH 16/18] Minor changes to SmartScrollbar components. --- .../SmartScrollbar/SmartScrollbarEndpoints.tsx | 9 ++++++++- .../src/components/SmartScrollbar/SmartScrollbarFill.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx index 44c7bc82812..dc19ed2c025 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -10,6 +10,12 @@ const CAP_COLOR = 'hsl(var(--neutral) / 1.0)'; interface SmartScrollbarEndpointsProps { marked: Uint8Array; + /** + * Change token that MUST be bumped when the contents of `marked` change while + * the `marked` array reference stays the same (in-place mutation). + * + * Recommended: manage `marked` + `version` together via `useByteArray()`. + */ version: number; className?: string; } @@ -26,7 +32,8 @@ export const SmartScrollbarEndpoints = React.memo(function SmartScrollbarEndpoin useSmartScrollbarLayoutContext(); const fillAreaTop = fillPadding; - const pixelCount = trackHeight - fillPadding * 2; + const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2)); + if (pixelCount === 0) return null; const pixelFilled = computePixelFilledFromMarked(marked, pixelCount); // Scan for the first and last filled pixel row in O(n) so endpoints align diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx index 18132125fa1..552792fb7c0 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -4,6 +4,12 @@ import { computeContiguousRuns, computePixelFilledFromMarked } from './utils'; interface SmartScrollbarFillProps { marked: Uint8Array; + /** + * Change token that MUST be bumped when the contents of `marked` change while + * the `marked` array reference stays the same (in-place mutation). + * + * Recommended: manage `marked` + `version` together via `useByteArray()`. + */ version: number; className?: string; loadingClassName?: string; @@ -21,7 +27,7 @@ export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ const runs = useMemo(() => { // Render fill in pixel space so the fill never overstates coverage when // many indices map into a single pixel row (subpixel heights). - const pixelCount = trackHeight - fillPadding * 2; + const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2)); const pixelFilled = computePixelFilledFromMarked(marked, pixelCount); return computeContiguousRuns(pixelFilled); }, [marked, version, trackHeight, fillPadding]); From 1ae805a5d268e2591e126d00e497c528a941ebb6 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Wed, 1 Apr 2026 13:57:48 -0400 Subject: [PATCH 17/18] Add unit tests for SmartScrollbar utils functions computePixelFilledFromMarked and computeContiguousRuns. --- platform/ui-next/jest.config.js | 10 +++ platform/ui-next/package.json | 2 + .../components/SmartScrollbar/utils.test.ts | 69 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 platform/ui-next/jest.config.js create mode 100644 platform/ui-next/src/components/SmartScrollbar/utils.test.ts diff --git a/platform/ui-next/jest.config.js b/platform/ui-next/jest.config.js new file mode 100644 index 00000000000..b34c25aafd0 --- /dev/null +++ b/platform/ui-next/jest.config.js @@ -0,0 +1,10 @@ +const base = require('../../jest.config.base.js'); +const pkg = require('./package'); + +module.exports = { + ...base, + displayName: pkg.name, + + // Override the base setting that transforms node_modules. + transformIgnorePatterns: ['/node_modules/'], +}; diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index b87a774a4ca..90ba3e1bbb5 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -17,6 +17,8 @@ "start": "yarn run build --watch", "dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.playground.js", "test": "echo \"Error: no test specified\" && exit 1", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage", "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", "build:package": "yarn run build" }, diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.test.ts b/platform/ui-next/src/components/SmartScrollbar/utils.test.ts new file mode 100644 index 00000000000..db0035550e5 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/utils.test.ts @@ -0,0 +1,69 @@ +import { computeContiguousRuns, computePixelFilledFromMarked } from './utils'; + +function u8(values: number[]): Uint8Array { + return new Uint8Array(values); +} + +describe('computePixelFilledFromMarked', () => { + it('returns empty when pixelCount <= 0', () => { + expect(computePixelFilledFromMarked(u8([1, 1, 1]), 0)).toEqual(new Uint8Array(0)); + expect(computePixelFilledFromMarked(u8([1, 1, 1]), -10)).toEqual(new Uint8Array(0)); + }); + + it('returns empty when marked is empty', () => { + expect(computePixelFilledFromMarked(new Uint8Array(0), 10)).toEqual(new Uint8Array(0)); + }); + + it('downsamples conservatively when total >= pixelCount (AND within each bucket)', () => { + // total=7, pixelCount=3 => buckets [0..2), [2..4), [4..7) (requires rounding) + const marked = u8([1, 1, 1, 0, 1, 1, 1]); + const result = computePixelFilledFromMarked(marked, 3); + expect(Array.from(result)).toEqual([1, 0, 1]); + }); + + it('upsamples by filling the full pixel span of each marked item when pixelCount > total', () => { + // total=4, pixelCount=7 => + // item 0 spans pixels [0..1) (requires rounding) + // item 2 spans pixels [3..5) (requires rounding) + const marked = u8([1, 0, 1, 0]); + const result = computePixelFilledFromMarked(marked, 7); + expect(Array.from(result)).toEqual([1, 0, 0, 1, 1, 0, 0]); + }); + + it('uses Math.floor(pixelCount) for output length (downsample branch)', () => { + const marked = u8([1, 0, 1, 0]); + expect(computePixelFilledFromMarked(marked, 3.9)).toHaveLength(3); + expect(computePixelFilledFromMarked(marked, 3.1)).toHaveLength(3); + }); + + it('uses Math.floor(pixelCount) for output length (upsample branch)', () => { + const marked = u8([1, 0, 1]); + expect(computePixelFilledFromMarked(marked, 7.9)).toHaveLength(7); + expect(computePixelFilledFromMarked(marked, 7.1)).toHaveLength(7); + }); +}); + +describe('computeContiguousRuns', () => { + it('returns [] for empty input', () => { + expect(computeContiguousRuns(new Uint8Array(0))).toEqual([]); + }); + + it('returns [] when all bytes are zero', () => { + expect(computeContiguousRuns(u8([0, 0, 0, 0]))).toEqual([]); + }); + + it('returns a single run when all bytes are non-zero', () => { + expect(computeContiguousRuns(u8([1, 1, 2, 3]))).toEqual([{ start: 0, length: 4 }]); + }); + + it('finds runs with leading/trailing zeros', () => { + expect(computeContiguousRuns(u8([0, 0, 1, 1, 0, 2, 0, 0]))).toEqual([ + { start: 2, length: 2 }, + { start: 5, length: 1 }, + ]); + }); + + it('treats any non-zero byte as filled', () => { + expect(computeContiguousRuns(u8([0, 255, 128, 0]))).toEqual([{ start: 1, length: 2 }]); + }); +}); From a62f792aae4c5d4f2cad06d7bd86fac9e46216f6 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso Date: Wed, 1 Apr 2026 14:58:12 -0400 Subject: [PATCH 18/18] SmartScrollbar: use stable fill keys and expand pixel-fill rounding tests --- .../SmartScrollbar/SmartScrollbarFill.tsx | 7 +++---- .../src/components/SmartScrollbar/utils.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx index 552792fb7c0..92dbb752b35 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -21,8 +21,7 @@ export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ className, loadingClassName, }: SmartScrollbarFillProps) { - const { trackHeight, effectiveWidth, fillPadding, isLoading } = - useSmartScrollbarLayoutContext(); + const { trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarLayoutContext(); const runs = useMemo(() => { // Render fill in pixel space so the fill never overstates coverage when @@ -39,13 +38,13 @@ export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ return ( <> - {runs.map(run => { + {runs.map((run, index) => { const top = fillAreaTop + run.start; const height = run.length; return (
{ expect(Array.from(result)).toEqual([1, 0, 1]); }); + it('downsamples conservatively with uneven bucket sizes (requires rounding)', () => { + // total=5, pixelCount=4 => buckets [0..1), [1..2), [2..3), [3..5) + const marked = u8([1, 1, 1, 1, 0]); + const result = computePixelFilledFromMarked(marked, 4); + expect(Array.from(result)).toEqual([1, 1, 1, 0]); + }); + it('upsamples by filling the full pixel span of each marked item when pixelCount > total', () => { // total=4, pixelCount=7 => // item 0 spans pixels [0..1) (requires rounding) @@ -30,6 +37,13 @@ describe('computePixelFilledFromMarked', () => { expect(Array.from(result)).toEqual([1, 0, 0, 1, 1, 0, 0]); }); + it('upsamples correctly when a marked item maps to a single pixel row', () => { + // total=6, pixelCount=7 => items 0..4 each map to exactly 1 pixel; item 5 maps to 2 pixels. + const marked = u8([0, 0, 0, 1, 0, 1]); // item 3 => pixel 3; item 5 => pixels 5-6 + const result = computePixelFilledFromMarked(marked, 7); + expect(Array.from(result)).toEqual([0, 0, 0, 1, 0, 1, 1]); + }); + it('uses Math.floor(pixelCount) for output length (downsample branch)', () => { const marked = u8([1, 0, 1, 0]); expect(computePixelFilledFromMarked(marked, 3.9)).toHaveLength(3);