---
phase: 4
slug: tasks-kanban
status: draft
shadcn_initialized: false
preset: none
created: 2026-05-15
---

# Phase 4 — UI Design Contract: Tasks (Kanban)

> Visual and interaction contract for Phase 4. Generated by gsd-ui-researcher.
> Downstream consumers: gsd-ui-checker, gsd-planner, gsd-executor, gsd-ui-auditor.

---

## Design System

| Property | Value | Source |
|----------|-------|--------|
| Tool | none (hand-rolled CSS + Tailwind v4) | codebase — `backend/internal/web/ui/` |
| Preset | not applicable | no shadcn |
| Component library | Custom (`ui.Card`, `ui.Button`, `ui.Badge`, `ui.CSRFField`) | codebase — `backend/internal/web/ui/` |
| Icon library | none (text labels + drag handle via CSS `⠿` or plain `::before` dots) | CONTEXT.md — Claude's discretion |
| Font | system-ui, -apple-system, "Segoe UI", Roboto, sans-serif | codebase — `base.css` |

shadcn gate: Not applicable — project is Go/HTMX, not React/Next.js/Vite. No `components.json` expected.

---

## Spacing Scale

Declared values (multiples of 4 only):

| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Icon gaps, drag-handle padding, color swatch gutter |
| sm | 8px | Task card internal padding (top/bottom), badge gap |
| md | 16px | Task card internal padding (left/right), form field gaps, column internal spacing |
| lg | 24px | Card padding (matches existing `.ui-card` padding: 1.5rem = 24px) |
| xl | 32px | Kanban board top margin below tablo header |
| 2xl | 48px | Empty state vertical padding |
| 3xl | 64px | Not used in this phase |

Exceptions:
- Drag handle touch target: minimum 44px height on `.task-drag-handle` (matches Phase 3 danger/neutral button `min-height: 44px`)
- Column width: fixed `w-72` (288px = 18rem) — not on the 8-point scale but matches RESEARCH.md KanbanColumn templ example; consistent with CONTEXT.md D-10

---

## Typography

All sizes derived from the existing codebase. No new type sizes introduced.

| Role | Size | Weight | Line Height | Source |
|------|------|--------|-------------|--------|
| Body | 16px (1rem) | 400 | 1.5 | codebase — base.css, tablos.templ `text-base` |
| Label / small | 14px (0.875rem) | 500 | 1.25 | codebase — tablos.templ `text-sm font-medium` for field labels |
| Card heading | 20px (1.25rem) | 600 | 1.375 | codebase — tablos.templ `text-xl font-semibold leading-snug` |
| Page heading | 28px (1.75rem) | 600 | 1.2 | codebase — tablos.templ `text-[28px] font-semibold leading-tight` |

Weights in use: 400 (regular) and 600 (semibold). No other weights. Consistent with Phase 3.

Column header typography: 14px / weight 600 / line-height 1.25 — maps to existing `text-sm font-semibold text-slate-700` pattern from RESEARCH.md KanbanColumn templ example.

---

## Color

All hex values extracted directly from codebase CSS files (`base.css`, `button.css`, `card.css`, `badge.css`).

| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | `#ffffff` (white) | Page background (`bg-white` in Layout body) |
| Secondary (30%) | `#f8fafc` (slate-50) | Card backgrounds (`.ui-card` background, header `bg-slate-50`) |
| Accent (10%) | `#2563eb` (blue-600) | Reserved: primary action buttons, focus rings, link text hover |
| Destructive | `#b91c1c` (red-700) | Destructive actions only: delete confirm button, delete trigger |

Accent reserved for:
1. "Add task" submit button (primary CTA per column)
2. "Save changes" button on task edit form
3. Focus ring on all interactive elements (`outline: 2px solid #2563eb`)
4. "View" link text on tablo cards (existing pattern: `text-blue-600`)

