docs(15): create phase plan

This commit is contained in:
Arthur Belleville 2026-05-16 21:35:53 +02:00
parent 431096e4de
commit 13b6f525de
No known key found for this signature in database
3 changed files with 647 additions and 7 deletions

View file

@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v3.0
milestone_name: Design System & Visual Polish
status: executing
last_updated: "2026-05-16T17:40:00.000Z"
last_activity: 2026-05-16 -- Phase 14 Plan 02 execution complete, awaiting final verification
last_updated: "2026-05-16T19:35:46.574Z"
last_activity: 2026-05-16 -- Phase 15 planning complete
progress:
total_phases: 5
completed_phases: 1
total_plans: 7
completed_phases: 2
total_plans: 10
completed_plans: 7
percent: 100
percent: 70
---
# STATE
@ -30,8 +30,8 @@ See: `.planning/PROJECT.md` (updated 2026-05-16)
Phase: 14
Plan: 02 (complete)
Status: Phase 14 execution complete — awaiting final visual verification
Last activity: 2026-05-16 -- Phase 14 Plan 02 execution complete, awaiting final verification
Status: Ready to execute
Last activity: 2026-05-16 -- Phase 15 planning complete
## Previous Milestone Status

View file

@ -0,0 +1,563 @@
# 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 69):
```go
package templates
import (
"backend/internal/auth"
"backend/internal/db/sqlc"
"backend/internal/web/ui"
)
```
**Core shell pattern** (`auth_layout.templ` lines 1540 — 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 5456:
```go
// Scripts to load (from layout.templ lines 5456):
<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 5491):
```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 172179 — 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 1739
**Exact pattern to copy** (go-backend `home.go` lines 139):
```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 4753):
```go
type sidebarNavItem struct {
Href string
Label string
Icon string
Active bool
DividerAfter bool
}
```
**Nav items slice builder** (go-backend `home.go` lines 158167 — 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 1526) — same file, pattern evolution
**Signature change pattern** — follow the call-forwarding convention in `tablos.templ` line 13:
```go
// BEFORE (line 1213):
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 238244 — `.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 178214, 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 8590:
```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 1027):
```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 103115 — 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 131143 — 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 187188, 515516):
```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 | 455465 | `.dashboard-shell`, `.dashboard-sidebar` |
| Sidebar nav shell | 467479 | `.sidebar-nav-shell` |
| Sidebar brand | 481509 | `.sidebar-brand`, `.sidebar-brand-link`, `.sidebar-brand-logo`, `.sidebar-brand-title` |
| Sidebar collapse button | 511527 | `.sidebar-collapse-button` (non-functional in Phase 15) |
| Sidebar primary + list | 533558 | `.sidebar-primary`, `.sidebar-list`, `.sidebar-divider` |
| Sidebar nav items | 560605 | `.sidebar-nav-item`, `.sidebar-nav-item.is-active`, `.sidebar-nav-link`, `.sidebar-nav-link-inner`, `.sidebar-nav-icon`, `.sidebar-nav-label` |
| Sidebar projects | 607668 | `.sidebar-projects`, `.sidebar-section-label`, `.sidebar-project-list`, `.sidebar-project-link`, `.sidebar-project-icon`, `.sidebar-project-label` |
| Sidebar footer links | 670673 | `.sidebar-footer-links` |
| Sidebar organization | 675732 | `.sidebar-organization`, `.organization-button`, `.organization-avatar`, `.organization-name`, `.organization-meta` |
| Dashboard main | 734741 | `.dashboard-main` |
| Overview section heading | 875892 | `.overview-section`, `.overview-section-heading` |
| Project grid | 894899 | `.project-grid` |
| Project card | 900914 | `.project-card`, `.project-card-top` |
| Project card icon button overrides | 945963 | **Adapt** go-backend's `.borderless-icon-button` overrides to use backend class names (see Shared Patterns below) |
| Project title/avatar | 9861005 | `.project-card-title-row`, `.project-avatar` |
| Project date row | 10391046 | `.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 120)
**Pattern to follow** (current file lines 720):
```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 3955 (current `TablosListHandler`)
**Current pattern** (lines 3955):
```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 4448 verbatim.
---
### `backend/internal/web/handlers_planning.go` (modified — handler)
**Analog:** `backend/internal/web/handlers_planning.go` lines 3458 (self, current)
**Current pattern** (lines 3458):
```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 720
**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 8590
**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 4142
**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 179187
**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 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);
}
```
### Handler error + render pattern
**Source:** `backend/internal/web/handlers_tablos.go` lines 4454
**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 1874). 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

View file

@ -0,0 +1,77 @@
---
phase: 15
slug: dashboard-tablos
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-05-16
---
# Phase 15 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Go `testing` + `net/http/httptest` (existing) |
| **Config file** | none — standard Go test runner |
| **Quick run command** | `cd backend && go test ./internal/web/... -run TestTablos -v` |
| **Full suite command** | `cd backend && go test ./...` |
| **Estimated runtime** | ~5 seconds |
---
## Sampling Rate
- **After every task commit:** Run `cd backend && go test ./internal/web/... -run TestTablos -count=1`
- **After every plan wave:** Run `cd backend && go test ./... -count=1`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** 10 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 15-W0-01 | W0 | 0 | DASH-01 | — | N/A | integration | `go test ./internal/web/... -run TestTablosDashboard_Sidebar` | ❌ Wave 0 | ⬜ pending |
| 15-W0-02 | W0 | 0 | DASH-02 | — | N/A | integration | `go test ./internal/web/... -run TestTablosDashboard_ProjectCards` | ❌ Wave 0 | ⬜ pending |
| 15-W0-03 | W0 | 0 | DASH-03 | — | N/A | integration | `go test ./internal/web/... -run TestTablosDashboard_EmptyState` | ❌ Wave 0 | ⬜ pending |
| 15-regression | — | 1 | (regression) | — | N/A | integration | `go test ./internal/web/... -run TestTablos` | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `backend/internal/web/handlers_tablos_test.go` — add `TestTablosDashboard_Sidebar` (asserts sidebar brand, nav items, tablo list, footer present in GET / response body)
- [ ] `backend/internal/web/handlers_tablos_test.go` — add `TestTablosDashboard_ProjectCards` (asserts `.project-card` elements rendered for each tablo)
- [ ] `backend/internal/web/handlers_tablos_test.go` — add `TestTablosDashboard_EmptyState` (asserts `.ui-empty-state` present when no tablos exist)
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Sidebar visual layout matches go-backend screenshot | DASH-01 | CSS layout/visual fidelity requires browser inspection | Open `/` in browser; compare sidebar structure, spacing, and icon rendering against go-backend reference |
| Project card color avatars render correctly | DASH-02 | Color rendering requires browser | Check tablo cards show colored circles; null color shows neutral gray |
| Empty state matches design system | DASH-03 | Visual fidelity | Check empty state icon, title, description, and CTA button match Phase 13 `ui.EmptyState` design |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 10s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending