docs(15): create phase plan — sidebar + project-card dashboard

3 plans across 3 waves:
- 15-01 (Wave 0): RED test stubs for DASH-01/02/03
- 15-02 (Wave 1): app.css CSS foundation + AppLayout templ component
- 15-03 (Wave 2): tablos.templ restyled + handler wiring + visual checkpoint

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-05-16 21:28:25 +02:00
parent 8422d82fc8
commit 8baf8a430f
No known key found for this signature in database
4 changed files with 758 additions and 0 deletions

View file

@ -86,6 +86,7 @@ Plans:
**Mode:** mvp **Mode:** mvp
**Status:** Pending **Status:** Pending
**Requirements:** DASH-01, DASH-02, DASH-03 **Requirements:** DASH-01, DASH-02, DASH-03
**Plans:** 3 plans
**Success Criteria:** **Success Criteria:**
1. Sidebar has brand section, nav items with icons, tablo list section, and user/account footer using sidebar-nav-shell classes 1. Sidebar has brand section, nav items with icons, tablo list section, and user/account footer using sidebar-nav-shell classes
2. Tablo list uses project-card layout with color avatars, creation date, and action controls 2. Tablo list uses project-card layout with color avatars, creation date, and action controls
@ -93,6 +94,16 @@ Plans:
4. All existing tablo CRUD handler tests pass unchanged 4. All existing tablo CRUD handler tests pass unchanged
5. Browser walkthrough of tablos list matches the go-backend project-card / sidebar design 5. Browser walkthrough of tablos list matches the go-backend project-card / sidebar design
Plans:
**Wave 0**
- [ ] 15-01-PLAN.md — Wave 0 test stubs: TestTablosDashboard_Sidebar, TestTablosDashboard_ProjectCards, TestTablosDashboard_EmptyState (RED baseline for DASH-01/02/03)
**Wave 1** *(blocked on Wave 0 completion)*
- [ ] 15-02-PLAN.md — CSS foundation + AppLayout: app.css ported from go-backend, tailwind.input.css updated, app_layout.templ + app_layout_helpers.go created
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 15-03-PLAN.md — Dashboard wiring: tablos.templ restyled (project-card grid, ui.EmptyState), handlers updated, planning + account_providers switched to AppLayout, browser verify checkpoint
**User-in-loop:** Approve sidebar shape (nav items, tablo list section) and tablo card layout before implementation. **User-in-loop:** Approve sidebar shape (nav items, tablo list section) and tablo card layout before implementation.
### Phase 16: Tablo Detail ### Phase 16: Tablo Detail

View file

