/* ===========================================================================
   The breakthrough: the rank-up ceremony.
   ===========================================================================

   Companion to js/rankup.js. The markup is static in index.html, beside
   #win-screen.

   WHAT IT IS
   The pawn breaks through a Quoridor wall in the OLD rank's colour, and the
   broken wall re-forms UNDERNEATH it in the NEW rank's colour. Ceiling becomes
   floor. That is the whole idea, and every rule in here serves it.

   WHY IT IS NOT css/ranks-journey.css
   The vocabulary is that file's - capsule walls with a lit top edge and a
   three-stop gradient, extruded disc pawns, a tile floor - but the sheet
   itself cannot be linked into index.html. It sets
   html.ranks-snap{scroll-snap-type:y mandatory} and .ranks-body{background}
   for the ranks page's document scroller, and both would fight the game page
   for as long as the tag was in the head. So the twenty lines that matter are
   copied below. If the wall recipe changes there, change it here too; there is
   no way to share it that does not drag the page furniture along with it.

   WHY --cell IS DECLARED HERE
   On ranks.html --cell is measured and published by ranks-page.js's fit() -
   the largest tile that divides innerHeight a whole number of times. This page
   does not load that script, so --cell would resolve to the 64px fallback
   baked into every one of the copied rules. It is published below instead.

   PERFORMANCE, WHICH IS NOT NEGOTIABLE HERE
   confetti() in main.js has already spent a full-viewport canvas budget by the
   time this runs, so this ceremony is DOM and CSS transforms - no canvas, no
   second particle system, fourteen debris chips rather than a hundred and
   forty. Nothing in this file animates box-shadow, filter, background-position
   or width; the floor colour change is two stacked divs trading opacity rather
   than one div animating a gradient. backdrop-filter is explicitly turned OFF
   (see .rankup-screen). will-change is set by JS on three elements only and
   dropped again on animationend - see the note there.
   ======================================================================== */

/* ---------------------------------------------------------------------------
   1. The host
   ------------------------------------------------------------------------ */

/* .overlay-screen is inherited on purpose: js/tabbar.js queries that class
   exactly once at mount and observes only the nodes it found, so an element
   that is not one of them never sets body.overlay-open - and the tab bar
   (z-index 120) and the app header dock (115) would paint straight across the
   bottom of the ceremony in the native app. Everything below is the cost of
   borrowing the class: the centring flexbox, the padding and the blur all have
   to be undone. */
.rankup-screen {
  position: fixed;
  inset: 0;
  /* Over the confetti canvas and the trophy toasts (both 130) and the streak
     toast (140); under .capture-exit (200). */
  z-index: 150;
  display: block;
  padding: 0;
  background: rgba(8, 7, 5, .94);
  /* NOT inherited from .overlay-screen. A second full-viewport backdrop blur
     under a running animation is 10-15ms a frame on a mid-range Android before
     anything has moved, and it buys nothing: the backdrop is .94 opaque. */
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  /* The stage is a closed world - nothing inside it affects layout outside it,
     and the debris that flies past the edge is clipped rather than painted.
     The explicit size is because `strict` includes size containment, and a box
     whose dimensions come from its own contents would collapse; these come
     from the insets, which containment does not touch. */
  width: 100%;
  height: 100%;
  contain: strict;
  overflow: hidden;
  opacity: 0;
  transition: opacity .26s ease;

  /* ---- the measurements everything else is written in ---- */
  --cell: clamp(34px, 8.6vw, 74px);
  /* The ranks page derives both of these from --cell (.24 and .46 of a tile).
     It can, because its tile is a measured slice of the VIEWPORT HEIGHT and
     lands around 76px. Ours is a width clamp that lands at 34px on a phone, and
     .24 of that is a 8px hairline with a 16px pawn on it - the two objects the
     whole ceremony is about, drawn too small to read. They get their own
     clamps, sized to look like the ranks page's wall and pawn rather than to
     match its arithmetic. */
  --wall-t: max(11px, calc(var(--cell) * .34));
  --pawn: max(28px, calc(var(--cell) * .62));

  /* The stage height in px, republished by js/rankup.js on every arm. Every
     vertical position below is a fraction of it. It is NOT vh: inside the app
     the fixed overlay is the small viewport and vh is the large one, and the
     two differ by the height of a browser chrome that is not there - enough to
     put the pawn through the wrong part of the wall. */
  --h: 100dvh;
  --rank-from: #8B5A2B;
  --rank-to: #FFD700;
  --rank: var(--rank-to);

  /* The four heights the pawn and the walls are placed at. Named because the
     demotion variant redefines three of them and inherits the rest. */
  --wall-y: calc(var(--h) * .46);
  --pawn-a: calc(var(--h) * .72);                                        /* the floor */
  --pawn-b: calc(var(--wall-y) + var(--wall-t) / 2 + var(--pawn) / 2);   /* touching the wall */
  --pawn-c: calc(var(--h) * .24);                                        /* through it */
  --new-wall-y: calc(var(--pawn-c) + var(--pawn) / 2 + var(--wall-t) / 2 + 10px);
}
.rankup-screen.is-open { opacity: 1; }

