3.2 Kanban board UI + drag-drop transitions
The board surface: a horizontally-scrolling row of columns, each a vertical stack of cards, with accessible drag-drop that moves a card between columns (a workflow transition) or reorders it within a column (a rank change). This is a pure frontend story — it builds NO new backend; every read and write goes through the Story-3.1 board API (GET /api/projects/[key]/board → BoardProjectionDto, GET …/board/columns/[id]/cards → the lazy page, POST …/board/move → the moved card). The load-bearing contract carried from 3.1: moving a card is a workflow transition, never a board-local write — a cross-column drop calls POST …/move, which resolves to issuesService.updateStatus under the project's policy mode; an in-column drop is a pure work_item.position rank change. The UI applies the move optimistically and reconciles against the server response, snapping the card back when the server rejects an illegal transition (IllegalBoardMoveError → HTTP 409) or an unmapped target (UnmappedColumnTargetError → 422).
Where it lives. The /boards route already exists as a stub placeholder (app/(authed)/boards/page.tsx renders ProjectStubPage … comingIn="Epic 3"); the sidebar "Boards" nav link + the Cmd-K "Go to Boards" entry are already wired (Story 1.5). This story replaces the stub with the real surface — no new navigation wiring. The board renders the active project's single default Kanban board (3.1 auto-seeds one per project); multi-board routing is a non-breaking addition planned as Story 3.7 (the API already takes a boardId).
Reuse, do not reinvent (the card visual language). A board card is the same issue, shown compactly — so it REUSES the issue-list card primitives, not a parallel set: the IssueTypeIcon (kind hue via --el-type-*), the Pill tones for status/priority, the ReadinessBadge blocked/ready signal, the assignee avatar, and the key/identifier chip — all already shipped under app/(authed)/issues/_components/issueCellPrimitives.tsx + components/ui/*. Clicking a card opens the existing IssueQuickView panel (Story 2.5), not a new detail surface. The board is a new arrangement of shipped primitives.
Accessibility is in scope, not deferred. The stub mandates "accessible keyboard DnD," and Jira/Linear both ship it — so drag-drop uses dnd-kit (@dnd-kit/core + @dnd-kit/sortable), the de-facto accessible React DnD library (pointer + keyboard sensors, a DragOverlay, and aria-live move announcements out of the box). This is a new dependency (no DnD lib exists yet); it is the mirror-product-standard choice (decision-ladder rung 1), not a deviation. A drag must be fully operable by keyboard (pick up / move / drop / cancel) with screen-reader announcements; drop targets are not signalled by colour alone (finding #35).
Scale shape — the board is bounded, never "render every card" (finding #57). 3.1 already returns a bounded first page per column with a per-column total count and a cursor; this story consumes that — each column shows its count, lazy-loads further cards on demand via GET …/board/columns/[id]/cards?cursor=, and virtualizes a tall column so the DOM row count stays bounded (reusing the windowing primitive Story 2.5.15 establishes for the issue tree — do NOT introduce a second virtualization library). A board that fetched and rendered every card would be prototype-thinking; the projection is paged by design and the UI must honour it.
Completeness — the real-product states, not just the happy path. A real board has: a loading skeleton (columns + card placeholders) while the projection streams; an empty state (project has no issues yet → a create-issue affordance); a no-board / error / forbidden state; and an unmapped-statuses affordance — 3.1's projection deliberately returns unmappedStatuses (project statuses mapped to no column, the Jira behaviour), which the board surfaces as a tray/banner (with a path to the board/workflow admin) rather than silently dropping them. The board is responsive (horizontal scroll on desktop, a usable mobile layout). These are planned as their own subtask, not bolted on.
Out of scope (Epic-3 siblings): swimlanes + WIP-limit enforcement / over-limit warnings (Story 3.3 — this story renders the wipLimit the projection returns as a count display only, and leaves a slot for 3.3); the sprint-scoped Scrum board (Story 4.5 — moved to Epic 4 per mistake #32); the cross-cutting drag-drop + WIP + swimlane Playwright journey (Story 3.5 — this story ships its OWN UI component tests + the core drag/snapback E2E, the same split 3.1.7 used). The column↔status mapping admin — where an unmapped status is put on a column (this story’s unmapped-tray links there) — is Story 3.6; board CRUD / multi-board is Story 3.7.
Verification
- Pull the Story branch,
pnpm install(picks up the new@dnd-kit/*deps),pnpm prisma migrate dev,pnpm db:seed,pnpm dev. pnpm test— vitest covers the optimistic-move / snapback reducer (move applied → confirmed on 200; reverted on 409/422), theBoardCard/BoardColumnrender (type hue, readiness, count), and the column lazy-load page-append logic.pnpm test:e2e --grep board-ui— Playwright drives the real board: drag a card across columns (status persists), an illegal move snaps back (409), in-column reorder changes only rank, and the keyboard-DnD path moves a card end-to-end.- Visual / design check: open
/boardson the seededmoooon→motirproject. The board matchesdesign/boards/board.mock.html— columns in workflow order with a card count each, cards showing type icon + key + title + assignee + priority + a blocked indicator where applicable, colour via--el-*(not grey-only, finding #54). - Move = transition check: drag a card from To Do → In Progress → the card lands, the count updates, and re-opening the issue (or the quick view) shows the new status. Attempt an illegal move under a
restrictedworkflow (e.g. To Do → Done with no such transition) → the card animates back to its origin column and a toast explains the rejection; the issue status is unchanged. - In-column reorder check: drag a card up/down within a column → it stays in the column, no status change, the order persists on reload (rank only).
- Keyboard-DnD check: focus a card, press the pick-up key (Space/Enter), arrow to another column, drop → the move happens with an
aria-liveannouncement; Escape mid-drag cancels with the card returning home. Verify with the keyboard alone (no mouse). - Scale check (finding #57):
pnpm db:seed:large, open a column with hundreds of cards → it shows the bounded first page + the total count + a "Load more" affordance; scrolling/loading fetches the next page viaGET …/columns/[id]/cards?cursor=; the rendered DOM row count stays bounded (virtualized) even when the column is fully expanded. - Unmapped-status check (mirror-product): add a custom status via the workflow editor (Story 2.2.5) → the board shows it in the unmapped-statuses tray (NOT a new column, NOT silently dropped), with a link to map it; existing columns are unaffected.
- States check: a brand-new project with no issues shows the empty state (with a create affordance); throttling the network shows the loading skeleton; a forced API error shows the error state with a retry.