Supporting surface colors (from existing codebase, not new decisions):
- Border: `#e2e8f0` (slate-200) — card borders, form input borders
- Muted text: `#64748b` (slate-500) and `#94a3b8` (slate-400) — placeholder, secondary copy
- Body text: `#0f172a` (slate-900) — primary text on white
- Secondary text: `#334155` (slate-700) and `#475569` (slate-600) — labels, descriptions

Column header backgrounds: `#f1f5f9` (slate-100) — a light tinted band above each column's task list. Distinct from card white surface to visually anchor the column. Uses existing `bg-slate-100` Tailwind class (no new color).

Task card (display state): white `#ffffff` background with `border: 1px solid #e2e8f0`. Matches `.ui-card` exactly — use `ui.Card` directly.

Task card (drag ghost): `#f1f5f9` (slate-100) — Sortable.js `ghostClass: "bg-slate-100"` per RESEARCH.md Pattern 3.

---

## Component Inventory

All components used in this phase and their source:

| Component | Type | Source | Notes |
|-----------|------|--------|-------|
| `ui.Card` | existing | `backend/internal/web/ui/card.templ` | Task card display state; use `templ.Attributes` for `data-task-id`, drag class |
| `ui.Button` (solid default md) | existing | `backend/internal/web/ui/button.templ` | "Add task" submit, "Save changes" on edit form |
| `ui.Button` (soft neutral md) | existing | `backend/internal/web/ui/button.templ` | "Discard task" on add-task form, "Discard changes" on edit form |
| `ui.Button` (solid danger md) | existing | `backend/internal/web/ui/button.templ` | "Yes, delete" in task delete confirmation |
| `ui.Button` (soft danger md) | new variant needed | `backend/internal/web/ui/button.css` | Task delete trigger button — same `.ui-button-soft-danger-md` class pattern as Phase 3's `soft-neutral`; add CSS rule |
| `ui.Badge` (info) | existing | `backend/internal/web/ui/badge.templ` | Task count per column header: `BadgeVariantInfo`, e.g. "3" |
| `ui.CSRFField` | existing | `backend/internal/web/ui/csrf_field.templ` | All forms including hidden reorder form |
| `KanbanBoard` | new templ component | `backend/templates/tasks.templ` | Wraps all 4 columns + hidden reorder form |
| `KanbanColumn` | new templ component | `backend/templates/tasks.templ` | Single column: header + sortable list + add-task slot |
| `TaskCard` | new templ component | `backend/templates/tasks.templ` | Display state; carries `.task-card` + `data-task-id` |
| `TaskEditFragment` | new templ component | `backend/templates/tasks.templ` | Inline edit form (title + description); carries `.task-card-zone` |
| `TaskCreateForm` | new templ component | `backend/templates/tasks.templ` | Inline add-task form at column bottom (title only) |
| `TaskDeleteConfirmFragment` | new templ component | `backend/templates/tasks.templ` | Inline confirmation: "Delete task?" + confirm/cancel |

New CSS rule needed (add to `backend/internal/web/ui/button.css`):

```css
.ui-button-soft-danger-md {
  display: inline-flex;
  align-items: center;
  border-radius: 0.375rem;
  background-color: #fee2e2;
  padding: 0.5rem 1rem;
  font-size: 1rem;
  font-weight: 600;
  color: #b91c1c;
  border: 1px solid #fecaca;
  min-height: 44px;
}
.ui-button-soft-danger-md:hover {
  background-color: #fecaca;
}
.ui-button-soft-danger-md:focus-visible {
  outline: 2px solid #b91c1c;
  outline-offset: 2px;
}
```

---

## Interaction Contracts

### § 1 — Kanban Board (TASK-01)

**Route:** `GET /tablos/{id}` (extension of Phase 3 tablo detail page)

**Layout:** Tablo title zone (Phase 3) + description zone (Phase 3) + delete zone (Phase 3) → then below: `<div id="kanban-board">` containing 4 `KanbanColumn` components in left-to-right order: To do | In progress | In review | Done.

