/* ═════════════════════════════════════════════════════════════════════════
   NADIS Public Site — Auth

   Log in, sign up, OTP verification, password reset, set new password.

   These pages are not marketing. Someone is here to do one thing, usually
   in a hurry, sometimes on a phone at a front desk with a patient waiting.
   So: no scroll reveals, no animation beyond focus states, one column of
   attention, and the primary action always visible without scrolling on a
   laptop.

   The slate panel on the left is the only decoration, it is hidden below
   900px, and it carries nothing the form needs. If it never loaded, the
   page would still work perfectly.
   ═════════════════════════════════════════════════════════════════════════ */

/* ── Shell ────────────────────────────────────────────────────────────────
   Full-bleed: these pages deliberately do not sit inside `.nx-wrap`, so the
   slate panel can run to the edge of the viewport.

   100dvh, not 100vh — on mobile Safari `vh` includes the retracting address
   bar, which pushes the submit button under the browser chrome.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth {
  display: grid;
  min-height: calc(100dvh - 72px);
}

@media (min-width: 900px) {
  .nx-auth { grid-template-columns: 0.85fr 1fr; }
  .nx-auth--wide { grid-template-columns: 0.7fr 1fr; }

  /* MUST come after the two rules above — same specificity, so order is
     what decides. `.nx-auth--single` also carries `.nx-auth`, so without
     this it inherits the two-column track list, and with no aside rendered
     the card lands in the 0.85fr column and sits off to the left. */
  .nx-auth--single { grid-template-columns: 1fr; }
}

/* ── The panel ────────────────────────────────────────────────────────────
   One green surface, content centred, never scrolls. Hidden below 900px.

   `justify-content: space-between` was tried first and is wrong here: it
   pushed a heading to the vertical middle of an 1800px column on signup,
   where nobody ever saw it. The inner block is centred in the viewport
   instead, with the optional labels pinned to the bottom.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth__aside { display: none; }

@media (min-width: 900px) {
  .nx-auth__aside {
    display: grid;
    /* Centred block, then a bottom row that collapses to nothing when the
       caller passes no labels. */
    grid-template-rows: 1fr auto;
    gap: var(--nx-space-5);
    padding: var(--nx-space-6);
    background: var(--nx-band);
    color: var(--nx-on-band);

    /* Pinned to one viewport and NOT scrollable. If content ever stops
       fitting here, the answer is less content, not a scrollbar. */
    position: sticky;
    top: 72px;
    height: calc(100dvh - 72px);
    align-self: start;
    overflow: hidden;
  }
}

.nx-auth__aside-inner {
  display: grid;
  align-content: center;
  /* Left-aligned, not centred.

     Centred looked right for the mark and the tagline, and wrong for
     everything under them. Centred body copy is ragged on BOTH edges, so
     the eye has to hunt for the start of each line — measurably slower on
     the three-line note, and the chip row underneath had no edge to sit
     against either.

     Left also gives the panel and the form a shared vertical rhythm: two
     columns of text both starting at a left edge, rather than one poster
     next to one form. */
  justify-items: start;
  gap: var(--nx-space-4);
  text-align: left;
  /* Full width, with the measure capped on the text itself rather than
     here. Same result now that everything is left-aligned, and it keeps
     each element responsible for its own line length. */
}

/* ── The light on the panel ───────────────────────────────────────────────
   Light entering from the top of the panel and falling away down it, with
   the mark sitting inside the lit part. NOT a shape behind the logo.

   Two earlier attempts were both wrong, and for the same reason: a soft
   radial halo, then a contained disc. Anything with a boundary reads as an
   OBJECT stuck behind the mark — a smudge in the first case, a coaster in
   the second. A full-width vertical wash has no edge anywhere, so there is
   nothing to recognise as a shape; it just looks like the top of the panel
   is nearer a window.

   TINTED MINT, NOT WHITE. This is the detail that makes it work. Low-alpha
   near-white over a saturated dark green desaturates the green underneath
   and turns it grey — which is exactly why the halo looked like a
   compression artefact. Lightening with the band's own mint keeps the hue
   and only raises the luminance, so the top of the panel reads as the same
   green, lit.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth__aside::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 68%;
  z-index: 0;
  pointer-events: none;
  background: linear-gradient(to bottom,
    rgba(127, 217, 196, 0.115) 0%,
    rgba(127, 217, 196, 0.070) 22%,
    rgba(127, 217, 196, 0.035) 45%,
    rgba(127, 217, 196, 0.012) 70%,
    rgba(127, 217, 196, 0)     100%);
}

/* The wash is absolutely positioned, so it would paint over static content.
   Lift the panel's own children above it. */
