chore: merge executor worktree (worktree-agent-a80aab8b5919843d3)

This commit is contained in:
Arthur Belleville 2026-05-16 14:09:40 +02:00
commit f57952b6fb
No known key found for this signature in database
14 changed files with 615 additions and 1 deletions

View file

@ -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 `<button>@UIIcon</button>` with `IconButtonClass`. `UIIcon` switch with 8 cases (plus, grid3x3, list, filter, search, calendar, pencil, trash) as inline SVGs + default `<span>` 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 `<span>`, `SpaceY` renders `<div>` using exported `SpaceXClass`/`SpaceYClass`.
- `button.templ`: replaced Plan 02 placeholder comment with actual `if props.Icon != "" { <span class="ui-button-icon">@UIIcon(props.Icon)</span> }`.
- `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 `<interfaces>` 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

View file

@ -17,7 +17,9 @@ type ButtonProps struct {
templ Button(props ButtonProps) {
<button type={ buttonType(props.Type) } class={ ButtonClass(props.Variant, props.Tone, props.Size) } { props.Attrs... }>
// UIIcon added in Plan 04 — Icon field present, rendering wired when UIIcon exists
if props.Icon != "" {
<span class="ui-button-icon">@UIIcon(props.Icon)</span>
}
{ props.Label }
</button>
}

View file

@ -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;
}

View file

@ -0,0 +1,27 @@
package ui
type EmptyStateProps struct {
Title string
Description string
Icon templ.Component
Action templ.Component
}
templ EmptyState(props EmptyStateProps) {
<section class="ui-empty-state">
if props.Icon != nil {
<div class="ui-empty-state-icon">
@props.Icon
</div>
}
<h3 class="ui-empty-state-title">{ props.Title }</h3>
if props.Description != "" {
<p class="ui-empty-state-description">{ props.Description }</p>
}
if props.Action != nil {
<div class="ui-empty-state-action">
@props.Action
</div>
}
</section>
}

View file

@ -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;
}

View file

@ -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) {
<button type={ buttonType(props.Type) } class={ IconButtonClass(props.Variant, props.Tone) } aria-label={ props.Label } { props.Attrs... }>
@UIIcon(props.Icon)
</button>
}
templ UIIcon(kind string) {
switch kind {
case "plus":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
case "grid3x3":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M3 9h18"></path>
<path d="M3 15h18"></path>
<path d="M9 3v18"></path>
<path d="M15 3v18"></path>
</svg>
case "list":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12h.01"></path>
<path d="M3 18h.01"></path>
<path d="M3 6h.01"></path>
<path d="M8 12h13"></path>
<path d="M8 18h13"></path>
<path d="M8 6h13"></path>
</svg>
case "filter":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
case "search":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
case "calendar":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 2v4"></path>
<path d="M16 2v4"></path>
<rect width="18" height="18" x="3" y="4" rx="2"></rect>
<path d="M3 10h18"></path>
</svg>
case "pencil":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 1 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
case "trash":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2 w-4 h-4" aria-hidden="true">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" x2="10" y1="11" y2="17"></line>
<line x1="14" x2="14" y1="11" y2="17"></line>
</svg>
default:
<span aria-hidden="true">{ kind }</span>
}
}

View file

@ -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;
}

View file

@ -0,0 +1,27 @@
package ui
type ModalProps struct {
Title string
Body templ.Component
Actions templ.Component
}
templ Modal(props ModalProps) {
<div class="ui-modal-backdrop">
<div class="ui-modal-panel">
<div class="ui-modal-header">
<h2>{ props.Title }</h2>
</div>
if props.Body != nil {
<div class="ui-modal-body">
@props.Body
</div>
}
if props.Actions != nil {
<div class="ui-modal-actions">
@props.Actions
</div>
}
</div>
</div>
}

View file

@ -0,0 +1,13 @@
package ui
type SpaceProps struct {
Size SpacingStep
}
templ SpaceX(props SpaceProps) {
<span class={ SpaceXClass(props.Size) } aria-hidden="true"></span>
}
templ SpaceY(props SpaceProps) {
<div class={ SpaceYClass(props.Size) } aria-hidden="true"></div>
}

View file

@ -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;
}

View file

@ -0,0 +1,10 @@
.ui-table-shell {
overflow-x: auto;
width: 100%;
}
.ui-table {
border-collapse: collapse;
min-width: 100%;
width: 100%;
}

View file

@ -0,0 +1,23 @@
package ui
type TableProps struct {
Head templ.Component
Body templ.Component
}
templ Table(props TableProps) {
<div class="ui-table-shell">
<table class="ui-table">
<thead>
if props.Head != nil {
@props.Head
}
</thead>
<tbody>
if props.Body != nil {
@props.Body
}
</tbody>
</table>
</div>
}

View file

@ -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, "<h2>") || !strings.Contains(out, "Confirm action") {
t.Errorf("expected <h2> 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, "<svg") {
t.Errorf("expected <svg in output for 'plus' icon; got: %s", out)
}
}
func TestUIIcon_Fallback(t *testing.T) {
out := render(t, context.Background(), UIIcon("unknown-kind"))
if !strings.Contains(out, "<span") {
t.Errorf("expected <span fallback for unknown icon kind; got: %s", out)
}
}
func TestSpaceX_MD(t *testing.T) {
out := render(t, context.Background(), SpaceX(SpaceProps{Size: SpacingStepMD}))
if !strings.Contains(out, "ui-space-x-md") {
t.Errorf("expected ui-space-x-md in output; got: %s", out)
}
}
func TestSpaceY_LG(t *testing.T) {
out := render(t, context.Background(), SpaceY(SpaceProps{Size: SpacingStepLG}))
if !strings.Contains(out, "ui-space-y-lg") {
t.Errorf("expected ui-space-y-lg in output; got: %s", out)
}
}
func TestButton_IconRendered(t *testing.T) {
out := render(t, context.Background(), Button(ButtonProps{Label: "Add", Icon: "plus"}))
if !strings.Contains(out, "<svg") {
t.Errorf("expected <svg in output when Button.Icon is set; got: %s", out)
}
}

View file

@ -12,4 +12,9 @@
@import "./internal/web/ui/input.css";
@import "./internal/web/ui/textarea.css";
@import "./internal/web/ui/select.css";
@import "./internal/web/ui/modal.css";
@import "./internal/web/ui/empty-state.css";
@import "./internal/web/ui/table.css";
@import "./internal/web/ui/icon-button.css";
@import "./internal/web/ui/form-field.css";
@import "./internal/web/ui/spacing.css";