Board container: `flex gap-4 overflow-x-auto pb-4` — horizontal scroll when viewport is narrow.

Column wrapper: `flex-shrink-0 w-72` (288px). Column list area: `min-h-16 space-y-2 sortable-column` — accepts drag, has minimum height so empty columns remain droppable targets.

Column header: `flex items-center justify-between mb-2` with `<h3 class="text-sm font-semibold text-slate-700">` + `ui.Badge` (info, count as string). Column header area background: `bg-slate-100 rounded px-3 py-2 mb-2` to visually separate from card list.

**Primary focal point:** The column header row — column label (14px/600 weight) paired with the task count badge — is the visual entry point for each column. Its `bg-slate-100` tinted band sits above the card list and draws the eye left-to-right across all four columns before the user reads down into the cards below.

**States:**
- Populated column: shows N task cards stacked vertically with `space-y-2`
- Empty column: shows a `<p class="text-sm text-slate-400 italic text-center py-4">No tasks yet</p>` inside the sortable div, so the column still accepts drops (min-h-16 ensures drop zone height)

### § 2 — Task Create (TASK-02)

**Routes:**
- `GET /tablos/{id}/tasks/new?status={status}` — returns `TaskCreateForm` fragment
- `POST /tablos/{id}/tasks` — creates task, returns `TaskCard` fragment + OOB reset of add-task slot

**Trigger:** `+ Add task` text button at bottom of each column. Rendered as `<button class="ui-button ui-button-soft-neutral-md w-full text-left text-sm mt-2" hx-get="..." hx-target="#add-task-slot-{status}" hx-swap="innerHTML">+ Add task</button>`.

**Form layout:**
```
[Title input — full width, required, placeholder "Task title"]
[Save (solid default md)] [Discard task (soft neutral md, restores trigger button without server round-trip via hx-get)]
```

Title: `<input type="text" name="title" required maxlength="255" placeholder="Task title" class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"/>`

**On submit (HTMX):** New `TaskCard` appended to `#column-{status}` via `hx-target="#column-{status}" hx-swap="beforeend"`. Add-task slot reset to trigger button via OOB swap on `#add-task-slot-{status}`.

**On cancel:** `hx-get="/tablos/{id}/tasks/cancel-new?status={status}"` returns `AddTaskTrigger` fragment — restores the `+ Add task` button without a POST.

**Validation error:** Inline `@FieldError(errs.Title)` below title input (same pattern as Phase 3 `FieldError`). Form stays open with error copy inline.

### § 3 — Task Edit (TASK-03)

**Routes:**
- `GET /tablos/{id}/tasks/{task_id}/edit` — returns `TaskEditFragment`
- `POST /tablos/{id}/tasks/{task_id}` — saves, returns `TaskCard`

**Trigger:** Clicking anywhere on a `TaskCard` (not the delete button, not the drag handle). `hx-get` on the card body `<div>` with `hx-target="closest .task-card-zone" hx-swap="outerHTML"`.

**Edit form layout:**
```
[Title input — full width, current value pre-filled]
[Description textarea — 3 rows, current value pre-filled, placeholder "Description (optional)"]
[Save changes (solid default md)] [Discard changes (soft neutral md)]
```

"Discard changes" uses `hx-get="/tablos/{id}/tasks/{task_id}/show"` to restore the display card without a POST (mirrors Phase 3 cancel pattern).

**On save (HTMX):** Returns `TaskCard` fragment; `hx-target="closest .task-card-zone" hx-swap="outerHTML"`. Updated title/description appear immediately.

**Outer element class:** `.task-card-zone` on both `TaskCard` and `TaskEditFragment` wrappers so outerHTML round-trips work correctly (mirrors `.tablo-title-zone` pattern from Phase 3).