.nx-auth__aside-inner,
.nx-auth__points { position: relative; z-index: 1; }

.nx-auth__figure {
  position: relative;
  display: grid;
  /* Left, with everything else. Centring the mark over left-aligned text
     was tried and the mixed alignment read as indecision rather than as a
     choice — one edge down the panel is stronger. */
  justify-self: start;
  margin-bottom: var(--nx-space-2);
}

.nx-auth__aside-mark {
  position: relative;   /* above the halo */
  /* Large. This is the only place on the site the mark gets room, and at
     92px it read as an icon rather than as an identity. The upper bound is
     held at 148px so the panel still fits a 768px-tall laptop without the
     proof labels being pushed off. */
  width: clamp(96px, 9.5vw, 148px);
  height: auto;
}

/* ── The arrival ──────────────────────────────────────────────────────────
   One full turn, once per session, as the mark settles. The header lockup
   gets a small 32° tilt for the same moment; here the mark is large and
   central and can carry a whole rotation without becoming a toy — which is
   the line the old site crossed by spinning 720° with a scale bounce on
   every single page view.

   Gated on `.nx-anim` (set by site.js only when motion is allowed) and on
   `.is-arriving` (set only on a first visit), so with no JS, with reduced
   motion, or on a return visit the mark is simply there.
   ───────────────────────────────────────────────────────────────────────── */
.nx-anim .nx-auth__aside-mark.is-arriving {
  animation: nx-mark-arrive 1100ms var(--nx-ease-settle) both;
}

@keyframes nx-mark-arrive {
  /* The end state is written out as rotate(0deg) scale(1), NOT `none`.

     A full turn starts and ends at the same matrix — rotate(-360deg) and
     rotate(0deg) are indistinguishable once composed. So if the browser
     ever falls back to interpolating matrix-to-matrix, it finds no
     difference between the two keyframes and the mark simply fades in with
     no rotation at all.

     Spelling out the same function list on both sides keeps interpolation
     per-function, where -360deg → 0deg is a real turn. This is the whole
     animation; it is not worth leaving to a fallback. */
  from { opacity: 0; transform: rotate(-360deg) scale(0.82); }
  to   { opacity: 1; transform: rotate(0deg) scale(1); }
}



/* ── The tagline ──────────────────────────────────────────────────────────
   One sentence per line, broken at the full stop rather than wherever the
   box happens to end. A two-sentence tagline that wraps arbitrarily reads
   like body copy; broken at its own punctuation it reads like a line
   someone wrote on purpose.

   TRACKING. This is a tagline, not a headline, and it wants air.

   It started at -0.018em, borrowed from the display register where the
   type is two or three times larger and negative tracking is correct. At
   28px that reads as cramped — the letters sit on top of each other. It is
   now POSITIVE: +0.014em, roughly 0.4px between characters at this size.
   Enough to feel deliberate and open, not so much that the words start to
   come apart. Word spacing is nudged with it so the gaps between words stay
   proportionally larger than the gaps between letters, which is what keeps
   loose tracking legible rather than merely airy.

   Font is Inter Variable at 600, self-hosted — the same family the
   signed-in product uses.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth__quote {
  font-size: clamp(1.375rem, 1.05rem + 1.1vw, 1.75rem);
  font-weight: 600;
  line-height: 1.4;
  letter-spacing: 0.014em;
  word-spacing: 0.06em;
  color: var(--nx-on-band);
  /* Wide enough that "Your system should move with it." holds one line.
     This cap used to live on the wrapper; it moved here so the wrapper can
     be full width and the mark can centre against the whole panel. */
  max-width: 480px;
}

