32 KiB
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):
/* ============================================================
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):
/* ============================================================
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 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):
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 71–117) —.project-card-top,.project-card-title-row,.project-avatar,@ui.IconButtonusage - Overview section pattern:
TablosDashboard(lines 12–42) —.overview-section,.overview-section-heading - EmptyState pattern:
TablosEmptyState(lines 47–64) —@ui.EmptyStatecall signature - Delete confirm pattern:
TabloDeleteConfirmFragment(lines 584–623) —@ui.Button+ HTMX confirm flow
Imports pattern (lines 1–7 — unchanged):
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):
// 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):
// 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):
// 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):
@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 279–282):
<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 10–18) — model for groupTasksByEtape helper.
Imports pattern (lines 1–8 — 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 10–18):
// 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.templline 413:@KanbanBoard(tablo.ID, csrfToken, tasks, filter)→ add, etapeshandlers_tasks.goline 594:templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter)→ add, etapeshandlers_tasks.goline 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 372–379 — 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 138–165 — 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 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):
// 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 72–99):
// 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):
// 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.
// 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 110–112 (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 14–29), backend/internal/web/ui/app.css lines 348–361
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 47–64)
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 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
-
<tr>requirement:FileListRowandFileDeleteConfirmFragmentmust use<tr>as their outer element when rendered inside@ui.Table. The HTMXhx-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>). -
KanbanBoard call sites: Three locations must be updated when adding
etapes []sqlc.Etapeparameter — compile will fail if any is missed. Runtempl generate && go build ./...after changing the signature. -
EtapeStrip OOB removal: Remove
@EtapeStrip(...)fromTaskCardGone(line 386) andTaskCardOOB(line 398). Keep theetapes []sqlc.Etapeandcounts EtapeTaskCountsparameters on those components to avoid handler changes. -
TabloDeleteButtonFragment unchanged: The dashboard delete button fragment must not be modified. Phase 16 adds a separate inline
@ui.IconButtoninTabloDetailPageheader instead. -
No Tailwind utility classes in new CSS: All new CSS rules in
app.cssusevar(--...)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