@ -0,0 +1,130 @@
---
phase: 15-dashboard-tablos
plan: 01
type: execute
wave: 0
depends_on: []
files_modified:
- backend/internal/web/handlers_tablos_test.go
autonomous: true
requirements:
- DASH-01
- DASH-02
- DASH-03
must_haves:
truths:
- "TestTablosDashboard_Sidebar fails RED until AppLayout is implemented"
- "TestTablosDashboard_ProjectCards fails RED until project-card template is implemented"
- "TestTablosDashboard_EmptyState fails RED until empty state is wired to ui.EmptyState"
artifacts:
- path: "backend/internal/web/handlers_tablos_test.go"
provides: "Wave 0 test stubs for DASH-01/02/03"
contains: "TestTablosDashboard_Sidebar"
key_links:
- from: "TestTablosDashboard_Sidebar"
to: "GET / handler response body"
via: "httptest.ResponseRecorder"
pattern: "strings.Contains.*dashboard-sidebar"
---
## Phase Goal
**As a** signed-in user, **I want to** see a sidebar-based dashboard with project cards for my tablos, **so that** the app matches the go-backend visual design and I can navigate my work efficiently.
<objective>
Create failing integration test stubs for DASH-01, DASH-02, and DASH-03 in the existing handlers_tablos_test.go file. These tests define the acceptance target for Plans 02 and 03 — they MUST fail (RED) when this plan is complete.
Purpose: Nyquist compliance requires automated tests for every requirement ID before implementation begins. Wave 0 plants the tests that Plans 02 and 03 will turn green.
Output: Three new test functions added to handlers_tablos_test.go, all failing at runtime until the AppLayout and project-card templates are implemented.
</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>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/15-dashboard-tablos/15-VALIDATION.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add Wave 0 RED test stubs for DASH-01, DASH-02, DASH-03</name>
<files>backend/internal/web/handlers_tablos_test.go</files>
<read_first>
- backend/internal/web/handlers_tablos_test.go — read the full file to understand: package name, existing test helper signatures (loginUser, newTabloTestRouter, getCSRFToken), how existing tests issue GET / and check response body via strings.Contains, and how tablos are pre-inserted (look for helper that inserts a tablo row).
- backend/internal/web/handlers_auth_test.go — read to understand setupTestDB, preInsertUser, and the newTestRouter pattern used across test files.
</read_first>
<behavior>
- TestTablosDashboard_Sidebar: authenticated GET / must contain "dashboard-sidebar" in the response body. Fails RED until AppLayout renders the sidebar.
- TestTablosDashboard_ProjectCards: authenticated GET / with at least one pre-inserted tablo must contain "project-card" in the response body. Fails RED until TabloProjectCard is implemented.
- TestTablosDashboard_EmptyState: authenticated GET / with zero tablos must contain "ui-empty-state" in the response body. Fails RED until TablosEmptyState uses @ui.EmptyState.
</behavior>
<action>
Append three new test functions at the end of handlers_tablos_test.go. Each follows the exact same httptest pattern already used by the file's existing tests: call loginUser to get session cookies, issue GET / with those cookies, check rec.Code == 200, call strings.Contains on rec.Body.String() for the target string.
TestTablosDashboard_Sidebar: after login (no tablo pre-insert needed), GET /, assert strings.Contains(body, "dashboard-sidebar"). The test should also assert strings.Contains(body, "sidebar-nav-shell") as a secondary structural check.
TestTablosDashboard_ProjectCards: pre-insert one tablo for the user (use whatever helper function the file already uses for tablo insertion, or insert directly via deps.Queries.CreateTablo if available), then GET /, assert strings.Contains(body, "project-card"). If no tablo-insert helper exists yet, use a direct sqlc query call matching the CreateTablo pattern visible elsewhere in the test file.
TestTablosDashboard_EmptyState: after login with zero tablos, GET /, assert strings.Contains(body, "ui-empty-state").
All three functions must be inside a TEST_DATABASE_URL guard (same pattern used by the existing integration tests in the file — check how they skip when no DB is configured). Follow the package-level skip pattern exactly as seen in handlers_tablos_test.go.
Do NOT implement: any template changes, any CSS, any handler changes. This task is test-only.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./internal/web/... 2>&amp;1</automated>
</verify>
<acceptance_criteria>
- handlers_tablos_test.go compiles without error: `go build ./internal/web/...` exits 0
- File contains func TestTablosDashboard_Sidebar: `grep -c "func TestTablosDashboard_Sidebar" backend/internal/web/handlers_tablos_test.go` returns 1
- File contains func TestTablosDashboard_ProjectCards: `grep -c "func TestTablosDashboard_ProjectCards" backend/internal/web/handlers_tablos_test.go` returns 1
- File contains func TestTablosDashboard_EmptyState: `grep -c "func TestTablosDashboard_EmptyState" backend/internal/web/handlers_tablos_test.go` returns 1
- Each test asserts "dashboard-sidebar", "project-card", and "ui-empty-state" respectively via strings.Contains
- Without TEST_DATABASE_URL set, `go test ./internal/web/... -run "TestTablosDashboard_Sidebar|TestTablosDashboard_ProjectCards|TestTablosDashboard_EmptyState" -count=1` exits 0 (tests skip rather than fail)
- The existing TestTablos* tests still pass: `go test ./internal/web/... -run TestTablos -count=1` exits 0 (skips count as pass when no DB configured)
</acceptance_criteria>
<done>Three test stubs exist, compile cleanly, and skip when TEST_DATABASE_URL is absent. They will turn RED when a DB is wired and AppLayout is not yet implemented — that is the expected Wave 0 state.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| test harness → handler | httptest requests carry session cookies issued by the test login helper |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-15-01-01 | Information Disclosure | test DB | accept | Tests skip without TEST_DATABASE_URL; no production secrets in test code |
</threat_model>
<verification>
`cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./internal/web/... && go test ./internal/web/... -run TestTablos -count=1`
All existing Tablos tests pass (skip without DB). Three new stubs compile and skip without DB.
</verification>
<success_criteria>
- handlers_tablos_test.go contains TestTablosDashboard_Sidebar, TestTablosDashboard_ProjectCards, TestTablosDashboard_EmptyState
- `go build ./internal/web/...` exits 0
- No existing tests broken
</success_criteria>
<output>
After completion, create `.planning/phases/15-dashboard-tablos/15-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,278 @@
---
phase: 15-dashboard-tablos
plan: 02
type: execute
wave: 1
depends_on:
- 15-01
files_modified:
- backend/internal/web/ui/app.css
- backend/tailwind.input.css
- backend/templates/app_layout.templ
- backend/templates/app_layout_helpers.go
autonomous: true
requirements:
- DASH-01
must_haves:
truths:
- "AppLayout renders a .dashboard-shell containing a .dashboard-sidebar and .dashboard-main"
- "Sidebar contains brand logo (logo_dark.png), nav items with icons, SidebarProjectsSection, and SidebarOrganizationFooter"
- "Active nav item receives is-active class based on activePath string"
- "app.css is imported into tailwind.input.css and compiled into /static/tailwind.css"
artifacts:
- path: "backend/internal/web/ui/app.css"
provides: "Sidebar and project-card CSS ported from go-backend"
contains: ".dashboard-shell"
- path: "backend/templates/app_layout.templ"
provides: "AppLayout + DashboardSidebar + SidebarProjectsSection + SidebarOrganizationFooter templ components"
exports: ["AppLayout", "DashboardSidebar", "SidebarProjectsSection", "SidebarOrganizationFooter"]
- path: "backend/templates/app_layout_helpers.go"
provides: "sidebarNavItemClass, isActivePath, sidebarNavItemID, sidebarNavItem type, sidebarPrimaryNavItems"
contains: "func isActivePath"
key_links:
- from: "backend/tailwind.input.css"
to: "backend/internal/web/ui/app.css"
via: "@import"
pattern: "@import.*web/ui/app.css"
- from: "backend/templates/app_layout.templ"
to: "backend/templates/app_layout_helpers.go"
via: "sidebarPrimaryNavItems + sidebarNavItemClass calls"
pattern: "sidebarPrimaryNavItems"
---
## Phase Goal
**As a** signed-in user, **I want to** see a sidebar-based dashboard with project cards for my tablos, **so that** the app matches the go-backend visual design and I can navigate my work efficiently.
<objective>
Create the AppLayout shell and its CSS foundation. This plan delivers two things: (1) the new `backend/internal/web/ui/app.css` file with sidebar and project-card CSS ported from go-backend, registered in tailwind.input.css, and (2) the `backend/templates/app_layout.templ` file with AppLayout + sidebar sub-components + helpers.
No handler or tablos.templ changes in this plan — that is Plan 03.
Purpose: AppLayout is the foundation all authenticated pages will use. The CSS and component must exist before tablos.templ and handler call sites can be updated.
Output: app.css (new), tailwind.input.css (updated), app_layout.templ (new), app_layout_helpers.go (new).
</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>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/15-dashboard-tablos/15-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/15-dashboard-tablos/15-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/15-dashboard-tablos/15-PATTERNS.md
<interfaces>
<!-- Key types and contracts the executor needs. -->
From backend/templates/auth_layout.templ (pattern to follow):
```go
templ AuthLayout(title string, csrfToken string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
<link rel="stylesheet" href="/static/tailwind.css"/>
</head>
<body>
<div class="login-screen">
{ children... }
</div>
<script src="/static/htmx.min.js" defer></script>
</body>
</html>
}
```
From backend/internal/web/ui/icon_button.templ — UIIcon accepts these case strings:
"pencil", "trash", "plus", "grid3x3", "list", "filter", "search", "calendar"
-- "panels", "layers", "planning", "chat", "files", "tasks" are NOT yet in UIIcon
From go-backend/internal/web/views/icons.templ — SidebarIcon implements:
"panels" (rect+paths), "tasks" (rect+paths), "layers" (paths), "planning" (rect+paths), "chat" (path), "files" (path)
From backend/tailwind.input.css (current end of file):
@import "./internal/web/ui/spacing.css";
-- app.css must be appended after this line
From backend/internal/web/ui/variants.go — IconButtonClass output:
IconButtonClass(IconButtonVariantNeutral, IconButtonToneGhost)
→ "borderless-icon-button ui-icon-button-ghost ui-icon-button-neutral"
IconButtonClass(IconButtonVariantDanger, IconButtonToneGhost)
→ "borderless-icon-button ui-icon-button-ghost ui-icon-button-danger"
AppLayout signature (per D-L01):
templ AppLayout(title string, user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo)
Sub-components:
templ DashboardSidebar(activePath string, tablos []sqlc.Tablo, user *auth.User, csrfToken string)
templ SidebarProjectsSection(tablos []sqlc.Tablo)
templ SidebarOrganizationFooter(user *auth.User, csrfToken string)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Port sidebar + project-card CSS into app.css and register in tailwind</name>
<files>backend/internal/web/ui/app.css, backend/tailwind.input.css</files>
<read_first>
- go-backend/internal/web/ui/app.css — read lines 455743 (dashboard shell, sidebar), then lines 875892 (overview section heading), then lines 8941046 (project-card). Port these sections verbatim. All color values already use var(--...) tokens — do not substitute.
- backend/internal/web/ui/auth.css — read to understand the file structure pattern (single-purpose CSS file, no Tailwind directives, uses var(--...) tokens from base.css).
- backend/tailwind.input.css — read to confirm current last line and the exact @import path format used.
- backend/internal/web/ui/base.css — skim to confirm which CSS custom property names exist (color tokens, spacing tokens) so any go-backend vars not found in base.css can be noted.
</read_first>
<action>
Create backend/internal/web/ui/app.css. The file contains CSS ported verbatim from go-backend/internal/web/ui/app.css for these class groups only (in order):
1. .dashboard-shell, .dashboard-sidebar (grid layout — lines 455465 in go-backend)
2. .sidebar-nav-shell (lines 467479)
3. .sidebar-brand, .sidebar-brand-link, .sidebar-brand-logo, .sidebar-brand-title (lines 481509)
4. .sidebar-collapse-button (lines 511527 — include the CSS; the button is non-functional without JS in Phase 15)
5. .sidebar-primary, .sidebar-list, .sidebar-divider (lines 533558)
6. .sidebar-nav-item, .sidebar-nav-item:hover, .sidebar-nav-item.is-active (lines 560578)
7. .sidebar-nav-link, .sidebar-nav-link-inner, .sidebar-nav-icon, .sidebar-nav-label (lines 580605)
8. .sidebar-projects, .sidebar-section-label, .sidebar-project-list (lines 607628)
9. .sidebar-project-link, .sidebar-project-icon, .sidebar-project-label (lines 630668)
10. .sidebar-footer-links (lines 670673)
11. .sidebar-organization, .organization-button, .organization-avatar, .organization-copy, .organization-name, .organization-meta (lines 675732 — per D-F03; include .organization-copy if it exists)
12. .dashboard-main (lines 734741)
13. .overview-section, .overview-section-heading (lines 875892)
14. .project-grid (lines 894899)
15. .project-card, .project-card-top (lines 900944)
16. Project card icon button overrides — adapt go-backend lines 945963 using VERIFIED backend class names:
`.project-card-top .borderless-icon-button { padding: 0; }`
`.project-card-top .ui-icon-button-ghost.ui-icon-button-neutral:hover { color: var(--color-text-primary); }`
`.project-card-top .ui-icon-button-ghost.ui-icon-button-danger:hover { color: var(--color-status-danger-icon-hover); }`
Note: do NOT use `.borderless-icon-button` as a selector for these overrides if go-backend uses a different class — use the VERIFIED class names from variants.go output above.
17. .project-card-title-row, .project-avatar (lines 9861005)
18. .project-date-row (lines 10391046)
Add one line to backend/tailwind.input.css after the last existing @import line:
`@import "./internal/web/ui/app.css";`
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && grep -c "dashboard-shell" internal/web/ui/app.css && grep -c "project-card" internal/web/ui/app.css && grep -c "app.css" tailwind.input.css</automated>
</verify>
<acceptance_criteria>
- `backend/internal/web/ui/app.css` exists and contains `.dashboard-shell`: `grep -c "\.dashboard-shell" backend/internal/web/ui/app.css` returns >= 1
- File contains `.project-card`: `grep -c "\.project-card" backend/internal/web/ui/app.css` returns >= 1
- File contains `.sidebar-organization`: `grep -c "\.sidebar-organization" backend/internal/web/ui/app.css` returns >= 1
- No hard-coded hex colors: `grep -v "var(--" backend/internal/web/ui/app.css | grep -cE "#[0-9a-fA-F]{3,6}"` returns 0 (all colors use design tokens)
- `backend/tailwind.input.css` imports app.css: `grep -c "web/ui/app.css" backend/tailwind.input.css` returns 1
- `go build ./...` from backend/ exits 0 (CSS changes do not affect Go compilation)
</acceptance_criteria>
<done>app.css exists with all sidebar + project-card CSS classes. tailwind.input.css registers the import. go build exits 0.</done>
</task>
<task type="auto">
<name>Task 2: Create app_layout_helpers.go and app_layout.templ with all sidebar sub-components</name>
<files>backend/templates/app_layout_helpers.go, backend/templates/app_layout.templ</files>
<read_first>
- backend/templates/auth_layout.templ — read the full file for the exact package declaration, import style, and HTML shell pattern to replicate.
- backend/templates/layout.templ — read lines 4060 for the three script tags that must be preserved in AppLayout (htmx.min.js, sortable.min.js, discussion-sse.js per D-L04).
- go-backend/internal/web/views/dashboard_components.templ — read lines 54145 for DashboardSidebar, SidebarNavItem, SidebarProjectsSection, SidebarOrganization component structure and logic.
- go-backend/internal/web/views/home.go — read lines 1170 for sidebarNavItemClass, isActivePath, sidebarNavItemID, sidebarNavItem struct, and sidebarPrimaryNavItems slice builder.
- go-backend/internal/web/views/icons.templ — read lines 80130 for SidebarIcon SVG content for: "panels", "tasks", "layers", "planning", "chat", "files".
- backend/internal/web/ui/icon_button.templ — read UIIcon switch cases to confirm which icons already exist (pencil, trash, etc.) vs. which are sidebar-only.
- backend/templates/tablos.templ — read line 98 for the templ.SafeURL pattern used in href attributes.
</read_first>
<action>
Create backend/templates/app_layout_helpers.go in package templates. This is a pure Go file (no templ directives). It contains:
1. Import: "strings" only.
2. sidebarNavItem struct with fields: Href string, Label string, Icon string, Active bool, DividerAfter bool.
3. func sidebarNavItemClass(active bool) string — returns "sidebar-nav-item is-active" when active, "sidebar-nav-item" otherwise.
4. func isActivePath(activePath string, href string) bool — returns strings.TrimSpace(activePath) != "" && activePath == href.
5. func sidebarNavItemID(href string) string — switch on href, case "/": return "sidebar-nav-home", default: slug from strings.Trim(strings.ReplaceAll(href, "/", "-"), "-"), prefix with "sidebar-nav-".
6. func sidebarPrimaryNavItems(activePath string) []sidebarNavItem — return the slice per D-N01/D-N02:
- {Href: "/", Label: "Dashboard", Icon: "panels", Active: isActivePath(activePath, "/"), DividerAfter: true}
- {Href: "", Label: "Tasks", Icon: "tasks", Active: false} (visual-only per D-N02, no href)
- {Href: "/planning", Label: "Planning", Icon: "planning", Active: isActivePath(activePath, "/planning")}
- {Href: "", Label: "Chat", Icon: "chat", Active: false} (visual-only per D-N03)
- {Href: "", Label: "Files", Icon: "files", Active: false} (visual-only per D-N02)
Create backend/templates/app_layout.templ in package templates. It contains these components in order:
A. templ SidebarNavIcon(kind string) — a switch/case block with the SVG content for each icon kind: "panels", "tasks", "layers", "planning", "chat", "files". Copy SVG paths verbatim from go-backend/internal/web/views/icons.templ SidebarIcon. Default case renders an empty span. This avoids extending UIIcon in the ui package for sidebar-only icons.
B. templ SidebarNavItemRow(item sidebarNavItem) — renders one nav list item. If item.Href is empty, render as a div with class sidebarNavItemClass(item.Active) and style="cursor: default" (no anchor tag, per D-N02). If item.Href is non-empty, render as an anchor tag with href={templ.SafeURL(item.Href)} and class sidebarNavItemClass(item.Active). Inside both cases, render the .sidebar-nav-link-inner div with .sidebar-nav-icon span containing @SidebarNavIcon(item.Icon) and .sidebar-nav-label div containing item.Label.
C. templ SidebarProjectsSection(tablos []sqlc.Tablo) — per the pattern in PATTERNS.md: div.sidebar-projects > hr[role=separator] > div.sidebar-section-label "Projects" > ul.sidebar-project-list. Each tablo renders as li > a.sidebar-project-link with href=templ.SafeURL("/tablos/"+tablo.ID.String()), containing span.sidebar-project-icon (with style="background-color: "+tablo.Color.String if tablo.Color.Valid && tablo.Color.String != "") and span.sidebar-project-label containing tablo.Title.
D. templ SidebarOrganizationFooter(user *auth.User, csrfToken string) — per D-F01/D-F02/D-F03: div.sidebar-organization > div.organization-button > span.organization-avatar containing the first rune of user.Email (use string([]rune(user.Email)[:1])) > span.organization-copy > span.organization-name containing user.Email > form[method=POST][action=/logout] with @ui.CSRFField(csrfToken) and a submit button with text "Log out" and class "text-sm".
E. templ DashboardSidebar(activePath string, tablos []sqlc.Tablo, user *auth.User, csrfToken string) — aside.dashboard-sidebar > nav[aria-label="Main navigation"].sidebar-nav-shell > div.sidebar-brand with a.sidebar-brand-link[href=/][aria-label="Home"] containing img.sidebar-brand-logo[src=/static/logo_dark.png][alt="Logo XTablo"] and h1.sidebar-brand-title "XTablo" > div.sidebar-primary > ul.sidebar-list[role=list] looping sidebarPrimaryNavItems(activePath) rendering @SidebarNavItemRow(item) for each > @SidebarProjectsSection(tablos) > @SidebarOrganizationFooter(user, csrfToken).
F. templ AppLayout(title string, user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo) — full HTML shell following AuthLayout pattern: DOCTYPE html > html[lang=en] > head (charset, viewport, title, link[rel=stylesheet][href=/static/tailwind.css]) > body > div.dashboard-shell > @DashboardSidebar(activePath, tablos, user, csrfToken) > main#app-main-content.dashboard-main > { children... } > end div > three script tags with defer: /static/htmx.min.js, /static/sortable.min.js, /static/discussion-sse.js (per D-L04).
Imports for app_layout.templ: "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui".
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && templ generate ./... 2>&amp;1 &amp;&amp; go build ./... 2>&amp;1</automated>
</verify>
<acceptance_criteria>
- app_layout_helpers.go compiles in package templates: `go build ./...` exits 0
- app_layout.templ generates without error: `templ generate ./...` exits 0
- app_layout.templ contains func AppLayout: `grep -c "templ AppLayout(" backend/templates/app_layout.templ` returns 1
- app_layout.templ contains func DashboardSidebar: `grep -c "templ DashboardSidebar(" backend/templates/app_layout.templ` returns 1
- app_layout.templ contains func SidebarProjectsSection: `grep -c "templ SidebarProjectsSection(" backend/templates/app_layout.templ` returns 1
- app_layout.templ contains func SidebarOrganizationFooter: `grep -c "templ SidebarOrganizationFooter(" backend/templates/app_layout.templ` returns 1
- app_layout_helpers.go contains func isActivePath: `grep -c "func isActivePath" backend/templates/app_layout_helpers.go` returns 1
- app_layout_helpers.go contains func sidebarPrimaryNavItems: `grep -c "func sidebarPrimaryNavItems" backend/templates/app_layout_helpers.go` returns 1
- `go build ./...` from backend/ exits 0 — no compile errors
- `go test ./... -count=1` exits 0 (skips without DB; existing tests not broken)
</acceptance_criteria>
<done>AppLayout and all sidebar sub-components exist and compile. isActivePath and sidebarPrimaryNavItems helpers are in place. No handler or tablos.templ changes yet — that is Plan 03.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| template → browser | HTML rendered server-side; CSS served from /static/tailwind.css |
| user.Email → sidebar footer | Email displayed as-is; templ auto-escapes HTML entities |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-15-02-01 | Information Disclosure | SidebarOrganizationFooter | accept | user.Email already visible to the authenticated user; templ escapes HTML |
| T-15-02-02 | Spoofing | sidebar nav hrefs | accept | Nav hrefs are hardcoded strings, not user input; templ.SafeURL guards dynamic tablo hrefs |
</threat_model>
<verification>
`cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && templ generate ./... && go build ./... && go test ./... -count=1`
All existing tests pass (skip without DB). New files compile. app.css registered in tailwind.input.css.
</verification>
<success_criteria>
- `backend/internal/web/ui/app.css` exists with sidebar + project-card CSS
- `backend/tailwind.input.css` imports app.css
- `backend/templates/app_layout.templ` exists with AppLayout + DashboardSidebar + SidebarProjectsSection + SidebarOrganizationFooter
- `backend/templates/app_layout_helpers.go` exists with isActivePath + sidebarPrimaryNavItems
- `templ generate ./... && go build ./...` exits 0
</success_criteria>
<output>
After completion, create `.planning/phases/15-dashboard-tablos/15-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,339 @@
---
phase: 15-dashboard-tablos
plan: 03
type: execute
wave: 2
depends_on:
- 15-02
files_modified:
- backend/templates/tablos.templ
- backend/templates/planning.templ
- backend/templates/account_providers.templ
- backend/internal/web/handlers_tablos.go
- backend/internal/web/handlers_planning.go
autonomous: false
requirements:
- DASH-01
- DASH-02
- DASH-03
must_haves:
truths:
- "GET / renders .dashboard-sidebar, .sidebar-nav-shell, and sidebar-organization in the response body"
- "GET / with tablos renders .project-card for each tablo"
- "GET / with zero tablos renders .ui-empty-state"
- "GET /planning renders inside AppLayout (sidebar present)"
- "All existing TABLO-01..06 handler tests pass unchanged"
- "TabloDetailPage and TabloNotFoundPage render inside AppLayout"
artifacts:
- path: "backend/templates/tablos.templ"
provides: "TablosDashboard (AppLayout), TabloProjectCard, TablosEmptyState (ui.EmptyState), TabloDetailPage (AppLayout)"
contains: "TabloProjectCard"
- path: "backend/templates/planning.templ"
provides: "PlanningPage using AppLayout"
contains: "AppLayout"
- path: "backend/templates/account_providers.templ"
provides: "AccountProvidersPage using AppLayout"
contains: "AppLayout"
- path: "backend/internal/web/handlers_tablos.go"
provides: "Updated handler calls passing activePath and tablos to TablosDashboard"
contains: "tabloCardsFromViews"
- path: "backend/internal/web/handlers_planning.go"
provides: "PlanningPageHandler fetches tablos for sidebar before rendering"
contains: "ListTablosByUser"
key_links:
- from: "TablosListHandler"
to: "templates.TablosDashboard"
via: "activePath='/' and tablos derived from cardViews"
pattern: "TablosDashboard.*user.*csrf.*\"/\".*tablos.*cardViews"
- from: "TablosDashboard"
to: "AppLayout"
via: "templ children slot"
pattern: "@AppLayout.*TablosDashboard"
- from: "TablosEmptyState"
to: "ui.EmptyState"
via: "templ component call"
pattern: "@ui.EmptyState"
---
## Phase Goal
**As a** signed-in user, **I want to** see a sidebar-based dashboard with project cards for my tablos, **so that** the app matches the go-backend visual design and I can navigate my work efficiently.
<objective>
Wire AppLayout into all authenticated pages and restyle the tablo dashboard. This plan updates tablos.templ (new signatures, TabloProjectCard, EmptyState via ui.EmptyState), switches TabloDetailPage and TabloNotFoundPage to AppLayout, updates all handler call sites to pass activePath and tablos, switches planning.templ and account_providers.templ to AppLayout, and updates their handlers to fetch the tablos list.
Purpose: AppLayout exists after Plan 02; this plan connects it to all live pages and replaces the old card list with the project-card grid.
Output: The dashboard renders with a sidebar and project cards. All existing tests remain green.
</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>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/15-dashboard-tablos/15-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/15-dashboard-tablos/15-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/15-dashboard-tablos/15-PATTERNS.md
<interfaces>
<!-- Interfaces established by Plan 02 that this plan builds against. -->
From backend/templates/app_layout.templ (Plan 02 output):
```go
templ AppLayout(title string, user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo)
```
From backend/internal/db/sqlc/tablos.sql.go:
```go
// Already exists — confirmed in RESEARCH.md:
func (q *Queries) ListTablosByUser(ctx context.Context, userID uuid.UUID) ([]Tablo, error)
```
From backend/templates/tablos.templ (current signatures to replace):
```go
// CURRENT — replace these:
templ TablosDashboard(user *auth.User, csrfToken string, tablos []TabloCardView)
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, discussion DiscussionTabData, activeTab string)
templ TabloNotFoundPage(user *auth.User, csrfToken string)
// AFTER:
templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, cards []TabloCardView)
templ TabloDetailPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, discussion DiscussionTabData, activeTab string)
templ TabloNotFoundPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo)
```
From backend/internal/web/ui/empty_state.templ:
```go
type EmptyStateProps struct {
Title string
Description string
Icon string // optional
Action templ.Component // optional button
}
templ EmptyState(props EmptyStateProps)
// CSS class on root element: "ui-empty-state"
```
From backend/internal/web/ui/icon_button.templ:
```go
type IconButtonProps struct {
Label string
Icon string // "pencil" or "trash" for card actions
Variant IconButtonVariant
Tone IconButtonTone
Type string
Attrs templ.Attributes
}
templ IconButton(props IconButtonProps)
// IconButtonVariantNeutral, IconButtonVariantDanger
// IconButtonToneGhost
```
From backend/templates/tablos.templ (current HTMX delete attrs to preserve):
```go
// Delete button HTMX attrs (preserve exactly per D-C04 + Pitfall 3):
"hx-get": "/tablos/" + tablo.ID.String() + "/delete-confirm"
"hx-target": "closest .tablo-delete-zone"
"hx-swap": "outerHTML"
// The .tablo-delete-zone wrapper must wrap the delete icon button:
<div class="tablo-delete-zone">
@ui.IconButton(ui.IconButtonProps{...delete attrs...})
</div>
```
Current title edit HTMX attrs (for card edit button per D-C02):
```go
// From tablos.templ TabloTitleDisplay zone (lines 305-315):
"hx-get": "/tablos/" + tablo.ID.String() + "/edit-title"
"hx-target": ".tablo-title-zone" // or "closest .tablo-title-zone"
"hx-swap": "outerHTML"
```
From backend/templates/planning.templ (current signature):
```go
templ PlanningPage(user *auth.User, csrfToken string, agenda PlanningAgenda)
// AFTER:
templ PlanningPage(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, agenda PlanningAgenda)
```
From backend/templates/account_providers.templ:
```go
// BEFORE:
@Layout("Linked providers", user, csrfToken)
// AFTER: uses AppLayout with activePath="/" (settings don't exist yet, closest is /)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Restyle tablos.templ — project-card grid, EmptyState, AppLayout wiring</name>
<files>backend/templates/tablos.templ</files>
<read_first>
- backend/templates/tablos.templ — read the full file (all ~526 lines). Extract: current TablosDashboard signature and body, TablosEmptyState raw HTML, TabloCard + tabloCardBody implementation, TabloDeleteButtonFragment HTMX attributes, TabloTitleDisplay zone structure and its edit button hx-get target, TabloDetailPage full signature and its @Layout call, TabloNotFoundPage body.
- backend/templates/app_layout.templ — confirm AppLayout signature established in Plan 02.
- backend/internal/web/ui/empty_state.templ — read EmptyStateProps struct and the CSS class on the root element (.ui-empty-state).
- backend/internal/web/ui/icon_button.templ — read IconButtonProps struct and IconButtonVariant/Tone constants.
- backend/templates/discussion_forms.go — read TabloCardView struct definition (fields: Tablo sqlc.Tablo, DiscussionUnreadCount int64).
</read_first>
<action>
Modify backend/templates/tablos.templ. Make these changes:
1. Update TablosDashboard signature to: templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, cards []TabloCardView). Body: call @AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos) as the outer shell. Inside { children... }: render a section.overview-section > div.overview-section-heading containing h3 "Your Tablos" + @ui.Button("New tablo" with existing hx-get=/tablos/new attrs) + div#create-form-slot. Below the heading div: div#tablos-list.project-grid containing either @TablosEmptyState() if len(cards)==0, or loop over cards rendering @TabloProjectCard(card, csrfToken).
2. Replace TablosEmptyState with: templ TablosEmptyState() using @ui.EmptyState(ui.EmptyStateProps{Title: "No tablos yet", Description: "Create your first tablo to get started.", Action: ui.Button(ui.ButtonProps{Label: "New tablo", Variant: ui.ButtonVariantDefault, Tone: ui.ButtonToneSolid, Size: ui.SizeMD, Type: "button", Attrs: templ.Attributes{"hx-get": "/tablos/new", "hx-target": "#create-form-slot", "hx-swap": "innerHTML"}})}).
3. Add new templ TabloProjectCard(card TabloCardView, csrfToken string) per D-C02. Structure: article#{"tablo-"+card.Tablo.ID.String()}.project-card > div.project-card-top containing div.flex.items-center.gap-2 with two @ui.IconButton calls:
- Edit button: Icon "pencil", Variant IconButtonVariantNeutral, Tone IconButtonToneGhost, Type "button". HTMX attrs: hx-get="/tablos/"+card.Tablo.ID.String()+"/edit-title", hx-target="closest .tablo-title-zone" (check exact target from TabloTitleDisplay in read_first — use whatever the existing zone class/selector is).
- Delete wrapper: wrap the delete icon button in div.tablo-delete-zone. Delete icon button: Icon "trash", Variant IconButtonVariantDanger, Tone IconButtonToneGhost, Type "button". HTMX attrs per D-C04: hx-get="/tablos/"+card.Tablo.ID.String()+"/delete-confirm", hx-target="closest .tablo-delete-zone", hx-swap="outerHTML".
Below the project-card-top div: div.project-card-title-row with span.project-avatar (apply style="background-color: "+card.Tablo.Color.String if card.Tablo.Color.Valid && card.Tablo.Color.String != "") + h4 containing card.Tablo.Title. Below that: div.project-date-row containing card.Tablo.CreatedAt.Time.Format("Jan 2, 2006") (Pitfall 6 — use .Time accessor).
4. Keep TabloCard unchanged (existing tests reference it and fragments like TabloCardWithOOBFormClear depend on it). The existing TabloCard is used by fragment responses (OOB clear, edit/delete swap); TabloProjectCard is the new full-page card.
5. Update TabloDetailPage signature: add activePath string and sidebarTablos []sqlc.Tablo as the 3rd and 4th params (after csrfToken, before tablo). Change @Layout(...) to @AppLayout("Tablos — Xtablo", user, csrfToken, activePath, sidebarTablos). All other template body content (tab nav, tab content, zones) is preserved unchanged.
6. Update TabloNotFoundPage signature: add activePath string and sidebarTablos []sqlc.Tablo. Change @Layout("Not found", user, csrfToken) to @AppLayout("Not found", user, csrfToken, activePath, sidebarTablos).
Note on edit button target: read the TabloTitleDisplay zone structure in tablos.templ carefully. The edit-title handler swaps the ".tablo-title-zone" element. The card edit button should target the card's own title zone. However, since TabloProjectCard on the dashboard is a full-page card (not the detail page), the edit hx-target must be a selector reachable from within the card. If TabloDetailPage is the only place with .tablo-title-zone, the dashboard card edit button should instead link to the tablo detail page via a plain href (not HTMX inline edit). Use Claude's discretion: if inline editing on the dashboard card is complex, use @ui.IconButton with a simple href to /tablos/{id} for the edit button and no hx-get.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && templ generate ./... 2>&amp;1 &amp;&amp; go build ./... 2>&amp;1</automated>
</verify>
<acceptance_criteria>
- templ generate exits 0: no compilation errors in tablos.templ
- go build exits 0
- TablosDashboard calls AppLayout: `grep -c "AppLayout" backend/templates/tablos.templ` returns >= 2 (TablosDashboard + TabloDetailPage)
- TablosEmptyState uses ui.EmptyState: `grep -c "ui.EmptyState" backend/templates/tablos.templ` returns >= 1
- TabloProjectCard exists: `grep -c "templ TabloProjectCard" backend/templates/tablos.templ` returns 1
- TabloProjectCard renders project-card class: `grep -c "project-card" backend/templates/tablos.templ` returns >= 1
- TabloDetailPage signature contains activePath: `grep -c "activePath" backend/templates/tablos.templ` returns >= 3
- Color null-safety: `grep -c "Color.Valid" backend/templates/tablos.templ` returns >= 1
- CreatedAt uses .Time accessor: `grep -c "CreatedAt.Time.Format" backend/templates/tablos.templ` returns >= 1
</acceptance_criteria>
<done>tablos.templ uses AppLayout, TabloProjectCard renders project-card layout, TablosEmptyState uses @ui.EmptyState, TabloDetailPage and TabloNotFoundPage use AppLayout. `templ generate && go build` exits 0.</done>
</task>
<task type="auto">
<name>Task 2: Update handlers + planning/account_providers templates to use AppLayout signatures</name>
<files>backend/internal/web/handlers_tablos.go, backend/internal/web/handlers_planning.go, backend/templates/planning.templ, backend/templates/account_providers.templ</files>
<read_first>
- backend/internal/web/handlers_tablos.go — read the full file. Find: TablosListHandler (lines 3956), TabloDetailHandler (lines 188210), and all other handler functions that call TabloDetailPage or TabloNotFoundPage. Also find TabloCardsFromUnreadRows reference — it lives in discussion_forms.go (read that file's signature).
- backend/internal/web/handlers_planning.go — read the full file for PlanningPageHandler structure and PlanningDeps struct.
- backend/templates/planning.templ — read to find PlanningPage signature and its @Layout call.
- backend/templates/account_providers.templ — read to find AccountProvidersPage signature and its @Layout call.
- backend/internal/web/handlers_tablos.go — already in read_first above; note the AccountProviders handler location (may be in a separate file — grep for AccountProvidersHandler).
</read_first>
<action>
Update backend/internal/web/handlers_tablos.go:
1. In TablosListHandler: after building cardViews via templates.TabloCardsFromUnreadRows(tabloRows), derive sidebarTablos without a second DB query: `sidebarTablos := make([]sqlc.Tablo, 0, len(cardViews)); for _, cv := range cardViews { sidebarTablos = append(sidebarTablos, cv.Tablo) }`. Update the template call to: templates.TablosDashboard(user, csrf.Token(r), "/", sidebarTablos, cardViews).
2. In TabloDetailHandler: the handler already has tablo (from loadOwnedTablo). Add a sidebarTablos fetch: `sidebarTablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID); if err != nil { slog.Default().Error(...); http.Error(w, "internal server error", http.StatusInternalServerError); return }; if sidebarTablos == nil { sidebarTablos = []sqlc.Tablo{} }`. Update all templates.TabloDetailPage calls in that handler to include activePath and sidebarTablos as the new 3rd and 4th args. Pass activePath as the current tab's path — since the detail page is /tablos/{id} (not a top-level nav), pass activePath="/" (dashboard is the closest active nav item) or "" (no active item). Use "" as the activePath for tablo detail pages so no nav item shows as active.
3. Wherever TabloNotFoundPage is called, add the two new params (activePath, sidebarTablos). If the not-found path happens before user resolution, pass user=nil and sidebarTablos=[]sqlc.Tablo{} — the AppLayout must handle nil user gracefully (check if SidebarOrganizationFooter panics on nil user; if so, add a nil guard in that template, or fetch tablos only when user is non-nil). For not-found cases, pass activePath="" and sidebarTablos=[]sqlc.Tablo{}.
Update backend/internal/web/handlers_planning.go:
4. In PlanningPageHandler: after building agenda, add: `sidebarTablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID); if err != nil { slog.Default().Error("planning: ListTablosByUser failed", "user_id", user.ID, "err", err); http.Error(w, "internal server error", http.StatusInternalServerError); return }; if sidebarTablos == nil { sidebarTablos = []sqlc.Tablo{} }`. Update the template call to: templates.PlanningPage(user, csrf.Token(r), "/planning", sidebarTablos, agenda).
Update backend/templates/planning.templ:
5. Update PlanningPage signature to: templ PlanningPage(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, agenda PlanningAgenda). Change @Layout("Planning - Xtablo", user, csrfToken) to @AppLayout("Planning - Xtablo", user, csrfToken, activePath, tablos). All other planning template body content is preserved.
Update backend/templates/account_providers.templ:
6. Update AccountProvidersPage signature to accept activePath string and tablos []sqlc.Tablo after csrfToken. Change @Layout("Linked providers", user, csrfToken) to @AppLayout("Linked providers", user, csrfToken, activePath, tablos). Find where AccountProvidersPage is called (grep for AccountProviders in handlers) and update the call site to pass activePath="/" and a fetched sidebarTablos slice using ListTablosByUser.
Run templ generate after all .templ edits.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && templ generate ./... 2>&amp;1 &amp;&amp; go build ./... 2>&amp;1 &amp;&amp; go test ./internal/web/... -run TestTablos -count=1 2>&amp;1</automated>
</verify>
<acceptance_criteria>
- templ generate exits 0
- go build exits 0 — all handler call sites and template signatures are consistent
- handlers_tablos.go TablosListHandler derives sidebarTablos from cardViews: `grep -c "sidebarTablos" backend/internal/web/handlers_tablos.go` returns >= 2 (declaration + use)
- handlers_planning.go calls ListTablosByUser: `grep -c "ListTablosByUser" backend/internal/web/handlers_planning.go` returns >= 1
- planning.templ uses AppLayout: `grep -c "AppLayout" backend/templates/planning.templ` returns >= 1
- account_providers.templ uses AppLayout: `grep -c "AppLayout" backend/templates/account_providers.templ` returns >= 1
- No remaining @Layout( calls in authenticated templates: `grep -c "@Layout(" backend/templates/tablos.templ backend/templates/planning.templ backend/templates/account_providers.templ` returns 0
- Existing TABLO tests pass (skip without DB): `go test ./internal/web/... -run TestTablos -count=1` exits 0
- Full suite compiles: `go test ./... -count=1` exits 0 (all tests skip or pass without DB)
</acceptance_criteria>
<done>All authenticated pages use AppLayout. Handlers pass activePath and tablos. No @Layout( calls remain in the modified templates. Full go build exits 0. All existing Tablos tests pass.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Sidebar-based app layout is now live across all authenticated pages. The tablo dashboard shows a .project-grid of .project-card elements with colored circle avatars, titles, creation dates, and edit/delete icon buttons. The sidebar shows the brand logo, nav items with SVG icons, the user's tablo list as a sidebar-projects section, and the organization footer with email + logout button. The empty state uses the Phase 13 ui.EmptyState component.
</what-built>
<how-to-verify>
1. Start the dev server: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just dev` (or `air`)
2. Open http://localhost:8080 in the browser. Sign in.
3. Dashboard (GET /): Verify sidebar renders on the left with logo, nav items (Dashboard, Tasks, Planning, Chat, Files), tablo list section ("Projects"), and footer with email + logout button.
4. If you have tablos: each should appear as a project-card with colored circle avatar (or neutral gray if no color), title, creation date, and icon buttons (edit/delete).
5. If no tablos: the empty state should appear with title "No tablos yet", description, and a "New tablo" button.
6. Click "New tablo" — the create form should appear in the section header slot.
7. Create a tablo — it should appear in the grid as a project-card.
8. Navigate to /planning — sidebar should persist with Planning nav item active.
9. Navigate to a tablo detail page (/tablos/{id}) — sidebar should persist with no nav item marked active.
10. The logout button in the sidebar footer should function.
</how-to-verify>
<resume-signal>Type "approved" if the sidebar and project cards render correctly, or describe any visual issues to fix.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → GET / | Authenticated request; session cookie validates the user |
| handler → template | user.Email threaded to SidebarOrganizationFooter — must not panic on nil |
| tablo.Color → style attr | User-controlled color value rendered into inline style attribute |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-15-03-01 | Injection | tablo.Color → style="background-color: X" | mitigate | Color value is validated by isValidCSSColor in TablosCreateHandler and TablosUpdateHandler before DB write; templ does not escape inline style values, so the DB-stored value is the trust boundary — handler validation is the gate |
| T-15-03-02 | Information Disclosure | user.Email in sidebar | accept | Email is already known to the authenticated user; standard authenticated-page disclosure |
| T-15-03-03 | Spoofing | CSRF token in logout form | accept | Existing gorilla/csrf middleware validates the token on POST /logout; SidebarOrganizationFooter uses @ui.CSRFField(csrfToken) correctly |
| T-15-03-04 | Denial of Service | ListTablosByUser added to PlanningPageHandler | accept | One extra DB query per planning page load; tablos count is bounded per user; acceptable at this scale |
</threat_model>
<verification>
`cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && templ generate ./... && go build ./... && go test ./internal/web/... -run TestTablos -count=1`
All three commands exit 0. Human checkpoint approves visual layout. If TEST_DATABASE_URL is configured, the three Wave 0 tests (TestTablosDashboard_Sidebar, TestTablosDashboard_ProjectCards, TestTablosDashboard_EmptyState) turn GREEN.
</verification>
<success_criteria>
1. GET / response body contains "dashboard-sidebar" and "sidebar-nav-shell"
2. GET / with tablos contains "project-card" for each tablo
3. GET / with no tablos contains "ui-empty-state"
4. GET /planning renders inside AppLayout (sidebar present)
5. All existing TABLO-01..06 handler tests pass unchanged
6. `templ generate ./... && go build ./...` exits 0
7. Browser checkpoint approved
</success_criteria>
<output>
After completion, create `.planning/phases/15-dashboard-tablos/15-03-SUMMARY.md`
</output>