3.2.4 Drag-drop transitions — dnd-kit, optimistic move, snap-back on illegal/unmapped
Estimate: 26m · Depends on: 3.2.3, 3.1.6
The heart of the story: make cards draggable, move them via the Story-3.1 transition contract, and reconcile optimistically. Introduce dnd-kit (@dnd-kit/core + @dnd-kit/sortable) — the accessible React DnD standard (the stub mandates accessible keyboard DnD; Jira/Linear ship it). pnpm add the deps (a plain library add — no dashboard/SaaS provisioning, so no manual subtask). Wire it per design/boards/.
DnD wiring. A DndContext over the board with pointer AND keyboard sensors; each BoardColumn is a droppable, each BoardCard a sortable draggable; a DragOverlay renders the lifted card; an insertion indicator marks the drop position (the 3.2.1 treatment, not colour-alone — finding #35).
Move semantics → the 3.1 contract. On drop, compute { workItemId, toColumnId, beforeId?, afterId? } from the source/target and call POST /api/projects/[key]/board/move (Story 3.1.6). A cross-column drop is a workflow transition (the server resolves the target status + validates via canTransition); an in-column drop is a rank change (work_item.position). The UI does NOT decide legality — the server does.
Optimistic update + reconcile. Apply the move to the board state immediately (card jumps to the target column/position) for snappy feedback, then:
- 200 → reconcile to the returned
BoardCardDto(confirmed status/position; update the source + target column counts). - 409
IllegalBoardMoveError→ snap the card back to its origin column/position (the 3.2.1 snap-back treatment) and show aToastexplaining the rejected transition. - 422
UnmappedColumnTargetError→ snap back + a toast (drop onto an unmapped target). - other / network error → snap back + a generic retry toast; never leave the card in a lying optimistic position.
Accessibility. Keyboard DnD must fully work: focus a card, pick up (Space/Enter), arrow between columns/positions, drop, and Escape-to-cancel — with aria-live announcements (dnd-kit announcements, copy from the 3.2.1 design-notes). Operable with the keyboard alone.
Out of scope here: the lazy load-more/virtualization the dragged stack lives in (3.2.5 — ensure the DnD wiring tolerates a virtualized list); WIP-limit rejection on over-limit drops (Story 3.3 — the server does not reject on WIP yet, and this story does not pre-empt it).
Acceptance criteria
@dnd-kit/core+@dnd-kit/sortableadded; aDndContextwith pointer + keyboard sensors makes cards draggable between and within columns, with aDragOverlay+ an insertion indicator (not colour-alone, finding #35).- A drop calls
POST …/board/movewith the right{ workItemId, toColumnId, beforeId?, afterId? }; cross-column = transition, in-column = rank — the UI defers legality to the server. - The move applies optimistically; on 200 it reconciles to the returned card + updates column counts; on 409 it snaps the card back + toasts; on 422 and other errors it snaps back + toasts. The card never stays in a rejected position.
- Keyboard DnD works end-to-end (pick up / move / drop / Esc-cancel) with
aria-liveannouncements; the flow is operable with no mouse. - Vitest covers the optimistic-move/reconcile reducer (confirm on 200, revert on 409/422) in isolation; colours/shape via tokens.
Context refs
- Story 3.1.5 / 3.1.6 —
boardsService.moveCardsemantics +POST …/board/move+ theIllegalBoardMoveError(409) /UnmappedColumnTargetError(422) typed errors this branches on components/ui/Toast— the rejection feedback;app/(authed)/issues/_components/IssueInlineEdit.tsx— an existing optimistic-update precedent to mirror- dnd-kit docs (
DndContext,useSortable,DragOverlay, keyboard sensor,announcements);design/boards/design-notes.md(3.2.1) — drag states + announcement copy - finding #35 (not colour-alone);
motir-core/CLAUDE.md—--el-*+ element-shape rules