/* =========================================================================
   GYRE — a six-digit code field that spins into one tile.

   Adapted from a four-digit design. Two things changed for six: the boxes are
   narrower so a row of them fits a phone, and the tokens moved from :root onto
   .otp so this cannot repaint the panel around it.

   Architecture: the whole component is a state machine with ONE source of
   truth — data-state on .sheet (idle | filling | checking | ok | error).
   Every colour, glow, shake and copy swap below is a selector on that
   attribute, so the visual layer can never drift out of sync with the logic.

   Who owns which transform (nothing is written by both):
     app.js   .slot          translate + rotate + scale  (the curl, the spin,
                                                          the screw down)
              .orbit__ring   scale + rotate              (the track)
              .orbit__hub    scale                       (the point it lands on)
              .slot__digit   scale                       (a digit landing)
              .slot__glow    scale                       (its afterglow)
              .mote          translate + scale           (light off the tile)
     app.css  .seal__tile / .seal__ring / .seal__sheen / everything else

   The orbit is far taller than the row, so that clearance is reserved
   permanently in the neighbours' margins. Nothing below the boxes ever moves.

   Zero dependencies. Transform and opacity only.
   ========================================================================= */

.otp {
  /* Surface, ink and lines are the panel's own, so this card belongs to the
     page it is on. The design shipped a fixed near-black palette, which on the
     light theme put a black card in the middle of a white screen — a component
     that reads as pasted in from somewhere else.

     Everything below is a mapping, not a new palette: change the panel's
     theme and this changes with it. */
  --card:    var(--surface-raised);
  --card-2:  var(--surface);
  --sunk:    var(--surface-sunken);

  --cream:   var(--text);
  --muted:   var(--text-muted);
  --dim:     var(--text-muted);
  --line:    var(--border-line);
  --line-2:  var(--border-strong);

  /* COLOUR IS RESERVED FOR VERDICTS. Attention has no hue of its own here: the
     box you are in, the charge running its border and the six on the track are
     the accent, brighter than the type around them and carrying a glow, so
     they read as a source rather than as a colour. Mint is the outcome and red
     is the refusal, and they are the only other two hues on the card, which is
     what makes them impossible to miss when they arrive.

     The original used a cold blue-white that only works on black. The accent
     carries the same job on either theme and is already the panel's word for
     "this is the live one".

     Focus is still never light alone: it also lifts the box's surface and adds
     a halo, the error fills all six boxes, and every state changes the copy
     and the live region too. */
  --ice:       var(--accent);
  --ice-lift:  var(--accent-hover);
  --ice-glow:  var(--accent-soft);
  --ok:        var(--success);
  --ok-lift:   var(--success);
  --ok-glow:   var(--success-soft);
  --bad:       var(--danger);
  --bad-lift:  var(--danger);
  --bad-glow:  var(--danger-soft);

  /* The panel's own face, so the code does not arrive in a stranger's font. */
  --font: inherit;

  /* Shape + motion */
  --r-slot: 16px;
  --r-card: 30px;
  --ease:   cubic-bezier(0.22, 1, 0.36, 1);
  --spring: cubic-bezier(0.2, 1.4, 0.35, 1);

  /* Six boxes and five gaps, inside a card with 24px of padding either
     side. At the floor that is 6×38 + 5×8 = 268px, which clears the narrowest
     phone in use; the ceiling keeps it from looking like a keypad on a
     desktop. */
  --slot-w: clamp(38px, 11.5vw, 52px);

  /* THE ORBIT'S FOOTPRINT, and the reason this card is laid out the way it is.
     gyre.js puts the six on a circle of radius 1.34 × a box, so the ring's
     outer edge reaches 1.34 + 0.5 = 1.84 boxes from the row's centre line
     while the row itself only reaches 0.5 — an OVERHANG of 1.34 of a box on
     each side. That much has to be clear above and below, and it has to be
     the SAME on each side or the ring is not centred between the subtitle and
     the footer. Reserving it as padding on the stage costs a card full of
     dead space, so it is carried by margins that were there anyway: the
     subtitle's below it, and the error line's reserve plus the actions'
     margin above it.

     1.34 is ORBIT_R in gyre.js, which derives it from the digit count so six
     boxes get the same air beside them that four did. CSS cannot compute a
     sine, so the number is written here and the two have to agree. */
  --gyre-over:   calc(var(--slot-w) * 1.34);
  --gyre-clear:  calc(var(--gyre-over) + 34px);   /* overhang + breathing room */
  --err-reserve: 36px;                            /* two lines of error copy */
  --err-gap:     14px;                            /* its clearance under the row */
}

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ---------- the sheet ----------------------------------------------------- */

