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). 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 } }); 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 && (