# Phase 13: Design System Foundation - Pattern Map

**Mapped:** 2026-05-16
**Files analyzed:** 22 (new/modified files across all waves)
**Analogs found:** 22 / 22 (all from go-backend reference implementation)

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/internal/web/ui/base.css` | config | static | `go-backend/internal/web/ui/base.css` | exact (verbatim port) |
| `backend/internal/web/ui/button.css` | config | static | `go-backend/internal/web/ui/button.css` | exact (replace) |
| `backend/internal/web/ui/badge.css` | config | static | `go-backend/internal/web/ui/badge.css` | exact (replace) |
| `backend/internal/web/ui/card.css` | config | static | `go-backend/internal/web/ui/card.css` | exact (replace) |
| `backend/internal/web/ui/input.css` | config | static | `go-backend/internal/web/ui/input.css` | exact (new file) |
| `backend/internal/web/ui/textarea.css` | config | static | `go-backend/internal/web/ui/textarea.css` | exact (new file) |
| `backend/internal/web/ui/select.css` | config | static | `go-backend/internal/web/ui/select.css` | exact (new file) |
| `backend/internal/web/ui/modal.css` | config | static | `go-backend/internal/web/ui/modal.css` | exact (new file) |
| `backend/internal/web/ui/empty-state.css` | config | static | `go-backend/internal/web/ui/empty-state.css` | exact (new file) |
| `backend/internal/web/ui/table.css` | config | static | `go-backend/internal/web/ui/table.css` | exact (new file) |
| `backend/internal/web/ui/icon-button.css` | config | static | `go-backend/internal/web/ui/icon-button.css` | exact (new file) |
| `backend/internal/web/ui/form-field.css` | config | static | `go-backend/internal/web/ui/form-field.css` | exact (new file) |
| `backend/internal/web/ui/spacing.css` | config | static | `go-backend/internal/web/ui/spacing.css` | exact (new file) |
| `backend/tailwind.input.css` | config | static | current file + RESEARCH.md manifest | exact |
| `backend/internal/web/ui/variants.go` | utility | transform | `go-backend/internal/web/ui/variants.go` | exact (extend) |
| `backend/internal/web/ui/helpers.go` | utility | transform | `go-backend/internal/web/ui/helpers.go` | exact (extend) |
| `backend/internal/web/ui/button.templ` | component | request-response | `go-backend/internal/web/ui/button.templ` | exact |
| `backend/internal/web/ui/card.templ` | component | request-response | `go-backend/internal/web/ui/card.templ` | exact (breaking API change) |
| `backend/internal/web/ui/input.templ` | component | request-response | `go-backend/internal/web/ui/input.templ` | exact |
| `backend/internal/web/ui/textarea.templ` | component | request-response | `go-backend/internal/web/ui/textarea.templ` | exact (new file) |
| `backend/internal/web/ui/select.templ` | component | event-driven | `go-backend/internal/web/ui/select.templ` | exact (new file) |
| `backend/internal/web/ui/select_helpers.go` | utility | transform | `go-backend/internal/web/ui/select_helpers.go` | exact (new file) |
| `backend/internal/web/ui/modal.templ` | component | request-response | `go-backend/internal/web/ui/modal.templ` | exact (new file) |
| `backend/internal/web/ui/empty_state.templ` | component | request-response | `go-backend/internal/web/ui/empty_state.templ` | exact (new file) |
| `backend/internal/web/ui/table.templ` | component | request-response | `go-backend/internal/web/ui/table.templ` | exact (new file) |
| `backend/internal/web/ui/icon_button.templ` | component | request-response | `go-backend/internal/web/ui/icon_button.templ` | exact (new file) |
| `backend/internal/web/ui/form_field.templ` | component | request-response | `go-backend/internal/web/ui/form_field.templ` | exact (new file) |
| `backend/internal/web/ui/space.templ` | component | request-response | `go-backend/internal/web/ui/space.templ` | exact (new file) |
| `backend/internal/web/ui/ui_test.go` | test | transform | `go-backend/internal/web/ui/ui_test.go` | exact (extend) |
| `backend/internal/web/ui/catalog/catalog.templ` | component | request-response | `go-backend/internal/web/ui/catalog/catalog.templ` | role-match (adapted) |
| `backend/internal/web/ui/catalog/examples.go` | utility | transform | `go-backend/internal/web/ui/catalog/examples.go` | role-match (adapted) |
| `backend/internal/web/catalog_route_catalog.go` | middleware | request-response | `backend/internal/web/router.go` | role-match |
| `backend/internal/web/catalog_route_stub.go` | middleware | request-response | build-tag stub pattern (RESEARCH.md) | role-match |
| `backend/internal/web/ui/auth.css` | config | static | `backend/internal/web/ui/button.css` lines 121–180 | exact (extract) |

---

## Pattern Assignments

### Wave 1: Token and Enum Foundation

---

### `backend/internal/web/ui/base.css` (config, static)

**Analog:** `go-backend/internal/web/ui/base.css`
**Action:** Replace the 28-line stub entirely. Port the 223-line `:root` block verbatim.

**Current stub pattern** (`backend/internal/web/ui/base.css` lines 1–28):
```css
/* 28-line stub — no tokens, only box-sizing reset and body defaults */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui; color: #0f172a; background-color: #ffffff; }
:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
```

**Target pattern** (`go-backend/internal/web/ui/base.css` lines 1–60, first section only):
```css
:root {
  /* Text */
  --color-text-primary: hsl(0 0% 9%);
  --color-text-secondary: #475467;
  --color-text-muted: hsl(0 0% 43.5%);
  --color-text-faint: #9ca3af;
  --color-text-inverse: #ffffff;
  --color-text-brand: #804eec;
  /* ... all 223 lines verbatim ... */

  /* Surfaces */
  --color-surface-page: hsl(0 0% 100%);
  --color-surface-default: #ffffff;

  /* Borders */
  --color-border-default: hsl(0 0% 90.9%);

  /* Brand and focus */
  --color-brand-primary: #804eec;
  --color-focus-ring: rgba(124, 58, 237, 0.2);
}
```

**Critical rule:** D-T02 — port ALL 223 lines, not just a subset. D-T03 — replace entirely, do not merge.

---

### `backend/internal/web/ui/variants.go` (utility, transform)

**Analog:** `go-backend/internal/web/ui/variants.go`

**Current pattern** (`backend/internal/web/ui/variants.go` lines 1–106):
- Exported `ButtonClass()`, `BadgeClass()`, `NormalizedButtonVariant()` etc.
- `ButtonClass` produces compound class: `"ui-button ui-button-solid-default-md"` (line 96)
- Missing: `IconButtonVariant`, `IconButtonTone`, `SpacingStep` enums
- Missing: `ButtonVariantGhost`, `BadgeVariantPrimary` constants

**Target multi-class pattern** (`go-backend/internal/web/ui/variants.go` lines 63–64):
```go
func buttonClass(variant ButtonVariant, tone ButtonTone, size Size) string {
    return "ui-button ui-button-" + string(normalizedButtonTone(tone)) +
           " ui-button-" + string(normalizedButtonVariant(variant)) +
           " ui-button-" + string(normalizedSize(size))
}
// Output: "ui-button ui-button-solid ui-button-default ui-button-md"
```

**New enums to add** (`go-backend/internal/web/ui/variants.go` lines 28–51):
```go
type IconButtonVariant string
const (
    IconButtonVariantNeutral IconButtonVariant = "neutral"
    IconButtonVariantWarning IconButtonVariant = "warning"
    IconButtonVariantSuccess IconButtonVariant = "success"
    IconButtonVariantDanger  IconButtonVariant = "danger"
)
type IconButtonTone string
const (
    IconButtonToneSolid IconButtonTone = "solid"
    IconButtonToneGhost IconButtonTone = "ghost"
)
type SpacingStep string
const (
    SpacingStepXS SpacingStep = "xs"
    SpacingStepSM SpacingStep = "sm"
    SpacingStepMD SpacingStep = "md"
    SpacingStepLG SpacingStep = "lg"
    SpacingStepXL SpacingStep = "xl"
)
```

**New variant constants to add:**
```go
// Add to ButtonVariant block:
ButtonVariantGhost ButtonVariant = "ghost"

