` using exported `SpaceXClass`/`SpaceYClass`.
+- `button.templ`: replaced Plan 02 placeholder comment with actual `if props.Icon != "" {
@UIIcon(props.Icon) }`.
+- `tailwind.input.css`: added 5 new imports (modal, empty-state, table, icon-button, form-field position unchanged, spacing) — total 14 `@import` lines for `web/ui` files.
+- 7 tests: `TestIconButton_GhostNeutral`, `TestIconButton_SolidDanger`, `TestUIIcon_Plus`, `TestUIIcon_Fallback`, `TestSpaceX_MD`, `TestSpaceY_LG`, `TestButton_IconRendered`.
+
+## Verification Results
+
+- `templ generate`: succeeds (3 new components generated)
+- `go test ./internal/web/ui/... -count=1`: all 36 tests pass (29 from Plans 01-03 + 13 new)
+- `go test ./... -count=1`: all packages pass (auth, db, files, jobs, web, web/ui, templates)
+- `grep -c '@import.*web/ui' backend/tailwind.input.css`: returns 14 (base + auth + 12 components)
+- `grep 'templ UIIcon' backend/internal/web/ui/icon_button.templ`: matches
+- `grep '@UIIcon' backend/internal/web/ui/button.templ`: matches (icon wired)
+- `grep 'ui-table-shell' backend/internal/web/ui/table.css`: matches
+- `grep 'ui-empty-state' backend/internal/web/ui/empty-state.css`: matches (5 lines)
+
+## Deviations from Plan
+
+None — plan executed exactly as written.
+
+The go-backend's `space.templ` uses private `spaceXClass`/`spaceYClass` functions; the backend uses exported `SpaceXClass`/`SpaceYClass` (set up in Plan 01). This is not a deviation — Plan 01 deliberately exported these functions and the plan's `
` section specifies the exported names `SpaceXClass`/`SpaceYClass`.
+
+## Known Stubs
+
+None — all components are fully implemented with correct CSS selectors and templ rendering logic. UIIcon renders actual inline SVGs (not placeholders) for all 8 icon kinds. No data wiring required for these presentational components.
+
+## Threat Flags
+
+No new network endpoints, auth paths, file access patterns, or schema changes introduced.
+
+The threat model entries T-13-04-01 through T-13-04-03 are all satisfied:
+- T-13-04-01: UIIcon default fallback renders `{ kind }` which templ auto-escapes — no XSS via icon name string
+- T-13-04-02: icon-button.css is a public static asset with no sensitive information
+- T-13-04-03: tailwind.input.css update controls CSS only — no route registration or handler changes
+
+## TDD Gate Compliance
+
+- Task 1 RED gate: commit `4bdb78d` — 6 failing tests (undefined: Modal, ModalProps, EmptyState, EmptyStateProps, Table, TableProps)
+- Task 1 GREEN gate: commit `fbdf188` — all 6 TestModal/TestEmptyState/TestTable tests pass; full suite green
+- Task 2 RED gate: commit `fa24059` — 7 failing tests (undefined: IconButton, IconButtonProps, UIIcon, SpaceX, SpaceProps, SpaceY)
+- Task 2 GREEN gate: commit `c80ebcb` — all 7 new tests pass; full suite green
+- REFACTOR gate: Not needed — implementation was clean on first pass
+
+## Self-Check: PASSED
diff --git a/backend/internal/web/ui/button.templ b/backend/internal/web/ui/button.templ
index 1c517af..6401eb7 100644
--- a/backend/internal/web/ui/button.templ
+++ b/backend/internal/web/ui/button.templ
@@ -17,7 +17,9 @@ type ButtonProps struct {
templ Button(props ButtonProps) {
}
diff --git a/backend/internal/web/ui/empty-state.css b/backend/internal/web/ui/empty-state.css
new file mode 100644
index 0000000..b361f0a
--- /dev/null
+++ b/backend/internal/web/ui/empty-state.css
@@ -0,0 +1,40 @@
+.ui-empty-state {
+ align-items: center;
+ border: 1px dashed var(--color-border-subtle);
+ border-radius: 1rem;
+ color: var(--color-text-muted);
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ justify-content: center;
+ padding: 3rem 1.5rem;
+ text-align: center;
+}
+
+.ui-empty-state-title {
+ color: var(--color-text-primary);
+ font-size: 1.125rem;
+ font-weight: 700;
+ margin: 0;
+}
+
+.ui-empty-state-icon {
+ align-items: center;
+ background: var(--color-surface-muted);
+ border-radius: 999px;
+ color: var(--color-text-faint);
+ display: inline-flex;
+ height: 4rem;
+ justify-content: center;
+ width: 4rem;
+}
+
+.ui-empty-state-icon svg {
+ height: 2rem;
+ width: 2rem;
+}
+
+.ui-empty-state-description {
+ margin: 0;
+ max-width: 32rem;
+}
diff --git a/backend/internal/web/ui/empty_state.templ b/backend/internal/web/ui/empty_state.templ
new file mode 100644
index 0000000..22a975d
--- /dev/null
+++ b/backend/internal/web/ui/empty_state.templ
@@ -0,0 +1,27 @@
+package ui
+
+type EmptyStateProps struct {
+ Title string
+ Description string
+ Icon templ.Component
+ Action templ.Component
+}
+
+templ EmptyState(props EmptyStateProps) {
+
+ if props.Icon != nil {
+
+ @props.Icon
+
+ }
+ { props.Title }
+ if props.Description != "" {
+ { props.Description }
+ }
+ if props.Action != nil {
+
+ @props.Action
+
+ }
+
+}
diff --git a/backend/internal/web/ui/icon-button.css b/backend/internal/web/ui/icon-button.css
new file mode 100644
index 0000000..a60bdcc
--- /dev/null
+++ b/backend/internal/web/ui/icon-button.css
@@ -0,0 +1,50 @@
+.ui-icon-button {
+ align-items: center;
+ appearance: none;
+ background: transparent;
+ border: 0;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ display: inline-flex;
+ justify-content: center;
+ min-height: 44px;
+ min-width: 44px;
+ padding: 0.5rem;
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease;
+}
+
+.ui-icon-button:focus-visible,
+.borderless-icon-button:focus-visible {
+ box-shadow: 0 0 0 3px var(--color-focus-ring);
+ outline: none;
+}
+
+.ui-icon-button-solid.ui-icon-button-neutral {
+ color: var(--color-text-muted);
+}
+
+.ui-icon-button-solid.ui-icon-button-neutral:hover {
+ background: var(--color-surface-neutral-hover);
+ color: var(--color-text-primary);
+}
+
+.borderless-icon-button {
+ appearance: none;
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ cursor: pointer;
+ outline: none;
+}
+
+.ui-icon-button-ghost.ui-icon-button-neutral,
+.ui-icon-button-ghost.ui-icon-button-danger {
+ color: var(--color-text-faint);
+}
+
+.borderless-icon-button svg {
+ height: 1rem;
+ width: 1rem;
+}
diff --git a/backend/internal/web/ui/icon_button.templ b/backend/internal/web/ui/icon_button.templ
new file mode 100644
index 0000000..bd97b32
--- /dev/null
+++ b/backend/internal/web/ui/icon_button.templ
@@ -0,0 +1,74 @@
+package ui
+
+type IconButtonProps struct {
+ Label string
+ Icon string
+ Variant IconButtonVariant
+ Tone IconButtonTone
+ Type string
+ Attrs templ.Attributes
+}
+
+templ IconButton(props IconButtonProps) {
+
+}
+
+templ UIIcon(kind string) {
+ switch kind {
+ case "plus":
+
+ case "grid3x3":
+
+ case "list":
+
+ case "filter":
+
+ case "search":
+
+ case "calendar":
+
+ case "pencil":
+
+ case "trash":
+
+ default:
+ { kind }
+ }
+}
diff --git a/backend/internal/web/ui/modal.css b/backend/internal/web/ui/modal.css
new file mode 100644
index 0000000..854e1cd
--- /dev/null
+++ b/backend/internal/web/ui/modal.css
@@ -0,0 +1,53 @@
+.ui-modal-backdrop {
+ align-items: center;
+ background: var(--overlay-backdrop-default);
+ display: flex;
+ inset: 0;
+ justify-content: center;
+ padding: 1rem;
+ position: fixed;
+ z-index: 40;
+}
+
+.ui-modal-panel {
+ background: var(--color-surface-default);
+ border: 1px solid var(--color-border-default);
+ border-radius: 1rem;
+ box-shadow: var(--shadow-surface-lg);
+ max-width: 32rem;
+ width: min(100%, 32rem);
+}
+
+.ui-modal-header,
+.ui-modal-body,
+.ui-modal-actions {
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+}
+
+.ui-modal-header {
+ border-bottom: 1px solid var(--color-border-default);
+ padding-bottom: 1rem;
+ padding-top: 1.25rem;
+}
+
+.ui-modal-header h2 {
+ color: var(--color-text-primary);
+ font-size: 1.125rem;
+ font-weight: 700;
+ margin: 0;
+}
+
+.ui-modal-body {
+ padding-bottom: 1.25rem;
+ padding-top: 1.25rem;
+}
+
+.ui-modal-actions {
+ border-top: 1px solid var(--color-border-default);
+ display: flex;
+ gap: 0.75rem;
+ justify-content: flex-end;
+ padding-bottom: 1rem;
+ padding-top: 1rem;
+}
diff --git a/backend/internal/web/ui/modal.templ b/backend/internal/web/ui/modal.templ
new file mode 100644
index 0000000..f3ac5d4
--- /dev/null
+++ b/backend/internal/web/ui/modal.templ
@@ -0,0 +1,27 @@
+package ui
+
+type ModalProps struct {
+ Title string
+ Body templ.Component
+ Actions templ.Component
+}
+
+templ Modal(props ModalProps) {
+
+
+
+ if props.Body != nil {
+
+ @props.Body
+
+ }
+ if props.Actions != nil {
+
+ @props.Actions
+
+ }
+
+
+}
diff --git a/backend/internal/web/ui/space.templ b/backend/internal/web/ui/space.templ
new file mode 100644
index 0000000..4cdd215
--- /dev/null
+++ b/backend/internal/web/ui/space.templ
@@ -0,0 +1,13 @@
+package ui
+
+type SpaceProps struct {
+ Size SpacingStep
+}
+
+templ SpaceX(props SpaceProps) {
+
+}
+
+templ SpaceY(props SpaceProps) {
+
+}
diff --git a/backend/internal/web/ui/spacing.css b/backend/internal/web/ui/spacing.css
new file mode 100644
index 0000000..2d0e782
--- /dev/null
+++ b/backend/internal/web/ui/spacing.css
@@ -0,0 +1,48 @@
+.ui-space-x {
+ display: inline-block;
+ flex-shrink: 0;
+}
+
+.ui-space-y {
+ display: block;
+}
+
+.ui-space-x-xs {
+ width: 0.25rem;
+}
+
+.ui-space-x-sm {
+ width: 0.5rem;
+}
+
+.ui-space-x-md {
+ width: 0.75rem;
+}
+
+.ui-space-x-lg {
+ width: 1rem;
+}
+
+.ui-space-x-xl {
+ width: 1.5rem;
+}
+
+.ui-space-y-xs {
+ height: 0.25rem;
+}
+
+.ui-space-y-sm {
+ height: 0.5rem;
+}
+
+.ui-space-y-md {
+ height: 0.75rem;
+}
+
+.ui-space-y-lg {
+ height: 1rem;
+}
+
+.ui-space-y-xl {
+ height: 1.5rem;
+}
diff --git a/backend/internal/web/ui/table.css b/backend/internal/web/ui/table.css
new file mode 100644
index 0000000..292f192
--- /dev/null
+++ b/backend/internal/web/ui/table.css
@@ -0,0 +1,10 @@
+.ui-table-shell {
+ overflow-x: auto;
+ width: 100%;
+}
+
+.ui-table {
+ border-collapse: collapse;
+ min-width: 100%;
+ width: 100%;
+}
diff --git a/backend/internal/web/ui/table.templ b/backend/internal/web/ui/table.templ
new file mode 100644
index 0000000..dead420
--- /dev/null
+++ b/backend/internal/web/ui/table.templ
@@ -0,0 +1,23 @@
+package ui
+
+type TableProps struct {
+ Head templ.Component
+ Body templ.Component
+}
+
+templ Table(props TableProps) {
+
+
+
+ if props.Head != nil {
+ @props.Head
+ }
+
+
+ if props.Body != nil {
+ @props.Body
+ }
+
+
+
+}
diff --git a/backend/internal/web/ui/ui_test.go b/backend/internal/web/ui/ui_test.go
index 4000ad1..dcf0aa3 100644
--- a/backend/internal/web/ui/ui_test.go
+++ b/backend/internal/web/ui/ui_test.go
@@ -346,3 +346,121 @@ func TestFormField_NoErrorWhenEmpty(t *testing.T) {
t.Errorf("expected NO ui-form-error in output when Error is empty; got: %s", out)
}
}
+
+// Phase 13 Plan 04 — Modal, EmptyState, Table component tests (TDD RED)
+
+func TestModal_RendersBackdropAndPanel(t *testing.T) {
+ out := render(t, context.Background(), Modal(ModalProps{Title: "Confirm action"}))
+ for _, want := range []string{"ui-modal-backdrop", "ui-modal-panel"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected %q in output; got: %s", want, out)
+ }
+ }
+}
+
+func TestModal_RendersTitle(t *testing.T) {
+ out := render(t, context.Background(), Modal(ModalProps{Title: "Confirm action"}))
+ // Title must appear inside a heading element
+ if !strings.Contains(out, "") || !strings.Contains(out, "Confirm action") {
+ t.Errorf("expected with title text in output; got: %s", out)
+ }
+}
+
+func TestModal_NilBodyOmitted(t *testing.T) {
+ out := render(t, context.Background(), Modal(ModalProps{Title: "Confirm action"}))
+ if strings.Contains(out, "ui-modal-body") {
+ t.Errorf("expected NO ui-modal-body when Body is nil; got: %s", out)
+ }
+}
+
+func TestEmptyState_RendersTitle(t *testing.T) {
+ out := render(t, context.Background(), EmptyState(EmptyStateProps{Title: "Nothing here yet"}))
+ for _, want := range []string{"ui-empty-state", "Nothing here yet"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected %q in output; got: %s", want, out)
+ }
+ }
+}
+
+func TestEmptyState_NilIconOmitted(t *testing.T) {
+ out := render(t, context.Background(), EmptyState(EmptyStateProps{Title: "Nothing here yet"}))
+ if strings.Contains(out, "ui-empty-state-icon") {
+ t.Errorf("expected NO ui-empty-state-icon when Icon is nil; got: %s", out)
+ }
+}
+
+func TestTable_RendersShell(t *testing.T) {
+ out := render(t, context.Background(), Table(TableProps{}))
+ for _, want := range []string{"ui-table-shell", "ui-table"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected %q in output; got: %s", want, out)
+ }
+ }
+}
+
+// Phase 13 Plan 04 — IconButton, UIIcon, Space, and Button icon wiring tests (TDD RED)
+
+func TestIconButton_GhostNeutral(t *testing.T) {
+ out := render(t, context.Background(), IconButton(IconButtonProps{
+ Icon: "plus",
+ Variant: IconButtonVariantNeutral,
+ Tone: IconButtonToneGhost,
+ Label: "Add",
+ }))
+ if !strings.Contains(out, "borderless-icon-button") {
+ t.Errorf("expected borderless-icon-button class; got: %s", out)
+ }
+ if !strings.Contains(out, `aria-label="Add"`) {
+ t.Errorf("expected aria-label=\"Add\"; got: %s", out)
+ }
+}
+
+func TestIconButton_SolidDanger(t *testing.T) {
+ out := render(t, context.Background(), IconButton(IconButtonProps{
+ Icon: "trash",
+ Variant: IconButtonVariantDanger,
+ Tone: IconButtonToneSolid,
+ Label: "Delete",
+ }))
+ if !strings.Contains(out, "ui-icon-button-solid") {
+ t.Errorf("expected ui-icon-button-solid class; got: %s", out)
+ }
+ if !strings.Contains(out, "ui-icon-button-danger") {
+ t.Errorf("expected ui-icon-button-danger class; got: %s", out)
+ }
+}
+
+func TestUIIcon_Plus(t *testing.T) {
+ out := render(t, context.Background(), UIIcon("plus"))
+ if !strings.Contains(out, "