docs(16): create phase plan

This commit is contained in:
Arthur Belleville 2026-05-16 23:30:34 +02:00
parent 5060e4bb96
commit 337c380d6b
No known key found for this signature in database
2 changed files with 921 additions and 6 deletions

View file

@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v3.0
milestone_name: Design System & Visual Polish
status: executing
last_updated: "2026-05-16T20:47:02.232Z"
last_activity: 2026-05-16 -- Phase 15 execution started
last_updated: "2026-05-16T21:30:26.245Z"
last_activity: 2026-05-16 -- Phase 16 planning complete
progress:
total_phases: 5
completed_phases: 3
total_plans: 10
total_plans: 14
completed_plans: 10
percent: 100
percent: 71
---
# STATE
@ -30,8 +30,8 @@ See: `.planning/PROJECT.md` (updated 2026-05-16)
Phase: 15 (dashboard-tablos) — EXECUTING
Plan: 1 of 3
Status: Executing Phase 15
Last activity: 2026-05-16 -- Phase 15 execution started
Status: Ready to execute
Last activity: 2026-05-16 -- Phase 16 planning complete
## Previous Milestone Status

View file

@ -0,0 +1,915 @@
# Phase 16: Tablo Detail - Pattern Map
**Mapped:** 2026-05-16
**Files analyzed:** 5 (4 templ files + 1 CSS file)
**Analogs found:** 5 / 5
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/internal/web/ui/app.css` | config/styles | transform | `go-backend/internal/web/ui/app.css` | exact (source of CSS blocks to port) |
| `backend/internal/web/ui/icon_button.templ` | component | request-response | existing icon cases in same file | exact (additive switch cases) |
| `backend/templates/tablos.templ` | component | request-response | same file — `TabloProjectCard`, `TablosDashboard` | exact (existing patterns within same file) |
| `backend/templates/tasks.templ` | component | request-response | `go-backend/internal/web/views/tasks.templ`, same file | role-match |
| `backend/templates/etapes.templ` | component | request-response | same file (EtapeStrip removal) | exact (deletion, no replacement pattern needed) |
| `backend/templates/files.templ` | component | request-response | same file — `TablosDashboard` (EmptyState), `FilesTabFragment` | role-match |
---
## Pattern Assignments
### `backend/internal/web/ui/app.css` — CSS additions
**Analog:** `go-backend/internal/web/ui/app.css` (verbatim source for all new CSS blocks)
**Existing CSS already in `backend/internal/web/ui/app.css` (do NOT re-add):**
Lines 344361: `.overview-section`, `.overview-section-heading`, shared `h3` font-size rule
Lines 386407: `.project-card-top`, icon button overrides inside `.project-card-top`
Lines 420437: `.project-avatar`
Lines 134152: `.sidebar-nav-item`, `.sidebar-nav-item:hover`, `.sidebar-nav-item.is-active`
**New CSS blocks to append** — verbatim from `go-backend/internal/web/ui/app.css`:
**Block 1: Tasks section (go-backend lines 12531348):**
```css
/* ============================================================
Section 19 — Tasks section (kanban column container)
============================================================ */
.tasks-section {
background: var(--color-surface-default);
border: 1px solid var(--color-border-subtle);
border-radius: 1rem;
overflow: hidden;
}
.tasks-section-header {
align-items: center;
border-bottom: 1px solid var(--color-border-muted);
display: flex;
justify-content: space-between;
padding: 1.2rem 1rem;
}
.tasks-add-button {
align-items: center;
background: var(--color-surface-default);
border: 1px solid var(--color-border-muted);
border-radius: 0.7rem;
color: var(--color-text-secondary);
cursor: pointer;
display: inline-flex;
font-weight: 500;
gap: 0.5rem;
min-height: 2.75rem;
padding: 0.7rem 1rem;
}
.task-list {
display: flex;
flex-direction: column;
}
.task-row {
align-items: center;
border-bottom: 1px solid var(--color-border-muted);
cursor: pointer;
display: flex;
gap: 0.75rem;
padding: 0.9rem 1rem;
transition: background-color 0.2s ease;
}
.task-row:hover {
background: var(--color-surface-neutral-hover);
}
.task-check {
align-items: center;
background: var(--color-surface-default);
border: 2px solid var(--color-border-strong);
border-radius: 999px;
color: var(--color-text-inverse);
cursor: pointer;
display: inline-flex;
flex-shrink: 0;
height: 2rem;
justify-content: center;
width: 2rem;
}
.task-check.is-complete {
background: var(--color-text-brand-strong);
border-color: var(--color-text-brand-strong);
}
.task-body {
flex: 1;
min-width: 0;
}
.task-body p {
color: var(--color-surface-muted-inverse);
font-size: 0.95rem;
font-weight: 500;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-row.is-complete .task-body p {
color: var(--color-text-faint);
text-decoration: line-through;
}
.task-meta {
align-items: center;
color: var(--color-text-muted);
display: flex;
flex-wrap: wrap;
font-size: 0.75rem;
gap: 0.45rem;
margin-top: 0.3rem;
}
```
**Block 2: Progress bar (go-backend lines 10611072):**
```css
/* ============================================================
Section 20 — Progress track and bar
============================================================ */
.project-progress-track {
background: var(--color-surface-muted);
border-radius: 999px;
height: 0.5rem;
overflow: hidden;
}
.project-progress-bar {
background: var(--project-color, var(--color-project-fallback));
border-radius: 999px;
height: 100%;
}
```
**Block 3: New classes for Phase 16 (not in go-backend, spec from UI-SPEC.md):**
```css
/* ============================================================
Section 21 — Tab nav (tablo detail tab bar)
============================================================ */
.tab-nav {
align-items: center;
border-bottom: 1px solid var(--color-border-muted);
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
overflow-x: auto;
}
.tab-nav-item {
align-items: center;
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
display: flex;
font-size: 0.875rem;
font-weight: 400;
gap: 0.5rem;
min-height: 44px;
padding-bottom: 0.75rem;
padding-inline: 0.25rem;
transition: color 0.15s ease, border-color 0.15s ease;
}
.tab-nav-item.is-active {
border-bottom-color: var(--color-brand-primary);
color: var(--color-text-brand);
font-weight: 600;
}
.tab-nav-item:hover:not(.is-active) {
color: var(--color-text-primary);
}
/* ============================================================
Section 22 — Tablo detail metadata row
============================================================ */
.tablo-metadata-row {
align-items: center;
border-bottom: 1px solid var(--color-border-muted);
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
}
.tablo-metadata-date {
align-items: center;
color: var(--color-text-muted);
display: flex;
font-size: 0.875rem;
gap: 0.5rem;
}
/* ============================================================
Section 23 — Kanban column wrapper
============================================================ */
.kanban-column {
flex-shrink: 0;
width: 18rem;
}
/* Scope column h3 to 1rem — base rule is 1.6rem (full-width views).
18rem columns overflow at 1.6rem. Base rule left unchanged. */
.kanban-column .tasks-section-header h3 {
font-size: 1rem;
}
/* ============================================================
Section 24 — Etape group sub-headings inside kanban columns
============================================================ */
.etape-group-header {
align-items: center;
background: var(--color-surface-muted);
border-bottom: 1px solid var(--color-border-muted);
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
}
.etape-group-color-dot {
background: var(--project-color, var(--color-project-fallback));
border-radius: 999px;
flex-shrink: 0;
height: 0.5rem;
width: 0.5rem;
}
.etape-group-label {
color: var(--color-text-secondary);
font-size: 0.875rem;
font-weight: 600;
}
.etape-group-label.is-unassigned {
color: var(--color-text-muted);
font-weight: 400;
}
/* ============================================================
Section 25 — Empty task list placeholder
============================================================ */
.task-list-empty {
color: var(--color-text-faint);
font-size: 0.875rem;
font-style: italic;
padding: 0.75rem 1rem;
}
```
---
### `backend/internal/web/ui/icon_button.templ` — add `download` and `chat` icon cases
**Analog:** Existing switch cases in same file (lines 1873), specifically the `trash` case (lines 6370) as the model for SVG structure.
**Import pattern** (lines 11 — `package ui` header only, no imports needed, templ handles it):
```go
package ui
```
**Core pattern — add two new cases before `default:`** (insert after line 70, before line 71):
```go
case "download":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" x2="12" y1="15" y2="3"></line>
</svg>
case "chat":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path>
</svg>
```
**Critical note:** The `default` case (line 71) emits `<span>{kind}</span>` as raw text — any IconButton with an unknown icon renders its icon name as visible text. The `download` and `chat` cases MUST be added before using those icon names in header or file row buttons.
---
### `backend/templates/tablos.templ` — header, tab nav, overview tab
**Analog within same file:**
- Header layout: `TabloProjectCard` (lines 71117) — `.project-card-top`, `.project-card-title-row`, `.project-avatar`, `@ui.IconButton` usage
- Overview section pattern: `TablosDashboard` (lines 1242) — `.overview-section`, `.overview-section-heading`
- EmptyState pattern: `TablosEmptyState` (lines 4764) — `@ui.EmptyState` call signature
- Delete confirm pattern: `TabloDeleteConfirmFragment` (lines 584623) — `@ui.Button` + HTMX confirm flow
**Imports pattern** (lines 17 — unchanged):
```go
package templates
import (
"backend/internal/auth"
"backend/internal/db/sqlc"
"backend/internal/web/ui"
)
```
**Color avatar pattern** (from `TabloProjectCard` lines 103112 — reuse for detail header):
```go
// Current (renders no character inside avatar — Phase 16 adds first-char text):
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
<span class="project-avatar" style={ "background-color: " + card.Tablo.Color.String }></span>
} else {
<span class="project-avatar"></span>
}
// Phase 16 target (add first character + guard for empty title):
if tablo.Color.Valid && tablo.Color.String != "" {
<span class="project-avatar" style={ "background-color: " + tablo.Color.String }>
if len(tablo.Title) > 0 {
{ string([]rune(tablo.Title)[0:1]) }
}
</span>
} else {
<span class="project-avatar">
if len(tablo.Title) > 0 {
{ string([]rune(tablo.Title)[0:1]) }
}
</span>
}
```
**Tab nav active state pattern** (from `TabloDetailPage` lines 306310 — replace inline Tailwind hex with token classes):
```go
// Current (hardcoded hex — remove this):
if activeTab == "overview" || activeTab == "" {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 shrink-0 min-h-[44px] text-[#804EEC] border-[#804EEC]"
} else {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 border-transparent shrink-0 min-h-[44px] text-[#667085] hover:text-gray-900"
}
// Phase 16 target (design token CSS classes — matches sidebar-nav-item.is-active pattern):
if activeTab == "overview" || activeTab == "" {
class="tab-nav-item is-active"
} else {
class="tab-nav-item"
}
```
Apply this pattern to all 5 tab `<a>` elements (overview, tasks, files, discussion, events).
**IconButton for Discussion link** (replaces hardcoded `<a>` button at lines 252262):
```go
// Phase 16: @ui.IconButton with "chat" icon and an outer <a> for navigation
// Note: Discussion is a navigation link, not a button — wrap differently:
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/discussion") }
hx-get={ "/tablos/" + tablo.ID.String() + "/discussion" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() + "/discussion" }
class="tab-nav-item" // or use @ui.Button(ButtonVariantGhost) depending on layout
aria-label="Discussion"
>Discussion</a>
```
**Button for Invite Member** (replaces inline `<button>` at lines 263266):
```go
@ui.Button(ui.ButtonProps{
Label: "Invite Member",
Variant: ui.ButtonVariantGhost,
Tone: ui.ButtonToneSolid, // ghost variant ignores tone (see variants.go line 159-163)
Size: ui.SizeMD,
Type: "button",
})
```
**IconButton for Delete in detail header** (per Pitfall 6 — do NOT modify `TabloDeleteButtonFragment`, use inline button):
```go
// Inline in TabloDetailPage header — does not use TabloDeleteButtonFragment
// (that fragment is used by dashboard cards; changing it would affect them)
<div class="tablo-delete-zone">
@ui.IconButton(ui.IconButtonProps{
Label: "Delete tablo",
Icon: "trash",
Variant: ui.IconButtonVariantDanger,
Tone: ui.IconButtonToneGhost,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tablo.ID.String() + "/delete-confirm",
"hx-target": "closest .tablo-delete-zone",
"hx-swap": "outerHTML",
},
})
</div>
```
**Metadata row with Badge** (replaces inline status span at lines 279282):
```go
<div class="tablo-metadata-row">
<div class="tablo-metadata-date">
// calendar SVG icon...
<span>Created</span>
<span>{ tablo.CreatedAt.Time.Format("Jan 2, 2006") }</span>
</div>
@ui.Badge(ui.BadgeProps{Label: "In progress", Variant: ui.BadgeVariantPrimary})
<div class="project-progress-track">
<div class="project-progress-bar" style="width: 0%;"></div>
</div>
</div>
```
**Description zone moved to Overview tab** (from `TabloDescDisplay` / `TabloDescEditFragment` — preserve as-is, just relocate call):
```go
// TabloOverviewTabFragment — add desc zone call here:
templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) {
<div class="overview-tab">
<div class="tablo-desc-zone">
@TabloDescDisplay(tablo, csrfToken)
</div>
// other overview content...
</div>
}
```
**TasksTabFragment — remove EtapeStrip call** (line 412 — delete this line):
```go
// DELETE this line from TasksTabFragment:
@EtapeStrip(tablo.ID, etapes, counts, filter, csrfToken, false)
// UPDATE KanbanBoard call to pass etapes:
@KanbanBoard(tablo.ID, csrfToken, tasks, filter, etapes)
```
---
### `backend/templates/tasks.templ` — kanban restyling + etape grouping
**Analog:** `groupTasksByStatus` in same file (lines 1018) — model for `groupTasksByEtape` helper.
**Imports pattern** (lines 18 — add nothing; `sqlc` already imported):
```go
package templates
import (
"backend/internal/db/sqlc"
"backend/internal/web/ui"
"github.com/google/uuid"
"strconv"
)
```
**groupTasksByEtape helper** (mirrors `groupTasksByStatus` lines 1018):
```go
// EtapeGroup holds tasks for one etape within a kanban column.
type EtapeGroup struct {
EtapeID string // "" for unassigned
EtapeTitle string // "No etape" for unassigned
EtapeColor string // "" for unassigned
Tasks []sqlc.Task
}
// groupTasksByEtape groups tasks by etape in declaration order; unassigned last.
// Model: groupTasksByStatus (lines 10-18).
func groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup {
// 1. Build etapeID → Etape index for O(1) lookup
// 2. Collect tasks per etapeID (preserve etape slice order)
// 3. Build result: one EtapeGroup per etape that has tasks
// 4. Append unassigned group at end (tasks where EtapeID is nil/zero)
}
```
**KanbanBoard signature change** (line 23 — add `etapes []sqlc.Etape` parameter):
```go
// Current (line 23):
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter)
// Phase 16 target:
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter, etapes []sqlc.Etape)
```
Three call sites must be updated simultaneously:
- `tablos.templ` line 413: `@KanbanBoard(tablo.ID, csrfToken, tasks, filter)` → add `, etapes`
- `handlers_tasks.go` line 594: `templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter)` → add `, etapes`
- `handlers_tasks.go` line 645: `templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter)` → add `, etapes`
**KanbanColumn restyled with tasks-section** (replaces current `<div class="flex-shrink-0 w-72">` at line 107):
```go
// Current column container (lines 107-131):
<div class="flex-shrink-0 w-72">
<div class="bg-slate-100 rounded px-3 py-2 mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-700">{ TaskColumnLabels[status] }</h3>
<span id={ "task-count-badge-" + string(status) }>
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
</span>
</div>
<div class="sortable-column min-h-16 space-y-2" ...>
...task cards...
</div>
<div id={ "add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken, filter)
</div>
</div>
// Phase 16 target (tasks-section layout with etape grouping):
<div class="kanban-column">
<div class="tasks-section">
<div class="tasks-section-header">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<h3>{ TaskColumnLabels[status] }</h3>
<span id={ "task-count-badge-" + string(status) }>
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
</span>
</div>
<div id={ "add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken, filter)
</div>
</div>
<div
class="task-list sortable-column"
data-status={ string(status) }
id={ "column-" + string(status) }
aria-label={ TaskColumnLabels[status] + " column" }
>
if len(tasks) == 0 {
<p class="task-list-empty">No tasks yet</p>
} else {
{{ groups := groupTasksByEtape(tasks, etapes) }}
for _, group := range groups {
@EtapeGroupHeader(group)
for _, task := range group.Tasks {
@TaskCard(tabloID, task, csrfToken)
}
}
}
</div>
</div>
</div>
```
**AddTaskTrigger restyled** (replaces current button at lines 372379 — use `.tasks-add-button` class):
```go
// Current (uses ui-button classes directly):
<button type="button" class="ui-button ui-button-soft ui-button-neutral ui-button-md w-full text-left text-sm mt-2" ...>
+ Add task
</button>
// Phase 16 target:
<button type="button" class="tasks-add-button" ...>
+ Add task
</button>
```
**TaskCard restyled** (replaces `.task-card` div at lines 138165 — use `.task-row` CSS):
```go
// Current (lines 136-167):
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
<div class="task-card bg-white rounded border border-slate-200 px-3 py-2 shadow-sm" data-task-id={ task.ID.String() }>
<div class="flex items-start justify-between gap-2">
<div class="task-drag-handle cursor-grab text-slate-400 select-none mt-0.5"></div>
<div class="flex-1 min-w-0 cursor-pointer" hx-get=... >
<p class="text-sm text-slate-800 break-words">{ task.Title }</p>
</div>
<button ...>Delete</button>
</div>
</div>
</div>
// Phase 16 target (task-row pattern from go-backend):
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
<div
class="task-row task-card"
data-task-id={ task.ID.String() }
hx-get={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/edit" }
hx-target="closest .task-card-zone"
hx-swap="outerHTML"
role="button"
aria-label={ "Edit task: " + task.Title }
>
<div class="task-check" role="checkbox" aria-checked="false"></div>
<div class="task-body">
<p>{ task.Title }</p>
</div>
@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.String() + "/tasks/" + task.ID.String() + "/delete-confirm",
"hx-target": "closest .task-card-zone",
"hx-swap": "outerHTML",
},
})
</div>
</div>
```
**EtapeGroupHeader helper component** (new templ function in `tasks.templ`):
```go
// EtapeGroupHeader renders the sub-heading row for an etape group within a kanban column.
// "No etape" / unassigned group omits the color dot and uses muted label style.
templ EtapeGroupHeader(group EtapeGroup) {
<div class="etape-group-header">
if group.EtapeColor != "" {
<span class="etape-group-color-dot" style={ "background-color: " + group.EtapeColor }></span>
}
if group.EtapeID == "" {
<span class="etape-group-label is-unassigned">{ group.EtapeTitle }</span>
} else {
<span class="etape-group-label">{ group.EtapeTitle }</span>
}
</div>
}
```
**EtapeStrip OOB removal** (from `TaskCardGone` line 386 and `TaskCardOOB` line 398):
```go
// DELETE from TaskCardGone (line 386):
@EtapeStrip(tabloID, etapes, counts, filter, csrfToken, true)
// DELETE from TaskCardOOB (line 398):
@EtapeStrip(tabloID, etapes, counts, filter, csrfToken, true)
// Keep etapes/counts parameters on TaskCardGone and TaskCardOOB signatures
// to avoid handler signature changes that would break tests.
// Mark with TODO comment for future cleanup.
```
---
### `backend/templates/etapes.templ` — EtapeStrip removal
**No new pattern needed.** The `EtapeStrip` function itself is retained (lines 11101) but called from nowhere after removal from `tablos.templ`, `TaskCardGone`, and `TaskCardOOB`. `EtapeEditFormFragment`, `EtapeCreateFormFragment`, and `EtapeDeleteConfirmFragment` are preserved unchanged.
The `@ui.CSRFField` + `@ui.Button` pattern in etape forms (lines 103195) is already correct — no restyling needed.
---
### `backend/templates/files.templ` — files section restyling
**Analog within same file:** `FileDeleteConfirmFragment` (lines 104146) for the confirm HTMX pattern. `TablosDashboard` in `tablos.templ` for `@ui.EmptyState` and `.overview-section-heading`.
**FilesTabFragment — new structure** (replaces lines 1227):
```go
// Current:
templ FilesTabFragment(tablo sqlc.Tablo, files []sqlc.TabloFile, csrfToken string) {
<div class="files-tab">
@FileUploadForm(tablo.ID, csrfToken, "")
<div class="mt-6">
if len(files) == 0 {
@FileListEmpty()
} else {
<ul class="divide-y divide-slate-200 rounded border border-slate-200">
for _, f := range files {
@FileListRow(tablo.ID, f)
}
</ul>
}
</div>
</div>
}
// Phase 16 target:
templ FilesTabFragment(tablo sqlc.Tablo, files []sqlc.TabloFile, csrfToken string) {
<div class="overview-section">
<div class="overview-section-heading">
<h3>Files</h3>
@ui.Button(ui.ButtonProps{
Label: "Upload file",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
// hx-get to reveal FileUploadForm inline or toggle a slot
"hx-get": "/tablos/" + tablo.ID.String() + "/files/upload-form",
"hx-target": "#file-upload-slot",
"hx-swap": "innerHTML",
},
})
</div>
<div id="file-upload-slot"></div>
if len(files) == 0 {
@ui.EmptyState(ui.EmptyStateProps{
Title: "No files yet",
Description: "Upload your first file to get started.",
})
} else {
@ui.Table(ui.TableProps{
Head: fileTableHead(),
Body: fileTableBody(tablo.ID, files),
})
}
</div>
}
```
**FileTableHead helper** (new private templ in `files.templ`):
```go
// fileTableHead renders the <thead> content for the files table.
// @ui.Table places this inside <thead>; render <tr><th>...</th></tr>.
templ fileTableHead() {
<tr>
<th>Filename</th>
<th>Size</th>
<th>Uploaded</th>
<th>Actions</th>
</tr>
}
```
**FileTableBody helper** (new private templ in `files.templ`):
```go
// fileTableBody renders the <tbody> content for the files table.
templ fileTableBody(tabloID uuid.UUID, files []sqlc.TabloFile) {
for _, f := range files {
@FileListRow(tabloID, f)
}
}
```
**FileListRow — must be `<tr>` for @ui.Table** (replaces `<li>` at lines 7299):
```go
// Current (uses <li> — incompatible with @ui.Table's <tbody>):
templ FileListRow(tabloID uuid.UUID, file sqlc.TabloFile) {
<li class="file-row-zone" id={ "file-" + file.ID.String() }>...
// Phase 16 target (CRITICAL: outer must be <tr> for valid HTML inside <tbody>):
templ FileListRow(tabloID uuid.UUID, file sqlc.TabloFile) {
<tr class="file-row-zone" id={ "file-" + file.ID.String() }>
<td>{ file.Filename }</td>
<td>{ formatBytes(file.SizeBytes) }</td>
<td>
if file.CreatedAt.Valid {
{ file.CreatedAt.Time.Format("2006-01-02") }
}
</td>
<td>
<a href={ templ.SafeURL("/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/download") } aria-label="Download file">
@ui.IconButton(ui.IconButtonProps{
Label: "Download file",
Icon: "download",
Variant: ui.IconButtonVariantNeutral,
Tone: ui.IconButtonToneGhost,
Type: "button",
})
</a>
@ui.IconButton(ui.IconButtonProps{
Label: "Delete file",
Icon: "trash",
Variant: ui.IconButtonVariantDanger,
Tone: ui.IconButtonToneGhost,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete-confirm",
"hx-target": "closest .file-row-zone",
"hx-swap": "outerHTML",
},
})
</td>
</tr>
}
```
**FileDeleteConfirmFragment — must also be `<tr>`** (replaces `<div>` at lines 104146 — see Pitfall 2):
```go
// Current outer element (line 105):
<div class="file-row-zone" id={ "file-" + file.ID.String() }>
// Phase 16 target — outerHTML swap requires matching element type:
<tr class="file-row-zone" id={ "file-" + file.ID.String() }>
<td colspan="4">
// confirm dialog content (buttons unchanged)
</td>
</tr>
```
**FileListEmpty is replaced by @ui.EmptyState** — the `FileListEmpty()` function (lines 149151) can be retained for backward compatibility but should not be called from `FilesTabFragment` or `UploadErrorFragment` anymore.
---
## Shared Patterns
### IconButton + HTMX outerHTML delete flow
**Source:** `backend/templates/files.templ` `FileDeleteConfirmFragment` (lines 104146), `backend/templates/tasks.templ` `TaskDeleteConfirmFragment` (lines 326367)
**Apply to:** All delete icon buttons in file rows and task cards.
```go
// Pattern: trigger button on the row → HTMX loads confirm fragment → outerHTML swap
// The trigger button and the confirm fragment MUST share the same zone class and id.
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete-confirm",
"hx-target": "closest .file-row-zone",
"hx-swap": "outerHTML",
},
```
### Design token enforcement (no hardcoded hex)
**Source:** `backend/internal/web/ui/base.css` + existing `app.css` sections
**Apply to:** All new CSS rules in `app.css` and all new inline styles in `.templ` files.
```css
/* ALWAYS use tokens: */
color: var(--color-text-muted); /* NOT #667085 */
color: var(--color-text-brand); /* NOT #804EEC */
border-color: var(--color-border-muted); /* NOT #F2F4F7 */
background: var(--color-surface-muted); /* NOT #F9FAFB */
```
### @ui.Badge usage (task count + status pill)
**Source:** `backend/templates/tasks.templ` lines 110112 (task count badge in KanbanColumn)
**Apply to:** Column header task count, status pill in metadata row.
```go
// Task count in column header (existing pattern — keep BadgeVariantInfo):
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
// Status pill in metadata row (Phase 16 — use BadgeVariantPrimary per UI-SPEC):
@ui.Badge(ui.BadgeProps{Label: "In progress", Variant: ui.BadgeVariantPrimary})
```
### .overview-section-heading header pattern
**Source:** `backend/templates/tablos.templ` `TablosDashboard` (lines 1429), `backend/internal/web/ui/app.css` lines 348361
**Apply to:** Files section header (D-F01).
```go
// Analog from TablosDashboard:
<section class="overview-section">
<div class="overview-section-heading">
<h3>Your Tablos</h3>
@ui.Button(...)
</div>
...
</section>
```
### @ui.EmptyState usage
**Source:** `backend/templates/tablos.templ` `TablosEmptyState` (lines 4764)
**Apply to:** `FilesTabFragment` empty state (replaces `FileListEmpty()`).
```go
// Analog (TablosEmptyState):
@ui.EmptyState(ui.EmptyStateProps{
Title: "No tablos yet",
Description: "Create your first tablo to get started.",
Action: ui.Button(...),
})
// Phase 16 usage (no action button in files empty state):
@ui.EmptyState(ui.EmptyStateProps{
Title: "No files yet",
Description: "Upload your first file to get started.",
})
```
### CSRF token in forms
**Source:** All existing forms use `@ui.CSRFField(csrfToken)` — no exceptions.
**Apply to:** Any new form elements added in Phase 16 (file upload trigger form, etape create/edit forms already have this).
---
## No Analog Found
None. All files have strong analogs.
---
## Implementation Order (Wave Map)
| Wave | Files | Gate |
|------|-------|------|
| 0 | `icon_button.templ` (add `download` + `chat` cases) | `templ generate && go build ./...` |
| 1 | `app.css` (append Sections 1925) | Visual diff in browser |
| 2 | `tablos.templ` (header, tab nav, overview tab, TasksTabFragment EtapeStrip removal) | `go test ./backend/internal/web/... -run TestTablos -count=1` |
| 3 | `tasks.templ` (groupTasksByEtape, KanbanBoard/Column/Card restyling, EtapeStrip OOB removal), `etapes.templ` (no-op, EtapeStrip already unused) | `go test ./backend/internal/web/... -run TestTask -count=1` |
| 4 | `files.templ` (FilesTabFragment, FileListRow as `<tr>`, FileDeleteConfirmFragment as `<tr>`, EmptyState) | `go test ./backend/internal/web/... -run TestFilesTab -count=1` |
---
## Critical Constraints
1. **`<tr>` requirement:** `FileListRow` and `FileDeleteConfirmFragment` must use `<tr>` as their outer element when rendered inside `@ui.Table`. The HTMX `hx-target="closest .file-row-zone"` + `hx-swap="outerHTML"` depends on element identity — both the trigger row and the confirm row must use the same element type (`<tr>`).
2. **KanbanBoard call sites:** Three locations must be updated when adding `etapes []sqlc.Etape` parameter — compile will fail if any is missed. Run `templ generate && go build ./...` after changing the signature.
3. **EtapeStrip OOB removal:** Remove `@EtapeStrip(...)` from `TaskCardGone` (line 386) and `TaskCardOOB` (line 398). Keep the `etapes []sqlc.Etape` and `counts EtapeTaskCounts` parameters on those components to avoid handler changes.
4. **TabloDeleteButtonFragment unchanged:** The dashboard delete button fragment must not be modified. Phase 16 adds a separate inline `@ui.IconButton` in `TabloDetailPage` header instead.
5. **No Tailwind utility classes in new CSS:** All new CSS rules in `app.css` use `var(--...)` tokens only. Existing Tailwind utilities in edit forms are left as-is.
---
## Metadata
**Analog search scope:** `backend/templates/`, `backend/internal/web/ui/`, `go-backend/internal/web/ui/`, `go-backend/internal/web/views/`
**Files scanned:** 11
**Pattern extraction date:** 2026-05-16