// Add to BadgeVariant block:
BadgeVariantPrimary BadgeVariant = "primary"
```

**New class functions to add** (`go-backend/internal/web/ui/variants.go` lines 67–88):
```go
func IconButtonClass(variant IconButtonVariant, tone IconButtonTone) string {
    normalizedVariant := NormalizedIconButtonVariant(variant)
    switch NormalizedIconButtonTone(tone) {
    case IconButtonToneGhost:
        return "borderless-icon-button ui-icon-button-ghost ui-icon-button-" + string(normalizedVariant)
    default:
        return "ui-icon-button ui-icon-button-solid ui-icon-button-" + string(normalizedVariant)
    }
}
func SpaceXClass(step SpacingStep) string {
    return "ui-space-x ui-space-x-" + string(NormalizedSpacingStep(step))
}
func SpaceYClass(step SpacingStep) string {
    return "ui-space-y ui-space-y-" + string(NormalizedSpacingStep(step))
}
```

**Updated normalizer for Ghost variant:**
```go
func NormalizedButtonVariant(variant ButtonVariant) ButtonVariant {
    switch variant {
    case ButtonVariantNeutral, ButtonVariantWarning, ButtonVariantSuccess, ButtonVariantDanger, ButtonVariantGhost:
        return variant
    default:
        return ButtonVariantDefault
    }
}
```

**Naming convention:** Backend uses exported names (`ButtonClass`, `NormalizedButtonVariant`) while go-backend uses unexported (`buttonClass`, `normalizedButtonVariant`). Keep the exported naming for backend consistency.

---

### `backend/internal/web/ui/helpers.go` (utility, transform)

**Analog:** `go-backend/internal/web/ui/helpers.go`

**Current pattern** (`backend/internal/web/ui/helpers.go` lines 1–16):
- `mergeAttrs(base, override templ.Attributes) templ.Attributes` — keep this

**New functions to add** (`go-backend/internal/web/ui/helpers.go` lines 1–31):
```go
func buttonType(value string) string {
    if value == "" {
        return "button"
    }
    return value
}
func inputType(value string) string {
    if value == "" {
        return "text"
    }
    return value
}
func inputID(id string, name string) string {
    if id != "" {
        return id
    }
    return name
}
func textareaRows(rows int) string {
    if rows <= 0 {
        rows = 4
    }
    return strconv.Itoa(rows)
}
```

**Import to add:** `"strconv"` (for `textareaRows`)

---

### Wave 2: Migrate Existing Components

---

### `backend/internal/web/ui/auth.css` (config, static — NEW prerequisite file)

**Analog:** `backend/internal/web/ui/button.css` lines 121–180 (auth-provider block to extract)

**Reason:** Before replacing `button.css` entirely with go-backend's version, the auth-provider selectors (`.auth-provider-button`, `.auth-provider-separator`, etc.) must be moved to a standalone `auth.css` file. Go-backend has no equivalent — this is backend-only CSS from Phase 8.

**Extract pattern** (lines 121–180 of current `backend/internal/web/ui/button.css`):
```css
/* auth.css — sign-in provider controls, extracted from button.css in Phase 13 */
.auth-provider-stack { ... }
.auth-provider-button { ... }
.auth-provider-button:hover { ... }
.auth-provider-button:focus-visible { ... }
.auth-provider-button-disabled { ... }
.auth-provider-separator { ... }
.auth-provider-separator span { ... }
.auth-provider-separator em { ... }
```

Then add `@import "./internal/web/ui/auth.css";` to `tailwind.input.css` to preserve login page styling.

---

### `backend/internal/web/ui/button.css` (config, static — REPLACE)

**Analog:** `go-backend/internal/web/ui/button.css`

**Multi-class selector pattern** (`go-backend/internal/web/ui/button.css` lines 1–163):
```css
/* Base button */
.ui-button {
    align-items: center;
    border: 0;
    border-radius: 0.35rem;
    cursor: pointer;
    display: inline-flex;
    font-weight: 600;
    gap: 0.5rem;
    justify-content: center;
    line-height: 1;
    min-height: 44px;
    text-decoration: none;
    transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.ui-button-icon, .ui-button-icon svg { height: 1rem; width: 1rem; }
.ui-button:focus-visible { box-shadow: 0 0 0 3px var(--color-focus-ring); outline: none; }

/* Sizes — standalone, apply to all variants */
.ui-button-sm { font-size: 0.875rem; min-height: 40px; padding: 0.625rem 0.9rem; }
.ui-button-md { font-size: 0.95rem; padding: 0.7rem 1rem; }
.ui-button-lg { font-size: 1rem; padding: 0.82rem 1.15rem; }

/* Solid+variant compound selectors */
.ui-button-solid.ui-button-default { background: var(--color-brand-primary); color: var(--color-text-inverse); }
.ui-button-solid.ui-button-default:hover { background: var(--color-brand-primary-hover); }
/* ... all tone+variant combinations from go-backend ... */
.ui-button-soft.ui-button-danger { background: var(--color-status-danger-soft-bg-alt); color: var(--color-status-danger-soft-foreground-strong); }
```

**Ghost variant to ADD** (not in go-backend — authored new per RESEARCH.md):
```css
/* Ghost variant — new in Phase 13, D-CA01 */
.ui-button-ghost {
    background: transparent;
    color: var(--color-brand-primary);
}
.ui-button-ghost:hover { background: var(--color-surface-brand-soft); }
.ui-button-ghost:focus-visible { box-shadow: 0 0 0 3px var(--color-focus-ring); outline: none; }
```

**CRITICAL: No nesting rule.** Both the current and go-backend files use flat top-level selectors. `.ui-button-solid.ui-button-default` is a compound selector (two classes on the same element) — not nesting.

---

### `backend/internal/web/ui/button.templ` (component, request-response — UPDATE)

**Analog:** `go-backend/internal/web/ui/button.templ`

**Current pattern** (`backend/internal/web/ui/button.templ` lines 1–23):
```go
type ButtonProps struct {
    Label   string
    Variant ButtonVariant
    Tone    ButtonTone
    Size    Size
    Type    string
    Attrs   templ.Attributes
}
templ Button(props ButtonProps) {
    {{ btnType := props.Type }}
    if btnType == "" { {{ btnType = "button" }} }
    {{ class := ButtonClass(props.Variant, props.Tone, props.Size) }}
    <button type={ btnType } class={ class } { props.Attrs... }>{ props.Label }</button>
}
```

**Target pattern** (`go-backend/internal/web/ui/button.templ` lines 1–22):
```go
type ButtonProps struct {
    Label   string
    Variant ButtonVariant
    Tone    ButtonTone
    Size    Size
    Type    string
    Icon    string          // NEW — added from go-backend
    Attrs   templ.Attributes
}
templ Button(props ButtonProps) {
    <button type={ buttonType(props.Type) } class={ ButtonClass(props.Variant, props.Tone, props.Size) } { props.Attrs... }>
        if props.Icon != "" {
            <span class="ui-button-icon">
                @UIIcon(props.Icon)
            </span>
        }
        { props.Label }
    </button>
}
```

**Notes:**
- Switch from inline `{{ btnType := ... }}` to `buttonType()` helper from `helpers.go`
- `ButtonClass()` output changes from compound to multi-class (via variants.go update)
- Do NOT add `Disabled bool` — use `Attrs: templ.Attributes{"disabled": true}` per RESEARCH.md

---

### Template hardcode migration (4 files)

**Files with old compound class strings:**
- `backend/templates/planning.templ` — lines 16, 20, 22, 25
- `backend/templates/tasks.templ` — lines 158, 375
- `backend/templates/events.templ` — lines 23, 32
- `backend/templates/etapes.templ` — lines 57, 65, 77, 86, 93

**Old → new class string mapping:**
```
"ui-button ui-button-solid-default-md"  →  "ui-button ui-button-solid ui-button-default ui-button-md"
"ui-button ui-button-soft-neutral-md"   →  "ui-button ui-button-soft ui-button-neutral ui-button-md"
"ui-button ui-button-soft-danger-md"    →  "ui-button ui-button-soft ui-button-danger ui-button-md"
```

**Pattern for additional Tailwind utility classes** (tasks.templ, etapes.templ have extras like `flex-shrink-0`, `text-xs`):
Keep the extra classes; replace only the `ui-button-*` part:
```
class="ui-button ui-button-soft-danger-md flex-shrink-0 text-xs"
→
class="ui-button ui-button-soft ui-button-danger ui-button-md flex-shrink-0 text-xs"
```

---

### `backend/internal/web/ui/badge.css` (config, static — REPLACE)

**Analog:** `go-backend/internal/web/ui/badge.css`

**Target pattern** (`go-backend/internal/web/ui/badge.css` lines 1–33):
```css
.ui-badge {
    border: 1px solid transparent;
    border-radius: 999px;    /* pill shape — different from current */
    display: inline-flex;
    font-size: 0.75rem;
    font-weight: 600;
    line-height: 1.2;
    padding: 0.3rem 0.75rem;
}
.ui-badge-info { background: var(--color-status-info-soft-bg); border-color: var(--color-status-info-soft-border); color: var(--color-status-info-foreground); }
.ui-badge-warning { background: var(--color-status-warning-soft-bg); border-color: var(--color-status-warning-soft-border); color: var(--color-status-warning-foreground); }
.ui-badge-success { background: var(--color-status-success-soft-bg); border-color: var(--color-status-success-soft-border); color: var(--color-status-success-foreground); }
.ui-badge-danger { background: var(--color-status-danger-soft-bg); border-color: var(--color-status-danger-soft-border); color: var(--color-status-danger-foreground); }
```

**Primary variant to ADD** (not in go-backend — authored new per D-CA02):
```css
.ui-badge-primary {
    background: var(--color-surface-brand-soft);
    border-color: rgba(128, 78, 236, 0.3);
    color: var(--color-text-brand);
}
```

---

### `backend/internal/web/ui/card.css` (config, static — REPLACE)

**Analog:** `go-backend/internal/web/ui/card.css`

**Target pattern** (`go-backend/internal/web/ui/card.css` lines 1–27):
```css
.ui-card { background: var(--color-surface-default); border: 1px solid var(--color-border-default); border-radius: 1rem; box-shadow: var(--shadow-surface-md); }
.ui-card-header, .ui-card-body, .ui-card-footer { padding: 1.25rem 1.5rem; }
.ui-card-header, .ui-card-footer { border-color: var(--color-border-default); }
.ui-card-header { border-bottom-style: solid; border-bottom-width: 1px; }
.ui-card-footer { border-top-style: solid; border-top-width: 1px; }
```

---

### `backend/internal/web/ui/card.templ` (component, request-response — BREAKING UPDATE)

**Analog:** `go-backend/internal/web/ui/card.templ`

**Current pattern** (`backend/internal/web/ui/card.templ` lines 1–11) — children-based:
```go
templ Card(attrs templ.Attributes) {
    <section class="ui-card" { attrs... }>
        { children... }
    </section>
}
```

**Target pattern** (`go-backend/internal/web/ui/card.templ` lines 1–27) — typed fields:
```go
type CardProps struct {
    Header templ.Component
    Body   templ.Component
    Footer templ.Component
}
templ Card(props CardProps) {
    <section class="ui-card">
        if props.Header != nil {
            <div class="ui-card-header">@props.Header</div>
        }
        if props.Body != nil {
            <div class="ui-card-body">@props.Body</div>
        }
        if props.Footer != nil {
            <div class="ui-card-footer">@props.Footer</div>
        }
    </section>
}
```

**Call site impact:** No production templates call `Card` with children — the only breakage is `TestCard_RendersChildren` in `ui_test.go` (see test section below).

---

### Wave 3: New CSS + templ Files

---

### `backend/internal/web/ui/input.css` (config, static — NEW)

**Analog:** `go-backend/internal/web/ui/input.css` (port verbatim, strip any page-level selectors)

```css
.ui-input {
    appearance: none;
    background: var(--color-surface-default);
    border: 1px solid var(--color-border-default);
    border-radius: 0.75rem;
    color: var(--color-text-primary);
    font: inherit;
    line-height: 1.4;
    min-height: 44px;
    padding: 0.75rem 0.95rem;
    width: 100%;
}
.ui-input::placeholder { color: var(--color-text-faint); }
.ui-input:focus { border-color: var(--color-brand-focus); box-shadow: 0 0 0 3px var(--color-focus-ring-strong); outline: none; }
```

---

### `backend/internal/web/ui/input.templ` (component, request-response — UPDATE existing stub)

**Analog:** `go-backend/internal/web/ui/input.templ`

**Target pattern** (`go-backend/internal/web/ui/input.templ` lines 1–22):
```go
type InputProps struct {
    ID          string
    Name        string
    Value       string
    Placeholder string
    Type        string
    Attrs       templ.Attributes
}
templ Input(props InputProps) {
    <input
        id={ inputID(props.ID, props.Name) }
        type={ inputType(props.Type) }
        name={ props.Name }
        value={ props.Value }
        placeholder={ props.Placeholder }
        class="ui-input"
        { props.Attrs... }
    />
}
```

**Note on `Disabled`/`Required`:** Go-backend does NOT have these as explicit fields — pass via `Attrs: templ.Attributes{"disabled": true}`. The UI-SPEC allows adding them as explicit fields (D-CA03 is Claude's discretion). Planner may choose to add them with `if props.Disabled { disabled }` conditionals per RESEARCH.md Props detail section.

---

### `backend/internal/web/ui/textarea.templ` (component, request-response — NEW FILE)

**Analog:** `go-backend/internal/web/ui/textarea.templ`

**Pattern** (`go-backend/internal/web/ui/textarea.templ` lines 1–21):
```go
type TextareaProps struct {
    ID          string
    Name        string
    Value       string
    Placeholder string
    Rows        int
    Attrs       templ.Attributes
}
templ Textarea(props TextareaProps) {
    <textarea
        id={ inputID(props.ID, props.Name) }
        name={ props.Name }
        placeholder={ props.Placeholder }
        rows={ textareaRows(props.Rows) }
        class="ui-textarea"
        { props.Attrs... }
    >{ props.Value }</textarea>
}
```

**Helpers used:** `inputID()` and `textareaRows()` from `helpers.go` (add these in Wave 1).

---

### `backend/internal/web/ui/textarea.css` (config, static — NEW FILE)

Port verbatim from go-backend. File mirrors input.css styling with `min-height: 7rem` and `resize: vertical`.

---

### `backend/internal/web/ui/select.templ` + `select_helpers.go` (component, event-driven — NEW FILES)

**Analog:** `go-backend/internal/web/ui/select.templ` + `go-backend/internal/web/ui/select_helpers.go`

**Props pattern** (`go-backend/internal/web/ui/select.templ` lines 1–18):
```go
type SelectOption struct {
    Value    string
    Label    string
    Disabled bool
}
type SelectProps struct {
    ID          string
    Name        string
    Placeholder string
    Value       string
    Values      []string
    Multiple    bool
    Options     []SelectOption
    Attrs       templ.Attributes
}
```

**Inline script pattern** (`go-backend/internal/web/ui/select.templ` lines 114–249):
The `<script>` block inside `templ Select(...)` uses `window.__uiSelectInitAll` + `htmx:afterSwap` listener. Port this verbatim — HTMX re-init support is mandatory (Pitfall 6).

**select_helpers.go pattern** (`go-backend/internal/web/ui/select_helpers.go` lines 1–104):
Port verbatim. Key helpers: `selectPlaceholder`, `selectNativeID`, `selectMenuID`, `selectBoolData`, `selectOptionSelected`, `selectSelectedLabels`, `selectSelectedLabel`, `selectMenuOptionClass`, `selectIsDisabled`.

Import required: `"strings"` and `"github.com/a-h/templ"`.

---

### `backend/internal/web/ui/modal.templ` (component, request-response — NEW FILE)

**Analog:** `go-backend/internal/web/ui/modal.templ`

**Pattern** (`go-backend/internal/web/ui/modal.templ` lines 1–27):
```go
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>
}
```

**Catalog rendering note:** The catalog must render only `ui-modal-panel` directly (no backdrop wrapper) to avoid the fixed-position overlay covering the catalog page (Pitfall 7).

---

### `backend/internal/web/ui/empty_state.templ` (component, request-response — NEW FILE)

**Analog:** `go-backend/internal/web/ui/empty_state.templ`

**Pattern** (`go-backend/internal/web/ui/empty_state.templ` lines 1–28):
```go
type EmptyStateProps struct {
    Title       string
    Description string
    Icon        templ.Component   // NOT string — pass ui.UIIcon("grid3x3")
    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>
}
```

---

### `backend/internal/web/ui/table.templ` (component, request-response — NEW FILE)

**Analog:** `go-backend/internal/web/ui/table.templ`

**Pattern** (`go-backend/internal/web/ui/table.templ` lines 1–24):
```go
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>
}
```

---

### `backend/internal/web/ui/icon_button.templ` (component, request-response — NEW FILE)

**Analog:** `go-backend/internal/web/ui/icon_button.templ`

**Pattern** (`go-backend/internal/web/ui/icon_button.templ` lines 1–74):
```go
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 ...><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
        case "grid3x3": <svg ...>...</svg>
        case "list": <svg ...>...</svg>
        case "filter": <svg ...>...</svg>
        case "search": <svg ...>...</svg>
        case "calendar": <svg ...>...</svg>
        case "pencil": <svg ...>...</svg>
        case "trash": <svg ...>...</svg>
        default: <span aria-hidden="true">{ kind }</span>
    }
}
```

**Note:** Backend uses exported `IconButtonClass()` (from variants.go); go-backend uses unexported `iconButtonClass()`. Use exported naming for backend consistency.

---

### `backend/internal/web/ui/form_field.templ` (component, request-response — NEW FILE)

**Analog:** `go-backend/internal/web/ui/form_field.templ`

**Pattern** (`go-backend/internal/web/ui/form_field.templ` lines 1–26):
```go
type FormFieldProps struct {
    Label string
    For   string
    Field templ.Component
    Error string
    Hint  string
}
templ FormField(props FormFieldProps) {
    <div class="ui-form-field">
        if props.Label != "" {
            <label for={ props.For } class="ui-form-label">{ props.Label }</label>
        }
        if props.Field != nil { @props.Field }
        if props.Hint != "" {
            <p class="ui-form-hint">{ props.Hint }</p>
        }
        if props.Error != "" {
            <p class="ui-form-error">{ props.Error }</p>
        }
    </div>
}
```

---

### `backend/internal/web/ui/space.templ` (component, request-response — NEW FILE)

**Analog:** `go-backend/internal/web/ui/space.templ`

**Pattern** (`go-backend/internal/web/ui/space.templ` lines 1–13):
```go
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>
}
```

---

### `backend/tailwind.input.css` (config, static — UPDATE)

**Analog:** Current file (`backend/tailwind.input.css` lines 1–11) + RESEARCH.md manifest

**Current pattern** (lines 1–11):
```css
@import "tailwindcss";
@source "./templates/**/*.templ";
@source "./internal/web/**/*.templ";
@source "./internal/web/**/*.go";
@import "./internal/web/ui/base.css";
@import "./internal/web/ui/button.css";
@import "./internal/web/ui/card.css";
@import "./internal/web/ui/badge.css";
```

**Target pattern** (final state per RESEARCH.md):
```css
@import "tailwindcss";
@source "./templates/**/*.templ";
@source "./internal/web/**/*.templ";
@source "./internal/web/**/*.go";
@import "./internal/web/ui/base.css";
@import "./internal/web/ui/auth.css";
@import "./internal/web/ui/button.css";
@import "./internal/web/ui/badge.css";
@import "./internal/web/ui/card.css";
@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";
```

---

### `backend/internal/web/ui/ui_test.go` (test — EXTEND)

**Analog:** `go-backend/internal/web/ui/ui_test.go`

**Current test helper pattern** (`backend/internal/web/ui/ui_test.go` lines 12–21):
```go
func render(t *testing.T, ctx context.Context, c templ.Component) string {
    t.Helper()
    var buf bytes.Buffer
    if err := c.Render(ctx, &buf); err != nil {
        t.Fatalf("render: unexpected error: %v", err)
    }
    return buf.String()
}
```

**Target helper pattern** (`go-backend/internal/web/ui/ui_test.go` lines 467–482):
```go
func renderToString(t *testing.T, component templ.Component) string {
    t.Helper()
    var buf bytes.Buffer
    if err := component.Render(context.Background(), &buf); err != nil {
        t.Fatalf("render component: %v", err)
    }
    return buf.String()
}
func textComponent(text string) templ.Component {
    return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
        _, err := w.Write([]byte(text))
        return err
    })
}
```

**Tests to rewrite/update:**
1. `TestButton_DefaultSolidMD` — update `wantClass` from `"ui-button ui-button-solid-default-md"` to asserting `"ui-button"`, `"ui-button-solid"`, `"ui-button-default"`, `"ui-button-md"` separately (slice pattern from go-backend lines 26–36)
2. `TestButtonClass_String` — update assertion from `"ui-button ui-button-solid-default-md"` to `"ui-button ui-button-solid ui-button-default ui-button-md"`
3. `TestCard_RendersChildren` — REWRITE to `TestCard_RendersTypedRegions` using `CardProps{Header: ..., Body: ..., Footer: ...}` (go-backend lines 402–421)

**New test functions to add** (following go-backend test pattern with `[]string` slice of expected substrings):
```go
// Pattern from go-backend lines 15–37:
func TestXxx(t *testing.T) {
    component := ComponentName(ComponentProps{...})
    html := renderToString(t, component)
    for _, want := range []string{
        `expected-attr`,
        `expected-class`,
        `expected-content`,
    } {
        if !strings.Contains(html, want) {
            t.Fatalf("expected %q in %q", want, html)
        }
    }
}
```

**Required imports for test file:** `"bytes"`, `"context"`, `"io"`, `"strings"`, `"testing"`, `"github.com/a-h/templ"`

---

### Wave 4: Catalog

---

### `backend/internal/web/ui/catalog/catalog.templ` (component, request-response — NEW DIR+FILE)

**Analog:** `go-backend/internal/web/ui/catalog/catalog.templ`

**Go-backend catalog structure** — uses static HTML file generation pattern (separate `cmd/designsystem`). The backend instead uses a live HTTP handler. The templ layout structure is still the same model.

**Backend catalog templ pattern** (adapted from go-backend pattern, lines 3–61):
- Single-page layout: sidebar nav (anchor links to `#buttons`, `#badges`, etc.) + main content area
- Each component section: rendered variants + `<pre><code>` templ snippet
- Fake shell using Tailwind utilities, not app.css classes
- No JavaScript — all pre-rendered

