diff --git a/backend/internal/web/catalog_route_catalog.go b/backend/internal/web/catalog_route_catalog.go
new file mode 100644
index 0000000..10aa122
--- /dev/null
+++ b/backend/internal/web/catalog_route_catalog.go
@@ -0,0 +1,24 @@
+//go:build catalog
+
+package web
+
+import (
+ "net/http"
+
+ "github.com/a-h/templ"
+ "github.com/go-chi/chi/v5"
+
+ "backend/internal/web/ui/catalog"
+)
+
+// RegisterCatalogRoute mounts the /ui-catalog route.
+// This file is only compiled with -tags catalog (dev tool; never ships to production).
+func RegisterCatalogRoute(r chi.Router) {
+ r.Get("/ui-catalog", catalogPageHandler())
+}
+
+func catalogPageHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ templ.Handler(catalog.CatalogPage()).ServeHTTP(w, r)
+ }
+}
diff --git a/backend/internal/web/catalog_route_stub.go b/backend/internal/web/catalog_route_stub.go
new file mode 100644
index 0000000..045e49b
--- /dev/null
+++ b/backend/internal/web/catalog_route_stub.go
@@ -0,0 +1,9 @@
+//go:build !catalog
+
+package web
+
+import "github.com/go-chi/chi/v5"
+
+// RegisterCatalogRoute is a no-op in production builds.
+// The catalog route is only available when built with -tags catalog.
+func RegisterCatalogRoute(r chi.Router) {} //nolint:revive
diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go
index 53b3cec..33e8e18 100644
--- a/backend/internal/web/router.go
+++ b/backend/internal/web/router.go
@@ -146,6 +146,10 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps))
})
+ // Dev-only catalog route (build tag: -tags catalog).
+ // In production builds catalog_route_stub.go provides the no-op symbol.
+ RegisterCatalogRoute(r)
+
// Liveness probe (D-12): always 200, no DB contact.
r.Get("/healthz", HealthzHandler())
// Readiness probe (D-13): probes DB; 200 when ready, 503 when degraded.
diff --git a/backend/internal/web/ui/catalog/catalog.templ b/backend/internal/web/ui/catalog/catalog.templ
new file mode 100644
index 0000000..02f5607
--- /dev/null
+++ b/backend/internal/web/ui/catalog/catalog.templ
@@ -0,0 +1,87 @@
+package catalog
+
+// CatalogPage renders the single-page component catalog.
+// Layout: fixed 240px sidebar + fluid main content area.
+// The catalog uses Tailwind utility classes for its shell — not app.css (D-A04).
+// Built with -tags catalog only; never compiled into production binaries.
+templ CatalogPage() {
+
+
+
+
+
+ Component Catalog
+
+
+
+
+
+
+ Component Catalog
+ All 11 component types with variants. Visual sign-off gate for Phase 14.
+ @catalogSection("badge", "Badge — DS-05", badgeExamples())
+ @catalogSection("button", "Button — DS-02", buttonExamples())
+ @catalogSection("card", "Card — DS-04", cardExamples())
+ @catalogSection("empty-state", "Empty State — DS-09", emptyStateExamples())
+ @catalogSection("form-field", "Form Field — DS-08", formFieldExamples())
+ @catalogSection("icon-button", "Icon Button — DS-07", iconButtonExamples())
+ @catalogSection("input", "Input — DS-03", inputExamples())
+ @catalogSection("modal", "Modal — DS-06", modalExamples())
+ @catalogSection("select", "Select — DS-10", selectExamples())
+ @catalogSection("table", "Table — DS-11", tableExamples())
+ @catalogSection("textarea", "Textarea — DS-03b", textareaExamples())
+
+
+
+
+}
+
+templ catalogSection(id string, heading string, examples []Example) {
+
+ { heading }
+ for _, ex := range examples {
+
+
{ ex.Title }
+
+ @ex.Preview
+
+ if ex.Snippet != "" {
+
{ ex.Snippet }
+ }
+
+ }
+
+}
diff --git a/backend/internal/web/ui/catalog/examples.go b/backend/internal/web/ui/catalog/examples.go
new file mode 100644
index 0000000..4e031f8
--- /dev/null
+++ b/backend/internal/web/ui/catalog/examples.go
@@ -0,0 +1,407 @@
+package catalog
+
+import (
+ "context"
+ "io"
+
+ "github.com/a-h/templ"
+
+ "backend/internal/web/ui"
+)
+
+// Example is a single rendered component variant with a templ call annotation.
+type Example struct {
+ Title string
+ Preview templ.Component
+ Snippet string
+}
+
+type anyComponent = templ.Component
+
+func badgeExamples() []Example {
+ return []Example{
+ {
+ Title: "Info",
+ Preview: ui.Badge(ui.BadgeProps{
+ Label: "Info",
+ Variant: ui.BadgeVariantInfo,
+ }),
+ Snippet: `@ui.Badge(ui.BadgeProps{Label: "Info", Variant: ui.BadgeVariantInfo})`,
+ },
+ {
+ Title: "Warning",
+ Preview: ui.Badge(ui.BadgeProps{
+ Label: "Warning",
+ Variant: ui.BadgeVariantWarning,
+ }),
+ Snippet: `@ui.Badge(ui.BadgeProps{Label: "Warning", Variant: ui.BadgeVariantWarning})`,
+ },
+ {
+ Title: "Success",
+ Preview: ui.Badge(ui.BadgeProps{
+ Label: "Success",
+ Variant: ui.BadgeVariantSuccess,
+ }),
+ Snippet: `@ui.Badge(ui.BadgeProps{Label: "Success", Variant: ui.BadgeVariantSuccess})`,
+ },
+ {
+ Title: "Danger",
+ Preview: ui.Badge(ui.BadgeProps{
+ Label: "Danger",
+ Variant: ui.BadgeVariantDanger,
+ }),
+ Snippet: `@ui.Badge(ui.BadgeProps{Label: "Danger", Variant: ui.BadgeVariantDanger})`,
+ },
+ {
+ Title: "Primary",
+ Preview: ui.Badge(ui.BadgeProps{
+ Label: "Primary",
+ Variant: ui.BadgeVariantPrimary,
+ }),
+ Snippet: `@ui.Badge(ui.BadgeProps{Label: "Primary", Variant: ui.BadgeVariantPrimary})`,
+ },
+ }
+}
+
+func buttonExamples() []Example {
+ return []Example{
+ {
+ Title: "Solid / Default",
+ Preview: ui.Button(ui.ButtonProps{
+ Label: "Create project",
+ Variant: ui.ButtonVariantDefault,
+ Tone: ui.ButtonToneSolid,
+ Size: ui.SizeMD,
+ Type: "button",
+ }),
+ Snippet: `@ui.Button(ui.ButtonProps{
+ Label: "Create project",
+ Variant: ui.ButtonVariantDefault,
+ Tone: ui.ButtonToneSolid,
+ Size: ui.SizeMD,
+ Type: "button",
+})`,
+ },
+ {
+ Title: "Solid / Danger",
+ Preview: ui.Button(ui.ButtonProps{
+ Label: "Delete",
+ Variant: ui.ButtonVariantDanger,
+ Tone: ui.ButtonToneSolid,
+ Size: ui.SizeMD,
+ Type: "button",
+ }),
+ Snippet: `@ui.Button(ui.ButtonProps{
+ Label: "Delete",
+ Variant: ui.ButtonVariantDanger,
+ Tone: ui.ButtonToneSolid,
+ Size: ui.SizeMD,
+ Type: "button",
+})`,
+ },
+ {
+ Title: "Soft / Neutral",
+ Preview: ui.Button(ui.ButtonProps{
+ Label: "Cancel",
+ Variant: ui.ButtonVariantNeutral,
+ Tone: ui.ButtonToneSoft,
+ Size: ui.SizeMD,
+ Type: "button",
+ }),
+ Snippet: `@ui.Button(ui.ButtonProps{
+ Label: "Cancel",
+ Variant: ui.ButtonVariantNeutral,
+ Tone: ui.ButtonToneSoft,
+ Size: ui.SizeMD,
+ Type: "button",
+})`,
+ },
+ {
+ Title: "Ghost",
+ Preview: ui.Button(ui.ButtonProps{
+ Label: "Learn more",
+ Variant: ui.ButtonVariantGhost,
+ Size: ui.SizeMD,
+ Type: "button",
+ }),
+ Snippet: `@ui.Button(ui.ButtonProps{
+ Label: "Learn more",
+ Variant: ui.ButtonVariantGhost,
+ Size: ui.SizeMD,
+ Type: "button",
+})`,
+ },
+ }
+}
+
+func cardExamples() []Example {
+ return []Example{
+ {
+ Title: "Card with header, body, and footer",
+ Preview: ui.Card(ui.CardProps{
+ Header: textBody("Card Header"),
+ Body: textBody("Card body content goes here."),
+ Footer: textBody("Card Footer"),
+ }),
+ Snippet: `@ui.Card(ui.CardProps{
+ Header: headerComponent,
+ Body: bodyComponent,
+ Footer: footerComponent,
+})`,
+ },
+ }
+}
+
+func emptyStateExamples() []Example {
+ return []Example{
+ {
+ Title: "Empty list",
+ Preview: ui.EmptyState(ui.EmptyStateProps{
+ Title: "Nothing here yet",
+ Description: "Add your first item to get started.",
+ Icon: ui.UIIcon("grid3x3"),
+ Action: ui.Button(ui.ButtonProps{
+ Label: "Add item",
+ Variant: ui.ButtonVariantDefault,
+ Tone: ui.ButtonToneSolid,
+ Size: ui.SizeMD,
+ Type: "button",
+ Icon: "plus",
+ }),
+ }),
+ Snippet: `@ui.EmptyState(ui.EmptyStateProps{
+ Title: "Nothing here yet",
+ Description: "Add your first item to get started.",
+ Icon: ui.UIIcon("grid3x3"),
+ Action: ui.Button(...),
+})`,
+ },
+ }
+}
+
+func formFieldExamples() []Example {
+ return []Example{
+ {
+ Title: "Field with label, hint, and error",
+ Preview: ui.FormField(ui.FormFieldProps{
+ Label: "Project name",
+ For: "catalog-name",
+ Field: ui.Input(ui.InputProps{
+ ID: "catalog-name",
+ Name: "name",
+ Placeholder: "Enter a name",
+ Type: "text",
+ }),
+ Hint: "Enter a value between 1 and 100.",
+ Error: "This field is required.",
+ }),
+ Snippet: `@ui.FormField(ui.FormFieldProps{
+ Label: "Project name",
+ For: "catalog-name",
+ Field: ui.Input(ui.InputProps{...}),
+ Hint: "Enter a value between 1 and 100.",
+ Error: "This field is required.",
+})`,
+ },
+ }
+}
+
+func iconButtonExamples() []Example {
+ return []Example{
+ {
+ Title: "Ghost / Neutral (plus icon)",
+ Preview: ui.IconButton(ui.IconButtonProps{
+ Label: "Add item",
+ Icon: "plus",
+ Variant: ui.IconButtonVariantNeutral,
+ Tone: ui.IconButtonToneGhost,
+ Type: "button",
+ }),
+ Snippet: `@ui.IconButton(ui.IconButtonProps{
+ Label: "Add item",
+ Icon: "plus",
+ Variant: ui.IconButtonVariantNeutral,
+ Tone: ui.IconButtonToneGhost,
+ Type: "button",
+})`,
+ },
+ {
+ Title: "Solid / Danger (trash icon)",
+ Preview: ui.IconButton(ui.IconButtonProps{
+ Label: "Delete item",
+ Icon: "trash",
+ Variant: ui.IconButtonVariantDanger,
+ Tone: ui.IconButtonToneSolid,
+ Type: "button",
+ }),
+ Snippet: `@ui.IconButton(ui.IconButtonProps{
+ Label: "Delete item",
+ Icon: "trash",
+ Variant: ui.IconButtonVariantDanger,
+ Tone: ui.IconButtonToneSolid,
+ Type: "button",
+})`,
+ },
+ }
+}
+
+func inputExamples() []Example {
+ return []Example{
+ {
+ Title: "Text input",
+ Preview: ui.Input(ui.InputProps{
+ Name: "name",
+ Placeholder: "Enter text here",
+ Type: "text",
+ }),
+ Snippet: `@ui.Input(ui.InputProps{
+ Name: "name",
+ Placeholder: "Enter text here",
+ Type: "text",
+})`,
+ },
+ {
+ Title: "Email input",
+ Preview: ui.Input(ui.InputProps{
+ Name: "email",
+ Placeholder: "you@example.com",
+ Type: "email",
+ }),
+ Snippet: `@ui.Input(ui.InputProps{
+ Name: "email",
+ Placeholder: "you@example.com",
+ Type: "email",
+})`,
+ },
+ }
+}
+
+func modalExamples() []Example {
+ return []Example{
+ {
+ Title: "Modal panel (no backdrop)",
+ // Render only the panel div — not the full Modal component with backdrop.
+ // Pitfall 7: ui-modal-backdrop is position:fixed and would overlay the catalog page.
+ Preview: componentFunc(func(ctx context.Context, w io.Writer) error {
+ _, err := io.WriteString(w, ``)
+ if err != nil {
+ return err
+ }
+ if err := renderComponents(ctx, w,
+ textBody(``),
+ textBody(`
Are you sure you want to proceed?
`),
+ textBody(`
`),
+ ); err != nil {
+ return err
+ }
+ if err := ui.Button(ui.ButtonProps{
+ Label: "Cancel",
+ Variant: ui.ButtonVariantNeutral,
+ Tone: ui.ButtonToneSoft,
+ Size: ui.SizeMD,
+ Type: "button",
+ }).Render(ctx, w); err != nil {
+ return err
+ }
+ if err := ui.Button(ui.ButtonProps{
+ Label: "Confirm",
+ Variant: ui.ButtonVariantDefault,
+ Tone: ui.ButtonToneSolid,
+ Size: ui.SizeMD,
+ Type: "button",
+ }).Render(ctx, w); err != nil {
+ return err
+ }
+ _, err = io.WriteString(w, `
`)
+ return err
+ }),
+ Snippet: `@ui.Modal(ui.ModalProps{
+ Title: "Confirm action",
+ Body: bodyContent,
+ Actions: actionButtons,
+})`,
+ },
+ }
+}
+
+func selectExamples() []Example {
+ return []Example{
+ {
+ Title: "Single select",
+ Preview: ui.Select(ui.SelectProps{
+ Name: "status",
+ Placeholder: "Select a status",
+ Value: "in-progress",
+ Options: []ui.SelectOption{
+ {Value: "todo", Label: "To do"},
+ {Value: "in-progress", Label: "In progress"},
+ {Value: "done", Label: "Done"},
+ },
+ }),
+ Snippet: `@ui.Select(ui.SelectProps{
+ Name: "status",
+ Placeholder: "Select a status",
+ Value: "in-progress",
+ Options: []ui.SelectOption{
+ {Value: "todo", Label: "To do"},
+ {Value: "in-progress", Label: "In progress"},
+ {Value: "done", Label: "Done"},
+ },
+})`,
+ },
+ }
+}
+
+func tableExamples() []Example {
+ return []Example{
+ {
+ Title: "Data table",
+ Preview: ui.Table(ui.TableProps{
+ Head: textBody(`| Name | Status |
`),
+ Body: textBody(`| Example Project | In progress |
| Another Project | Done |
`),
+ }),
+ Snippet: `@ui.Table(ui.TableProps{
+ Head: tableHead,
+ Body: tableBody,
+})`,
+ },
+ }
+}
+
+func textareaExamples() []Example {
+ return []Example{
+ {
+ Title: "Textarea with placeholder",
+ Preview: ui.Textarea(ui.TextareaProps{
+ Name: "description",
+ Placeholder: "Enter a description...",
+ Rows: 4,
+ }),
+ Snippet: `@ui.Textarea(ui.TextareaProps{
+ Name: "description",
+ Placeholder: "Enter a description...",
+ Rows: 4,
+})`,
+ },
+ }
+}
+
+func componentFunc(fn func(context.Context, io.Writer) error) templ.Component {
+ return templ.ComponentFunc(fn)
+}
+
+func textBody(text string) templ.Component {
+ return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
+ _, err := io.WriteString(w, text)
+ return err
+ })
+}
+
+func renderComponents(ctx context.Context, w io.Writer, components ...templ.Component) error {
+ for _, c := range components {
+ if err := c.Render(ctx, w); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/backend/justfile b/backend/justfile
index 43fda88..3e2e250 100644
--- a/backend/justfile
+++ b/backend/justfile
@@ -137,6 +137,19 @@ worker: db-up
S3_USE_PATH_STYLE='{{ s3_use_path_style }}' \
go run ./cmd/worker
+# Run the component catalog on localhost:8080/ui-catalog (dev-only, -tags catalog).
+# Visit http://localhost:8080/ui-catalog to review all 11 component sections.
+catalog:
+ just generate
+ DATABASE_URL='{{ database_url }}' \
+ S3_ENDPOINT='{{ s3_endpoint }}' \
+ S3_BUCKET='{{ s3_bucket }}' \
+ S3_REGION='{{ s3_region }}' \
+ S3_ACCESS_KEY='{{ s3_access_key }}' \
+ S3_SECRET_KEY='{{ s3_secret_key }}' \
+ S3_USE_PATH_STYLE='{{ s3_use_path_style }}' \
+ go run -tags catalog ./cmd/web
+
test:
just generate
go test ./...