What changed, and why
A returning player — someone who signed up before, so their email already belongs to an account — couldn't sign in. The form took their email, then dead-ended on "sign-in restore is coming soon."
The cause is that sign-in had only one move. Every visitor is already an anonymous Supabase user, so SignInForm attached the email to that user with updateUser. For a new player that's exactly right: the account upgrades in place, same id, runs carried over. For an already-registered email there's nothing to upgrade — Supabase rejects it with email_exists — and the form had no second move (a known P2).
This adds the second move. A small utils/sign-in.ts decides which path applies, and SignInForm calls it. Returning players now get a magic link into the account they already have. The return side is unchanged — the link still lands on /play, where the handler from the previous PR finalizes it.
The decision — utils/sign-in.ts
The form can't tell a new email from a returning one before it tries, so the logic tries the in-place upgrade first and reads the failure. The contract names just the two client methods it needs and the slice of the Supabase error it branches on, so the util stays free of Nuxt/Supabase runtime imports and unit-tests in plain node.
Two methods, and the error fields (.code) the decision reads.
utils/sign-in.ts · 79 lines
⋯ 19 lines hidden (lines 1–19)
⋯ 43 lines hidden (lines 37–79)
"Already registered" has carried more than one error code across Supabase versions, so the check matches a small set rather than a single string — a version bump can't silently strand a returning player back on the failure path.
updateUser first; on a conflict code, sign into the existing account.
utils/sign-in.ts · 79 lines
⋯ 43 lines hidden (lines 1–43)
One form, both players — SignInForm.vue
The component keeps a single email field and a single "Send link" button; nothing in the template changes. send hands the work to requestSignInLink, passing thin adapters over the live Supabase client, and renders the result: the "check your email" confirmation on success, or the returned message on failure.
The orchestration moved to the util; the component just wires + renders.
components/SignInForm.vue · 60 lines
⋯ 41 lines hidden (lines 1–41)
⋯ 1 line hidden (lines 60–60)
Verification
The decision is pure over its two injected methods, so the test drives every branch directly: a new player (upgrade only, OTP untouched), a returning player (each conflict code falls back to the sign-in), the rate-limit message, and a non-conflict error that must NOT trigger a fallback.
New vs returning, plus the no-fallback-on-other-errors guard.