**`pages.go` / struct pattern** (`go-backend/internal/web/ui/catalog/pages.go` lines 6–17):
```go
type Example struct {
    Title       string
    Description string
    Preview     templ.Component    // rendered component
    Snippet     string             // templ call as a raw string
}
// Used as: Example{Title: "...", Preview: ui.Button(...), Snippet: `@ui.Button(...)`}
```

---

### `backend/internal/web/ui/catalog/examples.go` (utility, transform — NEW FILE)

**Analog:** `go-backend/internal/web/ui/catalog/examples.go`

**Pattern** (`go-backend/internal/web/ui/catalog/examples.go` lines 14–69):
```go
func buttonExamples() []Example {
    return []Example{
        {
            Title:   "Default solid action",
            Preview: ui.Button(ui.ButtonProps{Label: "Nouveau projet", Variant: ui.ButtonVariantDefault, Size: ui.SizeMD, Type: "button"}),
            Snippet: `@ui.Button(ui.ButtonProps{
    Label:   "Nouveau projet",
    Variant: ui.ButtonVariantDefault,
    ...
})`,
        },
        // ... more variants
    }
}
```

**Import pattern:**
```go
import (
    "context"
    "io"
    "github.com/a-h/templ"
    "backend/internal/web/ui"
)
```

**Helper for inline components** (`go-backend/internal/web/ui/catalog/examples.go`):
```go
type anyComponent = templ.Component

func componentFunc(f func(ctx context.Context, w io.Writer) error) templ.Component {
    return templ.ComponentFunc(f)
}
```

