소스 검색

Point at a password reset and at sign-up after a second failed sign-in (#490)

* Point at a password reset and at sign-up after a second failed sign-in

Two refusals in a row for a wrong email or password now show a help box
under the error with both ways out: reset the password, or create an
account with the address typed ("No account with sam@example.com yet?"),
the second left out where sign-up is closed. The address goes on to the
reset or sign-up page through sessionStorage, read once, so it never sits
in a URL or a page view. The refusal itself is now in the visitor's
language instead of Better Auth's English, and the Spanish sign-in strings
get their missing ¿ and accents.

* Keep the help row that shows the typed address out of analytics

PostHog autocapture records the text of a clicked link and session replay what is on screen; the sign-up row says "No account with sam@example.com yet?", so it carries ph-no-capture.

* Ship the typefaces with npm instead of fetching them from Google at build time

next/font/google downloads every face from Google during the build, and a
build whose download failed, failed outright: an e2e shard went red now and
then for nothing but a Google Fonts timeout. The ten faces now come from
Fontsource packages imported in the root layout, so they arrive with npm ci
and the build never leaves the machine. fonts.css declares the same CSS
variables the font sets read, with the fallback faces next/font generated,
so nothing else changes. The browser still fetches only the subsets a page
needs; Geist now carries its Cyrillic too, so Russian is set in it.
Bernt Christian Egeland 1 일 전
부모
커밋
d9c08378e0

+ 8 - 0
messages/de/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Passkey-Anmeldung fehlgeschlagen. Versuchen Sie es erneut oder verwenden Sie E-Mail und Passwort.",
       "passkeyFailed": "Passkey-Anmeldung fehlgeschlagen. Versuchen Sie es erneut oder verwenden Sie E-Mail und Passwort.",
       "invalidOrigin": "Sie haben Torqvoice unter {origin} geöffnet, es ist aber für {configured} konfiguriert. Setzen Sie NEXT_PUBLIC_APP_URL auf genau die Adresse, die Sie im Browser verwenden, und starten Sie Torqvoice neu."
       "invalidOrigin": "Sie haben Torqvoice unter {origin} geöffnet, es ist aber für {configured} konfiguriert. Setzen Sie NEXT_PUBLIC_APP_URL auf genau die Adresse, die Sie im Browser verwenden, und starten Sie Torqvoice neu."
     },
     },
+    "recovery": {
+      "title": "Anmeldung klappt immer noch nicht?",
+      "resetTitle": "Passwort zurücksetzen",
+      "resetHint": "Wir senden Ihnen einen Link, mit dem Sie ein neues wählen.",
+      "signUpTitle": "Konto erstellen",
+      "signUpHint": "Noch kein Konto mit dieser E-Mail-Adresse? Erstellen Sie eines.",
+      "signUpHintEmail": "Noch kein Konto mit {email}? Erstellen Sie eines."
+    },
     "resetPasswordCta": "Hier zurücksetzen"
     "resetPasswordCta": "Hier zurücksetzen"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/en/auth.json

@@ -18,6 +18,14 @@
       "invalidCredentials": "Invalid email or password",
       "invalidCredentials": "Invalid email or password",
       "passkeyFailed": "Passkey sign-in failed. Try again or use email and password.",
       "passkeyFailed": "Passkey sign-in failed. Try again or use email and password.",
       "invalidOrigin": "You opened Torqvoice at {origin}, but it is configured for {configured}. Set NEXT_PUBLIC_APP_URL to the exact address you use in the browser, then restart Torqvoice."
       "invalidOrigin": "You opened Torqvoice at {origin}, but it is configured for {configured}. Set NEXT_PUBLIC_APP_URL to the exact address you use in the browser, then restart Torqvoice."
+    },
+    "recovery": {
+      "title": "Still can't sign in?",
+      "resetTitle": "Reset your password",
+      "resetHint": "We email you a link to choose a new one.",
+      "signUpTitle": "Create an account",
+      "signUpHint": "No account with this email yet? Create one.",
+      "signUpHintEmail": "No account with {email} yet? Create one."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 13 - 5
messages/es/auth.json

@@ -3,8 +3,8 @@
     "title": "Bienvenido de nuevo",
     "title": "Bienvenido de nuevo",
     "description": "Inicie sesión en su taller",
     "description": "Inicie sesión en su taller",
     "passwordPlaceholder": "Ingrese su contraseña",
     "passwordPlaceholder": "Ingrese su contraseña",
-    "forgotPassword": "Olvido su contraseña?",
-    "noAccount": "No tiene una cuenta?",
+    "forgotPassword": "¿Olvidó su contraseña?",
+    "noAccount": "¿No tiene una cuenta?",
     "createOne": "Crear una",
     "createOne": "Crear una",
     "newHere": "¿Nuevo en Torqvoice?",
     "newHere": "¿Nuevo en Torqvoice?",
     "createFreeAccount": "Cree una cuenta gratuita",
     "createFreeAccount": "Cree una cuenta gratuita",
@@ -13,12 +13,20 @@
     "passkey": "Iniciar sesión con Passkey",
     "passkey": "Iniciar sesión con Passkey",
     "passkeyDescription": "Usa tu huella digital, rostro o llave de seguridad",
     "passkeyDescription": "Usa tu huella digital, rostro o llave de seguridad",
     "errors": {
     "errors": {
-      "tooManyAttempts": "Demasiados intentos, vuelva a intentarlo mas tarde.",
-      "invalidCredentials": "Correo electrónico o contraseña no validos",
+      "tooManyAttempts": "Demasiados intentos, vuelva a intentarlo más tarde.",
+      "invalidCredentials": "Correo electrónico o contraseña no válidos",
       "passkeyFailed": "Error al iniciar sesión con passkey. Inténtelo de nuevo o use correo y contraseña.",
       "passkeyFailed": "Error al iniciar sesión con passkey. Inténtelo de nuevo o use correo y contraseña.",
       "invalidOrigin": "Has abierto Torqvoice en {origin}, pero está configurado para {configured}. Pon en NEXT_PUBLIC_APP_URL exactamente la dirección que usas en el navegador y reinicia Torqvoice."
       "invalidOrigin": "Has abierto Torqvoice en {origin}, pero está configurado para {configured}. Pon en NEXT_PUBLIC_APP_URL exactamente la dirección que usas en el navegador y reinicia Torqvoice."
     },
     },
-    "resetPasswordCta": "Restablécela aquí"
+    "recovery": {
+      "title": "¿Sigue sin poder iniciar sesión?",
+      "resetTitle": "Restablecer su contraseña",
+      "resetHint": "Le enviamos un enlace para elegir una nueva.",
+      "signUpTitle": "Crear una cuenta",
+      "signUpHint": "¿Aún no tiene una cuenta con este correo? Cree una.",
+      "signUpHintEmail": "¿Aún no tiene una cuenta con {email}? Cree una."
+    },
+    "resetPasswordCta": "Restablézcala aquí"
   },
   },
   "signUp": {
   "signUp": {
     "title": "Crear una cuenta",
     "title": "Crear una cuenta",

+ 8 - 0
messages/fr/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Échec de la connexion par passkey. Réessayez ou utilisez l'email et le mot de passe.",
       "passkeyFailed": "Échec de la connexion par passkey. Réessayez ou utilisez l'email et le mot de passe.",
       "invalidOrigin": "Vous avez ouvert Torqvoice à l'adresse {origin}, mais il est configuré pour {configured}. Définissez NEXT_PUBLIC_APP_URL sur l'adresse exacte que vous utilisez dans le navigateur, puis redémarrez Torqvoice."
       "invalidOrigin": "Vous avez ouvert Torqvoice à l'adresse {origin}, mais il est configuré pour {configured}. Définissez NEXT_PUBLIC_APP_URL sur l'adresse exacte que vous utilisez dans le navigateur, puis redémarrez Torqvoice."
     },
     },
