loopable / web /src /shell /LoginPage.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c9d432b verified
Raw
History Blame Contribute Delete
5.85 kB
// ---------------------------------------------------------------------------
// 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 (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M2.2 10s2.8-4.6 7.8-4.6S17.8 10 17.8 10 15 14.6 10 14.6 2.2 10 2.2 10z" />
<circle cx="10" cy="10" r="2.3" />
{off ? <path d="M4 16 16 4" /> : null}
</svg>
);
}
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 (
<div className="login-root">
<div className="login-card">
<Brand size={44} className="login-brand" />
<form className="login-form" onSubmit={submit} noValidate>
{/* 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. */}
<label className="lp-sr-only" htmlFor="login-username">Username</label>
<input
id="login-username"
className="login-input"
name="username"
type="text"
autoComplete="username"
placeholder="Username"
value={username}
onChange={edit(setUsername)}
disabled={busy}
aria-invalid={error ? true : undefined}
/>
<label className="lp-sr-only" htmlFor="login-password">Password</label>
<div className="login-field">
<input
id="login-password"
className="login-input"
name="password"
type={reveal ? "text" : "password"}
autoComplete="current-password"
placeholder="Password"
value={password}
onChange={edit(setPassword)}
disabled={busy}
aria-invalid={error ? true : undefined}
/>
{/* tabIndex −1: Tab goes Username → Password → Log in; the reveal is
a mouse affordance, same as the host's. */}
<button
type="button"
className="login-eye"
aria-label={reveal ? "Hide password" : "Show password"}
onClick={() => setReveal((v) => !v)}
disabled={busy}
tabIndex={-1}
>
<EyeIcon off={reveal} />
</button>
</div>
{/* 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. */}
<button className="login-submit" type="submit" disabled={busy}>
{busy ? "Signing in…" : "Log in"}
</button>
</form>
{/* 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. */}
<p className="login-msg" role="status" aria-live="polite">
{error}
</p>
</div>
</div>
);
}