xtablo-source/.planning/phases/16-tablo-detail/16-PATTERNS.md
2026-05-16 23:30:34 +02:00

32 KiB
Raw Blame History

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):

/* ============================================================
   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):

/* ============================================================
   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):

/* ============================================================
   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):

package ui

Core pattern — add two new cases before default: (insert after line 70, before line 71):

    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):

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):

// 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):

// 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):

// 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):

@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):

// 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):

<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):

// 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):

// 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):

package templates

import (
    "backend/internal/db/sqlc"
    "backend/internal/web/ui"
    "github.com/google/uuid"
    "strconv"
)

groupTasksByEtape helper (mirrors groupTasksByStatus lines 1018):

// 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):

// 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):

// 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):

// 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):

// 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):

// 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):

// 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):

// 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):

// 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):

// 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):

// 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):

// 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.

// 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.

/* 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.

// 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).

// 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()).

// 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