---
id: T-2019
name: "arc-007 S4d — drag-to-reorder kanban (cross-column status change)"
description: >
  arc-007 S4d (last T-1992 slice) — drag a kanban card onto a different column to
  change its status via the existing /api/task/<id>/status endpoint (no new server
  route). Native HTML5 drag-and-drop, zero library, shell-level delegated listeners
  that survive htmx #content swaps. Keyboard-accessible fallback = the per-card inline
  status select (same endpoint). Within-column reorder is descoped (see Decisions).

status: work-completed
workflow_type: build
owner: human
horizon: now
tags: [arc:watchtower-redesign, ui, watchtower]
components: [tests/playwright/test_kanban_drag.py, 
      tests/unit/test_kanban_drag.py, web/static/kanban-drag.js, 
      web/templates/base.html, web/templates/tasks.html]
related_tasks: [T-1992, T-1987, T-2015, T-2018]
arc_id: watchtower-redesign
created: 2026-05-24T09:46:14Z
last_update: '2026-06-11T22:23:29Z'
date_finished: 2026-05-26T06:49:38Z
cost_estimate_proposed:
  - ts: '2026-05-24T10:00:02Z'
    estimator: bvp-estimator-v1-heuristic
    cost_estimate:
      blast_radius: 0
      tier: 2
      effort: 8
    rationale: blast_radius=0 (no-signal); tier=2 (no-signal); effort=8 
      (no-signal)
    rubric_sha: e4a00f38e801
bvp_scores_proposed:
  - ts: '2026-05-24T10:00:02Z'
    estimator: bvp-estimator-v1-heuristic
    scores:
      D1: 3
      D2: 3
      D3: 0
      D4: 0
    rationale: D1=3 (body:test-or-audit-check); D2=3 
      (body:component-silent-failure); D3=0 (no-signal); D4=0 (no-signal)
    rubric_sha: e4a00f38e801
  - ts: '2026-05-28T22:54:11Z'
    estimator: bvp-estimator-v1-heuristic
    scores:
      D1: 3
      D2: 3
      D3: 0
      D4: 0
      F1: 0
      F2: 0
    rationale: D1=3 (body:test-or-audit-check); D2=3 
      (body:component-silent-failure); D3=0 (no-signal); D4=0 (no-signal); F1=0 
      (no-signal); F2=0 (no-signal)
    rubric_sha: e4a00f38e801
  - ts: '2026-06-11T22:23:29Z'
    estimator: bvp-estimator-v1-heuristic
    scores:
      D1: 3
      D2: 3
      D3: 0
      D4: 0
      F-RECALL: 0
      F-ORCH: 0
      F3: 0
      F1: 0
      F2: 0
    rationale: D1=3 (body:test-or-audit-check); D2=3 
      (body:component-silent-failure); D3=0 (no-signal); D4=0 (no-signal); 
      F-RECALL=0 (no-signal); F-ORCH=0 (no-signal); F3=0 (no-signal); F1=0 
      (no-signal); F2=0 (no-signal)
    rubric_sha: e4a00f38e801
---

# T-2019: arc-007 S4d — drag-to-reorder kanban (cross-column status change)

## Context

arc-007 S4d — the last remaining T-1992 slice (build order S4a→S4c→S4b→S4e/S6c→**S4d**).
The kanban board at `/tasks` renders one `.kanban-column[data-status="X"]` per status
(`web/templates/tasks.html`), each holding `.kanban-card` articles. A governed status-change
endpoint already exists — `POST /api/task/<id>/status` (`web/blueprints/tasks.py:1036`) runs
`fw task update --status`, so it inherits every gate (T-1068 horizon invariant, enum
validation, R-033). The per-card inline status `<select>` already POSTs to it.

S4d makes the board **directly manipulable**: drag a card onto another column → POST that
column's `data-status` to the existing endpoint. This reuses the exact pattern S4e
(bulk-actions.js) and S4a (task-panel.js) established — native vanilla JS, zero dependency,
a shell-level script in `base.html` with document-delegated listeners that survive htmx
`#content` swaps. No drag library (SortableJS et al.) — HTML5 `dragstart`/`dragover`/`drop`
is sufficient for cross-column card moves.

