Our sign-up form asked for a name, an email address, a password that satisfied four rules, and a tick confirming you had read two policies. The two OAuth buttons sitting directly above it asked for none of that. Same account, same terms, two completely different amounts of work depending on which button you pressed.

The form is now two fields. SignupForm.tsx went from 195 lines to 113.

The rules that went

The field-level password rule used to be five checks in a fixed order, so that the first complaint you saw was stable:

 export function validatePassword(password: string): string | undefined { if (password.length < PASSWORD_MIN_LENGTH) return VALIDATION_MESSAGES.passwordTooShort; - if (!/[a-z]/.test(password)) return VALIDATION_MESSAGES.passwordNeedsLowercase; - if (!/[A-Z]/.test(password)) return VALIDATION_MESSAGES.passwordNeedsUppercase; - if (!/[0-9]/.test(password)) return VALIDATION_MESSAGES.passwordNeedsNumber; - if (strengthScore(password) < PASSWORD_MIN_STRENGTH) return VALIDATION_MESSAGES.passwordTooWeak; return undefined; } 

Length is the only rule now, and the minimum is unchanged at 8. Four of the seven strings in VALIDATION_MESSAGES went with the checks, and so did PASSWORD_MIN_STRENGTH = 3, a constant whose entire job was to require the label "Good" on a four-step meter.

Composition rules push people towards Password1! and away from a long passphrase, and the one that required a strength score was the worst of the four, because the meter's score is our own arithmetic and the error message asked the user to satisfy it by guessing.

The test file is the clearest record of the change, because the same three inputs moved from one table to the other:

- it.each([ - ['short', 'Ab1!'], - ['no uppercase', 'weakpass123'], - ['no lowercase', 'WEAKPASS123'], - ['no digit', 'WeakPassword'], - ])('rejects a %s password', (_label, pw) => { + it.each([ + ['lowercase only', 'correcthorse'], + ['uppercase only', 'CORRECTHORSE'], + ['no digit', 'CorrectHorse'], + ])('accepts a %s password of eight or more characters', (_label, pw) => { + expect(passwordSchema.safeParse(pw).success).toBe(true); + }); + + it.each([ + ['empty', ''], + ['seven-character', 'Ab1!xyz'], + ])('rejects an %s password', (_label, pw) => { expect(passwordSchema.safeParse(pw).success).toBe(false); }); 

The tick box went too. It was never protecting anything the OAuth paths were not already relying on: the terms line under the buttons applies to all three ways of making an account, and only one of the three made you acknowledge it twice.

The meter is still here, with nobody left to render it

PasswordInput takes a showStrengthMeter prop. Removing showStrengthMeter from the sign-up form left that prop with zero call sites, and left calculatePasswordStrength, the private strengthScore helper and all five STRENGTH_LEVELS entries exported and unreachable from any form in the product.

Which is deliberate. The doc comment says so:

 * Length is the only rule. Character-class and strength rules were dropped on * 2026-11-13 because they turned sign-up into a guessing game and length is * what actually makes a password hard to crack. The strength meter is gone from * the sign-up form for the same reason; `calculatePasswordStrength` stays for * any form that still opts into it. 

Dead code with a note that says it is parked is a different thing from dead code you forgot about, but only if somebody reads the note. There are now four labels in that array that no user of ours can see, and in six months the honest move is probably to delete the lot.

The name field had a reader we did not think about

The action derives a name instead of asking for one:

// The form asks for no name, to keep sign-up to two fields. The account's // name is the address's local part, the same fallback the OAuth callback uses // when a provider sends no name. const data = { ...validation.data, name: validation.data.email.split('@')[0] }; 

Reusing the OAuth callback's existing fallback felt safe. It is not quite, because the name column has a downstream reader three steps away: the greeting on our lifecycle emails.

export function greetingName(name: string | null | undefined): string { const trimmed = (name ?? '').trim(); if (!trimmed || trimmed.includes('@')) return 'there'; const first = trimmed.split(/\s+/)[0]; if (first.length < 2 || first.length > 24) return 'there'; return first; } 

That function was written to defend against a full address landing in the column, and it does. It was not written to defend against a local part, because until this commit a local part never reached it. Run the real thing over the addresses people actually sign up with:

"john.smith" -> "john.smith" "j.smith42" -> "j.smith42" "recruitment.team.uk" -> "recruitment.team.uk" "ada" -> "ada" "d" -> "there" 

So the abandoned-checkout email, the application reminder and the admin notice email will all cheerfully open with "Hi john.smith," and, for a one-letter local part, correctly fall back to "Hi there,". Three call sites, one guard, and the guard's assumptions quietly stopped holding the moment a form lost a field.

The fix is small and it belongs in greetingName, not in the action: anything containing a dot, a digit or an underscore is not a first name, and the right answer for all of those is "there". It is on the list. The reason it is worth writing down is that nothing failed. Types were fine, the suite was fine, the column is still a non-null string, and the only symptom is a slightly strange email that a person reads a day later.

See it

Open cogniprep.app/signup and view source rather than the page. The HTML has exactly two <input> elements, both hidden server-action ids, and the word "Password" appears zero times: the email form sits behind a "Continue with Email" toggle, so neither field exists until you press it. Everything you can actually click on first load is Google, Microsoft, and that toggle.

Then search the page's JavaScript. Of the 20 chunks it loads, exactly one contains Password must be at least 8 characters, none contain must contain an uppercase, and none contain I have read and agree. Those last two were in a chunk a day ago.

While you are there: new-password is now in that same chunk, because the rebuilt form sets autoComplete on both inputs. I wrote a post on 25 October noting that our auth forms carried zero autocomplete attributes and that a password manager had nothing to go on. The repository now has 24 autoComplete attributes, 19 of which are off on game answer inputs, admin tools and search pickers, and the two real ones are on this form. One of the nicer side effects of deleting half a form is that you rewrite the half you keep.