xtablo-source/.planning/phases/03-tablos-crud/03-RESEARCH.md
Arthur Belleville f53b54637b
docs(03): plan phase 3 — Tablos CRUD (3 plans, 3 waves)
Plans cover TABLO-01..06 via MVP vertical slices: foundation (migration
+ sqlc + test scaffold + button CSS), list+create (dashboard, inline
form, OOB swap), and detail+edit+delete (ownership 404, inline edit
fragments, inline confirm delete). Includes Nyquist VALIDATION.md and
PATTERNS.md with real analog excerpts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:08:08 +02:00

38 KiB

Phase 3: Tablos CRUD — Research

Researched: 2026-05-14 Domain: Go + chi + templ + sqlc + HTMX — server-rendered CRUD with fragment swaps Confidence: HIGH


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Hard-delete only — no deleted_at column. Deletion is irreversible via the UI.
  • D-02: tablos table: id uuid PK DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, title text NOT NULL, description text (nullable), color text (nullable), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(). Index on user_id.
  • D-03: Sort order always ORDER BY created_at DESC. No position column.
  • D-04: URL /tablos/{uuid}. Non-owner or unauthenticated → 404 (not 403).
  • D-05: Create UX: inline form via HTMX swap into #create-form-slot. On success, form collapses and new card prepends to #tablos-list.
  • D-06: Edit UX: inline on tablo detail page. hx-get fetches edit fragment; save POSTs back; success swaps display fragment back.
  • D-07: Delete: inline confirmation fragment — no modal, no confirm(). Button swaps to confirm row; confirming fires actual delete.

Claude's Discretion

  • Exact Tailwind styling for tablo cards (consistent with Phase 1/2 design system).
  • Color rendered as dot/badge on card or left border accent.
  • HTTP verb for edit: POST /tablos/{id}/edit vs PATCH with _method override.
  • updated_at maintenance: Postgres trigger vs explicit SET updated_at = now() in UPDATE query.

Deferred Ideas (OUT OF SCOPE)

  • Tablo reordering by user (drag-and-drop / button reorder).
  • Color palette picker UI (color column stored, plain text input is fine).
  • Slug-based URLs.
  • Sharing / permissions beyond owner-only. </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
TABLO-01 Authenticated user can list their tablos on the dashboard (newest first) Migration D-02, sqlc list query with ORDER BY created_at DESC
TABLO-02 User can create a tablo with at minimum a title (and optional description) Inline form (D-05), POST /tablos handler, sqlc InsertTablo
TABLO-03 User can view a single tablo's detail page (only owners can view in v1) GET /tablos/{id}, ownership check → 404 on mismatch
TABLO-04 User can edit a tablo's title and description Inline edit fragments (D-06), POST /tablos/{id} handler, sqlc UpdateTablo
TABLO-05 User can delete a tablo (hard delete, confirmed in D-01) Inline confirm fragment (D-07), DELETE handler, sqlc DeleteTablo
TABLO-06 All tablo mutations are HTMX-driven (no full page reloads for CRUD actions) HX-Request detection pattern, HX-Retarget/HX-Reswap headers, non-HTMX 303 fallback
</phase_requirements>

Summary

Phase 3 adds the Tablos CRUD surface on top of the fully established Phase 1/2 foundation. The work is almost entirely additive: one new migration, one new sqlc query file, one new handler file, one new templ file, and route wiring into the existing router. The internal/tablos/ package is a Phase 1 placeholder (just doc.go) that the implementation will fill.

The hardest HTMX problem in this phase is the dual-target swap on successful create: the server must simultaneously clear the #create-form-slot and prepend a card to #tablos-list. HTMX v2 supports this via HX-Retarget + HX-Reswap response headers — the create handler returns the new card HTML body but redirects the swap to #tablos-list with afterbegin, while leaving #create-form-slot empty via a second response or by relying on HX-Trigger + an out-of-band swap (hx-swap-oob). The cleanest verified approach for HTMX v2 is hx-swap-oob="true" on an empty <div id="create-form-slot"> element included in the response body alongside the card HTML.

The edit flow uses outerHTML swaps on named zone elements (.tablo-title-zone, .tablo-desc-zone, .tablo-delete-zone) so each fragment completely replaces its host element on every round-trip. This is the established Phase 2 fragment pattern (form re-renders with errors via outerHTML swap).

