diff --git a/src/client/app.css b/src/client/app.css index 3e82d7b..4a078b5 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -1536,33 +1536,57 @@ body.selecting #selectBtn { padding: 1px 5px; } +/* Anchored to the top edge of the composer (see .dockinner), so it holds still + while the thread scrolls under it and stays put as the composer grows. Mono + and small, like the other meta labels — it's a hint, not an action to take. */ .jump { position: absolute; left: 50%; - transform: translateX(-50%); - bottom: 14px; - width: 34px; - height: 34px; - display: none; + bottom: 100%; + margin-bottom: 10px; + display: inline-flex; align-items: center; - justify-content: center; + gap: 5px; + padding: 5px 11px 5px 9px; + font-family: var(--mono); + font-size: 11.5px; + white-space: nowrap; color: var(--ink-muted); background: var(--bg-raise); border: 1px solid var(--rule-strong); border-radius: var(--radius-pill); cursor: pointer; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.14); + /* Hidden by default: it fades in rather than popping, and stays out of the + way of clicks (and the tab order) while it's away. */ + opacity: 0; + visibility: hidden; + transform: translate(-50%, 6px); transition: + opacity 0.14s ease, + transform 0.14s ease, + visibility 0.14s, background 0.12s ease, color 0.12s ease; } +.jump.show { + opacity: 1; + visibility: visible; + transform: translate(-50%, 0); +} .jump:hover { background: var(--bg-sunk); color: var(--ink); } .jump svg { - width: 18px; - height: 18px; + width: 13px; + height: 13px; + opacity: 0.7; +} +@media (prefers-reduced-motion: reduce) { + .jump { + transition: none; + } } /* ---- composer dock ----------------------------------------------------- */ @@ -1571,6 +1595,7 @@ body.selecting #selectBtn { background: var(--bg); } .dockinner { + position: relative; /* the jump button hangs off its top edge */ max-width: var(--measure); margin: 0 auto; padding: 8px 16px 16px; diff --git a/src/client/app.js b/src/client/app.js index aa99943..333f334 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -2455,12 +2455,58 @@ import { mountSidebar } from "./sidebar.js"; }); scroll.addEventListener("scroll", function () { atBottom = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40; - jump.style.display = atBottom ? "none" : "flex"; + jump.classList.toggle("show", !atBottom); }); + // Glide to the end rather than teleporting, so it's clear the thread moved + // rather than swapped — but on our clock, not the browser's. Native smooth + // scrolling paces itself by distance and takes well over a second down a long + // conversation. Streaming's own autoScroll stays instant; a tween per flush + // would fight itself. + // Snappy off the line, and distance barely lengthens it: a screen away and a + // hundred screens away should both feel like one flick. + var JUMP_BASE_MS = 80; + var JUMP_MS_PER_PX = 0.06; + var JUMP_MAX_MS = 500; + var jumpAnim = null; + function glideToBottom() { + if (jumpAnim) cancelAnimationFrame(jumpAnim); + jumpAnim = null; + var from = scroll.scrollTop; + var dist = scroll.scrollHeight - scroll.clientHeight - from; + if (dist <= 0 || matchMedia("(prefers-reduced-motion: reduce)").matches) { + scroll.scrollTop = scroll.scrollHeight; + return; + } + // Short hops shouldn't take as long as long ones, but nothing takes longer + // than the ceiling. + var ms = Math.min(JUMP_MAX_MS, JUMP_BASE_MS + dist * JUMP_MS_PER_PX); + var t0 = null; + jumpAnim = requestAnimationFrame(function step(now) { + if (t0 === null) t0 = now; + var p = Math.min(1, (now - t0) / ms); + var eased = 1 - (1 - p) ** 3; // easeOutCubic, same as the context gauge + // Re-read the end each frame: a live run can extend the thread mid-flight. + scroll.scrollTop = from + (scroll.scrollHeight - scroll.clientHeight - from) * eased; + if (p < 1) jumpAnim = requestAnimationFrame(step); + else { + jumpAnim = null; + scroll.scrollTop = scroll.scrollHeight; + } + }); + } + // Our own scrollTop writes fire `scroll`, so a wheel/touch is how we tell that + // the user changed their mind. Give way immediately when they do. + function cancelGlide() { + if (!jumpAnim) return; + cancelAnimationFrame(jumpAnim); + jumpAnim = null; + } + scroll.addEventListener("wheel", cancelGlide, { passive: true }); + scroll.addEventListener("touchstart", cancelGlide, { passive: true }); jump.addEventListener("click", function () { atBottom = true; - jump.style.display = "none"; - scroll.scrollTop = scroll.scrollHeight; + jump.classList.remove("show"); + glideToBottom(); }); // Keep the view pinned to the bottom while `atBottom` as the thread grows — // opening a large conversation, async enrich (code/math), and images all add diff --git a/src/client/index.html b/src/client/index.html index 2277b24..0b142bc 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -65,11 +65,14 @@