---
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 `hx-get="/tablos/{id}/edit-title"` + `hx-target` selector for the title zone, TabloDetailPage full signature and its @Layout call, TabloNotFoundPage body.
    - backend/internal/web/handlers_tablos.go — read to confirm the edit-title route exists and identify the `hx-target` class/selector used by the existing TabloTitleDisplay zone. This is the authoritative source for the edit-icon HTMX wiring on TabloProjectCard.
    - 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 (per D-C04, matching verified TabloCard behavior): hx-get="/tablos/"+card.Tablo.ID.String()+"/edit-title", hx-target matching the title zone selector from TabloTitleDisplay (confirmed in read_first — the existing zone uses class `.tablo-title-zone`; use `hx-target="closest .tablo-title-zone"`), hx-swap="outerHTML". Do NOT use a plain href fallback — the `/tablos/{id}/edit-title` route exists and returns an inline edit field.
       - 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 wrapped in a div.tablo-title-zone (so the edit-title inline swap has a zone to target on the dashboard card). 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).

  </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
    - TabloProjectCard edit button uses edit-title route: `grep -c "edit-title" backend/templates/tablos.templ` returns >= 1
    - No plain href fallback on edit button: `grep -c "href.*tablos.*id" backend/templates/tablos.templ` (TabloProjectCard section has no href-only edit link — the edit icon uses hx-get)
  </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 39–56), TabloDetailHandler (lines 188–210), 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>