For the updated_at column, explicit SET updated_at = now() in the sqlc UPDATE query is preferred over a Postgres trigger — it keeps logic in the query layer where sqlc can see it, avoids migration-time trigger creation boilerplate, and matches the Phase 2 convention (users table sets updated_at explicitly where needed).

Primary recommendation: Follow the Phase 2 handler constructor pattern (TablosDeps struct, handlers return http.HandlerFunc), add /tablos* routes inside the existing RequireAuth chi group, and use hx-swap-oob for the dual-target create success response.


Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Tablo list (dashboard) Go server (templ render) Full page + DB query at request time
Create form expand Go server (HTMX fragment) Server renders the inline form HTML
Create mutation Go server (handler) Postgres Validation, insert, fragment response
Detail page render Go server (templ) Postgres DB fetch + ownership check
Inline edit fragments Go server (HTMX fragment) Server renders editable inputs
Edit mutation Go server (handler) Postgres Validation, update, fragment response
Delete confirmation Go server (HTMX fragment) Server renders confirm row
Delete mutation Go server (handler) Postgres Hard delete, card removal from DOM
CSRF protection Go server (gorilla/csrf) Locked middleware from Phase 2
Auth gating Go server (RequireAuth middleware) Locked from Phase 2
Ownership enforcement Go server (handler logic) auth.Authed(ctx) + DB row check → 404

Standard Stack

Core (all verified in go.mod / existing codebase)

Library Version Purpose Why Standard
github.com/go-chi/chi/v5 v5.2.5 Router, URL params (chi.URLParam) Locked Phase 1
github.com/a-h/templ v0.3.1020 HTML template compilation Locked Phase 1
github.com/jackc/pgx/v5 v5.9.2 Postgres driver + pgtype nullable types Locked Phase 1
github.com/pressly/goose/v3 v3.27.1 Migration runner Locked Phase 1
github.com/gorilla/csrf v1.7.3 CSRF token validation Locked Phase 2
github.com/google/uuid v1.6.0 UUID parsing from URL path params Locked Phase 1
sqlc v1.31.1 (CLI tool) Generate type-safe Go from SQL Locked Phase 1

[VERIFIED: backend/go.mod and justfile]

Nullable Column Handling with sqlc + pgx/v5

description text and color text are nullable. sqlc with pgx/v5 generates pgtype.Text for nullable text columns. The template receives the Go struct field and must check .Valid before rendering.

// Generated model shape for nullable text:
type Tablo struct {
    ID          uuid.UUID
    UserID      uuid.UUID
    Title       string
    Description pgtype.Text  // .Valid = true when non-null, .String = value
    Color       pgtype.Text
    CreatedAt   pgtype.Timestamptz
    UpdatedAt   pgtype.Timestamptz
}

[VERIFIED: backend/internal/db/sqlc/models.go — User.CreatedAt uses pgtype.Timestamptz; same pattern applies to text nullables]


Architecture Patterns

System Architecture Diagram

Browser
  │
  │ GET /          (full page)
  │ GET /tablos/new  (HTMX fragment)
  │ POST /tablos     (HTMX fragment or 303)
  │ GET /tablos/{id} (full page)
  │ GET /tablos/{id}/edit-title  (HTMX fragment)
  │ POST /tablos/{id}            (HTMX fragment or 303)
  │ GET /tablos/{id}/show-title  (HTMX fragment — cancel)
  │ GET /tablos/{id}/delete-confirm (HTMX fragment)
  │ POST /tablos/{id}/delete        (200+HX-Redirect or 303)
  │ GET /tablos/{id}/delete-cancel  (HTMX fragment)
  ▼
chi Router  ──► RequireAuth middleware ──► auth.Authed(ctx) → *auth.User
  │
  ├─► TablosListHandler → sqlc.ListTablosByUser → templ.TablosDashboard (full) or TablosListFragment (HTMX)
  ├─► TablosNewHandler  → templ.TabloCreateFormFragment
  ├─► TablosCreateHandler → validate → sqlc.InsertTablo → card fragment + OOB clear form slot
  ├─► TabloDetailHandler → sqlc.GetTabloByID → ownership check → templ.TabloDetailPage
  ├─► TabloEditTitleHandler → templ.TabloTitleEditFragment
  ├─► TabloUpdateHandler   → validate → sqlc.UpdateTablo → templ.TabloTitleDisplay / TabloDescDisplay
  ├─► TabloShowTitleHandler → templ.TabloTitleDisplay  (cancel edit)
  ├─► TabloDeleteConfirmHandler → templ.TabloDeleteConfirmFragment
  ├─► TabloDeleteHandler → sqlc.DeleteTablo → 200+HX-Redirect:/ or 303:/
  └─► TabloDeleteCancelHandler → templ.TabloDeleteButtonFragment
          │
          ▼
       Postgres (tablos table)
