---
phase: 13-design-system-foundation
plan: 03
type: execute
wave: 3
depends_on:
  - 13-01
  - 13-02
files_modified:
  - backend/internal/web/ui/input.css
  - backend/internal/web/ui/input.templ
  - backend/internal/web/ui/textarea.css
  - backend/internal/web/ui/textarea.templ
  - backend/internal/web/ui/select.css
  - backend/internal/web/ui/select.templ
  - backend/internal/web/ui/select_helpers.go
  - backend/internal/web/ui/form-field.css
  - backend/internal/web/ui/form_field.templ
  - backend/internal/web/ui/ui_test.go
autonomous: true
requirements:
  - DS-03

must_haves:
  truths:
    - "Input component renders with class ui-input and respects ID/Name/Placeholder/Type props"
    - "Textarea component renders with class ui-textarea and respects Rows default (4 when 0)"
    - "Select component renders with class ui-select-control, inline JS for open/close, and HTMX re-init listener"
    - "FormField wraps any component with label, hint, and error regions"
    - "All four CSS files exist and contain their primary .ui-* selectors"
    - "go test ./internal/web/ui/... passes for all new component tests"
    - "just generate succeeds — all new .templ files compile"
  artifacts:
    - path: "backend/internal/web/ui/input.templ"
      provides: "Input component with InputProps"
      contains: "type InputProps struct"
    - path: "backend/internal/web/ui/textarea.templ"
      provides: "Textarea component with TextareaProps"
      contains: "type TextareaProps struct"
    - path: "backend/internal/web/ui/select.templ"
      provides: "Select component with inline JS"
      contains: "SelectProps"
    - path: "backend/internal/web/ui/select_helpers.go"
      provides: "Select helper functions"
      contains: "selectPlaceholder"
    - path: "backend/internal/web/ui/form_field.templ"
      provides: "FormField wrapper component"
      contains: "type FormFieldProps struct"
  key_links:
    - from: "backend/internal/web/ui/select.templ"
      to: "select_helpers.go"
      via: "selectPlaceholder(), selectOptionSelected() calls"
      pattern: "selectPlaceholder"
    - from: "backend/internal/web/ui/textarea.templ"
      to: "helpers.go"
      via: "textareaRows(), inputID() calls"
      pattern: "textareaRows"
---

## Phase Goal

**As a** developer, **I want to** have Input, Textarea, Select, and FormField components available as templ components with matching CSS, **so that** form-heavy views in Phases 14–17 can use these components instead of raw HTML form elements.

<objective>
Port all four form-input component types from go-backend to backend. Each component delivers
a complete vertical slice: CSS file → templ Props struct → templ rendering → test coverage.

Purpose: These are the form primitives that all subsequent phases need. Auth pages (Phase 14)
need Input and FormField. Tablo create/edit dialogs (Phase 15-16) need all four.

Output:
- input.css + input.templ (update existing stub to match go-backend API)
- textarea.css + textarea.templ (new files — port from go-backend)
- select.css + select.templ + select_helpers.go (new files — complex, includes inline JS)
- form-field.css + form_field.templ (new files — wrapper component)
- ui_test.go extended with tests for all four component types
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/13-design-system-foundation/13-CONTEXT.md
@.planning/phases/13-design-system-foundation/13-RESEARCH.md
@.planning/phases/13-design-system-foundation/13-PATTERNS.md
@.planning/phases/13-design-system-foundation/13-01-SUMMARY.md
@.planning/phases/13-design-system-foundation/13-02-SUMMARY.md
</context>

<interfaces>
From backend/internal/web/ui/helpers.go (after Plan 01):
  func inputType(value string) string   // returns "text" if empty
  func inputID(id string, name string) string  // returns id if non-empty, else name
  func textareaRows(rows int) string    // returns "4" if rows <= 0

From go-backend/internal/web/ui/select_helpers.go (source to port):
  func selectPlaceholder(props SelectProps) string
  func selectNativeID(id, name string) string
  func selectMenuID(id, name string) string
  func selectBoolData(b bool) string
  func selectOptionSelected(option SelectOption, values []string) bool
  func selectSelectedLabels(props SelectProps) string
  func selectSelectedLabel(props SelectProps) string
  func selectMenuOptionClass(option SelectOption, values []string) string
  func selectIsDisabled(props SelectProps, option SelectOption) bool
