# Phase 15: Dashboard & Tablos - Pattern Map

**Mapped:** 2026-05-16
**Files analyzed:** 7 new/modified files
**Analogs found:** 7 / 7

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `backend/templates/app_layout.templ` | layout component | request-response | `backend/templates/auth_layout.templ` | role-match |
| `backend/templates/app_layout_helpers.go` | utility | pure function | `go-backend/internal/web/views/home.go` | exact |
| `backend/templates/tablos.templ` | component (modified) | request-response | `backend/templates/tablos.templ` (self, current) | exact |
| `backend/internal/web/ui/app.css` | config/CSS | static | `backend/internal/web/ui/auth.css` | role-match |
| `backend/tailwind.input.css` | config (modified) | static | `backend/tailwind.input.css` (self, current) | exact |
| `backend/internal/web/handlers_tablos.go` | handler (modified) | request-response | `backend/internal/web/handlers_planning.go` | role-match |
| `backend/internal/web/handlers_planning.go` | handler (modified) | request-response | `backend/internal/web/handlers_planning.go` (self) | exact |

---

## Pattern Assignments

### `backend/templates/app_layout.templ` (new — layout component, request-response)

**Analog:** `backend/templates/auth_layout.templ`

**Imports pattern** (`auth_layout.templ` lines 1 — no explicit imports; `layout.templ` lines 6–9):
```go
package templates

import (
    "backend/internal/auth"
    "backend/internal/db/sqlc"
    "backend/internal/web/ui"
)
```

**Core shell pattern** (`auth_layout.templ` lines 15–40 — the established top-level HTML shell convention):
```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>
}
```

**AppLayout adaptation** — follow this pattern exactly but swap `.login-screen` for `.dashboard-shell`, add sidebar sub-component call, and load the three scripts from `layout.templ` lines 54–56:
```go
// Scripts to load (from layout.templ lines 54–56):
<script src="/static/htmx.min.js" defer></script>
<script src="/static/sortable.min.js" defer></script>
<script src="/static/discussion-sse.js" defer></script>
```

**Sidebar structure** (from `go-backend/internal/web/views/dashboard_components.templ` lines 54–91):
```go
templ DashboardSidebar(activePath string, tablos []tablomodel.Record) {
    <aside class="dashboard-sidebar">
        <nav aria-label="Main navigation" class="sidebar-nav-shell">
            <div class="sidebar-brand">
                <a class="sidebar-brand-link" aria-label="Home" href="/">
                    <img alt="Logo XTablo" class="sidebar-brand-logo" src="/static/logo_dark.png"/>
                    <h1 class="sidebar-brand-title">XTablo</h1>
                </a>
                <button class="sidebar-collapse-button" type="button">...</button>
            </div>
            <div class="sidebar-primary">
                <ul class="sidebar-list" role="list">
                    // nav items loop
                </ul>
                @SidebarProjectsSection(tablos)
                <ul class="sidebar-list sidebar-footer-links" role="list">
                    // footer nav items
                </ul>
            </div>
            @SidebarOrganization(user)
        </nav>
    </aside>
}
```

**Note:** go-backend's sidebar nav links use `hx-get` + `hx-target="#app-main-content"` for HTMX partial swaps. In the backend, per D-N02, routes that don't exist render as visual-only items (no href, cursor: default). For existing routes, use standard `<a href="...">` without HTMX (full-page navigation is fine for Phase 15 — no SPA swap is required).

**OOB constraint** (`tablos.templ` lines 172–179 — established pattern to follow):
```go
// OOB fragments MUST be top-level siblings, never nested inside AppLayout:
templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
    @TabloCard(TabloCardFromTablo(tablo), csrfToken)
    <div id="create-form-slot" hx-swap-oob="true"></div>
}
```

---

### `backend/templates/app_layout_helpers.go` (new — utility, pure function)

**Analog:** `go-backend/internal/web/views/home.go` lines 17–39