**Scope (BVP carve):** cross-column drag = high value (direct manipulation of core workflow
state) / low cost (existing endpoint, native DnD). Within-column reorder = low value (the
framework has no task-order field; ordering is not meaningful persisted state) / high cost
(new persistence model + a11y). Within-column reorder is **descoped** — see Decisions.

## Acceptance Criteria

### Agent
- [x] Each kanban card carries `draggable="true"` and `data-task-id="T-XXX"`; dragging a card onto a *different* `.kanban-column` POSTs that column's `data-status` to the existing `POST /api/task/<id>/status` (no new server route) and the status change persists (unit + Playwright).
- [x] Drag logic lives in a shell-level script (`web/static/kanban-drag.js`, injected via `base.html`) with **document-delegated** listeners, so it survives an htmx `#content` swap (Playwright: drag still works after switching board↔list↔board).
- [x] Dropping a card back on its **own** column is a no-op — no POST fires (Playwright asserts no status request on same-column drop).
- [x] On a successful cross-column drop a toast confirms the change and the board refreshes; on a rejected move the existing `htmx:responseError`/`showToast` path surfaces the server error text — no silent failure (unit asserts toast wiring present; Playwright proves the success toast).
- [x] Keyboard-accessible fallback is present and operable: every card still renders its inline status `<select>` which changes the card's column via the same endpoint (unit asserts the select is on every card; this is the documented a11y equivalent of cross-column drag).
- [x] No new server route or mutation path is added — `/api/task/<task_id>/status` exists in the url_map and no rule contains "drag"/"reorder" (unit).
- [x] Drag does not hijack the existing card interactions: the panel-open id link (`data-task-panel`), the bulk-select checkbox, and the inline selects remain clickable (Playwright regression: open panel + tick bulk checkbox still work on a draggable card).

### Human
- [ ] [REVIEW] Drag interaction feels smooth and the drop targets are clear
  **Steps:**
  1. Open the board: `cd /opt/999-Agentic-Engineering-Framework && bin/fw watchtower url` → open `<url>/tasks` in a browser
  2. Drag a card from one column onto another; watch the drop-target column highlight and the dragged card's visual state
  3. Drop it; confirm the toast appears and the card lands in the new column after refresh
  4. Try a keyboard-only path: tab to a card's status select and change it — confirm the card moves columns
  **Expected:** Drop targets are unambiguous (clear column highlight), the drag has no jank, the toast confirms, and the keyboard fallback moves the card equivalently
  **If not:** Note which column/transition felt unclear or janky and capture a screenshot; check the browser console for errors

## Verification

# tasks.py still parses
python3 -c "import ast; ast.parse(open('web/blueprints/tasks.py').read())"
# the new drag script is valid JS (node if available; else skip cleanly)
command -v node >/dev/null && node --check web/static/kanban-drag.js || echo "node not present — skipping JS syntax check"
# unit guards for the slice
python3 -m pytest tests/unit/test_kanban_drag.py -q
# reviewer static scan passes
out=$(bin/fw reviewer T-2019 2>&1); echo "$out" | grep -q "Overall:.*PASS"

## RCA

<!-- Not a bug-class task — no RCA required. -->

## Evolution

### 2026-05-24 — slice carve + within-column descope
- **What changed:** The umbrella T-1992 S4d AC bundled "drag between columns" + "reorder within a column". On survey, cross-column drag maps cleanly onto the existing governed `/api/task/<id>/status` endpoint (zero new mutation path), but within-column ordering has **no model in the framework** — tasks sort by id/name/horizon; there is no per-task order field, and adding one is a new mutation + dubious cross-session semantics.
- **Plan impact:** S4d ships cross-column drag only. Within-column reorder is descoped (high cost / low value — see Decisions).
- **Triggered:** Scope cut documented here + in Decisions; no new task filed (within-column reorder can be re-proposed if a real ordering need emerges — it would need an order-persistence design first).