/* The panel's own card, not a black one. The original's shadow was tuned for
   a near-black room and reads as a smudge on a light page. */
.sheet {
  position: relative;
  width: 100%;
  padding: 26px 24px;
  border-radius: var(--r-card);
  background: linear-gradient(170deg, var(--card), var(--card-2));
  border: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
  backdrop-filter: blur(var(--blur-card));
  -webkit-backdrop-filter: blur(var(--blur-card));
  overflow: hidden;
  text-align: center;
}

.verify {
  position: relative;
  z-index: 1;
  margin: 0;
}

/* Title and subtitle each hold both copies stacked, so the swap is a pure
   cross-fade with no reflow and no height jump. */
.verify__title,
.verify__sub { position: relative; display: grid; }

.verify__title-in,
.verify__sub-in {
  grid-area: 1 / 1;
  transition: opacity 0.5s var(--ease), transform 0.5s var(--ease);
}

.verify__title {
  margin: 0 0 10px;
  font-size: clamp(19px, 5.4vw, 22px);
  font-weight: 700;
  letter-spacing: -0.015em;
  color: var(--cream);
}

.verify__sub {
  margin: 0 0 var(--gyre-clear);
  font-size: 13.5px;
  font-weight: 400;
  line-height: 1.6;
  color: var(--muted);
}

[data-title-ok],
[data-sub-ok] {
  opacity: 0;
  transform: translateY(6px);
  pointer-events: none;
}

.sheet[data-state="ok"] [data-title-idle],
.sheet[data-state="ok"] [data-sub-idle] {
  opacity: 0;
  transform: translateY(-6px);
  transition-duration: 0.26s;
}

.sheet[data-state="ok"] [data-title-ok] {
  opacity: 1;
  transform: none;
  color: var(--ok);
  transition-delay: 0.24s;
}

.sheet[data-state="ok"] [data-sub-ok] {
  opacity: 1;
  transform: none;
  transition-delay: 0.3s;
}

/* ---------- the stage the spin happens on --------------------------------- */

/* No padding: the clearance the orbit needs lives in the neighbours' margins
   instead, so an idle card is not mostly reserve. .orbit, .seal and .burst are
   inset:0 here and none of them clips, so the ring is free to overhang. */
.code-wrap {
  position: relative;
  isolation: isolate;
}

.code {
  position: relative;
  z-index: 2;
  display: flex;
  justify-content: center;
  gap: 14px;
}

.slot {
  position: relative;
  width: var(--slot-w);
  aspect-ratio: 1;
  border-radius: var(--r-slot);
  background: var(--sunk);
  box-shadow: inset 0 0 0 1px var(--line-2);
  cursor: text;
  transition:
    box-shadow 0.26s var(--ease),
    background-color 0.26s var(--ease);
}

/* Focus rings exactly one box; the error state fills all four. Those two are
   never confusable even though both are a coloured edge. */
.slot:focus-within {
  background: var(--sunk);
  box-shadow:
    inset 0 0 0 1.6px var(--ice),
    0 0 0 5px var(--ice-glow);
}

.slot__input {
  position: absolute;
  inset: 0;
  z-index: 3;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  border-radius: inherit;
  background: transparent;
  font-family: inherit;
  font-size: clamp(20px, 6vw, 25px);
  font-weight: 700;
  color: var(--cream);
  caret-color: var(--ice-lift);
  text-align: center;
  outline: none;
  -webkit-tap-highlight-color: transparent;
}

/* app.js sets .gyre-live on <html>; if the script never runs, the native input
   and its native caret stay visible and the field still works. */
.gyre-live .slot__input {
  color: transparent;
  caret-color: transparent;
}

.slot__input::selection { background: transparent; }
.slot__input::-webkit-contacts-auto-fill-button { opacity: 0; }

.slot__win {
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow: hidden;
  border-radius: inherit;
  contain: paint;
}

.slot__digit {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: clamp(20px, 6vw, 25px);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--cream);
  line-height: 1;
}

