diff --git a/cmd/librenotes/web/public/login.html b/cmd/librenotes/web/public/login.html
new file mode 100644
index 0000000..ba4ed43
--- /dev/null
+++ b/cmd/librenotes/web/public/login.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Sign in — librenotes
+
+
+
+
+
+
+ Sign in with a magic link. We'll email you a one-time link valid for 15 minutes.
+
+
+
+
+
Check your email — we sent a sign-in link to .
+
The link expires in 15 minutes and can only be used once.
+
+
+
+
+
+
+
diff --git a/cmd/librenotes/web/public/login.js b/cmd/librenotes/web/public/login.js
new file mode 100644
index 0000000..b0ec6ef
--- /dev/null
+++ b/cmd/librenotes/web/public/login.js
@@ -0,0 +1,82 @@
+// login.js — magic-link request flow.
+//
+// We deliberately do NOT store any state until /auth/verify returns.
+// Until verification we have no JWT and no tenant identity, so there
+// is nothing to scope. The post-verify page (verify.html) handles
+// session storage.
+
+(function () {
+ "use strict";
+
+ const form = document.getElementById("login-form");
+ const emailInput = document.getElementById("email");
+ const emailError = document.getElementById("email-error");
+ const submitBtn = document.getElementById("submit-btn");
+ const success = document.getElementById("success");
+ const successEmail = document.getElementById("success-email");
+
+ // Minimal RFC-5322-ish check. Server is the source of truth; this
+ // only catches the obvious typos so users don't waste a request.
+ const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+ function showError(msg) {
+ emailError.textContent = msg;
+ emailError.hidden = false;
+ emailInput.setAttribute("aria-invalid", "true");
+ }
+
+ function clearError() {
+ emailError.hidden = true;
+ emailError.textContent = "";
+ emailInput.removeAttribute("aria-invalid");
+ }
+
+ function setLoading(on) {
+ submitBtn.disabled = on;
+ submitBtn.textContent = on ? "Sending…" : "Send magic link";
+ }
+
+ async function submit(email) {
+ setLoading(true);
+ try {
+ const resp = await fetch("/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ });
+ if (resp.status === 202) {
+ form.hidden = true;
+ success.hidden = false;
+ successEmail.textContent = email;
+ return;
+ }
+ if (resp.status === 429) {
+ showError("Too many requests for this email. Try again in a few minutes.");
+ return;
+ }
+ if (resp.status === 400) {
+ showError("That email looks invalid. Double-check and try again.");
+ return;
+ }
+ showError("Something went wrong on our side. Please try again.");
+ } catch (e) {
+ showError("Network error — please check your connection.");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ form.addEventListener("submit", function (ev) {
+ ev.preventDefault();
+ clearError();
+ const email = emailInput.value.trim().toLowerCase();
+ if (!emailRe.test(email)) {
+ showError("Please enter a valid email address.");
+ emailInput.focus();
+ return;
+ }
+ submit(email);
+ });
+
+ emailInput.addEventListener("input", clearError);
+})();