mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
A component's surface must never depend on WHERE it is mounted. app.css painted
controls with the descendant selectors `form input {…}` and `.hanzo-id-btn, form
button {…}`. DeviceApproval — the screen a human hits to authorise the CLI — has
2 inputs and 0 <form> ancestors, so its device-code field fell out of the
stylesheet and rendered as raw UA chrome: 31px tall, #3b3b3b, a 2px inset bevel,
square corners, sitting beside correctly-styled 44px siblings. That is the mirror
image of a component library shipping utility class names with no CSS behind
them — bare elements instead of bare class names — and it was live on hanzo.id,
lux.id and pars.id at once. Every control now carries its own class
(.hanzo-id-input / .hanzo-id-btn / .hanzo-id-field / .hanzo-id-form) and no
element-descendant selector for surface is left in the file.
.hanzo-id-spinner had NO rule at all: the loading state measured 0px and was
invisible on every portal. It is a real 28px ring now.
Tokens come from @hanzo/design — the same token layer hanzoai/pay renders from,
so the two halves of one flow (sign in -> pay) agree on the page black (#000000,
--background; the page was painted with the --card value), the type ramp (h1 21px
on both), the radii and the greys. Nothing in this file invents a colour, radius
or size. @hanzo/gui was declared as a dependency and imported exactly zero times;
it is removed rather than left as decoration.
ONE button: .hanzo-id-social-btn and the .primary modifier are gone. .hanzo-id-btn
IS primary and .ghost is the secondary surface, so 13 treatments become 1
primitive with 1 modifier. ONE focus indicator: the file had a single focus rule
(`form input:focus`), so every button, link and social entry fell back to
Chrome's blue outline: auto on a monochrome surface. `font: inherit` on every
control: buttons and inputs rendered in Arial while headings rendered in the
platform face — two typefaces in one 432px card, including on the Sign in CTA.
Control borders are --white-40 on purpose. On --background the semantic --border
(#1f1f1f) measures 1.27:1 and --border-strong (#404040) 2.03:1; neither clears
the 3:1 a control boundary needs (WCAG 1.4.11), and --white-40 measures 3.66:1
and is on the ladder. Same reasoning for the focus ring: --ring (#333333)
measures 1.66:1 and cannot carry one, so the ring is --primary.
Fonts are deliberately NOT imported from the design package: tokens/fonts.css
pulls Geist from fonts.googleapis.com and the sign-in path loads no third-party
font. --font-sans resolves to the platform stack, the identical value hanzoai/pay
sets.
Measured in a real browser against the built bundle, fresh context and empty
storage: page #000000, every control 44px, one 6px radius, h1 21px, a white
2px focus ring, nothing under the touch floor at 390px, no horizontal overflow.
134 unit tests and every workspace typecheck green.
Co-authored-by: Hanzo Dev <[email protected]>
379 lines
14 KiB
CSS
379 lines
14 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.
|
|
*/
|
|
|
|
@import '@hanzo/design/tokens/colors.css';
|
|
@import '@hanzo/design/tokens/radius.css';
|
|
@import '@hanzo/design/tokens/typography.css';
|
|
@import '@hanzo/design/tokens/motion.css';
|
|
|
|
:root {
|
|
/* The design system's face is Geist, pulled from fonts.googleapis.com by
|
|
@hanzo/design/tokens/fonts.css. The sign-in path deliberately loads NO
|
|
third-party font, so --font-sans resolves to the platform stack instead.
|
|
hanzoai/pay resolves it to the same value, so one flow renders in one
|
|
typeface. Self-hosting Geist would let both import fonts.css unchanged. */
|
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, ui-sans-serif, system-ui, sans-serif;
|
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
|
|
font-family: var(--font-sans);
|
|
color-scheme: dark;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
html, body, #root { height: 100%; margin: 0; }
|
|
body {
|
|
background: var(--background);
|
|
color: var(--foreground);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: var(--text-base);
|
|
}
|
|
|
|
/* ONE focus indicator, on every focusable thing. There used to be exactly one
|
|
focus rule in this file (`form input:focus`), so every button, link and social
|
|
entry fell back to Chrome's `outline: auto` — a BLUE ring on a monochrome
|
|
surface. --ring (#333333) measures 1.66:1 against --background and cannot
|
|
carry a focus indicator (WCAG 2.4.13 wants 3:1), so the ring is --primary. */
|
|
:focus-visible {
|
|
outline: 2px solid var(--primary);
|
|
outline-offset: 2px;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
/* ── 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). */
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-width: 480px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}
|
|
|
|
.hanzo-id-brand-header { padding: 16px 0 32px; }
|
|
|
|
/* 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); /* <16px makes iOS Safari zoom on focus */
|
|
background: var(--white-05);
|
|
color: var(--foreground);
|
|
/* A control boundary must clear 3:1 against the page (WCAG 1.4.11). On
|
|
--background (#000) the semantic --border (#1f1f1f) measures 1.27:1 and
|
|
--border-strong (#404040) 2.03:1 — neither can carry an input edge. The
|
|
ladder rung that does is --white-40 (3.66:1). */
|
|
border: 1px solid var(--white-40);
|
|
border-radius: var(--radius-sm);
|
|
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; }
|
|
|
|
/* 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;
|
|
border-radius: var(--radius-sm);
|
|
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; }
|
|
|
|
.hanzo-id-btn.ghost {
|
|
background: var(--white-05);
|
|
color: var(--foreground);
|
|
border-color: var(--white-40);
|
|
font-weight: var(--weight-medium);
|
|
}
|
|
.hanzo-id-btn.ghost:hover:not(:disabled) { background: var(--white-10); border-color: var(--white-60); }
|
|
|
|
.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); }
|
|
.hanzo-id-footer-links a { color: var(--text-primary); }
|
|
|
|
/* 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; }
|
|
|
|
/* ── 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); }
|