diff --git a/backend/internal/web/ui/form-field.css b/backend/internal/web/ui/form-field.css
new file mode 100644
index 0000000..922a3c8
--- /dev/null
+++ b/backend/internal/web/ui/form-field.css
@@ -0,0 +1,22 @@
+.ui-form-field {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.ui-form-label {
+ color: var(--color-text-primary);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.ui-form-hint {
+ color: var(--color-text-muted);
+ font-size: 0.875rem;
+ margin: 0;
+}
+
+.ui-form-error {
+ color: var(--color-status-danger-foreground);
+ font-size: 0.875rem;
+ margin: 0;
+}
diff --git a/backend/internal/web/ui/form_field.templ b/backend/internal/web/ui/form_field.templ
new file mode 100644
index 0000000..01f2efb
--- /dev/null
+++ b/backend/internal/web/ui/form_field.templ
@@ -0,0 +1,26 @@
+package ui
+
+type FormFieldProps struct {
+ Label string
+ For string
+ Field templ.Component
+ Error string
+ Hint string
+}
+
+templ FormField(props FormFieldProps) {
+
+}
diff --git a/backend/internal/web/ui/select.css b/backend/internal/web/ui/select.css
new file mode 100644
index 0000000..aa24802
--- /dev/null
+++ b/backend/internal/web/ui/select.css
@@ -0,0 +1,154 @@
+.ui-select {
+ position: relative;
+ width: 100%;
+}
+
+.ui-select-native {
+ height: 0;
+ left: 0;
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ width: 0;
+}
+
+.ui-select-control {
+ align-items: center;
+ appearance: none;
+ background: var(--color-surface-default);
+ border: 1px solid var(--color-border-default);
+ border-radius: 0.75rem;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ display: flex;
+ gap: 0.75rem;
+ justify-content: space-between;
+ min-height: 44px;
+ padding: 0.55rem 0.75rem 0.55rem 0.95rem;
+ text-align: left;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+ width: 100%;
+}
+
+.ui-select-control:focus-visible {
+ border-color: var(--color-brand-focus);
+ box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
+ outline: none;
+}
+
+.ui-select-control:disabled {
+ color: var(--color-text-faint);
+ cursor: not-allowed;
+}
+
+.ui-select-value-wrapper {
+ align-items: center;
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ min-width: 0;
+}
+
+.ui-select-placeholder {
+ color: var(--color-text-faint);
+}
+
+.ui-select-chip {
+ background: var(--color-surface-muted);
+ border-radius: 999px;
+ color: var(--color-text-primary);
+ display: inline-flex;
+ font-size: 0.875rem;
+ line-height: 1;
+ padding: 0.35rem 0.6rem;
+}
+
+.ui-select-arrow-zone {
+ align-items: center;
+ color: var(--color-text-secondary);
+ display: inline-flex;
+ flex: 0 0 auto;
+ justify-content: center;
+}
+
+.ui-select-arrow-icon {
+ height: 1rem;
+ transition: transform 0.2s ease;
+ width: 1rem;
+}
+
+.ui-select.is-open .ui-select-arrow-icon {
+ transform: rotate(180deg);
+}
+
+.ui-select-menu {
+ background: var(--color-surface-default);
+ border: 1px solid var(--color-border-default);
+ border-radius: 0.9rem;
+ box-shadow: var(--shadow-surface-md);
+ display: grid;
+ gap: 0.25rem;
+ left: 0;
+ margin-top: 0.45rem;
+ max-height: 16rem;
+ overflow-y: auto;
+ padding: 0.4rem;
+ position: absolute;
+ right: 0;
+ top: 100%;
+ z-index: 30;
+}
+
+.ui-select-menu[hidden] {
+ display: none;
+}
+
+.ui-select-option {
+ align-items: center;
+ appearance: none;
+ background: transparent;
+ border: 0;
+ border-radius: 0.7rem;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ display: flex;
+ font: inherit;
+ gap: 0.75rem;
+ justify-content: space-between;
+ padding: 0.7rem 0.8rem;
+ text-align: left;
+ width: 100%;
+}
+
+.ui-select-option:hover,
+.ui-select-option:focus-visible {
+ background: var(--color-surface-muted);
+ outline: none;
+}
+
+.ui-select-option.is-selected {
+ background: var(--color-status-info-soft-bg);
+ color: var(--color-text-brand);
+}
+
+.ui-select-option:disabled {
+ color: var(--color-text-faint);
+ cursor: not-allowed;
+}
+
+.ui-select-option-text {
+ flex: 1 1 auto;
+}
+
+.ui-select-option-check {
+ color: currentColor;
+ opacity: 0;
+}
+
+.ui-select-option.is-selected .ui-select-option-check {
+ opacity: 1;
+}
diff --git a/backend/internal/web/ui/select.templ b/backend/internal/web/ui/select.templ
new file mode 100644
index 0000000..befd82e
--- /dev/null
+++ b/backend/internal/web/ui/select.templ
@@ -0,0 +1,251 @@
+package ui
+
+type SelectOption struct {
+ Value string
+ Label string
+ Disabled bool
+}
+
+type SelectProps struct {
+ ID string
+ Name string
+ Placeholder string
+ Value string
+ Values []string
+ Multiple bool
+ Options []SelectOption
+ Attrs templ.Attributes
+}
+
+templ Select(props SelectProps) {
+
+
+
+
+
+
+}
diff --git a/backend/internal/web/ui/select_helpers.go b/backend/internal/web/ui/select_helpers.go
new file mode 100644
index 0000000..5cf941e
--- /dev/null
+++ b/backend/internal/web/ui/select_helpers.go
@@ -0,0 +1,104 @@
+package ui
+
+import (
+ "strings"
+
+ "github.com/a-h/templ"
+)
+
+func selectPlaceholder(props SelectProps) string {
+ if props.Placeholder != "" {
+ return props.Placeholder
+ }
+ if props.Multiple {
+ return "Select values"
+ }
+ return "Select an option"
+}
+
+func selectNativeID(id string, name string) string {
+ baseID := inputID(id, name)
+ if baseID == "" {
+ return "ui-select-native"
+ }
+ return baseID + "-native"
+}
+
+func selectMenuID(id string, name string) string {
+ baseID := inputID(id, name)
+ if baseID == "" {
+ return "ui-select-menu"
+ }
+ return baseID + "-menu"
+}
+
+func selectBoolData(value bool) string {
+ if value {
+ return "true"
+ }
+ return "false"
+}
+
+func selectSelectedValues(props SelectProps) []string {
+ if props.Multiple {
+ return props.Values
+ }
+ if props.Value == "" {
+ return nil
+ }
+ return []string{props.Value}
+}
+
+func selectOptionSelected(props SelectProps, value string) bool {
+ for _, selected := range selectSelectedValues(props) {
+ if selected == value {
+ return true
+ }
+ }
+ return false
+}
+
+func selectSelectedLabels(props SelectProps) []string {
+ var labels []string
+ for _, option := range props.Options {
+ if selectOptionSelected(props, option.Value) {
+ labels = append(labels, option.Label)
+ }
+ }
+ return labels
+}
+
+func selectSelectedLabel(props SelectProps) string {
+ return strings.Join(selectSelectedLabels(props), ", ")
+}
+
+func selectMenuOptionClass(selected bool, disabled bool) string {
+ className := "ui-select-option"
+ if selected {
+ className += " is-selected"
+ }
+ if disabled {
+ className += " is-disabled"
+ }
+ return className
+}
+
+func selectIsDisabled(attrs templ.Attributes) bool {
+ if attrs == nil {
+ return false
+ }
+
+ value, ok := attrs["disabled"]
+ if !ok {
+ return false
+ }
+
+ switch typed := value.(type) {
+ case bool:
+ return typed
+ case string:
+ return typed != "" && typed != "false"
+ default:
+ return true
+ }
+}
diff --git a/backend/tailwind.input.css b/backend/tailwind.input.css
index 7e17912..6581cc5 100644
--- a/backend/tailwind.input.css
+++ b/backend/tailwind.input.css
@@ -11,3 +11,5 @@
@import "./internal/web/ui/card.css";
@import "./internal/web/ui/input.css";
@import "./internal/web/ui/textarea.css";
+@import "./internal/web/ui/select.css";
+@import "./internal/web/ui/form-field.css";