Phone input: visible +1 (US) prefix + strict validation (#777) โ
Date: 2026-08-03 Issue: #777 Status: approved (design), in implementation
Problem โ
Every auth screen hand-rolls a free-text type="tel" input (placeholder +1 (555) 123-4567) and calls normalizePhoneNumber on submit. A human handling a raw number can drop the +1 or mistype the country code, and the resulting failure is opaque. (Surfaced during the #144 E2E setup: a Prelude sandbox number entered as +6195... instead of +16195... produced a confusing "Invalid Number".) The app normalizes correctly, but the input invites the mistake and does not clearly reject a malformed number.
Goal โ
Make the +1 explicit and impossible to fat-finger on the phone inputs, and reject anything that is not a clean 10-digit US number so no account is created under a garbage string.
Scope (Tight) โ
Convert two screens: PhonePinSignup (step 1) and PhonePinLogin. This also covers the admin-to-app-user bridge, which runs through PhonePinSignup's same step-1 phone field (isAdminFlow is detected after step 1, on the normalized number), so an admin signing up with their US number works unchanged.
Out of scope (later sweep, same component): AdminSignup, PhoneMigrationFlow, LockScreen, ForgotPassphraseModal, PhoneReclaimFlow, MerchantSignup. A full country-code selector is explicitly deferred (issue calls it low priority).
Design โ
New shared component apps/web/src/components/PhoneNumberInput.jsx (mirrors the existing BirthDateInput shared-input pattern):
- A fixed, non-editable
+1affix on the left; the user types only the local number. - Formats as typed:
619 555 0001. Strips non-digits, hard-caps at 10 digits. onChangereports the local digits (0-10) to the parent; atoE164(localDigits)helper composes+1XXXXXXXXXX.validonly when exactly 10 digits. Below that, the parent keeps its submit button disabled (as today) and can show an inline hint.- Accessible:
<label>association,inputMode="tel",aria-invalidwhen a submit was attempted with < 10 digits. - Structured so a country
<Select>(project wrapper) can replace the fixed+1affix later without changing the parent contract.
normalizePhoneNumber stays the single source of truth. The component composes +1 + 10 local digits; the parent still calls normalizePhoneNumber on submit as the final gate. Because the component guarantees a 10-digit local US number, the normalized result is always a clean +1XXXXXXXXXX; a malformed string can no longer reach account creation.
Parent changes (both screens): replace the free-text <input type="tel"> with <PhoneNumberInput>; keep the existing phone/setPhone state but hold the composed E.164 (or hold local digits and compose on submit, whichever is cleaner per screen). Existing submit-disabled and error logic is preserved.
Testing โ
- Unit (
PhoneNumberInput.test.jsx): formatting as typed, non-digit stripping, 10-digit cap,toE164composition,validtransitions, paste of a full+1.../1.../(619) 555-0001string normalizes to the same 10 local digits. - Story (
PhoneNumberInput.stories.jsx): empty, partial, complete, invalid states (story coverage is enforced). - Manual test plan at
docs/engineering/testing/runs/phone-input-plus1/README.md(operator-run browser scenarios: US number signup, admin-number signup, login, paste behavior, reject junk), linked in the PR. - Live/emulator: unit covers the component; a running-app or E2E pass confirms the signup/login flow still completes end to end.
Risks โ
- A fixed
+1blocks non-US numbers until the country selector lands. Accepted: matches the app's existing US-only normalization assumption and the issue's deferral. - Paste handling must not double-count a pasted
+1/1prefix; covered by a unit test.