+    "recovery": {
+      "title": "Toujours impossible de vous connecter ?",
+      "resetTitle": "Réinitialiser votre mot de passe",
+      "resetHint": "Nous vous envoyons un lien pour en choisir un nouveau.",
+      "signUpTitle": "Créer un compte",
+      "signUpHint": "Pas encore de compte avec cette adresse ? Créez-en un.",
+      "signUpHintEmail": "Pas encore de compte avec {email} ? Créez-en un."
+    },
     "resetPasswordCta": "Réinitialisez-le ici"
     "resetPasswordCta": "Réinitialisez-le ici"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/it/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Accesso con passkey fallito. Riprova o usa email e password.",
       "passkeyFailed": "Accesso con passkey fallito. Riprova o usa email e password.",
       "invalidOrigin": "Hai aperto Torqvoice su {origin}, ma è configurato per {configured}. Imposta NEXT_PUBLIC_APP_URL sull'indirizzo esatto che usi nel browser e riavvia Torqvoice."
       "invalidOrigin": "Hai aperto Torqvoice su {origin}, ma è configurato per {configured}. Imposta NEXT_PUBLIC_APP_URL sull'indirizzo esatto che usi nel browser e riavvia Torqvoice."
     },
     },
+    "recovery": {
+      "title": "Ancora non riesce ad accedere?",
+      "resetTitle": "Reimposti la password",
+      "resetHint": "Le inviamo un link via email per sceglierne una nuova.",
+      "signUpTitle": "Crei un account",
+      "signUpHint": "Nessun account con questa email? Ne crei uno.",
+      "signUpHintEmail": "Nessun account con {email}? Ne crei uno."
+    },
     "resetPasswordCta": "Reimpostala qui"
     "resetPasswordCta": "Reimpostala qui"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/lt/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Prisijungimas su prieigos raktu nepavyko. Bandykite dar kartą arba naudokite el. paštą ir slaptažodį.",
       "passkeyFailed": "Prisijungimas su prieigos raktu nepavyko. Bandykite dar kartą arba naudokite el. paštą ir slaptažodį.",
       "invalidOrigin": "Atidarėte Torqvoice adresu {origin}, bet jis sukonfigūruotas adresui {configured}. Nustatykite NEXT_PUBLIC_APP_URL tiksliai tokį adresą, kokį naudojate naršyklėje, ir paleiskite Torqvoice iš naujo."
       "invalidOrigin": "Atidarėte Torqvoice adresu {origin}, bet jis sukonfigūruotas adresui {configured}. Nustatykite NEXT_PUBLIC_APP_URL tiksliai tokį adresą, kokį naudojate naršyklėje, ir paleiskite Torqvoice iš naujo."
     },
     },
+    "recovery": {
+      "title": "Vis tiek nepavyksta prisijungti?",
+      "resetTitle": "Atkurkite slaptažodį",
+      "resetHint": "Atsiųsime nuorodą el. paštu, kad galėtumėte pasirinkti naują.",
+      "signUpTitle": "Sukurkite paskyrą",
+      "signUpHint": "Dar neturite paskyros su šiuo el. paštu? Sukurkite ją.",
+      "signUpHintEmail": "Dar neturite paskyros su {email}? Sukurkite ją."
+    },
     "resetPasswordCta": "Iš naujo nustatykite čia"
     "resetPasswordCta": "Iš naujo nustatykite čia"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/nb/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Passkey-innlogging mislyktes. Prøv igjen eller bruk e-post og passord.",
       "passkeyFailed": "Passkey-innlogging mislyktes. Prøv igjen eller bruk e-post og passord.",
       "invalidOrigin": "Du åpnet Torqvoice på {origin}, men den er satt opp for {configured}. Sett NEXT_PUBLIC_APP_URL til nøyaktig den adressen du bruker i nettleseren, og start Torqvoice på nytt."
       "invalidOrigin": "Du åpnet Torqvoice på {origin}, men den er satt opp for {configured}. Sett NEXT_PUBLIC_APP_URL til nøyaktig den adressen du bruker i nettleseren, og start Torqvoice på nytt."
     },
     },
+    "recovery": {
+      "title": "Får du fortsatt ikke logget inn?",
+      "resetTitle": "Tilbakestill passordet",
+      "resetHint": "Vi sender deg en lenke på e-post så du kan velge et nytt.",
+      "signUpTitle": "Opprett en konto",
+      "signUpHint": "Ingen konto med denne e-posten ennå? Opprett en.",
+      "signUpHintEmail": "Ingen konto med {email} ennå? Opprett en."
+    },
     "resetPasswordCta": "Tilbakestill det her"
     "resetPasswordCta": "Tilbakestill det her"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/nl/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Passkey-aanmelding mislukt. Probeer het opnieuw of gebruik e-mail en wachtwoord.",
       "passkeyFailed": "Passkey-aanmelding mislukt. Probeer het opnieuw of gebruik e-mail en wachtwoord.",
       "invalidOrigin": "U opende Torqvoice op {origin}, maar het is ingesteld voor {configured}. Zet NEXT_PUBLIC_APP_URL op precies het adres dat u in de browser gebruikt en herstart Torqvoice."
       "invalidOrigin": "U opende Torqvoice op {origin}, maar het is ingesteld voor {configured}. Zet NEXT_PUBLIC_APP_URL op precies het adres dat u in de browser gebruikt en herstart Torqvoice."
     },
     },
