diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index af13b07ba0..c821c2393b 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -8,7 +8,7 @@ pointer-events: none; } -.rail { +.frame { /* The band a reader actually sees: the scrollport minus the sticky composer stack covering its floor. ConversationRoot publishes both measurements on the scrollport; the fallbacks carry the first paint before its observer @@ -24,9 +24,9 @@ padding, so the rail gives that inset back and keeps 12px of its own. */ right: calc(12px - (var(--dsh-composer-side-clearance) + 16px)); width: 28px; - /* Never taller than the band it centers in: a short window (a tall composer, - a low viewport) shrinks the rail instead of pushing marks under the - composer or above the scrollport. */ + /* Fixed-pitch marks never compress: a ladder taller than the band scrolls + inside this frame instead of pushing marks under the composer or above + the scrollport. */ height: min( var(--turn-natural-height), max(0px, calc(var(--turn-rail-band) - 64px)), @@ -38,14 +38,46 @@ transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } -.marks { +/* The frame's inner scroller: no visible scrollbar, no scroll chaining into + the transcript, and gradient fades over the ends that can still scroll. */ +.scroller { position: absolute; - inset: var(--turn-rail-inset) 0; + inset: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; +} + +.scroller::-webkit-scrollbar { + display: none; +} + +.fadeTop { + mask-image: linear-gradient(to bottom, transparent 0, #000 24px, #000 100%); +} + +.fadeBottom { + mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%); +} + +.fadeTop.fadeBottom { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 24px, + #000 calc(100% - 24px), + transparent 100% + ); +} + +.marks { + position: relative; + height: var(--turn-natural-height); } .markPosition { position: absolute; - top: min(var(--turn-natural-position), var(--turn-position)); + top: calc(var(--turn-natural-position) + var(--turn-rail-inset)); right: 0; left: 0; height: 10px; @@ -118,13 +150,14 @@ .preview { position: absolute; - /* Centered on its mark (mark positions are measured inside the rail inset), - then held clear of both rail ends. */ + /* Centered on its mark. Mark positions live in the scrolled ladder, so the + frame-level preview subtracts the scroller's offset, then holds clear of + both frame ends. */ top: clamp( 0px, calc( - min(var(--turn-natural-position), var(--turn-position)) - + var(--turn-rail-inset) - var(--turn-preview-height) / 2 + var(--turn-natural-position) + var(--turn-rail-inset) + - var(--turn-scroll-top, 0px) - var(--turn-preview-height) / 2 ), calc(100% - var(--turn-preview-height)) ); @@ -186,12 +219,14 @@ } @media (prefers-reduced-motion: reduce) { - .rail, + .frame, + .scroller, .markPosition, .mark::before, .markBusy::before, .preview { transition: none; animation: none; + scroll-behavior: auto; } } diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index 3eef55269a..f56b3ab3f3 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -1,5 +1,6 @@ import { - memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, + memo, useEffect, useId, useRef, useState, + type CSSProperties, type MouseEvent, type PointerEvent, } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' import type { TurnRailItem } from './turn-rail-items.ts' @@ -14,102 +15,184 @@ interface TurnNavigatorProps { readonly t: ChatViewSlotProps['t'] } -/** Resting gap between neighbouring marks before the rail compresses to fit. */ +/** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */ const TURN_SPACING_PX = 10 /** Rail padding above the first mark and below the last one, per end. */ const RAIL_INSET_PX = 6 +/** Fade band the mask reserves at a scrollable end. */ +const FADE_PX = 24 type TurnPositionStyle = CSSProperties & { readonly '--turn-natural-position': string - readonly '--turn-position': string } -type TurnRailStyle = CSSProperties & { +type TurnFrameStyle = CSSProperties & { readonly '--turn-natural-height': string readonly '--turn-rail-inset': string + readonly '--turn-scroll-top': string } -function itemPosition(index: number, count: number): TurnPositionStyle { - const ratio = count <= 1 ? 0 : index / (count - 1) - return { - '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px`, - '--turn-position': `${String(ratio * 100)}%`, - } +function itemPosition(index: number): TurnPositionStyle { + return { '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px` } } -function railSize(count: number): TurnRailStyle { +function frameStyle(count: number, scrollTop: number): TurnFrameStyle { return { '--turn-natural-height': `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`, '--turn-rail-inset': `${String(RAIL_INSET_PX)}px`, + '--turn-scroll-top': `${String(scrollTop)}px`, } } function itemAtPointer( items: readonly TurnRailItem[], - rail: HTMLElement, + frame: HTMLElement, + scrollTop: number, clientY: number, ): TurnRailItem | undefined { - const rect = rail.getBoundingClientRect() - const usableHeight = Math.max(1, rect.height - 2 * RAIL_INSET_PX) - const ratio = Math.max(0, Math.min(1, (clientY - rect.top - RAIL_INSET_PX) / usableHeight)) - return items[Math.round(ratio * (items.length - 1))] + const rect = frame.getBoundingClientRect() + const offset = clientY - rect.top + scrollTop - RAIL_INSET_PX + const index = Math.max(0, Math.min(items.length - 1, Math.round(offset / TURN_SPACING_PX))) + return items[index] +} + +/** Scroll state the mask fades and follow logic read together. */ +interface RailScrollState { + readonly top: number + readonly canScrollUp: boolean + readonly canScrollDown: boolean +} + +const RAIL_AT_REST: RailScrollState = { top: 0, canScrollUp: false, canScrollDown: false } + +function railScrollState(scroller: HTMLElement): RailScrollState { + const top = scroller.scrollTop + return { + top, + canScrollUp: top > 1, + canScrollDown: top < scroller.scrollHeight - scroller.clientHeight - 1, + } +} + +function sameRailScrollState(left: RailScrollState, right: RailScrollState): boolean { + return left.top === right.top + && left.canScrollUp === right.canScrollUp + && left.canScrollDown === right.canScrollDown } function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnNavigatorProps) { const [previewTurn, setPreviewTurn] = useState(null) + const [scrollState, setScrollState] = useState(RAIL_AT_REST) + const scrollerRef = useRef(null) + /** While the pointer works the rail, follow must not move it under the hand. */ + const pointerInsideRef = useRef(false) const previewId = useId() + + const syncScrollState = (): void => { + const scroller = scrollerRef.current + if (scroller === null) return + const next = railScrollState(scroller) + setScrollState(current => sameRailScrollState(current, next) ? current : next) + } + + // Frame resizes (band/composer changes) move the overflow edges without a + // scroll event; item count changes move the content height the same way. + useEffect(() => { + const scroller = scrollerRef.current + if (scroller === null || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(syncScrollState) + observer.observe(scroller) + return () => { observer.disconnect() } + }, []) + useEffect(syncScrollState, [items.length]) + + // Keep the active mark visible: centre it whenever it leaves the scrollport, + // unless the reader's pointer is working the rail. + useEffect(() => { + const scroller = scrollerRef.current + const index = items.findIndex(item => item.turn === activeTurn) + if (scroller === null || index < 0 || pointerInsideRef.current) return + const markTop = index * TURN_SPACING_PX + RAIL_INSET_PX + const viewTop = scroller.scrollTop + const viewHeight = scroller.clientHeight + if (viewHeight <= 0 || (markTop >= viewTop + FADE_PX && markTop <= viewTop + viewHeight - FADE_PX)) return + const target = Math.max(0, markTop - viewHeight / 2) + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + if (typeof scroller.scrollTo === 'function') { + scroller.scrollTo({ top: target, behavior: reduced ? 'auto' : 'smooth' }) + } else { + scroller.scrollTop = target + } + syncScrollState() + }, [activeTurn, items]) + if (items.length < 2) return null const previewIndex = items.findIndex(item => item.turn === previewTurn) const preview = previewIndex < 0 ? undefined : items[previewIndex] - const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex, items.length) + const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex) const previewAtPointer = (event: PointerEvent): void => { - setPreviewTurn(itemAtPointer(items, event.currentTarget, event.clientY)?.turn ?? null) + const scrollTop = scrollerRef.current?.scrollTop ?? 0 + setPreviewTurn(itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)?.turn ?? null) } const navigateAtPointer = (event: MouseEvent): void => { - const item = itemAtPointer(items, event.currentTarget, event.clientY) + const scrollTop = scrollerRef.current?.scrollTop ?? 0 + const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY) if (item !== undefined) onNavigate(item) } + const fadeClasses = [css.scroller] + if (scrollState.canScrollUp) fadeClasses.push(css.fadeTop) + if (scrollState.canScrollDown) fadeClasses.push(css.fadeBottom) return (