---
phase: 04-tasks-kanban
plan: 02
type: execute
wave: 2
depends_on:
  - 04-01-PLAN.md
files_modified:
  - backend/internal/web/handlers_tasks.go
  - backend/templates/tasks.templ
  - backend/internal/web/router.go
  - backend/cmd/web/main.go
  - backend/templates/tablos.templ
  - backend/templates/layout.templ
  - backend/internal/web/handlers_tasks_test.go
autonomous: true
requirements:
  - TASK-01
  - TASK-02
  - TASK-06

must_haves:
  truths:
    - "GET /tablos/{id} renders a kanban board with 4 column headers: To do, In progress, In review, Done"
    - "Authenticated owner sees task count badge (0 initially) on each column header"
    - "POST /tablos/{id}/tasks creates a task and returns TaskCard fragment for HTMX"
    - "POST /tablos/{id}/tasks with empty title returns 422 with inline error"
    - "GET /tablos/{id}/tasks/{task_id}/delete-confirm returns TaskDeleteConfirmFragment"
    - "POST /tablos/{id}/tasks/{task_id}/delete hard-deletes the task and returns empty div"
    - "Non-owner GET/POST task routes return 404"
    - "Unauthenticated requests redirect to /login"
  artifacts:
    - path: "backend/internal/web/handlers_tasks.go"
      provides: "TasksDeps, TaskCreateHandler, TaskDeleteConfirmHandler, TaskDeleteHandler, TaskShowHandler, TaskNewFormHandler, TaskCancelNewHandler"
      exports: ["TasksDeps", "TaskCreateHandler"]
    - path: "backend/templates/tasks.templ"
      provides: "KanbanBoard, KanbanColumn, TaskCard, TaskCreateForm, TaskDeleteConfirmFragment, AddTaskTrigger"
      contains: "KanbanBoard"
    - path: "backend/internal/web/router.go"
      provides: "task routes wired inside RequireAuth group"
      contains: "tasks/reorder"
    - path: "backend/templates/tablos.templ"
      provides: "KanbanBoard embedded below tablo detail header"
      contains: "KanbanBoard"
  key_links:
    - from: "backend/templates/tasks.templ"
      to: "backend/internal/web/handlers_tasks.go"
      via: "templ components rendered by handler functions"
      pattern: "templates.KanbanBoard|templates.TaskCard"
    - from: "backend/internal/web/router.go"
      to: "backend/internal/web/handlers_tasks.go"
      via: "chi route group inside RequireAuth"
      pattern: "TaskCreateHandler|TaskDeleteHandler"
    - from: "backend/templates/tablos.templ"
      to: "backend/templates/tasks.templ"
      via: "TabloDetailPage calls KanbanBoard"
      pattern: "@KanbanBoard"
---

<objective>
Vertical slice 1: render the kanban board on the tablo detail page (TASK-01), add task creation via inline column form (TASK-02), and add task deletion with inline confirmation (TASK-06). After this plan, a real user can open a tablo, see 4 columns, create tasks, and delete them.

Purpose: Delivers the first user-visible slice — the kanban board scaffolding plus the two simplest task mutations. No drag-and-drop yet; that lands in Plan 03.
Output: handlers_tasks.go with 7 handlers, tasks.templ with 6 components, router.go updated, tablos.templ updated to embed kanban, handlers_tasks_test.go updated from SKIP to real assertions.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-UI-SPEC.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-01-SUMMARY.md

<interfaces>
<!-- Key types and contracts the executor needs. -->

From backend/internal/web/handlers_tablos.go (handler pattern to mirror exactly):
  type TablosDeps struct { Queries *sqlc.Queries }
  func loadOwnedTablo(w, r, deps TablosDeps) (sqlc.Tablo, *auth.User, bool) { ... }
  // HTMX-aware response pattern:
  if r.Header.Get("HX-Request") == "true" {
      w.Header().Set("Content-Type", "text/html; charset=utf-8")
      _ = templates.TaskCard(task, csrfToken).Render(ctx, w)
      return
  }
  http.Redirect(w, r, "/tablos/"+tabloID.String(), http.StatusSeeOther)