.nx-auth__quote-line { display: block; }

/* The arrival.

   Deliberately small: each line rises 6px and fades, one after the other,
   and the final full stop lands after both. ~700ms end to end, once, on
   load. No typewriter, no per-character stutter, nothing that draws
   attention to itself being an animation — the effect should register as
   the sentence settling rather than as a thing that happened.

   `both` holds the from-state before the delay elapses, so nothing flashes
   at full opacity first. Under prefers-reduced-motion the global rule in
   tokens.css collapses every duration, and the end state — fully visible —
   is what remains. */
.nx-anim .nx-auth__quote-line {
  animation: nx-line-in 520ms var(--nx-ease-out) both;
  animation-delay: calc(var(--nx-line, 0) * 130ms);
}

@keyframes nx-line-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}

/* ── The full stop ────────────────────────────────────────────────────────
   Colour on punctuation. It ends the sentence either way — the colour adds
   nothing a reader needs, which is exactly why it is safe to use it here.
   Remove it and the copy is unchanged.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth__stop { color: var(--nx-amber); }
.nx-auth__stop--mint  { color: var(--nx-on-band-accent); }
.nx-auth__stop--amber { color: var(--nx-amber); }

/* The tagline's closing stop arrives after both lines have settled. */
.nx-auth__stop--accent { display: inline-block; }

.nx-anim .nx-auth__stop--accent {
  animation: nx-stop-in 320ms var(--nx-ease-settle) both;
  animation-delay: 460ms;
}

@keyframes nx-stop-in {
  from { opacity: 0; transform: translateY(-2px) scale(0.7); }
  to   { opacity: 1; transform: none; }
}

.nx-auth__aside-note {
  font-size: var(--nx-text-small);
  line-height: 1.6;
  color: var(--nx-on-band-muted);
  /* Narrower than the tagline above it: prose wants a shorter measure than
     a display line does.

     No `margin-inline: auto` — it was there to centre the line back when
     the panel was centred, and once the panel went left-aligned those auto
     margins kept absorbing the leftover width of the 480px column and
     pushing the prose ~70px in from the mark and the tagline. Left edges
     have to actually line up, or the alignment reads as an accident. */
  max-width: 36ch;
}

/* Set in the attention colour. Used on login, where the line is the warm
   half of the message rather than an explanation of it. */
.nx-auth__aside-note--amber {
  color: var(--nx-amber);
  font-weight: 500;
}

/* The promises break onto their own line while staying inside the same
   paragraph — still prose, still one sentence after another, just given
   room to land. A <br> would do the same thing and say nothing about why;
   a block span keeps the reason in the stylesheet. */
.nx-auth__note-promises {
  display: block;
  margin-top: var(--nx-space-3);
  color: var(--nx-on-band);
}

/* ── Proof labels ─────────────────────────────────────────────────────────
   Labels only, wrapped into one quiet row at the foot of the panel. The
   sentences that used to explain each one were what made this panel scroll;
   if a claim cannot survive being four words, it belongs on the home page
   where there is room to make the argument properly.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth__points {
  display: flex;
  flex-wrap: wrap;
  /* Left, matching the text above — a centred chip row under left-aligned
     copy leaves a ragged left edge running down the whole panel. */
  justify-content: flex-start;
  gap: var(--nx-space-2);
  align-self: end;
  padding-top: var(--nx-space-4);
  border-top: 1px solid var(--nx-band-rule);
}

.nx-auth__point {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px var(--nx-space-3);
  border: 1px solid var(--nx-band-rule);
  border-radius: var(--nx-radius-pill);
  font-size: var(--nx-text-label);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--nx-on-band-muted);
  font-variant-numeric: tabular-nums lining-nums;
}

/* Amber, used on this site for one thing only: not finished yet. */
.nx-auth__point.is-pending {
  color: var(--nx-amber);
  border-color: color-mix(in srgb, var(--nx-amber) 22%, transparent);
  background: var(--nx-amber-fill);
}
.nx-auth__point-flag { font-weight: 620; }