**Exact pattern to copy** (go-backend `home.go` lines 1–39):
```go
package templates   // NOTE: package is "templates" not "views"

import "strings"

func sidebarNavItemClass(active bool) string {
    if active {
        return "sidebar-nav-item is-active"
    }
    return "sidebar-nav-item"
}

func isActivePath(activePath string, href string) bool {
    return strings.TrimSpace(activePath) != "" && activePath == href
}

func sidebarNavItemID(href string) string {
    switch href {
    case "/":
        return "sidebar-nav-home"
    default:
        slug := strings.Trim(strings.ReplaceAll(href, "/", "-"), "-")
        if slug == "" {
            slug = "item"
        }
        return "sidebar-nav-" + slug
    }
}
```

**sidebarNavItem struct** (go-backend `home.go` lines 47–53):
```go
type sidebarNavItem struct {
    Href         string
    Label        string
    Icon         string
    Active       bool
    DividerAfter bool
}
```

**Nav items slice builder** (go-backend `home.go` lines 158–167 — adapt to English labels + backend routes):
```go
func sidebarPrimaryNavItems(activePath string) []sidebarNavItem {
    return []sidebarNavItem{
        {Href: "/", Label: "Dashboard", Icon: "panels", Active: isActivePath(activePath, "/"), DividerAfter: true},
        // Tasks, Chat, Files — visual-only (no Href, or Href: "" per D-N02)
        {Href: "/planning", Label: "Planning", Icon: "planning", Active: isActivePath(activePath, "/planning")},
    }
}
```

---

### `backend/templates/tablos.templ` (modified — components, request-response)

**Analog:** `backend/templates/tablos.templ` (current file, lines 1–526) — same file, pattern evolution

**Signature change pattern** — follow the call-forwarding convention in `tablos.templ` line 13:
```go
// BEFORE (line 12–13):
templ TablosDashboard(user *auth.User, csrfToken string, tablos []TabloCardView) {
    @Layout("Tablos — Xtablo", user, csrfToken) {

// AFTER — new signature forwards activePath + tablos slice to AppLayout:
templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, cards []TabloCardView) {
    @AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos) {
```

**Section heading pattern** (from go-backend `dashboard_components.templ` lines 238–244 — `.overview-section-heading`):
```go
<section class="overview-section">
    <div class="overview-section-heading">
        <h3>Your Tablos</h3>
        // New tablo button + #create-form-slot go here (D-C05)
    </div>
    <div id="tablos-list" class="project-grid">
        // cards or empty state
    </div>
</section>
```

**Project card pattern** (adapted from go-backend `tablos.templ` lines 178–214, preserving backend HTMX attrs):
```go
templ TabloProjectCard(card TabloCardView, csrfToken string) {
    <article id={ "tablo-" + card.Tablo.ID.String() } class="project-card">
        <div class="project-card-top">
            <div class="flex items-center gap-3">
                @ui.IconButton(ui.IconButtonProps{
                    Label:   "Edit tablo",
                    Icon:    "pencil",
                    Variant: ui.IconButtonVariantNeutral,
                    Tone:    ui.IconButtonToneGhost,
                    Type:    "button",
                    Attrs: templ.Attributes{
                        // PRESERVE existing HTMX attrs from current tablos.templ (Pitfall 3)
                    },
                })
                @ui.IconButton(ui.IconButtonProps{
                    Label:   "Delete tablo",
                    Icon:    "trash",
                    Variant: ui.IconButtonVariantDanger,
                    Tone:    ui.IconButtonToneGhost,
                    Type:    "button",
                    Attrs: templ.Attributes{
                        "hx-get":    "/tablos/" + card.Tablo.ID.String() + "/delete-confirm",
                        "hx-target": "closest .tablo-delete-zone",
                        "hx-swap":   "outerHTML",
                    },
                })
            </div>
        </div>
        <div class="project-card-title-row">
            <span class="project-avatar"
                if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
                    style={ "background-color: " + card.Tablo.Color.String }
                }>
            </span>
            <h4>{ card.Tablo.Title }</h4>
        </div>
        <div class="project-date-row">
            { card.Tablo.CreatedAt.Time.Format("Jan 2, 2006") }
        </div>
    </article>
}
```

