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>
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_atcolumn. Deletion is irreversible via the UI. - D-02:
tablostable: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 onuser_id. - D-03: Sort order always
ORDER BY created_at DESC. Nopositioncolumn. - 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-getfetches edit fragment; savePOSTs 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}/editvsPATCHwith_methodoverride. updated_atmaintenance: Postgres trigger vs explicitSET 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)
Recommended Project Structure
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:
- The new tablo card (primary body — swapped into
#create-form-slotper the form'shx-target) - 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-deleteon forms: HTML forms only support GET/POST. UsePOST /tablos/{id}/deletefor delete (non-HTMX compatible). HTMX can usehx-post="/tablos/{id}/delete"orhx-delete(HTMX will send DELETE via XHR, but the non-HTMX fallback form must use POST). Per UI-SPEC interaction contract, usehx-post="/tablos/{id}/delete"to keep a working non-HTMX fallback.r.Bodyinstead ofr.PostFormValue: gorilla/csrf consumes the body. Always user.PostFormValue()(established in Phase 2, comment: "Pitfall 1").- Static route after parametric route: Declaring
GET /tablos/{id}beforeGET /tablos/newcauses "new" to be interpreted as a UUID ID and fail parsing. - CSS nesting in ui/*.css: Established prohibition — no
&:hovernesting, 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-runjust generate— sqlc must generate before any test compilesbackend/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)
-
RESOLVED: IndexHandler vs TablosListHandler for GET /
- What we know: Phase 1/2 registered
IndexHandlerforGET /which renders the HTMX demo placeholder. CONTEXT.md saysindex.templ"transforms into the tablo dashboard." - What's unclear: Whether to delete
index.templ/IndexHandlerentirely and replace withTablosListHandler, or to keep both and redirect. - Recommendation: Delete
templates/Indexandhandlers.go'sIndexHandler, replaceGET /registration inrouter.gowithTablosListHandler(tabloDeps). The HTMX demo (/demo/time) can remain for Phase 3.
- What we know: Phase 1/2 registered
-
RESOLVED:
_methodoverride for edit vs plainPOST /tablos/{id}/edit- What we know: CONTEXT.md marks this as Claude's discretion. chi supports
_methodoverride viachimw.MethodOverridemiddleware if added. - What's unclear: Whether
_methodoverride 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 specifieshx-post="/tablos/{id}".
- What we know: CONTEXT.md marks this as Claude's discretion. chi supports
-
RESOLVED: Color field validation
- What we know:
coloristextnullable. 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.
- What we know:
Sources
Primary (HIGH confidence)
backend/internal/web/handlers_auth.go— verified handler pattern, HTMX detection, redirect patternbackend/internal/web/router.go— verified route group structurebackend/internal/web/ui/*.templ,*.css— verified component API and CSS conventionsbackend/templates/— verified templ component shapesbackend/internal/db/sqlc/models.go— verified pgtype usage patternbackend/migrations/0002_auth.sql— verified migration stylebackend/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)