diff --git a/.planning/phases/13-design-system-foundation/13-04-SUMMARY.md b/.planning/phases/13-design-system-foundation/13-04-SUMMARY.md new file mode 100644 index 0000000..5a31234 --- /dev/null +++ b/.planning/phases/13-design-system-foundation/13-04-SUMMARY.md @@ -0,0 +1,124 @@ +--- +phase: 13-design-system-foundation +plan: "04" +subsystem: backend/internal/web/ui +tags: [modal, empty-state, table, icon-button, space, uiicon, tailwind, design-system, tdd] +dependency_graph: + requires: [13-01, 13-02, 13-03] + provides: [modal-component, empty-state-component, table-component, icon-button-component, uiicon-function, space-components, full-css-manifest] + affects: + - backend/internal/web/ui/modal.css + - backend/internal/web/ui/modal.templ + - backend/internal/web/ui/empty-state.css + - backend/internal/web/ui/empty_state.templ + - backend/internal/web/ui/table.css + - backend/internal/web/ui/table.templ + - backend/internal/web/ui/icon-button.css + - backend/internal/web/ui/icon_button.templ + - backend/internal/web/ui/spacing.css + - backend/internal/web/ui/space.templ + - backend/internal/web/ui/button.templ + - backend/internal/web/ui/ui_test.go + - backend/tailwind.input.css +tech_stack: + added: [] + patterns: [verbatim-port, tdd-red-green, nil-guard-optional-regions, inline-svg-icon-switch] +key_files: + created: + - backend/internal/web/ui/modal.css + - backend/internal/web/ui/modal.templ + - backend/internal/web/ui/empty-state.css + - backend/internal/web/ui/empty_state.templ + - backend/internal/web/ui/table.css + - backend/internal/web/ui/table.templ + - backend/internal/web/ui/icon-button.css + - backend/internal/web/ui/icon_button.templ + - backend/internal/web/ui/spacing.css + - backend/internal/web/ui/space.templ + modified: + - backend/internal/web/ui/button.templ + - backend/internal/web/ui/ui_test.go + - backend/tailwind.input.css +decisions: + - "UIIcon defined in icon_button.templ (same file as IconButton) — matches go-backend pattern; UIIcon is callable from button.templ because both are in package ui" + - "SpaceX/SpaceY use exported SpaceXClass/SpaceYClass (not lowercase like go-backend) — backend variants.go already exported these functions in Plan 01" + - "tailwind.input.css final import order: base, auth, button, badge, card, input, textarea, select, modal, empty-state, table, icon-button, form-field, spacing (14 total per D-A02: no app.css)" +metrics: + duration: "~10 minutes" + completed_date: "2026-05-16" + tasks: 2 + files: 13 +--- + +# Phase 13 Plan 04: Remaining Components — Modal, EmptyState, Table, IconButton, Space Summary + +Five remaining component types ported from go-backend with CSS, typed Props structs, and test coverage: Modal (ui-modal-backdrop/panel/header/body/actions), EmptyState (dashed border, nil-guarded Icon/Action), Table (ui-table-shell wrapper), IconButton + UIIcon switch (8 icons + fallback), SpaceX/SpaceY. UIIcon wired into button.templ. tailwind.input.css updated to 14 total imports. + +## Tasks Completed + +| Task | Name | Commit | Key Files | +|------|------|--------|-----------| +| 1 (RED) | Failing tests for Modal, EmptyState, Table | 4bdb78d | ui_test.go (+6 tests) | +| 1 (GREEN) | Port modal, empty-state, table components with CSS and templ | fbdf188 | modal.css, modal.templ, empty-state.css, empty_state.templ, table.css, table.templ | +| 2 (RED) | Failing tests for IconButton, UIIcon, Space, Button icon wiring | fa24059 | ui_test.go (+7 tests) | +| 2 (GREEN) | Port icon-button/space; wire UIIcon into button.templ; update tailwind.input.css | c80ebcb | icon-button.css, icon_button.templ, spacing.css, space.templ, button.templ, tailwind.input.css | + +## What Was Built + +**Task 1 — Modal, EmptyState, Table (TDD):** +- `modal.css`: `.ui-modal-backdrop` (position fixed, flex center), `.ui-modal-panel` (max-width 32rem, border-radius 1rem, box-shadow), `.ui-modal-header`/`.ui-modal-body`/`.ui-modal-actions` (padding, border-top/bottom dividers). +- `modal.templ`: `ModalProps` struct with `Title string`, `Body templ.Component`, `Actions templ.Component`. Nil-guard conditionals for Body and Actions regions. +- `empty-state.css`: `.ui-empty-state` (dashed border, flex column center, padding 3rem 1.5rem), `.ui-empty-state-icon` (4rem circle, surface-muted background), `.ui-empty-state-title` (font-weight 700), `.ui-empty-state-description`. +- `empty_state.templ`: `EmptyStateProps` struct with `Icon templ.Component` (not string — callers pass UIIcon(...)). Nil-guard for Icon, empty-string guard for Description, nil-guard for Action. +- `table.css`: `.ui-table-shell` (overflow-x auto, width 100%), `.ui-table` (border-collapse collapse, min-width 100%). +- `table.templ`: `TableProps` struct with `Head templ.Component`, `Body templ.Component`. Nil-guarded thead/tbody regions. +- 6 tests: `TestModal_RendersBackdropAndPanel`, `TestModal_RendersTitle`, `TestModal_NilBodyOmitted`, `TestEmptyState_RendersTitle`, `TestEmptyState_NilIconOmitted`, `TestTable_RendersShell`. + +**Task 2 — IconButton, UIIcon, Space + button.templ wiring (TDD):** +- `icon-button.css`: `.ui-icon-button` (min 44x44px, transparent background, border-radius 0.5rem), `.borderless-icon-button` (no border/shadow/outline), `.ui-icon-button-solid.ui-icon-button-neutral` and ghost color rules, `.borderless-icon-button svg` (1rem size). +- `icon_button.templ`: `IconButtonProps` struct with `Label`, `Icon`, `Variant`, `Tone`, `Type`, `Attrs`. `IconButton` renders `` with `IconButtonClass`. `UIIcon` switch with 8 cases (plus, grid3x3, list, filter, search, calendar, pencil, trash) as inline SVGs + default `` fallback. +- `spacing.css`: `.ui-space-x`/`.ui-space-y` base display rules + `.ui-space-x-{xs/sm/md/lg/xl}` width steps + `.ui-space-y-{xs/sm/md/lg/xl}` height steps. +- `space.templ`: `SpaceProps` struct with `Size SpacingStep`. `SpaceX` renders ``, `SpaceY` renders `
` 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: + + } +} 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) { +
+
+
+

{ props.Title }

+
+ 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, "