/* ── The form side ────────────────────────────────────────────────────── */
.nx-auth__main {
  display: grid;
  align-content: center;
  justify-items: center;
  padding: var(--nx-space-6) var(--nx-gutter);
}

.nx-auth__card { width: 100%; max-width: 420px; }
.nx-auth__card--wide { max-width: 620px; }

/* The mark, shown only where the slate panel is not. Below 900px the form
   is the whole page and would otherwise carry no brand at all. */
.nx-auth__mark { margin-bottom: var(--nx-space-5); }
.nx-auth__mark img { height: 32px; width: auto; }
@media (min-width: 900px) { .nx-auth__mark { display: none; } }

.nx-auth__title { margin-bottom: var(--nx-space-2); }
.nx-auth__sub   { margin-bottom: var(--nx-space-5); }

.nx-auth__alerts { margin-bottom: var(--nx-space-4); }

.nx-auth__form   { display: grid; gap: var(--nx-space-4); }
.nx-auth__submit { margin-top: var(--nx-space-2); }

.nx-auth__foot {
  margin-top: var(--nx-space-5);
  padding-top: var(--nx-space-4);
  border-top: 1px solid var(--nx-rule);
  display: grid;
  gap: var(--nx-space-2);
  font-size: var(--nx-text-small);
  color: var(--nx-ink-muted);
}

/* ── Signup fieldsets ─────────────────────────────────────────────────────
   Twelve inputs is a wall. Grouping them into three named steps turns it
   into three short reads.

   Every input stays in the DOM at all times — nothing is hidden, disabled
   or conditionally rendered — so the submitted payload is byte-identical to
   the form this replaced. The grouping is presentation only.
   ───────────────────────────────────────────────────────────────────────── */
/* 21, not 34. The step between sections still has to be bigger than the step
   between fields — that difference is what makes the three groups read as
   groups — so as the field gap came down to 13 this comes down to 21. The
   RATIO is what carries the grouping, not the absolute size. */
.nx-fieldset + .nx-fieldset { margin-top: var(--nx-space-4); }

.nx-legend__step {
  display: grid;
  place-items: center;
  flex: none;
  width: 22px; height: 22px;
  border-radius: 50%;
  background: var(--nx-ink);
  color: var(--nx-paper);
  font-size: var(--nx-text-label);
  font-weight: 600;
  font-variant-numeric: tabular-nums lining-nums;
}

/* Two fields side by side from 560px. Below that they stack — a 48px-tall
   input in a half-width column is unusable on a phone. */
/* Row gap 13 to match `.nx-field + .nx-field`; column gap stays 21, because
   two controls sitting side by side need more air between them than two
   stacked rows do — at 13 the Country and Phone boxes start to read as one
   wide control. */
.nx-pair { display: grid; gap: var(--nx-space-3) var(--nx-space-4); }
@media (min-width: 560px) { .nx-pair { grid-template-columns: 1fr 1fr; } }

/* `.nx-field + .nx-field` adds top margin for stacked fields; inside a pair
   the grid gap already handles it. */
.nx-pair > .nx-field + .nx-field { margin-top: 0; }

/* ── OTP ──────────────────────────────────────────────────────────────────
   ONE input, not six boxes.

   Six separate boxes look considered and behave badly: `autocomplete`
   cannot fill them, iOS and Android SMS autofill do not target them, paste
   needs bespoke JS, backspace across a boundary needs more, screen readers
   announce six unlabelled fields, and the form has to reassemble a value
   the server already knew how to receive.

   One field with `autocomplete="one-time-code"` gets the OS keyboard
   suggestion, gets paste for free, needs no JavaScript at all, and posts
   exactly the `otp` the route already reads.
   ───────────────────────────────────────────────────────────────────────── */
