diff --git a/.planning/STATE.md b/.planning/STATE.md index b04cefc..f10e4ee 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 diff --git a/.planning/phases/15-dashboard-tablos/15-PATTERNS.md b/.planning/phases/15-dashboard-tablos/15-PATTERNS.md new file mode 100644 index 0000000..d6a5ef0 --- /dev/null +++ b/.planning/phases/15-dashboard-tablos/15-PATTERNS.md @@ -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 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) { + + + + + + { title } + + + +
+ { children... } +
+ + + +} +``` + +**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): + + + +``` + +**Sidebar structure** (from `go-backend/internal/web/views/dashboard_components.templ` lines 54–91): +```go +templ DashboardSidebar(activePath string, tablos []tablomodel.Record) { + +} +``` + +**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 `` 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) +
+} +``` + +--- + +### `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 +
+
+

Your Tablos

+ // New tablo button + #create-form-slot go here (D-C05) +
+
+ // cards or empty state +
+
+``` + +**Project card pattern** (adapted from go-backend `tablos.templ` lines 178–214, preserving backend HTMX attrs): +```go +templ TabloProjectCard(card TabloCardView, csrfToken string) { +
+
+
+ @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", + }, + }) +
+
+
+ + +

{ card.Tablo.Title }

+
+
+ { card.Tablo.CreatedAt.Time.Format("Jan 2, 2006") } +
+
+} +``` + +**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) { +
+} +``` + +**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) { + +} +``` + +**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(": 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 `