---
phase: 3
slug: tablos-crud
status: draft
shadcn_initialized: false
preset: none
created: 2026-05-14
---

# Phase 3 — UI Design Contract

> Visual and interaction contract for Phase 3: Tablos CRUD.
> Generated by gsd-ui-researcher. Stack is Go + templ + HTMX + Tailwind v4 — no React, no shadcn.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none (plain HTML + Tailwind v4) |
| Preset | not applicable |
| Component library | ui package — `backend/internal/web/ui/` (Card, Button, Badge, CSRFField) |
| Icon library | none — Phase 3 uses text labels and Unicode characters only |
| Font | system-ui, -apple-system, "Segoe UI", Roboto, sans-serif (inherited from base.css) |

shadcn gate: Not applicable. Stack is Go + templ, not React/Next.js/Vite.

---

## Base Layout Contract

Inherited from Phase 1/2 (`backend/templates/layout.templ`). Do NOT modify the shell.

| Property | Value | Source |
|----------|-------|--------|
| Container max-width | max-w-5xl | layout.templ — locked |
| Horizontal padding | px-4 sm:px-6 | layout.templ — locked |
| Main content top padding | py-8 | layout.templ — locked |
| Header background | bg-slate-50 border-b border-slate-200 | layout.templ — locked |
| Body background | bg-white | layout.templ (base.css) — locked |
| HTMX script | `/static/htmx.min.js` deferred — no CDN | layout.templ — locked |

The footer text "Phase 2 · Authentication" must be updated to "Phase 3 · Tablos" in layout.templ.

---

## Spacing Scale

Declared values (multiples of 4 only):

| Token | Value | Tailwind class | Usage |
|-------|-------|----------------|-------|
| xs | 4px | gap-1, p-1 | Icon gaps, inline chip padding |
| sm | 8px | gap-2, p-2 | Compact element spacing, badge padding |
| md | 16px | gap-4, p-4 | Default element spacing, card internal gap |
| lg | 24px | gap-6, p-6 | Card padding (matches `.ui-card` 1.5rem) |
| xl | 32px | gap-8, mt-8 | Section breaks within page |
| 2xl | 48px | gap-12 | Major section separators |
| 3xl | 64px | — | Not used in Phase 3 |

Exceptions:
- Touch-target minimum 44px height for all interactive buttons (per WCAG 2.5.5). Achieved via `py-2 px-4` on `.ui-button-solid-default-md` (already 40px rendered; add `min-h-[44px]` when implementing new danger/neutral button variants for Phase 3).
- Form `space-y-5` (20px) is an existing pattern from auth forms — reuse exactly for tablo create/edit forms.

---

## Typography

All sizes sourced from existing codebase patterns in `templates/` and `ui/`.

| Role | Size | Weight | Line Height | Tailwind classes | Usage |
|------|------|--------|-------------|-----------------|-------|
| Display | 28px | 600 (semibold) | 1.2 (tight) | `text-[28px] font-semibold leading-tight` | Dashboard page heading ("Your Tablos") |
| Heading | 20px | 600 (semibold) | 1.3 (snug) | `text-xl font-semibold leading-snug` | Tablo card title, detail page section headings |
| Body | 16px | 400 (regular) | 1.5 | `text-base text-slate-600` | Tablo descriptions, paragraph content |
| Label | 14px | 600 (semibold) | 1.4 | `text-sm font-semibold text-slate-700` | Form labels, metadata (created date, owner email) |
| Small/muted | 14px | 400 (regular) | 1.4 | `text-sm text-slate-500` | "Signed in as" subtext, timestamps |

2 declared weights: 400 (regular) and 600 (semibold). Form labels use font-semibold (600), consistent with the Button component convention already established in Phase 2.

---

## Color

| Role | Hex | Tailwind | Usage |
|------|-----|----------|-------|
| Dominant surface (60%) | #ffffff | bg-white | Page background, form inputs |
| Secondary surface (30%) | #f8fafc | bg-slate-50 | Cards (`ui-card`), header strip, input focus hover |
| Accent (10%) | #2563eb | bg-blue-600 | Primary CTA button only (see accent reserved list) |
| Destructive | #b91c1c | bg-red-700 / text-red-700 | Delete confirmation button, field error text, general error banner |