/* A caret, but only in the box that is both focused and still empty. */
.gyre-live .slot:focus-within:not(.is-filled) .slot__win::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 30%;
  width: 2px;
  height: 40%;
  margin-left: -1px;
  border-radius: 2px;
  background: var(--ice-lift);
  animation: blink 1.1s steps(1, end) infinite;
}

@keyframes blink { 0%, 55% { opacity: 1 } 56%, 100% { opacity: 0 } }

/* The warmth a landing digit leaves. Its own element, so it never fights the
   box for a transform. */
.slot__glow {
  position: absolute;
  inset: -10%;
  z-index: 1;
  pointer-events: none;
  border-radius: calc(var(--r-slot) + 5px);
  background: radial-gradient(56% 56% at 50% 50%, var(--ice-glow), transparent 72%);
  opacity: 0;
}

/* The charge that runs a box's border. A short lit dash on an invisible track:
   the dash IS the particle. */
.slot__spark {
  position: absolute;
  inset: 0;
  z-index: 4;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  opacity: 0;
}

.slot__arc {
  fill: none;
  stroke: var(--ice);
  stroke-width: 2;
  stroke-dasharray: 0.32 0.68;
  stroke-dashoffset: 1;
  filter: drop-shadow(0 0 5px var(--ice-glow));
}

/* ---------- the track -----------------------------------------------------
   The circle the four ride and the point they collapse onto. app.js measures
   the row and writes each one's left/top/width/height ONCE, then only ever
   animates scale, rotate and opacity — so nothing here reflows.
   -------------------------------------------------------------------------- */

.orbit {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}

.orbit__ring {
  position: absolute;
  overflow: visible;
  opacity: 0;
}

/* Dotted rather than solid: a solid ring at this size reads as a progress
   spinner, which is the one thing the payoff must not look like. The dashes
   are declared in screen pixels via non-scaling-stroke, so they stay the same
   size whatever radius the row measures out to. */
.orbit__path {
  fill: none;
  stroke: var(--ice);
  stroke-width: 1.5;
  stroke-dasharray: 2 8;
  stroke-linecap: round;
}

/* The point the four screw down onto. Present from the first frame of the
   spin so the collapse has somewhere to go, rather than arriving with it. */
.orbit__hub {
  position: absolute;
  width: 7px;
  height: 7px;
  margin: -3.5px 0 0 -3.5px;
  border-radius: 50%;
  background: var(--ice-lift);
  box-shadow: 0 0 8px var(--ice-glow), 0 0 20px rgba(150, 200, 255, 0.34);
  opacity: 0;
  transform: scale(0.4);
}

/* ---------- what the four become ------------------------------------------ */

.seal {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: grid;
  place-items: center;
  pointer-events: none;
}

.seal__tile,
.seal__ring {
  grid-area: 1 / 1;
  border-radius: var(--r-slot);
}

/* The four screw in clockwise and the tile turns in clockwise behind them —
   the same direction, so the hand-off is one continuous turn, not a swap. */
.seal__tile {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--slot-w);
  height: var(--slot-w);
  overflow: hidden;
  background: var(--ok-glow);
  box-shadow: inset 0 0 0 2px var(--ok), 0 12px 34px -8px var(--ok-glow);
  opacity: 0;
  transform: rotate(-38deg) scale(0.32);
  transition: opacity 0.18s linear, transform 0.66s var(--spring);
}

.sheet[data-state="ok"] .seal__tile {
  opacity: 1;
  transform: rotate(0deg) scale(1);
  transition-delay: 0.04s;
}

/* One pass of light across the face once it has settled. */
.seal__sheen {
  position: absolute;
  inset: -40%;
  pointer-events: none;
  background: linear-gradient(74deg,
    transparent 38%,
    rgba(255, 255, 255, 0.42) 50%,
    transparent 62%);
  opacity: 0;
  transform: translateX(-120%);
}

.sheet[data-state="ok"] .seal__sheen {
  animation: sheen 0.75s cubic-bezier(0.3, 0, 0.2, 1) 0.46s both;
}

@keyframes sheen {
  from { opacity: 0.9; transform: translateX(-120%); }
  to   { opacity: 0;   transform: translateX(120%); }
}

.seal__tile svg { width: 26px; height: 26px; }

.seal__check {
  stroke: var(--ok-lift);
  stroke-width: 3.6;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transition: stroke-dashoffset 0.4s var(--ease);
}

.sheet[data-state="ok"] .seal__check {
  stroke-dashoffset: 0;
  transition-delay: 0.3s;
}

