- **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 `POST`s 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.
| 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.
```go
// 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)
│ ├── 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.
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.
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:
```go
// 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 |
| 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:
```templ
if tablo.Description.Valid && tablo.Description.String != "" {
**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
```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);
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.
| `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:
| 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
[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.
- 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.
- 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}"`.
- 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.