+    "recovery": {
+      "title": "Lukt inloggen nog steeds niet?",
+      "resetTitle": "Wachtwoord opnieuw instellen",
+      "resetHint": "We mailen u een link om een nieuw wachtwoord te kiezen.",
+      "signUpTitle": "Account aanmaken",
+      "signUpHint": "Nog geen account met dit e-mailadres? Maak er een aan.",
+      "signUpHintEmail": "Nog geen account met {email}? Maak er een aan."
+    },
     "resetPasswordCta": "Stel het hier opnieuw in"
     "resetPasswordCta": "Stel het hier opnieuw in"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/pl/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Logowanie za pomocą passkey nie powiodło się. Spróbuj ponownie lub użyj adresu e-mail i hasła.",
       "passkeyFailed": "Logowanie za pomocą passkey nie powiodło się. Spróbuj ponownie lub użyj adresu e-mail i hasła.",
       "invalidOrigin": "Otwarto Torqvoice pod adresem {origin}, ale jest skonfigurowany dla {configured}. Ustaw NEXT_PUBLIC_APP_URL na dokładnie ten adres, którego używasz w przeglądarce, i uruchom Torqvoice ponownie."
       "invalidOrigin": "Otwarto Torqvoice pod adresem {origin}, ale jest skonfigurowany dla {configured}. Ustaw NEXT_PUBLIC_APP_URL na dokładnie ten adres, którego używasz w przeglądarce, i uruchom Torqvoice ponownie."
     },
     },
+    "recovery": {
+      "title": "Nadal nie możesz się zalogować?",
+      "resetTitle": "Zresetuj hasło",
+      "resetHint": "Wyślemy Ci e-mailem link do ustawienia nowego.",
+      "signUpTitle": "Utwórz konto",
+      "signUpHint": "Nie masz jeszcze konta z tym adresem? Utwórz je.",
+      "signUpHintEmail": "Nie masz jeszcze konta z adresem {email}? Utwórz je."
+    },
     "resetPasswordCta": "Zresetuj je tutaj"
     "resetPasswordCta": "Zresetuj je tutaj"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/pt-BR/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Falha ao entrar com passkey. Tente novamente ou use e-mail e senha.",
       "passkeyFailed": "Falha ao entrar com passkey. Tente novamente ou use e-mail e senha.",
       "invalidOrigin": "Você abriu o Torqvoice em {origin}, mas ele está configurado para {configured}. Defina NEXT_PUBLIC_APP_URL com exatamente o endereço que você usa no navegador e reinicie o Torqvoice."
       "invalidOrigin": "Você abriu o Torqvoice em {origin}, mas ele está configurado para {configured}. Defina NEXT_PUBLIC_APP_URL com exatamente o endereço que você usa no navegador e reinicie o Torqvoice."
     },
     },
+    "recovery": {
+      "title": "Ainda não consegue entrar?",
+      "resetTitle": "Redefinir sua senha",
+      "resetHint": "Enviamos por e-mail um link para você escolher uma nova.",
+      "signUpTitle": "Criar uma conta",
+      "signUpHint": "Ainda não tem conta com este e-mail? Crie uma.",
+      "signUpHintEmail": "Ainda não tem conta com {email}? Crie uma."
+    },
     "resetPasswordCta": "Redefina aqui"
     "resetPasswordCta": "Redefina aqui"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/pt-PT/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Não foi possível iniciar sessão com a passkey. Tente novamente ou utilize o e-mail e a palavra-passe.",
       "passkeyFailed": "Não foi possível iniciar sessão com a passkey. Tente novamente ou utilize o e-mail e a palavra-passe.",
       "invalidOrigin": "Abriu o Torqvoice em {origin}, mas está configurado para {configured}. Defina NEXT_PUBLIC_APP_URL exatamente com o endereço que utiliza no navegador e reinicie o Torqvoice."
       "invalidOrigin": "Abriu o Torqvoice em {origin}, mas está configurado para {configured}. Defina NEXT_PUBLIC_APP_URL exatamente com o endereço que utiliza no navegador e reinicie o Torqvoice."
     },
     },
+    "recovery": {
+      "title": "Continua sem conseguir entrar?",
+      "resetTitle": "Redefinir a palavra-passe",
+      "resetHint": "Enviamos-lhe por e-mail uma ligação para escolher uma nova.",
+      "signUpTitle": "Criar uma conta",
+      "signUpHint": "Ainda não tem conta com este e-mail? Crie uma.",
+      "signUpHintEmail": "Ainda não tem conta com {email}? Crie uma."
+    },
     "resetPasswordCta": "Redefina-a aqui"
     "resetPasswordCta": "Redefina-a aqui"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/ru/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Вход по ключу доступа не удался. Попробуйте снова или используйте эл. почту и пароль.",
       "passkeyFailed": "Вход по ключу доступа не удался. Попробуйте снова или используйте эл. почту и пароль.",
       "invalidOrigin": "Вы открыли Torqvoice по адресу {origin}, но он настроен на {configured}. Укажите в NEXT_PUBLIC_APP_URL точно тот адрес, который используете в браузере, и перезапустите Torqvoice."
       "invalidOrigin": "Вы открыли Torqvoice по адресу {origin}, но он настроен на {configured}. Укажите в NEXT_PUBLIC_APP_URL точно тот адрес, который используете в браузере, и перезапустите Torqvoice."
     },
     },
+    "recovery": {
+      "title": "Всё ещё не получается войти?",
+      "resetTitle": "Сбросить пароль",
+      "resetHint": "Мы пришлём ссылку на почту, чтобы вы задали новый.",
+      "signUpTitle": "Создать аккаунт",
+      "signUpHint": "Ещё нет аккаунта с этим адресом? Создайте его.",
+      "signUpHintEmail": "Ещё нет аккаунта с адресом {email}? Создайте его."
+    },
     "resetPasswordCta": "Сбросьте его здесь"
     "resetPasswordCta": "Сбросьте его здесь"
   },
   },
   "signUp": {
   "signUp": {

+ 8 - 0
messages/tr/auth.json

@@ -18,6 +18,14 @@
       "passkeyFailed": "Passkey ile giriş başarısız. Tekrar deneyin veya e-posta ve şifre kullanın.",
       "passkeyFailed": "Passkey ile giriş başarısız. Tekrar deneyin veya e-posta ve şifre kullanın.",
       "invalidOrigin": "Torqvoice'u {origin} adresinde açtınız ama {configured} için yapılandırılmış. NEXT_PUBLIC_APP_URL değerini tarayıcıda kullandığınız adresle birebir aynı yapın ve Torqvoice'u yeniden başlatın."
       "invalidOrigin": "Torqvoice'u {origin} adresinde açtınız ama {configured} için yapılandırılmış. NEXT_PUBLIC_APP_URL değerini tarayıcıda kullandığınız adresle birebir aynı yapın ve Torqvoice'u yeniden başlatın."
     },
     },