/* The shockwave the tile lets go of. Sized off the box so it scales with it. */
.seal__ring {
  width: var(--slot-w);
  height: var(--slot-w);
  box-shadow: 0 0 0 2px var(--ok);
  opacity: 0;
  transform: scale(0.8);
}

.sheet[data-state="ok"] .seal__ring {
  animation: shock 0.85s cubic-bezier(0.2, 0.7, 0.3, 1) 0.08s both;
}

.seal__ring--wide { box-shadow: 0 0 0 1px var(--ok); }

.sheet[data-state="ok"] .seal__ring--wide {
  animation: shock-wide 1.15s cubic-bezier(0.16, 0.8, 0.24, 1) 0.2s both;
}

@keyframes shock {
  from { opacity: 0.85; transform: scale(0.8); }
  to   { opacity: 0;    transform: scale(2.2); }
}

@keyframes shock-wide {
  from { opacity: 0.45; transform: scale(0.9); }
  to   { opacity: 0;    transform: scale(3.6); }
}

/* ---------- confetti ------------------------------------------------------ */

.burst {
  position: absolute;
  inset: 0;
  z-index: 4;
  display: grid;
  place-items: center;
  pointer-events: none;
}

/* Motes rather than confetti: round, small, few and slow. Squares thrown fast
   read as a party popper; this reads as light coming off the tile. */
.mote {
  grid-area: 1 / 1;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--ok);
  box-shadow: 0 0 7px var(--ok-glow), 0 0 16px rgba(46, 230, 168, 0.28);
  opacity: 0;
}

.mote--sm   { width: 3.5px; height: 3.5px; }
.mote--lg   { width: 8px; height: 8px; }
.mote--pale { background: var(--ok-lift); }

/* ---------- checking: the boxes become bodies on a track ------------------ */

.sheet[data-state="checking"] .slot {
  background: var(--sunk);
  box-shadow: inset 0 0 0 1.6px var(--ice);
  /* A wave down the row while the server is being asked.
     The ring cannot start here — it is the SUCCESS animation, and until the
     answer arrives there is no outcome to play. But recolouring the boxes and
     then standing still for a round trip reads as the thing having frozen at
     the exact moment you finished typing, which is what "the animation comes
     late" was. This fills that gap and says only "working", never "yes". */
  animation: slot-waiting 1.1s ease-in-out infinite;
  animation-delay: calc(var(--i, 0) * 90ms);
}

@keyframes slot-waiting {
  0%, 70%, 100% { box-shadow: inset 0 0 0 1.6px var(--ice); }
  35%           { box-shadow: inset 0 0 0 2px var(--ice-lift), 0 0 0 4px var(--ice-glow); }
}

.sheet[data-state="checking"] .slot__input { caret-color: transparent; }

/* Nothing that spins should also be a text target. */
.sheet[data-state="checking"] .slot,
.sheet[data-state="ok"] .slot { cursor: default; }

/* ---------- the ring closes ----------------------------------------------
   Four inputs stop being four inputs the moment the spin brakes and all four
   land back on their marks. The edges, the digits and the track leave the
   working colour together, because they are one object now — and that is the
   beat the payoff is built on: the ring is already the outcome's colour
   before it becomes the tile.
   -------------------------------------------------------------------------- */

.sheet[data-locked] .slot {
  background: var(--ok-glow);
  box-shadow: inset 0 0 0 1.8px var(--ok);
}

.sheet[data-locked] .slot__digit { color: var(--ok-lift); }

.sheet[data-locked] .slot__glow {
  background: radial-gradient(56% 56% at 50% 50%, var(--ok-glow), transparent 72%);
}

.sheet[data-locked] .orbit__path { stroke: var(--ok); }

.sheet[data-locked] .orbit__hub {
  background: var(--ok-lift);
  box-shadow: 0 0 10px var(--ok-glow), 0 0 26px rgba(46, 230, 168, 0.34);
}

/* ---------- error ---------------------------------------------------------
   The whole set fills; focus only ever rings one. Shape carries the meaning,
   not the hue.
   -------------------------------------------------------------------------- */

.sheet[data-state="error"] .slot,
.sheet[data-state="error"] .slot:focus-within {
  background: var(--bad-glow);
  box-shadow: inset 0 0 0 1.6px var(--bad);
}

.sheet[data-state="error"] .slot__digit { color: var(--bad-lift); }