---

### `backend/internal/web/catalog_route_catalog.go` + `catalog_route_stub.go` (middleware — NEW FILES)

**Analog:** RESEARCH.md build-tag pattern + `backend/internal/web/router.go`

**Two-file build-tag pattern** (`go-backend` adapted, per RESEARCH.md lines 445–465):

```go
// File: backend/internal/web/catalog_route_catalog.go
//go:build catalog
package web

import "github.com/go-chi/chi/v5"

func RegisterCatalogRoute(r chi.Router) {
    r.Get("/ui-catalog", catalogPageHandler())
    // If multi-page: r.Get("/ui-catalog/{slug}", catalogSectionHandler())
}
```

```go
// File: backend/internal/web/catalog_route_stub.go
//go:build !catalog
package web

import "github.com/go-chi/chi/v5"

func RegisterCatalogRoute(r chi.Router) {} // no-op in production builds
```

**Router wiring** (`backend/internal/web/router.go` — add one call):
```go
// In NewRouter, after all protected routes, before healthz:
RegisterCatalogRoute(r)
```

**Handler signature pattern** (from existing handlers in `backend/internal/web/`):
```go
func catalogPageHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // render catalog.templ
    }
}
```

---

## Shared Patterns

### templ Component — nil-guard pattern for optional regions
**Source:** `go-backend/internal/web/ui/card.templ`, `modal.templ`, `empty_state.templ`, `form_field.templ`
**Apply to:** All new components with optional `templ.Component` fields
```go
if props.OptionalField != nil {
    <div class="ui-component-region">
        @props.OptionalField
    </div>
}
```

