// --------------------------------------------------------------------------- // shell/LoginPage.tsx — X5: the branded door. // // THE CARD LANGUAGE IS `gate()`'s, DELIBERATELY (app.py:1228). Same lockup // (mark + "Loopable" wordmark, side by side — wave-9 I5), same 410px card with // ONE hairline closing all four sides (wave-10 I25a removed the gold top-rule: // "there is an orange line at the top of the sign-in place… it's ugly" — do not // reintroduce an accent edge here), same two fields and one primary button. // Two doors into one product must not look like two products, and this is the // door that outlives the other: `gate()` dies with app.py at EXIT-6. // // WHAT IT DOES NOT INHERIT: `gate()`'s `st.empty()` slot dance is a workaround // for Streamlit replacing elements POSITIONALLY, labelled in its own docstring // as something "the successor shell deletes". This is that successor. React // unmounts what it unmounts; there is no ghost card to chase. // // NO CLIENT-SIDE BACKOFF (X5: the server owns it). The only local rule is // single-flight — a second submit while one is in the air is dropped, which is // double-click protection, not rate limiting. // --------------------------------------------------------------------------- import { useState } from "react"; import type { FormEvent } from "react"; import { Brand } from "./Brand"; import { login } from "./session"; import type { SessionUser } from "./session"; /** The reveal toggle the host's password field ships natively (BaseWeb's eye). * Two doors, one affordance. */ function EyeIcon({ off }: { off: boolean }) { return ( ); } export default function LoginPage({ onSignedIn }: { onSignedIn: (user: SessionUser) => void }) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [reveal, setReveal] = useState(false); async function submit(e: FormEvent) { e.preventDefault(); if (busy) return; setBusy(true); setError(""); const result = await login(username, password); if (result.ok) { // No setBusy(false): the parent unmounts this tree on success, and // flipping state on the way out is how "update on an unmounted // component" warnings are born. onSignedIn(result.user); return; } setBusy(false); setError(result.message); setPassword(""); } // An error that survives the correction is an error that reads as sticky. function edit(set: (v: string) => void) { return (e: { target: { value: string } }) => { if (error) setError(""); set(e.target.value); }; } return (
{/* Labels are present and hidden, not absent. `gate()` collapses them because Streamlit gives no other way to get a placeholder-only field; here the label exists for assistive tech and the visual is identical. */} {/* No autoFocus: the other door cannot autofocus (Streamlit), and the focus ring it would paint on load reads as a highlighted error on an untouched form. */}
{/* tabIndex −1: Tab goes Username → Password → Log in; the reveal is a mouse affordance, same as the host's. */}
{/* Disabled through THREE channels (the real attribute, the cursor and the opacity), never opacity alone — a control that only looks dead is a control that still takes the click. */}
{/* The message line is ALWAYS in the layout (min-height: 1lh) so an arriving error does not shove the card. `role="status"` rather than "alert": polite announcement, no interruption. */}

{error}

); }