backend/
├── internal/
│   ├── tablos/          # Phase 1 placeholder (doc.go only) — fill in Phase 3
│   │   └── doc.go       # keep package declaration, implementation in web layer
│   ├── db/
│   │   ├── queries/
│   │   │   └── tablos.sql      # NEW — sqlc source queries
│   │   └── sqlc/
│   │       ├── tablos.sql.go   # generated by sqlc generate
│   │       └── models.go       # updated with Tablo struct
│   └── web/
│       ├── handlers_tablos.go  # NEW — TablosDeps + all tablo handlers
│       └── router.go           # MODIFIED — add /tablos* routes
├── migrations/
│   └── 0003_tablos.sql         # NEW — tablos table + index
└── templates/
    └── tablos.templ             # NEW — all tablo templates

Note: internal/tablos/ remains a thin package (Phase 1 placeholder shell). All tablo handler logic lives in internal/web/handlers_tablos.go following the AuthDeps / handlers_auth.go precedent. There is no separate service layer in this codebase.

Pattern 1: Handler Constructor with Deps Struct

Every handler group follows the AuthDeps pattern exactly — a struct holding dependencies, handler functions as closures returned from constructors.

// Source: backend/internal/web/handlers_auth.go (established pattern)

// TablosDeps holds dependencies for all tablo handlers.
type TablosDeps struct {
    Queries *sqlc.Queries
}

// TablosListHandler renders GET / — the dashboard with the user's tablos.
func TablosListHandler(deps TablosDeps) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        _, user, _ := auth.Authed(r.Context())
        tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
        if err != nil {
            http.Error(w, "internal server error", http.StatusInternalServerError)
            return
        }
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w)
    }
}

[VERIFIED: backend/internal/web/handlers_auth.go — AuthDeps pattern]

Pattern 2: HTMX Request Detection

Check r.Header.Get("HX-Request") == "true" to branch between fragment and full-page response. Established in Phase 2 and used throughout.

// Source: backend/internal/web/handlers_auth.go — renderSignupError
if r.Header.Get("HX-Request") == "true" {
    _ = templates.TabloCreateFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
} else {
    _ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w)
}

[VERIFIED: backend/internal/web/handlers_auth.go]

Pattern 3: HTMX-Aware Redirect

For post-delete navigation back to /:

// Source: backend/internal/web/handlers_auth.go (logout/signup patterns)
if r.Header.Get("HX-Request") == "true" {
    w.Header().Set("HX-Redirect", "/")
    w.WriteHeader(http.StatusOK)
    return
}
http.Redirect(w, r, "/", http.StatusSeeOther)

[VERIFIED: backend/internal/web/handlers_auth.go — LogoutHandler, SignupPostHandler]

Pattern 4: Dual-Target Swap on Create Success

HTMX v2's hx-swap-oob (out-of-band swap) lets a single response update two DOM regions. On successful create, the handler returns HTML containing:

  1. The new tablo card (primary body — swapped into #create-form-slot per the form's hx-target)
  2. An empty <div id="create-form-slot" hx-swap-oob="true"></div> to clear the form

Wait — this is backwards. The form's hx-post targets #create-form-slot. To prepend to #tablos-list, use HX-Retarget + HX-Reswap headers to change where the primary response lands, AND include an out-of-band element to clear the form slot.

// Successful create handler response:
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Retarget", "#tablos-list")
w.Header().Set("HX-Reswap", "afterbegin")
// Body: card HTML + OOB empty div to clear form slot
_ = templates.TabloCardWithOOBFormClear(tablo, csrfToken).Render(r.Context(), w)

The template includes both the card and the out-of-band form-clear element:

// TabloCardWithOOBFormClear renders a card plus an OOB element to clear #create-form-slot.
templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
    @TabloCard(tablo, csrfToken)
    <div id="create-form-slot" hx-swap-oob="true"></div>
}

