terminal: snappier snap-to-bottom — 140ms rAF animation (#400 follow-up)

Mara: *can we make this really fast to not be a problem? like its
okay if it is pretty fast*.

`scrollTo({ behavior: 'smooth' })` runs ~500ms in Chromium/Firefox
— "still smooth, but visibly slow." Swap it for a custom rAF loop
with an ease-out cubic over 140ms. Distances under 24px short-
circuit to instant — animating a 12px nudge is just jitter.

Each call cancels the previous rAF before starting a new one, so a
burst of mutations coalesces into one ride to the latest bottom
instead of two animations fighting over scrollTop.

Re-reads `scrollHeight - clientHeight` each frame so a renderer
mutation landing mid-animation (the common case — `api.row`
returned, then renderer appended badge + body) extends the
destination smoothly rather than landing short.

`smoothScrollingUntil` gate shrinks accordingly (140 + 80 = 220ms)
— still protects the scroll handler from flipping stickToBottom
on the intermediate scroll events the rAF fires.
This commit is contained in:
iris 2026-05-25 21:01:58 +02:00 committed by Mara
commit 502e9e0e70

View file

@ -50,6 +50,14 @@
// count=0); pages use it to set state flags from the replayed history.
const NEAR_BOTTOM_PX = 48;
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
// as "still smooth, but visibly slow." 140ms with ease-out is fast
// enough to feel snap-y, slow enough that the row's destination
// reads as motion (not a jump). Distances under SCROLL_SNAP_PX
// short-circuit to instant — animating a 12px nudge is just jitter.
const SCROLL_ANIM_MS = 140;
const SCROLL_SNAP_PX = 24;
export function create(opts) {
const log = opts.logEl;
@ -70,39 +78,69 @@ export function create(opts) {
// operator-driven wheel/drag stay in sync.
let stickToBottom = true;
// Guards scroll-event-handler from misreading the position while
// our own smooth-scroll animation is mid-flight (#400). When we
// initiate `scrollTo({ behavior: 'smooth' })`, the browser fires
// a stream of scroll events as it eases toward the target — the
// position passes through "not near bottom" before settling at
// bottom. Without this gate, the scroll handler flips
// `stickToBottom` to false mid-animation, which then causes the
// MutationObserver to skip the next snap and leaves the operator
// stranded mid-scroll. The gate is released on a timeout sized
// to outlast the smooth-scroll animation (~500ms in Chromium /
// Firefox; 800ms gives comfortable headroom). Each fresh snap
// re-arms the timer so back-to-back snaps stay gated.
// our own animation is mid-flight (#400). The animation drives
// scrollTop with rAF, which fires a stream of scroll events as
// the position eases toward the target — the position passes
// through "not near bottom" before settling. Without this gate,
// the scroll handler flips `stickToBottom` to false mid-animation,
// which then causes the MutationObserver to skip the next snap
// and leaves the operator stranded mid-scroll. Set to the
// animation's nominal end + small headroom; each fresh snap
// re-arms it so back-to-back snaps stay gated.
let smoothScrollingUntil = 0;
// rAF id for the current snap animation. Cancelled when a new
// snap starts so we never have two animations fighting over
// scrollTop.
let scrollAnimRaf = 0;
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
}
// Smooth-scroll the log to the bottom (#400 — replaces the
// instant `scrollTop = scrollHeight` jumps with eased animation).
// Each call re-arms the smooth-scrolling gate so back-to-back
// burst writes coalesce into one smooth ride to the new bottom
// instead of a sequence of half-cancelled animations. Falls back
// to instant scroll when `currentNoAnim` is true (backfill replay
// — the operator never sees the intermediate positions so the
// animation is just wasted frames + jitter).
// Snap the log to the bottom with a brief eased animation
// (#400 + mara: snappier than the browser's default 500ms smooth
// scroll). Each call cancels the previous frame loop and starts a
// fresh one, so a burst of mutations coalesces into one ride to
// the latest bottom. Re-evaluates the target each frame so a
// renderer mutation landing mid-animation extends the destination
// without a visible jump. Falls back to instant scroll when
// `currentNoAnim` is true (backfill replay — operator never sees
// intermediate positions, animation is wasted frames) or when the
// remaining distance is under SCROLL_SNAP_PX.
function snapToBottom(immediate) {
stickToBottom = true;
if (immediate || currentNoAnim) {
if (scrollAnimRaf) {
cancelAnimationFrame(scrollAnimRaf);
scrollAnimRaf = 0;
}
const target = log.scrollHeight - log.clientHeight;
const start = log.scrollTop;
const distance = target - start;
if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) {
smoothScrollingUntil = 0;
log.scrollTop = log.scrollHeight;
log.scrollTop = target;
return;
}
smoothScrollingUntil = Date.now() + 800;
log.scrollTo({ top: log.scrollHeight, behavior: 'smooth' });
smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80;
const t0 = performance.now();
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
const step = (now) => {
const elapsed = now - t0;
const frac = Math.min(1, elapsed / SCROLL_ANIM_MS);
// Re-read target each frame so mutations landing mid-animation
// (the common case — a renderer appended badge / body bits
// after api.row returned) extend the destination smoothly
// rather than landing short.
const currentTarget = log.scrollHeight - log.clientHeight;
log.scrollTop = start + (currentTarget - start) * easeOut(frac);
if (frac < 1) {
scrollAnimRaf = requestAnimationFrame(step);
} else {
// Final exact settle on the as-of-now bottom.
log.scrollTop = log.scrollHeight - log.clientHeight;
scrollAnimRaf = 0;
}
};
scrollAnimRaf = requestAnimationFrame(step);
}
function ensurePill() {
if (pill) return pill;