### templ Component — Attrs pass-through pattern
**Source:** `backend/internal/web/ui/button.templ` line 22, `go-backend` input/textarea/select
**Apply to:** All components with `Attrs templ.Attributes` field
```go
// In the root element of the templ:
{ props.Attrs... }
```

### CSS — no-nesting rule
**Source:** `backend/internal/web/ui/button.css` comment (line 1), all go-backend CSS files
**Apply to:** All CSS files ported from go-backend
```css
/* Correct — flat top-level selectors */
.ui-button-solid.ui-button-default { background: ...; }
.ui-button-solid.ui-button-default:hover { background: ...; }

/* Wrong — CSS nesting (not used in this project) */
.ui-button-solid.ui-button-default { background: ...; &:hover { background: ...; } }
```

### Go test — multi-assertion slice pattern
**Source:** `go-backend/internal/web/ui/ui_test.go` lines 15–37
**Apply to:** All new component tests in `ui_test.go`
```go
for _, want := range []string{
    `expected-attribute`,
    `expected-class`,
    `expected-content`,
} {
    if !strings.Contains(html, want) {
        t.Fatalf("expected %q in %q", want, html)
    }
}
```

### Go test — textComponent helper for templ.Component fields
**Source:** `go-backend/internal/web/ui/ui_test.go` lines 477–482
**Apply to:** Tests for Card, Modal, Table, EmptyState, FormField (all with `templ.Component` Props fields)
```go
func textComponent(text string) templ.Component {
    return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
        _, err := w.Write([]byte(text))
        return err
    })
}
```

