mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
password: an eye, so you can see what you typed
Every credential this portal takes was masked with no way to unmask. That is
the most common cause of a sign-in failure that looks like a wrong password,
and it is worst where typing is least reliable -- a phone keyboard, one glyph
at a time, with autocorrect and a shifted layout in the way. On SIGNUP it is
worse still: the account is created with whatever was actually typed, so a
typo behind the dots locks you out of an account you believe you just made.
ONE PasswordField, used by both forms. Login and signup had separate
hand-rolled <label>+<input> pairs; adding a toggle to each would have been two
answers to one question. Both now render the same primitive, so the field is
also the one place autoComplete is stated (current-password vs new-password --
getting that wrong is how a manager offers to "update" a saved credential
during a fresh signup).
Details that are the whole feature:
- type="button". A bare <button> in a <form> defaults to submit, so the
reveal would have submitted the form.
- The button is OUTSIDE the <label>. A <button> inside one is activated
twice -- as itself, and again by the label forwarding to its control -- so
the toggle would fight itself. htmlFor keeps click-the-text-to-focus.
- 44x44, the touch floor this stylesheet already holds everywhere else. An
icon button only as big as its 20px glyph is a miss on a phone, which is
the device this is for.
- The input gives back 58px of right padding, so a long password never runs
under the eye.
- aria-pressed carries the state and the colour follows it, not :hover --
on touch there is no hover, and "is my password on screen" must not
depend on where a pointer is.
- Icons live in ui/icons.tsx with the provider marks, inline and
currentColor-driven. No icon dependency on the credential path.
Measured in a real browser on the built bundle, /login and /signup, at 390 /
768 / 1440 with the viewport actually resized between readings: button 44x44,
input 44 high, button inside the input box, no horizontal overflow, in all six.
Toggle across frames: password -> text -> password, aria-pressed false -> true
-> false. Suite 170/170, typecheck clean.
Default is masked. Nothing about the resting page changes.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -183,6 +183,43 @@ body {
|
||||
.hanzo-id-input::placeholder { color: var(--text-disabled); }
|
||||
.hanzo-id-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Password reveal. The button sits INSIDE the field's box rather than beside it,
|
||||
so adding it costs no layout: the row is still one 44px control on one line,
|
||||
at every width, and nothing below it moves. */
|
||||
.hanzo-id-reveal { position: relative; display: flex; }
|
||||
/* Reserve the button's width so a long password never runs under the eye. 14px
|
||||
resting pad + 44px target = 58px, which is what the input gives back. */
|
||||
.hanzo-id-reveal .hanzo-id-input { padding-right: 58px; }
|
||||
.hanzo-id-revealbtn {
|
||||
font: inherit;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
/* 44x44 — the same touch floor every other control here holds, and the reason
|
||||
the button is sized rather than just padded: an icon button that is only as
|
||||
big as its 20px glyph is a miss on a phone, which is the device this whole
|
||||
feature is for. */
|
||||
width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* No box of its own: a second bordered surface inside the field would read as
|
||||
a nested control. The glyph IS the affordance. */
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.hanzo-id-revealbtn:hover { color: var(--foreground); }
|
||||
/* Pressed = the password is currently visible. Worth a permanent, non-hover
|
||||
signal: the strongest cue that a credential is on screen should not depend on
|
||||
where the pointer is, and on touch there is no hover at all. */
|
||||
.hanzo-id-revealbtn[aria-pressed='true'] { color: var(--foreground); }
|
||||
|
||||
/* iOS Safari ZOOMS the viewport when a focused control's text is under 16px,
|
||||
and it does not zoom back out — the user is left on a magnified, sideways-
|
||||
scrolling sign-in page mid-credential. This rule used to be a COMMENT on
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { LoginResponse } from '../types'
|
||||
import { PasswordField } from './PasswordField'
|
||||
|
||||
export interface LoginFormProps {
|
||||
readonly client: AuthClient
|
||||
@@ -111,17 +112,12 @@ export function LoginForm(props: LoginFormProps) {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<PasswordField
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { EyeIcon, EyeOffIcon } from './icons'
|
||||
|
||||
/**
|
||||
* The ONE password field. Every credential this portal collects is typed into
|
||||
* this component, so "can I see what I typed" is answered in one place rather
|
||||
* than per form.
|
||||
*
|
||||
* A masked field with no way to unmask is the single most common cause of a
|
||||
* failed sign-in that looks like a wrong password, and it is worst exactly where
|
||||
* typing is least reliable: a phone keyboard, one glyph at a time, with
|
||||
* autocorrect and a shifted layout in the way. On signup it is worse still — the
|
||||
* account is CREATED with whatever was actually typed, so a typo behind the dots
|
||||
* locks the user out of an account they believe they just made.
|
||||
*
|
||||
* The reveal is the user's call, not ours. It defaults to masked, so nothing
|
||||
* about the resting page changes; unmasking is a deliberate act with the eye
|
||||
* open as its own state indicator.
|
||||
*/
|
||||
export interface PasswordFieldProps {
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly onChange: (value: string) => void
|
||||
/**
|
||||
* `current-password` on sign-in, `new-password` on registration. It is what
|
||||
* lets a password manager fill the right thing, and getting it wrong is how a
|
||||
* manager offers to "update" a saved credential during a fresh signup.
|
||||
*/
|
||||
readonly autoComplete: 'current-password' | 'new-password'
|
||||
readonly minLength?: number
|
||||
}
|
||||
|
||||
export function PasswordField(props: PasswordFieldProps) {
|
||||
const [shown, setShown] = useState(false)
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="hanzo-id-field">
|
||||
{/* A <label> wrapping the control is the idiom everywhere else here, but a
|
||||
<button> inside a <label> is activated twice — once as the button, once
|
||||
as the label forwarding to its control — so the toggle would fight
|
||||
itself. `htmlFor` keeps the same click-the-text-to-focus behaviour with
|
||||
the button safely outside. */}
|
||||
<label htmlFor={id}>{props.label}</label>
|
||||
<div className="hanzo-id-reveal">
|
||||
<input
|
||||
id={id}
|
||||
className="hanzo-id-input"
|
||||
type={shown ? 'text' : 'password'}
|
||||
autoComplete={props.autoComplete}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
minLength={props.minLength}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
// NOT a submit. A bare <button> in a <form> defaults to type=submit,
|
||||
// so without this, revealing the password would submit the form.
|
||||
type="button"
|
||||
className="hanzo-id-revealbtn"
|
||||
// The control is a toggle, so it says what it DOES and reports its
|
||||
// state separately; a label that flips between "Show"/"Hide" makes
|
||||
// screen readers announce a change of control rather than of state.
|
||||
aria-label="Show password"
|
||||
aria-pressed={shown}
|
||||
aria-controls={id}
|
||||
onClick={() => setShown((s) => !s)}
|
||||
>
|
||||
{shown ? <EyeOffIcon width={20} height={20} /> : <EyeIcon width={20} height={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import { PasswordField } from './PasswordField'
|
||||
|
||||
export interface SignupFormProps {
|
||||
readonly client: AuthClient
|
||||
@@ -81,18 +82,13 @@ export function SignupForm(props: SignupFormProps) {
|
||||
<span>Email</span>
|
||||
<input className="hanzo-id-input" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<PasswordField
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
autoComplete="new-password"
|
||||
minLength={12}
|
||||
/>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
</form>
|
||||
|
||||
@@ -49,3 +49,29 @@ export function WalletIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reveal, the only pair here that is a STATE rather than a brand: eye
|
||||
* = currently masked (tap to reveal), struck-through eye = currently visible
|
||||
* (tap to hide). Stroked rather than filled, because at 20px a filled eye reads
|
||||
* as a blob; `base` supplies the 18px default, and PasswordField overrides to 20
|
||||
* so the glyph holds its own inside a 44px target.
|
||||
*/
|
||||
export function EyeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12Z" />
|
||||
<circle cx="12" cy="12" r="2.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EyeOffIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10.6 6.1A9.9 9.9 0 0 1 12 5.5c6.5 0 10 6.5 10 6.5a17 17 0 0 1-3.1 3.9M6.4 7.9A17 17 0 0 0 2 12s3.5 6.5 10 6.5a9.9 9.9 0 0 0 3.6-.65" />
|
||||
<path d="M10.1 10.1a2.75 2.75 0 0 0 3.8 3.8" />
|
||||
<path d="m3 3 18 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
|
||||
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
|
||||
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
|
||||
export { Divider } from './Divider'
|
||||
export { PasswordField, type PasswordFieldProps } from './PasswordField'
|
||||
|
||||
Reference in New Issue
Block a user