docs(13): create phase 13 design system foundation plan (5 plans, 4 waves)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-05-16 13:20:43 +02:00
parent a25421e2cf
commit 9e954a40e7
No known key found for this signature in database
6 changed files with 1573 additions and 0 deletions

View file

@ -33,6 +33,7 @@
**Mode:** mvp
**Status:** Pending
**Requirements:** DS-01, DS-02, DS-03, DS-04, DS-05, DS-06, DS-07, DS-08, DS-09
**Plans:** 5 plans
**Success Criteria:**
1. `backend/internal/web/ui/base.css` defines all CSS custom properties (color, spacing, typography, shadows, gradients) matching the go-backend token vocabulary
2. All component types are implemented as templ components: button, input, textarea, select, card, badge, modal, empty-state, table, icon-button
@ -40,6 +41,13 @@
4. A component catalog page (`/ui-catalog`, dev-only) renders all components for visual verification
5. All existing templates compile and unit tests pass with no regressions
Plans:
- [ ] 13-01-PLAN.md — Token vocabulary + enum/helper foundation (base.css, auth.css extraction, variants.go, helpers.go)
- [ ] 13-02-PLAN.md — Migrate existing components to go-backend API (button multi-class, badge pill, card typed API, template hardcodes)
- [ ] 13-03-PLAN.md — Port form-input components: input, textarea, select, form-field (CSS + templ + tests)
- [ ] 13-04-PLAN.md — Port surface components: modal, empty-state, table, icon-button, space + tailwind.input.css manifest
- [ ] 13-05-PLAN.md — Catalog route (build-tag gated) + visual sign-off checkpoint
**User-in-loop:** Review the catalog page before proceeding to per-view application phases. Confirm token choices (brand color, radius, shadow levels) match what you want the product to look like.
### Phase 14: Auth Pages

View file

