go-htmx-gsd #1

Merged
arthur merged 558 commits from go-htmx-gsd into main 2026-05-23 15:16:44 +00:00
5 changed files with 988 additions and 1 deletions
Showing only changes of commit 965ec5e5ce - Show all commits

View file

@ -111,6 +111,7 @@ Plans:
**Mode:** mvp
**Status:** Pending
**Requirements:** DETAIL-01, DETAIL-02, DETAIL-03, DETAIL-04
**Plans:** 4 plans
**Success Criteria:**
1. Tablo detail header uses project-card-top layout with title, avatar, and action controls
2. Task kanban uses tasks-section design: section header with add button, task rows with checkbox and meta
@ -118,7 +119,20 @@ Plans:
4. Files section uses the table component with consistent row actions
5. All existing task, etape, and file handler tests pass unchanged
**User-in-loop:** Approve kanban layout shape (whether columns stay horizontal or fold to list) and etape grouping UI before implementation.
Plans:
**Wave 1**
- [ ] 16-01-PLAN.md — CSS foundation + icons: append CSS Sections 1925 to app.css; add download + chat icon cases to UIIcon switch
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 16-02-PLAN.md — Header + tab nav + overview tab: restyle TabloDetailPage header (project-card-top), metadata row, tab nav (design token classes), move desc to overview tab, remove EtapeStrip call from TasksTabFragment
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 16-03-PLAN.md — Kanban + etape grouping: groupTasksByEtape helper, restyled KanbanBoard/Column/TaskCard, EtapeStrip OOB removal, handlers_tasks.go call sites updated
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 16-04-PLAN.md — Files section: @ui.Table, @ui.EmptyState, FileListRow as tr, FileDeleteConfirmFragment as tr, browser verify checkpoint
**User-in-loop:** Browser verify checkpoint in Plan 04 — approve visual result before considering the phase complete.
### Phase 17: Chat & Planning
**Goal:** Restyle the discussion view and planning page using design system components to make them visually consistent with the rest of the app.

View file