[VERIFIED: HTMX docs — HX-Retarget, HX-Reswap, hx-swap-oob are all valid HTMX v2 response headers/attributes]

Pattern 5: Ownership Check → 404

// In TabloDetailHandler and any handler that fetches a specific tablo:
tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID)
if err != nil {
    if errors.Is(err, pgx.ErrNoRows) {
        http.NotFound(w, r)
        return
    }
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
}
// D-04: 404 for non-owner, not 403 — no information leakage
if tablo.UserID != user.ID {
    http.NotFound(w, r)
    return
}

[VERIFIED: CONTEXT.md D-04]

Pattern 6: UUID Extraction from URL Params

// chi URL parameter extraction + uuid parsing
import (
    "github.com/go-chi/chi/v5"
    "github.com/google/uuid"
)

idStr := chi.URLParam(r, "id")
tabloID, err := uuid.Parse(idStr)
if err != nil {
    http.NotFound(w, r)
    return
}

[VERIFIED: backend/go.mod — chi v5.2.5, uuid v1.6.0]

Pattern 7: Router Extension — Tablos Route Group

// In NewRouter (router.go), inside the RequireAuth group:
r.Group(func(r chi.Router) {
    r.Use(auth.RequireAuth)
    r.Get("/", TablosListHandler(tabloDeps))        // replaces IndexHandler
    r.Post("/logout", LogoutHandler(deps))
    // New tablo routes:
    r.Get("/tablos/new", TablosNewHandler(tabloDeps))
    r.Post("/tablos", TablosCreateHandler(tabloDeps))
    r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps))
    r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps))
    r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps))
    r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps))
    r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps))
    r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps))
    r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
    r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
    r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))
})

Note: chi routes are matched in declaration order. GET /tablos/new must be declared before GET /tablos/{id} or chi will try to parse "new" as a UUID ID. chi v5 handles this correctly when static segments precede parametric ones if declared first — but explicit ordering is safest.

[VERIFIED: chi v5 routing behavior — static routes take precedence over parametric routes at the same depth]

Pattern 8: Non-HTMX Form Fallback

Every mutating form includes method="POST" + action="/tablos" so it works without JS. The handler checks HX-Request and falls back to 303 redirect:

// Non-HTMX create success:
http.Redirect(w, r, "/", http.StatusSeeOther)

[VERIFIED: backend/internal/web/handlers_auth.go — established pattern throughout]