.nx-otp {
  font-size: 1.75rem;
  font-weight: 560;
  /* Wide tracking so the digits read as a code rather than a number. The
     right padding compensates for the trailing letter-space, which would
     otherwise push the group visually off-centre. */
  letter-spacing: 0.38em;
  text-align: center;
  padding-left: calc(var(--nx-space-3) + 0.38em);
  min-height: 64px;
  font-variant-numeric: tabular-nums lining-nums;
}
.nx-otp::placeholder { letter-spacing: 0.28em; font-size: 1.25rem; }

.nx-otp-sent {
  padding: var(--nx-space-3) var(--nx-space-4);
  background: var(--nx-sand);
  border-radius: var(--nx-radius-2);
  font-size: var(--nx-text-small);
  color: var(--nx-ink-muted);
}
.nx-otp-sent strong { color: var(--nx-ink); font-weight: 560; }

/* The resend form is a second <form> posting elsewhere, so it cannot sit
   inside the verify form. It gets a quiet link-shaped button instead. */
.nx-resend {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--nx-space-2);
}
.nx-resend__btn {
  padding: 0;
  background: none;
  border: 0;
  color: var(--nx-blue);
  font-size: var(--nx-text-small);
  font-weight: 500;
  text-decoration: underline;
  text-underline-offset: 0.22em;
  cursor: pointer;
}
.nx-resend__btn:hover { color: var(--nx-blue-deep); }

/* ── Centred card — reset, verify, set-new-password ───────────────────────
   These are interruptions in a flow rather than destinations, so they get a
   single centred card and no slate panel. Nothing to look at but the one
   thing being asked for.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth--single { place-content: center; justify-items: center; }
.nx-auth--single .nx-auth__main { max-width: 480px; }
.nx-auth--single .nx-auth__mark { display: block; }

/* Failed-submit summary. role="alert" in the markup means it is announced
   the moment it appears; the styling just makes it visible to everyone
   else. Sits directly above the submit button, which is where someone is
   looking when nothing happens. */
.nx-auth__summary {
  margin-top: var(--nx-space-4);
  padding: var(--nx-space-3);
  background: #f8e9e9;
  border-radius: var(--nx-radius-2);
}

/* ── Short viewports ──────────────────────────────────────────────────────
   The panel never scrolls, which means on a short window something has to
   give — and by default it was the proof labels, silently clipped off the
   bottom. Measured: at 1366×640 the last row sat 27px below the panel edge.

   So below 700px of viewport height the mark comes down and the gaps
   tighten. The mark is the right thing to sacrifice: it is the one element
   with no information in it.

   700px, not 780px: 768- and 720-tall viewports were measured fitting the
   full-size mark with 55px to spare, and shrinking it there would have cost
   the panel its presence on the commonest laptop sizes for no reason.

   Height media queries are rare enough to be worth stating plainly — this
   is about the laptop lid, not the window width, and the two are
   independent.
   ───────────────────────────────────────────────────────────────────────── */
@media (min-width: 900px) and (max-height: 700px) {
  .nx-auth__aside {
    gap: var(--nx-space-4);
    padding: var(--nx-space-5);
  }
  .nx-auth__aside-mark {
    width: clamp(64px, 6vw, 96px);
    margin-bottom: 0;
  }
  .nx-auth__aside-inner { gap: var(--nx-space-3); }
  .nx-auth__points { gap: 6px; padding-top: var(--nx-space-3); }
}

/* ── The kicker ───────────────────────────────────────────────────────────
   One line under the mark, above the tagline. Given a short rule above it
   rather than a chip or a dot: the editorial kicker treatment reads as
   deliberate typesetting, and it avoids borrowing the pill shape that means
   "status" everywhere else on this site.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth__kicker {
  font-size: var(--nx-text-small);
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--nx-amber);
  /* Pulls the kicker up against the mark so the two read as one unit, with
     the tagline's own gap keeping it separate from what follows. */
  margin-top: calc(var(--nx-space-2) * -1);
}

.nx-auth__kicker::before {
  content: "";
  display: block;
  width: 26px;
  height: 2px;
  border-radius: 2px;
  background: var(--nx-amber);
  margin-bottom: var(--nx-space-3);
}

