File size: 5,851 Bytes
092334a c9d432b 092334a c9d432b 092334a c9d432b 092334a c9d432b 092334a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | // ---------------------------------------------------------------------------
// 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>
);
}
|