From backend/internal/web/router.go (route registration pattern):
  r.Group(func(r chi.Router) {
      r.Use(auth.RequireAuth)
      // static segments BEFORE parametric (Pitfall 1):
      r.Get("/tablos/new", ...)
      r.Post("/tablos", ...)
      r.Get("/tablos/{id}", ...)
      ...
  })

From backend/internal/db/sqlc/ (after Plan 01 generates these):
  type TaskStatus string
  const (
      TaskStatusTodo       TaskStatus = "todo"
      TaskStatusInProgress TaskStatus = "in_progress"
      TaskStatusInReview   TaskStatus = "in_review"
      TaskStatusDone       TaskStatus = "done"
  )
  type Task struct {
      ID          uuid.UUID
      TabloID     uuid.UUID
      Title       string
      Description pgtype.Text
      Status      TaskStatus
      Position    int32
      CreatedAt   pgtype.Timestamptz
      UpdatedAt   pgtype.Timestamptz
  }
  // Key query signatures:
  func (q *Queries) ListTasksByTablo(ctx, tabloID uuid.UUID) ([]Task, error)
  func (q *Queries) InsertTask(ctx, params InsertTaskParams) (Task, error)
  func (q *Queries) DeleteTask(ctx, params DeleteTaskParams) error
  func (q *Queries) MaxPositionByTabloAndStatus(ctx, params MaxPositionByTabloAndStatusParams) (int32, error)

From backend/templates/tasks_forms.go (Plan 01):
  type TaskCreateForm struct { Title string; Status string }
  type TaskCreateErrors struct { Title string; General string }
  type TaskUpdateForm struct { Title string; Description string }
  type TaskUpdateErrors struct { Title string; Description string; General string }

From backend/internal/web/ui/variants.go:
  ButtonVariantDanger  ButtonVariant = "danger"
  ButtonToneSolid      ButtonTone    = "solid"
  ButtonToneSoft       ButtonTone    = "soft"
  BadgeVariantInfo     BadgeVariant  = "info"
  SizeMD               Size          = "md"

From backend/templates/tablos.templ (TabloDetailPage signature to update):
  templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo) { ... }

From backend/templates/layout.templ (Sortable.js script tag placement):
  <script src="/static/htmx.min.js" defer></script>
  // Add after htmx: <script src="/static/sortable.min.js" defer></script>