.rux-stage {
  position: absolute;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  pointer-events: none;
}

/* ---------------------------------------------------------------------------
   2. The floor - copied from css/ranks-journey.css
   ------------------------------------------------------------------------
   Four background layers, bottom to top: the vertical tile bevel, the
   horizontal tile bevel, the checker, and the colour wash. The tiles butt
   flush; what makes them read as objects rather than as a checkerboard is the
   two bevel layers giving every cell a lit edge and a shaded one.

   TWO of them, stacked, because the floor has to change colour mid-ceremony
   and a background is not something to animate. The `to` layer sits on top at
   opacity 0 and fades in over the `from` layer. */
.rux-floor {
  position: absolute;
  inset: 0;
  background-color: #17150f;
  background-image:
    repeating-linear-gradient(90deg,
      rgba(255, 255, 255, .055) 0 7%,
      rgba(255, 255, 255, 0) 7% 92%,
      rgba(0, 0, 0, .10) 92% 100%),
    repeating-linear-gradient(180deg,
      rgba(255, 255, 255, .075) 0 7%,
      rgba(255, 255, 255, 0) 7% 92%,
      rgba(0, 0, 0, .13) 92% 100%),
    /* The checker, at about half the contrast the ranks page uses. There the
       tile is a measured slice of the viewport HEIGHT and comes out near 76px,
       so .24/.07 reads as a floor; ours is 34px on a phone, and at that size
       the same numbers read as a loud chequerboard with a rank name sitting on
       it. Same recipe, quieter, for the same result. */
    conic-gradient(
      rgba(0, 0, 0, .13) 0 25%, rgba(255, 255, 255, .04) 0 50%,
      rgba(0, 0, 0, .13) 0 75%, rgba(255, 255, 255, .04) 0),
    var(--wash);
  background-size:
    var(--cell) var(--cell),
    var(--cell) var(--cell),
    calc(var(--cell) * 2) calc(var(--cell) * 2),
    100% 100%;
  background-position: 0 0;
}
/* The wash is strongest at the top, where the light is, and dies out into the
   dark the words sit on. Strong enough to name the rank, never so strong that
   the tiles become the subject. */