## Decisions

### 2026-05-24 — within-column reorder descoped
- **Chose:** Ship cross-column drag (status change via existing endpoint) only; do not implement within-column card reordering in S4d.
- **Why:** The framework has no task-order concept — tasks render in id/name/horizon sort order. Persisting an arbitrary within-column order would require either a new frontmatter `order` field (a new mutation path with unclear cross-agent/cross-session semantics) or per-browser order storage (low value, high a11y cost). Per the standing directive (prioritize high-value/low-cost), the cross-column move carries essentially all the value at a fraction of the cost.
- **Rejected:** (a) SortableJS or similar drag library — unnecessary; native HTML5 DnD covers cross-column moves and keeps the zero-dependency vanilla-JS pattern. (b) New `order` frontmatter field — adds a mutation path and ordering semantics the framework doesn't otherwise have.

### 2026-05-24 — a11y fallback is the existing inline status select
- **Chose:** Treat the per-card inline status `<select>` as the keyboard-accessible equivalent of cross-column drag, rather than building a separate keyboard drag affordance.
- **Why:** Native HTML5 drag-and-drop is not keyboard operable, but changing a card's status via its select moves it to exactly the target column through the same governed endpoint — a genuine, already-shipped equivalent. Building a parallel keyboard-drag mechanism would duplicate that with no added capability.
- **Rejected:** Custom keyboard "grab/move/drop" handlers — duplicate behaviour of the status select for no new capability.

## Recommendation

**Recommendation:** GO (pending the one [REVIEW] Human AC)

**Rationale:** Cross-column drag reuses the existing governed status-change endpoint (no new
mutation path, inherits all gates), follows the established zero-dependency shell-script +
delegated-listener pattern (S4a/S4e), and ships with a real keyboard-accessible fallback.
Within-column reorder is a documented, BVP-justified scope cut. All Agent ACs are covered by
unit + Playwright tests; the single Human AC is a genuine taste check on drag feel/clarity.

**Evidence:**
- Unit `tests/unit/test_kanban_drag.py` — **6 passed** (cards draggable + data-task-id; script injected shell-level; no drag/reorder route; status endpoint still validates enum; a11y select fallback present; script POSTs + toasts failures).
- Playwright `tests/playwright/test_kanban_drag.py` — **5 passed** (cross-column drag fires `POST /api/task/<id>/status` with `status=<target>` + "Moved" toast; same-column drop is a no-op; listeners survive an htmx `#content` swap; drag doesn't hijack checkbox/panel; review screenshot).
- Eyes-on screenshot `web/static/ux-review/T-2019-kanban-drag.png` — drop-target column shows the dashed outline, dragged card is faded; affordance reads clearly.
- Net-zero: the Playwright status POST is intercepted with `page.route` (synthetic 200) — no real `fw task update` runs.
- Regression: `test_task_panel_edit.py` + `test_bulk_actions.py` — **8 passed** (S4a/S4b/S4e coexist with the new draggable cards).
- Reviewer `fw reviewer T-2019` — **Overall: PASS**, no findings, needs_human=no.

## Updates

### 2026-05-24T09:46:14Z — task-created [task-create-agent]
- **Action:** Created task via task-create agent
- **Output:** /opt/999-Agentic-Engineering-Framework/.tasks/active/T-2019-arc-007-s4d--drag-to-reorder-kanban-cros.md
- **Context:** Initial task creation

## Reviewer Verdict (v1.5)

- **Scan ID:** R-26ccaf0f
- **Timestamp:** 2026-05-26T06:49:49Z
- **Catalogue:** v1.3-seed
- **Overall:** PASS
- **Needs Human:** no
- **Findings:** none

### 2026-05-26T06:49:38Z — status-update [task-update-agent]
- **Change:** status: started-work → work-completed