UI-SPEC locked interaction contracts:
  - D-08/UI-SPEC §2: POST /tablos/{id}/tasks target="#column-{status}" swap="beforeend" for new TaskCard
  - D-08/UI-SPEC §2: OOB swap resets #add-task-slot-{status} to AddTaskTrigger
  - D-09/UI-SPEC §5: POST /tablos/{id}/tasks/{task_id}/delete returns <div id="task-{task_id}" class="task-card-zone"></div>
  - D-08/UI-SPEC §1: Column min-h-16, space-y-2, sortable-column; empty state "No tasks yet" in italic
  - UI-SPEC §4: drag handle .task-drag-handle with ⠿ glyph, aria-hidden="true"
  - UI-SPEC §3: .task-card-zone on both TaskCard wrapper and TaskEditFragment wrapper (for outerHTML round-trips)
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: handlers_tasks.go — TasksDeps + create/delete/show handlers</name>
  <files>backend/internal/web/handlers_tasks.go, backend/internal/web/router.go, backend/cmd/web/main.go</files>
  <read_first>
    - backend/internal/web/handlers_tablos.go (full file — TablosDeps, loadOwnedTablo, all handler functions — replicate this pattern exactly)
    - backend/internal/web/router.go (full file — RequireAuth group, route registration order for Pitfall 1)
    - backend/cmd/web/main.go (NewRouter call site — must be updated to pass TasksDeps)
    - backend/internal/db/sqlc/ (generated task types and query functions — confirm exact signatures)
  </read_first>
  <behavior>
    - GET /tablos/{id}/tasks/new?status=todo returns 200 + TaskCreateForm HTML fragment
    - GET /tablos/{id}/tasks/new?status=invalid_status returns 200 + TaskCreateForm HTML (status defaults to "todo" if invalid)
    - POST /tablos/{id}/tasks with HX-Request:true, title="My task", status="todo" returns 200 + TaskCard HTML containing "My task"
    - POST /tablos/{id}/tasks with HX-Request:true, title="" returns 422 + form HTML containing "Title is required"
    - POST /tablos/{id}/tasks without HX-Request header returns 303 redirect to /tablos/{id}
    - GET /tablos/{id}/tasks/{task_id}/delete-confirm returns 200 + TaskDeleteConfirmFragment HTML
    - POST /tablos/{id}/tasks/{task_id}/delete with HX-Request:true returns 200 + empty div with id="task-{task_id}"
    - Any task route called by non-owner returns 404
    - GET /tablos/{id}/tasks/{task_id}/show returns 200 + TaskCard HTML (used by cancel paths)
    - GET /tablos/{id}/tasks/cancel-new?status=todo returns 200 + AddTaskTrigger HTML
  </behavior>
  <action>
    Create `backend/internal/web/handlers_tasks.go` in package web. Structure mirrors handlers_tablos.go exactly.

    TasksDeps struct: field Queries *sqlc.Queries. This struct is referenced in handlers_tasks_test.go (Plan 01).

    Define these exported Go constants/variables in handlers_tasks.go:
    - TaskColumns []sqlc.TaskStatus = {TaskStatusTodo, TaskStatusInProgress, TaskStatusInReview, TaskStatusDone}
    - TaskColumnLabels map[sqlc.TaskStatus]string with values: todo→"To do", in_progress→"In progress", in_review→"In review", done→"Done"

    loadOwnedTabloForTask helper: accepts (w, r, deps TasksDeps) — calls loadOwnedTablo (which is in handlers_tablos.go, same package) to get the tablo, then parses chi.URLParam(r, "task_id") as uuid.UUID, then calls deps.Queries.GetTaskByID with (ctx, GetTaskByIDParams{ID: taskID, TabloID: tablo.ID}) — returns (sqlc.Tablo, sqlc.Task, *auth.User, bool). Returns false+writes HTTP error on any failure.

    Implement these handler functions, each returning http.HandlerFunc:
    - TaskNewFormHandler(deps) — GET /tablos/{id}/tasks/new?status={status}: read status from r.URL.Query().Get("status"), validate it is one of the 4 valid statuses (default to "todo" if invalid), return TaskCreateForm fragment
    - TaskCancelNewHandler(deps) — GET /tablos/{id}/tasks/cancel-new?status={status}: return AddTaskTrigger fragment
    - TaskCreateHandler(deps) — POST /tablos/{id}/tasks: read title=r.PostFormValue("title"), status=r.PostFormValue("status"). Validate title non-empty (max 255 chars). On validation error: 422 + form fragment (HTMX) or 422 + redirect (non-HTMX). On success: call MaxPositionByTabloAndStatus to get current max, compute nextPos = maxPos + 100. Call InsertTask with InsertTaskParams{TabloID: tablo.ID, Title: title, Description: pgtype.Text{Valid: false}, Status: sqlc.TaskStatus(status), Position: nextPos}. HTMX response: set HX-Reswap: "beforeend" + HX-Retarget: "#column-{status}", return TaskCard fragment; also include OOB AddTaskTrigger swap targeting #add-task-slot-{status}. Non-HTMX: 303 to /tablos/{id}.
    - TaskShowHandler(deps) — GET /tablos/{id}/tasks/{task_id}/show: return TaskCard fragment
    - TaskDeleteConfirmHandler(deps) — GET /tablos/{id}/tasks/{task_id}/delete-confirm: return TaskDeleteConfirmFragment
    - TaskDeleteHandler(deps) — POST /tablos/{id}/tasks/{task_id}/delete: call DeleteTask(ctx, DeleteTaskParams{ID: task.ID, TabloID: tablo.ID}). HTMX: return 200 + empty div `<div id="task-{task_id}" class="task-card-zone"></div>`. Non-HTMX: 303 to /tablos/{id}.

    In `backend/internal/web/router.go`, inside the RequireAuth group, add task routes AFTER the existing tablo routes but register static segments BEFORE parametric (Pitfall 1 from RESEARCH.md). Route order within /tablos/{id}/tasks*:
    1. r.Get("/tablos/{id}/tasks/new", TaskNewFormHandler(taskDeps))
    2. r.Post("/tablos", ...) — existing
    3. r.Post("/tablos/{id}/tasks", TaskCreateHandler(taskDeps))
    4. r.Get("/tablos/{id}/tasks/cancel-new", TaskCancelNewHandler(taskDeps))
    5. r.Post("/tablos/{id}/tasks/reorder", TaskReorderHandler(taskDeps)) — stub returning 501 for now; Plan 03 implements it
    6. r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps))
    7. r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) — stub returning 501 for Plan 03
    8. r.Post("/tablos/{id}/tasks/{task_id}", TaskUpdateHandler(taskDeps)) — stub returning 501 for Plan 03
    9. r.Get("/tablos/{id}/tasks/{task_id}/delete-confirm", TaskDeleteConfirmHandler(taskDeps))
    10. r.Post("/tablos/{id}/tasks/{task_id}/delete", TaskDeleteHandler(taskDeps))

    Update NewRouter signature to accept taskDeps TasksDeps as a parameter (after tabloDeps). Update all call sites. In `backend/cmd/web/main.go`, instantiate TasksDeps{Queries: queries} and pass to NewRouter.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./internal/web/ -run "TestTaskCreate|TestTaskDelete|TestTaskOwnership" -v -count=1</automated>
  </verify>
  <done>
    `go build ./...` exits 0. TestTaskCreate, TestTaskDelete, TestTaskOwnership tests pass (green). Handler functions TaskCreateHandler and TaskDeleteHandler exist in handlers_tasks.go. Router has task routes registered. `grep -c 'TaskCreateHandler' internal/web/router.go` returns 1 or more.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: tasks.templ — KanbanBoard, KanbanColumn, TaskCard, TaskCreateForm, TaskDeleteConfirmFragment, AddTaskTrigger</name>
  <files>backend/templates/tasks.templ, backend/templates/tablos.templ, backend/templates/layout.templ</files>
  <read_first>
    - backend/templates/tablos.templ (full file — TabloDetailPage, TabloCard patterns; understand how KanbanBoard is embedded below tablo header)
    - backend/templates/layout.templ (full file — script tag location for adding sortable.min.js)
    - backend/internal/web/ui/button.templ (Button component signature)
    - backend/internal/web/ui/badge.templ (Badge component signature)
    - backend/internal/web/ui/csrf_field.templ (CSRFField usage)
    - backend/internal/web/handlers_tasks.go (TaskColumns, TaskColumnLabels — used in templates)
  </read_first>
  <behavior>
    - KanbanBoard renders a div#kanban-board with flex gap-4 overflow-x-auto pb-4 containing 4 KanbanColumn components
    - KanbanBoard contains a hidden form#reorder-form with hx-post="/tablos/{id}/tasks/reorder" hx-target="#kanban-board" hx-swap="outerHTML"
    - KanbanColumn renders: column header with h3 text-sm font-semibold text-slate-700 + ui.Badge(info, count), sortable-column div with data-status and id="column-{status}", empty state "No tasks yet" when len(tasks)==0, add-task slot div with id="add-task-slot-{status}"
    - TaskCard has outer wrapper div with class "task-card-zone" and id="task-{task.ID}", inner .task-card with data-task-id="{task.ID}", drag handle div.task-drag-handle with ⠿ glyph and aria-hidden="true", title text, "Delete" button (soft-danger-md class directly), hx-get for delete-confirm
    - TaskCreateForm has title input (name="title", maxlength="255", required), hidden status input, CSRFField, Save button (solid default md), Discard button (soft neutral md)
    - TaskDeleteConfirmFragment has "Delete task?" heading, "This cannot be undone." body, "Yes, delete" form (POST .../delete with CSRFField), "Keep task" button (hx-get to show)
    - AddTaskTrigger renders the "+ Add task" button with hx-get and hx-target="#add-task-slot-{status}" hx-swap="innerHTML"
    - TabloDetailPage now calls @KanbanBoard(tablo.ID, csrfToken, tasks) after the existing tablo header section; requires tasks []sqlc.Task param added to TabloDetailPage signature
    - layout.templ has sortable.min.js script tag alongside htmx.min.js (both defer)
  </behavior>
  <action>
    Create `backend/templates/tasks.templ` in package templates. Imports needed: backend/internal/db/sqlc, backend/internal/web, backend/internal/web/ui, github.com/google/uuid, strconv (for Itoa in badge count), templ.

    Implement these templ components following exact specs from UI-SPEC.md and RESEARCH.md Pattern 3:

    groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task — a plain Go helper function (not a templ component) in this file that groups the task slice by Status field.

    KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) — outer div#kanban-board, hidden reorder form with @ui.CSRFField(csrfToken), 4 KanbanColumn calls iterating web.TaskColumns. Use templ.SafeURL for action attribute: templ.SafeURL("/tablos/"+tabloID.String()+"/tasks/reorder").

    KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string) — column header div with bg-slate-100 rounded px-3 py-2 mb-2, h3 text-sm font-semibold text-slate-700 with column label from web.TaskColumnLabels[status] + ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo}). Sortable list div with classes "sortable-column min-h-16 space-y-2", data-status={string(status)}, id={"column-"+string(status)}, aria-label={web.TaskColumnLabels[status]+" column"}. Empty state paragraph when len(tasks)==0. For each task: @TaskCard(tabloID, task, csrfToken). Add-task slot div#add-task-slot-{status} containing @AddTaskTrigger(tabloID, status, csrfToken).

    TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) — outer div with class "task-card-zone" and id={"task-"+task.ID.String()}. Inner div class="task-card" with data-task-id={task.ID.String()}. Include: drag handle div class="task-drag-handle ..." aria-hidden="true" with ⠿ glyph. Task title in p tag. Clickable area for edit (hx-get to .../edit, hx-target="closest .task-card-zone", hx-swap="outerHTML") with role="button" aria-label={"Edit task: "+task.Title}. Delete button using class "ui-button-soft-danger-md" directly (not ui.Button — this variant is raw CSS) with hx-get for delete-confirm, hx-target="closest .task-card-zone", hx-swap="outerHTML", aria-label={"Delete task: "+task.Title}.

    TaskCreateForm(tabloID uuid.UUID, status sqlc.TaskStatus, form templates.TaskCreateForm, errs templates.TaskCreateErrors, csrfToken string) — form with method POST, action="/tablos/{tabloID}/tasks", hx-post, hx-target="#column-{status}", hx-swap="beforeend". Hidden status input (name="status"). Title input with name="title", value={form.Title}, maxlength="255", required, placeholder="Task title". @FieldError(errs.Title) if non-empty. CSRFField. Save button (ui.Button solid default md type="submit"). Discard button (ui.Button soft neutral md) with hx-get="/tablos/{tabloID}/tasks/cancel-new?status={status}" hx-target="#add-task-slot-{status}" hx-swap="innerHTML".

    TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken string) — outer div class="task-card-zone" id={"task-"+task.ID.String()}. Heading "Delete task?", body "This cannot be undone." Delete form: method POST, action="/tablos/{tabloID}/tasks/{taskID}/delete", hx-post, hx-target="closest .task-card-zone", hx-swap="outerHTML", with CSRFField + "Yes, delete" submit button (ui.Button solid danger md). Keep button (ui.Button soft neutral md) with hx-get=".../show" hx-target="closest .task-card-zone" hx-swap="outerHTML" label="Keep task".

    AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string) — a button with class "ui-button ui-button-soft-neutral-md w-full text-left text-sm mt-2", hx-get="/tablos/{tabloID}/tasks/new?status={status}", hx-target="#add-task-slot-{status}", hx-swap="innerHTML", label "+ Add task".

    TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string) — a helper component used by TaskCreateHandler to append the new card AND return OOB reset of the add-task slot. Contains: @TaskCard(...) followed by a div with hx-swap-oob="innerHTML:#add-task-slot-{status}" containing @AddTaskTrigger(...).

    Update `backend/templates/tablos.templ`: Change TabloDetailPage(user, csrfToken, tablo) to TabloDetailPage(user, csrfToken, tablo, tasks []sqlc.Task). Add @KanbanBoard(tablo.ID, csrfToken, tasks) below the existing tablo header/edit/delete sections with a div class="mt-8" wrapper. Also update the handler in handlers_tablos.go to fetch tasks before rendering: call deps.Queries.ListTasksByTablo(ctx, tablo.ID) and pass results to TabloDetailPage. If query fails, log and use empty slice (don't 500 — the tablo itself is valid). NOTE: TablosDeps must also reference Queries which has the tasks queries after sqlc generate; no struct change needed since both tablo and task queries are on the same *sqlc.Queries.

    Update `backend/templates/layout.templ`: add `<script src="/static/sortable.min.js" defer></script>` after the htmx.min.js script tag. Also update the footer text from "Phase 3 · Tablos" to "Phase 4 · Tasks".

    After editing .templ files, run: cd backend && just generate && go build ./...
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run "TestTasksKanbanRenders|TestTaskCreate|TestTaskDelete" -v -count=1</automated>
  </verify>
  <done>
    `just generate` exits 0 (no templ errors). `go build ./...` exits 0. TestTasksKanbanRenders, TestTaskCreate, TestTaskDelete all pass. `grep -rn 'KanbanBoard' templates/tasks.templ` returns at least one match. `grep -c 'sortable.min.js' templates/layout.templ` returns 1.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: Update handlers_tasks_test.go — turn RED stubs GREEN for TASK-01, TASK-02, TASK-06</name>
  <files>backend/internal/web/handlers_tasks_test.go</files>
  <read_first>
    - backend/internal/web/handlers_tasks_test.go (current state — the stubs from Plan 01 to replace)
    - backend/internal/web/handlers_tablos_test.go (full file — integration test patterns: loginUser, preInsertUser, getCSRFToken, HTMX header setting, cookie forwarding)
    - backend/internal/web/handlers_tasks.go (handler signatures — confirm route paths and response shapes)
    - backend/internal/db/sqlc/ (InsertTaskParams, TaskStatus constants for test setup)
  </read_first>
  <behavior>
    - TestTasksKanbanRenders: authenticated GET /tablos/{id} response body contains "To do", "In progress", "In review", "Done" (4 column headers) and "kanban-board" id attribute
    - TestTaskCreate: authenticated HTMX POST /tablos/{id}/tasks with title="Finish tests", status="todo" returns 200; body contains "Finish tests"; task exists in DB after
    - TestTaskCreateValidation: HTMX POST with title="" returns 422; body contains "Title is required"
    - TestTaskDelete: pre-insert a task; HTMX POST /tablos/{id}/tasks/{task_id}/delete returns 200; body contains empty div with id="task-{task_id}"; GetTaskByID after delete returns pgx.ErrNoRows
    - TestTaskOwnership: userB attempts GET /tablos/{userA_tablo_id}/tasks/new returns 404; POST /tablos/{userA_tablo_id}/tasks returns 404
    - TestTasksKanbanRenders also verifies non-owner gets 404 (ownership guard from loadOwnedTablo)
  </behavior>
  <action>
    Replace the t.Skip() stubs in `backend/internal/web/handlers_tasks_test.go` for TestTasksKanbanRenders, TestTaskCreate, TestTaskCreateValidation, TestTaskDelete, and TestTaskOwnership with real integration test bodies.

    Use the same test helper pattern as handlers_tablos_test.go: setupTestDB(t), preInsertUser, loginUser, getCSRFToken.

    For each test that needs a tablo: use q.InsertTablo(ctx, InsertTabloParams{UserID: user.ID, Title: "Test tablo", ...}).

    For TestTaskCreate and TestTaskDelete: pre-insert a tablo, then make the request.

    For TestTaskDelete: pre-insert a task via q.InsertTask(ctx, InsertTaskParams{TabloID: tablo.ID, Title: "Delete me", Status: sqlc.TaskStatusTodo, Position: 100}).

    HTMX requests: set req.Header.Set("HX-Request", "true") and req.Header.Set("Content-Type", "application/x-www-form-urlencoded").

    For route param construction use tablo.ID.String() and task.ID.String().

    newTaskTestRouter helper: mirrors newTabloTestRouter but passes TasksDeps alongside TablosDeps. Since NewRouter now takes TasksDeps, update this call. Also keep TestTaskReorderCrossColumn, TestTaskReorderSameColumn, TestTaskUpdate, TestTaskOrderPersists as t.Skip() — those are Plan 03.

    Run the full task test suite after implementing: `go test ./internal/web/ -run TestTask -v -count=1`
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/ -run TestTask -v -count=1 2>&1 | grep -E "^(--- PASS|--- SKIP|--- FAIL|FAIL|ok)"</automated>
  </verify>
  <done>
    `go test ./internal/web/ -run TestTask -v` exits 0. TestTasksKanbanRenders, TestTaskCreate, TestTaskCreateValidation, TestTaskDelete, TestTaskOwnership all show PASS. TestTaskReorder*, TestTaskUpdate, TestTaskOrderPersists show SKIP. No FAIL lines.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Browser → POST /tablos/{id}/tasks | title and status come from user form input |
| POST /tablos/{id}/tasks/{task_id}/delete | task_id from URL param + tablo_id verified against user ownership |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-03 | Elevation of Privilege | loadOwnedTabloForTask | mitigate | GetTaskByID query uses WHERE id=$1 AND tablo_id=$2; tablo_id is already ownership-verified by loadOwnedTablo before task fetch |
| T-04-04 | Tampering | POST /tablos/{id}/tasks title field | mitigate | title validated non-empty, max 255 chars; status validated against known TaskStatus constants before DB insert |
| T-04-05 | Tampering | POST /tablos/{id}/tasks status field | mitigate | sqlc.TaskStatus(status) is passed to DB; Postgres ENUM rejects invalid values at DB layer |
| T-04-06 | Spoofing | Unauthenticated task routes | accept | RequireAuth middleware (Phase 2) blocks all /tablos/* group routes before handlers run |
| T-04-07 | Information Disclosure | GET /tablos/{id} with tasks | mitigate | TabloDetailPage only renders if loadOwnedTablo passes ownership check; tasks fetched with tablo_id filter (not user filter) but tablo ownership is already proven |
</threat_model>

<verification>
After all three tasks complete:
- `cd backend && go build ./...` exits 0
- `cd backend && go test ./internal/web/ -run TestTask -v` exits 0 with 5 PASS + 4 SKIP
- `cd backend && go test ./...` exits 0 (full suite green)
- `grep -c 'KanbanBoard' backend/templates/tasks.templ` returns at least 1
- `grep -c 'TaskCreateHandler' backend/internal/web/router.go` returns at least 1
- `grep -c 'sortable.min.js' backend/templates/layout.templ` returns 1
</verification>

<success_criteria>
Plan 02 complete when:
1. GET /tablos/{id} renders 4 kanban columns with correct labels (verified by test)
2. POST /tablos/{id}/tasks creates a task and returns TaskCard HTML fragment (verified by test)
3. POST /tablos/{id}/tasks with empty title returns 422 with "Title is required" (verified by test)
4. POST /tablos/{id}/tasks/{task_id}/delete removes the task and returns empty div (verified by test)
5. Non-owner requests return 404 (verified by TestTaskOwnership)
6. `go test ./...` is green (all tests in all packages pass)
</success_criteria>

<output>
After completion, create `.planning/phases/04-tasks-kanban/04-02-SUMMARY.md`
</output>