**Color null-safety pattern** — already established in current `tablos.templ` lines 85–90:
```go
// ESTABLISHED: always guard pgtype.Text with .Valid check
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
    style={ "background-color: " + card.Tablo.Color.String }
}
```

**CreatedAt formatting** — from Pitfall 6 in RESEARCH.md (pgtype.Timestamptz requires `.Time` accessor):
```go
// CORRECT:
card.Tablo.CreatedAt.Time.Format("Jan 2, 2006")
// WRONG (compiler error):
card.Tablo.CreatedAt.Format("Jan 2, 2006")
```

**EmptyState pattern** (`backend/internal/web/ui/empty_state.templ` lines 10–27):
```go
// Replace TablosEmptyState raw HTML with:
@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",
        },
    }),
})
```

**SidebarProjectsSection sub-component** (adapted from go-backend `dashboard_components.templ` lines 103–115 — use `sqlc.Tablo` not `tablomodel.Record`):
```go
templ SidebarProjectsSection(tablos []sqlc.Tablo) {
    <div id="sidebar-projects-section" class="sidebar-projects">
        <hr role="separator"/>
        <div class="sidebar-section-label">Projects</div>
        <ul class="sidebar-project-list">
            for _, tablo := range tablos {
                <li>
                    <a class="sidebar-project-link" href={ templ.SafeURL("/tablos/" + tablo.ID.String()) }>
                        <span class="sidebar-project-icon"
                            if tablo.Color.Valid && tablo.Color.String != "" {
                                style={ "background-color: " + tablo.Color.String }
                            }>
                        </span>
                        <span class="sidebar-project-label">{ tablo.Title }</span>
                    </a>
                </li>
            }
        </ul>
    </div>
}
```

**SidebarOrganization footer** (adapted from go-backend `dashboard_components.templ` lines 131–143 — use `user.Email` and include logout):
```go
templ SidebarOrganizationFooter(user *auth.User, csrfToken string) {
    <div class="sidebar-organization">
        <div class="organization-button">
            <span class="organization-avatar">{ string([]rune(user.Email)[:1]) }</span>
            <span class="organization-copy">
                <span class="organization-name">{ user.Email }</span>
            </span>
            <form method="POST" action="/logout" class="inline">
                @ui.CSRFField(csrfToken)
                <button type="submit" class="text-sm">Log out</button>
            </form>
        </div>
    </div>
}
```

**TabloDetailPage / TabloNotFoundPage layout switch** (current `tablos.templ` lines 187–188, 515–516):
```go
// BEFORE:
@Layout("Tablos — Xtablo", user, csrfToken) {
// AFTER (D-L01 — all authenticated pages switch):
@AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos) {
```

---

### `backend/internal/web/ui/app.css` (new — CSS)

**Analog:** `backend/internal/web/ui/auth.css` — same pattern: verbatim port of CSS sections from go-backend into a new file

**Go-backend source:** `go-backend/internal/web/ui/app.css`

**Sections to port verbatim** (line ranges from go-backend app.css):

