From 7b769e97aca29ad436bfe9b43233c38a2944c3a2 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 11 Sep 2026 13:06:16 +1000 Subject: [PATCH 1/6] feat(web): animate pinned threads with a subtle teardrop Pinning a thread in the sidebar now moves its row into Pinned along a short curved path instead of a straight glide. The row stays opaque and above its neighbours while it moves, an interrupted pin continues from its current curved position, and reduced motion skips the animation. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/src/components/Sidebar.motion.test.ts | 91 ++++++++++++++++++- apps/web/src/components/Sidebar.motion.ts | 72 ++++++++++++--- apps/web/src/components/Sidebar.tsx | 5 +- apps/web/src/sidebarPinPath.test.ts | 28 ++++++ apps/web/src/sidebarPinPath.ts | 50 ++++++++++ 5 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/sidebarPinPath.test.ts create mode 100644 apps/web/src/sidebarPinPath.ts diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 9e92c4d2c76e..442c46025d53 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -39,6 +39,9 @@ class TestRow { getBoundingClientRect() { return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight }; } + getAttribute(name: string) { + return this.attributes.find((attribute) => attribute.name === name)?.value ?? null; + } setAttribute(name: string, value: string) { this.removeAttribute(name); this.attributes.push({ name, value }); @@ -96,7 +99,10 @@ function fixture(rows: TestRow[]) { function expectMove(row: TestRow, offset: number) { expect(row.animate).toHaveBeenLastCalledWith( - [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], + [ + { transform: `translate(0px, ${offset}px)`, offset: 0 }, + { transform: "translate(0px, 0px)", offset: 1 }, + ], { duration: 150, easing: "ease-out" }, ); } @@ -542,3 +548,86 @@ describe("sidebar list motion", () => { expectMove(b, -83); }); }); + +describe("pinning motion", () => { + it("retargets a pin from its curved XY position during another layout change", () => { + const first = new TestRow("first"); + const pin = new TestRow("pin"); + const inserted = new TestRow("inserted"); + const { motion, layout } = fixture([first, pin]); + motion.update(true); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, first]); + motion.update(true); + const frames = pin.animate.mock.lastCall![0]; + // The first curve finishes at the 6px dip and 7.68px horizontal bow. + pin.animations[0]!.progress = Number(frames[80]!.offset); + layout([inserted, pin, first]); + motion.update(true); + expect(pin.animate.mock.lastCall![0][0]).toEqual({ + transform: "translate(7.68px, 6px)", + offset: 0, + zIndex: 20, + backgroundColor: "var(--sidebar)", + }); + expect(pin.animate.mock.lastCall![1].duration).toBe(150); + expect(pin.animations[0]!.cancel).toHaveBeenCalledOnce(); + // A second interruption retains the X carried into the ordinary glide. + pin.animations[1]!.progress = 0.5; + layout([pin, inserted, first]); + motion.update(true); + expect(pin.animate.mock.lastCall![0][0]).toEqual({ + transform: "translate(3.84px, 86px)", + offset: 0, + zIndex: 20, + backgroundColor: "var(--sidebar)", + }); + }); + + it("fades a removed pin from its curved position instead of its linear estimate", () => { + const first = new TestRow("first"); + const pin = new TestRow("pin"); + const { motion, layout } = fixture([first, pin]); + motion.update(true); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, first]); + motion.update(true); + pin.animations[0]!.progress = Number(pin.animate.mock.lastCall![0][80]!.offset); + layout([first]); + motion.update(true); + expect(pin.clones[0]!.style.top).toBe("97px"); + expect(pin.clones[0]!.style.left).toBe("11.68px"); + }); + + it("flies a newly pinned row from its old location into the pinned slot", () => { + const first = new TestRow("first"); + const pinned = new TestRow("pinned"); + const { motion, layout } = fixture([first, pinned]); + motion.update(false); + pinned.setAttribute("data-thread-pinned", "true"); + layout([pinned, first]); + motion.update(true); + const [frames, timing] = pinned.animate.mock.lastCall!; + expect(timing.duration).toBe(550); + expect(frames[0]?.transform).toBe("translate(0px, 83px)"); + expect(frames.at(-1)?.transform).toBe("translate(0px, 0px)"); + expectMove(first, -83); + pinned.animations[0]!.finish(); + pinned.setAttribute("data-thread-pinned", "false"); + layout([first, pinned]); + motion.update(true); + expectMove(pinned, -83); + }); + + it("does not fly pins when reduced motion is enabled", () => { + const first = new TestRow("first"); + const pinned = new TestRow("pinned"); + const { motion, layout, media } = fixture([first, pinned]); + motion.update(false); + media.matches = true; + pinned.setAttribute("data-thread-pinned", "true"); + layout([pinned, first]); + motion.update(true); + expect(pinned.animate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index dba6072e9d01..a50cb45508ee 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,3 +1,5 @@ +import { sidebarPinPath } from "../sidebarPinPath"; + const motionTiming = { duration: 150, easing: "ease-out" }; // Rows normally ride their displaced neighbour's travel. Absent a moving // neighbour, a row still travels on its own, clamped so a tall card does not @@ -10,7 +12,7 @@ const rowTravel = (height: number) => Math.min(height, 40); // so only the fade count decides whether an update animates. const MAX_FADED_ROWS_PER_UPDATE = 40; -type RowPosition = { top: number; left: number; width: number; height: number }; +type RowPosition = { top: number; left: number; width: number; height: number; pinned: boolean }; function progress(animation: Animation) { return animation.playState === "finished" @@ -26,7 +28,14 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( "(prefers-reduced-motion: reduce)", ); - const running = new Map(); + const running = new Map< + HTMLElement, + { + animation: Animation; + path: { x: number; y: number; offset: number }[]; + pinVisual: boolean; + } + >(); const entering = new Map(); const exiting = new Map(); // Visual tops at drag release, relative to the list, so the release @@ -35,10 +44,21 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const remainingOffset = (node: HTMLElement) => { const current = running.get(node); - const run = current ? current.offset * (1 - progress(current.animation)) : 0; const entry = entering.get(node); const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0; - return run + enter; + if (!current) return { x: 0, y: enter }; + const elapsed = progress(current.animation); + const afterIndex = current.path.findIndex((point) => point.offset >= elapsed); + const after = current.path[afterIndex === -1 ? current.path.length - 1 : afterIndex]!; + const before = current.path[Math.max(0, afterIndex - 1)]!; + const fraction = + after.offset === before.offset + ? 0 + : (elapsed - before.offset) / (after.offset - before.offset); + return { + x: before.x + (after.x - before.x) * fraction, + y: before.y + (after.y - before.y) * fraction + enter, + }; }; const clearFades = () => { for (const entry of entering.values()) entry.animation.cancel(); @@ -65,10 +85,11 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } clone.setAttribute("aria-hidden", "true"); clone.inert = true; + const offset = remainingOffset(node); Object.assign(clone.style, { position: "absolute", - top: `${position.top + remainingOffset(node)}px`, - left: `${position.left}px`, + top: `${position.top + offset.y}px`, + left: `${position.left + offset.x}px`, width: `${position.width}px`, height: `${position.height}px`, margin: "0", @@ -109,17 +130,29 @@ export function createSidebarListMotion(parent: HTMLUListElement) { positions = null; released = null; }; - const move = (node: HTMLElement, offset: number) => { + const move = (node: HTMLElement, offset: number, pinning = false, offsetX = 0) => { + const pinVisual = pinning || (running.get(node)?.pinVisual ?? false); cancel(node); const entry = entering.get(node); // The newer transform supersedes entry travel; its original opacity keeps fading. if (entry) entry.travel = 0; - if (offset === 0 && !entry) return; + if (offset === 0 && offsetX === 0 && !entry) return; + const path = pinning + ? sidebarPinPath(offsetX, offset) + : [ + { x: offsetX, y: offset, offset: 0 }, + { x: 0, y: 0, offset: 1 }, + ]; + // A pinning row stays opaque and above its neighbours until it settles. const animation = node.animate( - [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], - motionTiming, + path.map(({ x, y, offset }) => ({ + transform: `translate(${x}px, ${y}px)`, + offset, + ...(pinVisual ? { zIndex: 20, backgroundColor: "var(--sidebar)" } : {}), + })), + pinning ? { duration: 550, easing: "cubic-bezier(.32,0,.18,1)" } : motionTiming, ); - running.set(node, { animation, offset }); + running.set(node, { animation, path, pinVisual }); animation.addEventListener( "finish", () => { @@ -142,6 +175,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { left: node.offsetLeft, width: node.offsetWidth, height: node.offsetHeight, + pinned: node.getAttribute("data-thread-pinned") === "true", }, ]), ); @@ -171,7 +205,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const node = order[cursor]!; const delta = movedDelta.get(node); if (delta !== undefined) return delta; - if (retained(node)) return remainingOffset(node); + if (retained(node)) return remainingOffset(node).y; } return undefined; }; @@ -184,7 +218,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { for (const [node, position] of next) { const previousTop = positions!.get(node)?.top; if (previousTop === undefined || previousTop === position.top) continue; - movedDelta.set(node, previousTop + remainingOffset(node) - position.top); + movedDelta.set(node, previousTop + remainingOffset(node).y - position.top); } for (const [node, position] of positions!) { if (next.has(node)) continue; @@ -230,7 +264,17 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const delta = movedDelta.get(node); // Computed progress includes the effect's easing. Only our own // translate is carried forward; dnd-kit's transforms are never read. - if (delta !== undefined) move(node, delta); + // Interpolate the sampled path at the eased progress, so an + // interrupted pin preserves its curved XY position, not a linear Y. + if (delta !== undefined) { + const offset = remainingOffset(node); + move( + node, + delta, + position.pinned && !positions!.get(node)?.pinned, + offset.x, + ); + } } } if (released !== null) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 12b06d739476..8895a87eed26 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1720,12 +1720,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • diff --git a/apps/web/src/sidebarPinPath.test.ts b/apps/web/src/sidebarPinPath.test.ts new file mode 100644 index 000000000000..cca98c155599 --- /dev/null +++ b/apps/web/src/sidebarPinPath.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { sidebarPinPath } from "./sidebarPinPath"; + +describe("sidebar pin path", () => { + it.each([48, 280, 1200])("keeps a small rounded dip and arc over a %i px journey", (fromY) => { + const path = sidebarPinPath(0, fromY); + expect(path[0]).toEqual({ x: 0, y: fromY, offset: 0 }); + expect(path.at(-1)).toEqual({ x: 0, y: 0, offset: 1 }); + expect(Math.max(...path.map((point) => point.x))).toBe(16); + expect(Math.max(...path.map((point) => point.y))).toBe(fromY + 6); + expect(Math.min(...path.map((point) => point.y))).toBe(0); + const lowest = path.findIndex((point) => point.y === fromY + 6); + expect(path.slice(lowest + 1).every((point, i) => point.y <= path[lowest + i]!.y)).toBe(true); + expect(path.slice(1).every((point, i) => point.offset > path[i]!.offset)).toBe(true); + }); + + it.each([ + [12, 280], + [-12, 48], + [0, 0], + ])("starts at (%i, %i) and lands exactly in the slot with finite frames", (fromX, fromY) => { + const path = sidebarPinPath(fromX, fromY); + expect(path[0]).toEqual({ x: fromX, y: fromY, offset: 0 }); + expect(path.at(-1)).toEqual({ x: 0, y: 0, offset: 1 }); + expect(path.every((point) => Object.values(point).every(Number.isFinite))).toBe(true); + }); +}); diff --git a/apps/web/src/sidebarPinPath.ts b/apps/web/src/sidebarPinPath.ts new file mode 100644 index 000000000000..6d97658bd166 --- /dev/null +++ b/apps/web/src/sidebarPinPath.ts @@ -0,0 +1,50 @@ +/** + * Traces a small teardrop from a row's old offset into the pinned slot at (0, 0), + * at constant speed along each curve. + */ +export function sidebarPinPath(fromX: number, fromY: number) { + const bow = 16; + const dip = 6; + const y = -fromY; + const segments = [ + [ + [0, 0], + [0, dip * 0.68], + [bow * 0.18, dip], + [bow * 0.48, dip], + ], + [ + [bow * 0.48, dip], + [bow * 0.82, dip], + [bow, y * 0.08], + [bow, y * 0.22], + ], + [ + [bow, y * 0.22], + [bow, y * 0.58], + [bow * 0.16, y * 0.96], + [0, y], + ], + ] as const; + let previous = { x: 0, y: 0, distance: 0 }; + const points = [previous]; + for (const [p0, p1, p2, p3] of segments) { + for (let step = 1; step <= 80; step++) { + const t = step / 80; + const u = 1 - t; + const px = u ** 3 * p0[0] + 3 * u ** 2 * t * p1[0] + 3 * u * t ** 2 * p2[0] + t ** 3 * p3[0]; + const py = u ** 3 * p0[1] + 3 * u ** 2 * t * p1[1] + 3 * u * t ** 2 * p2[1] + t ** 3 * p3[1]; + previous = { + x: px, + y: py, + distance: previous.distance + Math.hypot(px - previous.x, py - previous.y), + }; + points.push(previous); + } + } + // Distance-based offsets keep velocity continuous where the curve segments join. + return points.map((point) => { + const offset = point.distance / previous.distance; + return { x: point.x + fromX * (1 - offset), y: point.y + fromY, offset }; + }); +} From d69a2baaad45b88ec6786c3c88526748d1d1647d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:00:43 +1000 Subject: [PATCH 2/6] fix(web): keep pin flights within the scrolled list --- .../web/src/components/Sidebar.motion.test.ts | 63 ++++++++++++++++++- apps/web/src/components/Sidebar.motion.ts | 20 +++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 442c46025d53..0f485b503897 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -37,7 +37,11 @@ class TestRow { public offsetHeight = 82, ) {} getBoundingClientRect() { - return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight }; + return { + top: this.offsetTop + this.dragTranslate, + bottom: this.offsetTop + this.dragTranslate + this.offsetHeight, + height: this.offsetHeight, + }; } getAttribute(name: string) { return this.attributes.find((attribute) => attribute.name === name)?.value ?? null; @@ -68,10 +72,12 @@ class TestRow { }); } -function fixture(rows: TestRow[]) { +function fixture(rows: TestRow[], viewportTop = 0) { const media = { matches: false }; + const viewport = { getBoundingClientRect: () => ({ top: viewportTop }), clientTop: 0 }; const parent = { children: rows, + closest: () => viewport, ownerDocument: { defaultView: { matchMedia: () => media } }, getBoundingClientRect: () => ({ top: 0 }), append(node: TestRow) { @@ -550,6 +556,59 @@ describe("sidebar list motion", () => { }); describe("pinning motion", () => { + it("finishes an offscreen pin at the top clipping edge instead of rushing through the viewport", () => { + const rows = Array.from({ length: 16 }, (_, index) => new TestRow(String(index))); + const pin = rows.at(-1)!; + const { motion, layout } = fixture(rows, 1000); + motion.update(false); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, ...rows.slice(0, -1)]); + motion.update(true); + const [frames, timing] = pin.animate.mock.lastCall!; + expect(timing.duration).toBe(550); + expect(frames[0]?.transform).toBe("translate(0px, 1245px)"); + // The row's bottom reaches 1000; removing the transform then lands it + // in the real pinned slot, entirely outside the viewport. + expect(frames.at(-1)?.transform).toBe("translate(0px, 910px)"); + }); + + it("keeps the real destination when the pinned row is partly visible", () => { + const [first, pin] = [new TestRow("first"), new TestRow("pin")]; + const { motion, layout } = fixture([first, pin], 50); + motion.update(false); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, first]); + motion.update(true); + expect(pin.animate.mock.lastCall![0].at(-1)?.transform).toBe("translate(0px, 0px)"); + }); + + it("keeps an interrupted offscreen pin aimed at the clipping edge", () => { + const rows = Array.from({ length: 16 }, (_, index) => new TestRow(String(index))); + const pin = rows.at(-1)!; + const inserted = new TestRow("inserted"); + const { motion, layout } = fixture(rows, 1000); + motion.update(false); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, ...rows.slice(0, -1)]); + motion.update(true); + pin.animations[0]!.progress = Number(pin.animate.mock.lastCall![0][80]!.offset); + layout([inserted, pin, ...rows.slice(0, -1)]); + motion.update(true); + const frames = pin.animate.mock.lastCall![0]; + expect(frames[0]?.transform).toBe("translate(7.68px, 1168px)"); + expect(frames.at(-1)?.transform).toBe("translate(0px, 827px)"); + }); + + it("does not fly a pin whose entire journey is already above the viewport", () => { + const [first, pin] = [new TestRow("first"), new TestRow("pin")]; + const { motion, layout } = fixture([first, pin], 1000); + motion.update(false); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, first]); + motion.update(true); + expect(pin.animate).not.toHaveBeenCalled(); + }); + it("retargets a pin from its curved XY position during another layout change", () => { const first = new TestRow("first"); const pin = new TestRow("pin"); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index a50cb45508ee..5b425800b88c 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -23,6 +23,7 @@ function progress(animation: Animation) { /** Animate rows between their layout positions. The list must be * positioned so every direct child's offsetTop has the same origin. */ export function createSidebarListMotion(parent: HTMLUListElement) { + const viewport = parent.closest('[data-slot="scroll-area-viewport"]'); let positions: Map | null = null; let disposed = false; const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( @@ -137,11 +138,26 @@ export function createSidebarListMotion(parent: HTMLUListElement) { // The newer transform supersedes entry travel; its original opacity keeps fading. if (entry) entry.travel = 0; if (offset === 0 && offsetX === 0 && !entry) return; + // Spend the flight on the visible journey. Once the whole row clears the + // scrollport, removing its transform lands it in the real offscreen slot. + const targetY = + pinVisual && viewport + ? Math.max( + 0, + viewport.getBoundingClientRect().top + + viewport.clientTop - + node.getBoundingClientRect().bottom, + ) + : 0; + if (targetY > 0 && offset <= targetY) return; const path = pinning - ? sidebarPinPath(offsetX, offset) + ? sidebarPinPath(offsetX, offset - targetY).map((point) => ({ + ...point, + y: point.y + targetY, + })) : [ { x: offsetX, y: offset, offset: 0 }, - { x: 0, y: 0, offset: 1 }, + { x: 0, y: targetY, offset: 1 }, ]; // A pinning row stays opaque and above its neighbours until it settles. const animation = node.animate( From be89d52102551adb68558fb735b406b2a7c727d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:04:59 +1000 Subject: [PATCH 3/6] fix(web): retain pinned state on slim sidebar rows --- apps/web/src/components/Sidebar.motion.test.ts | 14 ++++++++++++++ apps/web/src/components/Sidebar.tsx | 1 + 2 files changed, 15 insertions(+) diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 0f485b503897..b755efcfee12 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -556,6 +556,20 @@ describe("sidebar list motion", () => { }); describe("pinning motion", () => { + it("does not replay pinning when an already pinned slim row returns to the pinned section", () => { + const first = new TestRow("first"); + const pin = new TestRow("settled pin", 36); + const { motion, layout } = fixture([first, pin]); + motion.update(false); + pin.setAttribute("data-thread-pinned", "true"); + motion.update(true); + expect(pin.animate).not.toHaveBeenCalled(); + pin.offsetHeight = 82; + layout([pin, first]); + motion.update(true); + expectMove(pin, 83); + }); + it("finishes an offscreen pin at the top clipping edge instead of rushing through the viewport", () => { const rows = Array.from({ length: 16 }, (_, index) => new TestRow(String(index))); const pin = rows.at(-1)!; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8895a87eed26..8d9f22c3c177 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1567,6 +1567,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • Date: Sun, 13 Sep 2026 14:37:17 +1000 Subject: [PATCH 4/6] fix(web): retarget pin flights when the list scrolls --- .../web/src/components/Sidebar.motion.test.ts | 52 ++++++++++++++++++- apps/web/src/components/Sidebar.motion.ts | 35 ++++++++++--- apps/web/src/sidebarPinPath.ts | 3 +- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index b755efcfee12..717af58269aa 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -40,6 +40,7 @@ class TestRow { return { top: this.offsetTop + this.dragTranslate, bottom: this.offsetTop + this.dragTranslate + this.offsetHeight, + right: this.offsetLeft + this.offsetWidth, height: this.offsetHeight, }; } @@ -74,7 +75,13 @@ class TestRow { function fixture(rows: TestRow[], viewportTop = 0) { const media = { matches: false }; - const viewport = { getBoundingClientRect: () => ({ top: viewportTop }), clientTop: 0 }; + const viewport = Object.assign(new EventTarget(), { + top: viewportTop, + clientTop: 0, + clientLeft: 0, + clientWidth: 280, + getBoundingClientRect: () => ({ top: viewport.top, left: 0 }), + }); const parent = { children: rows, closest: () => viewport, @@ -100,7 +107,7 @@ function fixture(rows: TestRow[], viewportTop = 0) { } layout(rows); const motion = createSidebarListMotion(parent as unknown as HTMLUListElement); - return { motion, layout, media, parent }; + return { motion, layout, media, parent, viewport }; } function expectMove(row: TestRow, offset: number) { @@ -556,6 +563,47 @@ describe("sidebar list motion", () => { }); describe("pinning motion", () => { + it("retargets a clipped flight when scrolling exposes its old endpoint", () => { + const rows = Array.from({ length: 16 }, (_, index) => new TestRow(String(index))); + const pin = rows.at(-1)!; + const { motion, layout, viewport } = fixture(rows, 1000); + motion.update(false); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, ...rows.slice(0, -1)]); + motion.update(true); + const first = pin.animations[0]!; + first.progress = Number(pin.animate.mock.lastCall![0][80]!.offset); + viewport.top = 600; + viewport.dispatchEvent(new Event("scroll")); + expect(first.cancel).toHaveBeenCalledOnce(); + expect(pin.animate.mock.lastCall![0][0]?.transform).toBe("translate(7.68px, 1251px)"); + expect(pin.animate.mock.lastCall![0].at(-1)?.transform).toBe("translate(0px, 510px)"); + // Scrolling all the way back reveals the actual pinned slot. + pin.animations[1]!.progress = 0.5; + viewport.top = 0; + viewport.dispatchEvent(new Event("scroll")); + expect(pin.animate.mock.lastCall![0][0]?.transform).toBe("translate(3.84px, 880.5px)"); + expect(pin.animate.mock.lastCall![0].at(-1)?.transform).toBe("translate(0px, 0px)"); + motion.dispose(); + viewport.dispatchEvent(new Event("scroll")); + expect(pin.animate).toHaveBeenCalledTimes(3); + }); + + it("fits the bow inside the viewport's available right inset", () => { + const [first, pin] = [new TestRow("first"), new TestRow("pin")]; + const { motion, layout, viewport } = fixture([first, pin]); + viewport.clientWidth = 272; + motion.update(false); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, first]); + motion.update(true); + const xs = pin.animate.mock.lastCall![0].map((frame) => + Number(String(frame.transform).match(/translate\(([^p]+)px/)![1]), + ); + expect(Math.max(...xs)).toBe(8); + expect(Math.min(...xs)).toBe(0); + }); + it("does not replay pinning when an already pinned slim row returns to the pinned section", () => { const first = new TestRow("first"); const pin = new TestRow("settled pin", 36); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index 5b425800b88c..8a8cd0c3cec0 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -140,18 +140,27 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (offset === 0 && offsetX === 0 && !entry) return; // Spend the flight on the visible journey. Once the whole row clears the // scrollport, removing its transform lands it in the real offscreen slot. + const viewportRect = pinVisual ? viewport?.getBoundingClientRect() : undefined; + const rowRect = viewportRect ? node.getBoundingClientRect() : undefined; const targetY = - pinVisual && viewport + viewport && viewportRect && rowRect + ? Math.max(0, viewportRect.top + viewport.clientTop - rowRect.bottom) + : 0; + if (targetY > 0 && offset <= targetY) return; + // Transformed rows contribute to scrollable overflow. Keep the bow within + // the existing inset so it cannot introduce horizontal scrolling or fades. + const bow = + viewport && viewportRect && rowRect ? Math.max( 0, - viewport.getBoundingClientRect().top + - viewport.clientTop - - node.getBoundingClientRect().bottom, + Math.min( + 16, + viewportRect.left + viewport.clientLeft + viewport.clientWidth - rowRect.right, + ), ) - : 0; - if (targetY > 0 && offset <= targetY) return; + : 16; const path = pinning - ? sidebarPinPath(offsetX, offset - targetY).map((point) => ({ + ? sidebarPinPath(offsetX, offset - targetY, bow).map((point) => ({ ...point, y: point.y + targetY, })) @@ -178,6 +187,17 @@ export function createSidebarListMotion(parent: HTMLUListElement) { ); }; + // A scroll can reveal a clipped endpoint. Retarget from the current visual + // position so the row still clears the edge before its transform disappears. + const onScroll = () => { + for (const [node, { pinVisual }] of Array.from(running)) { + if (!pinVisual) continue; + const offset = remainingOffset(node); + move(node, offset.y, false, offset.x); + } + }; + viewport?.addEventListener("scroll", onScroll, { passive: true }); + return { update(animate: boolean) { if (disposed) return; @@ -320,6 +340,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { suspend, dispose() { suspend(); + viewport?.removeEventListener("scroll", onScroll); disposed = true; }, }; diff --git a/apps/web/src/sidebarPinPath.ts b/apps/web/src/sidebarPinPath.ts index 6d97658bd166..f8e60395df7a 100644 --- a/apps/web/src/sidebarPinPath.ts +++ b/apps/web/src/sidebarPinPath.ts @@ -2,8 +2,7 @@ * Traces a small teardrop from a row's old offset into the pinned slot at (0, 0), * at constant speed along each curve. */ -export function sidebarPinPath(fromX: number, fromY: number) { - const bow = 16; +export function sidebarPinPath(fromX: number, fromY: number, bow = 16) { const dip = 6; const y = -fromY; const segments = [ From e346cc71a99178f3cd867bed30300f73048ba8cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:08:41 +1000 Subject: [PATCH 5/6] style: apply formatter Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/Sidebar.motion.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index 8a8cd0c3cec0..62b2c2cdad57 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -304,12 +304,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { // interrupted pin preserves its curved XY position, not a linear Y. if (delta !== undefined) { const offset = remainingOffset(node); - move( - node, - delta, - position.pinned && !positions!.get(node)?.pinned, - offset.x, - ); + move(node, delta, position.pinned && !positions!.get(node)?.pinned, offset.x); } } } From f4bb85b77001f76ee450870b7372f3fed993d639 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:39:31 +1000 Subject: [PATCH 6/6] fix(web): preserve pin flights across unchanged scroll endpoints --- .../web/src/components/Sidebar.motion.test.ts | 33 +++++++++++++++++++ apps/web/src/components/Sidebar.motion.ts | 14 ++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 717af58269aa..39d4febc09b7 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -563,6 +563,24 @@ describe("sidebar list motion", () => { }); describe("pinning motion", () => { + it("keeps the curved flight when a scroll leaves its clipping endpoint unchanged", () => { + const rows = Array.from({ length: 16 }, (_, index) => new TestRow(String(index))); + const pin = rows.at(-1)!; + const { motion, layout, viewport } = fixture(rows, 1000); + motion.update(false); + pin.setAttribute("data-thread-pinned", "true"); + layout([pin, ...rows.slice(0, -1)]); + motion.update(true); + const first = pin.animations[0]!; + first.progress = Number(pin.animate.mock.lastCall![0][80]!.offset); + // The browser's rectangle includes the live animation transform. + pin.dragTranslate = 1251; + viewport.dispatchEvent(new Event("scroll")); + expect(first.cancel).not.toHaveBeenCalled(); + expect(pin.animate).toHaveBeenCalledTimes(1); + motion.dispose(); + }); + it("retargets a clipped flight when scrolling exposes its old endpoint", () => { const rows = Array.from({ length: 16 }, (_, index) => new TestRow(String(index))); const pin = rows.at(-1)!; @@ -573,6 +591,11 @@ describe("pinning motion", () => { motion.update(true); const first = pin.animations[0]!; first.progress = Number(pin.animate.mock.lastCall![0][80]!.offset); + pin.dragTranslate = 1251; + first.cancel.mockImplementation(() => { + first.playState = "idle"; + pin.dragTranslate = 0; + }); viewport.top = 600; viewport.dispatchEvent(new Event("scroll")); expect(first.cancel).toHaveBeenCalledOnce(); @@ -580,6 +603,11 @@ describe("pinning motion", () => { expect(pin.animate.mock.lastCall![0].at(-1)?.transform).toBe("translate(0px, 510px)"); // Scrolling all the way back reveals the actual pinned slot. pin.animations[1]!.progress = 0.5; + pin.dragTranslate = 880.5; + pin.animations[1]!.cancel.mockImplementation(() => { + pin.animations[1]!.playState = "idle"; + pin.dragTranslate = 0; + }); viewport.top = 0; viewport.dispatchEvent(new Event("scroll")); expect(pin.animate.mock.lastCall![0][0]?.transform).toBe("translate(3.84px, 880.5px)"); @@ -695,6 +723,11 @@ describe("pinning motion", () => { expect(pin.animations[0]!.cancel).toHaveBeenCalledOnce(); // A second interruption retains the X carried into the ordinary glide. pin.animations[1]!.progress = 0.5; + pin.dragTranslate = 880.5; + pin.animations[1]!.cancel.mockImplementation(() => { + pin.animations[1]!.playState = "idle"; + pin.dragTranslate = 0; + }); layout([pin, inserted, first]); motion.update(true); expect(pin.animate.mock.lastCall![0][0]).toEqual({ diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index 62b2c2cdad57..f873ed1f6b98 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -35,6 +35,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { animation: Animation; path: { x: number; y: number; offset: number }[]; pinVisual: boolean; + targetY: number; } >(); const entering = new Map(); @@ -177,7 +178,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { })), pinning ? { duration: 550, easing: "cubic-bezier(.32,0,.18,1)" } : motionTiming, ); - running.set(node, { animation, path, pinVisual }); + running.set(node, { animation, path, pinVisual, targetY }); animation.addEventListener( "finish", () => { @@ -190,9 +191,18 @@ export function createSidebarListMotion(parent: HTMLUListElement) { // A scroll can reveal a clipped endpoint. Retarget from the current visual // position so the row still clears the edge before its transform disappears. const onScroll = () => { - for (const [node, { pinVisual }] of Array.from(running)) { + for (const [node, { pinVisual, targetY }] of Array.from(running)) { if (!pinVisual) continue; const offset = remainingOffset(node); + const nextTargetY = viewport + ? Math.max( + 0, + viewport.getBoundingClientRect().top + + viewport.clientTop - + (node.getBoundingClientRect().bottom - offset.y), + ) + : 0; + if (nextTargetY === targetY) continue; move(node, offset.y, false, offset.x); } };