--- phase: 03-tablos-crud plan: 02 type: execute wave: 2 depends_on: [01] files_modified: - backend/internal/web/handlers_tablos.go - backend/templates/tablos.templ - backend/templates/tablos_templ.go - backend/templates/layout.templ - backend/templates/layout_templ.go - backend/templates/index.templ - backend/templates/index_templ.go - backend/internal/web/handlers.go - backend/internal/web/router.go - backend/cmd/web/main.go autonomous: true requirements: - TABLO-01 - TABLO-02 - TABLO-06 tags: - go - htmx - templ - chi - csrf must_haves: truths: - "Signed-in user visiting GET / sees a dashboard titled 'Your Tablos' listing only their tablos newest-first" - "Empty state renders 'No tablos yet' + 'Create your first tablo to get started.' when user has zero tablos" - "Clicking 'New tablo' fetches an inline form into #create-form-slot via hx-get /tablos/new" - "Submitting a valid POST /tablos via HTMX inserts the row, returns 200 with HX-Retarget: #tablos-list + HX-Reswap: afterbegin, prepends a new TabloCard, and clears #create-form-slot via OOB swap" - "Submitting POST /tablos without HTMX inserts the row and 303-redirects to /" - "Empty-title POST returns 422 with 'Title is required.' field error" artifacts: - path: "backend/internal/web/handlers_tablos.go" provides: "TablosDeps, TablosListHandler, TablosNewHandler, TablosCreateHandler" contains: "TablosCreateHandler" - path: "backend/templates/tablos.templ" provides: "TablosDashboard, TablosEmptyState, TabloCard, TabloCreateFormFragment, TabloCardWithOOBFormClear" contains: "TabloCardWithOOBFormClear" - path: "backend/internal/web/router.go" provides: "GET / → TablosListHandler, GET /tablos/new, POST /tablos route registration" contains: "TablosListHandler" - path: "backend/cmd/web/main.go" provides: "Construct TablosDeps and pass to NewRouter" contains: "TablosDeps{Queries: q}" key_links: - from: "backend/internal/web/router.go" to: "TablosCreateHandler" via: "r.Post(\"/tablos\", ...)" pattern: "r\\.Post\\(\"/tablos\"" - from: "backend/templates/tablos.templ" to: "ui.CSRFField" via: "@ui.CSRFField(csrfToken) inside create form" pattern: "ui\\.CSRFField" - from: "TablosCreateHandler" to: "Queries.InsertTablo" via: "sqlc binding" pattern: "InsertTablo" --- First vertical slice of Phase 3: a signed-in user can SEE their tablos on the dashboard and CREATE a new tablo end-to-end via HTMX, with a graceful non-JS POST fallback. Turns TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation green (TABLO-01, TABLO-02, TABLO-06 partially). Purpose: Deliver real user-visible value as the first slice — the dashboard renders, the create flow works, and the user can see a brand-new tablo appear without a full reload. Output: - handlers_tablos.go with TablosDeps + List/New/Create handlers - tablos.templ with dashboard page, empty state, card, create form, OOB-clear template - router.go updated: replace IndexHandler with TablosListHandler; mount /tablos/new and /tablos - main.go constructs TablosDeps - layout.templ footer "Phase 3 · Tablos" - index.templ + IndexHandler removed (or emptied) — dashboard now lives in tablos.templ @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md @.planning/STATE.md @.planning/phases/03-tablos-crud/03-CONTEXT.md @.planning/phases/03-tablos-crud/03-RESEARCH.md @.planning/phases/03-tablos-crud/03-PATTERNS.md @.planning/phases/03-tablos-crud/03-UI-SPEC.md @.planning/phases/03-tablos-crud/03-01-SUMMARY.md @backend/internal/web/handlers_auth.go @backend/internal/web/router.go @backend/cmd/web/main.go @backend/templates/auth_signup.templ @backend/templates/index.templ @backend/templates/layout.templ @backend/internal/web/ui/button.templ @backend/internal/web/ui/card.templ @backend/internal/web/ui/variants.go @backend/internal/db/sqlc/tablos.sql.go TablosDeps (declared in backend/internal/web/handlers_tablos.go): - Queries *sqlc.Queries Handler constructors (signature pattern matches AuthDeps): - TablosListHandler(deps TablosDeps) http.HandlerFunc → GET / - TablosNewHandler(deps TablosDeps) http.HandlerFunc → GET /tablos/new (returns TabloCreateFormFragment) - TablosCreateHandler(deps TablosDeps) http.HandlerFunc → POST /tablos NewRouter new signature (replaces existing): func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler Templates (callable by Plan 03): - TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) — full page - TablosEmptyState() — empty list state - TabloCard(t sqlc.Tablo, csrfToken string) — single card on dashboard; wraps in ui.Card with class "tablo-delete-zone" on delete button container - TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) — inline create form - TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — card + OOB div clearing #create-form-slot Form types (declared in backend/templates/tablos.templ or a sibling tablos_forms.go file, mirroring auth_forms.go): - TabloCreateForm { Title, Description, Color string } - TabloCreateErrors { Title, General string } Task 1: tablos.templ — dashboard, empty state, card, create form, OOB-clear (+ generate) backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go, backend/templates/layout.templ, backend/templates/layout_templ.go backend/templates/auth_signup.templ backend/templates/auth_forms.go backend/templates/auth_form_errors.templ backend/templates/index.templ backend/templates/layout.templ backend/internal/web/ui/card.templ backend/internal/web/ui/button.templ backend/internal/web/ui/variants.go backend/internal/web/ui/csrf_field.templ backend/internal/db/sqlc/models.go (Tablo struct) .planning/phases/03-tablos-crud/03-UI-SPEC.md (Component Inventory, Interaction Contracts §1+§2, Copywriting Contract) .planning/phases/03-tablos-crud/03-PATTERNS.md (tablos.templ analog block + OOB swap template shape) .planning/phases/03-tablos-crud/03-RESEARCH.md (Pattern 4 dual-target swap, Pitfall 5 top-level OOB) - TablosDashboard renders inside @Layout("Tablos — Xtablo", user, csrfToken). - Page heading "Your Tablos" using `text-[28px] font-semibold leading-tight` per UI-SPEC Typography. - "New tablo" button (ui.ButtonVariantDefault, ui.ButtonToneSolid, ui.SizeMD, Type "button") fires hx-get="/tablos/new", hx-target="#create-form-slot", hx-swap="innerHTML". - `
` rendered above `
`. - When tablos slice is empty → @TablosEmptyState() rendered inside #tablos-list. - Empty state copy exactly: heading "No tablos yet", body "Create your first tablo to get started.", CTA button "New tablo" with aria-label "Create your first tablo" and same hx-get="/tablos/new" attrs. - TabloCard renders inside @ui.Card with: title (Heading style `text-xl font-semibold leading-snug`), optional description paragraph only when `tablo.Description.Valid && tablo.Description.String != ""` (Pitfall 6), optional color dot (only when `tablo.Color.Valid && tablo.Color.String != ""`) using `inline-block w-2.5 h-2.5 rounded-full` with inline `style="background-color: {tablo.Color.String}"`, link "View" to `/tablos/{id}`, and a `.tablo-delete-zone` wrapper containing the Delete button. (Plan 03 wires delete-confirm; Plan 02 just needs the zone div + a Delete button stub.) - TabloCreateFormFragment renders a `
` with @ui.CSRFField(csrfToken), @GeneralError(errs.General), labelled Title input (required), Description textarea (3 rows, optional), Color text input (placeholder "#6366f1 or indigo", optional), submit button "Create tablo", cancel link/button that fires `hx-get="/tablos/new/cancel"` OR simpler: clears the slot via hx-get to an endpoint that returns empty — for Phase 02 use a "Cancel" button with `hx-on:click` not allowed (no JS); use a plain anchor `Cancel` that reloads / and resets the slot. - Field error rendering uses @FieldError(errs.Title) directly under the Title input. - TabloCardWithOOBFormClear renders @TabloCard(...) as the primary card AND a top-level sibling `
` (must be siblings, not nested per Pitfall 5). - layout.templ footer changes from "Phase 2 · Authentication" to "Phase 3 · Tablos". Create backend/templates/tablos.templ (package templates) and a sibling backend/templates/tablos_forms.go declaring `type TabloCreateForm struct { Title, Description, Color string }` and `type TabloCreateErrors struct { Title, General string }` (mirrors auth_forms.go). Implement these templ components per the behavior block: - TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) - TablosEmptyState() - TabloCard(t sqlc.Tablo, csrfToken string) — include `
` wrapper around the Delete button. The Delete button for now uses `hx-get="/tablos/{id}/delete-confirm"`, `hx-target="closest .tablo-delete-zone"`, `hx-swap="outerHTML"`, Variant ButtonVariantDanger, Tone ButtonToneSoft, Size SizeMD, Type "button". Plan 03 implements /delete-confirm. - TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) - TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — emit @TabloCard then a `
` as TOP-LEVEL siblings inside the template body (no wrapping container). Imports in tablos.templ: "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui". Use `{ tablo.Title }`, `{ tablo.Description.String }`, etc. — templ auto-escapes (V5 XSS). Modify backend/templates/layout.templ footer text from "Phase 2 · Authentication" → "Phase 3 · Tablos". No other layout edits. Run `just generate` (or `templ generate`) to produce tablos_templ.go and the updated layout_templ.go. Pitfall 3. cd backend && just generate && grep -q "templ TablosDashboard" templates/tablos.templ && grep -q "templ TablosEmptyState" templates/tablos.templ && grep -q "templ TabloCard" templates/tablos.templ && grep -q "templ TabloCreateFormFragment" templates/tablos.templ && grep -q "templ TabloCardWithOOBFormClear" templates/tablos.templ && grep -q "id=\"create-form-slot\" hx-swap-oob=\"true\"" templates/tablos.templ && grep -q "Phase 3 · Tablos" templates/layout.templ && grep -q "Create your first tablo to get started." templates/tablos.templ && grep -q "No tablos yet" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "type TabloCreateForm" templates/tablos_forms.go && go build ./... - File backend/templates/tablos.templ exists with `package templates` and imports "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui". - File contains the strings: `templ TablosDashboard`, `templ TablosEmptyState`, `templ TabloCard`, `templ TabloCreateFormFragment`, `templ TabloCardWithOOBFormClear`. - File contains exact copy strings (UI-SPEC Copywriting Contract): "Your Tablos", "New tablo", "Create a tablo", "Create tablo", "No tablos yet", "Create your first tablo to get started.". - File contains `id="create-form-slot"` and `id="tablos-list"`. - TabloCardWithOOBFormClear emits `
` as a top-level sibling (grep shows it OUTSIDE the @TabloCard call — verify by reading the templ component body). - File contains `class="tablo-delete-zone"` wrapper around the Delete button. - tablos.templ guards description rendering with `if tablo.Description.Valid && tablo.Description.String != ""` and color dot with `if tablo.Color.Valid && tablo.Color.String != ""`. - tablos_forms.go declares `type TabloCreateForm struct` with Title, Description, Color string fields and `type TabloCreateErrors struct` with Title, General string fields. - templates/layout.templ now contains "Phase 3 · Tablos" and does NOT contain "Phase 2 · Authentication". - `just generate` produced tablos_templ.go and refreshed layout_templ.go. - `go build ./...` exits 0. Templates compile, helper form/errors types declared, layout footer updated. Task 2: handlers_tablos.go — TablosDeps + List/New/Create handlers + router/main wiring backend/internal/web/handlers_tablos.go, backend/internal/web/router.go, backend/internal/web/handlers.go, backend/templates/index.templ, backend/templates/index_templ.go, backend/cmd/web/main.go backend/internal/web/handlers_auth.go backend/internal/web/handlers.go backend/internal/web/router.go backend/internal/web/middleware.go backend/cmd/web/main.go backend/internal/db/sqlc/tablos.sql.go backend/internal/db/sqlc/models.go backend/internal/auth/middleware.go (Authed) backend/internal/web/handlers_tablos_test.go (RED tests from Plan 01) .planning/phases/03-tablos-crud/03-PATTERNS.md (handlers_tablos.go analog block + router/main modify blocks) .planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 1-4, 7, 8 + Pitfalls 1, 2, 5, 6) .planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §1 + §2 + Validation copy) - TablosDeps struct holds *sqlc.Queries. - TablosListHandler: GET / — extracts user via auth.Authed(ctx); calls Queries.ListTablosByUser(ctx, user.ID); renders TablosDashboard with the slice (empty slice OK, template handles empty state); Content-Type: text/html; charset=utf-8. - TablosNewHandler: GET /tablos/new — renders TabloCreateFormFragment with zero-value form/errs; HTMX-only intent but works without HX-Request too. - TablosCreateHandler: POST /tablos — reads title/description/color via r.PostFormValue (NOT r.Body — Pitfall 2); validates: title required → "Title is required.", title len > 255 → "Title must be 255 characters or fewer." (UI-SPEC copy). On validation error: status 422, render TabloCreateFormFragment with field errors (HTMX path) or full TablosDashboard with errs (non-HTMX path). Build InsertTabloParams: Title=trimmed title, Description=pgtype.Text{Valid: description != "", String: description}, Color=pgtype.Text{Valid: color != "", String: color}, UserID=user.ID. On success + HX-Request: set HX-Retarget: #tablos-list, HX-Reswap: afterbegin, render TabloCardWithOOBFormClear. On success + non-HTMX: 303 to /. - All three handlers extract *auth.User via `_, user, _ := auth.Authed(r.Context())`. - Router signature changes to NewRouter(pinger, staticDir, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string). - Inside the RequireAuth chi group: GET / → TablosListHandler(tabloDeps), GET /tablos/new → TablosNewHandler(tabloDeps), POST /tablos → TablosCreateHandler(tabloDeps). Static segments declared before parametric (Pitfall 1) — Plan 03 will add /tablos/{id} routes AFTER /tablos/new. - cmd/web/main.go constructs `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter. - Delete the old IndexHandler from handlers.go (and templates.Index in index.templ → emptied or file deleted). DemoTimeHandler stays. If deleting index.templ causes any remaining references to break, leave the file but reduce it to a no-op package declaration; remove IndexHandler from handlers.go either way. - Stub for Plan 03: also register placeholder routes for endpoints the test scaffold exercises to avoid 404 noise during partial green: leave undefined; Plan 03 implements them. Tests for those endpoints stay RED until Plan 03. 1) Replace the Plan 01 stub `type TablosDeps struct { Queries *sqlc.Queries }` (or create) at the top of backend/internal/web/handlers_tablos.go in package web. Add full handler implementations following handlers_auth.go style exactly (constructor returns http.HandlerFunc closing over deps). 2) Implement TablosListHandler, TablosNewHandler, TablosCreateHandler per the behavior block. For nullable insert params use `pgtype.Text{String: s, Valid: s != ""}` (import "github.com/jackc/pgx/v5/pgtype"). For trim/validate, use `strings.TrimSpace`. For error responses use `w.WriteHeader(http.StatusUnprocessableEntity)` + form fragment (HX) or full dashboard (non-HX). For HTMX success use w.Header().Set("HX-Retarget", "#tablos-list") and w.Header().Set("HX-Reswap", "afterbegin"). Status 200 for HTMX success; 303 to "/" otherwise (http.StatusSeeOther — Pitfall 9 from Phase 2 carries over). 3) Edit backend/internal/web/router.go: change NewRouter signature to add `tabloDeps TablosDeps` as the parameter immediately after `deps AuthDeps`. Inside the existing protected group, replace `r.Get("/", IndexHandler())` with `r.Get("/", TablosListHandler(tabloDeps))` and add (in this order — static-before-parametric, Pitfall 1): - r.Get("/tablos/new", TablosNewHandler(tabloDeps)) - r.Post("/tablos", TablosCreateHandler(tabloDeps)) Do NOT yet add /tablos/{id} routes — Plan 03 owns those. 4) Edit backend/cmd/web/main.go: after the existing `deps := web.AuthDeps{...}` line, add `tabloDeps := web.TablosDeps{Queries: q}` and pass it as the new arg to NewRouter (positional, between deps and csrfKey). 5) Remove backend/internal/web/handlers.go IndexHandler. Remove backend/templates/index.templ (delete or reduce to bare `package templates`). Delete backend/templates/index_templ.go if `just generate` does not regenerate it (templ should remove it on next run if the .templ is gone; if leftover, delete manually). 6) Run `just generate` then `just test`. TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation must turn green; tests for endpoints not yet implemented (Plan 03) remain red. cd backend && just generate && grep -q "type TablosDeps struct" internal/web/handlers_tablos.go && grep -q "func TablosListHandler" internal/web/handlers_tablos.go && grep -q "func TablosNewHandler" internal/web/handlers_tablos.go && grep -q "func TablosCreateHandler" internal/web/handlers_tablos.go && grep -q "HX-Retarget" internal/web/handlers_tablos.go && grep -q "HX-Reswap" internal/web/handlers_tablos.go && grep -q "pgtype.Text" internal/web/handlers_tablos.go && grep -q "r.PostFormValue(\"title\")" internal/web/handlers_tablos.go && grep -q "tabloDeps TablosDeps" internal/web/router.go && grep -q "r.Get(\"/tablos/new\"" internal/web/router.go && grep -q "r.Post(\"/tablos\"" internal/web/router.go && grep -q "TablosDeps{Queries: q}" cmd/web/main.go && ! grep -q "IndexHandler" internal/web/handlers.go && go build ./... && go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation" -count=1 - backend/internal/web/handlers_tablos.go declares: `type TablosDeps struct { Queries *sqlc.Queries }`, `func TablosListHandler(deps TablosDeps) http.HandlerFunc`, `func TablosNewHandler(deps TablosDeps) http.HandlerFunc`, `func TablosCreateHandler(deps TablosDeps) http.HandlerFunc`. - handlers_tablos.go uses `r.PostFormValue("title")`, `r.PostFormValue("description")`, `r.PostFormValue("color")` (no r.Body reads). - handlers_tablos.go contains literal "Title is required." and "Title must be 255 characters or fewer." strings. - handlers_tablos.go sets HX-Retarget and HX-Reswap headers in the create-success path. - handlers_tablos.go uses pgtype.Text{...Valid: s != ""...} for nullable Description and Color inserts. - router.go signature reads `func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler`. - router.go protected group contains `r.Get("/", TablosListHandler(tabloDeps))` (no IndexHandler reference), `r.Get("/tablos/new", TablosNewHandler(tabloDeps))` (declared BEFORE any parametric /tablos/{id} route which is added in Plan 03), `r.Post("/tablos", TablosCreateHandler(tabloDeps))`. - cmd/web/main.go contains `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter. - backend/internal/web/handlers.go no longer contains the symbol `IndexHandler` (`grep -c IndexHandler handlers.go` == 0). - `go build ./...` exits 0. - `go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation"` exits 0 (all four pass). - Phase 1/2 tests still pass: `go test ./internal/web/... -run "TestSignup|TestLogin|TestLogout|TestCSRF"` exits 0. Dashboard + create slice green; create-form-slot OOB clear verified; non-HTMX 303 verified. Task 3: Human verify list + create flow Dashboard at GET / showing user's tablos newest-first or empty state. "New tablo" button expands inline form via HTMX; submitting creates a row, clears the form slot, and prepends the new card without a full reload. Non-JS fallback works via plain form POST. 1. cd backend && just dev (web server on local port). 2. Open http://localhost:PORT/login, log in with an existing test account (or sign up). 3. On the dashboard, confirm heading "Your Tablos" and either tablo cards or empty-state text "No tablos yet" + "Create your first tablo to get started.". 4. Click "New tablo" — inline form appears above the list, no full page reload (check DevTools Network panel: only XHR to /tablos/new). 5. Submit form with title "My first tablo" + description "Hello" + color "#6366f1": form collapses, new card appears at top of list, no full reload. 6. Submit form with empty title: form stays open, "Title is required." appears under the field. 7. Open a private window with no JS — submit the form: page does a real POST and redirects to /; new tablo appears in list. 8. View page source: verify CSRF token is rendered in the form's hidden _csrf field. Type "approved" if the dashboard + create flow works as described, or describe issues. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Browser → POST /tablos | Untrusted form input (title, description, color) crosses into the handler. | | Browser → GET /tablos/new | Authed GET; no body input. | | Handler → Postgres | Parameterised via sqlc-generated InsertTabloParams. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-03-02-01 | Spoofing | session cookie | mitigate (inherited) | RequireAuth middleware from Phase 2 wraps the entire tablos group — unauthed requests redirect to /login. | | T-03-02-02 | Tampering | CSRF on POST /tablos | mitigate | gorilla/csrf middleware from Phase 2 validates _csrf field; @ui.CSRFField(csrfToken) embedded in TabloCreateFormFragment. Missing token → 403 before handler runs. | | T-03-02-03 | Tampering | r.Body drained by gorilla/csrf | mitigate | Use r.PostFormValue exclusively (Pitfall 2). | | T-03-02-04 | DoS | over-long title | mitigate | TablosCreateHandler validates len(strings.TrimSpace(title)) > 0 AND <= 255; >255 returns 422 before DB insert. | | T-03-02-05 | XSS via title/description/color | Tampering | mitigate | templ auto-escapes all `{ variable }` interpolations; color rendered into inline style attribute is templ-escaped (inline style accepts limited chars, but templ's escaping prevents attribute breakout). No templ.Raw anywhere. | | T-03-02-06 | Tampering via OOB swap | hx-swap-oob top-level requirement | mitigate | TabloCardWithOOBFormClear emits the OOB div as a top-level sibling (Pitfall 5); template structure asserted in Task 1 acceptance criteria. | | T-03-02-07 | Elevation of privilege | Listing other users' tablos | mitigate | ListTablosByUser query is scoped by $1 = user.ID from auth.Authed(ctx); no path parameters, no way to widen scope. | - TestTabloList, TestTabloList_Empty, TestTabloCreate (incl. HTMX + non-HTMX sub-asserts), TestTabloCreate_Validation all pass. - Full Phase 1/2 test suite still passes (no regression in TestSignup/TestLogin/TestLogout/TestCSRF). - Manual browser verification of create flow per Task 3 checkpoint. - `grep -c IndexHandler backend/internal/web/handlers.go` returns 0. 1. Dashboard at GET / renders user's tablos newest-first (TABLO-01). 2. Empty state appears when user has zero tablos with exact UI-SPEC copy. 3. Creating a tablo via HTMX prepends a new card and clears the form slot in one round trip (TABLO-02 + TABLO-06). 4. Empty-title validation surfaces "Title is required." inline. 5. Non-HTMX POST /tablos redirects 303 to / (TABLO-06 degrade-gracefully). 6. CSRF token present in the create form (AUTH-06 inherited). 7. Plan 03 has the wiring it needs: TablosDeps available, NewRouter signature stable, TabloCard renders a `.tablo-delete-zone` placeholder. After completion, create `.planning/phases/03-tablos-crud/03-02-SUMMARY.md` documenting: TablosDeps shape, list of new handlers/templates, NewRouter signature change, which tests turned green (4 of 10), and which remain red for Plan 03 (6 of 10).