/* ── Inline headline ──────────────────────────────────────────────────────
   Sentences run on one line and wrap only when they have to. `inline-block`
   rather than `inline` for two reasons: transforms do not apply to inline
   boxes, so the arrival would not animate; and it keeps each sentence
   unbreakable internally while still allowing a break BETWEEN them, which
   is what "in line, but flexible" actually means on a narrow screen.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth__quote--inline .nx-auth__quote-line { display: inline-block; }

/* ── The typed line ───────────────────────────────────────────────────────
   Types itself out after the headline settles.

   The ghost span underneath reserves the finished line's exact height, so
   nothing below shifts while it types. Without it the panel grows a line at
   a time and the whole layout walks down the screen — the single most
   common way a typewriter effect ruins a page.

   The caret is a real element rather than a border on the text, so it does
   not jump around as characters land, and it is removed the moment typing
   finishes. A caret left blinking on a finished sentence suggests an input.
   ───────────────────────────────────────────────────────────────────────── */
.nx-auth__typed {
  position: relative;
  font-size: var(--nx-text-small);
  line-height: 1.6;
  color: var(--nx-on-band-muted);
  max-width: 36ch;
}

.nx-auth__typed-ghost { visibility: hidden; }

.nx-auth__typed-out {
  position: absolute;
  inset: 0;
}

.nx-auth__caret {
  display: inline-block;
  width: 1.5px;
  height: 1em;
  margin-left: 2px;
  vertical-align: -0.15em;
  background: var(--nx-on-band-accent);
  animation: nx-caret 900ms steps(1, end) infinite;
}

