703 lines
21 KiB
Markdown
703 lines
21 KiB
Markdown
|
|
# Phase 3: Tablos CRUD - Pattern Map
|
||
|
|
|
||
|
|
**Mapped:** 2026-05-14
|
||
|
|
**Files analyzed:** 9 new/modified files
|
||
|
|
**Analogs found:** 9 / 9
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Classification
|
||
|
|
|
||
|
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||
|
|
|-------------------|------|-----------|----------------|---------------|
|
||
|
|
| `backend/migrations/0003_tablos.sql` | migration | batch | `backend/migrations/0002_auth.sql` | exact |
|
||
|
|
| `backend/internal/db/queries/tablos.sql` | query | CRUD | `backend/internal/db/queries/users.sql` | role-match |
|
||
|
|
| `backend/internal/web/handlers_tablos.go` | handler | request-response | `backend/internal/web/handlers_auth.go` | exact |
|
||
|
|
| `backend/templates/tablos.templ` | component | request-response | `backend/templates/auth_signup.templ` + `backend/templates/index.templ` | exact |
|
||
|
|
| `backend/internal/web/router.go` | config | request-response | `backend/internal/web/router.go` (self — modify) | self |
|
||
|
|
| `backend/cmd/web/main.go` | config | request-response | `backend/cmd/web/main.go` (self — modify) | self |
|
||
|
|
| `backend/templates/layout.templ` | component | request-response | `backend/templates/layout.templ` (self — modify footer) | self |
|
||
|
|
| `backend/templates/index.templ` | component | request-response | `backend/templates/index.templ` (self — delete or empty) | self |
|
||
|
|
| `backend/internal/web/ui/button.css` | config | — | `backend/internal/web/ui/button.css` (self — add variants) | self |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Pattern Assignments
|
||
|
|
|
||
|
|
### `backend/migrations/0003_tablos.sql` (migration, batch)
|
||
|
|
|
||
|
|
**Analog:** `backend/migrations/0002_auth.sql`
|
||
|
|
|
||
|
|
**Header comment and goose Up/Down pattern** (lines 1-30 of analog):
|
||
|
|
```sql
|
||
|
|
-- migrations/0002_auth.sql
|
||
|
|
-- Phase 2: Authentication — users + sessions tables.
|
||
|
|
|
||
|
|
-- +goose Up
|
||
|
|
CREATE TABLE users (
|
||
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
email citext NOT NULL UNIQUE,
|
||
|
|
password_hash text NOT NULL,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
|
||
|
|
|
||
|
|
-- +goose Down
|
||
|
|
DROP TABLE IF EXISTS sessions;
|
||
|
|
DROP TABLE IF EXISTS users;
|
||
|
|
-- citext + pgcrypto left in place — extensions are cheap and shared across migrations.
|
||
|
|
```
|
||
|
|
|
||
|
|
**Copy conventions:**
|
||
|
|
- Header comment: `-- migrations/NNNN_name.sql` then `-- Phase N: Description`
|
||
|
|
- UUID PKs via `DEFAULT gen_random_uuid()` (no import needed — pgcrypto loaded in 0002)
|
||
|
|
- `timestamptz NOT NULL DEFAULT now()` for all timestamp columns
|
||
|
|
- `ON DELETE CASCADE` for FK to users(id)
|
||
|
|
- Named index pattern: `tablename_columnname_idx`
|
||
|
|
- `-- +goose Down` drops tables in reverse dependency order
|
||
|
|
- Comment on retained extensions at Down block end
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### `backend/internal/db/queries/tablos.sql` (query, CRUD)
|
||
|
|
|
||
|
|
**Analog:** `backend/internal/db/queries/users.sql`
|
||
|
|
|
||
|
|
**Full analog file** (lines 1-10):
|
||
|
|
```sql
|
||
|
|
-- name: InsertUser :one
|
||
|
|
INSERT INTO users (email, password_hash)
|
||
|
|
VALUES ($1, $2)
|
||
|
|
RETURNING id, email, password_hash, created_at, updated_at;
|
||
|
|
|
||
|
|
-- name: GetUserByEmail :one
|
||
|
|
SELECT id, email, password_hash, created_at, updated_at
|
||
|
|
FROM users
|
||
|
|
WHERE email = $1;
|
||
|
|
```
|
||
|
|
|
||
|
|
**Copy conventions:**
|
||
|
|
- `-- name: FuncName :one/:many/:exec` annotation on every query (sqlc requires it)
|
||
|
|
- Explicit column list in SELECT (no `SELECT *`)
|
||
|
|
- `RETURNING` on INSERT/UPDATE to get the full row back without a second query
|
||
|
|
- `$1, $2, ...` positional params (pgx/v5 style)
|
||
|
|
- `:exec` for DELETE (no return value needed)
|
||
|
|
- `:one` for INSERT/UPDATE RETURNING + single-row SELECT
|
||
|
|
- `:many` for list queries
|
||
|
|
|
||
|
|
**Note on nullable columns:** `description text` and `color text` are nullable. sqlc with pgx/v5 generates `pgtype.Text` for these. Templates must check `.Valid` before using `.String`.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### `backend/internal/web/handlers_tablos.go` (handler, request-response)
|
||
|
|
|
||
|
|
**Analog:** `backend/internal/web/handlers_auth.go`
|
||
|
|
|
||
|
|
**Imports pattern** (lines 1-18):
|
||
|
|
```go
|
||
|
|
package web
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"log/slog"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/mail"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"backend/internal/auth"
|
||
|
|
"backend/internal/db/sqlc"
|
||
|
|
"backend/templates"
|
||
|
|
|
||
|
|
"github.com/gorilla/csrf"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
"github.com/jackc/pgx/v5/pgconn"
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Deps struct pattern** (lines 25-30):
|
||
|
|
```go
|
||
|
|
// AuthDeps holds the dependencies shared by all auth handlers.
|
||
|
|
type AuthDeps struct {
|
||
|
|
Queries *sqlc.Queries
|
||
|
|
Store *auth.Store
|
||
|
|
Secure bool
|
||
|
|
Limiter *auth.LimiterStore
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
For tablos, copy this as:
|
||
|
|
```go
|
||
|
|
// TablosDeps holds dependencies for all tablo handlers.
|
||
|
|
type TablosDeps struct {
|
||
|
|
Queries *sqlc.Queries
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Handler constructor pattern** (lines 50-55):
|
||
|
|
```go
|
||
|
|
// SignupPageHandler renders the GET /signup page with an empty form.
|
||
|
|
func SignupPageHandler() http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
|
|
_ = templates.SignupPage(templates.SignupForm{}, templates.SignupErrors{}, csrf.Token(r)).Render(r.Context(), w)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Every handler returns `http.HandlerFunc` from a constructor that closes over the deps struct.
|
||
|
|
|
||
|
|
**HTMX-aware redirect pattern** (lines 137-143):
|
||
|
|
```go
|
||
|
|
// HTMX form submissions receive HX-Redirect so HTMX handles navigation client-side.
|
||
|
|
// Plain (no-JS) form submissions receive 303 See Other (NOT 302 — Pitfall 9).
|
||
|
|
if r.Header.Get("HX-Request") == "true" {
|
||
|
|
w.Header().Set("HX-Redirect", "/")
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Fragment vs full-page dispatch pattern** (lines 149-157):
|
||
|
|
```go
|
||
|
|
func renderSignupError(w http.ResponseWriter, r *http.Request, form templates.SignupForm, errs templates.SignupErrors, status int) {
|
||
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
if r.Header.Get("HX-Request") == "true" {
|
||
|
|
_ = templates.SignupFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
|
||
|
|
} else {
|
||
|
|
_ = templates.SignupPage(form, errs, csrf.Token(r)).Render(r.Context(), w)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Form value reading** (lines 71-72 and 188-189):
|
||
|
|
```go
|
||
|
|
// Always r.PostFormValue, never r.Body — gorilla/csrf consumes the body.
|
||
|
|
email := strings.TrimSpace(r.PostFormValue("email"))
|
||
|
|
password := r.PostFormValue("password")
|
||
|
|
```
|
||
|
|
|
||
|
|
**pgx.ErrNoRows handling** (lines 227-231):
|
||
|
|
```go
|
||
|
|
user, err := deps.Queries.GetUserByEmail(ctx, normalized)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
// handle not found
|
||
|
|
return
|
||
|
|
}
|
||
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**auth.Authed usage** (lines 248-251):
|
||
|
|
```go
|
||
|
|
// Extract authenticated session — RequireAuth middleware guarantees this is set.
|
||
|
|
sess, _, ok := auth.Authed(r.Context())
|
||
|
|
if !ok {
|
||
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
For tablo handlers the three-value form is:
|
||
|
|
```go
|
||
|
|
_, user, _ := auth.Authed(r.Context())
|
||
|
|
// user is guaranteed non-nil inside RequireAuth-gated routes
|
||
|
|
```
|
||
|
|
|
||
|
|
**Ownership check pattern** (new for Phase 3 — derived from D-04):
|
||
|
|
```go
|
||
|
|
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
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**slog error logging pattern** (lines 306-310):
|
||
|
|
```go
|
||
|
|
if err := deps.Store.Delete(r.Context(), sess.ID); err != nil {
|
||
|
|
slog.Default().Error("logout: delete session", "session_id", sess.ID, "err", err)
|
||
|
|
// Continue — partial invalidation is better than leaving state intact.
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### `backend/templates/tablos.templ` (component, request-response)
|
||
|
|
|
||
|
|
**Analog:** `backend/templates/auth_signup.templ` (form + fragment pattern) and `backend/templates/index.templ` (dashboard page with Card + Button + HTMX attrs)
|
||
|
|
|
||
|
|
**Package declaration and imports** (auth_signup.templ lines 1-3, index.templ lines 1-8):
|
||
|
|
```go
|
||
|
|
package templates
|
||
|
|
|
||
|
|
import (
|
||
|
|
"backend/internal/auth"
|
||
|
|
"backend/internal/db/sqlc"
|
||
|
|
"backend/internal/web/ui"
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Full-page template wrapping Layout** (auth_signup.templ lines 8-19):
|
||
|
|
```go
|
||
|
|
templ SignupPage(form SignupForm, errs SignupErrors, csrfToken string) {
|
||
|
|
@Layout("Sign up", nil, csrfToken) {
|
||
|
|
<div class="flex min-h-[60vh] items-start justify-center pt-16">
|
||
|
|
@ui.Card(nil) {
|
||
|
|
<div class="w-full max-w-sm px-6 py-8">
|
||
|
|
<h1 class="mb-6 text-2xl font-semibold">Create your account</h1>
|
||
|
|
@SignupFormFragment(form, errs, csrfToken)
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Fragment component with HTMX form** (auth_signup.templ lines 25-72):
|
||
|
|
```go
|
||
|
|
templ SignupFormFragment(form SignupForm, errs SignupErrors, csrfToken string) {
|
||
|
|
<form
|
||
|
|
id="signup-form"
|
||
|
|
method="POST"
|
||
|
|
action="/signup"
|
||
|
|
hx-post="/signup"
|
||
|
|
hx-target="#signup-form"
|
||
|
|
hx-swap="outerHTML"
|
||
|
|
class="space-y-5"
|
||
|
|
>
|
||
|
|
@ui.CSRFField(csrfToken)
|
||
|
|
@GeneralError(errs.General)
|
||
|
|
...
|
||
|
|
@ui.Button(ui.ButtonProps{
|
||
|
|
Label: "Create account",
|
||
|
|
Variant: ui.ButtonVariantDefault,
|
||
|
|
Tone: ui.ButtonToneSolid,
|
||
|
|
Size: ui.SizeMD,
|
||
|
|
Type: "submit",
|
||
|
|
})
|
||
|
|
</form>
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Button with HTMX attributes** (index.templ lines 27-39):
|
||
|
|
```go
|
||
|
|
@ui.Button(ui.ButtonProps{
|
||
|
|
Label: "Fetch server time",
|
||
|
|
Variant: ui.ButtonVariantDefault,
|
||
|
|
Tone: ui.ButtonToneSolid,
|
||
|
|
Size: ui.SizeMD,
|
||
|
|
Type: "button",
|
||
|
|
Attrs: templ.Attributes{
|
||
|
|
"hx-get": "/demo/time",
|
||
|
|
"hx-target": "#demo-out",
|
||
|
|
"hx-swap": "innerHTML",
|
||
|
|
"hx-indicator": "#demo-spinner",
|
||
|
|
},
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
**ui.Card with arbitrary attrs** (index.templ lines 21-22):
|
||
|
|
```go
|
||
|
|
@ui.Card(nil) {
|
||
|
|
// children here
|
||
|
|
}
|
||
|
|
// With attrs:
|
||
|
|
@ui.Card(templ.Attributes{"id": "tablo-123"}) {
|
||
|
|
// children here
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**pgtype.Text null check in templates** (from RESEARCH Pitfall 6):
|
||
|
|
```go
|
||
|
|
if tablo.Description.Valid && tablo.Description.String != "" {
|
||
|
|
<p class="mt-2 text-base text-slate-600">{ tablo.Description.String }</p>
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**FieldError and GeneralError** (auth_form_errors.templ lines 5-19):
|
||
|
|
```go
|
||
|
|
templ FieldError(msg string) {
|
||
|
|
if msg != "" {
|
||
|
|
<p class="mt-1 text-sm text-red-700">{ msg }</p>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
templ GeneralError(msg string) {
|
||
|
|
if msg != "" {
|
||
|
|
<div class="mb-4 rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||
|
|
{ msg }
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**OOB swap template shape** (from RESEARCH Pattern 4 — new pattern, no existing analog):
|
||
|
|
```go
|
||
|
|
// TabloCardWithOOBFormClear renders a card plus an OOB element to clear #create-form-slot.
|
||
|
|
// The OOB div MUST be a top-level sibling of the card, not nested (RESEARCH Pitfall 5).
|
||
|
|
templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
|
||
|
|
@TabloCard(tablo, csrfToken)
|
||
|
|
<div id="create-form-slot" hx-swap-oob="true"></div>
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### `backend/internal/web/router.go` (config, request-response — MODIFIED)
|
||
|
|
|
||
|
|
**Analog:** Self. Current file `backend/internal/web/router.go`.
|
||
|
|
|
||
|
|
**Current protected group pattern** (lines 74-78):
|
||
|
|
```go
|
||
|
|
// Protected routes — require an authenticated session (D-23, AUTH-05).
|
||
|
|
r.Group(func(r chi.Router) {
|
||
|
|
r.Use(auth.RequireAuth)
|
||
|
|
r.Get("/", IndexHandler())
|
||
|
|
r.Post("/logout", LogoutHandler(deps))
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
**NewRouter signature pattern** (line 47):
|
||
|
|
```go
|
||
|
|
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
|
||
|
|
```
|
||
|
|
|
||
|
|
**What to add — NewRouter signature change:**
|
||
|
|
```go
|
||
|
|
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
|
||
|
|
```
|
||
|
|
|
||
|
|
**What to add — protected group extension:**
|
||
|
|
```go
|
||
|
|
r.Group(func(r chi.Router) {
|
||
|
|
r.Use(auth.RequireAuth)
|
||
|
|
r.Get("/", TablosListHandler(tabloDeps)) // replaces IndexHandler
|
||
|
|
r.Post("/logout", LogoutHandler(deps))
|
||
|
|
// Static segment BEFORE parametric — chi static takes precedence when declared first
|
||
|
|
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))
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
**Route ordering rule:** `GET /tablos/new` MUST be declared before `GET /tablos/{id}`. chi v5 resolves static segments before parametric at the same depth, but explicit declaration order is safest.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### `backend/cmd/web/main.go` (config — MODIFIED)
|
||
|
|
|
||
|
|
**Analog:** Self. Current file `backend/cmd/web/main.go`.
|
||
|
|
|
||
|
|
**Current deps construction and router call** (lines 78-80):
|
||
|
|
```go
|
||
|
|
deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
|
||
|
|
|
||
|
|
router := web.NewRouter(pool, "./static", deps, csrfKey, env)
|
||
|
|
```
|
||
|
|
|
||
|
|
**What to add:**
|
||
|
|
```go
|
||
|
|
deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
|
||
|
|
tabloDeps := web.TablosDeps{Queries: q}
|
||
|
|
|
||
|
|
router := web.NewRouter(pool, "./static", deps, tabloDeps, csrfKey, env)
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: `tabloDeps` shares the same `*sqlc.Queries` instance (`q`) as `deps`. No new DB pool needed.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### `backend/templates/layout.templ` (component — MODIFIED: footer text only)
|
||
|
|
|
||
|
|
**Analog:** Self. Current file `backend/templates/layout.templ`.
|
||
|
|
|
||
|
|
**Current footer** (line 51):
|
||
|
|
```go
|
||
|
|
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
|
||
|
|
Phase 2 · Authentication
|
||
|
|
</footer>
|
||
|
|
```
|
||
|
|
|
||
|
|
**Change to:**
|
||
|
|
```go
|
||
|
|
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
|
||
|
|
Phase 3 · Tablos
|
||
|
|
</footer>
|
||
|
|
```
|
||
|
|
|
||
|
|
No other layout changes. All structural classes, container width, asset paths, and logout form are locked by UI-SPEC.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### `backend/templates/index.templ` (component — DELETED or emptied)
|
||
|
|
|
||
|
|
**Analog:** Self. Current content replaced by tablo dashboard in `tablos.templ`.
|
||
|
|
|
||
|
|
`IndexHandler()` in `handlers.go` (the Phase 1 placeholder) should also be removed or replaced. The `GET /` route in `router.go` is reassigned to `TablosListHandler(tabloDeps)`.
|
||
|
|
|
||
|
|
The HTMX demo (`/demo/time` + `DemoTimeHandler`) stays — it is not removed in Phase 3.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### `backend/internal/web/ui/button.css` (config — MODIFIED: add danger + neutral variants)
|
||
|
|
|
||
|
|
**Analog:** Self. Current file `backend/internal/web/ui/button.css`.
|
||
|
|
|
||
|
|
**Existing variant pattern** (lines 29-48):
|
||
|
|
```css
|
||
|
|
.ui-button-solid-default-md {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
border-radius: 0.375rem;
|
||
|
|
background-color: #2563eb;
|
||
|
|
padding: 0.5rem 1rem;
|
||
|
|
font-size: 1rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: #ffffff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ui-button-solid-default-md:hover {
|
||
|
|
background-color: #1d4ed8;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ui-button-solid-default-md:focus-visible {
|
||
|
|
outline: 2px solid #1d4ed8;
|
||
|
|
outline-offset: 2px;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**CSS conventions to follow (line 3-4 comment):**
|
||
|
|
- No CSS nesting (`&:hover`) — all pseudo-class rules are top-level selectors
|
||
|
|
- Class name format: `.ui-button-{tone}-{variant}-{size}` (matches `ButtonClass()` in variants.go)
|
||
|
|
|
||
|
|
**New classes needed per UI-SPEC:**
|
||
|
|
|
||
|
|
`ui-button-solid-danger-md` — for delete confirmation button (red, solid):
|
||
|
|
```css
|
||
|
|
.ui-button-solid-danger-md {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
border-radius: 0.375rem;
|
||
|
|
background-color: #dc2626;
|
||
|
|
padding: 0.5rem 1rem;
|
||
|
|
font-size: 1rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: #ffffff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ui-button-solid-danger-md:hover {
|
||
|
|
background-color: #b91c1c;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ui-button-solid-danger-md:focus-visible {
|
||
|
|
outline: 2px solid #dc2626;
|
||
|
|
outline-offset: 2px;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`ui-button-soft-neutral-md` — for cancel buttons (ghost-style, muted):
|
||
|
|
```css
|
||
|
|
.ui-button-soft-neutral-md {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
border-radius: 0.375rem;
|
||
|
|
background-color: transparent;
|
||
|
|
border: 1px solid #cbd5e1;
|
||
|
|
padding: 0.5rem 1rem;
|
||
|
|
font-size: 1rem;
|
||
|
|
font-weight: 500;
|
||
|
|
color: #475569;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ui-button-soft-neutral-md:hover {
|
||
|
|
background-color: #f1f5f9;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ui-button-soft-neutral-md:focus-visible {
|
||
|
|
outline: 2px solid #64748b;
|
||
|
|
outline-offset: 2px;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Usage in templ:**
|
||
|
|
```go
|
||
|
|
@ui.Button(ui.ButtonProps{
|
||
|
|
Label: "Delete",
|
||
|
|
Variant: ui.ButtonVariantDanger,
|
||
|
|
Tone: ui.ButtonToneSolid,
|
||
|
|
Size: ui.SizeMD,
|
||
|
|
Type: "submit",
|
||
|
|
})
|
||
|
|
|
||
|
|
@ui.Button(ui.ButtonProps{
|
||
|
|
Label: "Cancel",
|
||
|
|
Variant: ui.ButtonVariantNeutral,
|
||
|
|
Tone: ui.ButtonToneSoft,
|
||
|
|
Size: ui.SizeMD,
|
||
|
|
Type: "button",
|
||
|
|
Attrs: templ.Attributes{
|
||
|
|
"hx-get": "/tablos/{id}/delete-cancel",
|
||
|
|
"hx-target": "#tablo-delete-zone",
|
||
|
|
"hx-swap": "outerHTML",
|
||
|
|
},
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: `ButtonVariantDanger` and `ButtonVariantNeutral` already exist in `variants.go` (lines 20 and 18). `ButtonToneSoft` exists at line 32. The CSS classes are the only missing piece.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Shared Patterns
|
||
|
|
|
||
|
|
### Authentication — `auth.Authed` extraction
|
||
|
|
**Source:** `backend/internal/auth/middleware.go` lines 26-32
|
||
|
|
**Apply to:** All handler functions in `handlers_tablos.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Authed extracts the session and user from the request context.
|
||
|
|
// Returns (session, user, true) when a valid session is present.
|
||
|
|
func Authed(ctx context.Context) (*Session, *User, bool) {
|
||
|
|
a, ok := ctx.Value(sessionKey).(*authed)
|
||
|
|
if !ok || a == nil {
|
||
|
|
return nil, nil, false
|
||
|
|
}
|
||
|
|
return a.Session, a.User, true
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Inside `RequireAuth`-gated routes, the user is always present. Use the short form:
|
||
|
|
```go
|
||
|
|
_, user, _ := auth.Authed(r.Context())
|
||
|
|
```
|
||
|
|
|
||
|
|
### HTMX-aware redirect helper
|
||
|
|
**Source:** `backend/internal/auth/middleware.go` lines 119-126
|
||
|
|
**Apply to:** `TabloDeleteHandler` (post-delete navigation to `/`) and any handler that redirects
|
||
|
|
|
||
|
|
```go
|
||
|
|
func redirectTo(w http.ResponseWriter, r *http.Request, target string) {
|
||
|
|
if r.Header.Get("HX-Request") == "true" {
|
||
|
|
w.Header().Set("HX-Redirect", target)
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
This is an unexported function in the `auth` package. Replicate it inline in handlers or extract a shared helper in the `web` package. Do not add a new dependency on `auth` internals.
|
||
|
|
|
||
|
|
### CSRF token injection
|
||
|
|
**Source:** `backend/internal/web/ui/csrf_field.templ` lines 7-9
|
||
|
|
**Apply to:** Every `<form method="POST">` in `tablos.templ`
|
||
|
|
|
||
|
|
```go
|
||
|
|
templ CSRFField(token string) {
|
||
|
|
<input type="hidden" name="_csrf" value={ token }/>
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Call as `@ui.CSRFField(csrfToken)` as the first child of every POST form.
|
||
|
|
|
||
|
|
### gorilla/csrf token extraction
|
||
|
|
**Source:** `backend/internal/web/handlers_auth.go` (throughout — e.g. line 53)
|
||
|
|
**Apply to:** Every handler that renders a template with a form
|
||
|
|
|
||
|
|
```go
|
||
|
|
csrf.Token(r) // import "github.com/gorilla/csrf"
|
||
|
|
```
|
||
|
|
|
||
|
|
Pass as `csrfToken` parameter to all template constructors.
|
||
|
|
|
||
|
|
### Content-Type header
|
||
|
|
**Source:** `backend/internal/web/handlers_auth.go` lines 52, 162, 150
|
||
|
|
**Apply to:** Every handler that writes HTML
|
||
|
|
|
||
|
|
```go
|
||
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
|
|
```
|
||
|
|
|
||
|
|
Set before calling `.Render()`. For error responses, set before `w.WriteHeader(status)`.
|
||
|
|
|
||
|
|
### UUID param extraction
|
||
|
|
**Source:** RESEARCH.md Pattern 6 (verified against go.mod)
|
||
|
|
**Apply to:** All tablo handlers that accept `{id}` URL param
|
||
|
|
|
||
|
|
```go
|
||
|
|
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
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Dual-target HTMX create response (HX-Retarget + hx-swap-oob)
|
||
|
|
**Source:** RESEARCH.md Pattern 4 (new pattern — no prior codebase analog)
|
||
|
|
**Apply to:** `TablosCreateHandler` success path only
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Successful create: retarget to #tablos-list, prepend card, OOB-clear form slot.
|
||
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
|
|
w.Header().Set("HX-Retarget", "#tablos-list")
|
||
|
|
w.Header().Set("HX-Reswap", "afterbegin")
|
||
|
|
_ = templates.TabloCardWithOOBFormClear(tablo, csrf.Token(r)).Render(r.Context(), w)
|
||
|
|
```
|
||
|
|
|
||
|
|
The OOB element MUST be a top-level sibling in the response body, not nested (RESEARCH Pitfall 5).
|
||
|
|
|
||
|
|
### Input validation via r.PostFormValue
|
||
|
|
**Source:** `backend/internal/web/handlers_auth.go` lines 71-72
|
||
|
|
**Apply to:** All POST handlers in `handlers_tablos.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Pitfall 2: gorilla/csrf consumes r.Body. Always use r.PostFormValue, never
|
||
|
|
// r.Body / io.ReadAll. r.PostFormValue calls r.ParseForm which caches the result.
|
||
|
|
title := strings.TrimSpace(r.PostFormValue("title"))
|
||
|
|
description := r.PostFormValue("description")
|
||
|
|
color := strings.TrimSpace(r.PostFormValue("color"))
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## No Analog Found
|
||
|
|
|
||
|
|
No files are entirely without analog. All new files have at least a role-match or structural analog in the codebase. The only genuinely new patterns (OOB swap, HX-Retarget/HX-Reswap, ownership 404 check, UUID param extraction) are covered in the Shared Patterns section above and documented in RESEARCH.md with verification sources.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Metadata
|
||
|
|
|
||
|
|
**Analog search scope:** `backend/internal/web/`, `backend/templates/`, `backend/migrations/`, `backend/internal/db/queries/`, `backend/internal/auth/`, `backend/cmd/web/`, `backend/internal/web/ui/`
|
||
|
|
**Files read:** 18
|
||
|
|
**Pattern extraction date:** 2026-05-14
|