### Exported vs unexported convention
**Source:** `backend/internal/web/ui/variants.go` (exported), `go-backend/internal/web/ui/variants.go` (unexported)
**Apply to:** All new class-generation functions in backend
```go
// backend pattern — exported (callers are in templates package and tests):
func ButtonClass(...) string { ... }
func IconButtonClass(...) string { ... }
func SpaceXClass(...) string { ... }
// go-backend pattern (unexported — same package only) — do NOT follow for backend
func buttonClass(...) string { ... }
```

---

## No Analog Found

All files in this phase have direct go-backend analogs. The only authored-new content (no upstream reference) is:

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `backend/internal/web/ui/button.css` `.ui-button-ghost` rule | config | static | Ghost variant added in Phase 13 (D-CA01); does not exist in go-backend |
| `backend/internal/web/ui/badge.css` `.ui-badge-primary` rule | config | static | Primary badge added in Phase 13 (D-CA02); does not exist in go-backend |
| `backend/internal/web/ui/auth.css` | config | static | Auth-provider CSS from Phase 8 extracted from button.css; no go-backend equivalent |

For these, the pattern is:
- Ghost button CSS: follow the compound selector pattern `.ui-button-ghost { ... }` with `background: transparent; color: var(--color-brand-primary)` (RESEARCH.md lines 296–310)
- Primary badge CSS: `.ui-badge-primary { background: var(--color-surface-brand-soft); border-color: rgba(128,78,236,0.3); color: var(--color-text-brand); }` (RESEARCH.md lines 313–320)
- Auth CSS: verbatim copy of lines 121–180 from current `backend/internal/web/ui/button.css`

---

## Metadata

**Analog search scope:** `go-backend/internal/web/ui/`, `backend/internal/web/ui/`, `backend/templates/`
**Files scanned:** 34
**Pattern extraction date:** 2026-05-16