@ -0,0 +1,202 @@
---
phase: 16-tablo-detail
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/internal/web/ui/icon_button.templ
- backend/internal/web/ui/app.css
autonomous: true
requirements:
- DETAIL-01
- DETAIL-02
- DETAIL-03
- DETAIL-04
must_haves:
truths:
- "icon_button.templ UIIcon switch contains a case for 'download' that emits a download SVG arrow"
- "icon_button.templ UIIcon switch contains a case for 'chat' that emits a speech-bubble SVG"
- "app.css contains .tasks-section, .task-row, .task-check, .task-list, .task-body, .task-meta, .tasks-add-button CSS rules using var(--...) tokens"
- "app.css contains .project-progress-track and .project-progress-bar CSS rules"
- "app.css contains .tab-nav, .tab-nav-item, .tab-nav-item.is-active, .tab-nav-item:hover CSS rules"
- "app.css contains .tablo-metadata-row and .tablo-metadata-date CSS rules"
- "app.css contains .kanban-column and scoped .kanban-column .tasks-section-header h3 { font-size: 1rem } override"
- "app.css contains .etape-group-header, .etape-group-color-dot, .etape-group-label CSS rules"
- "app.css contains .task-list-empty CSS rule"
- "templ generate && go build ./... exits 0 after changes"
artifacts:
- path: backend/internal/web/ui/icon_button.templ
provides: Download and chat icon SVG cases in UIIcon switch
contains: "case \"download\":"
- path: backend/internal/web/ui/app.css
provides: All new CSS sections for Phase 16 (1925)
contains: ".tasks-section {"
key_links:
- from: backend/internal/web/ui/icon_button.templ
to: backend/internal/web/ui/icon_button_templ.go
via: templ generate
pattern: "case \"download\""
- from: backend/internal/web/ui/app.css
to: browser render
via: static asset embed
pattern: ".tab-nav-item.is-active"
---
## Phase Goal
**As a** tablo owner, **I want to** see a fully restyled tablo detail page — header, kanban, etapes, and files — **so that** the detail view looks visually consistent with the design system established in Phases 1315.
<objective>
Add the two missing icon SVG cases (`download`, `chat`) to `UIIcon` in `icon_button.templ`, and append all Phase 16 CSS sections (1925) to `app.css`. This is the shared substrate that plans 0204 build on — without it, `@ui.IconButton` with `Icon: "download"` or `Icon: "chat"` renders the icon name as raw text.
Purpose: Unblock header restyling (Discussion/Download IconButtons), kanban restyling (task-row CSS), and files restyling (table + icons).
Output: Updated `icon_button.templ` with `download` and `chat` cases; `app.css` with Sections 1925 appended.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/16-tablo-detail/16-CONTEXT.md
@.planning/phases/16-tablo-detail/16-RESEARCH.md
@.planning/phases/16-tablo-detail/16-PATTERNS.md
@.planning/phases/16-tablo-detail/16-UI-SPEC.md
<interfaces>
<!-- Key patterns the executor needs. No exploration required. -->
From backend/internal/web/ui/icon_button.templ (current UIIcon switch structure):
- Package: `package ui`
- Switch starts at line ~18, cases: "plus", "grid3x3", "list", "filter", "search", "calendar", "pencil", "trash"
- `default:` case emits `<span>{kind}</span>` (renders icon name as text — this is the bug)
- Insert `case "download":` and `case "chat":` BEFORE the `default:` case
From backend/internal/web/ui/app.css (current state):
- File is 450 lines; ends after Section 18 (project-card-top, project-avatar)
- Line 355-361: `.overview-section-heading h3, .tasks-section-header h3` shared rule exists (do NOT add a second `.tasks-section-header h3` rule; add only the scoped `.kanban-column .tasks-section-header h3 { font-size: 1rem; }` override)
- Sections to append: 19 (tasks-section block), 20 (progress bar), 21 (tab nav), 22 (metadata row), 23 (kanban-column wrapper), 24 (etape group), 25 (task-list-empty)
- All token names: `var(--color-surface-default)`, `var(--color-border-subtle)`, `var(--color-border-muted)`, `var(--color-border-strong)`, `var(--color-text-secondary)`, `var(--color-text-muted)`, `var(--color-text-faint)`, `var(--color-text-primary)`, `var(--color-text-brand)`, `var(--color-text-brand-strong)`, `var(--color-text-inverse)`, `var(--color-surface-muted)`, `var(--color-surface-neutral-hover)`, `var(--color-brand-primary)`, `var(--color-project-fallback)`, `var(--project-color, var(--color-project-fallback))`
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add `download` and `chat` icon cases to UIIcon switch</name>
<files>backend/internal/web/ui/icon_button.templ</files>
<read_first>
- backend/internal/web/ui/icon_button.templ (read entire file — ~80 lines — before editing; understand the full switch structure and the `default:` position)
</read_first>
<action>
Read the full `icon_button.templ` file. Find the UIIcon switch's `default:` case. Insert two new cases immediately before `default:`:
Case "download": emit an SVG with `viewBox="0 0 24 24"`, `fill="none"`, `stroke="currentColor"`, `stroke-width="2"`, `stroke-linecap="round"`, `stroke-linejoin="round"`, `aria-hidden="true"`. Contents: `path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"`, `polyline points="7 10 12 15 17 10"`, `line x1="12" x2="12" y1="15" y2="3"`.
Case "chat": emit an SVG with same attributes. Contents: `path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"`.
Do NOT modify any other part of the file. Do NOT change the `default:` case or any existing cases.
After editing, run: `just generate` (or `templ generate ./backend/...`) to regenerate `icon_button_templ.go`.
</action>
<verify>
<automated>grep -c 'case "download"' /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/ui/icon_button.templ && grep -c 'case "chat"' /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/ui/icon_button.templ && cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && go build ./backend/...</automated>
</verify>
<acceptance_criteria>
- `icon_button.templ` contains `case "download":` with a polyline + line SVG body
- `icon_button.templ` contains `case "chat":` with a path SVG body
- Both new cases appear before the `default:` case in the switch
- `go build ./backend/...` exits 0 (templ-generated file updated, no compile errors)
- The `default:` case and all 8 existing cases are unchanged
</acceptance_criteria>
<done>UIIcon switch contains "download" and "chat" cases; project compiles cleanly.</done>
</task>
<task type="auto">
<name>Task 2: Append CSS Sections 1925 to app.css</name>
<files>backend/internal/web/ui/app.css</files>
<read_first>
- backend/internal/web/ui/app.css (read lines 340450 to confirm the end of the file and the existing shared `.tasks-section-header h3` rule at lines 355361; verify no duplication before appending)
- .planning/phases/16-tablo-detail/16-PATTERNS.md (Pattern Assignments section: "New CSS blocks to append — Blocks 1, 2, 3" — these are the verbatim CSS blocks to append)
- .planning/phases/16-tablo-detail/16-UI-SPEC.md (New CSS Classes Required section — additional property values that refine the blocks)
</read_first>
<action>
Append the following seven CSS sections to the END of `backend/internal/web/ui/app.css`. Do NOT modify any existing rules. The shared `.tasks-section-header h3` rule at lines ~355-361 already exists — do NOT add another one; only add the scoped `.kanban-column .tasks-section-header h3 { font-size: 1rem; }` override.
Append in order:
Section 19 — Tasks section (ported from go-backend): `.tasks-section`, `.tasks-section-header`, `.tasks-add-button`, `.task-list`, `.task-row`, `.task-row:hover`, `.task-check`, `.task-check.is-complete`, `.task-body`, `.task-body p`, `.task-row.is-complete .task-body p`, `.task-meta`. All values from PATTERNS.md Block 1 verbatim.
Section 20 — Progress bar: `.project-progress-track`, `.project-progress-bar`. All values from PATTERNS.md Block 2 verbatim.
Section 21 — Tab nav: `.tab-nav`, `.tab-nav-item`, `.tab-nav-item.is-active`, `.tab-nav-item:hover:not(.is-active)`. Values from PATTERNS.md Block 3 / UI-SPEC.
Section 22 — Tablo detail metadata row: `.tablo-metadata-row`, `.tablo-metadata-date`, `.tablo-metadata-date svg`. Values from PATTERNS.md Block 3 / UI-SPEC.
Section 23 — Kanban column wrapper: `.kanban-column { flex-shrink: 0; width: 18rem; }` and `.kanban-column .tasks-section-header h3 { font-size: 1rem; }`.
Section 24 — Etape group sub-headings: `.etape-group-header`, `.etape-group-color-dot`, `.etape-group-label`, `.etape-group-label.is-unassigned`. Values from PATTERNS.md Block 3 / UI-SPEC.
Section 25 — Empty task list: `.task-list-empty { color: var(--color-text-faint); font-size: 0.875rem; font-style: italic; padding: 0.75rem 1rem; }`.
Every CSS value must use `var(--...)` tokens. No hardcoded hex values. No Tailwind utility classes.
</action>
<verify>
<automated>grep -c "\.tasks-section {" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/ui/app.css && grep -c "\.tab-nav-item\.is-active" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/ui/app.css && grep -c "\.kanban-column" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/ui/app.css && grep -c "\.etape-group-header" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/ui/app.css && grep -c "\.task-list-empty" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/ui/app.css</automated>
</verify>
<acceptance_criteria>
- `app.css` contains exactly one `.tasks-section {` rule (no duplication)
- `app.css` contains `.tab-nav-item.is-active` with `border-bottom-color: var(--color-brand-primary)` and `color: var(--color-text-brand)`
- `app.css` contains `.kanban-column` with `width: 18rem`
- `app.css` contains `.kanban-column .tasks-section-header h3` with `font-size: 1rem`
- `app.css` contains `.etape-group-header` with `background: var(--color-surface-muted)`
- `app.css` contains `.task-list-empty` with `font-style: italic`
- `app.css` contains `.project-progress-track` and `.project-progress-bar`
- `grep -c "#[0-9a-fA-F]\{3,6\}" app.css` returns 0 for newly appended lines (no hardcoded hex in new rules)
- `go build ./backend/...` exits 0 (CSS is a static asset, build verifies no templ regressions)
</acceptance_criteria>
<done>app.css has all Phase 16 CSS sections appended; no hardcoded hex values; project compiles cleanly.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Static asset → browser | CSS and templ-generated HTML served as static embed; no user input processed |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-01-01 | Tampering | app.css static embed | accept | CSS is embedded at build time; no runtime user modification possible; existing CSP controls unchanged |
| T-16-01-02 | Information Disclosure | SVG icon markup | accept | SVG icons are decorative, contain no user data; aria-hidden="true" on all decorative SVGs |
No new trust boundaries. No user input, no authentication changes, no handler changes.
</threat_model>
<verification>
After both tasks complete:
```bash
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source
go test ./backend/internal/web/... -count=1
```
All existing tests must pass. The CSS and icon changes do not affect test behavior (tests verify handler output, not CSS class names in most cases).
</verification>
<success_criteria>
- `icon_button.templ` has `case "download":` and `case "chat":` before `default:`
- `app.css` has 7 new sections (1925) appended with no hardcoded hex values
- `go test ./backend/internal/web/... -count=1` passes with no new failures
- `go build ./backend/...` exits 0
</success_criteria>
<output>
After completion, create `.planning/phases/16-tablo-detail/16-01-SUMMARY.md` using the summary template.
</output>

View file