+    "recovery": {
+      "title": "Hâlâ giriş yapamıyor musunuz?",
+      "resetTitle": "Şifrenizi sıfırlayın",
+      "resetHint": "Yeni bir şifre seçmeniz için e-postayla bir bağlantı göndeririz.",
+      "signUpTitle": "Hesap oluşturun",
+      "signUpHint": "Bu e-postayla henüz hesabınız yok mu? Bir tane oluşturun.",
+      "signUpHintEmail": "{email} ile henüz hesabınız yok mu? Bir tane oluşturun."
+    },
     "resetPasswordCta": "Buradan sıfırlayın"
     "resetPasswordCta": "Buradan sıfırlayın"
   },
   },
   "signUp": {
   "signUp": {

+ 100 - 0
package-lock.json

@@ -15,6 +15,16 @@
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/sortable": "^10.0.0",
         "@dnd-kit/sortable": "^10.0.0",
         "@dnd-kit/utilities": "^3.2.2",
         "@dnd-kit/utilities": "^3.2.2",
+        "@fontsource-variable/atkinson-hyperlegible-mono": "^5.3.0",
+        "@fontsource-variable/atkinson-hyperlegible-next": "^5.3.0",
+        "@fontsource-variable/geist": "^5.3.0",
+        "@fontsource-variable/geist-mono": "^5.3.0",
+        "@fontsource-variable/ibm-plex-sans": "^5.3.0",
+        "@fontsource-variable/inter": "^5.3.0",
+        "@fontsource-variable/inter-tight": "^5.3.0",
+        "@fontsource-variable/jetbrains-mono": "^5.3.0",
+        "@fontsource/barlow-condensed": "^5.3.0",
+        "@fontsource/ibm-plex-mono": "^5.3.0",
         "@prisma/adapter-pg": "^7.8.0",
         "@prisma/adapter-pg": "^7.8.0",
         "@prisma/client": "^7.8.0",
         "@prisma/client": "^7.8.0",
         "@react-pdf/renderer": "^4.3.2",
         "@react-pdf/renderer": "^4.3.2",
@@ -2756,6 +2766,96 @@
       "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
       "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/@fontsource-variable/atkinson-hyperlegible-mono": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-mono/-/atkinson-hyperlegible-mono-5.3.0.tgz",
+      "integrity": "sha512-0IbthhO18EEpLe69h+cmrsxCkl4yk80gMk+fAN3+9M5zQvoR1JlkzoJRR6U9/UXLYpVBGEXu+Md2LO8TIp30dw==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz",
+      "integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource-variable/geist": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
+      "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource-variable/geist-mono": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz",
+      "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource-variable/ibm-plex-sans": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
+      "integrity": "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource-variable/inter": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
+      "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource-variable/inter-tight": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/inter-tight/-/inter-tight-5.3.0.tgz",
+      "integrity": "sha512-ZhZZ29sNZ15P1K+cUt13GeBRYON0akgmNiftshUeDLos7X7zGcRgPzt434879Xhq1QbwlW3M/apBMk7jWabwvA==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource-variable/jetbrains-mono": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
+      "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource/barlow-condensed": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource/barlow-condensed/-/barlow-condensed-5.3.0.tgz",
+      "integrity": "sha512-RA7MmIx0v3eQ6Gcf74qLVLOwLB0GpPOzx+eU9iG/AmCnSJGcIWYeU8y6CEMGZ210MGwGg5CZhzLkkvyZdTTk1g==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource/ibm-plex-mono": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
+      "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
     "node_modules/@formatjs/fast-memoize": {
     "node_modules/@formatjs/fast-memoize": {
       "version": "3.1.4",
       "version": "3.1.4",
       "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz",
       "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz",

+ 10 - 0
package.json

@@ -27,6 +27,16 @@
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@dnd-kit/utilities": "^3.2.2",
+    "@fontsource-variable/atkinson-hyperlegible-mono": "^5.3.0",
+    "@fontsource-variable/atkinson-hyperlegible-next": "^5.3.0",
+    "@fontsource-variable/geist": "^5.3.0",
+    "@fontsource-variable/geist-mono": "^5.3.0",
+    "@fontsource-variable/ibm-plex-sans": "^5.3.0",
+    "@fontsource-variable/inter": "^5.3.0",
+    "@fontsource-variable/inter-tight": "^5.3.0",
+    "@fontsource-variable/jetbrains-mono": "^5.3.0",
+    "@fontsource/barlow-condensed": "^5.3.0",
+    "@fontsource/ibm-plex-mono": "^5.3.0",
     "@prisma/adapter-pg": "^7.8.0",
     "@prisma/adapter-pg": "^7.8.0",
     "@prisma/client": "^7.8.0",
     "@prisma/client": "^7.8.0",
     "@react-pdf/renderer": "^4.3.2",
     "@react-pdf/renderer": "^4.3.2",

+ 150 - 0
src/__tests__/features/auth/sign-in-recovery.test.tsx

@@ -0,0 +1,150 @@
+/**
+ * A sign-in that keeps failing.
+ *
+ * Someone refused twice for a wrong email or password has either forgotten
+ * the password or never had an account; many arrive from the marketing
+ * site's Login button. The form says so plainly after the second refusal,
+ * with both ways out, and carries the address they typed to the next page
+ * without putting it in the URL.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+vi.mock('next/navigation', () => ({
+  useSearchParams: () => ({ get: () => null }),
+  useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
+  usePathname: () => '/auth/sign-in',
+}))
+
+vi.mock('next/link', () => ({
+  default: ({
+    href,
+    children,
+    onClick,
+    className,
+  }: {
+    href: string
+    children: React.ReactNode
+    onClick?: () => void
+    className?: string
+  }) => (
+    <a
+      href={href}
+      className={className}
+      onClick={(e) => {
+        e.preventDefault()
+        onClick?.()
+      }}
+    >
+      {children}
+    </a>
+  ),
+}))
+
+vi.mock('@/components/auth/locale-switcher', () => ({ LocaleSwitcher: () => null }))
+
+const signInEmail = vi.fn()
+vi.mock('@/lib/auth-client', () => ({
+  signIn: { email: (...args: unknown[]) => signInEmail(...args) },
+  authClient: { signIn: { passkey: vi.fn() } },
+}))
+
+const { SignInForm } = await import('@/app/(public)/auth/sign-in/sign-in-form')
+const { takeHandedEmail } = await import('@/lib/auth-email-handoff')
+
+/** What Better Auth answers for a wrong email or password. */
+const REFUSED = {
+  error: { status: 401, code: 'INVALID_EMAIL_OR_PASSWORD', message: 'Invalid email or password' },
+}
+
+async function attempt(user: ReturnType<typeof userEvent.setup>, email: string) {
+  const emailInput = screen.getByLabelText('Email')
+  await user.clear(emailInput)
+  await user.type(emailInput, email)
+  await user.type(screen.getByLabelText('Password'), 'wrong-password')
+  await user.click(screen.getByRole('button', { name: 'Sign In' }))
+  await screen.findByText('Invalid email or password')
+}
+
+beforeEach(() => {
+  signInEmail.mockReset()
+  sessionStorage.clear()
+})
+
+describe('SignInForm after failed attempts', () => {
+  it('keeps the small reset hint after one refusal', async () => {
+    signInEmail.mockResolvedValue(REFUSED)
+    render(<SignInForm registrationDisabled={false} />)
+    const user = userEvent.setup()
+
+    await attempt(user, 'sam@example.com')
+
+    expect(screen.getByRole('link', { name: 'Reset it here' })).toBeInTheDocument()
+    expect(screen.queryByText("Still can't sign in?")).not.toBeInTheDocument()
+  })
+
+  it('offers a reset and an account after the second refusal', async () => {
+    signInEmail.mockResolvedValue(REFUSED)
+    render(<SignInForm registrationDisabled={false} />)
+    const user = userEvent.setup()
+
+    await attempt(user, 'sam@example.com')
+    await attempt(user, 'sam@example.com')
+
+    expect(screen.getByText("Still can't sign in?")).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: /Reset your password/ })).toHaveAttribute(
+      'href',
+      '/auth/forgot-password'
+    )
+    const signUp = screen.getByRole('link', { name: /Create an account/ })
+    expect(signUp).toHaveAttribute('href', '/auth/sign-up')
+    expect(signUp).toHaveTextContent('No account with sam@example.com yet? Create one.')
+    // The address is in the row's text, which analytics would otherwise record.
+    expect(signUp).toHaveClass('ph-no-capture')
+    expect(screen.queryByRole('link', { name: 'Reset it here' })).not.toBeInTheDocument()
+  })
+
+  it('hands the typed address to the next page, not to the URL', async () => {
+    signInEmail.mockResolvedValue(REFUSED)
+    render(<SignInForm registrationDisabled={false} />)
+    const user = userEvent.setup()
+
+    await attempt(user, 'sam@example.com')
+    await attempt(user, 'sam@example.com')
+    await user.click(screen.getByRole('link', { name: /Reset your password/ }))
+
+    expect(takeHandedEmail()).toBe('sam@example.com')
+    // Read once: a later visit starts empty.
+    expect(takeHandedEmail()).toBe('')
+  })
+
+  it('leaves out the account offer where nobody can sign up', async () => {
+    signInEmail.mockResolvedValue(REFUSED)
+    render(<SignInForm registrationDisabled />)
+    const user = userEvent.setup()
+
+    await attempt(user, 'sam@example.com')
+    await attempt(user, 'sam@example.com')
+
+    expect(screen.getByRole('link', { name: /Reset your password/ })).toBeInTheDocument()
+    expect(screen.queryByRole('link', { name: /Create an account/ })).not.toBeInTheDocument()
+  })
+
+  it('does not count a refusal for another reason', async () => {
+    signInEmail.mockResolvedValue({ error: { status: 429, message: 'Too many requests' } })
+    render(<SignInForm registrationDisabled={false} />)
+    const user = userEvent.setup()
+
+    for (let i = 0; i < 2; i++) {
+      await user.type(screen.getByLabelText('Email'), 'sam@example.com')
+      await user.type(screen.getByLabelText('Password'), 'pw')
+      await user.click(screen.getByRole('button', { name: 'Sign In' }))
+      await screen.findByText('Too many attempts, try again later.')
+      await user.clear(screen.getByLabelText('Email'))
+    }
+
+    expect(screen.queryByText("Still can't sign in?")).not.toBeInTheDocument()
+  })
+})

+ 8 - 1
src/app/(public)/auth/forgot-password/page.tsx

@@ -1,6 +1,6 @@
 'use client'
 'use client'
 
 
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { authClient } from '@/lib/auth-client'
 import { authClient } from '@/lib/auth-client'
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Loader2, ArrowLeft } from 'lucide-react'
 import { Loader2, ArrowLeft } from 'lucide-react'
 import { AuthLogo } from '@/components/auth-logo'
 import { AuthLogo } from '@/components/auth-logo'
