/** * Geometry snapshot for a scrollable chat viewport. */ export type ScrollGeometry = { scrollTop: number; scrollHeight: number; clientHeight: number; }; /** * The decision a scroll event implies for follow-bottom state. */ export type FollowBottomScrollDecision = { nextFollowingBottom: boolean; shouldEndLayoutPreservation: boolean; shouldScheduleSnapToBottom: boolean; }; export type MovementAttributionArgs = { userScrolling: boolean; preservingLayout: boolean; /** * Whether the movement landed on a clamp boundary (scrollTop 0 or * maxScroll). Browser-driven movement during layout turbulence is always * a clamp, and clamps can only land on boundaries — so a mid-list * landing is user movement no matter what else is in flight. */ landedOnClampBoundary: boolean; }; export type DecideFollowBottomAfterScrollArgs = MovementAttributionArgs & { atBottom: boolean; wasFollowingBottom: boolean; /** * Whether this event moved the viewport upward. Arrivals in the * at-bottom zone only re-engage follow when they are not upward: you * can only genuinely return to the bottom by moving down (or staying * put), while an upward step that happens to land inside the threshold * — e.g. a 40px ArrowUp against the 48px default — must not be * re-captured after the caller deliberately disengaged (#50). */ movedUp: boolean; followBottomThresholdPx: number; /** * Cumulative upward scrollTop travel (px) since the last non-upward * arrival at the bottom, counting only user-attributable movement — see * `accumulateUpwardTravel`, which owns the transition rule. This — not * the distance from the bottom — is the user's actual displacement: * content growth can manufacture an arbitrarily large bottom gap out of * a trivial (or nonexistent) gesture. */ upwardTravelPx: number; }; export type AccumulateUpwardTravelArgs = MovementAttributionArgs & { previousScrollTop: number; scrollTop: number; atBottom: boolean; upwardTravelPx: number; }; export declare const getMaxScroll: (geometry: Pick) => number; export declare const isViewportAtBottom: (geometry: ScrollGeometry, followBottomThresholdPx: number) => boolean; /** * Attribute scroll movement to the user when they are actually giving * input, when no layout change is in flight, or when the movement landed * somewhere only a user could put it. * * During layout turbulence the browser moves scrollTop on its own — e.g. a * snap write bouncing through a momentarily zero-scrollable boundary while * a CSS transition grows a message — and that must not read as "the user * scrolled". But those browser moves are clamps, and clamps can only land * on a boundary (0 or maxScroll): a mid-list landing during turbulence is * programmatic user navigation (scrollTo/scrollBy without input events) and * must not be yanked back by follow-bottom. * * The caller accumulating `upwardTravelPx` and this module's unfollow * decision must share this predicate, so it lives here, once. */ export declare const isMovementAttributableToUser: ({ userScrolling, preservingLayout, landedOnClampBoundary }: MovementAttributionArgs) => boolean; /** * Advance the upward-travel accumulator for one scroll event. * * Upward user-attributable movement always accumulates, inside or outside * the follow threshold: resetting within the threshold let the component's * own snap-backs erase the trail, so a sustained slow programmatic scroll * (no scroll intent to suppress the snap) could never accumulate past the * threshold and unfollow — a livelock at the bottom (#45). * * Only a non-upward arrival at the bottom wipes the slate: the component's * snap-backs, downward user scrolls, and clamps. Layout-turbulence movement * (see `isMovementAttributableToUser`) never accumulates. */ export declare const accumulateUpwardTravel: (args: AccumulateUpwardTravelArgs) => number; export declare const decideFollowBottomAfterScroll: (args: DecideFollowBottomAfterScrollArgs) => FollowBottomScrollDecision;