275 lines
18 KiB
Markdown
275 lines
18 KiB
Markdown
|
|
---
|
|||
|
|
phase: 16-tablo-detail
|
|||
|
|
plan: 03
|
|||
|
|
type: execute
|
|||
|
|
wave: 3
|
|||
|
|
depends_on:
|
|||
|
|
- 16-02
|
|||
|
|
files_modified:
|
|||
|
|
- backend/templates/tasks.templ
|
|||
|
|
- backend/internal/web/handlers_tasks.go
|
|||
|
|
autonomous: true
|
|||
|
|
requirements:
|
|||
|
|
- DETAIL-02
|
|||
|
|
- DETAIL-03
|
|||
|
|
|
|||
|
|
must_haves:
|
|||
|
|
truths:
|
|||
|
|
- "KanbanBoard templ signature accepts etapes []sqlc.Etape as 5th parameter"
|
|||
|
|
- "KanbanColumn renders tasks grouped by etape using groupTasksByEtape helper; unassigned tasks appear last"
|
|||
|
|
- "Each kanban column uses .kanban-column / .tasks-section / .tasks-section-header / .task-list CSS layout"
|
|||
|
|
- "TaskCard uses .task-row layout: .task-check + .task-body + trash @ui.IconButton"
|
|||
|
|
- "AddTaskTrigger uses .tasks-add-button class (not raw ui-button classes)"
|
|||
|
|
- "EtapeStrip OOB calls are removed from TaskCardGone and TaskCardOOB (etapes/counts params kept)"
|
|||
|
|
- "Both KanbanBoard call sites in handlers_tasks.go pass etapes as 5th argument"
|
|||
|
|
- "All 19 existing task handler tests pass unchanged"
|
|||
|
|
artifacts:
|
|||
|
|
- path: backend/templates/tasks.templ
|
|||
|
|
provides: groupTasksByEtape helper, EtapeGroup type, EtapeGroupHeader component, restyled KanbanBoard/Column/TaskCard
|
|||
|
|
contains: "groupTasksByEtape"
|
|||
|
|
- path: backend/internal/web/handlers_tasks.go
|
|||
|
|
provides: Updated KanbanBoard call sites (lines ~594, ~645)
|
|||
|
|
contains: "KanbanBoard.*etapes"
|
|||
|
|
key_links:
|
|||
|
|
- from: backend/templates/tasks.templ groupTasksByEtape
|
|||
|
|
to: backend/templates/tasks.templ KanbanColumn
|
|||
|
|
via: "groups := groupTasksByEtape(tasks, etapes)" expression inside KanbanColumn
|
|||
|
|
pattern: "groupTasksByEtape"
|
|||
|
|
- from: backend/internal/web/handlers_tasks.go
|
|||
|
|
to: backend/templates/tasks.templ KanbanBoard
|
|||
|
|
via: "templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter, etapes)"
|
|||
|
|
pattern: "KanbanBoard.*etapes"
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
<objective>
|
|||
|
|
Restyle the task kanban board (DETAIL-02) and implement server-side etape grouping (DETAIL-03). Changes: add `groupTasksByEtape` helper and `EtapeGroup` type to `tasks.templ`; add `EtapeGroupHeader` templ component; update `KanbanBoard` and `KanbanColumn` signatures to accept `etapes []sqlc.Etape`; restyle `KanbanColumn` with `.tasks-section` layout; restyle `TaskCard` with `.task-row` layout; restyle `AddTaskTrigger` with `.tasks-add-button`; remove `@EtapeStrip` OOB calls from `TaskCardGone` and `TaskCardOOB`; update both `KanbanBoard` call sites in `handlers_tasks.go`.
|
|||
|
|
|
|||
|
|
Purpose: Deliver DETAIL-02 (tasks-section kanban) and DETAIL-03 (etape grouping, EtapeStrip removal).
|
|||
|
|
Output: Restyled kanban with etape-grouped task rows; all 19 task handler tests pass.
|
|||
|
|
</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>
|
|||
|
|
@.planning/ROADMAP.md
|
|||
|
|
@.planning/phases/16-tablo-detail/16-CONTEXT.md
|
|||
|
|
@.planning/phases/16-tablo-detail/16-RESEARCH.md
|
|||
|
|
@.planning/phases/16-tablo-detail/16-PATTERNS.md
|
|||
|
|
@.planning/phases/16-tablo-detail/16-UI-SPEC.md
|
|||
|
|
@.planning/phases/16-tablo-detail/16-02-SUMMARY.md
|
|||
|
|
|
|||
|
|
<interfaces>
|
|||
|
|
<!-- Key contracts the executor needs. -->
|
|||
|
|
|
|||
|
|
From backend/templates/tasks.templ (current state — read the file before editing):
|
|||
|
|
|
|||
|
|
Existing groupTasksByStatus (lines ~10-18) — model for groupTasksByEtape:
|
|||
|
|
func groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task {
|
|||
|
|
result := make(map[sqlc.TaskStatus][]sqlc.Task, len(TaskColumns))
|
|||
|
|
for _, t := range tasks {
|
|||
|
|
result[t.Status] = append(result[t.Status], t)
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Current KanbanBoard signature (line ~23):
|
|||
|
|
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter)
|
|||
|
|
|
|||
|
|
Target KanbanBoard signature:
|
|||
|
|
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter, etapes []sqlc.Etape)
|
|||
|
|
|
|||
|
|
KanbanColumn — receives tasks for one status. After change, also receives etapes:
|
|||
|
|
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape)
|
|||
|
|
|
|||
|
|
EtapeGroup type to add (before groupTasksByEtape func):
|
|||
|
|
type EtapeGroup struct {
|
|||
|
|
EtapeID string
|
|||
|
|
EtapeTitle string
|
|||
|
|
EtapeColor string
|
|||
|
|
Tasks []sqlc.Task
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
groupTasksByEtape logic:
|
|||
|
|
- Build etapeID→Etape map from etapes slice for O(1) lookup
|
|||
|
|
- Use etapes slice ORDER to determine group order (not map iteration)
|
|||
|
|
- For each etape in order: collect tasks whose EtapeID matches; only add group if non-empty
|
|||
|
|
- sqlc.Task.EtapeID field: check the actual type (likely pgtype.UUID or *uuid.UUID) — use appropriate nil/zero check
|
|||
|
|
- At the end: collect tasks with nil/zero EtapeID → append as EtapeGroup{EtapeID: "", EtapeTitle: "No etape", EtapeColor: "", Tasks: unassigned}
|
|||
|
|
- If zero etapes are defined (etapes slice is empty), treat all tasks as unassigned
|
|||
|
|
|
|||
|
|
TaskCardGone current signature (line ~384): has etapes []sqlc.Etape and counts EtapeTaskCounts params
|
|||
|
|
TaskCardOOB current signature (line ~396): same
|
|||
|
|
Action: remove the @EtapeStrip(..., true) OOB call from both; KEEP the etapes and counts params (to avoid handler signature changes); add TODO comment
|
|||
|
|
|
|||
|
|
KanbanBoard call sites in handlers_tasks.go:
|
|||
|
|
Line ~594: templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter) → add etapes (from loadTasksTabData return)
|
|||
|
|
Line ~645: templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter) → add etapes (etapes is already available in scope)
|
|||
|
|
|
|||
|
|
loadTasksTabData return signature (check actual): returns (tasks, etapes, counts, filter, ok bool) — etapes is the 2nd return value
|
|||
|
|
|
|||
|
|
From backend/internal/web/ui/variants.go (for TaskCard IconButton):
|
|||
|
|
ui.IconButtonVariantDanger, ui.IconButtonToneGhost
|
|||
|
|
|
|||
|
|
From backend/internal/web/ui/app.css (Section 23 added in Plan 01):
|
|||
|
|
.kanban-column { flex-shrink: 0; width: 18rem; }
|
|||
|
|
.tasks-section { border: 1px solid var(--color-border-subtle); border-radius: 1rem; overflow: hidden; }
|
|||
|
|
.tasks-section-header { display: flex; justify-content: space-between; align-items: center; padding: 1.2rem 1rem; }
|
|||
|
|
.task-list { display: flex; flex-direction: column; }
|
|||
|
|
.task-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.9rem 1rem; border-bottom: 1px solid var(--color-border-muted); }
|
|||
|
|
.tasks-add-button { display: inline-flex; align-items: center; gap: 0.5rem; ... }
|
|||
|
|
</interfaces>
|
|||
|
|
</context>
|
|||
|
|
|
|||
|
|
<tasks>
|
|||
|
|
|
|||
|
|
<task type="auto">
|
|||
|
|
<name>Task 1: Add groupTasksByEtape helper, EtapeGroup type, EtapeGroupHeader component; update KanbanBoard/Column signatures and restyled column/card/trigger</name>
|
|||
|
|
<files>backend/templates/tasks.templ</files>
|
|||
|
|
<read_first>
|
|||
|
|
- backend/templates/tasks.templ (read the FULL file before editing — ~420 lines; you need the current: groupTasksByStatus pattern, KanbanBoard body, KanbanColumn body, TaskCard body, AddTaskTrigger body, TaskCardGone and TaskCardOOB bodies, existing import block, sqlc.Task.EtapeID field type)
|
|||
|
|
- backend/internal/db/sqlc/querier.go or backend/internal/db/sqlc/models.go (grep for "EtapeID" field on Task struct to confirm the exact type — pgtype.UUID, *uuid.UUID, or sql.NullString)
|
|||
|
|
- .planning/phases/16-tablo-detail/16-PATTERNS.md (sections: groupTasksByEtape helper, KanbanBoard signature change, KanbanColumn restyled, TaskCard restyled, EtapeGroupHeader component, EtapeStrip OOB removal)
|
|||
|
|
</read_first>
|
|||
|
|
<action>
|
|||
|
|
Edit `backend/templates/tasks.templ` to make the following changes. Read the entire file first to understand current structure before starting any edits.
|
|||
|
|
|
|||
|
|
1. ADD EtapeGroup type and groupTasksByEtape function after the existing `groupTasksByStatus` function (before the `KanbanBoard` templ declaration):
|
|||
|
|
- `EtapeGroup` struct: fields EtapeID string, EtapeTitle string, EtapeColor string, Tasks []sqlc.Task
|
|||
|
|
- `groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup`:
|
|||
|
|
* Build index map from etape ID string to sqlc.Etape for O(1) lookup
|
|||
|
|
* Iterate etapes in slice order; for each etape, collect tasks matching that etape ID; only append a group if it has tasks
|
|||
|
|
* Append an unassigned group (EtapeID: "", EtapeTitle: "No etape", EtapeColor: "") for tasks with nil/zero EtapeID — check the actual EtapeID field type (pgtype.UUID: use !t.EtapeID.Valid; *uuid.UUID: use t.EtapeID == nil; uuid.UUID zero value: use t.EtapeID == uuid.Nil)
|
|||
|
|
* If all tasks are assigned (no unassigned), omit the unassigned group entirely
|
|||
|
|
|
|||
|
|
2. ADD EtapeGroupHeader templ component (after the groupTasksByEtape function):
|
|||
|
|
`templ EtapeGroupHeader(group EtapeGroup)` rendering a `div class="etape-group-header"`:
|
|||
|
|
- If group.EtapeColor != "": render `span class="etape-group-color-dot" style="background-color: {group.EtapeColor}"`
|
|||
|
|
- If group.EtapeID == "": render `span class="etape-group-label is-unassigned"` with group.EtapeTitle
|
|||
|
|
- Else: render `span class="etape-group-label"` with group.EtapeTitle
|
|||
|
|
|
|||
|
|
3. UPDATE KanbanBoard signature: add `etapes []sqlc.Etape` as 5th parameter. Inside KanbanBoard, pass `etapes` through to each `@KanbanColumn(...)` call.
|
|||
|
|
|
|||
|
|
4. UPDATE KanbanColumn signature: add `etapes []sqlc.Etape` as 6th parameter. Restyle the column structure:
|
|||
|
|
- Outer: `div class="kanban-column"` (replaces the current flex-shrink-0 w-72 div)
|
|||
|
|
- Inner: `div class="tasks-section"` containing:
|
|||
|
|
* `div class="tasks-section-header"`: left side has h3 with TaskColumnLabels[status] + span with task-count-badge `@ui.Badge(...BadgeVariantInfo...)`, right side has `div id="add-task-slot-{status}"` containing `@AddTaskTrigger(...)`
|
|||
|
|
* `div class="task-list sortable-column" data-status="{status}" id="column-{status}" aria-label="{label} column"`:
|
|||
|
|
- If len(tasks) == 0: render `p class="task-list-empty" "No tasks yet"`
|
|||
|
|
- Else: compute `groups := groupTasksByEtape(tasks, etapes)`, then `for _, group := range groups { @EtapeGroupHeader(group); for _, task := range group.Tasks { @TaskCard(tabloID, task, csrfToken) } }`
|
|||
|
|
- Preserve existing `data-status` and `id="column-..."` attributes for Sortable.js compatibility
|
|||
|
|
|
|||
|
|
5. RESTYLE TaskCard: replace the current `.task-card bg-white rounded border border-slate-200...` inner div with `.task-row task-card` div:
|
|||
|
|
- Outer zone wrapper: keep `div class="task-card-zone" id="task-{task.ID.String()}"` unchanged (HTMX swap target)
|
|||
|
|
- Inner: `div class="task-row task-card" data-task-id="{task.ID.String()}" hx-get=... hx-target="closest .task-card-zone" hx-swap="outerHTML" role="button" aria-label="Edit task: {task.Title}"`
|
|||
|
|
- Children: `div class="task-check"` (round checkbox, role="checkbox" aria-checked="false") + `div class="task-body"` (p with task.Title) + trash `@ui.IconButton(ui.IconButtonProps{Label: "Delete task: {task.Title}", Icon: "trash", Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{"hx-get": "/tablos/{tabloID}/tasks/{task.ID}/delete-confirm", "hx-target": "closest .task-card-zone", "hx-swap": "outerHTML"}})`
|
|||
|
|
- Remove the old drag handle div (⠿)
|
|||
|
|
|
|||
|
|
6. RESTYLE AddTaskTrigger: change the button class from the current `ui-button ui-button-soft...` compound to simply `tasks-add-button`. Preserve all hx-* attributes.
|
|||
|
|
|
|||
|
|
7. REMOVE EtapeStrip OOB calls: in TaskCardGone and TaskCardOOB, delete the `@EtapeStrip(tabloID, etapes, counts, filter, csrfToken, true)` lines. Keep the `etapes []sqlc.Etape` and `counts EtapeTaskCounts` parameters on both component signatures. Add `// TODO: remove etapes and counts params after Phase 16 cleanup` comment.
|
|||
|
|
|
|||
|
|
After editing, run: `just generate` to regenerate `tasks_templ.go`.
|
|||
|
|
</action>
|
|||
|
|
<verify>
|
|||
|
|
<automated>grep -c "groupTasksByEtape" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "EtapeGroupHeader" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "kanban-column" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "tasks-section" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "task-row" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "EtapeStrip" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ</automated>
|
|||
|
|
</verify>
|
|||
|
|
<acceptance_criteria>
|
|||
|
|
- `tasks.templ` contains `func groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup`
|
|||
|
|
- `tasks.templ` contains `type EtapeGroup struct`
|
|||
|
|
- `tasks.templ` contains `templ EtapeGroupHeader(group EtapeGroup)`
|
|||
|
|
- `tasks.templ` contains `class="kanban-column"` (replacing w-72 Tailwind class)
|
|||
|
|
- `tasks.templ` contains `class="tasks-section"` inside kanban column
|
|||
|
|
- `tasks.templ` contains `class="task-row task-card"` inside TaskCard
|
|||
|
|
- `tasks.templ` contains `class="tasks-add-button"` in AddTaskTrigger
|
|||
|
|
- `grep -c "EtapeStrip" backend/templates/tasks.templ` returns 0 (OOB calls removed)
|
|||
|
|
- `grep "etapes \[\]sqlc.Etape" backend/templates/tasks.templ` shows parameter in both KanbanBoard and KanbanColumn signatures
|
|||
|
|
- `just generate` exits 0 (templ compiles cleanly)
|
|||
|
|
</acceptance_criteria>
|
|||
|
|
<done>tasks.templ has groupTasksByEtape, EtapeGroupHeader, restyled KanbanColumn/TaskCard/AddTaskTrigger, and no EtapeStrip OOB calls.</done>
|
|||
|
|
</task>
|
|||
|
|
|
|||
|
|
<task type="auto">
|
|||
|
|
<name>Task 2: Update KanbanBoard call sites in handlers_tasks.go and verify full test suite</name>
|
|||
|
|
<files>backend/internal/web/handlers_tasks.go</files>
|
|||
|
|
<read_first>
|
|||
|
|
- backend/internal/web/handlers_tasks.go (read lines 580–660 to see the two KanbanBoard call sites and the loadTasksTabData return variables in scope at each call site; confirm etapes is the 2nd return from loadTasksTabData: `tasks, etapes, counts, filter, ok := loadTasksTabData(...)`)
|
|||
|
|
- .planning/phases/16-tablo-detail/16-RESEARCH.md (Pattern: KanbanBoard call site count — three call sites must all be updated; tablos.templ was updated in Plan 02; handlers_tasks.go has two)
|
|||
|
|
</read_first>
|
|||
|
|
<action>
|
|||
|
|
Edit `backend/internal/web/handlers_tasks.go` at the two `KanbanBoard` call sites:
|
|||
|
|
|
|||
|
|
Line ~594 (inside TaskReorderHandler or similar): change
|
|||
|
|
`templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter)`
|
|||
|
|
to
|
|||
|
|
`templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter, etapes)`
|
|||
|
|
|
|||
|
|
Line ~645 (second KanbanBoard call): change the same pattern.
|
|||
|
|
|
|||
|
|
At each call site, verify that `etapes` is already in scope from the `loadTasksTabData` return values. The function returns `(tasks, etapes, counts, filter, ok)` — use the `etapes` variable directly.
|
|||
|
|
|
|||
|
|
After editing, run: `just generate && go build ./backend/...` to confirm all three KanbanBoard call sites match the updated signature.
|
|||
|
|
|
|||
|
|
Then run the full task test suite:
|
|||
|
|
`go test ./backend/internal/web/... -run TestTask -count=1`
|
|||
|
|
|
|||
|
|
And the full handler test suite (no DB required):
|
|||
|
|
`go test ./backend/internal/web/... -count=1`
|
|||
|
|
</action>
|
|||
|
|
<verify>
|
|||
|
|
<automated>grep -c "KanbanBoard.*etapes" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/handlers_tasks.go && cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && go build ./backend/... && go test ./backend/internal/web/... -run TestTask -count=1</automated>
|
|||
|
|
</verify>
|
|||
|
|
<acceptance_criteria>
|
|||
|
|
- `grep "KanbanBoard" backend/internal/web/handlers_tasks.go` shows both call sites include `etapes` as 5th argument
|
|||
|
|
- `go build ./backend/...` exits 0 (no KanbanBoard argument count mismatch)
|
|||
|
|
- `go test ./backend/internal/web/... -run TestTask -count=1` exits 0 (all 19 task tests pass)
|
|||
|
|
- `go test ./backend/internal/web/... -count=1` exits 0 (full handler test suite passes with no regressions)
|
|||
|
|
</acceptance_criteria>
|
|||
|
|
<done>Both KanbanBoard call sites in handlers_tasks.go pass etapes; all task tests pass; build is clean.</done>
|
|||
|
|
</task>
|
|||
|
|
|
|||
|
|
</tasks>
|
|||
|
|
|
|||
|
|
<threat_model>
|
|||
|
|
## Trust Boundaries
|
|||
|
|
|
|||
|
|
| Boundary | Description |
|
|||
|
|
|----------|-------------|
|
|||
|
|
| Handler → KanbanBoard template | etapes []sqlc.Etape passed from handler; data comes from authenticated DB query |
|
|||
|
|
| EtapeGroup rendering | EtapeColor rendered as inline style value (background-color) — same pattern as project-avatar; no script injection via CSS values |
|
|||
|
|
|
|||
|
|
## STRIDE Threat Register
|
|||
|
|
|
|||
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|||
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|||
|
|
| T-16-03-01 | Information Disclosure | EtapeGroup server-side grouping | accept | groupTasksByEtape operates on data already fetched via authenticated query; no new data access introduced |
|
|||
|
|
| T-16-03-02 | Tampering | EtapeColor inline style value | accept | Etape color is stored in DB as user-supplied value but rendered only as CSS background-color; browsers do not execute CSS property values as code |
|
|||
|
|
| T-16-03-03 | Spoofing | EtapeStrip OOB removal | accept | Removing dead OOB calls has no security impact; HTMX ignores swaps for missing targets |
|
|||
|
|
| T-16-03-04 | Denial of Service | groupTasksByEtape O(n*m) | accept | n = tasks per column (bounded by UI; typically < 50), m = etapes per tablo (typically < 10); no external input controls loop bounds; not a real DoS surface |
|
|||
|
|
</threat_model>
|
|||
|
|
|
|||
|
|
<verification>
|
|||
|
|
```bash
|
|||
|
|
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source
|
|||
|
|
go build ./backend/...
|
|||
|
|
go test ./backend/internal/web/... -count=1
|
|||
|
|
```
|
|||
|
|
Build clean; full handler test suite passes (all 19 task tests + all etape tests + all file tests).
|
|||
|
|
</verification>
|
|||
|
|
|
|||
|
|
<success_criteria>
|
|||
|
|
- `groupTasksByEtape` helper groups tasks by etape in etape declaration order with unassigned last
|
|||
|
|
- `KanbanBoard` and `KanbanColumn` both accept `etapes []sqlc.Etape`
|
|||
|
|
- Both `handlers_tasks.go` call sites pass `etapes`
|
|||
|
|
- Kanban column uses `.kanban-column` / `.tasks-section` / `.tasks-section-header` / `.task-list` CSS classes
|
|||
|
|
- TaskCard uses `.task-row` with `.task-check` + `.task-body` + trash `@ui.IconButton`
|
|||
|
|
- EtapeStrip OOB calls removed from TaskCardGone and TaskCardOOB
|
|||
|
|
- `go test ./backend/internal/web/... -count=1` passes (all 19 task tests)
|
|||
|
|
</success_criteria>
|
|||
|
|
|
|||
|
|
<output>
|
|||
|
|
After completion, create `.planning/phases/16-tablo-detail/16-03-SUMMARY.md` using the summary template.
|
|||
|
|
</output>
|