Task — engineering-spec@1

"Kanban Board view — drag/drop status transition + keyboard-first navigation + 60fps virtualised list rendering"

doneTASK-PROJ-014
module proj · class product · priority p0 · created 2026-05-16 · shipped null
depends on TASK-PROJ-002 · blocks TASK-PROJ-018

§1 — Description (BCP-14 normative)

The Kanban Board MUST present issues grouped by status with drag/drop transitions + keyboard parity. The contract:

  1. MUST render 6 columns matching TASK-PROJ-004 status enum: Backlog, Todo, InProgress, InReview, Done, Cancelled.
  2. MUST subscribe to TASK-PROJ-002 WebSocket for live updates; YjsProvider for description/comments.
  3. MUST support drag-and-drop between columns via @dnd-kit/core:
  1. MUST support keyboard navigation parity:
  1. MUST virtualise long columns via react-window FixedSizeList when > 200 items; 60fps maintained.
  2. MUST render IssueCard with: title (≤ 2 lines truncated), assignee avatar, estimate badge, priority indicator, blocker count badge (TASK-PROJ-011), labels.
  3. MUST show real-time presence indicators (TASK-PROJ-003 awareness): cursor + selection of other users editing the same issue (when in Brief Modal).
  4. MUST emit memory audit row proj.kanban_card_moved per drag-induced transition; payload {issue_id, from_status, to_status, by_subject_id, was_keyboard: bool, trace_id}.
  5. MUST emit OTel client-side metrics via web-vitals:
  1. MUST pass axe-core a11y audit: no critical/serious violations; ARIA roles for application/group/listitem correct; keyboard-only test passes.
  2. MUST handle WebSocket disconnect gracefully: banner "offline; changes will sync when connected"; drags queued in offline buffer (TASK-PROJ-003 §1 #8).
  3. MUST support ?member=<uuid> and ?label=<id> URL query filters; updates URL on filter change for shareability.
  4. MUST support WIP (Work-In-Progress) limits per column when configured: cyberos_proj_engagement_settings.wip_limits = {in_progress: 5, in_review: 3}. Drag into a column at-or-above limit → warning banner + soft block (user can confirm to proceed; emit proj.wip_limit_overridden audit).
  5. MUST support card minification: at zoom level < 75% OR per-engagement preference, render compact cards (title only, no badges). Reduces visual noise on dense boards.
  6. MUST support bulk operations via multi-select: Shift-click selects range; Cmd/Ctrl-click toggles; bulk drag moves all selected. Bulk transitions emit one memory row per issue (per TASK-PROJ-002 §1 #16).
  7. MUST support quick-add via keyboard: pressing c from column-focused state opens an inline issue creator at the top of that column with status=column's status. ESC cancels; Enter creates.
  8. MUST support swimlanes by assignee: optional view mode ?swimlanes=assignee renders rows per assignee with status columns. Helps team standups.
  9. MUST maintain scroll position across re-renders (e.g. WebSocket update doesn't reset scroll). Test: scroll column to 50%, receive update, scroll position preserved.
  10. MUST support undo for the last drag operation via Cmd/Ctrl+Z within 5 seconds; emits proj.kanban_card_move_undone audit; reverses the transition.
  11. MUST show drag-target preview: ghost card appears in target column at expected insert position during drag; users see exactly where it'll land.
  12. MUST support keyboard-driven card reorder within column: J/K with Shift held reorders the card (vs. moving focus); persists card order in cyberos_proj_kanban_order table.
  13. MUST include real-time issue count + WIP overflow indicators in column headers: "InProgress 7/5 ⚠" when over WIP limit.

§2 — Why this design (rationale for humans)

Why @dnd-kit (DEC-351 enabling)? Provides keyboard parity out of the box (focus-management hooks); react-dnd is mouse-first. WCAG AA mandates parity; @dnd-kit accelerates compliance.

Why optimistic UI (§1 #3)? 200ms reconciliation gap feels janky; optimistic move + rollback-on-error matches expected drag latency.

Why snap-back animation (§1 #3)? Without animation, rejection is invisible; user assumes drag succeeded. Spring animation signals "rejected; try again."

Why react-window FixedSizeList (DEC-352)? Variable size = layout recalculation thrash; fixed size = O(1) per scroll frame. Issue cards are visually uniform anyway.

Why keyboard shortcut for cross-column move (§1 #4)? Power users move many issues quickly; mouse drag for 50 cards is friction. Cmd+Shift+→ is the discoverable shortcut.

Why URL filter sync (§1 #12)? Operators share "look at Alice's bug backlog" via URL — board state must be URL-encodable.

Why audit was_keyboard flag (§1 #8)? Distinguishes user behaviour patterns; informs UX research ("kb users move 3× faster but make 30% more illegal-attempts").

Why soft WIP limit (§1 #13)? Hard block frustrates operators; soft warn-and-confirm respects autonomy while flagging overflow.

Why card minification (§1 #14)? Dense boards (50+ cards visible) overload visual processing; compact mode trades detail for density.

Why bulk operations (§1 #15)? Bulk status moves at sprint planning are common; single-card drag for 30 cards is friction.

Why quick-add c (§1 #16)? Keyboard-driven workflows need card creation without context switching. c is the standard issue-create keystroke in Linear / GitHub Issues.

Why swimlanes (§1 #17)? Standup view: "what is each person doing" maps naturally to rows-per-person.

Why scroll preservation (§1 #18)? WebSocket updates re-render; scrolling-to-the-top is jarring during browsing.

Why undo (§1 #19)? Accidental drag is the most common kanban error; undo within 5s = forgiveness without overhead.

Why drag-target preview (§1 #20)? Card order matters within column; preview removes guesswork about insert position.

Why keyboard reorder (§1 #21)? Operators reordering for prioritisation need keyboard parity; Shift+J/K is the discoverable pattern.

Why WIP overflow indicator (§1 #22)? Operators glancing at the board see overflow immediately; "5/3 ⚠" is visual quick-glance signal.


§3 — API contract (component sketches)

// web/proj-client/src/views/Kanban/Board.tsx
export function Board({ cycleId }: { cycleId: string }) {
  const issues = useIssuesForCycle(cycleId);
  const [filter, setFilter] = useUrlFilter();
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, { coordinateGetter: kbdCoordinateGetter }),
  );
  return (
    <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
      <div role="application" aria-label="Kanban board">
        {COLUMNS.map(status => (
          <Column key={status} status={status}
                  items={issues.filter(i => i.status === status && matchesFilter(i, filter))} />
        ))}
        <DragLayer />
      </div>
    </DndContext>
  );

  async function handleDragEnd(ev: DragEndEvent) {
    const issueId = ev.active.id as string;
    const newStatus = ev.over?.id as IssueStatus | undefined;
    if (!newStatus) return;
    const issue = issues.find(i => i.id === issueId)!;
    if (!isLegal(issue.status, newStatus)) {
      toast('Cannot move there', 'error');
      return;   // dnd-kit snaps back automatically
    }
    optimisticMove(issueId, newStatus);
    try {
      const reason = requiresReason(issue.status, newStatus) ? await promptReason() : undefined;
      await postTransition(issueId, newStatus, reason);
      emitMemory('proj.kanban_card_moved', { issueId, from: issue.status, to: newStatus,
                                              wasKeyboard: ev.activatorEvent instanceof KeyboardEvent });
    } catch (e) {
      rollbackMove(issueId);
      toast('Transition rejected', 'error');
    }
  }
}
// web/proj-client/src/views/Kanban/Column.tsx
import { FixedSizeList as List } from 'react-window';

export function Column({ status, items }: { status: IssueStatus; items: Issue[] }) {
  const VIRTUALISE_THRESHOLD = 200;
  return (
    <div className="kanban-column" role="group" aria-label={status}>
      <header><h2>{status}</h2><span>{items.length}</span></header>
      {items.length > VIRTUALISE_THRESHOLD ? (
        <List height={600} itemCount={items.length} itemSize={92} width="100%">
          {({ index, style }) => (
            <div style={style}><IssueCard issue={items[index]} /></div>
          )}
        </List>
      ) : (
        items.map(i => <IssueCard key={i.id} issue={i} />)
      )}
    </div>
  );
}
// web/proj-client/src/views/Kanban/KeyboardNav.tsx
function kbdCoordinateGetter(event: KeyboardEvent, args: any) {
  // J/K = down/up, H/L = left/right
  // Cmd+Shift+→ = move card to next-rightward legal column
  // Implementation per @dnd-kit/sortable docs
}

§4 — Acceptance criteria

  1. 6 columns render — board mounts with all 6 status columns.
  2. Drag valid → server accepts — drag Todo→InProgress → POST returns 200; card stays in InProgress.
  3. Drag illegal → snap back — drag Backlog→InReview → 422; card snaps back with animation; toast shown.
  4. Optimistic UI — drag → card visually moves before server response (verified via mock latency).
  5. Reason prompt on re-open — drag Done→InProgress → reason modal appears; submit moves card.
  6. Keyboard nav: Tab moves between columns — Tab cycles through column headers + cards.
  7. Keyboard nav: J/K within column — focus moves down/up.
  8. Keyboard nav: Cmd+Shift+→ moves to next legal column — works only when target is legal.
  9. Enter opens Brief Modal — focused card + Enter → TASK-PROJ-017 modal opens.
  10. Esc dismisses overlays — Esc closes modal / toast / reason prompt.
  11. Virtualised at > 200 items — fixture with 500 items → only ~30 rendered DOM nodes.
  12. 60fps maintained on scroll — Chrome devtools performance trace shows no frame > 16ms during scroll.
  13. Awareness indicator visible — second user opens same Brief Modal → cursor indicator shows on first user's view.
  14. Offline banner appears — disconnect WebSocket → banner; drags queued.
  15. URL filter sync — filter by member → URL updates; reload preserves filter.
  16. axe-core passes — no critical/serious violations on board page.
  17. memory audit row per move — every drag-induced transition → row.
  18. was_keyboard flag — keyboard-triggered move → flag true; mouse → false.
  19. Optimistic rollback metric — server reject → counter optimistic_rollback increments.
  20. LCP p95 < 2.5s — empirical on 1000-issue board.
  21. WIP soft block — drag into column at WIP limit → confirm dialog; confirm proceeds + audit (AC for §1 #13).
  22. Compact card at low zoom — set zoom 50% → cards render compact (no badges) (AC for §1 #14).
  23. Bulk-select via Shift-click — Shift-click two cards → range selected; bulk drag moves both (AC for §1 #15).
  24. Quick-add c opens creator — Tab to column header + c → inline creator (AC for §1 #16).
  25. Swimlanes by assignee?swimlanes=assignee → rows per assignee (AC for §1 #17).
  26. Scroll preserved across update — scroll to 50%, receive WS update → scroll preserved (AC for §1 #18).
  27. Undo within 5s — drag card; Cmd+Z within 5s → card reverts; proj.kanban_card_move_undone row (AC for §1 #19).
  28. Drag-target preview shows position — ghost card appears in target at insert position (AC for §1 #20).
  29. Shift+J/K reorders — focused card + Shift+J → moves down within column; persists to DB (AC for §1 #21).
  30. WIP overflow indicator in header — column with 7 cards + WIP=5 → header shows "7/5 ⚠" (AC for §1 #22).

§5 — Verification

// kanban_test.tsx
test('drag valid status accepted', async () => {
  const { user } = render(<Board cycleId={cycle.id} />);
  const card = screen.getByText(/My Issue/);
  await user.drag(card, screen.getByRole('group', { name: 'in_progress' }));
  await waitFor(() => expect(card).toHaveAttribute('data-status', 'in_progress'));
});

test('drag illegal status snaps back', async () => {
  const { user } = render(<Board cycleId={cycle.id} />);
  mockTransition.fail422();
  const card = screen.getByText(/Backlog Issue/);
  await user.drag(card, screen.getByRole('group', { name: 'in_review' }));
  await waitFor(() => expect(card).toHaveAttribute('data-status', 'backlog'));
  expect(screen.getByRole('alert')).toHaveTextContent(/cannot move/i);
});

test('keyboard nav: J moves focus down', async () => {
  const { user } = render(<Board cycleId={cycle.id} />);
  const first = screen.getAllByTestId('issue-card')[0];
  first.focus();
  await user.keyboard('j');
  expect(screen.getAllByTestId('issue-card')[1]).toHaveFocus();
});

test('virtualised at 500 items', async () => {
  setupCycle({ issueCount: 500 });
  render(<Board cycleId={cycle.id} />);
  const cards = screen.queryAllByTestId('issue-card');
  expect(cards.length).toBeLessThan(50);
});
// kanban_a11y_test.tsx
test('axe-core passes', async () => {
  const { container } = render(<Board cycleId={cycle.id} />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

test('keyboard-only workflow', async () => {
  const { user } = render(<Board cycleId={cycle.id} />);
  await user.tab();   // first column
  await user.tab();   // first card
  await user.keyboard('{Enter}');   // Brief Modal opens
  expect(screen.getByRole('dialog')).toBeVisible();
});

§6 — Implementation skeleton

(Component sketches above.)


§7 — Dependencies


§8 — Example payloads

{
  "kind": "proj.kanban_card_moved",
  "payload": {
    "issue_id":    "iss-...",
    "from_status": "todo",
    "to_status":   "in_progress",
    "by_subject_id": "7e57c0de-...",
    "was_keyboard": false,
    "trace_id":   "0af..."
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Server rejects transition422Snap back + toastUser retries
WS disconnect mid-draguseWebSocketStatusQueue in offline bufferReconnect drains
1000+ items in one columnvirtualisation60fps; no jankNone
Keyboard nav loses focusfocus-trap fallbackRe-focus first columnNone
Drag from filtered viewfilter still applies; underlying issue movesCard disappears from view post-dragBy design
Awareness flood (10 users)YjsProvider throttle30Hz capNone
LCP regressionweb-vitals reportingsev-2 alarmInvestigate via Lighthouse
Browser without IntersectionObserverpolyfill via npmNoneNone
Touch device@dnd-kit TouchSensorDrag works on iPadNone
Browser zoom > 200%reflow gracefully; no overlapUsableNone
Cross-tab sync via BroadcastChannelOPTIONAL slice 4+NoneNone
RTL localeflex direction reversedColumns flow right-to-leftNone
Issue deleted during dragserver returns 404; toastCard removedNone
Optimistic move + rollback orderingUI state machineLast-write-wins on local stateNone
WIP soft block but operator overridesaudit emittedNoneNone
WIP limits not configuredfeature absentworks without WIPNone
Compact card cuts off important infohover shows tooltipNoneOperator can re-enlarge
Bulk drag with > 50 cardsbounded; warn at 30bulk transition slow but completesNone
Quick-add with invalid titleinline validationerror shown; not createdNone
Swimlanes with many assignees (50+)virtualised; collapsibleNoneNone
Scroll preservation breaks on layout changeproperty testNoneAuthor fixes
Undo after 5srejectedNoneNone
Drag preview lag at 60fps boundaryRAF-throttledsmoothNone
Shift+J reorder race with WS updateconflict resolution via LWWNoneNone
WIP overflow indicator wrapsCSS layoutNoneNone
Quick-add c triggers in input fieldevent filter (no in inputs)NoneNone
Touch device drag-and-drop with bulk selectpartial support; warnbulk single-card only on touchNone
Locale RTL with swimlanesreflow correctlyNoneNone
WS connection flapping mid-undopreserves intent; reapplies on reconnectNoneNone

§11 — Implementation notes


End of TASK-PROJ-014.

As built (2026-07-02)

Client code lives under apps/web/src (there is no web/proj-client/).