</interfaces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Port input.templ, input.css, textarea.templ, textarea.css + tests</name>
  <files>
    backend/internal/web/ui/input.css,
    backend/internal/web/ui/input.templ,
    backend/internal/web/ui/textarea.css,
    backend/internal/web/ui/textarea.templ,
    backend/internal/web/ui/ui_test.go
  </files>
  <read_first>
    - backend/internal/web/ui/input.templ (existing stub — see current InputProps fields to understand what is already there)
    - go-backend/internal/web/ui/input.templ (source Props struct and rendering pattern)
    - go-backend/internal/web/ui/input.css (source CSS — port verbatim)
    - go-backend/internal/web/ui/textarea.templ (source Props struct and rendering pattern)
    - go-backend/internal/web/ui/textarea.css (source CSS — port verbatim)
    - 13-PATTERNS.md (input.templ, textarea.templ, input.css, textarea.css sections)
    - 13-RESEARCH.md (Props Struct Alignment Detail — Input and Textarea sections)
    - 13-UI-SPEC.md (Input and Textarea component inventory sections)
  </read_first>
  <behavior>
    - Test: Input(InputProps{Name: "email", Type: "email"}) HTML contains 'class="ui-input"' and 'type="email"'
    - Test: Input(InputProps{Name: "x"}) HTML contains 'type="text"' (default via inputType helper)
    - Test: Input(InputProps{ID: "my-id", Name: "x"}) HTML contains 'id="my-id"'
    - Test: Input(InputProps{Name: "x"}) with no ID — HTML contains 'id="x"' (inputID fallback to name)
    - Test: Textarea(TextareaProps{Name: "body"}) HTML contains 'class="ui-textarea"'
    - Test: Textarea(TextareaProps{Name: "x", Rows: 0}) HTML contains 'rows="4"' (default via textareaRows)
    - Test: Textarea(TextareaProps{Name: "x", Rows: 6}) HTML contains 'rows="6"'
  </behavior>
  <action>
    Step 1 — Write failing tests in ui_test.go (RED):
    Add TestInput_DefaultType, TestInput_EmailType, TestInput_IDFallback,
    TestTextarea_DefaultRows, TestTextarea_ExplicitRows.
    Run go test ./internal/web/ui/... — confirm failures.

    Step 2 — Create/update input.css: port go-backend/internal/web/ui/input.css verbatim.
    The file must contain .ui-input { } selector with: appearance: none, background, border,
    border-radius: 0.75rem, color, font: inherit, line-height: 1.4, min-height: 44px,
    padding: 0.75rem 0.95rem, width: 100%.
    Strip any page-level selectors (body, :root).

    Step 3 — Update input.templ to match go-backend's Props API:
    InputProps must have: Name string, ID string, Type string, Placeholder string, Value string, Attrs templ.Attributes.
    The templ function uses: inputID(props.ID, props.Name) for id, inputType(props.Type) for type.
    Per D-CA03 (Claude's discretion on props alignment) and UI-SPEC explicit fields: also add
    Disabled bool and Required bool with conditional attribute rendering:
    if props.Disabled { disabled } and if props.Required { required } in the <input> element.

    Step 4 — Create textarea.css: port go-backend/internal/web/ui/textarea.css verbatim.
    Must contain .ui-textarea { } with: same appearance/border/border-radius/color/font as input,
    min-height: 7rem, resize: vertical, width: 100%.

    Step 5 — Create textarea.templ: new file in backend/internal/web/ui/textarea.templ.
    package ui declaration at top. TextareaProps must have: Name string, ID string,
    Placeholder string, Value string, Rows int, Disabled bool, Required bool, Attrs templ.Attributes.
    The templ function renders <textarea> using inputID(props.ID, props.Name),
    textareaRows(props.Rows), and { props.Attrs... }.

    Run just generate && go test ./internal/web/ui/... — all tests GREEN.
  </action>
  <verify>
    <automated>cd backend && just generate && go test ./internal/web/ui/... -run "TestInput|TestTextarea" -v</automated>
  </verify>
  <acceptance_criteria>
    - TestInput_DefaultType: HTML contains 'type="text"'
    - TestInput_EmailType: HTML contains 'type="email"'
    - TestInput_IDFallback: HTML contains 'id="email"' when no explicit ID but Name="email"
    - TestTextarea_DefaultRows: HTML contains 'rows="4"'
    - TestTextarea_ExplicitRows: HTML contains 'rows="6"'
    - input.css contains ".ui-input {" and "min-height: 44px"
    - textarea.css contains ".ui-textarea {" and "min-height: 7rem" and "resize: vertical"
    - input.templ contains "type InputProps struct" with Name, ID, Type, Placeholder, Value, Disabled, Required, Attrs fields
    - textarea.templ contains "type TextareaProps struct" with Name, ID, Placeholder, Value, Rows, Disabled, Required, Attrs fields
    - just generate succeeds
    - go test ./internal/web/ui/... is fully green
  </acceptance_criteria>
  <done>input.css/input.templ created; textarea.css/textarea.templ created; all input/textarea tests passing</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Port select.templ + select_helpers.go + select.css + form_field.templ + form-field.css + tests</name>
  <files>
    backend/internal/web/ui/select.css,
    backend/internal/web/ui/select.templ,
    backend/internal/web/ui/select_helpers.go,
    backend/internal/web/ui/form-field.css,
    backend/internal/web/ui/form_field.templ,
    backend/internal/web/ui/ui_test.go
  </files>
  <read_first>
    - go-backend/internal/web/ui/select.templ (full file — port verbatim including inline script block)
    - go-backend/internal/web/ui/select_helpers.go (full file — port verbatim)
    - go-backend/internal/web/ui/select.css (full file — port verbatim)
    - go-backend/internal/web/ui/form_field.templ (full file — port verbatim)
    - go-backend/internal/web/ui/form-field.css (full file — port verbatim)
    - 13-PATTERNS.md (select.templ section, select_helpers.go section, form_field.templ section)
    - 13-RESEARCH.md (Select Component — Inline JavaScript section, Pitfall 6)
    - 13-UI-SPEC.md (Select and Form Field component inventory sections)
  </read_first>
  <behavior>
    - Test: Select(SelectProps{Name: "status", Options: []SelectOption{{Value: "a", Label: "Alpha"}}}) HTML contains "ui-select-control"
    - Test: Select renders a <script> block containing "__uiSelectInitAll" (inline JS for open/close)
    - Test: Select renders htmx:afterSwap listener reference in the script block
    - Test: FormField(FormFieldProps{Label: "Name", ForID: "name-input"}) HTML contains "ui-form-field" and "ui-form-label"
    - Test: FormField with Error set renders "ui-form-error" in HTML
    - Test: FormField with empty Error does NOT render "ui-form-error"
  </behavior>
  <action>
    Step 1 — Write failing tests in ui_test.go (RED):
    Add TestSelect_RendersControl, TestSelect_HasInlineScript,
    TestFormField_RendersLabel, TestFormField_RendersError, TestFormField_NoErrorWhenEmpty.
    Run go test ./internal/web/ui/... — confirm failures.

    Step 2 — Create select_helpers.go: port go-backend/internal/web/ui/select_helpers.go verbatim.
    File must be in package ui. Import "strings" and "github.com/a-h/templ".
    Port all 8 helper functions verbatim: selectPlaceholder, selectNativeID, selectMenuID,
    selectBoolData, selectOptionSelected, selectSelectedLabels, selectSelectedLabel,
    selectMenuOptionClass, selectIsDisabled.

    Step 3 — Create select.templ: port go-backend/internal/web/ui/select.templ verbatim.
    SelectOption struct: Value string, Label string, Disabled bool.
    SelectProps struct: ID string, Name string, Placeholder string, Value string, Values []string,
    Multiple bool, Options []SelectOption, Attrs templ.Attributes.
    The inline <script> block MUST be included verbatim — it contains window.__uiSelectInitAll
    and the document.addEventListener("htmx:afterSwap", ...) listener (Pitfall 6).
    Use SelectProps and SelectOption from the local package (matching go-backend structure).

    Step 4 — Create select.css: port go-backend/internal/web/ui/select.css verbatim.
    Must contain .ui-select { }, .ui-select-control { } (min-height: 44px, border-radius: 0.75rem),
    .ui-select-menu { } (absolute positioned, max-height: 16rem, overflow-y: auto).

    Step 5 — Create form-field.css: port go-backend/internal/web/ui/form-field.css verbatim.
    Must contain .ui-form-field { }, .ui-form-label { }, .ui-form-hint { }, .ui-form-error { }.

    Step 6 — Create form_field.templ: port go-backend/internal/web/ui/form_field.templ.
    FormFieldProps struct: Label string, For string, Field templ.Component, Error string, Hint string.
    Render <label for={props.For} class="ui-form-label"> only if Label is not empty.
    Render @props.Field if not nil.
    Render .ui-form-hint only if Hint is not empty.
    Render .ui-form-error only if Error is not empty.

    Run just generate && go test ./internal/web/ui/... — all GREEN.
  </action>
  <verify>
    <automated>cd backend && just generate && go test ./internal/web/ui/... -run "TestSelect|TestFormField" -v</automated>
  </verify>
  <acceptance_criteria>
    - TestSelect_RendersControl passes (HTML contains "ui-select-control")
    - TestSelect_HasInlineScript passes (HTML contains "__uiSelectInitAll")
    - TestFormField_RendersLabel passes (HTML contains "ui-form-label")
    - TestFormField_RendersError passes (HTML contains "ui-form-error" when Error is set)
    - TestFormField_NoErrorWhenEmpty passes (HTML does NOT contain "ui-form-error" when Error is "")
    - select_helpers.go contains func selectPlaceholder
    - select_helpers.go contains func selectOptionSelected
    - select.templ contains "type SelectProps struct"
    - select.templ contains "__uiSelectInitAll" (inline script present)
    - select.templ contains "htmx:afterSwap" (re-init listener present)
    - form_field.templ contains "type FormFieldProps struct"
    - form_field.templ contains "if props.Error != \"\""
    - form-field.css contains ".ui-form-field {"
    - form-field.css contains ".ui-form-error {"
    - just generate succeeds
    - Full go test ./internal/web/ui/... is green
  </acceptance_criteria>
  <done>select.css/select.templ/select_helpers.go created; form-field.css/form_field.templ created; all tests passing</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Browser → select inline script | The select inline script reads DOM attributes only; it does not fetch URLs or eval strings |
| Form field props → HTML output | templ auto-escapes all string interpolations in FormFieldProps (Label, Hint, Error are escaped) |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-13-03-01 | Tampering | select.templ inline script | accept | Script only toggles CSS classes (is-open) and the hidden attribute on the dropdown; no user-controlled string is eval'd; script content is static and compiled into the binary |
| T-13-03-02 | Tampering | FormField Error prop | accept | Error strings are server-controlled (validation messages from handler); templ auto-escapes any HTML in the string, preventing XSS injection through error messages |
| T-13-03-03 | Information Disclosure | Input Disabled/Required props | accept | These are static HTML attributes rendered server-side; no user-controlled data in field metadata |
</threat_model>

<verification>
After this plan completes:
- cd backend && just generate — must succeed
- cd backend && go test ./internal/web/ui/... -count=1 — must be green
- grep -l 'ui-input\|ui-textarea\|ui-select\|ui-form-field' backend/internal/web/ui/*.css — must list 4 files
- grep '__uiSelectInitAll' backend/internal/web/ui/select.templ — must match (inline JS present)
- grep 'htmx:afterSwap' backend/internal/web/ui/select.templ — must match (re-init listener present)
</verification>

<success_criteria>
1. Input component: class="ui-input", inputType/inputID helpers, Disabled/Required bool fields
2. Textarea component: class="ui-textarea", textareaRows default 4, resize: vertical in CSS
3. Select component: ui-select-control, inline JS with __uiSelectInitAll and htmx:afterSwap listener
4. FormField component: wraps Field component, conditional label/hint/error regions
5. All four component CSS files exist with their primary .ui-* selectors
6. go test ./internal/web/ui/... is green for all new test functions
</success_criteria>

<output>
After completion, create `.planning/phases/13-design-system-foundation/13-03-SUMMARY.md`
</output>
