diff --git a/frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx b/frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx
--- a/frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx
+++ b/frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx
@@ -18,6 +18,11 @@ interface ArtCraftSignUpProps {
   isLoading?: boolean;
 }
 
+// Server signup rules; the messages match the desktop's server error mapping
+// (password_auth_error_message.rs) so both read the same.
+const MIN_PASSWORD_LENGTH = 6;
+const USERNAME_PATTERN = /^[A-Za-z0-9_-]*$/;
+
 const FIELD_LABEL =
   "block font-mono text-[11px] font-semibold leading-4 uppercase tracking-[0.12em] text-white/70";
 const FIELD_INPUT =
@@ -47,26 +52,28 @@ export const ArtCraftSignUp = ({
 
   const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault();
+    setLocalError(undefined);
     const form = e.currentTarget;
     if (isSignUp) {
       const username = (form.elements.namedItem("username") as HTMLInputElement)
-        .value;
+        .value.trim();
       const email = (form.elements.namedItem("email") as HTMLInputElement)
-        .value;
+        .value.trim();
       const password = (form.elements.namedItem("password") as HTMLInputElement)
         .value;
       const confirmPassword = (
         form.elements.namedItem("confirmPassword") as HTMLInputElement
       ).value;
-      if (password !== confirmPassword) {
-        setLocalError("Passwords do not match.");
+      const validationError = validateSignup(username, password, confirmPassword);
+      if (validationError) {
+        setLocalError(validationError);
         return;
       }
       onSubmit(username, email, password, confirmPassword);
     } else {
       const usernameOrEmail = (
         form.elements.namedItem("usernameOrEmail") as HTMLInputElement
-      ).value;
+      ).value.trim();
       const password = (form.elements.namedItem("password") as HTMLInputElement)
         .value;
       onSubmit(usernameOrEmail, "", password, "");
@@ -129,7 +136,7 @@ export const ArtCraftSignUp = ({
           <Input
             name="password"
             type={showPassword ? "text" : "password"}
-            placeholder="Min. 8 characters"
+            placeholder={`Min. ${MIN_PASSWORD_LENGTH} characters`}
             required
             autoComplete="off"
             inputClassName={`${FIELD_INPUT} pr-12`}
@@ -198,3 +205,21 @@ export const ArtCraftSignUp = ({
     </form>
   );
 };
+
+/** Returns the first problem in form order, or undefined when the server should accept it. */
+function validateSignup(
+  username: string,
+  password: string,
+  confirmPassword: string,
+): string | undefined {
+  if (username.length < 3) return "Usernames must be at least 3 characters.";
+  if (username.length > 16) return "Usernames can be at most 16 characters.";
+  if (!USERNAME_PATTERN.test(username)) {
+    return "Usernames can only contain letters, numbers, underscores (_), and hyphens (-).";
+  }
+  if (password.length < MIN_PASSWORD_LENGTH) {
+    return `Passwords must be at least ${MIN_PASSWORD_LENGTH} characters.`;
+  }
+  if (password !== confirmPassword) return "Passwords do not match.";
+  return undefined;
+}
diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx b/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx
--- a/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx
+++ b/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx
@@ -61,12 +61,12 @@ describe("native login modal integration", () => {
     await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); });
     expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy();
     expect(screen.queryByPlaceholderText("you@example.com or username")).toBeNull();
-    expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull();
+    expect(screen.queryByPlaceholderText("Min. 6 characters")).toBeNull();
     expect(screen.queryByRole("button", { name: "Sign up" })).toBeNull();
 
     await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); });
     expect(screen.getByPlaceholderText("you@example.com or username")).toBeTruthy();
-    expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy();
+    expect(screen.getByPlaceholderText("Min. 6 characters")).toBeTruthy();
     expect(screen.getByRole("button", { name: "Login with Website" })).toBeTruthy();
     expect(screen.getByRole("button", { name: "Scan to Login" })).toBeTruthy();
     expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
@@ -82,10 +82,10 @@ describe("native login modal integration", () => {
     native.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
     await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Login with Website" })); });
     expect(screen.getByText("Preparing login…")).toBeTruthy();
-    expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull();
+    expect(screen.queryByPlaceholderText("Min. 6 characters")).toBeNull();
     await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); });
     await act(async () => { resolve({ challenge_id: "late_handle" }); });
