From c287941abe69389a26540977efb809d1ebd771d6 Mon Sep 17 00:00:00 2001 From: bkennedy Date: Tue, 14 Jul 2026 19:26:50 +0000 Subject: [PATCH 1/3] feat(board): PROG-118 settle cards on drop with the shared tween MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the outline's settle-on-drop to the board, per owner request: the DROP_ANIMATION config moves to src/client/dropAnimation.ts and both DragOverlay surfaces use it, replacing the board's dropAnimation={null}. That null was PROG-43's workaround for the default tween flying the card back to its stale slot — impossible on the board since onDragEnd commits the columns mirror synchronously (PROG-59), and impossible on the outline since PROG-119; the frame-sampling 'doesn't flash back' e2e spec passes with the tween on. --- src/client/dropAnimation.ts | 15 +++++++++++++++ src/client/pages/Home.tsx | 14 +++++++------- src/client/pages/Outline.tsx | 16 +--------------- 3 files changed, 23 insertions(+), 22 deletions(-) create mode 100644 src/client/dropAnimation.ts diff --git a/src/client/dropAnimation.ts b/src/client/dropAnimation.ts new file mode 100644 index 0000000..9bfcd47 --- /dev/null +++ b/src/client/dropAnimation.ts @@ -0,0 +1,15 @@ +// Settle-on-drop for every DragOverlay surface (PROG-118 polish; board + +// outline): the default drop tween glides the floating card from the release +// point into its final slot while the shadow eases off, and keeps the in-list +// source ghosted until it lands. Safe now that PROG-119 made optimistic writes +// notify synchronously — by the time the overlay measures its destination the +// row/card has ALREADY re-rendered at the drop position. (Pre-PROG-119 the +// measurement hit the stale slot and the card flew back to it, which is why +// the board and the old outline section overlays used dropAnimation={null} — +// PROG-43.) +import { defaultDropAnimationSideEffects, type DropAnimation } from "@dnd-kit/core"; + +export const DROP_ANIMATION: DropAnimation = { + duration: 180, + sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.3" } } }), +}; diff --git a/src/client/pages/Home.tsx b/src/client/pages/Home.tsx index 54d8ba4..79403d5 100644 --- a/src/client/pages/Home.tsx +++ b/src/client/pages/Home.tsx @@ -34,6 +34,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "wouter"; import { ACTION_STATUSES, type ActionStatus } from "../../shared/constants"; import { rankBetween } from "../../shared/rank"; +import { DROP_ANIMATION } from "../dropAnimation"; import { reorder, type ColumnMap } from "../boardOrder"; import { BOARD_FILTERS_KEY, FILTER_NONE, matchesNullableId } from "../boardFilters"; import FilterBar, { useStickyFilterUrl } from "../FilterBar"; @@ -416,13 +417,12 @@ export default function Home({ snapshot }: { snapshot: SnapshotPayload }) { /> ))} - {/* dropAnimation={null}: skip the default drop tween. It animates the - overlay back to the *original* dragged node, but the reorder is - already committed to state by onDragEnd, so the tween flies the card - to its old slot before the re-render snaps it to the new one - (PROG-43). Dropping it makes the card settle in place instantly — - on-brand with the instant-UI rule. */} - + {/* On release the overlay glides into the card's committed slot + (PROG-118 polish, shared DROP_ANIMATION): onDragEnd sets `columns` + synchronously, so the tween measures the card at its NEW position — + the old fly-back that dropAnimation={null} worked around (PROG-43) + can't happen. */} + {draggingAction && (
Date: Tue, 14 Jul 2026 19:26:50 +0000 Subject: [PATCH 2/3] test(e2e): PROG-118 stop leaking open actions onto the board; retime overlay clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit board-reorder's PROG-59 spec was failing for a reason unrelated to the tween: outline-move.spec.ts archived its focuses but left their actions OPEN — archiving a focus does not close its actions — so each run leaked open actions onto the shared dev board, and isolateColumn herded the growing pile between columns until the drag targets slid below the viewport. The spec now cancels its actions before archiving (and says why). The 32 already-leaked actions were canceled in the dev DB by hand. board-reorder's overlay assertion is retimed for the settle tween: the overlay now legitimately lives ~180ms gliding to the NEW slot, so 'clears within 120ms' becomes 'clears within 600ms'; the fly-back regression that bound guarded is covered by the frame-sampling 'doesn't flash back' spec. --- e2e/board-reorder.spec.ts | 8 +++++--- e2e/outline-move.spec.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/e2e/board-reorder.spec.ts b/e2e/board-reorder.spec.ts index c982718..6913859 100644 --- a/e2e/board-reorder.spec.ts +++ b/e2e/board-reorder.spec.ts @@ -113,9 +113,11 @@ test("dragging a card up settles in place — no fly-back to the old slot", asyn await glideY(page, c2.cx, c2.cy + 6, c0.y - 8); // drag the bottom card above the top await expect(overlay(page)).toHaveCount(1); // overlay present mid-drag await page.mouse.up(); - // Regression: the buggy default drop animation kept the overlay clone alive - // ~250ms flying back to the origin; it must clear well inside that. - await expect(overlay(page)).toHaveCount(0, { timeout: 120 }); + // The settle tween (PROG-118, shared DROP_ANIMATION ~180ms) glides the + // overlay into the card's NEW slot, then clears. The old fly-back-to-origin + // regression is covered by the frame-sampling spec below ("doesn't flash + // back"); here just require the overlay to clear once the tween ends. + await expect(overlay(page)).toHaveCount(0, { timeout: 600 }); await expect .poll(async () => { diff --git a/e2e/outline-move.spec.ts b/e2e/outline-move.spec.ts index 03e4e01..08a9a35 100644 --- a/e2e/outline-move.spec.ts +++ b/e2e/outline-move.spec.ts @@ -11,7 +11,12 @@ import { signInAsOwner } from "./auth"; // // Each test creates its OWN containers/actions via the API (unique names and // prefixes), so it is independent of the ambient dev DB and of other specs; it -// archives the containers again at the end to keep the ambient views clean. +// cancels its actions and archives the containers again at the end. The cancel +// matters: archiving a focus does NOT close its actions, and the board specs +// herd every ambient open action around with isolateColumn — leaked e2e +// actions accumulate across runs until those columns overflow the viewport and +// the board drags miss their targets (how PROG-118's leftovers broke +// board-reorder's PROG-59 spec). const tag = () => Math.random().toString(36).slice(2, 8); const prefix = (lead: string) => @@ -122,6 +127,8 @@ test("an action drags from one arc into another (PROG-118)", async ({ page }) => ).arcId, ).toBe(dst.id); + for (const a of [mover, anchor]) + await page.request.patch(`/api/actions/${a.id}`, { data: { status: "canceled" } }); await page.request.patch(`/api/focuses/${focus.id}`, { data: { archived: true } }); }); @@ -187,6 +194,8 @@ test("an action drags into another focus and is re-keyed (PROG-118)", async ({ p await page.reload(); await handleOf(page, `${to.keyPrefix}-${moved.number}`).waitFor(); + for (const a of [mover, anchor]) + await page.request.patch(`/api/actions/${a.id}`, { data: { status: "canceled" } }); for (const p of [from, to]) await page.request.patch(`/api/focuses/${p.id}`, { data: { archived: true } }); await page.request.patch(`/api/workspaces/${workspace.id}`, { data: { archived: true } }); From c8c35e6ccd38da79236674483adaee3a107f61d8 Mon Sep 17 00:00:00 2001 From: bkennedy Date: Tue, 14 Jul 2026 19:26:50 +0000 Subject: [PATCH 3/3] docs: PROG-118 record the board settle tween and the e2e action-leak fix --- docs/REFERENCE.md | 4 ++++ docs/decisions/PROG-118.md | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 9973a9d..15cd070 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -596,6 +596,10 @@ so old bookmarks keep working. vertically within a column (a manual work order) and moves them between columns to set status; both persist as one optimistic write via the card's `rank` (D44). + On release the floating card glides into its committed slot (the shared + settle tween, `src/client/dropAnimation.ts` — PROG-118; the pre-PROG-119 + fly-back that forced `dropAnimation={null}` can't recur because the drop is + committed to `columns` synchronously in `onDragEnd`). Mouse drags activate after 4px of movement (plain clicks navigate), touch drags after a 250ms press-and-hold (plain swipes scroll the board) — D30. When the columns overflow (a phone, where they hit their `min-w-72` floor) the row diff --git a/docs/decisions/PROG-118.md b/docs/decisions/PROG-118.md index f7c1c96..d6600d8 100644 --- a/docs/decisions/PROG-118.md +++ b/docs/decisions/PROG-118.md @@ -73,6 +73,19 @@ release popped. Owner feedback asked for the board's feel. *Decisions within:* easing off. The null was PROG-43's workaround for the tween flying to the *stale* slot; PROG-119's synchronous store notification means the destination row is already re-rendered when the tween measures it, so the - workaround (kept on the board, which still has its local mirror) is - unnecessary here. Verified frame-by-frame: the landed row is stationary at - its final position from frame 0 after mouseup. + workaround is unnecessary. Verified frame-by-frame: the landed row is + stationary at its final position from frame 0 after mouseup. The owner then + asked for the same feel on the **board**, so the tween lives in + `src/client/dropAnimation.ts`, shared by both surfaces — the board was + never exposed to the fly-back anyway (its `columns` mirror commits + synchronously in `onDragEnd`, PROG-59), and its "doesn't flash back" + frame-sampling e2e spec still passes with the tween on. + +While wiring the board tween, its PROG-59 e2e spec turned out to be failing +for an unrelated reason: `outline-move.spec.ts` archived its focuses but left +their **actions** open, and archiving a focus does not close its actions — so +every run leaked open actions onto the board, and `isolateColumn` herded the +growing pile between columns until the drag targets slid off-screen. The spec +now cancels its actions before archiving, and the board spec's overlay-clear +assertion was retimed for the settle tween (the fly-back regression it guarded +is covered by the frame-sampling spec).