Accent (#2563eb / blue-600) reserved for:
1. Primary action button — "New tablo" (create) and "Save changes" (edit submit)
2. Focus ring on all interactive elements (`outline: 2px solid #2563eb`)
3. Nothing else — links, secondary actions, and navigation use slate tones

Borders: slate-200 (`#e2e8f0`) for cards and input default state. slate-300 (`#cbd5e1`) for input borders.

Text: slate-900 (`#0f172a`) for primary content, slate-700 (`#334155`) for labels, slate-600 (`#475569`) for secondary text, slate-500 (`#64748b`) for muted/metadata text.

Tablo color field rendering: render as a 10px × 10px filled dot (CSS circle using `inline-block w-2.5 h-2.5 rounded-full`) on the dashboard card, using the stored hex or Tailwind color as inline `background-color` style. If `color` is null or empty, omit the dot entirely. No color picker UI in Phase 3 — accept hex via plain text input.

---

## Component Inventory

### Existing (reuse without modification)

| Component | Import | Usage in Phase 3 |
|-----------|--------|-----------------|
| `ui.Card` | `backend/internal/web/ui` | Tablo list card, create form container |
| `ui.Button` (solid/default/md) | `backend/internal/web/ui` | "New tablo" CTA, "Save changes" on edit, "Create tablo" submit |
| `ui.Badge` | `backend/internal/web/ui` | Optional: status labels if needed |
| `ui.CSRFField` | `backend/internal/web/ui` | All forms (create, edit, delete) — AUTH-06 |
| `templates.FieldError` | `backend/templates` | Inline field validation on create/edit forms |
| `templates.GeneralError` | `backend/templates` | General error banner on form submission failures |
| `templates.Layout` | `backend/templates` | All tablo pages |

### New (must be implemented in Phase 3)

| Component | File | Description |
|-----------|------|-------------|
| `templates.TablosDashboard` | `backend/templates/tablos.templ` | Full dashboard page (tablo list + create form slot) |
| `templates.TablosListFragment` | `backend/templates/tablos.templ` | HTMX-swappable list of tablo cards (reused on create/delete) |
| `templates.TabloCard` | `backend/templates/tablos.templ` | Single tablo card (title, description excerpt, color dot, delete button) |
| `templates.TabloCreateFormFragment` | `backend/templates/tablos.templ` | Inline create form (HTMX swap on validation error) |
| `templates.TabloDetailPage` | `backend/templates/tablos.templ` | Tablo detail page with inline edit areas |
| `templates.TabloTitleDisplay` | `backend/templates/tablos.templ` | Display fragment for title (swapped to edit input on click) |
| `templates.TabloTitleEditFragment` | `backend/templates/tablos.templ` | Edit input fragment for title |
| `templates.TabloDescDisplay` | `backend/templates/tablos.templ` | Display fragment for description |
| `templates.TabloDescEditFragment` | `backend/templates/tablos.templ` | Edit textarea fragment for description |
| `templates.TabloDeleteButtonFragment` | `backend/templates/tablos.templ` | Delete button (swaps to confirmation row) |
| `templates.TabloDeleteConfirmFragment` | `backend/templates/tablos.templ` | "Delete tablo? Yes / Keep tablo" confirmation row |
| `ui.ButtonDanger` (soft/danger/md CSS) | `backend/internal/web/ui/button.css` | Delete-confirm "Yes, delete" button (red variant) |
| `ui.ButtonNeutral` (soft/neutral/md CSS) | `backend/internal/web/ui/button.css` | "Discard changes" / "Keep tablo" button (slate/ghost variant) |

---

## Interaction Contracts

### 1. Dashboard — Tablo List

- Route: `GET /`
- Template: `templates.TablosDashboard`
- Container: `<div id="tablos-list">` wraps the list of `TabloCard` items.
- Sort: newest-first (from DB `ORDER BY created_at DESC`).
- Empty state: when no tablos exist, render the empty state inside `#tablos-list` (see Copywriting section).
- No HTMX on initial load — full page render.

### 2. Create Tablo — Inline Form

- Trigger: "New tablo" button (blue, solid, md) fires `hx-get="/tablos/new"` targeting `#create-form-slot` with `hx-swap="innerHTML"`.
- `#create-form-slot` is an empty `<div>` above `#tablos-list` in the dashboard.
- Form: `hx-post="/tablos"` → `hx-target="#create-form-slot"` → `hx-swap="innerHTML"`.
- On validation error (422): server returns the form fragment with field errors. Form re-renders in-place.
- On success (200 + HX-Trigger or fragment): server returns an empty `#create-form-slot` (form collapses) AND the new tablo card is prepended to `#tablos-list` using `hx-swap="afterbegin"` on the list, or via `HX-Retarget`/`HX-Reswap` response headers. Preferred approach: use `HX-Retarget: #tablos-list` + `HX-Reswap: afterbegin` response headers from the handler so a single HTMX response both clears the form slot and prepends the card.
- Non-HTMX fallback: standard `POST /tablos` → `303 /` redirect.
- Form fields: Title (required, text input), Description (optional, textarea 3 rows), Color (optional, text input with placeholder "#6366f1 or indigo").

### 3. Tablo Detail — View

- Route: `GET /tablos/{id}`
- Non-owner or unauthenticated: 404 (no 403 per D-04).
- Template: `templates.TabloDetailPage` extending `Layout`.
- Title and description rendered as display fragments with an "Edit" affordance.

### 4. Edit Tablo — Inline on Detail Page

- Trigger: Clicking the title or description area fires `hx-get="/tablos/{id}/edit-title"` (or `/edit-desc`) targeting the respective display element with `hx-swap="outerHTML"`.
- Edit fragment renders an `<input>` (title) or `<textarea>` (description) with a hidden CSRF field.
- Save: `hx-post="/tablos/{id}"` (with `_method=PATCH` hidden input for semantic clarity, handled by chi) targeting the edit fragment container, `hx-swap="outerHTML"`.
- On success: server returns the updated display fragment.
- On validation error: server returns the edit fragment with a field error.
- Cancel: A "Discard changes" button fires `hx-get="/tablos/{id}/show-title"` (or `/show-desc`) to restore the original display fragment without modifying the DB.
- Non-HTMX fallback: `POST /tablos/{id}` with `_method=PATCH` → `303 /tablos/{id}` redirect.

### 5. Delete Tablo — Inline Confirmation

- Context: delete button visible on both the dashboard card and the detail page.
- Trigger: Delete button fires `hx-get="/tablos/{id}/delete-confirm"` targeting the button's container (`hx-target="closest .tablo-delete-zone"`) with `hx-swap="outerHTML"`.
- Confirmation row: renders "Delete tablo? [Yes, delete] [Keep tablo]" inline.
- Confirm: `hx-delete="/tablos/{id}"` (or `hx-post` with `_method=DELETE`) with `hx-target="closest .ui-card"` and `hx-swap="outerHTML"` (removes the card from the DOM on success).
- Cancel: fires `hx-get="/tablos/{id}/delete-cancel"` to restore the original delete button fragment.
- On success (dashboard): card removed from `#tablos-list`. If list is now empty, server should return the empty state fragment via `HX-Retarget: #tablos-list` + `HX-Reswap: innerHTML`.
- On success (detail page): redirect to `/` via `HX-Redirect: /` response header (HTMX honored) or `303 /` for non-HTMX.
- Non-HTMX fallback: `POST /tablos/{id}/delete` with CSRF → `303 /`.

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Page title (browser tab) | "Tablos — Xtablo" |
| Dashboard heading | "Your Tablos" |
| Primary CTA (create) | "New tablo" |
| Create form heading | "Create a tablo" |
| Create submit button | "Create tablo" |
| Edit save button | "Save changes" |
| Edit cancel button | "Discard changes" |
| Delete button (card/detail) | "Delete" |
| Delete confirm heading | "Delete tablo?" |
| Delete confirm body | "This cannot be undone." |
| Delete confirm action | "Yes, delete" |
| Delete cancel action | "Keep tablo" |
| Empty state heading | "No tablos yet" |
| Empty state body | "Create your first tablo to get started." |
| Empty state CTA | "New tablo" (same blue button) |
| Validation — title required | "Title is required." |
| Validation — title too long | "Title must be 255 characters or fewer." |
| General server error | "Something went wrong. Please try again." |
| 404 page heading | "Not found" |
| 404 page body | "This tablo doesn't exist or you don't have access." |
| Footer text | "Phase 3 · Tablos" |

---

## Form Field Styles

Reuse the exact Tailwind classes from auth forms (locked pattern):

```
Input:    mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm
          placeholder-slate-400 focus:border-slate-500 focus:outline-none
Textarea: mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm
          placeholder-slate-400 focus:border-slate-500 focus:outline-none resize-y
Label:    block text-sm font-semibold text-slate-700
```

Field error: `<p class="mt-1 text-sm text-red-700">` (existing `templates.FieldError`).
General error banner: existing `templates.GeneralError` (border-red-300 bg-red-50).

---

## New Button Variants Required

Phase 3 needs two new CSS rules in `backend/internal/web/ui/button.css`:

**Danger solid md** (delete confirm "Yes, delete"):
```
.ui-button-solid-danger-md {
  background-color: #b91c1c;  /* red-700 */
  color: #ffffff;
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-size: 1rem;
  font-weight: 600;
}
.ui-button-solid-danger-md:hover { background-color: #991b1b; }
```

**Neutral soft md** (discard changes, keep tablo, secondary actions):
```
.ui-button-soft-neutral-md {
  background-color: #f1f5f9;  /* slate-100 */
  color: #334155;             /* slate-700 */
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-size: 1rem;
  font-weight: 400;
  border: 1px solid #e2e8f0;  /* slate-200 */
}
.ui-button-soft-neutral-md:hover { background-color: #e2e8f0; }
```

---

## Registry Safety

Not applicable. Stack is Go + templ — no shadcn, no npm component registries.

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none | not applicable — Go stack |
| third-party | none | not applicable — Go stack |

---

## HTMX Attribute Reference

Established patterns to reuse exactly (do not invent new patterns):

| Interaction | hx-method | hx-target | hx-swap |
|-------------|-----------|-----------|---------|
| Show create form | hx-get="/tablos/new" | #create-form-slot | innerHTML |
| Submit create (success) | hx-post="/tablos" | #create-form-slot | innerHTML + HX-Retarget header |
| Submit create (error) | hx-post="/tablos" | #create-form-slot | innerHTML |
| Show edit title | hx-get="/tablos/{id}/edit-title" | `.tablo-title-zone` | outerHTML |
| Save edit title | hx-post="/tablos/{id}" | `.tablo-title-zone` | outerHTML |
| Discard edit title | hx-get="/tablos/{id}/show-title" | `.tablo-title-zone` | outerHTML |
| Show delete confirm | hx-get="/tablos/{id}/delete-confirm" | `.tablo-delete-zone` | outerHTML |
| Confirm delete | hx-post="/tablos/{id}/delete" | `.ui-card` (closest) | outerHTML |
| Keep tablo (cancel delete) | hx-get="/tablos/{id}/delete-cancel" | `.tablo-delete-zone` | outerHTML |

All state-changing requests must include the CSRF token. The `@ui.CSRFField(csrfToken)` helper must be in every `<form>` element.

HTMX loading indicator: `class="htmx-indicator"` with `opacity-0` default, shown via `.htmx-request .htmx-indicator` CSS (already in Tailwind output from Phase 1 demo).

---

## Accessibility Floor

Inherited from `base.css` `:focus-visible` rule. Phase 3 additions:

- All form `<input>` and `<textarea>` elements must have an associated `<label for="...">`.
- Delete confirmation buttons use `aria-label="Confirm delete tablo"` and `aria-label="Keep tablo"` to distinguish from other secondary action buttons on the page.
- Empty state CTA button has `aria-label="Create your first tablo"`.
- `<main>` landmark present in `Layout` — no additional landmark required in Phase 3.

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
