# 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 344–361: `.overview-section`, `.overview-section-heading`, shared `h3` font-size rule
Lines 386–407: `.project-card-top`, icon button overrides inside `.project-card-top`
Lines 420–437: `.project-avatar`
Lines 134–152: `.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 1253–1348):**
```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 1061–1072):**
```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 18–73), specifically the `trash` case (lines 63–70) as the model for SVG structure.

**Import pattern** (lines 1–1 — `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 71–117) — `.project-card-top`, `.project-card-title-row`, `.project-avatar`, `@ui.IconButton` usage
- Overview section pattern: `TablosDashboard` (lines 12–42) — `.overview-section`, `.overview-section-heading`
- EmptyState pattern: `TablosEmptyState` (lines 47–64) — `@ui.EmptyState` call signature
- Delete confirm pattern: `TabloDeleteConfirmFragment` (lines 584–623) — `@ui.Button` + HTMX confirm flow

**Imports pattern** (lines 1–7 — unchanged):
```go
package templates

import (
    "backend/internal/auth"
    "backend/internal/db/sqlc"
    "backend/internal/web/ui"
)
```

**Color avatar pattern** (from `TabloProjectCard` lines 103–112 — 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 306–310 — 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 252–262):
```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 263–266):
```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 279–282):
```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 10–18) — model for `groupTasksByEtape` helper.

**Imports pattern** (lines 1–8 — 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 10–18):
```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 372–379 — 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 138–165 — 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 11–101) 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 103–195) is already correct — no restyling needed.

---

### `backend/templates/files.templ` — files section restyling

**Analog within same file:** `FileDeleteConfirmFragment` (lines 104–146) for the confirm HTMX pattern. `TablosDashboard` in `tablos.templ` for `@ui.EmptyState` and `.overview-section-heading`.

**FilesTabFragment — new structure** (replaces lines 12–27):
```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 72–99):
```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 104–146 — 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 149–151) 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 104–146), `backend/templates/tasks.templ` `TaskDeleteConfirmFragment` (lines 326–367)
**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 110–112 (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 14–29), `backend/internal/web/ui/app.css` lines 348–361
**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 47–64)
**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 19–25) | 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