+import { takeHandedEmail } from '@/lib/auth-email-handoff'
 
 
 export default function ForgotPasswordPage() {
 export default function ForgotPasswordPage() {
   const t = useTranslations('auth.forgotPassword')
   const t = useTranslations('auth.forgotPassword')
@@ -17,6 +18,12 @@ export default function ForgotPasswordPage() {
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [sent, setSent] = useState(false)
   const [sent, setSent] = useState(false)
 
 
+  // The address typed on sign-in, when that is where they came from.
+  useEffect(() => {
+    const handed = takeHandedEmail()
+    if (handed) setEmail((current) => current || handed)
+  }, [])
+
   const handleSubmit = async (e: React.FormEvent) => {
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
     e.preventDefault()
     setLoading(true)
     setLoading(true)

+ 98 - 7
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -10,15 +10,26 @@ import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
-import { Fingerprint, Loader2, PlayCircle, XCircle } from 'lucide-react'
+import { Fingerprint, KeyRound, Loader2, PlayCircle, UserPlus, XCircle } from 'lucide-react'
 import { AuthLogo } from '@/components/auth-logo'
 import { AuthLogo } from '@/components/auth-logo'
 import { AuthCard, AuthShell } from '@/components/auth/auth-shell'
 import { AuthCard, AuthShell } from '@/components/auth/auth-shell'
 import { GoogleMark } from '@/components/auth/google-mark'
 import { GoogleMark } from '@/components/auth/google-mark'
 import { TERMS_URL } from '@/lib/marketing-urls'
 import { TERMS_URL } from '@/lib/marketing-urls'
+import { handOnEmail } from '@/lib/auth-email-handoff'
+import { cn } from '@/lib/utils'
 
 
 const DEMO_EMAIL = 'demo@torqvoice.com'
 const DEMO_EMAIL = 'demo@torqvoice.com'
 const DEMO_PASSWORD = 'demo'
 const DEMO_PASSWORD = 'demo'
 
 
+/**
+ * Refused this many times for a wrong email or password, and the two ways
+ * out are shown plainly: the password may be forgotten, or there may never
+ * have been an account (many arrive here from the marketing site's Login).
+ * Counted over the visit, not only back to back: a "too many attempts" in
+ * between makes the help more needed, not less.
+ */
+const RECOVERY_AFTER = 2
+
 function SignInFormInner({
 function SignInFormInner({
   registrationDisabled,
   registrationDisabled,
   demoMode = false,
   demoMode = false,
@@ -45,11 +56,14 @@ function SignInFormInner({
     if (!code) return ''
     if (!code) return ''
     return code === 'account_not_linked' ? tSocial('notLinked') : tSocial('failed')
     return code === 'account_not_linked' ? tSocial('notLinked') : tSocial('failed')
   })
   })
+  const [failures, setFailures] = useState(0)
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [passkeyLoading, setPasskeyLoading] = useState(false)
   const [passkeyLoading, setPasskeyLoading] = useState(false)
   const [googleLoading, setGoogleLoading] = useState(false)
   const [googleLoading, setGoogleLoading] = useState(false)
   const passwordRef = useRef<HTMLInputElement>(null)
   const passwordRef = useRef<HTMLInputElement>(null)
 
 
+  const showRecovery = failures >= RECOVERY_AFTER
+
   const redirectParam = searchParams.get('redirect')
   const redirectParam = searchParams.get('redirect')
   const signUpHref = `/auth/sign-up${
   const signUpHref = `/auth/sign-up${
     redirectParam ? `?redirect=${encodeURIComponent(safeRedirectPath(redirectParam))}` : ''
     redirectParam ? `?redirect=${encodeURIComponent(safeRedirectPath(redirectParam))}` : ''
@@ -71,6 +85,10 @@ function SignInFormInner({
           setError(
           setError(
             t('errors.invalidOrigin', { origin: refusal.origin, configured: refusal.configured })
             t('errors.invalidOrigin', { origin: refusal.origin, configured: refusal.configured })
           )
           )
+        } else if (refusal.code === 'INVALID_EMAIL_OR_PASSWORD') {
+          // Better Auth's own sentence is English; this one is the visitor's.
+          setError(t('errors.invalidCredentials'))
+          setFailures((n) => n + 1)
         } else {
         } else {
           setError(result.error.message || t('errors.invalidCredentials'))
           setError(result.error.message || t('errors.invalidCredentials'))
         }
         }
@@ -187,16 +205,52 @@ function SignInFormInner({
           <XCircle className="mt-0.5 h-4 w-4 shrink-0" />
           <XCircle className="mt-0.5 h-4 w-4 shrink-0" />
           <div className="flex-1">
           <div className="flex-1">
             <p>{error}</p>
             <p>{error}</p>
-            <p className="mt-1 text-xs">
-              {t('forgotPassword')}{' '}
-              <Link href="/auth/forgot-password" className="font-medium underline">
-                {t('resetPasswordCta')}
-              </Link>
-            </p>
+            {!showRecovery && (
+              <p className="mt-1 text-xs">
+                {t('forgotPassword')}{' '}
+                <Link
+                  href="/auth/forgot-password"
+                  onClick={() => handOnEmail(email)}
+                  className="font-medium underline"
+                >
+                  {t('resetPasswordCta')}
+                </Link>
+              </p>
+            )}
           </div>
           </div>
         </div>
         </div>
       )}
       )}
 
 
+      {showRecovery && (
+        <div className="mb-4 rounded-lg border bg-muted/40 p-1.5">
+          <p className="px-2 pb-1 pt-1.5 text-sm font-medium">{t('recovery.title')}</p>
+          <RecoveryLink
+            href="/auth/forgot-password"
+            icon={KeyRound}
+            title={t('recovery.resetTitle')}
+            hint={t('recovery.resetHint')}
+            onClick={() => handOnEmail(email)}
+          />
+          {!registrationDisabled && (
+            <RecoveryLink
+              href={signUpHref}
+              icon={UserPlus}
+              title={t('recovery.signUpTitle')}
+              hint={
+                email.trim()
+                  ? t('recovery.signUpHintEmail', { email: email.trim() })
+                  : t('recovery.signUpHint')
+              }
+              onClick={() => handOnEmail(email)}
+              // The row carries the address typed; analytics records the text
+              // of what is clicked and replays what is on screen, so neither
+              // gets this one.
+              noCapture
+            />
+          )}
+        </div>
+      )}
+
       {googleEnabled && !demoMode && (
       {googleEnabled && !demoMode && (
         <div className="mb-5">
         <div className="mb-5">
           <Button
           <Button
@@ -331,6 +385,43 @@ function SignInFormInner({
   )
   )
 }
 }
 
 
+/** One way out of a sign-in that keeps failing, as a row in the help box. */
+function RecoveryLink({
+  href,
+  icon: Icon,
+  title,
+  hint,
+  onClick,
+  noCapture = false,
+}: {
+  href: string
+  icon: React.ComponentType<{ className?: string }>
+  title: string
+  hint: string
+  onClick: () => void
+  /** Kept out of PostHog's click capture and session replay. */
+  noCapture?: boolean
+}) {
+  return (
+    <Link
+      href={href}
+      onClick={onClick}
+      className={cn(
+        'flex items-start gap-3 rounded-md px-2 py-2 transition-colors hover:bg-background',
+        noCapture && 'ph-no-capture'
+      )}
+    >
+      <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
+        <Icon className="h-4 w-4" />
+      </span>
+      <span className="min-w-0">
+        <span className="block text-sm font-medium text-primary">{title}</span>
+        <span className="block break-words text-xs text-muted-foreground">{hint}</span>
+      </span>
+    </Link>
+  )
+}
+
 export function SignInForm({
 export function SignInForm({
   registrationDisabled,
   registrationDisabled,
   demoMode = false,
   demoMode = false,

+ 8 - 1
src/app/(public)/auth/sign-up/sign-up-form.tsx

@@ -1,7 +1,7 @@
 'use client'
 'use client'
 
 
 import { safeRedirectPath } from '@/lib/safe-redirect'
 import { safeRedirectPath } from '@/lib/safe-redirect'
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
@@ -17,6 +17,7 @@ import { Separator } from '@/components/ui/separator'
 import { TERMS_URL } from '@/lib/marketing-urls'
 import { TERMS_URL } from '@/lib/marketing-urls'
 import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
 import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
 import { track } from '@/lib/analytics'
 import { track } from '@/lib/analytics'
+import { takeHandedEmail } from '@/lib/auth-email-handoff'
 
 
 export function SignUpForm({
 export function SignUpForm({
   inviteToken,
   inviteToken,
@@ -54,6 +55,12 @@ export function SignUpForm({
   const termsRef = useRef<HTMLInputElement>(null)
   const termsRef = useRef<HTMLInputElement>(null)
   const router = useRouter()
   const router = useRouter()
 
 
+  // The address a failed sign-in was tried with, when that is where they came from.
+  useEffect(() => {
+    const handed = takeHandedEmail()
+    if (handed) setEmail((current) => current || handed)
+  }, [])
+
   // The pitch sits beside the form for a fresh cloud sign-up. An invited
   // The pitch sits beside the form for a fresh cloud sign-up. An invited
   // colleague is joining a workshop that already chose Torqvoice, so they
   // colleague is joining a workshop that already chose Torqvoice, so they
   // get the plain card and a title that says what is happening.
   // get the plain card and a title that says what is happening.

+ 97 - 0
src/app/fonts.css

@@ -0,0 +1,97 @@
+/* The app's typefaces, and the variables globals.css and the font sets read.
+
+   The files come from npm (Fontsource, the Google Fonts files repackaged),
+   imported in the root layout. They used to come from next/font/google, which
+   downloads every face from Google during the build: a build whose download
+   failed, failed outright, and in CI that was a red e2e shard every few days.
+   Now they arrive with npm ci and the build never leaves the machine. The
+   browser still fetches only the subsets a page needs, and a face only once
+   some text is set in it, so the sets nobody chose cost nothing.
+
+   The fallback faces are the ones next/font generated: a local Arial resized
+   to each face's metrics, so text does not jump when the face arrives.
+   Atkinson had none, its metrics not being in Next's table, so it names a
+   system face instead. */
+
+@font-face {
+  font-family: "Geist Fallback";
+  src: local("Arial");
+  ascent-override: 95.94%;
+  descent-override: 28.16%;
+  line-gap-override: 0%;
+  size-adjust: 104.76%;
+}
+@font-face {
+  font-family: "Geist Mono Fallback";
+  src: local("Arial");
+  ascent-override: 74.67%;
+  descent-override: 21.92%;
+  line-gap-override: 0%;
+  size-adjust: 134.59%;
+}
+@font-face {
+  font-family: "IBM Plex Sans Fallback";
+  src: local("Arial");
+  ascent-override: 101.32%;
+  descent-override: 27.18%;
+  line-gap-override: 0%;
+  size-adjust: 101.17%;
+}
+@font-face {
+  font-family: "IBM Plex Mono Fallback";
+  src: local("Arial");
+  ascent-override: 76.16%;
+  descent-override: 20.43%;
+  line-gap-override: 0%;
+  size-adjust: 134.59%;
+}
+@font-face {
+  font-family: "Barlow Condensed Fallback";
+  src: local("Arial");
+  ascent-override: 130.73%;
+  descent-override: 26.15%;
+  line-gap-override: 0%;
+  size-adjust: 76.49%;
+}
+@font-face {
+  font-family: "Inter Fallback";
+  src: local("Arial");
+  ascent-override: 90.44%;
+  descent-override: 22.52%;
+  line-gap-override: 0%;
+  size-adjust: 107.12%;
+}
+@font-face {
+  font-family: "Inter Tight Fallback";
+  src: local("Arial");
+  ascent-override: 100.51%;
+  descent-override: 25.03%;
+  line-gap-override: 0%;
+  size-adjust: 96.39%;
+}
+@font-face {
+  font-family: "JetBrains Mono Fallback";
+  src: local("Arial");
+  ascent-override: 75.79%;
+  descent-override: 22.29%;
+  line-gap-override: 0%;
+  size-adjust: 134.59%;
+}
+
+/* Geist is declared under *-base: globals.css points --font-geist-sans at it,
+   or at another face when a font set is chosen (see src/lib/font-sets.ts). */
+:root {
+  --font-geist-sans-base: "Geist Variable", "Geist Fallback";
+  --font-geist-mono-base: "Geist Mono Variable", "Geist Mono Fallback";
+  /* The "workshop" set. */
+  --font-plex-sans: "IBM Plex Sans Variable", "IBM Plex Sans Fallback";
+  --font-plex-mono: "IBM Plex Mono", "IBM Plex Mono Fallback";
+  --font-barlow-condensed: "Barlow Condensed", "Barlow Condensed Fallback";
+  /* The "precise" set. */
+  --font-inter: "Inter Variable", "Inter Fallback";
+  --font-inter-tight: "Inter Tight Variable", "Inter Tight Fallback";
+  --font-jetbrains-mono: "JetBrains Mono Variable", "JetBrains Mono Fallback";
+  /* The "legible" set. Latin only: the family has no Cyrillic. */
+  --font-atkinson: "Atkinson Hyperlegible Next Variable", system-ui, sans-serif;
+  --font-atkinson-mono: "Atkinson Hyperlegible Mono Variable", ui-monospace, monospace;
+}

+ 1 - 1
src/app/globals.css

@@ -333,7 +333,7 @@
 
 
    The theme's font utilities are inlined, so `font-sans` compiles straight to
    The theme's font utilities are inlined, so `font-sans` compiles straight to
    var(--font-geist-sans) and there is no --font-sans left to override. The
    var(--font-geist-sans) and there is no --font-sans left to override. The
-   swap is therefore made one level down: next/font declares Geist under
+   swap is therefore made one level down: fonts.css declares Geist under
    --font-geist-sans-base, and --font-geist-sans, the name everything uses, is
    --font-geist-sans-base, and --font-geist-sans, the name everything uses, is
    pointed at it here or at another face by a set. Every `font-sans` and
    pointed at it here or at another face by a set. Every `font-sans` and
    `font-mono` in the app follows, and the picker can still draw the default
    `font-mono` in the app follows, and the picker can still draw the default

+ 18 - 94
src/app/layout.tsx

@@ -1,16 +1,4 @@
 import type { Metadata } from 'next'
 import type { Metadata } from 'next'
-import {
-  Atkinson_Hyperlegible_Mono,
-  Atkinson_Hyperlegible_Next,
-  Barlow_Condensed,
-  Geist,
-  Geist_Mono,
-  IBM_Plex_Mono,
-  IBM_Plex_Sans,
-  Inter,
-  Inter_Tight,
-  JetBrains_Mono,
-} from 'next/font/google'
 import { NextIntlClientProvider } from 'next-intl'
 import { NextIntlClientProvider } from 'next-intl'
 import { getLocale, getMessages } from 'next-intl/server'
 import { getLocale, getMessages } from 'next-intl/server'
 import { Toaster } from '@/components/ui/sonner'
 import { Toaster } from '@/components/ui/sonner'
@@ -31,87 +19,25 @@ import { DEFAULT_APP_NAME, getAppBranding } from '@/lib/branding'
 import { headers } from 'next/headers'
 import { headers } from 'next/headers'
 import 'react-grid-layout/css/styles.css'
 import 'react-grid-layout/css/styles.css'
 import 'react-resizable/css/styles.css'
 import 'react-resizable/css/styles.css'
+// The typefaces ship with npm rather than from Google at build time; the
+// variables the app reads are declared in fonts.css.
+import '@fontsource-variable/geist'
+import '@fontsource-variable/geist-mono'
+import '@fontsource-variable/ibm-plex-sans'
+import '@fontsource/ibm-plex-mono/400.css'
+import '@fontsource/ibm-plex-mono/500.css'
+import '@fontsource/ibm-plex-mono/600.css'
+import '@fontsource/barlow-condensed/500.css'
+import '@fontsource/barlow-condensed/600.css'
+import '@fontsource/barlow-condensed/700.css'
+import '@fontsource-variable/inter'
+import '@fontsource-variable/inter-tight'
+import '@fontsource-variable/jetbrains-mono'
+import '@fontsource-variable/atkinson-hyperlegible-next'
+import '@fontsource-variable/atkinson-hyperlegible-mono'
+import './fonts.css'
 import './globals.css'
 import './globals.css'
 
 
-// Declared under *-base: globals.css points --font-geist-sans at it, or at
-// another face when a font set is chosen (see src/lib/font-sets.ts).
-const geistSans = Geist({
-  variable: '--font-geist-sans-base',
-  subsets: ['latin', 'latin-ext'],
-})
-
-const geistMono = Geist_Mono({
-  variable: '--font-geist-mono-base',
-  subsets: ['latin', 'latin-ext'],
-})
-
-// The "workshop" font set (Settings → Appearance). Not preloaded: a browser
-// fetches a font file only when some text is actually set in it, so whoever
-// stays on the default never downloads these.
-const plexSans = IBM_Plex_Sans({
-  variable: '--font-plex-sans',
-  weight: ['400', '500', '600', '700'],
-  subsets: ['latin', 'latin-ext', 'cyrillic'],
-  preload: false,
-})
-
-const plexMono = IBM_Plex_Mono({
-  variable: '--font-plex-mono',
-  weight: ['400', '500', '600'],
-  subsets: ['latin', 'latin-ext', 'cyrillic'],
-  preload: false,
-})
-
-const barlowCondensed = Barlow_Condensed({
-  variable: '--font-barlow-condensed',
-  weight: ['500', '600', '700'],
-  subsets: ['latin', 'latin-ext'],
-  preload: false,
-})
-
-// The "precise" set. Variable fonts, so one file covers every weight.
-const inter = Inter({
-  variable: '--font-inter',
-  subsets: ['latin', 'latin-ext', 'cyrillic'],
-  preload: false,
-})
-
-const interTight = Inter_Tight({
-  variable: '--font-inter-tight',
-  subsets: ['latin', 'latin-ext', 'cyrillic'],
-  preload: false,
-})
-
-const jetBrainsMono = JetBrains_Mono({
-  variable: '--font-jetbrains-mono',
-  subsets: ['latin', 'latin-ext', 'cyrillic'],
-  preload: false,
-})
-
-// The "legible" set. Latin only: the family has no Cyrillic.
-//
-// adjustFontFallback is off for these two. next/font sizes a stand-in system
-// font to match each face while it loads, from a table of font metrics shipped
-// with Next, and these families are newer than that table: it found nothing,
-// skipped the stand-in anyway, and said so on every compile. The fallback is
-// named here instead, so the text is still set in something sensible for the
-// moment before the font arrives.
-const atkinson = Atkinson_Hyperlegible_Next({
-  variable: '--font-atkinson',
-  subsets: ['latin', 'latin-ext'],
-  preload: false,
-  adjustFontFallback: false,
-  fallback: ['system-ui', 'sans-serif'],
-})
-
-const atkinsonMono = Atkinson_Hyperlegible_Mono({
-  variable: '--font-atkinson-mono',
-  subsets: ['latin', 'latin-ext'],
-  preload: false,
-  adjustFontFallback: false,
-  fallback: ['ui-monospace', 'monospace'],
-})
-
 // What every page says about itself when the install carries no branding of
 // What every page says about itself when the install carries no branding of
 // its own. Link previews (openGraph, twitter) stay TorqVoice even when it does.
 // its own. Link previews (openGraph, twitter) stay TorqVoice even when it does.
 const defaultMetadata: Metadata = {
 const defaultMetadata: Metadata = {
@@ -220,9 +146,7 @@ export default async function RootLayout({
           }}
           }}
         />
         />
       </head>
       </head>
-      <body
-        className={`${geistSans.variable} ${geistMono.variable} ${plexSans.variable} ${plexMono.variable} ${barlowCondensed.variable} ${inter.variable} ${interTight.variable} ${jetBrainsMono.variable} ${atkinson.variable} ${atkinsonMono.variable} font-sans antialiased`}
-      >
+      <body className="font-sans antialiased">
         <PostHogProvider
         <PostHogProvider
           enabled={isCloudMode() || isDemoMode}
           enabled={isCloudMode() || isDemoMode}
           posthogKey={process.env.POSTHOG_KEY}
           posthogKey={process.env.POSTHOG_KEY}

+ 32 - 0
src/lib/auth-email-handoff.ts

@@ -0,0 +1,32 @@
+/**
+ * Carries the address someone typed on sign-in over to the page they go to
+ * next (reset password, create an account), so they do not type it again.
+ *
+ * It goes through sessionStorage rather than the URL: an address in a query
+ * string ends up in browser history and in every analytics page view. It is
+ * read once and removed, and a browser that refuses storage just starts the
+ * next page empty, as it did before.
+ */
+
+const KEY = 'torqvoice.auth.email'
+
+export function handOnEmail(email: string): void {
+  const value = email.trim()
+  if (!value) return
+  try {
+    sessionStorage.setItem(KEY, value)
+  } catch {
+    /* storage refused: the next page starts empty */
+  }
+}
+
+/** The handed-on address, once; an empty string when there is none. */
+export function takeHandedEmail(): string {
+  try {
+    const value = sessionStorage.getItem(KEY) ?? ''
+    sessionStorage.removeItem(KEY)
+    return value
+  } catch {
+    return ''
+  }
+}

+ 3 - 2
src/lib/font-sets.ts

@@ -18,8 +18,9 @@
  *   be, which is what a plate, a VIN or a part number read off a screen across
  *   be, which is what a plate, a VIN or a part number read off a screen across
  *   a workshop needs. Latin only: Russian falls back to the system's face.
  *   a workshop needs. Latin only: Russian falls back to the system's face.
  *
  *
- * A new set is a line here, a block in globals.css, its fonts in the root
- * layout, its id in that layout's pre-paint script, and its name in
+ * A new set is a line here, a block in globals.css, its Fontsource packages
+ * imported in the root layout, its variables (and fallback faces) in
+ * fonts.css, its id in that layout's pre-paint script, and its name in
  * settings.appearance.fonts.
  * settings.appearance.fonts.
  */
  */