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:
zooqueen
2026-08-06 18:14:05 -07:00
co-authored by Hanzo Dev
parent acda3167f3
commit 21a9bba9f9
6 changed files with 152 additions and 23 deletions
+37
View File
@@ -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
+7 -11
View File
@@ -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>
+73
View File
@@ -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>
)
}
+8 -12
View File
@@ -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>
+26
View File
@@ -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>
)
}
+1
View File
@@ -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'