diff --git a/backend/templates/auth_form_errors.templ b/backend/templates/auth_form_errors.templ
new file mode 100644
index 0000000..f20b74b
--- /dev/null
+++ b/backend/templates/auth_form_errors.templ
@@ -0,0 +1,19 @@
+package templates
+
+// FieldError renders a small red error message beneath a form field.
+// Renders nothing when msg is empty so callers can unconditionally include it.
+templ FieldError(msg string) {
+ if msg != "" {
+
{ msg }
+ }
+}
+
+// GeneralError renders a full-width error banner above the form.
+// Renders nothing when msg is empty.
+templ GeneralError(msg string) {
+ if msg != "" {
+
+ { msg }
+
+ }
+}
diff --git a/backend/templates/auth_forms.go b/backend/templates/auth_forms.go
new file mode 100644
index 0000000..f3e6d83
--- /dev/null
+++ b/backend/templates/auth_forms.go
@@ -0,0 +1,17 @@
+package templates
+
+// SignupForm carries the submitted field values back to the template so
+// the email field can be repopulated on validation failure.
+// Password is intentionally never echoed back to the client (T-2-01, D-25).
+type SignupForm struct {
+ Email string
+ Password string // held here only for length validation; never passed to templates
+}
+
+// SignupErrors holds per-field and general error messages for the signup form.
+// A field with an empty string means "no error for this field".
+type SignupErrors struct {
+ Email string
+ Password string
+ General string
+}
diff --git a/backend/templates/auth_signup.templ b/backend/templates/auth_signup.templ
new file mode 100644
index 0000000..facc389
--- /dev/null
+++ b/backend/templates/auth_signup.templ
@@ -0,0 +1,72 @@
+package templates
+
+import "backend/internal/web/ui"
+
+// SignupPage renders the full /signup page wrapped in the base Layout.
+// It delegates the form section to SignupFormFragment so HTMX can swap just the
+// form on validation errors without re-rendering the surrounding shell.
+templ SignupPage(form SignupForm, errs SignupErrors) {
+ @Layout("Sign up") {
+
+ @ui.Card(nil) {
+
+
Create your account
+ @SignupFormFragment(form, errs)
+
+ }
+
+ }
+}
+
+// SignupFormFragment is the bare form used for HTMX swaps.
+// hx-post targets this component itself so the form can be replaced inline
+// on validation failure (D-19, D-25).
+// The outer id="signup-form" must match the hx-target on this element.
+templ SignupFormFragment(form SignupForm, errs SignupErrors) {
+
+}
diff --git a/backend/templates/auth_signup_test.go b/backend/templates/auth_signup_test.go
new file mode 100644
index 0000000..3afb332
--- /dev/null
+++ b/backend/templates/auth_signup_test.go
@@ -0,0 +1,65 @@
+package templates
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+)
+
+// TestSignupPage_RendersForm verifies the full SignupPage output contains the
+// expected form attributes and that email value round-trips correctly.
+func TestSignupPage_RendersForm(t *testing.T) {
+ var buf bytes.Buffer
+ err := SignupPage(SignupForm{Email: "x@y.z"}, SignupErrors{}).Render(context.Background(), &buf)
+ if err != nil {
+ t.Fatalf("SignupPage.Render: %v", err)
+ }
+ body := buf.String()
+
+ for _, want := range []string{
+ `name="email"`,
+ `name="password"`,
+ `action="/signup"`,
+ `hx-post="/signup"`,
+ `value="x@y.z"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("SignupPage body missing %q", want)
+ }
+ }
+}
+
+// TestSignupFormFragment_RendersErrors verifies that SignupFormFragment renders
+// field-specific error messages and does NOT include a full tag (it is
+// a fragment, not a complete page).
+func TestSignupFormFragment_RendersErrors(t *testing.T) {
+ var buf bytes.Buffer
+ errs := SignupErrors{Password: "Password must be 12-128 characters"}
+ err := SignupFormFragment(SignupForm{}, errs).Render(context.Background(), &buf)
+ if err != nil {
+ t.Fatalf("SignupFormFragment.Render: %v", err)
+ }
+ body := buf.String()
+
+ if !strings.Contains(body, "Password must be 12-128 characters") {
+ t.Errorf("fragment missing error message; body: %s", body)
+ }
+ if strings.Contains(body, " tag; got full page")
+ }
+}
+
+// TestSignupPage_DoesNotEchoPassword verifies that the password value is never
+// reflected back into any rendered HTML — even when form.Password is set
+// (security requirement T-2-01, D-25).
+func TestSignupPage_DoesNotEchoPassword(t *testing.T) {
+ var buf bytes.Buffer
+ err := SignupPage(SignupForm{Email: "a@b.com", Password: "hunter2hunter2"}, SignupErrors{}).Render(context.Background(), &buf)
+ if err != nil {
+ t.Fatalf("SignupPage.Render: %v", err)
+ }
+ if strings.Contains(buf.String(), "hunter2") {
+ t.Errorf("SignupPage must not echo back the password value")
+ }
+}