@ -0,0 +1,235 @@
---
phase: 16-tablo-detail
plan: 02
type: execute
wave: 2
depends_on:
- 16-01
files_modified:
- backend/templates/tablos.templ
autonomous: true
requirements:
- DETAIL-01
must_haves:
truths:
- "Tablo detail page header uses .project-card-top layout with color avatar, inline-editable title zone, and action controls"
- "Action controls use @ui.IconButton for Discussion and Delete, @ui.Button for Invite Member — no hardcoded #804EEC hex"
- "Metadata row uses .tablo-metadata-row with created date, @ui.Badge status pill, and .project-progress-track/bar"
- "Tab nav uses .tab-nav / .tab-nav-item / .tab-nav-item.is-active classes replacing hardcoded Tailwind hex classes"
- "Description zone renders inside TabloOverviewTabFragment (not in the persistent header)"
- "EtapeStrip is no longer called from TasksTabFragment; KanbanBoard call passes etapes parameter"
- "All existing tablo handler tests pass unchanged"
artifacts:
- path: backend/templates/tablos.templ
provides: Restyled TabloDetailPage header, tab nav, overview tab, TasksTabFragment EtapeStrip removal
contains: ".project-card-top"
- path: backend/templates/tablos_templ.go
provides: Generated Go from restyled tablos.templ
exports: []
key_links:
- from: backend/templates/tablos.templ TabloDetailPage
to: backend/internal/web/ui/app.css .project-card-top
via: CSS class on div element
pattern: "project-card-top"
- from: backend/templates/tablos.templ TabloOverviewTabFragment
to: TabloDescDisplay / TabloDescEditFragment
via: "@TabloDescDisplay(tablo, csrfToken)" call inside overview tab
pattern: "TabloDescDisplay"
- from: backend/templates/tablos.templ TasksTabFragment
to: KanbanBoard
via: "@KanbanBoard(tablo.ID, csrfToken, tasks, filter, etapes)"
pattern: "KanbanBoard.*etapes"
---
<objective>
Restyle `TabloDetailPage` header area (project-card-top layout, color avatar, action controls), metadata row, tab nav bar, and overview tab content. Move the description zone from the persistent header into `TabloOverviewTabFragment`. Remove the `@EtapeStrip` call from `TasksTabFragment` and update the `KanbanBoard` call to pass `etapes`. All HTMX wiring, inline-edit zones, delete flow, and tab routing are preserved unchanged.
Purpose: Deliver DETAIL-01 — the tablo detail header matches the project-card-top design.
Output: Restyled `tablos.templ` with no hardcoded hex values in the detail page section.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/16-tablo-detail/16-CONTEXT.md
@.planning/phases/16-tablo-detail/16-RESEARCH.md
@.planning/phases/16-tablo-detail/16-PATTERNS.md
@.planning/phases/16-tablo-detail/16-UI-SPEC.md
@.planning/phases/16-tablo-detail/16-01-SUMMARY.md
<interfaces>
<!-- Critical contracts and current patterns the executor must know. -->
From backend/templates/tablos.templ (current state — read the file before editing):
TabloDetailPage signature (line ~200):
templ TabloDetailPage(tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, csrfToken string, activeTab string, user auth.User)
Current header structure uses raw inline Tailwind classes with hardcoded #804EEC hex. Replace with design token classes.
Color avatar pattern from TabloProjectCard (lines ~103112) — reuse for detail header, adding first-character text:
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]) }
} else {
span class="project-avatar"
if len(tablo.Title) > 0 { string([]rune(tablo.Title)[0:1]) }
}
Tab nav active state — current pattern (lines ~306325) uses inline Tailwind hex classes per-tab. Replace ALL 5 tabs (overview, tasks, files, discussion, events):
if activeTab == "overview" || activeTab == "" { class="tab-nav-item is-active" } else { class="tab-nav-item" }
EtapeStrip call in TasksTabFragment (line ~412): DELETE this call
@EtapeStrip(tablo.ID, etapes, counts, filter, csrfToken, false)
KanbanBoard call in TasksTabFragment (line ~413): UPDATE to add etapes parameter
@KanbanBoard(tablo.ID, csrfToken, tasks, filter)
@KanbanBoard(tablo.ID, csrfToken, tasks, filter, etapes)
Delete button in detail header — per Pitfall 6: do NOT modify TabloDeleteButtonFragment (used by dashboard cards).
Create an INLINE IconButton inside a .tablo-delete-zone div in TabloDetailPage header:
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/{id}/delete-confirm", "hx-target": "closest .tablo-delete-zone", "hx-swap": "outerHTML"}})
Discussion action in header: @ui.IconButton with Icon "chat", Label "Discussion", linking/navigating to discussion tab (ghost/neutral variant). Since Discussion is navigation, wrap in an <a> tag or use hx-get to load the discussion tab fragment:
a href="/tablos/{id}/discussion" hx-get="/tablos/{id}/discussion" hx-target="#tab-content" hx-swap="innerHTML" hx-push-url="true" class="tab-nav-item" aria-label="Go to Discussion tab"
@ui.IconButton(...Icon: "chat"...) -- OR render the button label directly as link text with chat icon
Invite button: @ui.Button(ui.ButtonProps{Label: "Invite Member", Variant: ui.ButtonVariantGhost, Tone: ui.ButtonToneSolid, Size: ui.SizeMD, Type: "button"})
Metadata row:
div class="tablo-metadata-row"
div class="tablo-metadata-date"
[calendar SVG 1rem aria-hidden]
span "Created"
span { tablo.CreatedAt.Time.Format("Jan 2, 2006") }
@ui.Badge(ui.BadgeProps{Label: "In progress", Variant: ui.BadgeVariantPrimary})
div class="project-progress-track"
div class="project-progress-bar" style="width: 0%;"
Overview tab (TabloOverviewTabFragment): move the tablo-desc-zone call here (currently in persistent header). The component already exists — just relocate the @TabloDescDisplay(tablo, csrfToken) call inside a div class="tablo-desc-zone" inside TabloOverviewTabFragment.
Tab nav wrapper: change the outer div for the tab links to div class="tab-nav" (replaces existing raw flex Tailwind classes).
ui package variant identifiers (from backend/internal/web/ui/variants.go):
ui.ButtonVariantGhost, ui.ButtonVariantDefault, ui.ButtonToneSolid
ui.IconButtonVariantDanger, ui.IconButtonVariantNeutral, ui.IconButtonToneGhost
ui.BadgeVariantPrimary, ui.BadgeVariantInfo
ui.SizeMD
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Restyle TabloDetailPage header, metadata row, and tab nav</name>
<files>backend/templates/tablos.templ</files>
<read_first>
- backend/templates/tablos.templ (read the FULL file before editing — you need to understand the current header structure, tab nav structure, action control positions, and the persistent desc zone location; the file is ~650 lines)
- backend/internal/web/ui/variants.go (read to confirm exact Go identifiers for variants/tones: ButtonVariantGhost, IconButtonVariantDanger, IconButtonToneGhost, BadgeVariantPrimary, SizeMD — use these exact names)
- .planning/phases/16-tablo-detail/16-PATTERNS.md (section "backend/templates/tablos.templ" — color avatar pattern, tab nav pattern, IconButton for delete, metadata row pattern, description zone relocation)
</read_first>
<action>
Edit `backend/templates/tablos.templ` to restyle `TabloDetailPage`. Make the following targeted changes:
1. HEADER — replace the current header div (which uses inline Tailwind flex classes with #804EEC) with a `.project-card-top` layout:
- Outer div: `class="project-card-top"`
- Left: `div class="project-card-title-row"` containing the project-avatar span (with tablo color + first char of title per PATTERNS.md color avatar pattern) followed by the existing `.tablo-title-zone` inline-edit zone (`@TabloTitleDisplay(tablo, csrfToken)` preserved as-is)
- Right: action controls row with gap, containing: (a) Discussion `@ui.IconButton` with `Icon: "chat"`, `Label: "Discussion"`, ghost/neutral variant, wrapped so it navigates to the discussion tab (use hx-get="/tablos/{id}/discussion" hx-target="#tab-content" hx-swap="innerHTML" hx-push-url="true" as Attrs); (b) `@ui.Button` for "Invite Member" with `ButtonVariantGhost`; (c) a `.tablo-delete-zone` div containing the trash `@ui.IconButton` (DO NOT use or modify `@TabloDeleteButtonFragment`)
2. METADATA ROW — replace the current metadata span/div elements (which use hardcoded color classes) with `div class="tablo-metadata-row"` containing: tablo-metadata-date div (calendar SVG + "Created" + formatted date), `@ui.Badge` for "In progress" with `BadgeVariantPrimary`, and the progress track/bar divs using `.project-progress-track` + `.project-progress-bar` CSS classes (per PATTERNS.md metadata row pattern)
3. TAB NAV — change the outer container of the 5 tab links to `div class="tab-nav"`. For each of the 5 tabs (overview, tasks, files, discussion, events): replace the long inline Tailwind class strings (which contain `text-[#804EEC]`, `border-[#804EEC]`, etc.) with the simple conditional: `if activeTab == "{tab}" { class="tab-nav-item is-active" } else { class="tab-nav-item" }`. Preserve all `hx-get`, `hx-target`, `hx-swap`, `hx-push-url`, and `href` attributes on each tab link unchanged.
4. DESCRIPTION ZONE RELOCATION — Remove the `tablo-desc-zone` div (containing `@TabloDescDisplay`) from the persistent header area (above the tab nav). Move it into `TabloOverviewTabFragment` as the first element inside that component. The `TabloDescDisplay` and `TabloDescEditFragment` components themselves are not modified — only the call site location changes.
After editing, run: `just generate` to regenerate `tablos_templ.go`.
</action>
<verify>
<automated>grep -c "project-card-top" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tablos.templ && grep -c "tab-nav-item is-active" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tablos.templ && grep -c "tablo-metadata-row" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tablos.templ && grep -c "804EEC" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tablos.templ && cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && go test ./backend/internal/web/... -run TestTablos -count=1</automated>
</verify>
<acceptance_criteria>
- `tablos.templ` contains `class="project-card-top"` in TabloDetailPage header
- `tablos.templ` contains `class="tab-nav-item is-active"` (applied conditionally to the active tab)
- `tablos.templ` contains `class="tablo-metadata-row"`
- `tablos.templ` contains `@ui.Badge(ui.BadgeProps{Label: "In progress", Variant: ui.BadgeVariantPrimary})`
- `tablos.templ` contains `.project-progress-track` and `.project-progress-bar` class references
- `grep -c "#804EEC" backend/templates/tablos.templ` returns 0 (all hardcoded hex removed from detail page)
- `tablos.templ` contains `@TabloDescDisplay` call inside `TabloOverviewTabFragment` (not in the persistent header)
- `go test ./backend/internal/web/... -run TestTablos -count=1` passes
- `TabloDeleteButtonFragment` function body is unchanged (grep its content to confirm)
</acceptance_criteria>
<done>TabloDetailPage header uses project-card-top layout; tab nav uses design token classes; description is in overview tab; no hardcoded hex values remain in the detail page header/nav section.</done>
</task>
<task type="auto">
<name>Task 2: Remove EtapeStrip call from TasksTabFragment and update KanbanBoard call site</name>
<files>backend/templates/tablos.templ</files>
<read_first>
- backend/templates/tablos.templ (already read in Task 1 — find TasksTabFragment, line ~407420; confirm EtapeStrip and KanbanBoard call positions)
- .planning/phases/16-tablo-detail/16-RESEARCH.md (Pattern 4: EtapeStrip Removal and OOB Impact — explains which EtapeStrip call sites exist in tablos.templ; this task handles Site 1 only; Sites 2 and 3 in tasks.templ are handled in Plan 03)
</read_first>
<action>
In `TasksTabFragment` inside `tablos.templ`:
1. DELETE the line: `@EtapeStrip(tablo.ID, etapes, counts, filter, csrfToken, false)` — remove this entire call (per D-E01; the EtapeStrip UI strip is removed). Also remove any surrounding `div id="etape-strip"` wrapper if one exists.
2. UPDATE the KanbanBoard call: change `@KanbanBoard(tablo.ID, csrfToken, tasks, filter)` to `@KanbanBoard(tablo.ID, csrfToken, tasks, filter, etapes)` — adding `etapes` as the 5th argument. Note: the `KanbanBoard` templ signature update happens in Plan 03; this call site change MUST be done simultaneously with that signature change to avoid a compile error. If Plan 03 has not yet run, add a TODO comment next to this line noting the parameter will be added when KanbanBoard signature is updated in Plan 03. The `templ generate && go build ./...` gate in Plan 03 will catch any mismatch.
After editing, run: `just generate` to regenerate `tablos_templ.go`.
</action>
<verify>
<automated>grep -c "EtapeStrip" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tablos.templ && grep "KanbanBoard" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tablos.templ</automated>
</verify>
<acceptance_criteria>
- `grep "EtapeStrip" backend/templates/tablos.templ` returns 0 (the EtapeStrip call is gone from TasksTabFragment)
- `grep "KanbanBoard" backend/templates/tablos.templ` shows the call includes `etapes` as a parameter (or has a TODO comment pending Plan 03)
- `go test ./backend/internal/web/... -run TestTablos -count=1` passes (or compile error from KanbanBoard signature mismatch is expected and noted — will be resolved in Plan 03)
</acceptance_criteria>
<done>EtapeStrip is not called from TasksTabFragment; KanbanBoard call is updated to pass etapes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Go template → HTML response | tablos.templ renders tablo data from DB into HTML; existing ownership guard (`loadOwnedTablo`) unchanged |
| HTMX GET requests | hx-get endpoints for tab fragments are authenticated routes; no change to auth middleware |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-02-01 | Tampering | TabloDetailPage inline-edit zones | accept | tablo-title-zone and tablo-desc-zone HTMX endpoints validate CSRF token and ownership — unchanged; only CSS classes change |
| T-16-02-02 | Spoofing | Discussion/Delete action controls | accept | Auth middleware on all /tablos/{id}/* routes unchanged; restyling does not change route protection |
| T-16-02-03 | Information Disclosure | tablo.Color inline style | accept | tablo.Color is user-supplied but rendered as a CSS value in a style attribute; no script injection possible via `background-color: {value}` — browsers do not execute style values as JS |
</threat_model>
<verification>
```bash
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source
go test ./backend/internal/web/... -count=1
```
All existing tablo tests pass. No hardcoded #804EEC hex in tablos.templ for the detail page section.
</verification>
<success_criteria>
- `tablos.templ` header uses `.project-card-top`, `.project-avatar` (with first char), `.tablo-title-zone`
- All 5 tab nav items use `.tab-nav-item` / `.tab-nav-item.is-active` (no hardcoded hex)
- Description zone is inside `TabloOverviewTabFragment`
- `EtapeStrip` is not called from `TasksTabFragment`
- `KanbanBoard` call passes `etapes`
- `go test ./backend/internal/web/... -count=1` passes
</success_criteria>
<output>
After completion, create `.planning/phases/16-tablo-detail/16-02-SUMMARY.md` using the summary template.
</output>

View file

@ -0,0 +1,274 @@
---
phase: 16-tablo-detail
plan: 03
type: execute
wave: 3
depends_on:
- 16-02
files_modified:
- backend/templates/tasks.templ
- backend/internal/web/handlers_tasks.go
autonomous: true
requirements:
- DETAIL-02
- DETAIL-03
must_haves:
truths:
- "KanbanBoard templ signature accepts etapes []sqlc.Etape as 5th parameter"
- "KanbanColumn renders tasks grouped by etape using groupTasksByEtape helper; unassigned tasks appear last"
- "Each kanban column uses .kanban-column / .tasks-section / .tasks-section-header / .task-list CSS layout"
- "TaskCard uses .task-row layout: .task-check + .task-body + trash @ui.IconButton"
- "AddTaskTrigger uses .tasks-add-button class (not raw ui-button classes)"
- "EtapeStrip OOB calls are removed from TaskCardGone and TaskCardOOB (etapes/counts params kept)"
- "Both KanbanBoard call sites in handlers_tasks.go pass etapes as 5th argument"
- "All 19 existing task handler tests pass unchanged"
artifacts:
- path: backend/templates/tasks.templ
provides: groupTasksByEtape helper, EtapeGroup type, EtapeGroupHeader component, restyled KanbanBoard/Column/TaskCard
contains: "groupTasksByEtape"
- path: backend/internal/web/handlers_tasks.go
provides: Updated KanbanBoard call sites (lines ~594, ~645)
contains: "KanbanBoard.*etapes"
key_links:
- from: backend/templates/tasks.templ groupTasksByEtape
to: backend/templates/tasks.templ KanbanColumn
via: "groups := groupTasksByEtape(tasks, etapes)" expression inside KanbanColumn
pattern: "groupTasksByEtape"
- from: backend/internal/web/handlers_tasks.go
to: backend/templates/tasks.templ KanbanBoard
via: "templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter, etapes)"
pattern: "KanbanBoard.*etapes"
---
<objective>
Restyle the task kanban board (DETAIL-02) and implement server-side etape grouping (DETAIL-03). Changes: add `groupTasksByEtape` helper and `EtapeGroup` type to `tasks.templ`; add `EtapeGroupHeader` templ component; update `KanbanBoard` and `KanbanColumn` signatures to accept `etapes []sqlc.Etape`; restyle `KanbanColumn` with `.tasks-section` layout; restyle `TaskCard` with `.task-row` layout; restyle `AddTaskTrigger` with `.tasks-add-button`; remove `@EtapeStrip` OOB calls from `TaskCardGone` and `TaskCardOOB`; update both `KanbanBoard` call sites in `handlers_tasks.go`.
Purpose: Deliver DETAIL-02 (tasks-section kanban) and DETAIL-03 (etape grouping, EtapeStrip removal).
Output: Restyled kanban with etape-grouped task rows; all 19 task handler tests pass.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/16-tablo-detail/16-CONTEXT.md
@.planning/phases/16-tablo-detail/16-RESEARCH.md
@.planning/phases/16-tablo-detail/16-PATTERNS.md
@.planning/phases/16-tablo-detail/16-UI-SPEC.md
@.planning/phases/16-tablo-detail/16-02-SUMMARY.md
<interfaces>
<!-- Key contracts the executor needs. -->
From backend/templates/tasks.templ (current state — read the file before editing):
Existing groupTasksByStatus (lines ~10-18) — model for groupTasksByEtape:
func groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task {
result := make(map[sqlc.TaskStatus][]sqlc.Task, len(TaskColumns))
for _, t := range tasks {
result[t.Status] = append(result[t.Status], t)
}
return result
}
Current KanbanBoard signature (line ~23):
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter)
Target KanbanBoard signature:
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter, etapes []sqlc.Etape)
KanbanColumn — receives tasks for one status. After change, also receives etapes:
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape)
EtapeGroup type to add (before groupTasksByEtape func):
type EtapeGroup struct {
EtapeID string
EtapeTitle string
EtapeColor string
Tasks []sqlc.Task
}
groupTasksByEtape logic:
- Build etapeID→Etape map from etapes slice for O(1) lookup
- Use etapes slice ORDER to determine group order (not map iteration)
- For each etape in order: collect tasks whose EtapeID matches; only add group if non-empty
- sqlc.Task.EtapeID field: check the actual type (likely pgtype.UUID or *uuid.UUID) — use appropriate nil/zero check
- At the end: collect tasks with nil/zero EtapeID → append as EtapeGroup{EtapeID: "", EtapeTitle: "No etape", EtapeColor: "", Tasks: unassigned}
- If zero etapes are defined (etapes slice is empty), treat all tasks as unassigned
TaskCardGone current signature (line ~384): has etapes []sqlc.Etape and counts EtapeTaskCounts params
TaskCardOOB current signature (line ~396): same
Action: remove the @EtapeStrip(..., true) OOB call from both; KEEP the etapes and counts params (to avoid handler signature changes); add TODO comment
KanbanBoard call sites in handlers_tasks.go:
Line ~594: templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter) → add etapes (from loadTasksTabData return)
Line ~645: templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter) → add etapes (etapes is already available in scope)
loadTasksTabData return signature (check actual): returns (tasks, etapes, counts, filter, ok bool) — etapes is the 2nd return value
From backend/internal/web/ui/variants.go (for TaskCard IconButton):
ui.IconButtonVariantDanger, ui.IconButtonToneGhost
From backend/internal/web/ui/app.css (Section 23 added in Plan 01):
.kanban-column { flex-shrink: 0; width: 18rem; }
.tasks-section { border: 1px solid var(--color-border-subtle); border-radius: 1rem; overflow: hidden; }
.tasks-section-header { display: flex; justify-content: space-between; align-items: center; padding: 1.2rem 1rem; }
.task-list { display: flex; flex-direction: column; }
.task-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.9rem 1rem; border-bottom: 1px solid var(--color-border-muted); }
.tasks-add-button { display: inline-flex; align-items: center; gap: 0.5rem; ... }
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add groupTasksByEtape helper, EtapeGroup type, EtapeGroupHeader component; update KanbanBoard/Column signatures and restyled column/card/trigger</name>
<files>backend/templates/tasks.templ</files>
<read_first>
- backend/templates/tasks.templ (read the FULL file before editing — ~420 lines; you need the current: groupTasksByStatus pattern, KanbanBoard body, KanbanColumn body, TaskCard body, AddTaskTrigger body, TaskCardGone and TaskCardOOB bodies, existing import block, sqlc.Task.EtapeID field type)
- backend/internal/db/sqlc/querier.go or backend/internal/db/sqlc/models.go (grep for "EtapeID" field on Task struct to confirm the exact type — pgtype.UUID, *uuid.UUID, or sql.NullString)
- .planning/phases/16-tablo-detail/16-PATTERNS.md (sections: groupTasksByEtape helper, KanbanBoard signature change, KanbanColumn restyled, TaskCard restyled, EtapeGroupHeader component, EtapeStrip OOB removal)
</read_first>
<action>
Edit `backend/templates/tasks.templ` to make the following changes. Read the entire file first to understand current structure before starting any edits.
1. ADD EtapeGroup type and groupTasksByEtape function after the existing `groupTasksByStatus` function (before the `KanbanBoard` templ declaration):
- `EtapeGroup` struct: fields EtapeID string, EtapeTitle string, EtapeColor string, Tasks []sqlc.Task
- `groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup`:
* Build index map from etape ID string to sqlc.Etape for O(1) lookup
* Iterate etapes in slice order; for each etape, collect tasks matching that etape ID; only append a group if it has tasks
* Append an unassigned group (EtapeID: "", EtapeTitle: "No etape", EtapeColor: "") for tasks with nil/zero EtapeID — check the actual EtapeID field type (pgtype.UUID: use !t.EtapeID.Valid; *uuid.UUID: use t.EtapeID == nil; uuid.UUID zero value: use t.EtapeID == uuid.Nil)
* If all tasks are assigned (no unassigned), omit the unassigned group entirely
2. ADD EtapeGroupHeader templ component (after the groupTasksByEtape function):
`templ EtapeGroupHeader(group EtapeGroup)` rendering a `div class="etape-group-header"`:
- If group.EtapeColor != "": render `span class="etape-group-color-dot" style="background-color: {group.EtapeColor}"`
- If group.EtapeID == "": render `span class="etape-group-label is-unassigned"` with group.EtapeTitle
- Else: render `span class="etape-group-label"` with group.EtapeTitle
3. UPDATE KanbanBoard signature: add `etapes []sqlc.Etape` as 5th parameter. Inside KanbanBoard, pass `etapes` through to each `@KanbanColumn(...)` call.
4. UPDATE KanbanColumn signature: add `etapes []sqlc.Etape` as 6th parameter. Restyle the column structure:
- Outer: `div class="kanban-column"` (replaces the current flex-shrink-0 w-72 div)
- Inner: `div class="tasks-section"` containing:
* `div class="tasks-section-header"`: left side has h3 with TaskColumnLabels[status] + span with task-count-badge `@ui.Badge(...BadgeVariantInfo...)`, right side has `div id="add-task-slot-{status}"` containing `@AddTaskTrigger(...)`
* `div class="task-list sortable-column" data-status="{status}" id="column-{status}" aria-label="{label} column"`:
- If len(tasks) == 0: render `p class="task-list-empty" "No tasks yet"`
- Else: compute `groups := groupTasksByEtape(tasks, etapes)`, then `for _, group := range groups { @EtapeGroupHeader(group); for _, task := range group.Tasks { @TaskCard(tabloID, task, csrfToken) } }`
- Preserve existing `data-status` and `id="column-..."` attributes for Sortable.js compatibility
5. RESTYLE TaskCard: replace the current `.task-card bg-white rounded border border-slate-200...` inner div with `.task-row task-card` div:
- Outer zone wrapper: keep `div class="task-card-zone" id="task-{task.ID.String()}"` unchanged (HTMX swap target)
- Inner: `div class="task-row task-card" data-task-id="{task.ID.String()}" hx-get=... hx-target="closest .task-card-zone" hx-swap="outerHTML" role="button" aria-label="Edit task: {task.Title}"`
- Children: `div class="task-check"` (round checkbox, role="checkbox" aria-checked="false") + `div class="task-body"` (p with task.Title) + trash `@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}/tasks/{task.ID}/delete-confirm", "hx-target": "closest .task-card-zone", "hx-swap": "outerHTML"}})`
- Remove the old drag handle div (⠿)
6. RESTYLE AddTaskTrigger: change the button class from the current `ui-button ui-button-soft...` compound to simply `tasks-add-button`. Preserve all hx-* attributes.
7. REMOVE EtapeStrip OOB calls: in TaskCardGone and TaskCardOOB, delete the `@EtapeStrip(tabloID, etapes, counts, filter, csrfToken, true)` lines. Keep the `etapes []sqlc.Etape` and `counts EtapeTaskCounts` parameters on both component signatures. Add `// TODO: remove etapes and counts params after Phase 16 cleanup` comment.
After editing, run: `just generate` to regenerate `tasks_templ.go`.
</action>
<verify>
<automated>grep -c "groupTasksByEtape" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "EtapeGroupHeader" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "kanban-column" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "tasks-section" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "task-row" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "EtapeStrip" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ</automated>
</verify>
<acceptance_criteria>
- `tasks.templ` contains `func groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup`
- `tasks.templ` contains `type EtapeGroup struct`
- `tasks.templ` contains `templ EtapeGroupHeader(group EtapeGroup)`
- `tasks.templ` contains `class="kanban-column"` (replacing w-72 Tailwind class)
- `tasks.templ` contains `class="tasks-section"` inside kanban column
- `tasks.templ` contains `class="task-row task-card"` inside TaskCard
- `tasks.templ` contains `class="tasks-add-button"` in AddTaskTrigger
- `grep -c "EtapeStrip" backend/templates/tasks.templ` returns 0 (OOB calls removed)
- `grep "etapes \[\]sqlc.Etape" backend/templates/tasks.templ` shows parameter in both KanbanBoard and KanbanColumn signatures
- `just generate` exits 0 (templ compiles cleanly)
</acceptance_criteria>
<done>tasks.templ has groupTasksByEtape, EtapeGroupHeader, restyled KanbanColumn/TaskCard/AddTaskTrigger, and no EtapeStrip OOB calls.</done>
</task>
<task type="auto">
<name>Task 2: Update KanbanBoard call sites in handlers_tasks.go and verify full test suite</name>
<files>backend/internal/web/handlers_tasks.go</files>
<read_first>
- backend/internal/web/handlers_tasks.go (read lines 580660 to see the two KanbanBoard call sites and the loadTasksTabData return variables in scope at each call site; confirm etapes is the 2nd return from loadTasksTabData: `tasks, etapes, counts, filter, ok := loadTasksTabData(...)`)
- .planning/phases/16-tablo-detail/16-RESEARCH.md (Pattern: KanbanBoard call site count — three call sites must all be updated; tablos.templ was updated in Plan 02; handlers_tasks.go has two)
</read_first>
<action>
Edit `backend/internal/web/handlers_tasks.go` at the two `KanbanBoard` call sites:
Line ~594 (inside TaskReorderHandler or similar): change
`templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter)`
to
`templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter, etapes)`
Line ~645 (second KanbanBoard call): change the same pattern.
At each call site, verify that `etapes` is already in scope from the `loadTasksTabData` return values. The function returns `(tasks, etapes, counts, filter, ok)` — use the `etapes` variable directly.
After editing, run: `just generate && go build ./backend/...` to confirm all three KanbanBoard call sites match the updated signature.
Then run the full task test suite:
`go test ./backend/internal/web/... -run TestTask -count=1`
And the full handler test suite (no DB required):
`go test ./backend/internal/web/... -count=1`
</action>
<verify>
<automated>grep -c "KanbanBoard.*etapes" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/handlers_tasks.go && cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && go build ./backend/... && go test ./backend/internal/web/... -run TestTask -count=1</automated>
</verify>
<acceptance_criteria>
- `grep "KanbanBoard" backend/internal/web/handlers_tasks.go` shows both call sites include `etapes` as 5th argument
- `go build ./backend/...` exits 0 (no KanbanBoard argument count mismatch)
- `go test ./backend/internal/web/... -run TestTask -count=1` exits 0 (all 19 task tests pass)
- `go test ./backend/internal/web/... -count=1` exits 0 (full handler test suite passes with no regressions)
</acceptance_criteria>
<done>Both KanbanBoard call sites in handlers_tasks.go pass etapes; all task tests pass; build is clean.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Handler → KanbanBoard template | etapes []sqlc.Etape passed from handler; data comes from authenticated DB query |
| EtapeGroup rendering | EtapeColor rendered as inline style value (background-color) — same pattern as project-avatar; no script injection via CSS values |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-03-01 | Information Disclosure | EtapeGroup server-side grouping | accept | groupTasksByEtape operates on data already fetched via authenticated query; no new data access introduced |
| T-16-03-02 | Tampering | EtapeColor inline style value | accept | Etape color is stored in DB as user-supplied value but rendered only as CSS background-color; browsers do not execute CSS property values as code |
| T-16-03-03 | Spoofing | EtapeStrip OOB removal | accept | Removing dead OOB calls has no security impact; HTMX ignores swaps for missing targets |
| T-16-03-04 | Denial of Service | groupTasksByEtape O(n*m) | accept | n = tasks per column (bounded by UI; typically < 50), m = etapes per tablo (typically < 10); no external input controls loop bounds; not a real DoS surface |
</threat_model>
<verification>
```bash
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source
go build ./backend/...
go test ./backend/internal/web/... -count=1
```
Build clean; full handler test suite passes (all 19 task tests + all etape tests + all file tests).
</verification>
<success_criteria>
- `groupTasksByEtape` helper groups tasks by etape in etape declaration order with unassigned last
- `KanbanBoard` and `KanbanColumn` both accept `etapes []sqlc.Etape`
- Both `handlers_tasks.go` call sites pass `etapes`
- Kanban column uses `.kanban-column` / `.tasks-section` / `.tasks-section-header` / `.task-list` CSS classes
- TaskCard uses `.task-row` with `.task-check` + `.task-body` + trash `@ui.IconButton`
- EtapeStrip OOB calls removed from TaskCardGone and TaskCardOOB
- `go test ./backend/internal/web/... -count=1` passes (all 19 task tests)
</success_criteria>
<output>
After completion, create `.planning/phases/16-tablo-detail/16-03-SUMMARY.md` using the summary template.
</output>

View file

@ -0,0 +1,262 @@
---
phase: 16-tablo-detail
plan: 04
type: execute
wave: 4
depends_on:
- 16-03
files_modified:
- backend/templates/files.templ
autonomous: false
requirements:
- DETAIL-04
must_haves:
truths:
- "Files section uses .overview-section / .overview-section-heading layout with h3 'Files' on left and Upload file button on right"
- "File list uses @ui.Table with Filename / Size / Uploaded / Actions columns"
- "Each file row renders as <tr class='file-row-zone'> with Download and Delete @ui.IconButton actions"
- "FileDeleteConfirmFragment outer element is <tr class='file-row-zone'> (matching element type for outerHTML swap)"
- "Empty state uses @ui.EmptyState with Title 'No files yet' and Description 'Upload your first file to get started.'"
- "All 9 existing file handler tests pass unchanged"
- "Browser walkthrough confirms: files tab shows correct table layout, delete confirm swap works, empty state renders correctly"
artifacts:
- path: backend/templates/files.templ
provides: Restyled FilesTabFragment, fileTableHead, fileTableBody, FileListRow as tr, FileDeleteConfirmFragment as tr
contains: "@ui.Table(ui.TableProps{"
- path: backend/templates/files_templ.go
provides: Generated Go from restyled files.templ
exports: []
key_links:
- from: backend/templates/files.templ FileListRow
to: backend/templates/files.templ FileDeleteConfirmFragment
via: "hx-target='closest .file-row-zone' hx-swap='outerHTML'"
pattern: "class=\"file-row-zone\""
- from: backend/templates/files.templ FilesTabFragment
to: backend/internal/web/ui/table.templ @ui.Table
via: "@ui.Table(ui.TableProps{Head: fileTableHead(), Body: fileTableBody(...)})"
pattern: "ui.Table"
---
<objective>
Restyle the files section (DETAIL-04): replace raw `<ul>`/`<li>` layout with `@ui.Table`, add `.overview-section-heading` header, introduce `@ui.EmptyState`, and convert `FileListRow` and `FileDeleteConfirmFragment` to use `<tr>` as the outer element so the existing HTMX outerHTML delete swap continues to work. Ends with a browser verification checkpoint.
Purpose: Deliver DETAIL-04 — files section uses the table component with consistent row actions.
Output: Restyled `files.templ`; all 9 file tests pass; browser checkpoint approves files tab visual result.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/16-tablo-detail/16-CONTEXT.md
@.planning/phases/16-tablo-detail/16-RESEARCH.md
@.planning/phases/16-tablo-detail/16-PATTERNS.md
@.planning/phases/16-tablo-detail/16-UI-SPEC.md
@.planning/phases/16-tablo-detail/16-03-SUMMARY.md
<interfaces>
<!-- Key contracts the executor needs. -->
From backend/internal/web/ui/table.templ (verified in RESEARCH.md):
type TableProps struct {
Head templ.Component
Body templ.Component
}
Usage: @ui.Table(ui.TableProps{Head: fileTableHead(), Body: fileTableBody(tabloID, files)})
From backend/internal/web/ui/empty_state.templ (verified):
@ui.EmptyState(ui.EmptyStateProps{Title: "No files yet", Description: "Upload your first file to get started."})
From backend/internal/web/ui/variants.go:
ui.ButtonVariantDefault, ui.ButtonToneSolid, ui.SizeMD
ui.IconButtonVariantNeutral, ui.IconButtonVariantDanger, ui.IconButtonToneGhost
Current files.templ structure (must read file to confirm):
FilesTabFragment: renders <ul> with FileListRow <li> items or FileListEmpty()
FileListRow: <li class="file-row-zone" id="file-{id}"> — must become <tr>
FileDeleteConfirmFragment: <div class="file-row-zone" id="file-{id}"> — must become <tr>
FileListEmpty: standalone empty state component — no longer called; @ui.EmptyState used instead
FileUploadForm: existing form component — triggered from new "Upload file" button via HTMX
HTMX delete flow (Pitfall 2 — CRITICAL):
- FileListRow renders <tr class="file-row-zone" id="file-{id}"> inside @ui.Table's <tbody>
- Trash IconButton: hx-get="/tablos/{tabloID}/files/{fileID}/delete-confirm" hx-target="closest .file-row-zone" hx-swap="outerHTML"
- FileDeleteConfirmFragment must render <tr class="file-row-zone" id="file-{id}"> (same element type for outerHTML swap)
- Both use colspan="4" in the confirm <td> to span all 4 columns
Files table columns: Filename / Size / Uploaded / Actions (4 columns)
formatBytes helper: check if it already exists in files.templ or files_helpers.go (grep for formatBytes); if not in scope, use a simple inline format (file.SizeBytes/1024 + "KB" or similar)
File upload button triggers existing FileUploadForm via HTMX to a #file-upload-slot:
"Upload file" button → hx-get="/tablos/{tabloID}/files/upload-form" hx-target="#file-upload-slot" hx-swap="innerHTML"
div id="file-upload-slot" (empty initially, receives the form fragment)
Download icon button: wrap in <a> tag (download is a navigation, not a form POST):
<a href="/tablos/{tabloID}/files/{fileID}/download" aria-label="Download {file.Filename}">
@ui.IconButton(ui.IconButtonProps{Label: "Download file", Icon: "download", Variant: ui.IconButtonVariantNeutral, Tone: ui.IconButtonToneGhost, Type: "button"})
</a>
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Restyle FilesTabFragment, FileListRow, and FileDeleteConfirmFragment</name>
<files>backend/templates/files.templ</files>
<read_first>
- backend/templates/files.templ (read the FULL file before editing — check FilesTabFragment, FileListRow, FileDeleteConfirmFragment, FileListEmpty structures; find formatBytes helper location; confirm all HTMX attributes on existing delete confirm flow)
- backend/internal/web/ui/table.templ (read to confirm TableProps signature and how Head/Body components are rendered — specifically whether @ui.Table renders a full <table><thead><tbody> or just the body; affects how fileTableHead and fileTableBody must be structured)
- backend/internal/web/ui/empty_state.templ (read to confirm EmptyStateProps fields)
- .planning/phases/16-tablo-detail/16-PATTERNS.md (section "backend/templates/files.templ" — FilesTabFragment new structure, fileTableHead, fileTableBody, FileListRow as tr, FileDeleteConfirmFragment as tr)
</read_first>
<action>
Edit `backend/templates/files.templ` to make the following changes. Read the file before starting.
1. RESTYLE FilesTabFragment: replace the current body with:
- Outer: `div class="overview-section"`
- Header: `div class="overview-section-heading"` containing h3 "Files" on the left and `@ui.Button(ui.ButtonProps{Label: "Upload file", Variant: ui.ButtonVariantDefault, Tone: ui.ButtonToneSolid, Size: ui.SizeMD, Type: "button", Attrs: templ.Attributes{"hx-get": "/tablos/{tablo.ID.String()}/files/upload-form", "hx-target": "#file-upload-slot", "hx-swap": "innerHTML"}})` on the right
- `div id="file-upload-slot"` (empty; receives FileUploadForm on button click)
- Conditional: 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)})`
2. ADD fileTableHead private templ function (lowercase — not exported):
`templ fileTableHead()` rendering `<tr><th>Filename</th><th>Size</th><th>Uploaded</th><th>Actions</th></tr>`
3. ADD fileTableBody private templ function:
`templ fileTableBody(tabloID uuid.UUID, files []sqlc.TabloFile)` rendering `for _, f := range files { @FileListRow(tabloID, f) }`
4. RESTYLE FileListRow: change the outer element from `<li class="file-row-zone" id="file-{id}">` to `<tr class="file-row-zone" id="file-{id}">`. Inside the `<tr>`:
- `<td>{ file.Filename }</td>`
- `<td>{ formatBytes(file.SizeBytes) }</td>` (use existing formatBytes helper; if not found, format as strconv.FormatInt(file.SizeBytes/1024, 10) + " KB" inline)
- `<td>if file.CreatedAt.Valid { file.CreatedAt.Time.Format("2006-01-02") }</td>`
- `<td>` containing: `<a href="/tablos/{tabloID}/files/{fileID}/download" aria-label="Download {file.Filename}">@ui.IconButton(ui.IconButtonProps{Label: "Download file", Icon: "download", Variant: ui.IconButtonVariantNeutral, Tone: ui.IconButtonToneGhost, Type: "button"})</a>` followed by `@ui.IconButton(ui.IconButtonProps{Label: "Delete file", Icon: "trash", Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{"hx-get": "/tablos/{tabloID}/files/{fileID}/delete-confirm", "hx-target": "closest .file-row-zone", "hx-swap": "outerHTML"}})`
5. RESTYLE FileDeleteConfirmFragment: change outer element from `<div class="file-row-zone" id="file-{id}">` to `<tr class="file-row-zone" id="file-{id}">`. Wrap existing confirm dialog content in a single `<td colspan="4">`. Preserve all HTMX confirm/cancel button attributes exactly — do not change the confirm POST URL or cancel reload URL.
6. PRESERVE FileListEmpty function body unchanged (it is no longer called from FilesTabFragment but may be referenced elsewhere; keep it in the file).
7. ADD `"github.com/google/uuid"` to the import block if not already present (needed for fileTableBody parameter type).
After editing, run: `just generate` to regenerate `files_templ.go`.
</action>
<verify>
<automated>grep -c "@ui.Table" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/files.templ && grep -c "@ui.EmptyState" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/files.templ && grep -c "class=\"file-row-zone\"" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/files.templ && grep -c "overview-section-heading" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/files.templ && cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && go build ./backend/... && go test ./backend/internal/web/... -run TestFilesTab -count=1</automated>
</verify>
<acceptance_criteria>
- `files.templ` contains `@ui.Table(ui.TableProps{`
- `files.templ` contains `@ui.EmptyState(ui.EmptyStateProps{Title: "No files yet"`
- `files.templ` contains `class="overview-section-heading"`
- `files.templ` contains `class="file-row-zone"` on a `<tr>` element (not `<li>` or `<div>`)
- `files.templ` FileDeleteConfirmFragment outer element is `<tr class="file-row-zone"` with `<td colspan="4">`
- `files.templ` contains `Icon: "download"` in FileListRow actions column
- `go build ./backend/...` exits 0
- `go test ./backend/internal/web/... -run TestFilesTab -count=1` exits 0 (all file tests pass)
- `go test ./backend/internal/web/... -count=1` exits 0 (no regressions)
</acceptance_criteria>
<done>FilesTabFragment uses @ui.Table and @ui.EmptyState; FileListRow and FileDeleteConfirmFragment use tr; all file tests pass.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Plans 0104 together deliver the complete Phase 16 restyling:
- Plan 01: download + chat icons in UIIcon; CSS Sections 1925 in app.css
- Plan 02: Tablo detail header (project-card-top), metadata row, tab nav (design token classes), desc in overview tab, EtapeStrip removed from TasksTabFragment
- Plan 03: Kanban board with tasks-section layout, etape-grouped task rows, KanbanBoard/Column restyled, handlers updated
- Plan 04 (just completed): Files section with @ui.Table, @ui.EmptyState, download + delete IconButtons
</what-built>
<how-to-verify>
Start the dev server: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just dev` (or `air`)
Navigate to a tablo detail page (e.g. http://localhost:3000/tablos/{some-id}).
Check these items:
**Header (DETAIL-01):**
1. Header shows a colored circle avatar with the first letter of the tablo title
2. Title is inline-editable (click to edit, save with Enter or blur)
3. Action controls appear on the right: Discussion link with chat icon, Invite Member button, trash icon button for Delete
4. No hardcoded purple (#804EEC) hex appears as a visible style artifact — brand purple shows only via CSS tokens
**Metadata row:**
5. Created date appears with a calendar icon and formatted date
6. A purple badge labeled "In progress" appears
7. A progress bar track (gray) appears with a thin colored fill
**Tab nav:**
8. All 5 tabs (Overview, Tasks, Files, Discussion, Events) appear in a clean horizontal row
9. The active tab has a purple underline and bold text; inactive tabs are muted gray
10. Clicking a tab loads its content via HTMX
**Overview tab (DETAIL-01):**
11. Description zone appears inside the Overview tab (not above the tab nav)
12. Click description → edit form appears; save returns to display
**Tasks tab (DETAIL-02 + DETAIL-03):**
13. Three kanban columns (Todo / In Progress / Done) appear side by side
14. Each column has a section header with status label, task count badge, and "Add task" button
15. Tasks within each column appear as row-style cards with a round checkbox + task title + trash icon
16. If etapes are defined: tasks are grouped by etape with a colored dot sub-heading; unassigned tasks appear at the bottom under "No etape"
17. Drag-and-drop reorder still works (drag a task between columns)
18. No EtapeStrip filter pills appear anywhere in the tasks tab
**Files tab (DETAIL-04):**
19. Files section header shows "Files" on the left and "Upload file" button on the right
20. Clicking "Upload file" reveals the upload form inline
21. If files exist: they appear in a clean table with Filename / Size / Uploaded / Actions columns
22. Each file row has a download icon and a trash icon
23. Clicking trash → delete confirm replaces the row; confirm/cancel work
24. If no files: the empty state with "No files yet" appears
If any item above is broken or looks wrong, describe the specific issue.
</how-to-verify>
<resume-signal>Type "approved" if all 24 items pass, or describe specific issues to fix.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Handler → FilesTabFragment | files []sqlc.TabloFile from authenticated DB query; tabloID from URL path validated by loadOwnedTablo |
| HTMX outerHTML swap | delete confirm fragment swaps the .file-row-zone tr; hx-target="closest .file-row-zone" is client-driven but the server validates ownership before returning the confirm fragment |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-04-01 | Tampering | FileListRow download href | accept | Download URL /tablos/{id}/files/{fileID}/download is an authenticated route; file ownership validated server-side; no user-controlled URL injection (template uses templ.SafeURL) |
| T-16-04-02 | Information Disclosure | file.Filename in table | accept | Filename is user-supplied data returned to the same authenticated user who uploaded it; no cross-user exposure possible given ownership model |
| T-16-04-03 | Tampering | FileDeleteConfirmFragment tr element | accept | HTMX outerHTML swap of tr is client-side presentation only; server validates delete ownership at POST handler; confirm fragment is GET-only (no side effect) |
</threat_model>
<verification>
```bash
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source
go test ./backend/internal/web/... -count=1
```
All existing file tests pass. Browser checkpoint approved.
Final phase gate (run with DB):
```bash
TEST_DATABASE_URL='postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable' go test ./... -count=1
```
</verification>
<success_criteria>
- `files.templ` FilesTabFragment uses `.overview-section` + `.overview-section-heading` + `@ui.Table` + `@ui.EmptyState`
- `FileListRow` renders `<tr class="file-row-zone">` with Download + Delete `@ui.IconButton`
- `FileDeleteConfirmFragment` renders `<tr class="file-row-zone">` with `<td colspan="4">`
- `go test ./backend/internal/web/... -count=1` passes (all 9 file tests unchanged)
- Browser checkpoint approved: all 24 visual/interaction items verified
- `TEST_DATABASE_URL=... go test ./... -count=1` passes (full suite green)
</success_criteria>
<output>
After completion, create `.planning/phases/16-tablo-detail/16-04-SUMMARY.md` using the summary template.
</output>