| Section | Lines | Classes |
|---|---|---|
| Dashboard shell grid | 455–465 | `.dashboard-shell`, `.dashboard-sidebar` |
| Sidebar nav shell | 467–479 | `.sidebar-nav-shell` |
| Sidebar brand | 481–509 | `.sidebar-brand`, `.sidebar-brand-link`, `.sidebar-brand-logo`, `.sidebar-brand-title` |
| Sidebar collapse button | 511–527 | `.sidebar-collapse-button` (non-functional in Phase 15) |
| Sidebar primary + list | 533–558 | `.sidebar-primary`, `.sidebar-list`, `.sidebar-divider` |
| Sidebar nav items | 560–605 | `.sidebar-nav-item`, `.sidebar-nav-item.is-active`, `.sidebar-nav-link`, `.sidebar-nav-link-inner`, `.sidebar-nav-icon`, `.sidebar-nav-label` |
| Sidebar projects | 607–668 | `.sidebar-projects`, `.sidebar-section-label`, `.sidebar-project-list`, `.sidebar-project-link`, `.sidebar-project-icon`, `.sidebar-project-label` |
| Sidebar footer links | 670–673 | `.sidebar-footer-links` |
| Sidebar organization | 675–732 | `.sidebar-organization`, `.organization-button`, `.organization-avatar`, `.organization-name`, `.organization-meta` |
| Dashboard main | 734–741 | `.dashboard-main` |
| Overview section heading | 875–892 | `.overview-section`, `.overview-section-heading` |
| Project grid | 894–899 | `.project-grid` |
| Project card | 900–914 | `.project-card`, `.project-card-top` |
| Project card icon button overrides | 945–963 | **Adapt** go-backend's `.borderless-icon-button` overrides to use backend class names (see Shared Patterns below) |
| Project title/avatar | 986–1005 | `.project-card-title-row`, `.project-avatar` |
| Project date row | 1039–1046 | `.project-date-row` |

**Critical:** All color values in go-backend app.css already use `var(--...)` tokens — port verbatim without substitution.

---

### `backend/tailwind.input.css` (modified — config)

**Analog:** `backend/tailwind.input.css` (self, current — lines 1–20)

**Pattern to follow** (current file lines 7–20):
```css
@import "./internal/web/ui/base.css";
@import "./internal/web/ui/auth.css";
/* ... existing imports ... */
@import "./internal/web/ui/spacing.css";
```

**Change:** Add one new line after the existing imports:
```css
@import "./internal/web/ui/app.css";
```

---

### `backend/internal/web/handlers_tablos.go` (modified — handler)

**Analog:** `backend/internal/web/handlers_tablos.go` lines 39–55 (current `TablosListHandler`)

**Current pattern** (lines 39–55):
```go
func TablosListHandler(deps TablosDeps) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        _, user, _ := auth.Authed(r.Context())

        tabloRows, err := deps.Queries.ListTablosByUserWithDiscussionUnread(r.Context(), user.ID)
        if err != nil {
            slog.Default().Error("tablos list: query failed", "user_id", user.ID, "err", err)
            http.Error(w, "internal server error", http.StatusInternalServerError)
            return
        }
        if tabloRows == nil {
            tabloRows = []sqlc.ListTablosByUserWithDiscussionUnreadRow{}
        }

        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = templates.TablosDashboard(user, csrf.Token(r), templates.TabloCardsFromUnreadRows(tabloRows)).Render(r.Context(), w)
    }
}
```

**Change pattern** — derive `[]sqlc.Tablo` from already-fetched cardViews (no second query per RESEARCH.md Pattern 5):
```go
cardViews := templates.TabloCardsFromUnreadRows(tabloRows)

// Derive sidebar tablos from already-fetched data (no extra DB query)
tablos := make([]sqlc.Tablo, 0, len(cardViews))
for _, cv := range cardViews {
    tablos = append(tablos, cv.Tablo)
}

_ = templates.TablosDashboard(user, csrf.Token(r), "/", tablos, cardViews).Render(r.Context(), w)
```

**Error handling pattern** — unchanged, copy from lines 44–48 verbatim.

---

### `backend/internal/web/handlers_planning.go` (modified — handler)

**Analog:** `backend/internal/web/handlers_planning.go` lines 34–58 (self, current)

**Current pattern** (lines 34–58):
```go
func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        _, user, _ := auth.Authed(r.Context())
        // ... fetch events ...
        _ = templates.PlanningPage(user, csrf.Token(r), agenda).Render(r.Context(), w)
    }
}
```