Anti-Patterns to Avoid

  • hx-delete on forms: HTML forms only support GET/POST. Use POST /tablos/{id}/delete for delete (non-HTMX compatible). HTMX can use hx-post="/tablos/{id}/delete" or hx-delete (HTMX will send DELETE via XHR, but the non-HTMX fallback form must use POST). Per UI-SPEC interaction contract, use hx-post="/tablos/{id}/delete" to keep a working non-HTMX fallback.
  • r.Body instead of r.PostFormValue: gorilla/csrf consumes the body. Always use r.PostFormValue() (established in Phase 2, comment: "Pitfall 1").
  • Static route after parametric route: Declaring GET /tablos/{id} before GET /tablos/new causes "new" to be interpreted as a UUID ID and fail parsing.
  • CSS nesting in ui/*.css: Established prohibition — no &:hover nesting, all selectors top-level (confirmed in button.css comment).
  • CDN HTMX: Script served from /static/htmx.min.js, never CDN (layout.templ locked pattern).

Don't Hand-Roll

Problem Don't Build Use Instead Why
CSRF token injection Custom hidden field logic @ui.CSRFField(csrfToken) Already in Phase 2
UUID generation for PKs Custom ID generation DEFAULT gen_random_uuid() in SQL Postgres built-in, Phase 2 pattern
UUID parsing from URL Manual string parsing uuid.Parse(chi.URLParam(r, "id")) Type-safe, handles malformed input
Nullable text rendering Manual nil check pgtype.Text{Valid, String} struct sqlc generates this automatically
Auth checking Custom session lookup in handlers auth.Authed(r.Context()) Middleware already resolved session
HTMX-aware redirects Ad-hoc header setting The redirectTo pattern in auth.middleware.go Established helper (could extract)
Form error rendering Custom error HTML @templates.FieldError(msg) + @templates.GeneralError(msg) Phase 2 established components
Button danger/neutral CSS Inline styles ui-button-solid-danger-md + ui-button-soft-neutral-md CSS classes New but specified in UI-SPEC

Key insight: This phase reuses nearly all established infrastructure. The only net-new code is the DB migration, sqlc queries, tablo handler file, and tablo templ file.


Common Pitfalls

Pitfall 1: /tablos/new Parsed as a UUID ID

What goes wrong: GET /tablos/new matches GET /tablos/{id} if the parametric route is registered first. chi tries uuid.Parse("new") → fails → 404 or panic.

Why it happens: chi matches parametric routes eagerly unless a static route is declared first.

How to avoid: Register r.Get("/tablos/new", ...) before r.Get("/tablos/{id}", ...) in the route group.

Warning signs: GET /tablos/new returns 404 or a UUID parse error log.

Pitfall 2: r.Body vs r.PostFormValue with gorilla/csrf

What goes wrong: gorilla/csrf reads r.Body to extract the _csrf field. If the handler calls r.Body.Read() or io.ReadAll(r.Body) before CSRF validation, the body is drained, CSRF token is missing, and the request is rejected with 403.

Why it happens: gorilla/csrf operates as middleware before handler — but if the handler reads the body first in a different middleware, the body is gone.

How to avoid: Always use r.PostFormValue("field") — it calls r.ParseForm() which caches the parsed form, so repeated reads are safe.

Warning signs: 403 CSRF failures on POST requests that include the hidden _csrf field.

Pitfall 3: Missing templ generate After Editing .templ Files

What goes wrong: Go compiler sees the old *_templ.go generated file. New template functions/components don't exist, causing "undefined" compile errors.

Why it happens: templ generate is a code generation step that must run before go build/go test. just generate or just dev runs it, but manual go test doesn't.

How to avoid: Run just generate (or just dev) before any Go compile step. The just test recipe runs just generate first.

Warning signs: undefined: templates.TablosDashboard compile error even though the .templ file exists.

Pitfall 4: Tailwind Class Discovery for New CSS Files

What goes wrong: New CSS classes in backend/internal/web/ui/button.css (the danger/neutral variants) are not emitted in static/tailwind.css because Tailwind's @source scanning only covers .templ and .go files, not .css files referenced via @import.

Why it happens: tailwind.input.css uses @import "./internal/web/ui/button.css" — these static CSS rules are included by PostCSS/Tailwind as-is. The new .ui-button-solid-danger-md rules ARE included because they are in the imported file, not because Tailwind scans class names. This is actually fine — imported CSS files pass through verbatim.

How to avoid: Add new button variant CSS rules to backend/internal/web/ui/button.css directly. They will be included in the output via the @import. Re-run just generate (which rebuilds Tailwind) after modifying the CSS.

Warning signs: Buttons render unstyled. Check that just generate was run and static/tailwind.css contains the new class rules.

Pitfall 5: hx-swap-oob Element Must Be Top-Level in Response Body

What goes wrong: An out-of-band <div id="create-form-slot" hx-swap-oob="true"> nested inside another element is not processed by HTMX as OOB — it must be a direct sibling at the top level of the response body.

Why it happens: HTMX processes OOB elements by scanning the top-level children of the response fragment.

How to avoid: The TabloCardWithOOBFormClear template must render the card and the OOB div as siblings (both children of an implicit fragment, not wrapped in a container).

Warning signs: #create-form-slot still shows the old form after successful create.

Pitfall 6: pgtype.Text Null Check in Templates

What goes wrong: Passing tablo.Description.String to a template when tablo.Description.Valid is false renders an empty string — usually harmless, but could show empty description elements.

How to avoid: In templates, check .Valid before rendering description/color:

if tablo.Description.Valid && tablo.Description.String != "" {
    <p class="mt-2 text-base text-slate-600">{ tablo.Description.String }</p>
}

Warning signs: Empty <p> tags rendered for tablos with no description.

Pitfall 7: updated_at Not Updated on Edit

What goes wrong: sqlc generates the UPDATE query exactly as written. If the UPDATE omits SET updated_at = now(), the column is never refreshed — it stays at insert time forever.

How to avoid: Include updated_at = now() explicitly in the UPDATE SQL query. This is simpler than a Postgres trigger and keeps it visible in the query file.


Code Examples

Migration — 0003_tablos.sql

-- migrations/0003_tablos.sql
-- Phase 3: Tablos CRUD

-- +goose Up
CREATE TABLE tablos (
    id          uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     uuid        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title       text        NOT NULL,
    description text,
    color       text,
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX tablos_user_id_idx ON tablos(user_id);

-- +goose Down
DROP TABLE IF EXISTS tablos;

[VERIFIED: CONTEXT.md D-01/D-02, matches backend/migrations/0002_auth.sql style]

sqlc Queries — tablos.sql

-- internal/db/queries/tablos.sql

-- name: ListTablosByUser :many
SELECT id, user_id, title, description, color, created_at, updated_at
FROM tablos
WHERE user_id = $1
ORDER BY created_at DESC;

-- name: GetTabloByID :one
SELECT id, user_id, title, description, color, created_at, updated_at
FROM tablos
WHERE id = $1;

-- name: InsertTablo :one
INSERT INTO tablos (user_id, title, description, color)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, title, description, color, created_at, updated_at;

-- name: UpdateTablo :one
UPDATE tablos
SET title = $2, description = $3, updated_at = now()
WHERE id = $1
RETURNING id, user_id, title, description, color, created_at, updated_at;

-- name: DeleteTablo :exec
DELETE FROM tablos WHERE id = $1;

Note: color is stored but not editable in Phase 3 UI (user can submit it in the create form as plain text). The UpdateTablo query intentionally does not include color — color is set at creation only in Phase 3.

[VERIFIED: CONTEXT.md D-02/D-03, matches backend/internal/db/queries/users.sql style]

Template Structure — tablos.templ (abbreviated)

// Source: backend/templates/tablos.templ (new file)
package templates

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

// TablosDashboard renders the full dashboard (GET /).
templ TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) {
    @Layout("Tablos — Xtablo", user, csrfToken) {
        <div class="flex items-center justify-between mb-6">
            <h1 class="text-[28px] font-semibold leading-tight">Your Tablos</h1>
            @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",
                },
            })
        </div>
        <div id="create-form-slot"></div>
        <div id="tablos-list">
            if len(tablos) == 0 {
                @TablosEmptyState()
            } else {
                for _, t := range tablos {
                    @TabloCard(t, csrfToken)
                }
            }
        </div>
    }
}

[VERIFIED: CONTEXT.md D-05, UI-SPEC interaction contract]


Integration Points

Files That Change in Phase 3

File Change Notes
backend/migrations/0003_tablos.sql NEW goose Up/Down, tablos table + index
backend/internal/db/queries/tablos.sql NEW 5 sqlc queries
backend/internal/db/sqlc/tablos.sql.go GENERATED Run just generate
backend/internal/db/sqlc/models.go GENERATED Tablo struct added
backend/templates/tablos.templ NEW All tablo templates (~300 LOC)
backend/templates/tablos_templ.go GENERATED Run just generate
backend/internal/web/handlers_tablos.go NEW TablosDeps + 11 handler funcs
backend/internal/web/router.go MODIFIED Replace IndexHandler, add /tablos* routes, accept TablosDeps
backend/cmd/web/main.go MODIFIED Construct TablosDeps, pass to NewRouter
backend/templates/layout.templ MODIFIED Footer text → "Phase 3 · Tablos"
backend/templates/index.templ DELETED or emptied Dashboard moves to tablos.templ
backend/internal/web/ui/button.css MODIFIED Add danger + neutral button variants
backend/tailwind.input.css UNCHANGED Already imports button.css
backend/internal/tablos/doc.go UNCHANGED Placeholder stays; no impl moves there

NewRouter Signature Change

NewRouter currently accepts AuthDeps. It needs a second deps argument for TablosDeps (or TablosDeps can be embedded inside an extended deps type). The cleanest approach matching the pattern is a separate TablosDeps parameter:

func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler

cmd/web/main.go constructs TablosDeps{Queries: q} alongside AuthDeps.

[VERIFIED: backend/internal/web/router.go, backend/cmd/web/main.go]


State of the Art

Old Approach Current Approach When Changed Impact
hx-confirm (browser confirm()) Inline server-rendered confirmation fragment Phase 3 design decision (D-07) No JS required; server controls copy
Modal dialogs for CRUD Inline HTMX fragment swaps Phase 3 design decision (D-05/D-06/D-07) No new JS/modal components
Soft delete (deleted_at) Hard delete Phase 3 design decision (D-01) Simpler queries, irreversible

Deprecated in this codebase:

  • templates.Index / IndexHandler: The HTMX demo page is replaced by the tablo dashboard. The Phase 1 demo routes (/demo/time) remain for now.

Validation Architecture

Test Framework

Property Value
Framework Go standard testing package + net/http/httptest
Config file none — go test ./... from backend/
Quick run command go test ./internal/web/... -run TestTablo
Full suite command just test (runs just generate first, then go test ./...)

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
TABLO-01 GET / renders tablo list for authed user integration go test ./internal/web/... -run TestTabloList Wave 0
TABLO-01 GET / returns empty state when no tablos integration go test ./internal/web/... -run TestTabloList_Empty Wave 0
TABLO-02 POST /tablos inserts row and returns card fragment integration go test ./internal/web/... -run TestTabloCreate Wave 0
TABLO-02 POST /tablos with empty title returns 422 + form errors integration go test ./internal/web/... -run TestTabloCreate_Validation Wave 0
TABLO-03 GET /tablos/{id} renders detail for owner integration go test ./internal/web/... -run TestTabloDetail_Owner Wave 0
TABLO-03 GET /tablos/{id} returns 404 for non-owner integration go test ./internal/web/... -run TestTabloDetail_NonOwner Wave 0
TABLO-03 GET /tablos/{id} returns 404 for invalid UUID integration go test ./internal/web/... -run TestTabloDetail_InvalidID Wave 0
TABLO-04 POST /tablos/{id} updates title and returns display fragment integration go test ./internal/web/... -run TestTabloUpdate Wave 0
TABLO-05 POST /tablos/{id}/delete removes tablo integration go test ./internal/web/... -run TestTabloDelete Wave 0
TABLO-05 GET /tablos/{id}/delete-confirm returns confirm fragment integration go test ./internal/web/... -run TestTabloDeleteConfirm Wave 0
TABLO-06 HTMX create returns fragment (not full page) integration part of TestTabloCreate Wave 0
TABLO-06 Non-HTMX create POSTs and redirects 303 integration part of TestTabloCreate Wave 0

Sampling Rate

  • Per task commit: go test ./internal/web/... -run TestTablo
  • Per wave merge: just test
  • Phase gate: Full suite green before /gsd-verify-work

Wave 0 Gaps

  • backend/internal/web/handlers_tablos_test.go — integration tests for all TABLO-01..06 paths (follows handlers_auth_test.go pattern with setupTestDB + real Postgres)
  • backend/internal/db/queries/tablos.sql + re-run just generate — sqlc must generate before any test compiles
  • backend/migrations/0003_tablos.sql — test DB setup runs goose.Up which picks up this file automatically

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes (inherited) gorilla/csrf + session cookie from Phase 2
V3 Session Management yes (inherited) auth.ResolveSession + RequireAuth from Phase 2
V4 Access Control yes Ownership check in every tablo handler — 404 for non-owner
V5 Input Validation yes Server-side validation: title required, max 255 chars; color validated if provided
V6 Cryptography no new requirements UUID PKs are gen_random_uuid() (crypto-random)

Known Threat Patterns for This Stack

Pattern STRIDE Standard Mitigation
Unauthorized tablo access Elevation of privilege auth.Authed(ctx) + tablo.UserID != user.ID → 404
CSRF on state-changing forms Tampering @ui.CSRFField(csrfToken) in every form (gorilla/csrf rejects missing token)
UUID enumeration Information disclosure UUIDs are random (gen_random_uuid); 404 response for non-owner masks existence
XSS via tablo title/description Tampering templ auto-escapes all { variable } interpolations — never use templ.Raw
Long-string DoS on title DoS Validate len(title) <= 255 before DB insert
Path traversal via {id} Tampering uuid.Parse() rejects non-UUID strings; parametric route safely bounded

Environment Availability

Phase 3 is code/migration/template changes only. All runtime dependencies were established in Phase 2:

Dependency Required By Available Version Fallback
Postgres DB queries (local dev via podman compose)
templ CLI Template generation v0.3.1020 (go install)
sqlc CLI Query generation v1.31.1 (go install)
goose CLI Migrations v3.27.1 (go install)
Tailwind standalone CSS rebuild v4.0.0 (bin/tailwindcss)

[VERIFIED: backend/justfile — all tools pinned and installed via just bootstrap]


Assumptions Log

# Claim Section Risk if Wrong
A1 GET /tablos/new static route takes precedence over GET /tablos/{id} parametric in chi v5 when declared first Architecture Patterns, Pitfall 1 If wrong: "new" is parsed as a UUID, returns 404. Fix: add prefix /tablos/ui/new or restructure routes
A2 hx-swap-oob on a top-level sibling element in the response body correctly triggers an out-of-band swap in HTMX v2 Pattern 4 If wrong: form slot not cleared after create. Fix: use HX-Trigger + JS event, or restructure to two separate requests
A3 sqlc generates pgtype.Text (not *string) for nullable text columns when using pgx/v5 SQL package Standard Stack If wrong: nullable fields have different type; template code needs adjustment

Risk assessment: A1 and A3 are LOW risk — chi static-before-parametric ordering is well-documented, and pgtype.Text for nullable text is confirmed by existing models.go pattern (pgtype.Timestamptz). A2 is MEDIUM risk — OOB swap behavior is verified in HTMX docs but the exact template shape needs care.


Open Questions (RESOLVED)

  1. RESOLVED: IndexHandler vs TablosListHandler for GET /

    • What we know: Phase 1/2 registered IndexHandler for GET / which renders the HTMX demo placeholder. CONTEXT.md says index.templ "transforms into the tablo dashboard."
    • What's unclear: Whether to delete index.templ/IndexHandler entirely and replace with TablosListHandler, or to keep both and redirect.
    • Recommendation: Delete templates/Index and handlers.go's IndexHandler, replace GET / registration in router.go with TablosListHandler(tabloDeps). The HTMX demo (/demo/time) can remain for Phase 3.
  2. RESOLVED: _method override for edit vs plain POST /tablos/{id}/edit

    • What we know: CONTEXT.md marks this as Claude's discretion. chi supports _method override via chimw.MethodOverride middleware if added.
    • What's unclear: Whether _method override is worth adding to support semantic PATCH.
    • Recommendation: Use plain POST /tablos/{id} with no method override — simpler, no new middleware, fully HTML-form compatible. The route can infer "update" from the POST + path. The UI-SPEC already specifies hx-post="/tablos/{id}".
  3. RESOLVED: Color field validation

    • What we know: color is text nullable. CONTEXT.md says "validation at planner's discretion."
    • What's unclear: Whether to validate hex format or Tailwind class names.
    • Recommendation: Accept any non-empty string up to 32 chars. Render as inline style background-color: {{ color }} with the dot element. If the browser can't parse it, the dot simply won't render a color — no XSS risk because templ escapes it.

Sources

Primary (HIGH confidence)

  • backend/internal/web/handlers_auth.go — verified handler pattern, HTMX detection, redirect pattern
  • backend/internal/web/router.go — verified route group structure
  • backend/internal/web/ui/*.templ, *.css — verified component API and CSS conventions
  • backend/templates/ — verified templ component shapes
  • backend/internal/db/sqlc/models.go — verified pgtype usage pattern
  • backend/migrations/0002_auth.sql — verified migration style
  • backend/internal/db/queries/users.sql — verified sqlc query style
  • .planning/phases/03-tablos-crud/03-CONTEXT.md — locked user decisions
  • .planning/phases/03-tablos-crud/03-UI-SPEC.md — UI contract and interaction specs
  • Context7 /bigskysoftware/htmx — verified HX-Retarget, HX-Reswap, hx-swap-oob behavior

Secondary (MEDIUM confidence)

  • Context7 /websites/templ_guide — templ attribute and children syntax patterns
  • Context7 /bigskysoftware/htmx — hx-swap outerHTML, afterbegin, OOB swap patterns

Metadata

Confidence breakdown:

  • Standard Stack: HIGH — all versions verified in go.mod and justfile
  • Architecture: HIGH — all patterns derived from existing codebase
  • HTMX dual-swap: MEDIUM — OOB pattern verified in docs, template shape is new implementation
  • Pitfalls: HIGH — all derived from existing code comments and Phase 2 pitfall list

Research date: 2026-05-14 Valid until: 2026-07-14 (stable stack, 60-day window)