-    expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy();
+    expect(screen.getByPlaceholderText("Min. 6 characters")).toBeTruthy();
     expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
     expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: "late_handle" });
     expect(native.mock.calls.filter(([command]) => command === "plugin:opener|open_url")).toHaveLength(0);
@@ -124,7 +124,7 @@ describe("native login modal integration", () => {
     } else {
       fireEvent.change(screen.getByPlaceholderText("you@example.com or username"), { target: { value: "native_user" } });
     }
-    fireEvent.change(screen.getByPlaceholderText("Min. 8 characters"), { target: { value: "password123" } });
+    fireEvent.change(screen.getByPlaceholderText("Min. 6 characters"), { target: { value: "password123" } });
     const submit = screen.getAllByRole("button", { name: signup ? "Sign up" : "Log in" }).find((button) => button.getAttribute("type") === "submit")!;
     await act(async () => { fireEvent.click(submit); });
     expect(success).toHaveBeenCalledExactlyOnceWith(USER);
@@ -180,3 +180,47 @@ describe("native login modal integration", () => {
     expect(screen.queryByRole("heading", { name: "Logged in as google_user" })).toBeNull();
   });
 });
+
+describe("signup form validation", () => {
+  it.each([
+    ["Usernames must be at least 3 characters.", "ab", "password123", "password123"],
+    ["Usernames can be at most 16 characters.", "a".repeat(17), "password123", "password123"],
+    ["Usernames can only contain letters, numbers, underscores (_), and hyphens (-).", "bad name", "password123", "password123"],
+    ["Passwords must be at least 6 characters.", "native_user", "12345", "12345"],
+    ["Passwords do not match.", "native_user", "password123", "password124"],
+  ])("shows %j without contacting the server", async (message, username, password, confirmation) => {
+    await act(async () => { render(<LoginModal />); });
+    await submitSignup(username, "user@example.com", password, confirmation);
+    expect(screen.getByText(message)).toBeTruthy();
+    expect(native).not.toHaveBeenCalledWith("storyteller_password_signup_command", expect.anything());
+  });
+
+  it("trims the username and email before signing up", async () => {
+    await act(async () => { render(<LoginModal />); });
+    await submitSignup("  native_user ", " user@example.com ", "password123", "password123");
+    expect(native).toHaveBeenCalledWith("storyteller_password_signup_command", {
+      request: { username: "native_user", email_address: "user@example.com", password: "password123", password_confirmation: "password123", signup_source: "artcraft" },
+    });
+  });
+
+  it("clears the previous error when the form is submitted again", async () => {
+    native.mockImplementation(async (command: string) => {
+      if (command === "storyteller_password_signup_command") return new Promise(() => {});
+      return null;
+    });
+    await act(async () => { render(<LoginModal />); });
+    await submitSignup("native_user", "user@example.com", "password123", "password124");
+    expect(screen.getByText("Passwords do not match.")).toBeTruthy();
+    await submitSignup("native_user", "user@example.com", "password123", "password123");
+    expect(screen.queryByText("Passwords do not match.")).toBeNull();
+  });
+});
+
+async function submitSignup(username: string, email: string, password: string, confirmation: string) {
+  fireEvent.change(screen.getByPlaceholderText("Username"), { target: { value: username } });
+  fireEvent.change(screen.getByPlaceholderText("you@example.com"), { target: { value: email } });
+  fireEvent.change(screen.getByPlaceholderText("Min. 6 characters"), { target: { value: password } });
+  fireEvent.change(screen.getByPlaceholderText("Re-enter password"), { target: { value: confirmation } });
+  const submit = screen.getAllByRole("button", { name: "Sign up" }).find((button) => button.getAttribute("type") === "submit")!;
+  await act(async () => { fireEvent.click(submit); });
+}