@keyframes nx-caret {
  0%, 50%   { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}

/* The accented phrase inside a headline. Amber, and it is the one place
   amber is used for emphasis rather than for "not finished yet" — a
   deliberate exception, kept to a single phrase on a single page so the
   status meaning is not diluted. The closing full stop beside it is mint,
   so the two do not merge into one amber blob. */
.nx-auth__accent { color: var(--nx-amber); }

/* Inline headlines run smaller than stacked ones. At the stacked size the
   two sentences needed 560px and the measure is 480, so "in line" silently
   became two lines anyway. Sized to hold one line at the panel's real width
   and still allowed to break between sentences when the viewport is
   genuinely too narrow. */
.nx-auth__quote--inline {
  font-size: clamp(1.125rem, 0.82rem + 0.95vw, 1.5rem);
  max-width: 34ch;
}

/* A whole phrase carrying a colour, rather than just its full stop. Used on
   the typed line's closing call to action. Same three values as the stops,
   so the two systems never disagree about what "amber" means. */
.nx-auth__tone--amber  { color: var(--nx-amber); }
.nx-auth__tone--mint   { color: var(--nx-on-band-accent); }
.nx-auth__tone--accent { color: var(--nx-amber); }

/* ── Off-list city note ───────────────────────────────────────────────────
   A spelling suggestion, or confirmation that a town we do not have has
   been recorded as typed. Blue rather than red: nothing is wrong, and an
   error colour here would read as a rejection of a perfectly real place.
   ───────────────────────────────────────────────────────────────────────── */
.nx-field__note {
  margin-top: var(--nx-space-2);
  font-size: var(--nx-text-small);
  color: var(--nx-blue);
}
.nx-field__note[hidden] { display: none; }

/* The suggested name itself. A real <button>, so it is reachable by Tab and
   announced as something you can act on — a styled <span> with a click
   handler would be invisible to anyone not using a mouse. */
.nx-field__suggest {
  padding: 0 2px;
  background: none;
  border: 0;
  color: var(--nx-blue);
  font: inherit;
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 0.2em;
  cursor: pointer;
}
.nx-field__suggest:hover { color: var(--nx-blue-deep); }

/* "optional" beside a label. Marking the optional ones is more useful than
   marking the required ones — most fields here are required, so the short
   list is the informative one, and it stops people wondering whether they
   have missed something. */
.nx-field__optional {
  margin-left: var(--nx-space-2);
  font-size: var(--nx-text-label);
  font-weight: 500;
  letter-spacing: var(--nx-track-label);
  text-transform: uppercase;
  color: var(--nx-ink-faint);
}

/* ── Facility type ────────────────────────────────────────────────────────
   Four radio cards. A native <input type="radio"> under each one, visually
   hidden but never `display:none` — a hidden-but-present input keeps arrow
   key navigation, keeps the group announced as a radiogroup, and keeps the
   value in the form post. Styling is driven off `:checked` and `:focus-visible`
   on that real input, so nothing here is faked.
   ───────────────────────────────────────────────────────────────────────── */
.nx-choice-field { border: 0; padding: 0; margin: 0; }

.nx-choice {
  display: grid;
  gap: var(--nx-space-2);
  margin-top: var(--nx-space-2);
}
@media (min-width: 560px) {
  /* Two up. Four across would put each label in ~120px and force the hints
     to wrap to three lines. */
  .nx-choice { grid-template-columns: 1fr 1fr; }
}

.nx-choice__item {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: var(--nx-space-2);
  padding: var(--nx-space-3);
  background: var(--nx-paper-raised);
  border: 1px solid var(--nx-rule-strong);
  border-radius: var(--nx-radius-2);
  cursor: pointer;
  transition: border-color var(--nx-dur-fast) var(--nx-ease),
              background-color var(--nx-dur-fast) var(--nx-ease);
}
.nx-choice__item:hover { border-color: var(--nx-ink-faint); background: var(--nx-sand); }

/* Clipped rather than display:none — see the note above. */
.nx-choice__input {
  position: absolute;
  width: 1px; height: 1px;
  opacity: 0;
  margin: 0;
  pointer-events: none;
}

/* The mark. Drawn on the label so the whole card is the target — a 20px
   radio is a poor tap target and the card is 44px+. */
.nx-choice__item::before {
  content: "";
  flex: none;
  width: 18px; height: 18px;
  margin-top: 1px;
  border: 1.5px solid var(--nx-rule-strong);
  border-radius: 50%;
  transition: border-color var(--nx-dur-fast) var(--nx-ease),
              box-shadow var(--nx-dur-fast) var(--nx-ease);
}

.nx-choice__item:has(.nx-choice__input:checked) {
  border-color: var(--nx-ink);
  background: var(--nx-paper-raised);
}
.nx-choice__item:has(.nx-choice__input:checked)::before {
  border-color: var(--nx-ink);
  /* Inset shadow rather than a nested element — one box, no extra markup. */
  box-shadow: inset 0 0 0 4px var(--nx-paper-raised), inset 0 0 0 18px var(--nx-ink);
}

/* Keyboard focus lands on the hidden input, so the ring has to be drawn on
   the card. Without this the group is invisible to anyone tabbing. */
.nx-choice__item:has(.nx-choice__input:focus-visible) {
  outline: var(--nx-focus-width) solid var(--nx-focus);
  outline-offset: var(--nx-focus-offset);
}

.nx-choice__body { display: grid; gap: 1px; }
.nx-choice__label {
  font-size: var(--nx-text-small);
  font-weight: 560;
  color: var(--nx-ink);
}
.nx-choice__hint {
  font-size: var(--nx-text-label);
  letter-spacing: 0;
  text-transform: none;
  color: var(--nx-ink-muted);
}

/* ── Inline form errors ───────────────────────────────────────────────────
   Login answers JSON now, so there is no page reload to carry a flash.
   Errors have to render in place, above the field that caused them.

   `role="alert"` on the element in the markup, not here — this is only
   the look. Amber rather than red: the commonest message is "email or
   password is incorrect", which is a correction, not a catastrophe.
   ───────────────────────────────────────────────────────────────────────── */
.nx-formerror {
  margin: 0 0 var(--nx-space-3);
  padding: var(--nx-space-3);
  background: var(--nx-amber-tint);
  border-left: 2px solid var(--nx-amber);
  border-radius: var(--nx-radius-1);
  color: var(--nx-amber-ink);
  font-size: var(--nx-text-small);
  line-height: var(--nx-leading-body);
}
.nx-formerror[hidden] { display: none; }

/* ── Step-up challenge ────────────────────────────────────────────────────
   Holds either the Turnstile widget or the countdown message. Sits
   between the password field and the submit button, so it cannot be
   missed on the way to clicking.
   ───────────────────────────────────────────────────────────────────────── */
.nx-challenge {
  margin-bottom: var(--nx-space-3);
  padding: var(--nx-space-3);
  background: var(--nx-sand);
  border: 1px solid var(--nx-rule-strong);
  border-radius: var(--nx-radius-2);
}
.nx-challenge[hidden] { display: none; }

/* Signup only. The widget appears directly under "I agree to the Terms",
   and without this it reads as part of that sentence rather than as its
   own step. Top margin only — the shared rule above already spaces the
   submit button below it. */
.nx-challenge--lazy { margin-top: var(--nx-space-4); }

.nx-challenge__note {
  margin: 0 0 var(--nx-space-2);
  font-size: var(--nx-text-small);
  color: var(--nx-ink-muted);
  /* The countdown rewrites this every second. Tabular figures stop the
     text jittering as digit widths change — the same reason the ledger
     screens use them. */
  font-variant-numeric: tabular-nums;
}
.nx-challenge__note:empty { display: none; }

/* ── Facility chooser ─────────────────────────────────────────────────────
   One column, not the two-up of .nx-choice: a clinic name plus its city
   needs the width, and there are rarely more than three.

   Built on the same clipped-radio pattern, so the whole row is the tap
   target rather than an 18px circle.
   ───────────────────────────────────────────────────────────────────────── */
.nx-facilities {
  display: grid;
  gap: var(--nx-space-2);
  margin-bottom: var(--nx-space-4);
}

.nx-facility {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--nx-space-3);
  padding: var(--nx-space-3);
  background: var(--nx-paper-raised);
  border: 1px solid var(--nx-rule-strong);
  border-radius: var(--nx-radius-2);
  cursor: pointer;
  transition: border-color var(--nx-dur-fast) var(--nx-ease),
              background-color var(--nx-dur-fast) var(--nx-ease);
}
.nx-facility:hover { border-color: var(--nx-ink-faint); background: var(--nx-sand); }

