the sign-in order runs through the credential form

The wallet and the phone ranked third and fourth on the login screen, above the
email field, because they were entries in a strip and the strip led the page.
Somebody arriving to sign up for a small business met two specialist ways in
before the one they were going to use. They are still offered, they just stop
outranking the field: Google and GitHub lead, the form follows, the wallet and
the phone trail.

PROVIDER_ORDER carries the whole arrangement now rather than the buttons' half of
it, so the form has a named place in the one list and the column can be read in
one line. That is why the form is a child of the component that draws the column
— entries fall on both sides of it, and a component cannot place entries around a
sibling it does not contain. The alternative was a second list on the page saying
which entries lead and which trail, and two lists of one arrangement drift.

The rule travels with the form and appears only when something sits above it, so
an app with no providers configured no longer gets a rule separating its form
from nothing. It keeps the 24px of air it had when the page's gap owned it.

The form no longer waits on the two descriptor reads. It was safe to return
nothing until IAM answered while the form was a sibling; as a child that would be
a blank screen wherever IAM is slow.

Signup and device approval draw the same column from the same list. Device
approval had the form above the strip under a comment claiming that matched the
login page, which stopped being true when the one-click entries moved to the
front.
This commit is contained in:
zooqueen
2026-08-16 17:37:43 -07:00
parent c065c0a27a
commit 674b6bb263
7 changed files with 309 additions and 205 deletions
+6 -1
View File
@@ -762,13 +762,18 @@ select.hanzo-id-input option {
border-left: 1px solid var(--white-10);
}
/* Labeled divider between social row and email form. */
/* Labeled rule between the one-click entries and the credential form.
The 14px keeps the 24px of air it already had: the rule used to be a child of
`main` (gap 24px) and now sits in the column with the entries (gap 10px), and
without this it would read as another row in an even stack rather than as the
boundary between two groups. 10 + 14 = 24, both sides, unchanged. */
.hanzo-id-divider {
display: flex;
align-items: center;
text-align: center;
color: var(--muted-foreground);
font-size: var(--text-sm);
margin: 14px 0;
}
.hanzo-id-divider::before,
.hanzo-id-divider::after {
+12 -9
View File
@@ -189,21 +189,24 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
return (
<Shell brand={brand} org={client.org}>
<h1>Sign in to approve your device</h1>
{/* Same order as the login page: the credential leads, the strip follows.
One sign-in surface, not two that drift. */}
<LoginForm
client={client}
onAuthenticated={() => setPhase({ s: 'confirm' })}
kind={kind}
onKind={setKind}
/>
{/* Same column as the login page, from the same order — one sign-in
surface, not two that drift. This page had the form ABOVE the strip
and a comment claiming that matched login, which stopped being true
when the one-click entries moved to the front. */}
<SocialButtons
client={client}
intent="signin"
postLoginRedirect={returnTo}
kind={kind}
onKind={setKind}
/>
>
<LoginForm
client={client}
onAuthenticated={() => setPhase({ s: 'confirm' })}
kind={kind}
onKind={setKind}
/>
</SocialButtons>
<p className="hanzo-id-footer-links">
<a href="/forget">Forgot password?</a>
</p>
+25 -20
View File
@@ -209,13 +209,17 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
is 104px wide, so the pair read as the loudest thing on a page whose
job is one field and one button. */}
<h1>Login or Signup</h1>
{/* THE PROVIDERS LEAD. Most people arrive already signed into one of
them and finish in one click, where the credential is two fields and a
recall — so the shortest way in is first and the rule marks where it
ends. 0.2.54 read it the other way and put the credential first; this
is the arrangement the design asks for. The rule MOVED with the strip
rather than staying put: a separator that holds its place while the
things it separates swap ends up marking the wrong boundary. */}
{/* THE ONE-CLICK ENTRIES LEAD. Most people arrive already signed into
Google or GitHub and finish in one press, where the credential is two
fields and a recall — so the shortest way in is first and the rule
marks where it ends. 0.2.54 read it the other way and put the
credential first; this is the arrangement the design asks for.
The column runs THROUGH the form, which is why the form is a child
here: the wallet and the phone follow it. They used to precede it,
because they were in the strip and the strip led the page, so somebody
with an email address and a small business met two specialist entries
before the field they came for. PROVIDER_ORDER is where that reads. */}
<SocialButtons
client={client}
clientIdOverride={clientIdOverride}
@@ -223,19 +227,20 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
postLoginRedirect={redirectUri}
kind={kind}
onKind={setKind}
/>
<LoginForm
client={client}
redirectUri={redirectUri}
state={state}
clientIdOverride={clientIdOverride ?? undefined}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
nonce={nonce}
onMfaRequired={setMfa}
kind={kind}
onKind={setKind}
/>
>
<LoginForm
client={client}
redirectUri={redirectUri}
state={state}
clientIdOverride={clientIdOverride ?? undefined}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
nonce={nonce}
onMfaRequired={setMfa}
kind={kind}
onKind={setKind}
/>
</SocialButtons>
<p className="hanzo-id-footer-links">
{/* Carry the OIDC request across. These are full page loads, so a bare
href drops the client_id, redirect_uri, state and PKCE challenge the
+38 -37
View File
@@ -90,43 +90,44 @@ export function Signup({ client, brand }: { client: AuthClient; brand: BrandCont
clientIdOverride={clientIdOverride}
intent="signup"
postLoginRedirect={redirectUri}
/>
<SignupForm
client={client}
clientIdOverride={clientIdOverride}
redirectUri={redirectUri}
state={state}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
nonce={nonce}
onSubmitted={() => {
analytics.capture(EVENTS.SIGNUP_SUBMITTED)
// Send it on an ordinary request, now. Creating the account is a
// full round trip and the browser leaves for the app the moment it
// answers, so a step left on the flush timer only ever leaves on the
// unload beacon — the send that is lost when a tab is closed or the
// network stalls, and the one nothing can read back to check. The
// arrival is still buffered at this point, so it rides out here too,
// and the two steps the funnel is measured on stop depending on a
// page that is already going away.
analytics.flush()
}}
onCompleted={(subject) => {
// The visitor now has a name, so bind it BEFORE counting the
// completion: `identify` stamps the subject on this event and on
// everything after it, and emits the row that joins the anonymous
// arrival to the account it produced. The steps already queued keep
// the anonymous id they happened under, which is what they were.
//
// The subject alone — an opaque IAM id, no traits. The address is on
// the form a few lines above and has no business on this wire.
if (subject) analytics.identify(subject)
analytics.capture(EVENTS.SIGNUP_COMPLETED)
// The browser leaves for the app on the next line, so the whole
// buffer goes now rather than waiting on the flush timer.
analytics.flush(true)
}}
/>
>
<SignupForm
client={client}
clientIdOverride={clientIdOverride}
redirectUri={redirectUri}
state={state}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
nonce={nonce}
onSubmitted={() => {
analytics.capture(EVENTS.SIGNUP_SUBMITTED)
// Send it on an ordinary request, now. Creating the account is a
// full round trip and the browser leaves for the app the moment it
// answers, so a step left on the flush timer only ever leaves on the
// unload beacon — the send that is lost when a tab is closed or the
// network stalls, and the one nothing can read back to check. The
// arrival is still buffered at this point, so it rides out here too,
// and the two steps the funnel is measured on stop depending on a
// page that is already going away.
analytics.flush()
}}
onCompleted={(subject) => {
// The visitor now has a name, so bind it BEFORE counting the
// completion: `identify` stamps the subject on this event and on
// everything after it, and emits the row that joins the anonymous
// arrival to the account it produced. The steps already queued keep
// the anonymous id they happened under, which is what they were.
//
// The subject alone — an opaque IAM id, no traits. The address is on
// the form a few lines above and has no business on this wire.
if (subject) analytics.identify(subject)
analytics.capture(EVENTS.SIGNUP_COMPLETED)
// The browser leaves for the app on the next line, so the whole
// buffer goes now rather than waiting on the flush timer.
analytics.flush(true)
}}
/>
</SocialButtons>
<p className="hanzo-id-footer-links">
Already have an account? <a href={`/login${window.location.search}`}>Sign in</a>
</p>
+21 -11
View File
@@ -63,7 +63,7 @@ export function authorizeRequest(search: string, clientId: string): OAuthAuthori
}
/**
* The order federated providers are offered in, most-used first.
* The order the ways in are offered in, most-used first.
*
* Order is PROVIDER POLICY, so it lives here beside the other provider policy
* rather than in the component that happens to paint the buttons — and being a
@@ -71,17 +71,27 @@ export function authorizeRequest(search: string, clientId: string): OAuthAuthori
* actually hold still. It had drifted before: an unexported constant inside the
* component, covered by nothing.
*
* Google leads because it is the account most people arrive already signed into,
* and the wallet trails because it is the specialist entry. A provider absent
* from this list does not render at all, which is deliberate: a name here is the
* statement that the portal knows how to draw and finish that provider's flow.
* Google and GitHub lead because they are the accounts most people arrive already
* signed into and they finish in one click. A provider absent from this list does
* not render at all, which is deliberate: a name here is the statement that the
* portal knows how to draw and finish that provider's flow.
*
* `form` is the CREDENTIAL FORM's place in the column, and it is why this list is
* the whole arrangement rather than the buttons' half of it. The wallet and the
* phone used to sit above the form because they were in the strip and the strip
* led the page — so somebody signing up for a ceramics studio met two specialist
* entries before the email field they were going to use. They are still offered,
* they just stop outranking the field: `web3` and `phone` follow the form now.
* Splitting the column at the form is the only reason a component draws entries on
* both sides of it, and this is the one place that split is stated.
*
* `web3` and `phone` are not federated providers either — the wallet is a
* capability of the IAM binary and the phone selects which identifier the form
* asks for. They live in this order anyway because the order is the COLUMN's, not
* the provider list's: a person reading a column of ways to sign in does not care
* which of them is an OAuth hop.
*/
// `phone` is last and is NOT a federated provider — it selects which identifier
// the credential form asks for. It lives in this order anyway because the order
// is the STRIP's, not the provider list's: the wallet is already here on the same
// terms (a capability, not a provider row), and a person reading a column of ways
// to sign in does not care which of them is an OAuth hop.
export const PROVIDER_ORDER = ['google', 'github', 'gitlab', 'web3', 'phone'] as const
export const PROVIDER_ORDER = ['google', 'github', 'gitlab', 'form', 'web3', 'phone'] as const
/**
* Resolve a `provider_hint` from the authorize query to one of the app's
+64 -15
View File
@@ -52,6 +52,13 @@ function drawn(): (string | undefined)[] {
return [...document.querySelectorAll('[data-provider]')].map((b) => (b as HTMLElement).dataset.provider)
}
/** The whole column in DOM order — entries, rule and the credential slot alike. */
function column(): string[] {
return [...document.querySelectorAll('[data-provider], .hanzo-id-divider, form')].map((e) =>
(e as HTMLElement).dataset.provider ?? (e.tagName === 'FORM' ? 'form' : 'rule'),
)
}
/** Resolve when the strip has rendered (both descriptor reads have landed). */
async function settled(): Promise<void> {
await waitFor(() => assert.ok(document.querySelector('.hanzo-id-social')))
@@ -149,25 +156,67 @@ test('phone is the last entry drawn', async () => {
assert.deepEqual(drawn(), ['google', 'github', 'web3', 'phone'])
})
// The rule sits on the side facing the credential form, and the strip leads the
// page — so it TRAILS the buttons. There is no prop for which side, which is why
// this is worth a test: the only thing holding the rule on the correct side is
// where it appears in the JSX, and moving the strip is exactly when someone edits
// that JSX. It also renders only with buttons, so a lone password form on an app
// with no providers never gets a rule separating it from nothing.
test('the rule trails the strip, and only exists when the strip does', async () => {
render(<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google] }) })} />)
// THE ARRANGEMENT, and the reason the form is a child rather than a sibling.
//
// The wallet and the phone used to be part of a strip that led the page, so they
// ranked ABOVE the email field — a small business signing up met two specialist
// entries before the one they were going to use. They are still offered; the order
// simply runs through the form now. Nothing else can hold this still: PROVIDER_ORDER
// is a list of names, and where the form falls among them is only visible here.
test('the one-click entries lead, the form follows, the specialist entries trail', async () => {
render(
<SocialButtons
client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google, github], chains: ['evm'] }) })}
kind="email"
onKind={() => {}}
>
<form />
</SocialButtons>,
)
await settled()
const nodes = [...document.querySelectorAll('.hanzo-id-social, .hanzo-id-divider')].map((e) => e.className)
assert.deepEqual(nodes, ['hanzo-id-social', 'hanzo-id-divider'], 'buttons first, rule after')
assert.deepEqual(column(), ['google', 'github', 'rule', 'form', 'web3', 'phone'])
})
// A second, independent mount: the empty case is a different component life, not
// a re-render of this one.
// The rule separates the one-click entries from the form, so it travels WITH the
// form and appears only when something sits above it. There is no prop for which
// side: the page has one arrangement, and a flag here would be a second way to
// answer a settled question.
test('the rule sits above the form, and only when something is above it', async () => {
render(
<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google] }) })}>
<form />
</SocialButtons>,
)
await settled()
assert.deepEqual(column(), ['google', 'rule', 'form'])
// An app with no providers configured: the form is the whole way in, and a rule
// over it would separate it from nothing. Second mount, not a re-render — the
// empty case is a different component life.
cleanup()
render(<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({}) })} />)
await waitFor(() => assert.equal(document.querySelector('[data-provider]'), null))
assert.equal(document.querySelector('.hanzo-id-divider'), null, 'no buttons, no rule')
render(
<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({}) })}>
<form />
</SocialButtons>,
)
await waitFor(() => assert.ok(document.querySelector('form')))
assert.equal(document.querySelector('.hanzo-id-divider'), null, 'nothing above it, no rule')
})
// The form must not wait on the two descriptor reads. It is why the page exists,
// and rendering it only once IAM has answered twice is a blank screen wherever IAM
// is slow — the component used to return null until then, which was safe when the
// form was a sibling and is not now that it is a child.
test('the credential form renders before the descriptors land', async () => {
const never = (() => new Promise<Response>(() => {})) as unknown as typeof fetch
render(
<SocialButtons client={createAuthClient({ org: org(), fetchImpl: never })}>
<form data-credential="true" />
</SocialButtons>,
)
await waitFor(() => assert.ok(document.querySelector('[data-credential]')))
assert.equal(document.querySelector('[data-provider]'), null, 'nothing resolved yet, so no entries')
})
// A chain nobody can sign is a dead end of the same kind as a dead OAuth button.
+143 -112
View File
@@ -1,5 +1,5 @@
import { Fragment, useEffect, useId, useRef, useState } from 'react'
import type { ComponentType, SVGProps } from 'react'
import type { ComponentType, ReactNode, SVGProps } from 'react'
import type { Chain } from '@hanzo/id-connect'
import type { AuthClient } from '../client'
import type { AppProvider } from '../types'
@@ -11,10 +11,16 @@ import { Alert } from './Alert'
import { Divider } from './Divider'
/**
* Social + multi-chain wallet sign-in buttons.
* The column of ways in, in `PROVIDER_ORDER`.
*
* It draws the entries and places the page's credential form among them, because
* the order runs THROUGH the form — Google and GitHub above it, the wallet and the
* phone below — and a component cannot put entries on both sides of a sibling.
* That is the whole reason the form arrives as a child; everything else here is
* about which entries are offered at all.
*
* Two sign-in shapes, decomplected — and they are answered by two DIFFERENT
* questions, which is the whole shape of this component:
* questions, which is the shape of the rest of this component:
*
* - OAuth (github/google/gitlab) is per-APPLICATION config, so it comes from
* `/v1/iam/get-app-login`: which providers this app links, and IAM has already
@@ -88,6 +94,20 @@ export interface SocialButtonsProps {
* caller can drop to the interactive form instead of a blank redirect state).
*/
readonly onAutoStartResolved?: (started: boolean) => void
/**
* The credential form, placed at `form`'s slot in `PROVIDER_ORDER`.
*
* It is a child rather than a sibling because the order splits AROUND it —
* Google and GitHub above, the wallet and the phone below — and a component
* cannot draw entries on both sides of something it does not contain. The
* alternative was a second list on the page saying which entries lead and
* which trail, and two lists of one arrangement drift.
*
* The page decides what goes in the slot; this component only decides where.
* `Login` puts the form and the door beside it there. Absent, the slot is not
* drawn and neither is the rule above it.
*/
readonly children?: ReactNode
}
interface ProviderMeta {
@@ -131,6 +151,7 @@ export function SocialButtons({
onKind,
autoStart,
onAutoStartResolved,
children,
}: SocialButtonsProps) {
const [resolved, setResolved] = useState<Resolved | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -249,22 +270,32 @@ export function SocialButtons({
// In autoStart mode the component is headless — it exists only to run the hop
// above; the caller renders its own "signing you in" state. Render nothing.
if (autoStart) return null
if (resolved === null) return null // resolving — render nothing rather than flicker
// ONE order for the whole strip, wallet included — PROVIDER_ORDER is where that
// decision lives (google leads, the wallet trails). The wallet's slot is filled
// by the capability, every other slot by the app's own provider list.
// ONE order for the whole column — PROVIDER_ORDER is where that decision lives.
// Each slot is filled by a different fact: the federated entries by the app's
// provider list, the wallet by the binary's capability, the phone by there
// being a form to switch, the form by the page handing one over.
const ordered = PROVIDER_ORDER.filter((k) =>
k === 'web3'
? resolved.wallet.length > 0
: // Phone is drawn only where a credential form is there to switch. The
// entry SELECTS an identifier rather than starting a flow of its own, so
// on a surface with no form (a bare provider strip) it would be a button
// that changes nothing.
k === 'phone'
? onKind !== undefined
: k in resolved.providers,
// The form does not wait on the two descriptor reads. It is why the page
// exists, and a page that renders nothing until IAM answers twice is a blank
// screen wherever IAM is slow.
k === 'form'
? children !== undefined
: resolved === null
? false // still resolving — an entry we cannot finish is worse than none
: k === 'web3'
? resolved.wallet.length > 0
: // Phone is drawn only where a credential form is there to switch. The
// entry SELECTS an identifier rather than starting a flow of its own,
// so on a surface with no form it would change nothing.
k === 'phone'
? onKind !== undefined
: k in resolved.providers,
)
if (ordered.length === 0) return null
// The rule separates what is above the form from the form. So it sits with the
// form, and only when something is up there to separate — an app with no
// providers configured gets its credential form and no rule dangling over it.
const ruled = ordered.indexOf('form') > 0
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
@@ -310,113 +341,113 @@ export function SocialButtons({
}
return (
<>
<div className="hanzo-id-social">
{ordered.map((k) => {
if (k === 'web3') {
// ONE chain-agnostic entry. It connects straight when a single wallet
// is detected, else expands into the chooser below — so the page always
// shows exactly one "Connect Wallet" button, with every offered family
// reachable from it.
return (
<Fragment key="web3">
<button
type="button"
className="hanzo-id-btn ghost"
data-provider="web3"
data-wallet-connect="true"
aria-expanded={walletMenu}
disabled={busyChain !== null}
onClick={onConnectWallet}
>
<WalletIcon />
{/* "{verb} with Wallet", not "Connect Wallet": every other entry
in this strip reads "Continue with X", and one row breaking
the pattern reads as a different KIND of action rather than
the same action with a different credential. It follows the
strip's verb, so the signup surface says "Sign up with
Wallet" exactly as it does for Google. */}
<span>
{busyChain !== null && !walletMenu ? 'Connecting…' : `${verb} with Wallet`}
</span>
</button>
{walletMenu ? (
<div
className="hanzo-id-wallet-chains"
role="group"
aria-label="Choose a wallet network"
>
{resolved.wallet.map((chain) => (
<button
key={`web3-${chain}`}
type="button"
className="hanzo-id-btn ghost"
data-provider="web3"
data-chain={chain}
disabled={busyChain !== null}
onClick={() => startWallet(chain)}
>
<WalletIcon />
<span>{busyChain === chain ? 'Connecting…' : WALLET_CHAIN_LABELS[chain]}</span>
</button>
))}
</div>
) : null}
</Fragment>
)
}
if (k === 'phone') {
// Names the OTHER identifier, always, so one control switches both
// ways — it replaced a two-way text link and a one-way button would
// strand somebody who picked phone by mistake.
//
// It carries the strip's verb like every sibling, and it is honest
// about what it does: it selects how you are IDENTIFIED, then the
// form asks for the same credential it already would have. No code is
// promised, because none is sent — `enableCodeSignin` is what would
// offer that, and IAM answers it false wherever it cannot deliver.
const toPhone = kind !== 'phone'
return (
<div className="hanzo-id-social">
{ordered.map((k) => {
if (k === 'form') {
// The credential form, and the rule that says the ways above it end
// here. Both belong to this slot: the rule marks the boundary between
// the one-click entries and the field, so it moves with the field.
return (
<Fragment key="form">
{ruled ? <Divider /> : null}
{children}
</Fragment>
)
}
if (k === 'web3') {
// ONE chain-agnostic entry. It connects straight when a single wallet
// is detected, else expands into the chooser below — so the page always
// shows exactly one "Connect Wallet" button, with every offered family
// reachable from it.
return (
<Fragment key="web3">
<button
key="phone"
type="button"
className="hanzo-id-btn ghost"
data-provider="phone"
data-identifier-kind={kind ?? 'email'}
onClick={() => onKind?.(toPhone ? 'phone' : 'email')}
data-provider="web3"
data-wallet-connect="true"
aria-expanded={walletMenu}
disabled={busyChain !== null}
onClick={onConnectWallet}
>
<PhoneIcon />
<span>{toPhone ? `${verb} with Phone` : `${verb} with Email`}</span>
<WalletIcon />
{/* "{verb} with Wallet", not "Connect Wallet": every other entry
in this strip reads "Continue with X", and one row breaking
the pattern reads as a different KIND of action rather than
the same action with a different credential. It follows the
strip's verb, so the signup surface says "Sign up with
Wallet" exactly as it does for Google. */}
<span>
{busyChain !== null && !walletMenu ? 'Connecting…' : `${verb} with Wallet`}
</span>
</button>
)
}
const provider = resolved.providers[k]!
const meta = PROVIDER_META[k]!
const { Icon } = meta
{walletMenu ? (
<div
className="hanzo-id-wallet-chains"
role="group"
aria-label="Choose a wallet network"
>
{resolved!.wallet.map((chain) => (
<button
key={`web3-${chain}`}
type="button"
className="hanzo-id-btn ghost"
data-provider="web3"
data-chain={chain}
disabled={busyChain !== null}
onClick={() => startWallet(chain)}
>
<WalletIcon />
<span>{busyChain === chain ? 'Connecting…' : WALLET_CHAIN_LABELS[chain]}</span>
</button>
))}
</div>
) : null}
</Fragment>
)
}
if (k === 'phone') {
// Names the OTHER identifier, always, so one control switches both
// ways — it replaced a two-way text link and a one-way button would
// strand somebody who picked phone by mistake.
//
// It carries the strip's verb like every sibling, and it is honest
// about what it does: it selects how you are IDENTIFIED, then the
// form asks for the same credential it already would have. No code is
// promised, because none is sent — `enableCodeSignin` is what would
// offer that, and IAM answers it false wherever it cannot deliver.
const toPhone = kind !== 'phone'
return (
<button
key={k}
key="phone"
type="button"
className="hanzo-id-btn ghost"
data-provider={k}
onClick={() => startOAuth(provider)}
data-provider="phone"
data-identifier-kind={kind ?? 'email'}
onClick={() => onKind?.(toPhone ? 'phone' : 'email')}
>
<Icon />
<span>{verb} with {meta.label}</span>
<PhoneIcon />
<span>{toPhone ? `${verb} with Phone` : `${verb} with Email`}</span>
</button>
)
})}
<Alert id={errorId} message={error} />
</div>
{/* The "or" separator belongs WITH the social block and renders only when
there are buttons, so it never dangles beside a lone password form on an
app with no providers configured. It TRAILS the block, because the strip
leads the page: a rule separates what is above it from what is below it,
and one that stays put while the things it separates swap ends up marking
the boundary between the strip and the page footer instead. There is no
prop for which side — the page has one arrangement, so the rule has one
place, and a flag here would be a second way to answer a settled question. */}
<Divider />
</>
}
const provider = resolved!.providers[k]!
const meta = PROVIDER_META[k]!
const { Icon } = meta
return (
<button
key={k}
type="button"
className="hanzo-id-btn ghost"
data-provider={k}
onClick={() => startOAuth(provider)}
>
<Icon />
<span>{verb} with {meta.label}</span>
</button>
)
})}
<Alert id={errorId} message={error} />
</div>
)
}