### § 4 — Task Move + Reorder (TASK-04, TASK-05)

**Route:** `POST /tablos/{id}/tasks/reorder`

**Mechanism:** Sortable.js 1.15.7 (loaded from `/static/sortable.min.js`). Each `.sortable-column` div is a Sortable group="kanban" instance. The `onEnd` callback populates a hidden `#reorder-form` and triggers it via `htmx.trigger(form, "submit")`.

**Hidden form:** `<form id="reorder-form" method="POST" action="/tablos/{id}/tasks/reorder" hx-post="..." hx-target="#kanban-board" hx-swap="outerHTML" class="hidden">@ui.CSRFField(csrfToken)</form>`

**Drag handle:** Dedicated `.task-drag-handle` element at the top-left of each `TaskCard`. Rendered as: `<div class="task-drag-handle text-slate-300 hover:text-slate-500 cursor-grab select-none text-xs leading-none mr-2" aria-hidden="true">⠿</div>` (braille pattern dots — visual grip icon, no external icon library needed). Sortable option: `handle: ".task-drag-handle"`. Full card is NOT draggable — only the handle activates drag. Consistent with CONTEXT.md Claude's discretion.

**Visual feedback during drag:**
- Ghost class: `bg-slate-100` (applied by Sortable to the drag ghost)
- Chosen class: `opacity-50` (applied to the card being dragged while in flight)
- Sortable animation: `150ms`

**On drop (HTMX swap):** Returns full `#kanban-board` `outerHTML` (full-board refresh per RESEARCH.md Open Question 2 recommendation). Sortable re-initialized via `htmx.onLoad` wrapper.

**`+ Add task` form is not draggable:** Sortable option `draggable: ".task-card"` restricts draggable children to only `.task-card` elements. The add-task slot does not carry `.task-card`.

### § 5 — Task Delete (TASK-06)

**Routes:**
- `GET /tablos/{id}/tasks/{task_id}/delete-confirm` — returns `TaskDeleteConfirmFragment`
- `POST /tablos/{id}/tasks/{task_id}/delete` — hard-deletes, returns empty `<div>` with same ID (outerHTML swap removes the card)

**Trigger:** "Delete" button (soft danger md) on `TaskCard`, positioned top-right of the card. `hx-get="/tablos/{id}/tasks/{task_id}/delete-confirm" hx-target="closest .task-card-zone" hx-swap="outerHTML"`.

**Confirmation fragment:**
```
"Delete task?"
"This cannot be undone."
[Yes, delete (solid danger md)] [Keep task (soft neutral md)]
```

"Keep task" uses `hx-get="/tablos/{id}/tasks/{task_id}/show" hx-target="closest .task-card-zone" hx-swap="outerHTML"` to restore `TaskCard` without POST.

**On delete (HTMX):** Handler returns `<div id="task-{task_id}" class="task-card-zone"></div>` — empty element with same ID. `hx-swap="outerHTML"` replaces the confirmation fragment with an empty div, effectively removing the card from the DOM. (Per RESEARCH.md Open Question 3 recommendation.)

### § 6 — Task Ordering Persistence (TASK-07)

No dedicated UI. Satisfied by the reorder endpoint writing `position` to the DB on every drop. On `GET /tablos/{id}`, tasks are fetched `ORDER BY status, position, created_at` — stable order guaranteed across refreshes. No user-visible affordance needed.

---

## Copywriting Contract

