Files
id/apps/web/src/app.css
T
zooqueenandHanzo Dev 7514c5b41a id: style from @hanzo/design tokens, and stop painting controls by ancestor
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]>
2026-07-27 09:25:33 -07:00

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); }