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(`

Confirm action

`), + 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(`NameStatus`), + Body: textBody(`Example ProjectIn progressAnother ProjectDone`), + }), + 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 ./...