docs(16): create phase plan
This commit is contained in:
parent
5060e4bb96
commit
337c380d6b
2 changed files with 921 additions and 6 deletions
|
|
@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||
milestone: v3.0
|
||||
milestone_name: Design System & Visual Polish
|
||||
status: executing
|
||||
last_updated: "2026-05-16T20:47:02.232Z"
|
||||
last_activity: 2026-05-16 -- Phase 15 execution started
|
||||
last_updated: "2026-05-16T21:30:26.245Z"
|
||||
last_activity: 2026-05-16 -- Phase 16 planning complete
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 3
|
||||
total_plans: 10
|
||||
total_plans: 14
|
||||
completed_plans: 10
|
||||
percent: 100
|
||||
percent: 71
|
||||
---
|
||||
|
||||
# STATE
|
||||
|
|
@ -30,8 +30,8 @@ See: `.planning/PROJECT.md` (updated 2026-05-16)
|
|||
|
||||
Phase: 15 (dashboard-tablos) — EXECUTING
|
||||
Plan: 1 of 3
|
||||
Status: Executing Phase 15
|
||||
Last activity: 2026-05-16 -- Phase 15 execution started
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-05-16 -- Phase 16 planning complete
|
||||
|
||||
## Previous Milestone Status
|
||||
|
||||
|
|
|
|||
915
.planning/phases/16-tablo-detail/16-PATTERNS.md
Normal file
915
.planning/phases/16-tablo-detail/16-PATTERNS.md
Normal file
|
|
@ -0,0 +1,915 @@
|
|||
# Phase 16: Tablo Detail - Pattern Map
|
||||
|
||||
**Mapped:** 2026-05-16
|
||||
**Files analyzed:** 5 (4 templ files + 1 CSS file)
|
||||
**Analogs found:** 5 / 5
|
||||
|
||||
---
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `backend/internal/web/ui/app.css` | config/styles | transform | `go-backend/internal/web/ui/app.css` | exact (source of CSS blocks to port) |
|
||||
| `backend/internal/web/ui/icon_button.templ` | component | request-response | existing icon cases in same file | exact (additive switch cases) |
|
||||
| `backend/templates/tablos.templ` | component | request-response | same file — `TabloProjectCard`, `TablosDashboard` | exact (existing patterns within same file) |
|
||||
| `backend/templates/tasks.templ` | component | request-response | `go-backend/internal/web/views/tasks.templ`, same file | role-match |
|
||||
| `backend/templates/etapes.templ` | component | request-response | same file (EtapeStrip removal) | exact (deletion, no replacement pattern needed) |
|
||||
| `backend/templates/files.templ` | component | request-response | same file — `TablosDashboard` (EmptyState), `FilesTabFragment` | role-match |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `backend/internal/web/ui/app.css` — CSS additions
|
||||
|
||||
**Analog:** `go-backend/internal/web/ui/app.css` (verbatim source for all new CSS blocks)
|
||||
|
||||
**Existing CSS already in `backend/internal/web/ui/app.css` (do NOT re-add):**
|
||||
|
||||
Lines 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
|
||||
Loading…
Reference in a new issue