@ -0,0 +1,258 @@
---
phase: 13-design-system-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/internal/web/ui/base.css
- backend/internal/web/ui/auth.css
- backend/internal/web/ui/variants.go
- backend/internal/web/ui/helpers.go
- backend/tailwind.input.css
autonomous: true
requirements:
- DS-01
must_haves:
truths:
- "base.css contains the full 223-line CSS custom property vocabulary from go-backend"
- "auth-provider button styles are preserved in auth.css (not lost when button.css is replaced)"
- "variants.go declares ButtonVariantGhost, BadgeVariantPrimary, IconButtonVariant, IconButtonTone, and SpacingStep enums"
- "helpers.go declares buttonType, inputType, inputID, and textareaRows helper functions"
- "tailwind.input.css imports auth.css so the login page retains provider button styling"
- "go test ./internal/web/ui/... passes (no regressions from enum additions)"
artifacts:
- path: "backend/internal/web/ui/base.css"
provides: "Full CSS custom property token vocabulary"
contains: "--color-brand-primary"
- path: "backend/internal/web/ui/auth.css"
provides: "Auth provider button CSS extracted from button.css"
contains: ".auth-provider-button"
- path: "backend/internal/web/ui/variants.go"
provides: "All variant enums including Ghost, Primary, IconButton, SpacingStep"
contains: "ButtonVariantGhost"
- path: "backend/internal/web/ui/helpers.go"
provides: "Helper functions for templ components"
contains: "buttonType"
key_links:
- from: "backend/tailwind.input.css"
to: "backend/internal/web/ui/auth.css"
via: "@import"
pattern: "auth\\.css"
- from: "backend/internal/web/ui/variants.go"
to: "ButtonVariantGhost"
via: "const block"
pattern: "ButtonVariantGhost"
---
## Phase Goal
**As a** developer, **I want to** have a complete CSS token vocabulary and Go variant enums in the backend, **so that** every subsequent plan can port component CSS and templ files against a stable foundation without losing any existing styling.
<objective>
Replace the 28-line backend/base.css stub with the full 223-line token vocabulary from go-backend,
extract auth-provider CSS from button.css into auth.css before button.css is replaced,
and extend variants.go + helpers.go with all new enums and helper functions.
Purpose: This is the mandatory prerequisite wave. Without the full token vocabulary, component CSS
ported in Plans 0204 cannot reference var(--...) tokens. Without auth.css extraction, replacing
button.css in Plan 02 silently destroys the login page's provider button styling.
Output:
- backend/internal/web/ui/base.css — 223-line token vocabulary (verbatim port, per D-T01/T02/T03)
- backend/internal/web/ui/auth.css — auth-provider selectors extracted from current button.css
- backend/internal/web/ui/variants.go — extended with Ghost/Primary variants + new enums
- backend/internal/web/ui/helpers.go — extended with buttonType/inputType/inputID/textareaRows
- backend/tailwind.input.css — auth.css import added (button.css replacement in Plan 02)
</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/ROADMAP.md
@.planning/REQUIREMENTS.md
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Extract auth.css and replace base.css</name>
<files>
backend/internal/web/ui/auth.css,
backend/internal/web/ui/base.css,
backend/tailwind.input.css
</files>
<read_first>
- backend/internal/web/ui/button.css (lines 121180 contain the auth-provider CSS to extract verbatim)
- backend/internal/web/ui/base.css (current 28-line stub to understand what is being replaced)
- go-backend/internal/web/ui/base.css (the 223-line source to port verbatim)
- backend/tailwind.input.css (current 4-import manifest to update)
</read_first>
<action>
Step 1 — Create backend/internal/web/ui/auth.css: copy lines 121180 from the current
backend/internal/web/ui/button.css verbatim. The first line must be a comment:
"/* auth.css — sign-in provider controls, extracted from button.css in Phase 13 */".
The file must contain the selectors: .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.
Step 2 — Replace backend/internal/web/ui/base.css entirely with the contents of
go-backend/internal/web/ui/base.css (verbatim — per D-T01, D-T02, D-T03).
Do NOT merge with the existing 28-line stub. The target file is 223 lines.
Step 3 — Update backend/tailwind.input.css: add the line
"@import "./internal/web/ui/auth.css";" immediately after the base.css import line.
Do not yet add imports for the component CSS files being created in Plans 0204 — those
are added in Plan 04.
</action>
<verify>
<automated>grep -c 'color-brand-primary' backend/internal/web/ui/base.css</automated>
Expected output: at least 3 (the token appears in multiple rules).
Also: grep -c 'auth-provider-button' backend/internal/web/ui/auth.css — must return at least 3.
Also: grep 'auth\.css' backend/tailwind.input.css — must match.
</verify>
<acceptance_criteria>
- backend/internal/web/ui/base.css is exactly 223 lines (or close — the exact line count from go-backend)
- backend/internal/web/ui/base.css contains "--color-brand-primary: #804eec"
- backend/internal/web/ui/base.css contains "--color-text-primary"
- backend/internal/web/ui/base.css contains "--shadow-surface-md"
- backend/internal/web/ui/base.css does NOT contain "box-sizing: border-box" at the top (the stub had that; go-backend version puts it inside :root or omits it)
- backend/internal/web/ui/auth.css exists and contains ".auth-provider-button {"
- backend/internal/web/ui/auth.css contains ".auth-provider-separator {"
- backend/tailwind.input.css contains "@import "./internal/web/ui/auth.css";"
- The auth.css import appears after the base.css import in tailwind.input.css
</acceptance_criteria>
<done>base.css replaced with 223-line token vocabulary; auth.css created with extracted provider styles; tailwind.input.css updated with auth.css import</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Extend variants.go with new enums and helpers.go with new helper functions</name>
<files>
backend/internal/web/ui/variants.go,
backend/internal/web/ui/helpers.go,
backend/internal/web/ui/ui_test.go
</files>
<read_first>
- backend/internal/web/ui/variants.go (current file — full read to understand existing enum patterns)
- backend/internal/web/ui/helpers.go (current file — full read to understand existing helper pattern)
- backend/internal/web/ui/ui_test.go (current test file — understand existing test structure)
- go-backend/internal/web/ui/variants.go (source for new enums and class functions)
- go-backend/internal/web/ui/helpers.go (source for new helper functions)
- 13-PATTERNS.md (Pattern for variants.go and helpers.go changes)
- 13-RESEARCH.md (Code Examples section for exact function signatures)
</read_first>
<behavior>
- Test: NormalizedButtonVariant(ButtonVariantGhost) returns ButtonVariantGhost (not ButtonVariantDefault)
- Test: ButtonClass(ButtonVariantGhost, ButtonToneSolid, SizeMD) contains "ui-button-ghost"
- Test: NormalizedBadgeVariant(BadgeVariantPrimary) returns BadgeVariantPrimary
- Test: BadgeClass(BadgeVariantPrimary) == "ui-badge ui-badge-primary"
- Test: IconButtonClass(IconButtonVariantNeutral, IconButtonToneGhost) contains "borderless-icon-button"
- Test: IconButtonClass(IconButtonVariantNeutral, IconButtonToneSolid) contains "ui-icon-button-solid"
- Test: SpaceXClass(SpacingStepMD) == "ui-space-x ui-space-x-md"
- Test: SpaceYClass(SpacingStepLG) == "ui-space-y ui-space-y-lg"
</behavior>
<action>
Step 1 — Write the failing tests in ui_test.go first (RED). Add test functions for:
TestButtonVariantGhost_Normalizer, TestButtonClass_GhostVariant,
TestBadgeVariantPrimary_Normalizer, TestBadgeClass_PrimaryVariant,
TestIconButtonClass_GhostNeutral, TestIconButtonClass_SolidNeutral,
TestSpaceXClass_MD, TestSpaceYClass_LG.
Run go test ./internal/web/ui/... and confirm failures (RED).
Step 2 — Update variants.go (GREEN):
a. Add "ButtonVariantGhost ButtonVariant = "ghost"" to the ButtonVariant const block.
b. Add "ButtonVariantGhost" case to NormalizedButtonVariant switch: "case ButtonVariantGhost: return variant".
c. Add "BadgeVariantPrimary BadgeVariant = "primary"" to the BadgeVariant const block.
d. Add "BadgeVariantPrimary" case to NormalizedBadgeVariant switch.
e. Add IconButtonVariant type with constants: IconButtonVariantNeutral="neutral",
IconButtonVariantWarning="warning", IconButtonVariantSuccess="success",
IconButtonVariantDanger="danger".
f. Add IconButtonTone type with constants: IconButtonToneSolid="solid", IconButtonToneGhost="ghost".
g. Add SpacingStep type with constants: SpacingStepXS="xs", SpacingStepSM="sm",
SpacingStepMD="md", SpacingStepLG="lg", SpacingStepXL="xl".
h. Add NormalizedIconButtonVariant function (defaults to IconButtonVariantNeutral).
i. Add NormalizedIconButtonTone function (defaults to IconButtonToneSolid).
j. Add NormalizedSpacingStep function (defaults to SpacingStepMD).
k. Add IconButtonClass(variant IconButtonVariant, tone IconButtonTone) string function.
Ghost tone path: returns "borderless-icon-button ui-icon-button-ghost ui-icon-button-" + normalizedVariant.
Solid tone path: returns "ui-icon-button ui-icon-button-solid ui-icon-button-" + normalizedVariant.
l. Add SpaceXClass(step SpacingStep) string — returns "ui-space-x ui-space-x-" + normalizedStep.
m. Add SpaceYClass(step SpacingStep) string — returns "ui-space-y ui-space-y-" + normalizedStep.
Step 3 — Update helpers.go (GREEN):
Add four new unexported helper functions (following the existing mergeAttrs pattern):
a. buttonType(value string) string — returns "button" if value is empty, otherwise value.
b. inputType(value string) string — returns "text" if value is empty, otherwise value.
c. inputID(id string, name string) string — returns id if non-empty, otherwise name.
d. textareaRows(rows int) string — returns strconv.Itoa(rows) if rows > 0, else "4".
Add import "strconv" to helpers.go.
Do NOT change ButtonClass() output yet — that is Plan 02's work.
Run go test ./internal/web/ui/... and confirm all tests pass (GREEN).
</action>
<verify>
<automated>cd backend && go test ./internal/web/ui/... -run "TestButtonVariantGhost|TestBadgeVariantPrimary|TestIconButtonClass|TestSpaceXClass|TestSpaceYClass" -v</automated>
</verify>
<acceptance_criteria>
- All 8 new test functions pass
- go test ./internal/web/ui/... (full suite) is green — no existing tests broken
- variants.go contains "ButtonVariantGhost ButtonVariant = "ghost""
- variants.go contains "BadgeVariantPrimary BadgeVariant = "primary""
- variants.go contains type IconButtonVariant string
- variants.go contains type SpacingStep string
- variants.go contains func IconButtonClass
- variants.go contains func SpaceXClass
- helpers.go imports "strconv"
- helpers.go contains func buttonType
- helpers.go contains func textareaRows
- ButtonClass() still returns the OLD compound pattern (ui-button-solid-default-md) — this is intentional; Plan 02 migrates it
</acceptance_criteria>
<done>variants.go extended with all new enums and class functions; helpers.go extended with component helper functions; all tests green</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Static build → browser | CSS compiled by Tailwind CLI; no runtime input |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-13-01-01 | Information Disclosure | base.css token values | accept | Token values are design constants (brand colors, spacing). No secrets. Acceptable public visibility. |
| T-13-01-02 | Tampering | variants.go normalizer | accept | Normalizers return safe defaults for unknown inputs; no user-controlled data flows through variant enums at runtime. |
</threat_model>
<verification>
After this plan completes:
- cd backend && go test ./internal/web/ui/... — must be green
- grep -c 'color-brand-primary' backend/internal/web/ui/base.css — must return >= 3
- grep 'ButtonVariantGhost' backend/internal/web/ui/variants.go — must match
- grep 'auth-provider-button' backend/internal/web/ui/auth.css — must match
- grep 'auth\.css' backend/tailwind.input.css — must match
</verification>
<success_criteria>
1. base.css contains the full go-backend token vocabulary (223 lines, --color-brand-primary: #804eec present)
2. auth.css exists with .auth-provider-button and is imported in tailwind.input.css
3. variants.go has ButtonVariantGhost, BadgeVariantPrimary, IconButtonVariant, IconButtonTone, SpacingStep
4. variants.go has IconButtonClass(), SpaceXClass(), SpaceYClass() functions
5. helpers.go has buttonType(), inputType(), inputID(), textareaRows()
6. go test ./internal/web/ui/... is green
</success_criteria>
<output>
After completion, create `.planning/phases/13-design-system-foundation/13-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,314 @@
---
phase: 13-design-system-foundation
plan: 02
type: execute
wave: 2
depends_on:
- 13-01
files_modified:
- backend/internal/web/ui/button.css
- backend/internal/web/ui/button.templ
- backend/internal/web/ui/badge.css
- backend/internal/web/ui/badge.templ
- backend/internal/web/ui/card.css
- backend/internal/web/ui/card.templ
- backend/internal/web/ui/variants.go
- backend/internal/web/ui/ui_test.go
- backend/templates/planning.templ
- backend/templates/tasks.templ
- backend/templates/events.templ
- backend/templates/etapes.templ
autonomous: true
requirements:
- DS-02
- DS-04
- DS-05
must_haves:
truths:
- "ButtonClass() emits four separate classes: ui-button, ui-button-{tone}, ui-button-{variant}, ui-button-{size}"
- "All hardcoded compound button class strings in templates are updated to multi-class equivalents"
- "ButtonVariantGhost class combo renders with transparent background and brand color text"
- "BadgeVariantPrimary renders with brand-purple surface"
- "Card uses typed Header/Body/Footer templ.Component fields (not children passthrough)"
- "go test ./... passes — no regressions in any package"
- "just generate succeeds — all templ files compile"
artifacts:
- path: "backend/internal/web/ui/button.css"
provides: "Multi-class button selectors + ghost variant"
contains: ".ui-button-solid.ui-button-default {"
- path: "backend/internal/web/ui/card.templ"
provides: "Typed Header/Body/Footer Props API"
contains: "CardProps"
- path: "backend/internal/web/ui/variants.go"
provides: "ButtonClass() emitting multi-class output"
contains: "ui-button-"
key_links:
- from: "backend/templates/planning.templ"
to: "multi-class button pattern"
via: "class attribute strings"
pattern: "ui-button ui-button-soft ui-button-neutral"
- from: "backend/internal/web/ui/button.templ"
to: "ButtonClass()"
via: "ButtonClass(props.Variant, props.Tone, props.Size)"
pattern: "ButtonClass"
---
## Phase Goal
**As a** developer, **I want to** migrate existing Button/Badge/Card components to go-backend's API (multi-class button pattern, typed Card fields, new variants), **so that** buttons render correctly from the ported button.css and templates remain functional with no visual regressions.
<objective>
Migrate the three existing components (Button, Badge, Card) to match go-backend's API and CSS.
The critical task is the button multi-class migration — both the CSS selectors and the class
generation function must change atomically, and all hardcoded compound class strings in templates
must be updated in the same wave.
Purpose: This wave closes the gap between the current compound-class pattern and go-backend's
multi-class pattern. Without this, porting button.css (which uses compound CSS selectors like
.ui-button-solid.ui-button-default) would silently break all button rendering.
Output:
- button.css replaced with go-backend's multi-class selector version + new ghost rules
- ButtonClass() updated to emit "ui-button ui-button-solid ui-button-default ui-button-md"
- button.templ updated with Icon field and buttonType() helper (from Plan 01 helpers.go)
- badge.css replaced with go-backend's pill-shape version + new primary variant
- card.css replaced with go-backend's token-based version
- card.templ migrated from children to typed Header/Body/Footer fields
- All compound class strings in planning.templ, tasks.templ, events.templ, etapes.templ updated
- ui_test.go updated to match new patterns (multi-class assertions, card typed API)
</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
</context>
<interfaces>
From backend/internal/web/ui/variants.go (after Plan 01):
func ButtonClass(variant ButtonVariant, tone ButtonTone, size Size) string
// Currently returns: "ui-button ui-button-solid-default-md" — Plan 02 changes this output
// Target: "ui-button ui-button-solid ui-button-default ui-button-md"
From backend/internal/web/ui/helpers.go (after Plan 01):
func buttonType(value string) string // returns "button" if empty
func UIIcon(kind string) templ.Component // does NOT exist yet — created in Plan 04
Compound class → multi-class mapping (all occurrences to replace in templates):
"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"
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Update ButtonClass() multi-class output + button.templ + update ui_test.go button assertions</name>
<files>
backend/internal/web/ui/variants.go,
backend/internal/web/ui/button.templ,
backend/internal/web/ui/ui_test.go
</files>
<read_first>
- backend/internal/web/ui/variants.go (current — confirm ButtonClass() still uses old pattern after Plan 01)
- backend/internal/web/ui/button.templ (current — full file)
- backend/internal/web/ui/ui_test.go (current — find TestButton_DefaultSolidMD, TestButtonClass_String assertions to update)
- go-backend/internal/web/ui/button.templ (source for updated Props struct with Icon field)
- 13-PATTERNS.md (button.templ target pattern section)
- 13-RESEARCH.md (Multi-Class Button Pattern section, Code Examples section)
</read_first>
<behavior>
- Test: ButtonClass(ButtonVariantDefault, ButtonToneSolid, SizeMD) == "ui-button ui-button-solid ui-button-default ui-button-md"
- Test: ButtonClass(ButtonVariantGhost, ButtonToneSolid, SizeMD) contains "ui-button-ghost" and does NOT contain "ui-button-solid"
- Test: Button(ButtonProps{Label: "x"}) renders class attribute containing "ui-button ui-button-solid" (not "ui-button-solid-default-md")
</behavior>
<action>
Step 1 — Update the test assertions in ui_test.go (they will now FAIL — this is intentional RED state):
a. TestButton_DefaultSolidMD: change wantClass from "ui-button ui-button-solid-default-md"
to a for-loop checking for each of: "ui-button", "ui-button-solid", "ui-button-default", "ui-button-md"
using the multi-assertion slice pattern from 13-PATTERNS.md.
b. TestButtonClass_String: change want from "ui-button ui-button-solid-default-md"
to "ui-button ui-button-solid ui-button-default ui-button-md".
Run go test ./internal/web/ui/... — confirm these two tests FAIL (RED).
Step 2 — Update ButtonClass() in variants.go (GREEN):
Change the return statement from:
"ui-button ui-button-" + string(t) + "-" + string(v) + "-" + string(s)
to:
"ui-button ui-button-" + string(t) + " ui-button-" + string(v) + " ui-button-" + string(s)
The ghost variant is a special case: when variant is ButtonVariantGhost, the tone class is
omitted. Add a conditional: if v == ButtonVariantGhost, return "ui-button ui-button-ghost ui-button-" + string(s).
Step 3 — Update button.templ:
a. Add "Icon string" field to ButtonProps struct (after the Size field).
b. Replace the inline btnType variable logic with: type={ buttonType(props.Type) }.
c. Replace the class variable lookup with: class={ ButtonClass(props.Variant, props.Tone, props.Size) }.
d. Add icon rendering inside the button: if props.Icon != "" { <span class="ui-button-icon"> — leave
a placeholder comment "// UIIcon added in Plan 04" because UIIcon does not exist yet.
DO NOT reference UIIcon yet — it does not compile until Plan 04. Instead, skip the icon rendering
entirely in the templ for now: the Icon field is present in the struct, but icon rendering is
wired in Plan 04 when icon_button.templ creates UIIcon.
Run go test ./internal/web/ui/... — confirm all tests pass (GREEN).
Run cd backend && just generate — confirm templ generates without errors.
</action>
<verify>
<automated>cd backend && just generate && go test ./internal/web/ui/... -run "TestButton" -v</automated>
</verify>
<acceptance_criteria>
- ButtonClass(ButtonVariantDefault, ButtonToneSolid, SizeMD) returns "ui-button ui-button-solid ui-button-default ui-button-md" (not the old compound form)
- ButtonClass(ButtonVariantGhost, ButtonToneSolid, SizeMD) returns "ui-button ui-button-ghost ui-button-md"
- TestButton_DefaultSolidMD passes with multi-class assertions
- TestButtonClass_String passes with new expected string
- button.templ uses buttonType(props.Type) helper
- ButtonProps struct contains "Icon string" field
- just generate succeeds (no templ compile errors)
- All other existing tests still pass (TestButton_PassesThroughAttrs, TestButton_ExplicitTypeSubmit)
</acceptance_criteria>
<done>ButtonClass() emits multi-class output; button.templ updated with Icon field and buttonType helper; all button tests green</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Replace button.css + badge.css + card.css; migrate card.templ; update template hardcodes; extend ui_test.go</name>
<files>
backend/internal/web/ui/button.css,
backend/internal/web/ui/badge.css,
backend/internal/web/ui/card.css,
backend/internal/web/ui/card.templ,
backend/internal/web/ui/ui_test.go,
backend/templates/planning.templ,
backend/templates/tasks.templ,
backend/templates/events.templ,
backend/templates/etapes.templ
</files>
<read_first>
- backend/internal/web/ui/button.css (current — 180 lines; lines 1120 are button CSS, lines 121180 are auth-provider CSS that was extracted in Plan 01 but the file still needs full review)
- backend/internal/web/ui/badge.css (current — see current selectors)
- backend/internal/web/ui/card.css (current — see current selectors)
- backend/internal/web/ui/card.templ (current — children-based API to replace)
- backend/internal/web/ui/ui_test.go (current — TestCard_RendersChildren to rewrite)
- go-backend/internal/web/ui/button.css (source for multi-class selectors)
- go-backend/internal/web/ui/badge.css (source for pill shape + token colors)
- go-backend/internal/web/ui/card.css (source for header/body/footer pattern)
- go-backend/internal/web/ui/card.templ (source for typed Props API)
- 13-PATTERNS.md (button.css, badge.css, card.css, card.templ, template hardcode sections)
- 13-RESEARCH.md (Pitfall 1 — template compound class strings; Pitfall 3 — Card API break)
</read_first>
<behavior>
- Test (rewritten): Card(CardProps{Body: textComponent("hello")}) renders "ui-card-body" in HTML and "hello" inside it
- Test (new): BadgeClass(BadgeVariantPrimary) returns "ui-badge ui-badge-primary"
- Test (new): Badge(BadgeProps{Label: "x", Variant: BadgeVariantPrimary}) HTML contains "ui-badge-primary"
</behavior>
<action>
Step 1 — Rewrite TestCard_RendersChildren in ui_test.go to TestCard_RendersTypedRegions:
Add textComponent helper function (from 13-PATTERNS.md) and add imports "io".
New test passes CardProps{Header: textComponent("header"), Body: textComponent("body")}
and asserts "ui-card-header", "header", "ui-card-body", "body" are all in the output.
Add test for nil footer: CardProps{Body: textComponent("x")} must NOT contain "ui-card-footer".
Add TestBadge_PrimaryVariant: Badge(BadgeProps{Label: "x", Variant: BadgeVariantPrimary})
must contain "ui-badge-primary".
Run go test — these will fail RED (card.templ not yet migrated, PrimaryVariant normalizer not yet applied to BadgeClass).
Step 2 — Replace button.css with go-backend's multi-class version:
Port go-backend/internal/web/ui/button.css verbatim (multi-class selectors like
".ui-button-solid.ui-button-default { background: var(--color-brand-primary); }").
Do NOT include the auth-provider selectors (those are in auth.css from Plan 01).
After the go-backend content, append the ghost variant rules (per 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; }
Step 3 — Replace badge.css with go-backend's version:
Port go-backend/internal/web/ui/badge.css verbatim (pill shape, border-radius: 999px).
After the go-backend content, append the primary variant (per D-CA02):
.ui-badge-primary { background: var(--color-surface-brand-soft); border-color: rgba(128, 78, 236, 0.3); color: var(--color-text-brand); }
Step 4 — Replace card.css with go-backend's version:
Port go-backend/internal/web/ui/card.css verbatim (ui-card, ui-card-header, ui-card-body, ui-card-footer).
Step 5 — Migrate card.templ to typed Props API:
Replace the current children-based Card(attrs templ.Attributes) with:
type CardProps struct { Header templ.Component; Body templ.Component; Footer templ.Component }
templ Card(props CardProps) — with nil-guard conditionals for each region (see 13-PATTERNS.md).
Step 6 — Update compound class strings in templates. For each file, replace all occurrences:
planning.templ: "ui-button ui-button-soft-neutral-md" → "ui-button ui-button-soft ui-button-neutral ui-button-md",
"ui-button ui-button-solid-default-md" → "ui-button ui-button-solid ui-button-default ui-button-md"
tasks.templ: "ui-button ui-button-soft-danger-md flex-shrink-0 text-xs" → "ui-button ui-button-soft ui-button-danger ui-button-md flex-shrink-0 text-xs",
"ui-button ui-button-soft-neutral-md w-full text-left text-sm mt-2" → "ui-button ui-button-soft ui-button-neutral ui-button-md w-full text-left text-sm mt-2"
events.templ: "ui-button ui-button-soft-neutral-md" → "ui-button ui-button-soft ui-button-neutral ui-button-md"
etapes.templ: "ui-button ui-button-soft-neutral-md px-2" → "ui-button ui-button-soft ui-button-neutral ui-button-md px-2",
"ui-button ui-button-soft-danger-md px-2" → "ui-button ui-button-soft ui-button-danger ui-button-md px-2",
"ui-button ui-button-soft-neutral-md flex-shrink-0" → "ui-button ui-button-soft ui-button-neutral ui-button-md flex-shrink-0"
Do NOT strip Tailwind utility classes that follow the button classes — keep them as-is.
Run just generate && go test ./... — all tests must pass GREEN.
</action>
<verify>
<automated>cd backend && just generate && go test ./... -count=1</automated>
</verify>
<acceptance_criteria>
- go test ./... passes with no failures
- just generate produces no errors
- button.css contains ".ui-button-solid.ui-button-default {" (multi-class compound selector)
- button.css contains ".ui-button-ghost {" (new ghost variant)
- button.css does NOT contain ".auth-provider-button" (that is in auth.css)
- badge.css contains "border-radius: 999px" (pill shape from go-backend)
- badge.css contains ".ui-badge-primary {"
- card.css contains ".ui-card-header,"
- card.templ contains "type CardProps struct"
- card.templ contains "if props.Header != nil"
- planning.templ contains "ui-button ui-button-soft ui-button-neutral ui-button-md" and does NOT contain "ui-button-soft-neutral-md"
- tasks.templ does NOT contain "ui-button-soft-danger-md"
- TestCard_RendersTypedRegions passes (card renders typed regions correctly)
- TestBadge_PrimaryVariant passes (ui-badge-primary in output)
</acceptance_criteria>
<done>button.css/badge.css/card.css replaced; card.templ migrated to typed API; all template compound class strings updated; full test suite green</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Template HTML → browser | templ auto-escapes string interpolations; CSS class changes are compile-time constants |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-13-02-01 | Tampering | card.templ typed API migration | accept | No user input flows through CardProps fields at runtime; templ auto-escapes all string interpolations |
| T-13-02-02 | Information Disclosure | button.css ghost variant | accept | CSS is a public static asset; ghost variant is visual styling only, no sensitive data |
</threat_model>
<verification>
After this plan completes:
- cd backend && just generate — must succeed (no templ errors)
- cd backend && go test ./... -count=1 — must be green across all packages
- grep -r 'ui-button-soft-neutral-md\|ui-button-solid-default-md\|ui-button-soft-danger-md' backend/templates/ — must return nothing
- grep 'ui-button-solid.ui-button-default' backend/internal/web/ui/button.css — must match
- grep 'ui-badge-primary' backend/internal/web/ui/badge.css — must match
- grep 'type CardProps struct' backend/internal/web/ui/card.templ — must match
</verification>
<success_criteria>
1. ButtonClass() emits "ui-button ui-button-solid ui-button-default ui-button-md" (multi-class)
2. button.css uses multi-class compound selectors, includes ghost variant, excludes auth-provider CSS
3. badge.css is pill-shape, includes primary variant
4. card.templ uses typed Header/Body/Footer CardProps (no children passthrough)
5. All 4 template files have multi-class button strings (no compound "ui-button-solid-default-md")
6. Full test suite (go test ./...) is green
</success_criteria>
<output>
After completion, create `.planning/phases/13-design-system-foundation/13-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,317 @@
---
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 1417 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>

View file

@ -0,0 +1,364 @@
---
phase: 13-design-system-foundation
plan: 04
type: execute
wave: 3
depends_on:
- 13-01
- 13-02
files_modified:
- 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
autonomous: true
requirements:
- DS-06
- DS-07
- DS-08
- DS-09
must_haves:
truths:
- "Modal component renders ui-modal-backdrop and ui-modal-panel with typed Title/Body/Actions props"
- "EmptyState component renders ui-empty-state with Icon (templ.Component), Title, Description regions"
- "Table component renders ui-table-shell wrapping ui-table with typed Head/Body props"
- "IconButton component renders borderless-icon-button for ghost tone, ui-icon-button for solid tone"
- "UIIcon templ function renders inline SVG for recognized icon names"
- "SpaceX/SpaceY utility components render with SpacingStep CSS classes"
- "button.templ Icon field is wired to UIIcon (completing the Plan 02 TODO)"
- "tailwind.input.css imports all 13 component CSS files"
- "go test ./internal/web/ui/... is green for all new component tests"
- "just generate succeeds"
artifacts:
- path: "backend/internal/web/ui/icon_button.templ"
provides: "IconButton + UIIcon templ components"
contains: "templ UIIcon"
- path: "backend/internal/web/ui/modal.templ"
provides: "Modal component with backdrop and panel"
contains: "type ModalProps struct"
- path: "backend/internal/web/ui/empty_state.templ"
provides: "EmptyState component with Icon templ.Component"
contains: "type EmptyStateProps struct"
- path: "backend/internal/web/ui/table.templ"
provides: "Table component with Head/Body"
contains: "ui-table-shell"
- path: "backend/tailwind.input.css"
provides: "Full CSS import manifest"
contains: "spacing.css"
key_links:
- from: "backend/internal/web/ui/button.templ"
to: "backend/internal/web/ui/icon_button.templ"
via: "@UIIcon(props.Icon)"
pattern: "UIIcon"
- from: "backend/tailwind.input.css"
to: "all 13 CSS files"
via: "@import"
pattern: "spacing\\.css"
---
## Phase Goal
**As a** developer, **I want to** have Modal, EmptyState, Table, IconButton, and Space components available, and all CSS files imported in tailwind.input.css, **so that** the entire component library is complete and Tailwind compiles every component's CSS into the final stylesheet.
<objective>
Port the five remaining component types (modal, empty-state, table, icon-button, space) from
go-backend. Also wire the UIIcon function into button.templ (completing the Icon field from Plan 02),
and update tailwind.input.css with all 13 component imports.
Purpose: This plan closes DS-06 through DS-09 and makes the full component library available.
After this plan, Plans 03 and 04 together deliver all 11 component types with CSS + templ + tests.
The tailwind.input.css update here ensures Tailwind compiles the complete stylesheet.
Output:
- modal.css + modal.templ (new files)
- empty-state.css + empty_state.templ (new files)
- table.css + table.templ (new files)
- icon-button.css + icon_button.templ (UIIcon included) (new files)
- spacing.css + space.templ (new files)
- button.templ updated to wire @UIIcon(props.Icon)
- tailwind.input.css updated with all 13 CSS imports
- ui_test.go extended with 5 new component tests
</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/variants.go (after Plan 01):
type IconButtonVariant string — constants: IconButtonVariantNeutral, Warning, Success, Danger
type IconButtonTone string — constants: IconButtonToneSolid, IconButtonToneGhost
type SpacingStep string — constants: SpacingStepXS, SM, MD, LG, XL
func IconButtonClass(variant IconButtonVariant, tone IconButtonTone) string
// Ghost: "borderless-icon-button ui-icon-button-ghost ui-icon-button-{variant}"
// Solid: "ui-icon-button ui-icon-button-solid ui-icon-button-{variant}"
func SpaceXClass(step SpacingStep) string — "ui-space-x ui-space-x-{step}"
func SpaceYClass(step SpacingStep) string — "ui-space-y ui-space-y-{step}"
From backend/internal/web/ui/helpers.go (after Plan 01):
func buttonType(value string) string
UIIcon (being created in this plan):
templ UIIcon(kind string) — switch on kind, renders inline SVG for: plus, grid3x3, list, filter,
search, calendar, pencil, trash. Default: <span aria-hidden="true">{ kind }</span>
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Port modal, empty-state, table components + CSS + tests</name>
<files>
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/ui_test.go
</files>
<read_first>
- go-backend/internal/web/ui/modal.templ (source Props struct and rendering)
- go-backend/internal/web/ui/modal.css (source CSS)
- go-backend/internal/web/ui/empty_state.templ (source Props struct — note Icon is templ.Component)
- go-backend/internal/web/ui/empty-state.css (source CSS)
- go-backend/internal/web/ui/table.templ (source Props struct)
- go-backend/internal/web/ui/table.css (source CSS)
- 13-PATTERNS.md (modal.templ, empty_state.templ, table.templ, and their CSS sections)
- 13-RESEARCH.md (Pitfall 7 — modal backdrop in catalog)
- 13-UI-SPEC.md (Modal, Empty State, Table component inventory sections)
</read_first>
<behavior>
- Test: Modal(ModalProps{Title: "Confirm action"}) HTML contains "ui-modal-backdrop" and "ui-modal-panel"
- Test: Modal(ModalProps{Title: "Confirm action"}) HTML contains "Confirm action" in a heading element
- Test: Modal with nil Body does NOT render "ui-modal-body"
- Test: EmptyState(EmptyStateProps{Title: "Nothing here yet"}) HTML contains "ui-empty-state" and "Nothing here yet"
- Test: EmptyState with nil Icon does NOT render "ui-empty-state-icon"
- Test: Table(TableProps{}) HTML contains "ui-table-shell" and "ui-table"
</behavior>
<action>
Step 1 — Write failing tests in ui_test.go (RED):
Add TestModal_RendersBackdropAndPanel, TestModal_RendersTitle, TestModal_NilBodyOmitted,
TestEmptyState_RendersTitle, TestEmptyState_NilIconOmitted, TestTable_RendersShell.
The textComponent helper from Plan 02's ui_test.go changes is already in scope.
Run go test ./internal/web/ui/... — confirm failures.
Step 2 — Create modal.css: port go-backend/internal/web/ui/modal.css verbatim.
Must contain .ui-modal-backdrop { position: fixed; inset: 0; ... flex center },
.ui-modal-panel { max-width: 32rem; border-radius: 1rem; ... },
.ui-modal-header { font-size: 1.125rem; font-weight: 600; ... },
.ui-modal-body { ... }, .ui-modal-actions { ... }.
Step 3 — Create modal.templ: port go-backend/internal/web/ui/modal.templ.
ModalProps struct: Title string, Body templ.Component, Actions templ.Component.
Render structure: <div class="ui-modal-backdrop"><div class="ui-modal-panel">
<div class="ui-modal-header"><h2>{ props.Title }</h2></div>
nil-guarded Body region → nil-guarded Actions region.
Step 4 — Create empty-state.css: port go-backend/internal/web/ui/empty-state.css verbatim.
Must contain .ui-empty-state { border: 1px dashed var(--color-border-subtle); border-radius: 1rem; ... },
.ui-empty-state-icon { width: 4rem; height: 4rem; ... },
.ui-empty-state-title { font-size: 1.125rem; font-weight: 600; }.
Step 5 — Create empty_state.templ: port go-backend/internal/web/ui/empty_state.templ.
EmptyStateProps struct: Icon templ.Component (not string — callers pass UIIcon(...)),
Title string, Description string, Action templ.Component.
Render .ui-empty-state-icon region only if props.Icon != nil.
Render .ui-empty-state-description only if Description != "".
Render .ui-empty-state-action only if Action != nil.
Step 6 — Create table.css: port go-backend/internal/web/ui/table.css verbatim.
Must contain .ui-table-shell { overflow-x: auto; width: 100%; },
.ui-table { border-collapse: collapse; min-width: 100%; }.
Step 7 — Create table.templ: port go-backend/internal/web/ui/table.templ.
TableProps struct: Head templ.Component, Body templ.Component.
Render structure: <div class="ui-table-shell"><table class="ui-table">
<thead> nil-guarded Head → <tbody> nil-guarded Body.
Run just generate && go test ./internal/web/ui/... — all GREEN.
</action>
<verify>
<automated>cd backend && just generate && go test ./internal/web/ui/... -run "TestModal|TestEmptyState|TestTable" -v</automated>
</verify>
<acceptance_criteria>
- TestModal_RendersBackdropAndPanel passes
- TestModal_RendersTitle passes (HTML contains title text in heading)
- TestModal_NilBodyOmitted passes (no "ui-modal-body" when Body is nil)
- TestEmptyState_RendersTitle passes
- TestEmptyState_NilIconOmitted passes
- TestTable_RendersShell passes (HTML contains "ui-table-shell" and "ui-table")
- modal.css contains ".ui-modal-backdrop {"
- modal.css contains ".ui-modal-panel {"
- empty-state.css contains ".ui-empty-state {"
- empty-state.css contains ".ui-empty-state-icon {"
- table.css contains ".ui-table-shell {"
- empty_state.templ contains "type EmptyStateProps struct"
- empty_state.templ Icon field is type "templ.Component" (not string)
- just generate succeeds
- Full go test ./internal/web/ui/... is green
</acceptance_criteria>
<done>modal, empty-state, and table components ported with CSS and tests; all passing</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Port icon-button + UIIcon + space components; wire button.templ Icon; update tailwind.input.css</name>
<files>
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
</files>
<read_first>
- go-backend/internal/web/ui/icon_button.templ (full file — UIIcon switch with all icon cases)
- go-backend/internal/web/ui/icon-button.css (source CSS)
- go-backend/internal/web/ui/space.templ (source)
- go-backend/internal/web/ui/spacing.css (source CSS)
- backend/internal/web/ui/button.templ (current after Plan 02 — find the Icon placeholder comment)
- backend/tailwind.input.css (current state — need to add 9 new imports)
- 13-PATTERNS.md (icon_button.templ, space.templ, tailwind.input.css sections)
- 13-RESEARCH.md (IconButtonClass code example; tailwind.input.css Final State section)
- 13-UI-SPEC.md (Icon Button and CSS File Manifest sections)
</read_first>
<behavior>
- Test: IconButton(IconButtonProps{Icon: "plus", Variant: IconButtonVariantNeutral, Tone: IconButtonToneGhost, Label: "Add"}) HTML contains "borderless-icon-button" and "aria-label=\"Add\""
- Test: IconButton(IconButtonProps{Icon: "trash", Variant: IconButtonVariantDanger, Tone: IconButtonToneSolid, Label: "Delete"}) HTML contains "ui-icon-button-solid" and "ui-icon-button-danger"
- Test: UIIcon("plus") renders an <svg> element
- Test: UIIcon("unknown-kind") renders a <span> fallback
- Test: SpaceX(SpaceProps{Size: SpacingStepMD}) HTML contains "ui-space-x-md"
- Test: SpaceY(SpaceProps{Size: SpacingStepLG}) HTML contains "ui-space-y-lg"
- Test: Button with Icon set renders an <svg> element (via UIIcon wiring)
</behavior>
<action>
Step 1 — Write failing tests in ui_test.go (RED):
Add TestIconButton_GhostNeutral, TestIconButton_SolidDanger, TestUIIcon_Plus, TestUIIcon_Fallback,
TestSpaceX_MD, TestSpaceY_LG, TestButton_IconRendered.
Run go test ./internal/web/ui/... — confirm failures.
Step 2 — Create icon-button.css: port go-backend/internal/web/ui/icon-button.css verbatim.
Must contain .ui-icon-button { min-height: 44px; min-width: 44px; ... },
.ui-icon-button-solid { ... }, .borderless-icon-button { ... },
.ui-icon-button-ghost { ... }, and variant tone/color rules.
Step 3 — Create icon_button.templ: port go-backend/internal/web/ui/icon_button.templ.
IconButtonProps struct: Label string, Icon string, Variant IconButtonVariant, Tone IconButtonTone,
Type string, Attrs templ.Attributes.
IconButton templ: renders <button type={buttonType(props.Type)} class={IconButtonClass(props.Variant, props.Tone)} aria-label={props.Label} {props.Attrs...}>@UIIcon(props.Icon)</button>.
UIIcon templ function: switch on kind with cases for: "plus", "grid3x3", "list", "filter",
"search", "calendar", "pencil", "trash". Each case renders the inline SVG from go-backend.
Default case: <span aria-hidden="true">{ kind }</span>.
Note: UIIcon is defined in icon_button.templ (same file as IconButton) — this is the go-backend pattern.
Step 4 — Create spacing.css: port go-backend/internal/web/ui/spacing.css verbatim.
Must contain .ui-space-x-xs, .ui-space-x-sm, .ui-space-x-md, .ui-space-x-lg, .ui-space-x-xl
and matching .ui-space-y-* classes.
Step 5 — Create space.templ: port go-backend/internal/web/ui/space.templ.
SpaceProps struct: Size SpacingStep.
SpaceX templ: renders <span class={SpaceXClass(props.Size)} aria-hidden="true"></span>.
SpaceY templ: renders <div class={SpaceYClass(props.Size)} aria-hidden="true"></div>.
Step 6 — Wire UIIcon into button.templ:
Find the placeholder comment "// UIIcon added in Plan 04" in button.templ (added in Plan 02 Task 1).
Replace it with the actual icon rendering:
if props.Icon != "" {
<span class="ui-button-icon">@UIIcon(props.Icon)</span>
}
UIIcon is now available because icon_button.templ is in the same package.
Step 7 — Update backend/tailwind.input.css to match the final state from 13-RESEARCH.md:
The complete import order after base.css + auth.css: button.css, badge.css, card.css,
input.css, textarea.css, select.css, modal.css, empty-state.css, table.css,
icon-button.css, form-field.css, spacing.css.
Per D-A02: do NOT add an app.css import.
Run just generate && go test ./internal/web/ui/... — all GREEN.
Run just generate && go test ./... — full suite GREEN.
</action>
<verify>
<automated>cd backend && just generate && go test ./... -count=1</automated>
</verify>
<acceptance_criteria>
- TestIconButton_GhostNeutral passes (HTML contains "borderless-icon-button")
- TestIconButton_SolidDanger passes (HTML contains "ui-icon-button-solid" and "ui-icon-button-danger")
- TestUIIcon_Plus passes (HTML contains "<svg")
- TestUIIcon_Fallback passes (HTML contains "<span" fallback for unknown kind)
- TestSpaceX_MD passes (HTML contains "ui-space-x-md")
- TestSpaceY_LG passes (HTML contains "ui-space-y-lg")
- TestButton_IconRendered passes (Button with Icon="plus" renders an SVG)
- icon_button.templ contains "templ UIIcon(kind string)"
- icon_button.templ contains 'case "plus"' (icon switch)
- button.templ contains "@UIIcon(props.Icon)" (wired icon rendering)
- spacing.css contains ".ui-space-x-md {"
- tailwind.input.css contains "@import "./internal/web/ui/spacing.css";"
- tailwind.input.css contains "@import "./internal/web/ui/icon-button.css";"
- tailwind.input.css contains all 13 component CSS imports (count: grep -c '@import.*web/ui' backend/tailwind.input.css should return 14 — base + auth + 12 components)
- just generate succeeds
- go test ./... is fully green
</acceptance_criteria>
<done>icon-button/space components ported; UIIcon wired into button.templ; tailwind.input.css updated with all 13 imports; full test suite green</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| UIIcon switch → HTML | Icon names are developer-supplied string constants (not user input at runtime); inline SVGs are static Go source |
| Modal backdrop → browser DOM | Backdrop uses position:fixed — this is expected for production use; the catalog renders panel-only (Pitfall 7 mitigation applied in Plan 05) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-13-04-01 | Tampering | UIIcon default fallback | accept | The fallback renders { kind } which is escaped by templ; no XSS via icon name string |
| T-13-04-02 | Information Disclosure | icon-button.css | accept | CSS is a public static asset; no sensitive information in icon button styling rules |
| T-13-04-03 | Elevation of Privilege | catalog route (tailwind.input.css update) | accept | tailwind.input.css controls CSS only — no route registration, no handler; catalog route security is handled by Plan 05 build-tag gating |
</threat_model>
<verification>
After this plan completes:
- cd backend && just generate — must succeed
- cd backend && go test ./... -count=1 — must be green across all packages
- grep -c '@import.*web/ui' backend/tailwind.input.css — must return 14 (base + auth + 12 components)
- grep 'templ UIIcon' backend/internal/web/ui/icon_button.templ — must match
- grep '@UIIcon' backend/internal/web/ui/button.templ — must match (icon wired)
- grep 'ui-table-shell' backend/internal/web/ui/table.css — must match
- grep 'ui-empty-state' backend/internal/web/ui/empty-state.css — must match
</verification>
<success_criteria>
1. Modal, EmptyState, Table, IconButton (with UIIcon), Space components all exist with CSS + templ
2. UIIcon renders inline SVG for: plus, grid3x3, list, filter, search, calendar, pencil, trash
3. button.templ Icon field renders via @UIIcon
4. tailwind.input.css has all 13 CSS file imports (base, auth, button, badge, card, input, textarea, select, modal, empty-state, table, icon-button, form-field, spacing)
5. All DS-06/07/08/09 component tests pass
6. Full go test ./... is green
</success_criteria>
<output>
After completion, create `.planning/phases/13-design-system-foundation/13-04-SUMMARY.md`
</output>

View file

@ -0,0 +1,312 @@
---
phase: 13-design-system-foundation
plan: 05
type: execute
wave: 4
depends_on:
- 13-03
- 13-04
files_modified:
- backend/internal/web/ui/catalog/catalog.templ
- backend/internal/web/ui/catalog/examples.go
- backend/internal/web/catalog_route_catalog.go
- backend/internal/web/catalog_route_stub.go
- backend/internal/web/router.go
- backend/justfile
autonomous: false
requirements:
- DS-01
- DS-02
- DS-03
- DS-04
- DS-05
- DS-06
- DS-07
- DS-08
- DS-09
must_haves:
truths:
- "go run -tags catalog ./cmd/web serves GET /ui-catalog and returns HTTP 200"
- "The catalog page renders all 11 component sections with section headings matching DS-XX requirement IDs"
- "The default (no -tags catalog) build does not include the catalog route"
- "The developer can visually verify all component variants before Phase 14 begins"
- "go build ./... (without -tags catalog) succeeds — no undefined symbol linker errors"
- "go test ./... passes — no regressions"
artifacts:
- path: "backend/internal/web/ui/catalog/catalog.templ"
provides: "Single-page catalog layout with sidebar nav and component sections"
contains: "Component Catalog"
- path: "backend/internal/web/ui/catalog/examples.go"
provides: "Typed Example structs for each component variant"
contains: "buttonExamples"
- path: "backend/internal/web/catalog_route_catalog.go"
provides: "Build-tagged catalog route registration"
contains: "//go:build catalog"
- path: "backend/internal/web/catalog_route_stub.go"
provides: "No-op stub for production builds"
contains: "//go:build !catalog"
key_links:
- from: "backend/internal/web/router.go"
to: "RegisterCatalogRoute"
via: "unconditional call in NewRouter"
pattern: "RegisterCatalogRoute"
- from: "backend/internal/web/catalog_route_catalog.go"
to: "backend/internal/web/ui/catalog/catalog.templ"
via: "catalogPageHandler() templ.Handler render"
pattern: "catalog"
---
## Phase Goal
**As a** developer, **I want to** run `just catalog` and view all 11 component types rendered with every variant on a single page, **so that** I can visually sign off on token values and component shapes before Phase 14 begins applying the design system to real app views.
<objective>
Create the build-tag-gated /ui-catalog route with a single-page component catalog.
The catalog shows all component variants with their templ call annotations.
A human checkpoint at the end gates progression to Phase 14.
Purpose: Visual sign-off is the final gate before Phases 1417 apply the design system
to real app views. The catalog makes component shapes and token choices visible before
any per-view work begins.
Output:
- backend/internal/web/ui/catalog/catalog.templ (layout + section rendering)
- backend/internal/web/ui/catalog/examples.go (typed examples per component)
- backend/internal/web/catalog_route_catalog.go (//go:build catalog)
- backend/internal/web/catalog_route_stub.go (//go:build !catalog)
- router.go wired with unconditional RegisterCatalogRoute call
- justfile catalog target added
</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-UI-SPEC.md
@.planning/phases/13-design-system-foundation/13-03-SUMMARY.md
@.planning/phases/13-design-system-foundation/13-04-SUMMARY.md
</context>
<interfaces>
From backend/internal/web/router.go:
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps,
taskDeps TasksDeps, etapeDeps EtapesDeps, eventDeps EventsDeps,
discussionDeps DiscussionDeps, planningDeps PlanningDeps, fileDeps FilesDeps,
csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error)
// Add RegisterCatalogRoute(r) call inside this function, after all protected routes.
Build tag file pair pattern:
catalog_route_catalog.go — //go:build catalog — defines RegisterCatalogRoute(r chi.Router) that mounts handler
catalog_route_stub.go — //go:build !catalog — defines RegisterCatalogRoute(r chi.Router) {} no-op
Catalog layout (from 13-UI-SPEC.md):
Sidebar 240px fixed, right: main content fluid
Section order: alphabetical — badge, button, card, empty-state, form-field, icon-button, input, modal, select, table, textarea
Each section heading: "{ComponentName} — {DS-req}" (e.g. "Button — DS-02")
Modal section renders panel-only (no backdrop wrapper — Pitfall 7)
Catalog copywriting (from 13-UI-SPEC.md):
Page title: "Component Catalog"
Nav heading: "Components"
Empty state demo title: "Nothing here yet"
Empty state demo body: "Add your first item to get started."
Modal demo title: "Confirm action"
Form error demo: "This field is required."
Form hint demo: "Enter a value between 1 and 100."
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Create catalog package (catalog.templ + examples.go + route files + router wiring)</name>
<files>
backend/internal/web/ui/catalog/catalog.templ,
backend/internal/web/ui/catalog/examples.go,
backend/internal/web/catalog_route_catalog.go,
backend/internal/web/catalog_route_stub.go,
backend/internal/web/router.go
</files>
<read_first>
- go-backend/internal/web/ui/catalog/catalog.templ (structure reference — go-backend uses static file generation but the templ layout structure is the model)
- go-backend/internal/web/ui/catalog/examples.go (Example struct pattern and per-component example functions)
- backend/internal/web/router.go (find where to insert RegisterCatalogRoute call — after all protected routes, before healthz handler if any)
- 13-PATTERNS.md (catalog.templ, examples.go, catalog_route_catalog.go + stub sections)
- 13-RESEARCH.md (Catalog Route — Build Tag Pattern section, Pitfall 4, Pitfall 7)
- 13-UI-SPEC.md (Catalog Page Contract section — layout, section order, copywriting)
</read_first>
<action>
Step 1 — Create backend/internal/web/ui/catalog/ directory and catalog.templ:
Package declaration: "package catalog".
The catalog page uses a fake shell with Tailwind utilities (flex, min-h-screen, w-60, etc.)
— no import of real app.css (per D-A04).
Layout: two-column flex with sidebar (w-60, fixed, border-r) and main content area (flex-1, p-8).
Sidebar nav: heading "Components" + anchor links in alphabetical order:
#badge, #button, #card, #empty-state, #form-field, #icon-button, #input, #modal, #select, #table, #textarea.
Each section in main content: <section id="{slug}"> with <h2>{ComponentName} — {DS-req}</h2>
followed by the component examples from the examples list.
Each example: rendered component + <pre><code> block showing the Snippet string.
Modal section: render the ui-modal-panel div directly without the ui-modal-backdrop wrapper
(Pitfall 7 — backdrop position:fixed would overlay the page).
Page title: <title>Component Catalog</title>.
Import path for ui components: "backend/internal/web/ui".
Step 2 — Create backend/internal/web/ui/catalog/examples.go:
Package declaration: "package catalog".
Define type Example struct { Title string; Preview templ.Component; Snippet string }.
Define example functions for each component:
- badgeExamples() — returns examples for info, warning, success, danger, primary variants
- buttonExamples() — returns examples for solid/default, solid/danger, soft/neutral, ghost variants (all size MD)
- cardExamples() — returns one example with body content using textBody helper
- emptyStateExamples() — returns one example with title "Nothing here yet", description "Add your first item to get started."
- formFieldExamples() — returns example with Label, Hint, Error using "This field is required." and "Enter a value between 1 and 100."
- iconButtonExamples() — returns examples for ghost/neutral (plus icon) and solid/danger (trash icon)
- inputExamples() — returns examples for text type, email type, with placeholder
- modalExamples() — returns one example rendering ModalProps{Title: "Confirm action"} BUT wraps it to render only the ui-modal-panel (not the backdrop)
- selectExamples() — returns one example with a few options
- tableExamples() — returns one example with a simple head/body
- textareaExamples() — returns one example with placeholder and rows
Add a componentFunc helper (adapted from go-backend examples.go):
func componentFunc(f func(ctx context.Context, w io.Writer) error) templ.Component.
Imports: "context", "io", "github.com/a-h/templ", "backend/internal/web/ui".
Step 3 — Create backend/internal/web/catalog_route_catalog.go:
First line: //go:build catalog
Package declaration: package web
Import: "github.com/go-chi/chi/v5"
Define RegisterCatalogRoute(r chi.Router) that calls r.Get("/ui-catalog", catalogPageHandler()).
Define catalogPageHandler() http.HandlerFunc that renders catalog.templ using templ.Handler.
Import path for catalog: "backend/internal/web/ui/catalog".
Step 4 — Create backend/internal/web/catalog_route_stub.go:
First line: //go:build !catalog
Package declaration: package web
Import: "github.com/go-chi/chi/v5"
Define RegisterCatalogRoute(r chi.Router) {} // no-op in production builds.
Step 5 — Update backend/internal/web/router.go:
Add a single unconditional call RegisterCatalogRoute(r) inside NewRouter, after all authenticated
routes are registered and before the function returns. In the non-catalog build, the stub's
no-op satisfies the symbol — no linker error.
Run: cd backend && go build ./... — must succeed (without -tags catalog — stub satisfies symbol).
Run: cd backend && go build -tags catalog ./... — must succeed (catalog build).
Run: cd backend && just generate && go test ./... -count=1 — must be green.
</action>
<verify>
<automated>cd backend && go build ./... && go build -tags catalog ./... && just generate && go test ./... -count=1</automated>
</verify>
<acceptance_criteria>
- go build ./... (without catalog tag) succeeds — no linker errors
- go build -tags catalog ./... succeeds
- just generate && go test ./... -count=1 is green
- backend/internal/web/catalog_route_catalog.go starts with "//go:build catalog"
- backend/internal/web/catalog_route_stub.go starts with "//go:build !catalog"
- router.go contains "RegisterCatalogRoute(r)"
- catalog.templ contains "Component Catalog"
- catalog.templ contains each section anchor: "#badge", "#button", "#card", "#input"
- examples.go contains "func buttonExamples()"
- examples.go contains "func modalExamples()"
- The modal example does NOT use "ui-modal-backdrop" as a wrapper (renders panel-only)
</acceptance_criteria>
<done>Catalog package created; route files registered; router wired; build succeeds with and without catalog tag; tests green</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Visual sign-off — run catalog and verify all 11 component sections</name>
<what-built>
A dev-only catalog route at /ui-catalog that renders all 11 component types with all variants.
The catalog is gated by //go:build catalog so it never ships in production.
The justfile has a "catalog" target that runs: just generate && go run -tags catalog ./cmd/web
Developer visits http://localhost:8080/ui-catalog to review all components.
</what-built>
<how-to-verify>
1. Run the catalog server:
cd backend && just generate && go run -tags catalog ./cmd/web
(or: just catalog if the target is wired)
2. Visit http://localhost:8080/ui-catalog in a browser.
3. Verify the sidebar shows all 11 section links in alphabetical order:
badge, button, card, empty-state, form-field, icon-button, input, modal, select, table, textarea
4. For each section, verify:
- Section heading includes the DS requirement number (e.g. "Button — DS-02")
- All declared variants are rendered (e.g. button: solid/default, solid/danger, soft/neutral, ghost)
- Each variant shows a templ call annotation below the rendered component
5. Verify specific components:
- Button: ghost variant renders with transparent background (no colored fill)
- Badge: primary variant renders with purple-tinted background (different from info blue)
- Card: header and body regions render as separate bordered sections
- Modal: modal panel is visible (no full-page backdrop overlay)
- EmptyState: icon circle + title + description render correctly
- Select: dropdown control renders with chevron indicator
- IconButton: ghost variant has no background, solid variant has filled background
6. Check browser console — no JavaScript errors.
7. Confirm token values look correct:
- Brand purple (#804eec) on primary buttons and ghost button text
- Pill-shaped badges with subtle colored borders
- Cards with rounded corners and shadow
8. Type "approved" if all 11 sections look correct, or describe any issues to address.
</how-to-verify>
<resume-signal>
Type "approved" to proceed to Phase 14, or describe specific visual issues (component,
variant, observed vs expected appearance) so they can be fixed before Phase 14 begins.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Catalog route → production binary | //go:build catalog tag ensures the route is never compiled into production; production binary built without -tags catalog |
| Catalog page → browser | All catalog content is developer-authored static data (no user input rendered on catalog page) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-13-05-01 | Information Disclosure | /ui-catalog route in production | mitigate | //go:build catalog + //go:build !catalog stub ensures the route symbol is a no-op in default builds. Production deployment uses go build ./... (no catalog tag). Verify with: go build ./... followed by strings ./cmd/web/main-binary | grep ui-catalog — must return nothing. |
| T-13-05-02 | Elevation of Privilege | catalog route in dev | accept | Catalog is dev-only; no auth required is intentional for a local development tool; serves static component previews only, no user data |
| T-13-05-03 | Information Disclosure | examples.go static strings | accept | Demo strings ("Nothing here yet", "Confirm action", etc.) are hard-coded display text; no secrets, no user data |
</threat_model>
<verification>
After this plan completes (pre-checkpoint):
- cd backend && go build ./... — must succeed (no catalog tag, stub satisfies symbol)
- cd backend && go build -tags catalog ./... — must succeed
- cd backend && go test ./... -count=1 — must be green
- grep 'RegisterCatalogRoute' backend/internal/web/router.go — must match
- grep '^//go:build catalog$' backend/internal/web/catalog_route_catalog.go — must match
- grep '^//go:build !catalog$' backend/internal/web/catalog_route_stub.go — must match
After checkpoint approval:
- Developer has visually confirmed all 11 component sections on /ui-catalog
- Token values (brand purple, badge tones, card shadow) match expectations
- Phase 14 can begin
</verification>
<success_criteria>
1. go build ./... and go build -tags catalog ./... both succeed
2. go test ./... is green with no regressions
3. /ui-catalog serves all 11 component sections with variants and templ annotations
4. Production build (no -tags catalog) excludes catalog route — RegisterCatalogRoute is a no-op
5. Developer has approved the visual output via the checkpoint — all 11 sections confirmed correct
</success_criteria>
<output>
After completion, create `.planning/phases/13-design-system-foundation/13-05-SUMMARY.md`
</output>