.rux-floor-from {
  --wash: linear-gradient(180deg,
    color-mix(in srgb, var(--rank-from) 20%, #14120d) 0%,
    color-mix(in srgb, var(--rank-from) 9%, #14120d) 58%,
    #14120d 100%);
}
.rux-floor-to {
  opacity: 0;
  --wash: linear-gradient(180deg,
    color-mix(in srgb, var(--rank-to) 24%, #14120d) 0%,
    color-mix(in srgb, var(--rank-to) 11%, #14120d) 58%,
    #14120d 100%);
}

/* ---------------------------------------------------------------------------
   3. The wall
   ------------------------------------------------------------------------
   PRE-SPLIT, NEVER CLIPPED. The capsule is two halves butted together - square
   on the inner end, rounded on the outer - so the break is two elements moving
   apart rather than a mask, a clip-path or a canvas. A clipped wall has to
   re-rasterise its own shape on every frame of the fall; two halves are two
   composited transforms. */
.rux-wall {
  position: absolute;
  left: 50%;
  top: var(--wall-y);
  display: flex;
  width: min(86vw, 520px);
  height: var(--wall-t);
  margin-left: calc(min(86vw, 520px) / -2);
  margin-top: calc(var(--wall-t) / -2);
  --wall-c: var(--rank-from);
}
.rux-wall-new {
  top: var(--new-wall-y);
  --wall-c: var(--rank-to);
  opacity: 0;
}
.rux-half {
  flex: 1 1 50%;
  height: 100%;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--wall-c) 30%, #fff),
    var(--wall-c) 62%,
    color-mix(in srgb, var(--wall-c) 76%, #000));
  box-shadow: 0 6px 14px rgba(0, 0, 0, .5), inset 0 1.5px 0 rgba(255, 255, 255, .5);
}
.rux-half-l { border-radius: 999px 0 0 999px; transform-origin: 100% 50%; }
.rux-half-r { border-radius: 0 999px 999px 0; transform-origin: 0 50%; }

/* ---------------------------------------------------------------------------
   4. The pawn
   ------------------------------------------------------------------------
   An extruded disc - a lit face with a darker body under it, the same object
   the board and the app icon draw - and not a flat circle.

   Two elements, because the travel and the squash are two different
   transforms with two different origins: the outer carries the vertical
   travel, the inner squashes and stretches about its own feet. Composing both
   on one element means restating the travel in every keyframe of the squash. */
.rux-pawn {
  position: absolute;
  left: 50%;
  top: 0;
  width: var(--pawn);
  height: var(--pawn);
  margin-left: calc(var(--pawn) / -2);
  margin-top: calc(var(--pawn) / -2);
  transform: translate3d(0, var(--pawn-a), 0);
}
.rux-pawn > i {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  transform-origin: 50% 100%;
  background: radial-gradient(circle at 34% 30%, #ffffff, #e8e5c8 62%, #b7b499);
  box-shadow:
    0 calc(var(--pawn) * .11) calc(var(--pawn) * .26) rgba(0, 0, 0, .55),
    inset 0 calc(var(--pawn) * .11) 0 rgba(255, 255, 255, .45);
}

/* ---------------------------------------------------------------------------
   5. The impact, the break and the light
   ------------------------------------------------------------------------ */

/* A white radial bloom at the contact point. Opacity only - the gradient never
   changes, and a blur/box-shadow pulse here is the single most expensive thing
   this file could have done. */
.rux-flash {
  position: absolute;
  left: 50%;
  top: var(--wall-y);
  width: calc(var(--cell) * 6);
  height: calc(var(--cell) * 6);
  margin-left: calc(var(--cell) * -3);
  margin-top: calc(var(--cell) * -3);
  border-radius: 50%;
  opacity: 0;
  background: radial-gradient(circle at 50% 50%,
    rgba(255, 255, 255, .95) 0%,
    color-mix(in srgb, var(--rank-to) 60%, rgba(255, 255, 255, .7)) 22%,
    rgba(255, 255, 255, 0) 64%);
}

/* Three short strokes drawn out from the contact point, dashed on and then
   carried away with the halves. stroke-dashoffset is not a composited property
   but these are three paths of about 25 user units each, for 180ms, once. */
.rux-cracks {
  position: absolute;
  left: 50%;
  top: var(--wall-y);
  width: calc(var(--cell) * 4.4);
  height: calc(var(--cell) * 2.2);
  margin-left: calc(var(--cell) * -2.2);
  margin-top: calc(var(--cell) * -1.1);
  opacity: 0;
  overflow: visible;
}
.rux-cracks path {
  fill: none;
  stroke: rgba(255, 255, 255, .92);
  stroke-width: 3.2;
  stroke-linecap: round;
  stroke-dasharray: 44;
  stroke-dashoffset: 44;
}

/* The new rank's light, falling through the hole the pawn made. */
/* A soft cone rather than a rectangle: one radial gradient falls off on both
   axes at once, where a linear one would need a second layer or a mask to stop
   reading as a painted yellow box standing on the floor. */
.rux-beam {
  position: absolute;
  left: 50%;
  top: var(--wall-y);
  width: calc(var(--cell) * 5.2);
  height: calc(var(--h) - var(--wall-y));
  margin-left: calc(var(--cell) * -2.6);
  transform: scaleY(0);
  transform-origin: 50% 0;
  opacity: 0;
  background: radial-gradient(52% 86% at 50% 0%,
    color-mix(in srgb, var(--rank-to) 46%, transparent) 0%,
    color-mix(in srgb, var(--rank-to) 16%, transparent) 44%,
    transparent 76%);
}

.rux-debris { position: absolute; inset: 0; }
/* Fourteen of these, built and animated by js/rankup.js through WAAPI so each
   one gets its own arc. No will-change on them - fourteen promoted layers is
   how a phone runs out of texture memory halfway through a celebration. */
.rux-chip {
  position: absolute;
  left: 50%;
  top: var(--wall-y);
  width: max(5px, calc(var(--cell) * .17));
  height: max(5px, calc(var(--cell) * .17));
  border-radius: 2px;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--rank-from) 34%, #fff),
    var(--rank-from) 70%);
  opacity: 0;
}

/* ---------------------------------------------------------------------------
   6. The words
   ------------------------------------------------------------------------ */

.rux-card {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  /* A stacking context of its own, and above the stage. Without the z-index
     the scrim below - a negative-z pseudo-element - joins the HOST's stacking
     context, where negative children paint before .rux-stage does, and the
     floor covers the very thing that is meant to be darkening it. */
  z-index: 2;
  box-sizing: border-box;
  width: 100%;
  max-width: 620px;
  margin: 0 auto;
  padding: 0 max(22px, env(safe-area-inset-left))
           calc(34px + env(safe-area-inset-bottom))
           max(22px, env(safe-area-inset-right));
  text-align: left;
}
/* The floor runs under the words, so the words get the same scrim the ranks
   page frames its title with. Measured from the bottom, reaching about as far
   as the text does. */
.rux-card::before {
  content: '';
  position: absolute;
  /* Wider than the card, deliberately. The card is 620px at most and centred,
     so a scrim on its own box ends in two hard vertical edges across the floor
     on any wide screen - a dark rectangle with the words in it, which is the
     card this design does not have. The host is contain:strict, so the
     overspill is clipped rather than scrolled. */
  left: -50vw;
  right: -50vw;
  bottom: 0;
  top: -130px;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(to top,
    rgba(8, 7, 5, .95) 0%,
    rgba(8, 7, 5, .82) 34%,
    rgba(8, 7, 5, .3) 74%,
    rgba(8, 7, 5, 0) 100%);
}

.rux-kicker {
  margin: 0 0 10px;
  font-size: 11.5px;
  font-weight: 800;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--rank) 46%, #ffffff);
}

/* The rank left behind, struck through as the new one arrives. The line is a
   pseudo-element scaled in rather than a text-decoration fading up, because
   a decoration cannot be drawn on. */
.rux-from {
  position: relative;
  display: inline-block;
  margin: 0 0 4px;
  font-size: clamp(15px, 3.6vw, 19px);
  font-weight: 700;
  color: rgba(255, 255, 255, .62);
}
.rux-from::after {
  content: '';
  position: absolute;
  left: -2px;
  right: -2px;
  top: 52%;
  height: 2px;
  border-radius: 2px;
  background: rgba(255, 255, 255, .62);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

/* The .rank-name recipe from ranks-journey.css. */
.rux-rank {
  position: relative;
  isolation: isolate;
  margin: 0;
  font-size: clamp(38px, 9vw, 76px);
  font-weight: 800;
  letter-spacing: -.03em;
  line-height: .98;
  color: color-mix(in srgb, var(--rank) 30%, #ffffff);
  text-shadow: 0 2px 24px rgba(0, 0, 0, .55),
               0 0 46px color-mix(in srgb, var(--rank) 40%, transparent);
}
/* The idle breath, once the ceremony has settled. Opacity on one static
   gradient layer: a text-shadow that pulses re-rasterises the glyphs on every
   frame, forever, on a screen with no auto-dismiss. */
.rux-rank::after {
  content: '';
  position: absolute;
  inset: -34% -10%;
  z-index: -1;
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(58% 58% at 50% 50%,
    color-mix(in srgb, var(--rank) 34%, transparent), transparent 72%);
}

.rux-elo {
  margin: 12px 0 0;
  font-size: clamp(14px, 3.4vw, 18px);
  font-weight: 700;
  color: color-mix(in srgb, var(--rank) 40%, #ffffff);
  font-variant-numeric: tabular-nums;
}
.rux-elo-unit { opacity: .62; font-weight: 600; }

.rux-note {
  margin: 10px 0 0;
  font-size: clamp(13.5px, 3.2vw, 16px);
  line-height: 1.4;
  color: rgba(255, 255, 255, .72);
}
.rux-note[hidden] { display: none; }

/* ---- what the rank paid ---- */

.rux-unlock {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px 10px;
  margin-top: 14px;
  padding: 9px 13px;
  border-radius: 12px;
  background: color-mix(in srgb, var(--rank) 12%, rgba(0, 0, 0, .46));
  border: 1px solid color-mix(in srgb, var(--rank) 34%, transparent);
  font-size: clamp(13.5px, 3.2vw, 16px);
  color: rgba(255, 255, 255, .78);
}
.rux-unlock[hidden] { display: none; }
.rux-unlock-swatch { display: inline-flex; align-items: center; flex: none; }
.rux-unlock-txt b { color: color-mix(in srgb, var(--rank) 24%, #ffffff); font-weight: 800; }
/* The second item, for the two ranks that pay twice. A line, not a grid: a
   grid of swatches turns the one thing they earned into a shopping list. */
.rux-unlock-more {
  flex: 1 0 100%;
  font-size: .88em;
  opacity: .62;
}
.rux-unlock-more:empty { display: none; }

/* A board effect has no swatch in Settings to borrow the recipe from, so it
   gets the one thing it actually looks like: the trail the pawn leaves. */
.rux-fx {
  display: inline-flex;
  align-items: center;
  gap: 3px;
}
.rux-fx i {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--rank) 55%, #ffffff);
}
.rux-fx i:nth-child(1) { opacity: .35; }
.rux-fx i:nth-child(2) { opacity: .65; }
/* The board preview, at swatch size. Same two-tone square .theme-preview
   draws in Settings, built fresh here - never cloned out of the settings
   sheet, where a .uk-locked ancestor would have hidden it. */
.rux-theme {
  display: inline-block;
  width: 22px;
  height: 22px;
  border-radius: 5px;
  border: 2px solid var(--e);
  background: linear-gradient(135deg, var(--a) 0 50%, var(--b) 50% 100%);
  /* Neon's whole preview is near-black, frame included, and against this row
     it disappears. One hairline puts an edge on it without lightening it. */
  box-shadow: 0 0 0 1px rgba(255, 255, 255, .2);
}

/* ---- the way on ---- */

.rux-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 22px;
  /* Dead until the ceremony hands over. A button that is on screen and does
     nothing is worse than one that is not there yet. */
  pointer-events: none;
}
.rankup-screen.is-live .rux-actions { pointer-events: auto; }
.rux-actions .btn { flex: 1 1 180px; text-decoration: none; }

/* ---------------------------------------------------------------------------
   7. The promotion timeline
   ------------------------------------------------------------------------
   t=0 is the moment .is-run lands, 200ms after the backdrop starts fading up.
   Every delay below is milliseconds from there and matches the table in
   js/rankup.js - if you move one, move both.

   The pawn and the squash are ONE animation each covering the whole 1400ms
   rather than four chained ones, so the phases cannot drift apart and no
   animationend handler has to start the next step. */

@keyframes rux-travel {
  0%    { transform: translate3d(0, var(--pawn-a), 0); animation-timing-function: cubic-bezier(.4, 0, .6, 1); }
  35.7% { transform: translate3d(0, calc(var(--pawn-a) + var(--pawn) * .08), 0); animation-timing-function: cubic-bezier(.4, 0, .2, 1); }
  54.3% { transform: translate3d(0, var(--pawn-b), 0); }
  60.7% { transform: translate3d(0, var(--pawn-b), 0); animation-timing-function: cubic-bezier(.12, .72, .3, 1); }
  100%  { transform: translate3d(0, var(--pawn-c), 0); }
}
@keyframes rux-squash {
  0%    { transform: scale(1, 1); }
  35.7% { transform: scale(1.04, .88); }
  54.3% { transform: scale(.94, 1.12); }
  60.7% { transform: scale(.94, 1.12); }    /* the held frame: nothing moves */
  78%   { transform: scale(1.02, .97); }
  100%  { transform: scale(1, 1); }
}

/* The kick at 12% is the wall giving way, and after it ease-IN for the whole
   fall: these halves are falling away, not arriving.
   The kick has to OPEN A GAP, not just nudge. transform-origin is each half's
   INNER end, so the rotation pivots exactly where the two meet and contributes
   nothing to the parting - only the translate does. At 2% the centre was still
   shut 74ms after the impact while the pawn was already well above the wall,
   which reads as the pawn passing through solid material. */
@keyframes rux-break-l {
  0%   { transform: none; animation-timing-function: cubic-bezier(.2, .8, .3, 1); }
  12%  { transform: translate3d(-7%, -7%, 0) rotate(-4deg); animation-timing-function: cubic-bezier(.5, 0, 1, .5); }
  100% { transform: translate3d(-58vw, 34vh, 0) rotate(-26deg); opacity: 0; }
}
@keyframes rux-break-r {
  0%   { transform: none; animation-timing-function: cubic-bezier(.2, .8, .3, 1); }
  12%  { transform: translate3d(7%, -7%, 0) rotate(4deg); animation-timing-function: cubic-bezier(.5, 0, 1, .5); }
  100% { transform: translate3d(58vw, 34vh, 0) rotate(26deg); opacity: 0; }
}

@keyframes rux-flash  { 0% { opacity: 0; } 34% { opacity: .55; } 100% { opacity: 0; } }
@keyframes rux-crack  { 0% { opacity: 1; stroke-dashoffset: 44; } 100% { opacity: 1; stroke-dashoffset: 0; } }
@keyframes rux-crackout { 0% { opacity: 1; } 100% { opacity: 0; } }
@keyframes rux-fadein { from { opacity: 0; } to { opacity: 1; } }
@keyframes rux-beam   { 0% { transform: scaleY(0); opacity: 0; } 30% { opacity: 1; } 100% { transform: scaleY(1); opacity: .9; } }
@keyframes rux-newwall { 0% { opacity: 0; transform: scale(.55); } 100% { opacity: 1; transform: scale(1); } }
@keyframes rux-rise   { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes rux-strike { 0% { transform: scaleX(0); } 100% { transform: scaleX(1); } }
@keyframes rux-dim    { 0% { opacity: 1; } 100% { opacity: .42; } }
@keyframes rux-breathe { 0%, 100% { opacity: .22; } 50% { opacity: .85; } }

.rankup-screen.is-run .rux-pawn        { animation: rux-travel 1400ms linear both; }
.rankup-screen.is-run .rux-pawn > i    { animation: rux-squash 1400ms linear both; }
/* #rux-wall, NOT .rux-wall. The new wall carries both classes - it is the same
   object in the new rank's colour - so a selector on the class breaks the wall
   that is supposed to be re-forming, and the ceremony ends with nothing under
   the pawn at all. The id is the only thing that tells the two apart. */
.rankup-screen.is-run #rux-wall > .rux-half-l { animation: rux-break-l 620ms linear 850ms both; }
.rankup-screen.is-run #rux-wall > .rux-half-r { animation: rux-break-r 620ms linear 850ms both; }
.rankup-screen.is-run .rux-flash       { animation: rux-flash 260ms ease-out 760ms both; }
.rankup-screen.is-run .rux-cracks      { animation: rux-crackout 240ms ease-in 880ms both; }
.rankup-screen.is-run .rux-cracks path { animation: rux-crack 180ms ease-out 760ms both; }
.rankup-screen.is-run .rux-floor-to    { animation: rux-fadein 500ms ease 850ms both; }
.rankup-screen.is-run .rux-beam        { animation: rux-beam 400ms cubic-bezier(.2, .7, .3, 1) 850ms both; }
.rankup-screen.is-run .rux-wall-new    { animation: rux-newwall 340ms cubic-bezier(.34, 1.4, .5, 1) 1200ms both; }

.rankup-screen.is-run .rux-kicker      { animation: rux-rise 420ms cubic-bezier(.22, .61, .36, 1) 1330ms both; }
.rankup-screen.is-run .rux-rank        { animation: rux-rise 420ms cubic-bezier(.22, .61, .36, 1) 1400ms both; }
.rankup-screen.is-run .rux-from        { animation: rux-dim 420ms ease 1400ms both; }
.rankup-screen.is-run .rux-from::after { animation: rux-strike 300ms cubic-bezier(.4, 0, .2, 1) 1400ms both; }
.rankup-screen.is-run .rux-elo         { animation: rux-rise 380ms cubic-bezier(.22, .61, .36, 1) 1700ms both; }
.rankup-screen.is-run .rux-unlock      { animation: rux-rise 400ms cubic-bezier(.22, .61, .36, 1) 1900ms both; }
.rankup-screen.is-run .rux-note        { animation: rux-rise 400ms cubic-bezier(.22, .61, .36, 1) 1900ms both; }
.rankup-screen.is-run .rux-actions     { animation: rux-fadein 360ms ease 2300ms both; }

.rankup-screen.is-live .rux-rank::after { animation: rux-breathe 4.6s ease-in-out infinite; }

/* ---------------------------------------------------------------------------
   8. Demotion - the same stage, run backwards
   ------------------------------------------------------------------------
   The wall re-forms ABOVE the pawn and seals, the floor washes down from the
   rank that was held to the one below it, and the pawn settles. No crouch, no
   launch, no flash, no debris, no beam, no sound and no haptic: it states a
   fact and offers the way on, in about 1200ms. It is not a punishment beating,
   and nothing in here should read as one. */
/* Higher up the stage than the promotion's, because the pawn ENDS its travel
   here rather than starting it: at .68 it settled straight into the scrim the
   words sit on and arrived as a grey disc behind "Dropped a rank". */
.rankup-screen.is-down {
  --wall-y: calc(var(--h) * .30);
  --pawn-a: calc(var(--h) * .40);
  --pawn-c: calc(var(--h) * .52);
}
.rankup-screen.is-down .rux-wall-new,
.rankup-screen.is-down .rux-beam,
.rankup-screen.is-down .rux-flash,
.rankup-screen.is-down .rux-cracks,
.rankup-screen.is-down .rux-debris { display: none; }

@keyframes rux-seal-l {
  0%   { transform: translate3d(-34%, -16%, 0) rotate(-8deg); opacity: 0; }
  26%  { opacity: 1; }
  100% { transform: none; opacity: 1; }
}
@keyframes rux-seal-r {
  0%   { transform: translate3d(34%, -16%, 0) rotate(8deg); opacity: 0; }
  26%  { opacity: 1; }
  100% { transform: none; opacity: 1; }
}
@keyframes rux-settle {
  0%   { transform: translate3d(0, var(--pawn-a), 0); }
  100% { transform: translate3d(0, var(--pawn-c), 0); }
}
@keyframes rux-land {
  0%, 72% { transform: scale(1, 1); }
  86%     { transform: scale(1.06, .9); }
  100%    { transform: scale(1, 1); }
}

.rankup-screen.is-down.is-run .rux-floor-from,
.rankup-screen.is-down.is-run .rux-pawn,
.rankup-screen.is-down.is-run .rux-wall { animation: rux-fadein 200ms ease both; }
.rankup-screen.is-down.is-run #rux-wall > .rux-half-l { animation: rux-seal-l 420ms cubic-bezier(.3, .9, .3, 1) 180ms both; }
.rankup-screen.is-down.is-run #rux-wall > .rux-half-r { animation: rux-seal-r 420ms cubic-bezier(.3, .9, .3, 1) 180ms both; }
.rankup-screen.is-down.is-run .rux-floor-to { animation: rux-fadein 500ms ease 400ms both; }
.rankup-screen.is-down.is-run .rux-pawn     { animation: rux-settle 600ms cubic-bezier(.3, 0, .2, 1) 500ms both; }
.rankup-screen.is-down.is-run .rux-pawn > i { animation: rux-land 1100ms linear both; }

.rankup-screen.is-down.is-run .rux-kicker  { animation: rux-rise 300ms ease 700ms both; }
.rankup-screen.is-down.is-run .rux-rank    { animation: rux-rise 300ms ease 760ms both; }
.rankup-screen.is-down.is-run .rux-elo     { animation: rux-rise 300ms ease 820ms both; }
.rankup-screen.is-down.is-run .rux-note    { animation: rux-rise 300ms ease 880ms both; }
.rankup-screen.is-down.is-run .rux-actions { animation: rux-fadein 300ms ease 940ms both; }
/* No breath on a demotion: a glowing, pulsing rank name over "Dropped a rank"
   is the screen celebrating it. The kicker loses the rank colour for the same
   reason - the NAME keeps it, because that rank is simply where they are now
   and there is nothing shameful about the colour of it. */
.rankup-screen.is-down.is-live .rux-rank::after { animation: none; opacity: 0; }
.rankup-screen.is-down .rux-kicker { color: rgba(255, 255, 255, .56); }
.rankup-screen.is-down .rux-rank {
  text-shadow: 0 2px 24px rgba(0, 0, 0, .55),
               0 0 30px color-mix(in srgb, var(--rank) 18%, transparent);
}

/* ---------------------------------------------------------------------------
   9. Reduced motion - the wall still breaks
   ------------------------------------------------------------------------
   This is not a skip. Reduced motion is vestibular safety, not a quieter
   ceremony: the wall still breaks, the floor still changes colour, the sound
   and the haptics are untouched. What goes is travel, spin, flash, shake, the
   fourteen chips and the idle breath - and the text stagger, because a stagger
   is itself motion. About 1000ms, and every state change is a crossfade.

   Driven by a CLASS, not by the media query, so the one matchMedia check in
   js/rankup.js decides it - the same place main.js already gates confetti -
   and so the preview hook can exercise it. */
.rankup-screen.is-reduced { transition: none; }

@keyframes rux-cut-l {
  0%   { transform: none; opacity: 1; }
  48%  { transform: none; opacity: .12; }
  52%  { transform: translate3d(-12px, 0, 0) rotate(-6deg); opacity: .12; }
  100% { transform: translate3d(-12px, 0, 0) rotate(-6deg); opacity: 1; }
}
@keyframes rux-cut-r {
  0%   { transform: none; opacity: 1; }
  48%  { transform: none; opacity: .12; }
  52%  { transform: translate3d(12px, 0, 0) rotate(6deg); opacity: .12; }
  100% { transform: translate3d(12px, 0, 0) rotate(6deg); opacity: 1; }
}
@keyframes rux-cut-pawn {
  0%   { transform: translate3d(0, var(--pawn-a), 0); }
  100% { transform: translate3d(0, var(--pawn-c), 0); }
}

.rankup-screen.is-reduced.is-run .rux-stage    { animation: rux-fadein 200ms ease both; }
.rankup-screen.is-reduced.is-run .rux-pawn     { animation: rux-cut-pawn 1ms linear 500ms both; }
.rankup-screen.is-reduced.is-run .rux-pawn > i { animation: none; }
.rankup-screen.is-reduced.is-run #rux-wall > .rux-half-l { animation: rux-cut-l 180ms linear 260ms both; }
.rankup-screen.is-reduced.is-run #rux-wall > .rux-half-r { animation: rux-cut-r 180ms linear 260ms both; }
.rankup-screen.is-reduced.is-run .rux-floor-to { animation: rux-fadein 300ms ease 440ms both; }
.rankup-screen.is-reduced.is-run .rux-wall-new { animation: rux-fadein 200ms ease 500ms both; }
.rankup-screen.is-reduced.is-run .rux-beam,
.rankup-screen.is-reduced.is-run .rux-flash,
.rankup-screen.is-reduced.is-run .rux-cracks   { animation: none; opacity: 0; }
/* The paths too, not just their parent. A hidden parent still lets three
   stroke-dashoffset animations run for nothing. */
.rankup-screen.is-reduced.is-run .rux-cracks path { animation: none; }
.rankup-screen.is-reduced .rux-debris          { display: none; }

.rankup-screen.is-reduced.is-run .rux-kicker,
.rankup-screen.is-reduced.is-run .rux-from,
.rankup-screen.is-reduced.is-run .rux-rank,
.rankup-screen.is-reduced.is-run .rux-elo,
.rankup-screen.is-reduced.is-run .rux-unlock,
.rankup-screen.is-reduced.is-run .rux-note,
.rankup-screen.is-reduced.is-run .rux-actions  { animation: rux-fadein 300ms ease 700ms both; }
/* The strike is a state, not a draw: the line is simply there. */
.rankup-screen.is-reduced.is-run .rux-from::after { animation: none; transform: scaleX(1); }
.rankup-screen.is-reduced.is-live .rux-rank::after { animation: none; opacity: .4; }

/* Demotion under reduced motion: the halves are already sealed, the pawn is
   already down, everything fades up together. */
.rankup-screen.is-reduced.is-down.is-run #rux-wall > .rux-half-l,
.rankup-screen.is-reduced.is-down.is-run #rux-wall > .rux-half-r { animation: none; transform: none; }
.rankup-screen.is-reduced.is-down.is-run .rux-pawn { animation: rux-cut-pawn 1ms linear 400ms both; }
.rankup-screen.is-reduced.is-down.is-run .rux-floor-to { animation: rux-fadein 300ms ease 300ms both; }

/* ---------------------------------------------------------------------------
   10. Short and wide screens
   ------------------------------------------------------------------------ */

/* A phone turned sideways is 390px tall, and the words alone - kicker, old
   rank, name, ELO, unlock row and two 52px buttons - want most of that. So
   this is not a nudge of the wall position: the whole card is compressed to
   about 250px and the stage takes what is left at the top.

   --cell is measured off the HEIGHT here rather than the width. 8.6vw of a
   844px-wide landscape phone is a 73px tile on a screen with five rows of
   them, which is a wall of squares rather than a floor. */
@media (max-height: 620px) {
  .rankup-screen {
    --cell: clamp(28px, 8vh, 52px);
    --wall-t: max(9px, calc(var(--cell) * .26));
    --pawn: max(22px, calc(var(--cell) * .48));
    --wall-y: calc(var(--h) * .26);
    --pawn-a: calc(var(--h) * .46);
    --pawn-c: calc(var(--h) * .08);
    --new-wall-y: calc(var(--pawn-c) + var(--pawn) / 2 + var(--wall-t) / 2 + 7px);
  }
  .rankup-screen.is-down {
    --wall-y: calc(var(--h) * .12);
    --pawn-a: calc(var(--h) * .24);
    --pawn-c: calc(var(--h) * .38);
  }
  /* The scrim's reach is a fixed 130px above the card, which on a tall screen
     is a soft edge and on a short one is a third of the display. */
  .rux-card::before { top: -46px; }
  .rux-card { padding-bottom: calc(16px + env(safe-area-inset-bottom)); }
  .rux-kicker { margin-bottom: 4px; font-size: 10.5px; }
  .rux-from { font-size: 14px; margin-bottom: 1px; }
  .rux-rank { font-size: clamp(26px, 5.6vw, 42px); }
  .rux-elo { margin-top: 5px; font-size: 14px; }
  .rux-note { margin-top: 5px; font-size: 13px; }
  .rux-unlock { margin-top: 8px; padding: 6px 11px; font-size: 13.5px; }
  .rux-actions { margin-top: 12px; gap: 8px; }
  .rux-actions .btn.big { height: 46px; font-size: 15px; }
}

/* In the app the tab bar is hidden while an .overlay-screen is open, so there
   is nothing to clear - but the home indicator is still there. */
body.has-tabbar .rux-card { padding-bottom: calc(34px + env(safe-area-inset-bottom)); }

/* ---------------------------------------------------------------------------
   11. The preview readout
   ------------------------------------------------------------------------
   Only ever built by RankUp.preview(), which only runs behind the ?rankup=
   query param - so nothing paints this in production. It exists because a
   screenshot cannot tell a reclaim apart from a promotion by looking: the
   difference is which strings and which row are on screen, and reading the
   state back off the page beats inferring it from pixels. */
.rux-debug {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  padding: 4px 8px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: .04em;
  font-family: var(--font-mono, monospace);
  color: #0a0a0a;
  background: #7ee787;
  pointer-events: none;
}