**Change pattern** — add tablos fetch before template render:
```go
// Fetch tablos for sidebar (PlanningDeps already has Queries *sqlc.Queries)
tablos, 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 tablos == nil {
    tablos = []sqlc.Tablo{}
}

// Pass tablos + activePath to template
_ = templates.PlanningPage(user, csrf.Token(r), "/planning", tablos, agenda).Render(r.Context(), w)
```

---

## Shared Patterns

### CSS @import registration
**Source:** `backend/tailwind.input.css` lines 7–20
**Apply to:** New `app.css` file
```css
/* Add to tailwind.input.css after existing imports: */
@import "./internal/web/ui/app.css";
```

### pgtype.Text null-safety
**Source:** `backend/templates/tablos.templ` lines 85–90
**Apply to:** All color-rendering spans in `app_layout.templ` and `tablos.templ`
```go
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
    style={ "background-color: " + card.Tablo.Color.String }
}
```

### pgtype.Timestamptz formatting
**Source:** RESEARCH.md Pitfall 6 (verified against `sqlc.Tablo` type)
**Apply to:** Project card date row in `tablos.templ`
```go
// Always use .Time to unwrap pgtype.Timestamptz:
card.Tablo.CreatedAt.Time.Format("Jan 2, 2006")
```

### CSRF field
**Source:** `backend/templates/tablos.templ` line 121 / `layout.templ` lines 41–42
**Apply to:** All form fragments inside `app_layout.templ` (logout form)
```go
@ui.CSRFField(csrfToken)
```

### IconButton class names (VERIFIED)
**Source:** `backend/internal/web/ui/variants.go` lines 179–187
**Apply to:** `app.css` project-card icon button overrides

```
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"
```

The `.borderless-icon-button` class is already in `icon-button.css`. So `app.css` project-card overrides should target:
```css
/* Adapted from go-backend app.css lines 945–963 — 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);
}
```

### Handler error + render pattern
**Source:** `backend/internal/web/handlers_tablos.go` lines 44–54
**Apply to:** Any new tablos-fetch added to other handlers
```go
tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
if err != nil {
    slog.Default().Error("<handler>: ListTablosByUser failed", "user_id", user.ID, "err", err)
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
}
if tablos == nil {
    tablos = []sqlc.Tablo{}
}
```

### templ.SafeURL for dynamic links
**Source:** `backend/templates/tablos.templ` line 98
**Apply to:** All dynamic `href` attributes in `app_layout.templ`
```go
href={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
```

---

## No Analog Found

All Phase 15 files have analogs in the existing codebase. No file requires building from RESEARCH.md patterns alone.

---

## Key Notes for Planner

1. **`account_providers.templ`** also calls `@Layout(...)` (verified via grep in RESEARCH.md) — planner should include it in the layout-switch task if Phase 15 scope covers all authenticated pages.

2. **SVG icons:** The `SidebarNavItem` templ calls `@SidebarIcon(item.Icon)` in go-backend. The backend's equivalent is `@ui.UIIcon(kind)` (already in `icon_button.templ` lines 18–74). The nav items can call `@ui.UIIcon(item.Icon)` directly, or a new `SidebarIcon` wrapper can delegate to it. The icon names from go-backend (`"panels"`, `"tasks"`, `"layers"`, `"planning"`, `"chat"`, `"files"`) must either match a `case` in `UIIcon` or be added.

3. **Visual-only nav items** (D-N02): For Tasks, Files, Settings — render as `<div class="sidebar-nav-item">` without an `<a>` tag, or with `href=""` and `style="cursor: default"`. No HTMX attributes.

4. **`templ generate`** must be run after every `.templ` file change (Pitfall 5 from RESEARCH.md).

---

## Metadata

**Analog search scope:** `backend/templates/`, `backend/internal/web/`, `go-backend/internal/web/views/`, `go-backend/internal/web/ui/`
**Files scanned:** 12
**Pattern extraction date:** 2026-05-16