| Element | Copy | Source |
|---------|------|--------|
| Primary CTA (create) | "Add task" | CONTEXT.md D-09 "Add task" label; verb + noun |
| Primary CTA (save edit) | "Save changes" | codebase — tablos.templ existing pattern |
| Primary CTA (confirm delete) | "Yes, delete" | codebase — tablos.templ existing pattern |
| Cancel edit | "Discard changes" | codebase — tablos.templ existing pattern |
| Cancel add | "Discard task" | mirrors "Discard changes" pattern; specific verb + noun |
| Cancel delete | "Keep task" | mirroring codebase "Keep tablo" pattern |
| Empty column state | "No tasks yet" | default — minimal, specific to context |
| Task create form heading | (none — form is compact, column header already names context) | CONTEXT.md D-09 "title only for quick capture" |
| Task edit form heading | (none — inline expand, context is clear from card) | CONTEXT.md D-08 |
| Delete confirmation heading | "Delete task?" | mirrors "Delete tablo?" pattern from Phase 3 |
| Delete confirmation body | "This cannot be undone." | codebase — tablos.templ exact copy |
| Column header labels | "To do" / "In progress" / "In review" / "Done" | RESEARCH.md `TaskColumnLabels` map |
| Title field validation error | "Title is required." | default — consistent with Phase 3 pattern |
| Title too long error | "Title must be 255 characters or fewer." | default — matches maxlength constraint |
| Generic server error on create/edit | "Something went wrong. Please try again." | default — consistent with Phase 3 `errs.General` |
| Task count badge | "{N}" (integer only, no "tasks" label) | CONTEXT.md specifics — "task count badge" |

Destructive actions in this phase:
- **Delete task:** trigger = "Delete" (soft danger) → confirmation fragment → "Yes, delete" (solid danger). No modal. Inline confirmation inside the card zone. Hard-delete, irreversible. Pattern reuses Phase 3 D-07 exactly.

---

## Interaction State Summary

| Component | States | Transition Mechanism |
|-----------|--------|----------------------|
| TaskCard | display, editing, delete-confirm | HTMX outerHTML swap on `.task-card-zone` |
| KanbanColumn | populated, empty | server-rendered; re-rendered on reorder swap |
| AddTaskSlot | trigger-button, form-open | HTMX innerHTML swap on `#add-task-slot-{status}` |
| KanbanBoard | loading (htmx-request), idle | `htmx-request` class on `#reorder-form` dims cursor during reorder POST |
| TaskDragCard | idle, being-dragged (opacity-50), ghost (bg-slate-100) | Sortable.js classes |

---

## Asset Contract

| Asset | Path | Version | How Loaded |
|-------|------|---------|------------|
| Sortable.js | `/static/sortable.min.js` | 1.15.7 | `<script src="/static/sortable.min.js" defer></script>` in layout or tablo detail template |
| HTMX | `/static/htmx.min.js` | existing | already in `layout.templ` |
| Tailwind CSS | `/static/tailwind.css` | existing | already in `layout.templ` |

Sortable.js must be added to `just bootstrap` recipe (per RESEARCH.md Standard Stack installation instructions). It is NOT loaded from a CDN — consistent with the no-CDN policy (`D-10` anti-pattern).

Sortable.js script tag placement: in `layout.templ` alongside `htmx.min.js`, both `defer`. This means it loads on all pages (minor overhead); alternatively it can be conditional on tablo detail pages only — planner's call. Default: add to layout for simplicity.

---

## Accessibility Floor

Minimum requirements consistent with Phase 3 patterns:

| Element | Requirement |
|---------|-------------|
| Drag handle | `aria-hidden="true"` (decorative), `role` not needed; keyboard users cannot drag (v1 limitation — document in code) |
| Task card clickable area | `role="button"` + `aria-label="Edit task: {title}"` on the clickable div |
| Delete button | `aria-label="Delete task: {title}"` |
| Column sortable list | `aria-label="{Column label} column"` on the `.sortable-column` div |
| Form inputs | `<label>` with `for` attribute matching input `id`, consistent with Phase 3 |
| Focus ring | `focus-visible: outline: 2px solid #2563eb` — inherited from `base.css` |

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none | not applicable — no shadcn |
| third-party | none | not applicable |

No third-party component registries used. Sortable.js is a JS utility downloaded as a static file, not a component registry entry. No vetting gate required.

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