.nx-facility__input {
  position: absolute;
  width: 1px; height: 1px;
  opacity: 0;
  margin: 0;
  pointer-events: none;
}

.nx-facility::before {
  content: "";
  flex: none;
  width: 18px; height: 18px;
  border: 1.5px solid var(--nx-rule-strong);
  border-radius: 50%;
  transition: border-color var(--nx-dur-fast) var(--nx-ease),
              box-shadow var(--nx-dur-fast) var(--nx-ease);
}
.nx-facility:has(.nx-facility__input:checked) { border-color: var(--nx-ink); }
.nx-facility:has(.nx-facility__input:checked)::before {
  border-color: var(--nx-ink);
  box-shadow: inset 0 0 0 4px var(--nx-paper-raised), inset 0 0 0 18px var(--nx-ink);
}

/* Focus lands on the clipped input, so the ring must be drawn on the row
   or the group is invisible to anyone tabbing. */
.nx-facility:has(.nx-facility__input:focus-visible) {
  outline: var(--nx-focus-width) solid var(--nx-focus);
  outline-offset: var(--nx-focus-offset);
}

.nx-facility__body { display: grid; gap: 2px; min-width: 0; }
.nx-facility__name {
  font-size: var(--nx-text-body);
  font-weight: 560;
  color: var(--nx-ink);
  /* A facility name is free text and can be long; truncate rather than
     let it push the radio out of the row. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.nx-facility__meta {
  font-size: var(--nx-text-label);
  color: var(--nx-ink-muted);
  text-transform: capitalize;
}

/* A prompt, not a rejection.

   "Enter your email and password" means you have not finished; "Email or
   password is incorrect" means the server said no. Same slot, different
   news — so they must not look identical, or the second reads as more of
   the first and gets skimmed past. Blue for the nudge, amber stays for
   the refusal. */
.nx-formerror--hint {
  background: var(--nx-blue-tint);
  border-left-color: var(--nx-blue);
  color: var(--nx-blue-deep);
}