.sheet[data-state="error"] .code { animation: shake 0.44s cubic-bezier(0.36, 0.07, 0.19, 0.97); }

@keyframes shake {
  0%, 100% { transform: translate3d(0, 0, 0) }
  16%  { transform: translate3d(-7px, 0, 0) }
  33%  { transform: translate3d(6px, 0, 0) }
  50%  { transform: translate3d(-4px, 0, 0) }
  68%  { transform: translate3d(3px, 0, 0) }
  84%  { transform: translate3d(-1.5px, 0, 0) }
}

/* Space is reserved at all times, so showing the error shifts nothing. The
   error only ever shows when the code was WRONG, which is the one path that
   never spins — so it needs to clear the row, not the ring. */
.verify__err {
  margin: var(--err-gap) 0 0;
  min-height: var(--err-reserve);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 18px;
  color: var(--bad-lift);
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
}

.sheet[data-state="error"] .verify__err {
  opacity: 1;
  transform: none;
}

/* ---------- footer -------------------------------------------------------- */

/* Whatever the error reserve does not already contribute, so the space under
   the ring matches the space over it exactly. */
.verify__actions {
  display: grid;
  margin-top: calc(var(--gyre-clear) - var(--err-gap) - var(--err-reserve));
  min-height: 40px;
}

.verify__foot,
.verify__done {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: opacity 0.4s var(--ease), transform 0.4s var(--ease);
}

.verify__foot {
  font-size: 13px;
  color: var(--muted);
}

.verify__done {
  flex-direction: column;
  gap: 14px;
  opacity: 0;
  transform: translateY(8px);
  pointer-events: none;
}

.sheet[data-state="ok"] .verify__foot {
  opacity: 0;
  transform: translateY(-8px);
  pointer-events: none;
}

.sheet[data-state="ok"] .verify__done {
  opacity: 1;
  transform: none;
  pointer-events: auto;
  transition-delay: 0.44s;
}

.resend {
  padding: 4px 2px;
  border: 0;
  background: none;
  color: var(--cream);
  font: inherit;
  font-size: 13px;
  font-weight: 700;
  cursor: pointer;
  transition: color 0.2s var(--ease), opacity 0.2s;
}

.resend:hover:not(:disabled) { color: var(--ice-lift); }

.resend:focus-visible {
  outline: 2px solid var(--ice-lift);
  outline-offset: 3px;
  border-radius: 6px;
}

.resend:disabled {
  cursor: default;
  color: var(--dim);
  font-weight: 500;
}

.cta {
  padding: 11px 34px;
  border: 0;
  border-radius: 99px;
  background: var(--ok);
  color: var(--on-accent);
  font: inherit;
  font-weight: 700;
  font-size: 13.5px;
  cursor: pointer;
  transition: transform 0.24s var(--spring), filter 0.2s;
}

.cta:hover { filter: brightness(1.07); }
.cta:active { transform: scale(0.96); }

.cta:focus-visible {
  outline: 2px solid var(--ok);
  outline-offset: 3px;
}

/* ---------- short viewports ----------------------------------------------- */

@media (max-height: 760px) {
  .sheet { padding: 14px 20px 20px; }
  .sheet__grabber { margin-bottom: 18px; }
}

/* ---------- reduced motion ------------------------------------------------
   Same five states, same colours, same copy — nothing spins and nothing
   blinks. app.js reads the same query and swaps the whole gyre for a
   cross-fade straight to the tile.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .seal__sheen { display: none; }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  .verify__title-in,
  .verify__sub-in,
  .verify__err,
  .seal__tile,
  .verify__foot,
  .verify__done { transition-duration: 0.2s !important; }

  .seal__tile { transform: rotate(0deg) scale(1); }
  .seal__ring { display: none; }
  .orbit { display: none; }
  .gyre-live .slot:focus-within:not(.is-filled) .slot__win::after { animation: none; }
}

/* ---------- the number, read back ----------------------------------------

   A masked number sits inside a Persian sentence, and the dots between its
   two halves are neutral characters — so without an isolate the digits either
   side of them swap places and the person is shown a number that is not
   theirs. `bdi` does the isolating; this only makes it look like a number.
   -------------------------------------------------------------------------- */

.otp__phone {
  display: block;
  margin-top: 5px;
  font-size: 15px;
  font-weight: 700;
  color: var(--cream);
  /* Tabular figures so the mask lines up with the number they remember. */
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  white-space: nowrap;
}
