Files
id/apps/web/src/app.css
T
zooqueenandHanzo Dev bed41f1865 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]>
2026-08-06 16:08:50 -07:00

611 lines
26 KiB
CSS

/* Hanzo ID — the styling layer for hanzo.id / lux.id / zoo.id / pars.id.
*
* TOKENS COME FROM @hanzo/design. Not one colour, radius or type size is
* invented here; every value below resolves to a design token, so a token change
* lands on all four brand portals AND on pay.hanzo.ai — the other half of the
* same sign-in-then-pay flow — at once.
*
* ARCHITECTURE RULE, learned the hard way. A component's surface must never
* depend on WHERE it is mounted. This file used to paint controls with the
* descendant selectors `form input {…}` and `.hanzo-id-btn, form button {…}`,
* so any control that escaped a <form> ancestor fell out of the stylesheet and
* rendered as raw UA chrome: the device-approval screen (2 inputs, 0 forms)
* showed a 31px beveled browser input next to correctly-styled 44px siblings.
* That is the same class of defect as a distributed component shipping utility
* class names with no CSS behind them. Every rule below is keyed to a CLASS the
* component itself carries — `.hanzo-id-input`, `.hanzo-id-btn`,
* `.hanzo-id-field`, `.hanzo-id-form` — and there are no element-descendant
* selectors for surface anywhere in this file.
*/
/* ONE import, the whole token layer. This file used to cherry-pick four of the
* nine token groups, which meant z, elevation, spacing, fonts and the element
* defaults simply did not exist here — and an absent group is invisible: an
* unresolved var() paints nothing and reports no error. @hanzo/iam's account
* menu alone reaches for --z-popover, --shadow-floating and --space-1..3, none
* of which the four-group subset carried.
*
* The reason for cherry-picking is gone: as of @hanzo/design 0.3.0 Geist is
* SELF-HOSTED inside the package (two variable woff2, SIL OFL-1.1), so
* tokens/fonts.css no longer makes a request to fonts.googleapis.com and the
* sign-in path can take the typeface along with the colours. */
@import '@hanzo/design/styles.css';
:root {
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
/* 100dvh, with 100vh left underneath it as the fallback for anything that does
not know the unit. On a phone `vh` resolves against the LARGEST viewport —
the one with the URL bar retracted — so a bar that is actually on screen
makes a "full height" page taller than the space it has, and a login page
with one card on it acquires a scrollbar and sits low in the window. `dvh`
is the height that is really there. */
body {
background: var(--background);
color: var(--foreground);
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
font-size: var(--text-base);
}
/* NO focus rule here any more — tokens/base.css ships
`:focus-visible{outline:2px solid var(--ring);outline-offset:2px}` to every
consumer, and as of 0.3.0 --ring is var(--neutral-500), which measures 4.43:1
on --background (WCAG 2.4.13 wants 3:1). The local override that painted the
ring --primary existed only because --ring was #333333 at 1.66:1; that was a
finding against @hanzo/design, it has been fixed there, so the workaround
goes. One focus indicator, defined once, in the design system. */
/* ── Page shell ────────────────────────────────────────────────────── */
.hanzo-id-page {
flex: 1;
/* #root is not a flex container, so `flex: 1` alone never stretches this to
the viewport — pin a min height so `main`'s justify-content:center has room
to vertically center the auth card (box-sizing:border-box folds in padding).
dvh for the reason given on `body`; the vh line is the fallback. */
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
max-width: 480px;
margin: 0 auto;
/* index.html declares `viewport-fit=cover`, which puts the page UNDER the
notch, the rounded corners and the home indicator. Declaring cover without
consuming the insets is strictly worse than not declaring it, and nothing
in this stylesheet consumed them: `max()` keeps the 24px gutter everywhere
it is already enough and only grows it where the hardware intrudes, so
nothing moves on a device with no insets (env() is 0px there). */
padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
}
.hanzo-id-brand-header { padding: 16px 0 32px; }
/* The home link wraps a 32px mark but measured 32x18 — an inline <a> takes its
box from the LINE BOX of its own font, not from a replaced child, so the tap
target was SMALLER than the logo inside it. inline-flex gives the anchor the
mark's real box; the padding/negative-margin pair then grows the hit area to
the 44px floor while leaving the mark exactly where it was — the padding
offsets the margin, so the logo's painted position and the header's height
are both unchanged, and only the invisible target bleeds into the gutter. */
.hanzo-id-brand-header a {
display: inline-flex;
align-items: center;
box-sizing: content-box;
min-width: 32px;
min-height: 32px;
padding: 6px;
margin: -6px;
}
/* The lockup is 32px tall for EVERY brand, and it has to be said here.
BrandHeader writes `height={32}`, but that is a presentational attribute and
@hanzo/design's `:where(img,video){height:auto}` overrides it — so the logo
was sized by whatever the brand package happened to ship, not by this page.
The two assets differ in exactly the way that hides it: @hanzo/brand's SVG
carries a viewBox and no width/height, so it has no intrinsic size and landed
near the intended 32; @luxfi/brand's declares 1024x1024, so it took the full
column and rendered a 342px mark over lux.id's sign-in form. Same markup,
same CSS, opposite results, and only the brand nobody was looking at broke.
Pinning the height and letting width follow makes the header's own
declaration true again whatever a brand ships. */
.hanzo-id-brand-header img { height: 32px; width: auto; }
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
.hanzo-id-wordmark {
font-size: var(--text-xl);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
}
.hanzo-id-page main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 24px;
}
/* The type scale is the design scale, five rungs: xs / sm / base / xl / 2xl.
`h1` at --text-2xl is the SAME 24px as pay.hanzo.ai's `text-2xl` headings, so
a heading does not change size when the flow crosses between the two. */
.hanzo-id-page h1 {
margin: 0;
font-size: var(--text-2xl);
line-height: var(--leading-2xl);
letter-spacing: var(--tracking-tight);
}
.hanzo-id-page h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-xl); }
.hanzo-id-page .lede { color: var(--muted-foreground); margin: 0; font-size: var(--text-base); }
/* ── Field ─────────────────────────────────────────────────────────── */
.hanzo-id-form { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: var(--text-sm);
color: var(--muted-foreground);
}
.hanzo-id-input {
/* `font: inherit` first: a bare <input>/<button> otherwise renders in the UA
face (Arial), which put two typefaces inside one 432px card — including on
the primary CTA. */
font: inherit;
font-size: var(--text-base);
background: var(--white-05);
color: var(--foreground);
/* A control's resting edge is --border-control. 0.4.2 cut it back to the alpha
ladder (.15) because a boundary that clears 3:1 on a near-black page IS a
mid-grey box, and a form of them reads as a wireframe. The contrast budget
went to --ring instead — the focus indicator is what a keyboard user
navigates by, it is the only boundary still held at 3:1, and unlike a
resting edge it is worth the loudness because it is transient. */
border: 1px solid var(--border-control);
/* 8px. 0.4.2 moved every control in the system to --radius-md, and tokens/
base.css draws a bare field at it; --radius-sm (6px) keeps its job on
genuinely small parts — badges, chips, menu rows — where 8px looks bubbly. */
border-radius: var(--radius-md);
padding: 0 14px;
min-height: 44px; /* the touch-target floor */
width: 100%;
}
.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
font-size above claiming `--text-base` prevented that; --text-base is
0.875rem = 14px, so the protection was never in effect on either credential
field. The type scale has no 16px rung and should not grow one — 16 is not a
design value here, it is the threshold in Safari's own zoom heuristic — so it
is written as the literal it is, and scoped to touch-primary pointers so the
desktop type ramp is untouched. */
@media (pointer: coarse) {
.hanzo-id-input { font-size: 16px; }
}
/* Checkbox: the UA control, sized up from its 13px intrinsic box and tinted with
the brand. Its touch target is the whole <label> row, which is why it is the
one control here that is not 44px itself. */
.hanzo-id-check {
font: inherit;
accent-color: var(--primary);
width: 18px;
height: 18px;
min-height: 0;
margin-top: 2px;
flex: none;
}
/* ── Button — ONE primitive, two modifiers ─────────────────────────── */
/* `.hanzo-id-btn` is filled (the primary action). `.ghost` is the secondary
surface — social sign-in, org rows, Skip/Back. `.row` spreads content for a
list row. There is no `.primary`: the base IS primary, so there is one and
only one way to write the default button. */
.hanzo-id-btn {
font: inherit;
font-size: var(--text-base);
font-weight: var(--weight-semibold);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
background: var(--primary);
color: var(--primary-foreground);
border: 1px solid transparent;
/* Same 8px as the field it sits under. Two stacked controls in one 432px card
cannot round differently, and the system draws buttons at 8-10px too. */
border-radius: var(--radius-md);
padding: 10px 16px;
min-height: 44px;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out);
}
.hanzo-id-btn:disabled,
.hanzo-id-btn[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
.hanzo-id-btn svg { flex: none; }
/* The filled button had no hover at all. It has declared `transition:
background` since it was written and the system has shipped --primary-hover
the whole time, so the most-clicked control on the portal — Sign in, Continue,
Create account — was animating a property nothing ever changed. The ghost
variant below got its hover and this one was simply missed.
`:not(.ghost)` rather than relying on order: this selector and the ghost
hover both compute to (0,4,0), so with a bare `.hanzo-id-btn:hover` the two
would tie and source order would decide which surface a ghost button lifts
to. Saying which buttons are meant makes them unable to collide. */
.hanzo-id-btn:not(.ghost):hover:not(:disabled):not([aria-disabled='true']) {
background: var(--primary-hover);
}
.hanzo-id-btn.ghost {
background: var(--white-05);
color: var(--foreground);
border-color: var(--border-strong);
font-weight: var(--weight-medium);
}
/* Hover is a SURFACE lift, not a brighter edge. This used to also set
`border-color: var(--foreground)` — #ededed, 17.9:1 on --background — which
painted a near-solid-white 1px wireframe around the two most-hovered controls
on the page (Continue with GitHub / Continue with Google). Two problems with
moving the edge on hover, and the second is the one that matters:
1. It read as a wireframe, not as a refined surface. The house hairline is a
low-alpha step (--white-10/-15), ~4x quieter than #ededed.
2. Brightening an edge is THEME-HOSTILE. Every rung brighter than
--neutral-500 is worse on white: --neutral-400 measures 8.3:1 on black but
only 2.52:1 on white, which would fail the same WCAG 1.4.11 floor the
resting border is documented to hold in BOTH themes. There is no token
that gets brighter on dark and darker on light, so the edge must not
encode state at all.
So the border stays the constant control boundary (--border-strong, 4.43:1,
both themes) and the background carries the state — the lift the `transition`
on .hanzo-id-btn was already animating. */
.hanzo-id-btn.ghost:hover:not(:disabled) { background: var(--white-10); }
.hanzo-id-btn.row { justify-content: space-between; text-align: left; width: 100%; }
.hanzo-id-cta-row { display: flex; gap: 12px; }
.hanzo-id-cta-row .hanzo-id-btn { flex: 1; }
.hanzo-id-linkbtn {
font: inherit;
background: none;
border: 0;
color: var(--text-primary);
font-size: var(--text-sm);
cursor: pointer;
text-align: left;
padding: 4px 0;
text-decoration: underline;
text-underline-offset: 4px;
}
.hanzo-id-footer-links { color: var(--muted-foreground); font-size: var(--text-sm); }
/* "Forgot password?" / "Create account" / "Sign in" / "Back to sign in" are the
only route changes on these pages that are not buttons, and they measured 17px
tall — the line box of their own text. Vertical padding on an INLINE box is
hit-tested but does not enter line-box height, so this buys the 44px target
with zero layout movement and no change to the sentences they sit inside.
(17 + 14 + 14 = 45.) The 14px bleed stays inside `main`'s 24px gap, so no two
targets overlap. Horizontal size already clears 44px on every one of them. */
.hanzo-id-footer-links a {
color: var(--text-primary);
padding: 14px 0;
margin: -14px 0;
}
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
.hanzo-id-sms-consent { color: var(--muted-foreground); font-size: var(--text-xs); line-height: var(--leading-relaxed); }
.hanzo-id-sms-consent p { margin: 0 0 6px; }
.hanzo-id-sms-consent-links { margin: 0; }
.hanzo-id-sms-consent a { color: var(--text-primary); }
/* ── Inline message ────────────────────────────────────────────────── */
/* Red is one of the two hues @hanzo/design permits, and these are the system's
own state tokens — the same #fca5a5 pay.hanzo.ai renders errors in. The
informational variant used to be a blue (#78b8ff) that exists nowhere in the
system; on a monochrome surface "information" is a neutral card. */
.hanzo-id-error,
.hanzo-id-info {
padding: 12px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
line-height: var(--leading-sm);
margin: 0;
}
.hanzo-id-error {
background: var(--state-error-bg);
color: var(--state-error-text);
border: 1px solid var(--state-error);
}
.hanzo-id-info {
background: var(--card);
color: var(--text-secondary);
border: 1px solid var(--white-10);
}
/* ── Loading ───────────────────────────────────────────────────────── */
/* `.hanzo-id-spinner` used to be a class with NO rule behind it: the loading
state measured 0px tall and was invisible on every portal. */
.hanzo-id-spinner {
width: 28px;
height: 28px;
margin: 0 auto;
border: 2px solid var(--white-15);
border-top-color: var(--primary);
border-radius: var(--radius-full);
animation: hanzo-id-spin 700ms linear infinite;
}
@keyframes hanzo-id-spin { to { transform: rotate(360deg); } }
/* ── Device-authorization approval ─────────────────────────────────── */
.hanzo-id-device main { gap: 18px; }
.hanzo-id-device-prompt {
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
margin: 0;
}
.hanzo-id-device-prompt strong { color: var(--text-primary); }
.hanzo-id-device-code {
font-family: var(--font-mono);
font-size: var(--text-xl);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
}
.hanzo-id-device-confirm {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
}
/* ── Forced TOTP enrollment ────────────────────────────────────────── */
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: var(--text-xl); }
.hanzo-id-mfa-qr {
align-self: center;
background: var(--pure-white);
padding: 12px;
border-radius: var(--radius-lg);
width: 220px;
height: 220px;
box-sizing: content-box;
}
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
.hanzo-id-mfa-manual { font-size: var(--text-sm); color: var(--muted-foreground); }
.hanzo-id-mfa-manual summary { cursor: pointer; }
.hanzo-id-mfa-secret,
.hanzo-id-mfa-recovery code {
display: inline-block;
margin-top: 8px;
padding: 6px 10px;
background: var(--white-05);
border: 1px solid var(--white-10);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
letter-spacing: var(--tracking-widest);
word-break: break-all;
}
.hanzo-id-mfa-recovery { font-size: var(--text-sm); color: var(--muted-foreground); line-height: var(--leading-relaxed); }
.hanzo-id-mfa-recovery code { letter-spacing: var(--tracking-normal); }
/* ── Social / Web3 sign-in ─────────────────────────────────────────── */
/* These are `.hanzo-id-btn.ghost` — there is no separate social-button surface. */
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
/* Wallet chain chooser — revealed under the single "Connect Wallet" button when
the injected chain is ambiguous. Indented so the EVM/Solana options read as
children of the wallet entry. */
.hanzo-id-wallet-chains {
display: flex;
flex-direction: column;
gap: 10px;
margin-left: 4px;
padding-left: 12px;
border-left: 1px solid var(--white-10);
}
/* Labeled divider between social row and email form. */
.hanzo-id-divider {
display: flex;
align-items: center;
text-align: center;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
.hanzo-id-divider::before,
.hanzo-id-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--white-10);
}
.hanzo-id-divider span { padding: 0 12px; }
/* ── Signed-in portal: the apps launcher ───────────────────────────── */
/* These rules used to be an inline `style={{…}}` object on Portal.tsx carrying
rgba(255,255,255,0.14), borderRadius 12, fontSize 13, fontWeight 600 and two
bare opacities — six invented values for facts the token layer already
states. They are classes now for the same reason as everything else in this
file: a surface must not depend on where it is mounted. */
.hanzo-id-portal main { width: 100%; max-width: 760px; }
.hanzo-id-apps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: var(--space-3);
margin-top: var(--space-6);
}
.hanzo-id-applink {
display: block;
padding: var(--space-4);
/* A tile whose affordance is carried by its label, not its edge — so this is
the decorative hairline rung, not the 3:1 control boundary. --white-15 is
the ladder rung the old rgba(255,255,255,0.14) was approximating. */
border: 1px solid var(--white-15);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
transition: border-color var(--duration-fast) var(--ease-out);
}
.hanzo-id-applink:hover { border-color: var(--border-strong); text-decoration: none; }
.hanzo-id-applink-name { display: flex; justify-content: space-between; font-weight: var(--weight-semibold); }
.hanzo-id-applink-name span[aria-hidden] { color: var(--text-tertiary); }
.hanzo-id-applink-desc { color: var(--muted-foreground); font-size: var(--text-sm); margin-top: var(--space-1); }
/* The account control is a control, not a bar: cap it so the trigger (and the
menu, which matches the trigger's width) reads at the size it does on every
other Hanzo surface instead of spanning the whole 760px column. */
.hanzo-id-portal-account { margin-top: var(--space-6); max-width: 260px; }
/* ── Onboarding flow ───────────────────────────────────────────────── */
.hanzo-id-onboarding { display: flex; flex-direction: column; gap: 24px; }
.hanzo-id-onboarding-head { display: flex; flex-direction: column; gap: 6px; }
.hanzo-id-onboarding-head h1,
.hanzo-id-onboarding-done h1 { margin: 0; font-size: var(--text-2xl); letter-spacing: var(--tracking-tight); }
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-stepdots { display: flex; gap: 8px; }
.hanzo-id-stepdots span {
flex: 1;
height: 4px;
border-radius: var(--radius-full);
background: var(--white-10);
}
.hanzo-id-stepdots span.on { background: var(--primary); }
.hanzo-id-slug-preview {
color: var(--muted-foreground);
font-size: var(--text-sm);
margin: -8px 0 0;
font-family: var(--font-mono);
}
.hanzo-id-onboarding-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.hanzo-id-onboarding-actions .hanzo-id-btn { flex: 1; min-width: 120px; }
.hanzo-id-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
.hanzo-id-summary dt { color: var(--muted-foreground); font-size: var(--text-sm); }
.hanzo-id-summary dd { margin: 0; font-size: var(--text-sm); font-family: var(--font-mono); }
/* Consent step */
.hanzo-id-consent { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-consent p { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); line-height: var(--leading-normal); }
.hanzo-id-consent-check {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid var(--border-control);
border-radius: var(--radius-lg);
cursor: pointer;
font-size: var(--text-sm);
}
.hanzo-id-consent-check input { margin-top: 2px; accent-color: var(--primary); }
/* Plan step — one card per catalog plan + the pay-as-you-go card */
.hanzo-id-plans { display: flex; flex-direction: column; gap: 10px; }
.hanzo-id-plan {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-height: 44px;
padding: 14px 16px;
border: 1px solid var(--border-control);
border-radius: var(--radius-lg);
background: var(--white-05);
color: var(--foreground);
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.hanzo-id-plan:hover { background: var(--white-10); }
.hanzo-id-plan:disabled { opacity: 0.6; cursor: default; }
.hanzo-id-plan.popular { border-color: var(--border-selected); }
.hanzo-id-plan-badge {
align-self: flex-end;
margin: -4px 0 -18px;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--primary);
color: var(--primary-foreground);
font-size: var(--text-xs);
font-weight: 600;
}
.hanzo-id-plans-empty { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); }
.hanzo-id-plan-name { font-weight: 600; }
.hanzo-id-plan-price { font-size: var(--text-sm); }
.hanzo-id-plan-price em { font-style: normal; color: var(--muted-foreground); }
.hanzo-id-plan-desc { color: var(--muted-foreground); font-size: var(--text-sm); }