/* ===========================================================================
   The rank ladder: one floor, eight screens.
   ===========================================================================

   Companion to js/ranks-page.js.

   WHAT THIS IS
   A screen per rank. You scroll, one rank fills the display, it snaps, and
   the next one comes up. There is no card, no picture panel and no page
   furniture between you and the rank: the tiles ARE the page, the walls stand
   on them, and the rank's name sits in the lower third the way a title sits
   on a film frame.

   ONE FLOOR, NOT EIGHT BACKGROUNDS
   .ranks-floor is a single element behind every screen, carrying one tile
   grid and one colour wash for the whole document. That is the only way the
   tiles can be seamless: a background painted per screen restarts its pattern
   at each screen's top edge, and unless the screen height happens to be an
   exact multiple of the tile it leaves a half-height row and a flipped
   checker phase at every boundary - a seam across the page, seven times.
   With one element there is nothing to line up, because there is only ever
   one grid.

   The colour is a vertical gradient through that same element with a stop at
   the middle of each screen, so a rank's colour is pure at its own screen and
   washes into its neighbour's on the way there. Scrolling the ladder is
   walking across one floor that changes colour under you, rather than
   flicking through seven pictures of floors.

   WHY THE CELL IS COMPUTED IN JS
   The tile has to divide the screen height a whole number of times, or the
   walls - which are placed in cell units from the top of their screen - drift
   off the tile edges by a fraction of a cell more on every screen down the
   page. There is no CSS expression for "the largest tile under 78px that
   divides innerHeight exactly", so ranks-page.js measures once and publishes
   --cell, --sec-h and --rows. Everything here is written in terms of those.

   WHAT THIS REPLACED
   Cards on a line down the left, each with a picture in it. The line is gone
   (it was a progress bar for a page that is now paced by the screen), the
   cards are gone (a card is a box around a thing, and the thing is supposed
   to be the page), and the seven isometric scenes went with them.

   WHY ITS OWN FILE
   styles.css is 6,500 lines and is edited constantly and from more than one
   place at once. home-app.css was split out for exactly this reason and says
   so at the top. A page's worth of new rules is a page's worth of conflict
   surface; here it is none.
   ======================================================================== */

/* ---------------------------------------------------------------------------
   1. The page, and the snap
   ------------------------------------------------------------------------ */

/* Only ranks.html loads this sheet, so the document scroller can be set up
   here without reaching any other page. Mandatory is safe because every
   screen is exactly one viewport tall - there is no content that snapping
   could scroll past and strand. */
html.ranks-snap {
  scroll-snap-type: y mandatory;
  scroll-padding: 0;
  /* Except at the top, for the announcement bar. This page is the one place on
     the site where the bar could not simply be offset around: mandatory
     snapping ALIGNS a screen's start with the top of the snapport, so with a
     snapport starting at the viewport's top edge the browser actively pulls
     each screen up underneath the bar - undoing body's padding as fast as it is
     applied. The snapport has to start below the bar instead, and then each
     screen is one usable viewport rather than one innerHeight: see fit() in
     js/ranks-page.js, which measures the same amount off the height it divides
     so that the grid still comes out exact. 0px when there is no bar. */
  scroll-padding-top: var(--promo-h);
}

/* Full bleed. .profile-page's measure and padding are what make a form page
   readable; here they would put a margin around a floor that is supposed to
   run under everything. The padding comes back on .rank-sec-inner, where it
   belongs to the text rather than to the surface. */
.ranks-page {
  max-width: none;
  padding: 0;
}
.ranks-body { background: #17150f; }

.ranks-stage { position: relative; }

/* ---------------------------------------------------------------------------
   2. The floor
   ------------------------------------------------------------------------
   Four background layers, bottom to top:

     4. the tile bevel, vertical    a light left edge and a dark right edge
     3. the tile bevel, horizontal  a light top edge and a dark bottom edge
     2. the checker                 alternate tiles darkened / lifted
     1. the wash                    the rank colours down the page (from JS)

   The tiles butt flush - there is no groove, no gap and no outline anywhere
   in here. What makes them read as chunky objects rather than as a flat
   checkerboard is the two bevel layers: every cell gets a soft lit edge on
   the side the light comes from and a soft shaded edge opposite, which is how
   a toy block looks and how a drawn line does not. */

.ranks-floor {
  position: absolute;
  inset: 0;
  z-index: 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%),
    conic-gradient(
      rgba(0, 0, 0, .24) 0 25%, rgba(255, 255, 255, .07) 0 50%,
      rgba(0, 0, 0, .24) 0 75%, rgba(255, 255, 255, .07) 0),
    var(--floor-wash, linear-gradient(180deg, #1b1912, #1b1912));
  background-size:
    var(--cell, 64px) var(--cell, 64px),
    var(--cell, 64px) var(--cell, 64px),
    calc(var(--cell, 64px) * 2) calc(var(--cell, 64px) * 2),
    100% 100%;
  /* --xpad is the sub-cell remainder of centring the board across the screen,
     and it is FRACTIONAL on purpose. The warning at the top of this file about
     whole cells is about the VERTICAL phase, where a fraction accumulates from
     screen to screen down the page. This is horizontal: one constant for the
     whole document, applied identically here and to every wall and pawn, with
     nothing below it to drift against. See fit() in js/ranks-page.js. */
  background-position: var(--xpad, 0) 0;
}

/* ---------------------------------------------------------------------------
   3. A screen
   ------------------------------------------------------------------------ */

.rank-sec {
  position: relative;
  z-index: 1;
  /* --sec-h is window.innerHeight to the pixel, published by ranks-page.js.
     dvh would be close, but "close" is exactly what cannot be tolerated: the
     cell is innerHeight/rows, so a screen that is not innerHeight tall puts
     every wall below it off the grid. */
  height: var(--sec-h, 100dvh);
  display: flex;
  align-items: flex-end;
  overflow: hidden;
  scroll-snap-align: start;
}

/* The text sits in the lower third, so the scrim is measured from the bottom
   and reaches about that far. Above it the floor is untouched. */
.rank-sec::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 62%;
  background: linear-gradient(to top,
    rgba(8, 7, 5, .93) 0%,
    rgba(8, 7, 5, .78) 26%,
    rgba(8, 7, 5, .34) 62%,
    rgba(8, 7, 5, 0) 100%);
  pointer-events: none;
}

/* ...and the top of the next screen picks up at exactly the value the bottom
   of the last one ended on, then clears within a sixth of the display.

   The tiles line up across a boundary perfectly - that is what the whole
   measured-cell business is for - but the SHADING did not: the scrim reaches
   .93 at a screen's bottom edge and the screen below it started at nothing, so
   mid-scroll there was a hard dark-to-light line straight across the page at
   every join. Meeting at the same opacity makes the shading continuous too,
   and it frames each screen top and bottom with the walls in the light
   between, which is what a title frame does anyway. */
.rank-sec::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 18%;
  background: linear-gradient(to bottom,
    rgba(8, 7, 5, .93) 0%,
    rgba(8, 7, 5, .34) 34%,
    rgba(8, 7, 5, 0) 100%);
  pointer-events: none;
}

.rank-sec-body,
.rank-sec-inner {
  position: relative;
  /* Above both scrims. ::after is inserted as the section's LAST child, so
     without this the top vignette would paint over the words on any screen
     short enough for the two to meet. */
  z-index: 2;
  width: 100%;
  max-width: 760px;
  margin: 0 auto;
  padding: 0 max(22px, env(safe-area-inset-left))
           calc(38px + env(safe-area-inset-bottom))
           max(22px, env(safe-area-inset-right));
  box-sizing: border-box;
}

/* In the app the tab bar is fixed over the bottom of every screen. The floor
   is meant to run under it - that is the point of a floor - but the words are
   not. --tabbar-core-h is measured and republished by js/tabbar.js. */
body.has-tabbar .rank-sec-body,
body.has-tabbar .rank-sec-inner {
  padding-bottom: calc(var(--tabbar-core-h, 52px) + env(safe-area-inset-bottom) + 26px);
}

/* Each screen fades its text and its walls up as it arrives. The section is
   already the size of the display, so there is no "coming onto the screen"
   to catch - ranks-page.js sets .is-in from an IntersectionObserver at half
   coverage, which is the moment the screen becomes the one being looked at. */
.rank-sec-body {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity .5s ease, transform .5s cubic-bezier(.22, .61, .36, 1);
}
.rank-sec.is-in .rank-sec-body { opacity: 1; transform: none; }

/* ---- the words ---- */

.rank-step {
  display: inline-flex;
  align-items: center;
  gap: 1px;
  margin-bottom: 14px;
  font-size: 11.5px;
  font-weight: 800;
  letter-spacing: .1em;
  color: color-mix(in srgb, var(--rank) 60%, #ffffff);
  background: rgba(0, 0, 0, .42);
  border: 1px solid color-mix(in srgb, var(--rank) 38%, transparent);
  border-radius: 999px;
  padding: 5px 12px;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.rank-step-of { opacity: .55; }

.rank-name {
  /* An <h2>, so it arrives with the stylesheet's heading margins on it. */
  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);
}

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

/* The status chip: held / where you are / how far off it is. The only text on
   the page that changes with who is looking at it. */
.rank-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-top: 18px;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: .05em;
  text-transform: uppercase;
  padding: 8px 14px;
  border-radius: 999px;
  line-height: 1;
  white-space: nowrap;
  color: rgba(255, 255, 255, .74);
  background: rgba(0, 0, 0, .4);
  border: 1px solid rgba(255, 255, 255, .16);
}
.rank-chip svg { display: block; }
.rank-chip-earned {
  color: color-mix(in srgb, var(--rank) 40%, #ffffff);
  background: color-mix(in srgb, var(--rank) 22%, rgba(0, 0, 0, .4));
  border-color: color-mix(in srgb, var(--rank) 55%, transparent);
}
.rank-chip-here {
  color: #0d0d0d;
  background: var(--rank);
  border-color: transparent;
  box-shadow: 0 0 26px color-mix(in srgb, var(--rank) 60%, transparent);
}
/* The locked chip carries a number, so it is the one chip that is not shouted
   in caps - "180 ELO away" is a fact, not a label. */
.rank-chip-locked {
  text-transform: none;
  letter-spacing: 0;
  font-weight: 700;
  font-size: 13px;
}

/* What the rank pays out. A muted second LINE rather than a second chip: the
   chip is already the loudest thing in the text block and two of them beside
   each other would be a toolbar.

   On a rank the player has reached this names the items. On one they have not -
   and for a signed-out visitor, who is treated the same way on purpose - it
   names the category and nothing else. See unlocksHtml() in js/ranks-page.js
   and rule 2 in js/unlocks.js. */
.rank-unlocks {
  margin-top: 10px;
  font-size: clamp(11.5px, calc(var(--cell, 64px) * .19), 13.5px);
  font-weight: 600;
  letter-spacing: .01em;
  color: rgba(255, 255, 255, .42);
}
.rank-sec.is-locked .rank-unlocks { color: rgba(255, 255, 255, .34); }

/* ---------------------------------------------------------------------------
   4. The walls
   ------------------------------------------------------------------------
   Rounded, chunky, no outline: a wall is a fat capsule the thickness of a
   quarter of a tile, lying in the join between two tiles and casting a soft
   shadow onto them. Two cells long, because that is how long a wall is.

   --wc and --wr are its column and row on the tile grid; --woff and --hoff are
   the whole-cell offsets that centre the board in the screen (both from
   ranks-page.js, both integers so nothing lands off the grid vertically);
   --xpad is the sub-cell remainder of the horizontal centring, which is
   fractional and safe - see the note on .ranks-floor; and --wn is how many
   walls were placed before it. */

.rank-walls {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}
.rank-wall {
  position: absolute;
  left: calc((var(--wc) + var(--woff, 0)) * var(--cell, 64px) + var(--xpad, 0px));
  top: calc((var(--wr) + var(--hoff, 0)) * var(--cell, 64px));
  width: calc(var(--cell, 64px) * 2);
  height: var(--wall-t);
  margin-top: calc(var(--wall-t) / -2);
  border-radius: 999px;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--rank) 30%, #ffffff),
    var(--rank) 62%,
    color-mix(in srgb, var(--rank) 76%, #000000));
  box-shadow:
    0 calc(var(--cell, 64px) * .07) calc(var(--cell, 64px) * .16) rgba(0, 0, 0, .5),
    inset 0 1.5px 0 rgba(255, 255, 255, .5);
  /* Each wall drops in as its screen arrives, in the order they were placed,
     so the last one to land is the one this rank is for. */
  opacity: 0;
  transform: scale(.55);
  transition: opacity .34s ease, transform .34s cubic-bezier(.34, 1.4, .5, 1);
  transition-delay: calc(var(--wn) * .075s + .12s);
}
.rank-wall.is-v {
  width: var(--wall-t);
  height: calc(var(--cell, 64px) * 2);
  margin-top: 0;
  margin-left: calc(var(--wall-t) / -2);
}
.rank-sec.is-in .rank-wall { opacity: 1; transform: none; }

/* The opponent's walls. Four of the twelve are yours and eight are theirs, and
   without the distinction the board is a heap of bars rather than a game with
   two people in it.

   This REDEFINES --rank on the element instead of overriding `background`,
   which is the whole trick: the gradient above, the box-shadow, and the
   grayscale a locked screen puts over the lot all keep working exactly as
   written and none of them has to know that a wall has an owner.

   Bone rather than the opponent's blue (#4a90d9): against Strategist (#00BFFF)
   and Grand Architect (#00E5A8) blue-on-blue is mud, and the bone matches the
   pawn that builds them.

   The mix is computed one level up, on .rank-walls, and only HANDED to the
   wall here. Writing the obvious one-liner on the wall itself -

       .rank-wall.is-foe { --rank: color-mix(in srgb, var(--rank) 18%, #cdc6b4); }

   - looks right and paints nothing at all: a custom property whose own value
   reads itself on the SAME element is a dependency cycle, so --rank resolves
   to the guaranteed-invalid value, every var(--rank) below it becomes invalid
   at computed-value time, and `background` falls all the way back to
   transparent. What is left on screen is the drop shadow and the inset
   highlight of an empty box - a hairline where a wall should be. Two steps is
   what breaks the cycle: --rank-foe is computed on the parent, where --rank
   means the rank colour and nothing is referring to itself. */
.rank-walls { --rank-foe: color-mix(in srgb, var(--rank) 18%, #cdc6b4); }
.rank-wall.is-foe { --rank: var(--rank-foe); }

/* The floor is 7px rather than 9px because the board is nine columns wide now:
   on a 320-360px phone the cell falls to about 33px, and a 9px wall would be
   27% of a tile - a fat bar - exactly where a ninth column was added. */
.rank-walls { --wall-t: max(7px, calc(var(--cell, 64px) * .24)); }

/* ---- the crossings --------------------------------------------------------
   Two pawns and the routes between them, on the same grid the walls stand on:
   yours running left to right, the opponent's running right to left, a few
   rows apart so the two are separable at a glance.

   This is what the screens were missing. Walls appearing on an empty floor say
   nothing about a game; the same walls with two pawns working around them say
   exactly what the rank is for, and both routes lengthen down the ladder (see
   the table above WALLS in js/ranks-page.js).

   Positioned on cell CENTRES rather than on the grid lines the walls use -
   hence the +.5 - and centred with a translate so the drop-in can scale from
   the middle. The inner <i> carries the arrival so the outer is free to carry
   the walk. */
.rank-cross { position: absolute; inset: 0; z-index: 1; pointer-events: none; }

.rank-pawn,
.rank-step-dot {
  position: absolute;
  left: calc((var(--pc) + .5 + var(--woff, 0)) * var(--cell, 64px) + var(--xpad, 0px));
  top: calc((var(--pr) + .5 + var(--hoff, 0)) * var(--cell, 64px));
  transform: translate(-50%, -50%);
}

/* THE WALK.
   The pawn is parked at its start cell by the left/top above; the movement is
   a transform on top of it, from one @keyframes rule per pawn per screen that
   js/ranks-page.js generates once into a stylesheet of its own. The offsets
   inside those rules are in var(--cell), so a resize moves the walk for free
   and nothing is ever regenerated, and there is no per-frame JavaScript on
   this page at all.

   PAUSED IS THE DEFAULT, and that is the important line in this block. The
   .is-in flag the rest of the page hangs off is one-shot by design - the
   observer that sets it unobserves immediately - so anything looping attached
   to it would run on all eight screens for the rest of the session, in a
   background tab included. .is-active is the other observer: it never
   unobserves and it means "this is the screen being looked at". See gate().

   Toggling play-state rather than animation-name or display is deliberate:
   taking the animation off and putting it back restarts it from frame 0 and
   costs a style recalc on the way in, where pausing keeps the layer warm. */
.rank-cross .rank-pawn {
  animation-name: var(--walk);
  animation-duration: var(--walk-ms, 3s);
  animation-delay: var(--walk-delay, 1.4s);
  animation-timing-function: cubic-bezier(.34, 1.18, .5, 1);
  animation-iteration-count: infinite;
  animation-play-state: paused;              /* THE GATE. default off. */
}
.rank-sec.is-active .rank-cross .rank-pawn {
  animation-play-state: running;
  will-change: transform;                    /* only while it is moving */
}

/* A tab nobody is looking at still reports its sections as on screen, and it
   is the one place where a looping animation is pure cost. */
html.ranks-hidden .rank-pawn,
html.ranks-hidden .ranks-scroll-cue { animation-play-state: paused !important; }

/* An extruded disc, the same object the board and the app icon draw: a lit
   face with a darker body under it, not a flat circle. */
.rank-pawn > i {
  display: block;
  width: calc(var(--cell, 64px) * .46);
  height: calc(var(--cell, 64px) * .46);
  border-radius: 50%;
  box-shadow:
    0 calc(var(--cell, 64px) * .05) calc(var(--cell, 64px) * .12) rgba(0, 0, 0, .55),
    inset 0 calc(var(--cell, 64px) * .05) 0 rgba(255, 255, 255, .45);
  opacity: 0;
  transform: scale(.5);
  transition: opacity .3s ease, transform .34s cubic-bezier(.34, 1.4, .5, 1);
}
.rank-pawn.is-p1 > i {
  background: radial-gradient(circle at 34% 30%, #ffffff, #e8e5c8 62%, #b7b499);
}
.rank-pawn.is-p2 > i {
  background: radial-gradient(circle at 34% 30%, #8fbdf3, #4a90d9 62%, #2d6aa4);
}
.rank-sec.is-in .rank-pawn > i { opacity: 1; transform: none; }

/* The routes. Dots rather than a line: a line has to be drawn as one path and
   would need its own SVG on a grid that is already measured in cells, and the
   dots carry the walk better - they arrive one after another.

   They stay a one-shot transition and are deliberately NOT animated on a loop.
   Seventeen dots on eight screens is 136 elements; the budget for this page is
   two moving things, and they are the pawns. */
.rank-step-dot {
  width: calc(var(--cell, 64px) * .17);
  height: calc(var(--cell, 64px) * .17);
  border-radius: 50%;
  background: color-mix(in srgb, var(--rank) 55%, #ffffff);
  box-shadow: 0 0 calc(var(--cell, 64px) * .18)
              color-mix(in srgb, var(--rank) 70%, transparent);
  opacity: 0;
  transition: opacity .26s ease;
  /* After the walls have finished landing: they are what changed the route,
     so the route re-draws itself once they are standing. The lead was .55s
     when there were seven walls to land; there are twelve now, so the last of
     them is still dropping at that point. */
  transition-delay: calc(var(--dn) * .035s + .78s);
}
.rank-sec.is-in .rank-step-dot { opacity: .85; }

/* The opponent's route, dimmer and in their pawn's colour. Two trails in one
   ink on one board is a tangle; the point of the second crossing is that you
   can tell whose it is. */
.rank-step-dot.is-d2 {
  background: color-mix(in srgb, #4a90d9 58%, #ffffff);
  box-shadow: 0 0 calc(var(--cell, 64px) * .16)
              color-mix(in srgb, #4a90d9 60%, transparent);
}
.rank-sec.is-in .rank-step-dot.is-d2 { opacity: .55; }

/* The step count under the flavour line. */
.rank-route {
  margin-top: calc(var(--cell, 64px) * .10);
  font-size: clamp(12px, calc(var(--cell, 64px) * .21), 15px);
  font-weight: 600;
  color: var(--color-text-muted);
}
.rank-route-n {
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--rank) 72%, #ffffff);
}
/* Dimmer than the earned screens, not brighter. This line used to lift the
   step count to full white on a locked screen, which fought "locked" directly
   and fought it harder the further up the ladder the lock-out went. */
.rank-sec.is-locked .rank-route-n { color: rgba(255, 255, 255, .62); }

@media (prefers-reduced-motion: reduce) {
  .rank-pawn > i,
  .rank-step-dot { transition: none; }
  .rank-cross .rank-pawn { animation: none; }
}

/* THE BIG LOCK. On a rank you have not reached, this is the screen's subject:
   it sits over the middle of the board, at the size the board would otherwise
   occupy, and the greyed walls read as its backdrop.

   Sized from --cell - the measured tile that ranks-page.js publishes - for the
   same reason every other length on this page is: one measurement moves the
   whole screen, and a lock in px would drift off the floor at other heights.
   The box is the play area exactly (--play-rows, also published), so the lock
   is centred on the board rather than on a hard-coded guess at its height.

   It is NOT centred on the section: the lower third carries the name, the ELO
   and the chip, so the lock takes the open upper part and `bottom` is left to
   the text. Pointer-events off - there is nothing to click, and the page
   scroll-snaps through it.

   The fallback cell is 64px, the same one the walls use. Two different
   fallbacks for the same measurement is harmless right up until --cell is
   missing for a frame, at which point the lock and the walls disagree about
   how big a tile is. */
.rank-lock {
  position: absolute;
  left: 0; right: 0;
  top: calc(var(--hoff, 0) * var(--cell, 64px));
  height: calc(var(--cell, 64px) * var(--play-rows, 6));
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
  z-index: 3;                 /* over the walls and the crossing, under nothing */
}

/* The dim under it. A radial gradient rather than a blur or a backdrop-filter:
   six locked screens stacked in one scroller on a phone is exactly the place
   where either of those costs real frames, and a radial dim gets most of the
   effect for none of it. Hung here because .rank-lock already spans the play
   area, already sits above the walls, and already has pointer-events off - so
   nothing new has to be positioned. */
.rank-lock::before {
  content: '';
  position: absolute;
  inset: calc(var(--cell, 64px) * -.5) 0;
  background: radial-gradient(58% 70% at 50% 46%,
    var(--lock-veil) 0%,
    color-mix(in srgb, var(--lock-veil) 55%, transparent) 46%,
    transparent 78%);
  pointer-events: none;
}

/* CAST METAL, not a big thin icon. At 4.6 cells a 1.5-weight outline reads as
   a glyph floating in front of the board; the premise of this page is that the
   tiles ARE the page, so the lock is built the way .rank-wall is - a filled
   body with a gradient down it, a lit top edge, a thick shackle and a keyhole
   cut out with a mask - and the drop shadow is what stands it on the floor.

   The --sec-h term is a guard, not decoration. .rank-lock is at z-index 3,
   ABOVE .rank-sec-body's 2, so on a short screen a lock sized purely off the
   cell would grow straight over the rank's name. */
.rank-lock svg {
  width:  min(calc(var(--cell, 64px) * 4.6), calc(var(--sec-h, 100vh) * .30));
  height: min(calc(var(--cell, 64px) * 4.6), calc(var(--sec-h, 100vh) * .30));
  color: var(--lock-ink, rgba(238, 234, 226, .34));
  filter: drop-shadow(0 calc(var(--cell, 64px) * .13) calc(var(--cell, 64px) * .18) rgba(0, 0, 0, .62))
          drop-shadow(0 calc(var(--cell, 64px) * .02) 0 rgba(255, 255, 255, .07));
}

/* It arrives with the rest of the screen: the walls drop in, the lock fades up
   over them. Same timing vocabulary as .rank-wall so nothing races. */
.rank-sec .rank-lock { opacity: 0; transform: translateY(10px) scale(.97); }
.rank-sec.is-in .rank-lock {
  opacity: 1; transform: none;
  transition: opacity .5s ease .34s, transform .5s cubic-bezier(.2, .7, .3, 1) .34s;
}

@media (prefers-reduced-motion: reduce) {
  .rank-sec .rank-lock { opacity: 1; transform: none; transition: none; }
}

/* LOCKED. The wall is built in the rank's colour and then desaturated in CSS
   rather than being drawn grey, so the moment a rank is earned the very same
   wall in the very same place simply comes into colour. The floor under it is
   already dark by then - the wash that ranks-page.js builds takes every rank
   above your standing further into the ink, and further the higher it is.

   Two custom properties carry the weight of the lock-out so the three ramp
   classes below only have to restate two numbers each. */
.rank-sec.is-locked {
  --lock-ink:  rgba(238, 234, 226, .34);
  --lock-veil: rgba(10, 9, 7, .62);
}
.rank-sec.is-locked .rank-wall { filter: grayscale(1) brightness(.46) contrast(.88); }
/* The crossing goes further than the walls: a lit route through a dead board
   is the one thing that would still read as "playable". */
.rank-sec.is-locked .rank-cross { filter: grayscale(1) brightness(.48); opacity: .26; }

.rank-sec.is-locked .rank-name {
  color: rgba(255, 255, 255, .50);
  text-shadow: 0 2px 24px rgba(0, 0, 0, .55);
}
.rank-sec.is-locked .rank-elo { color: rgba(255, 255, 255, .42); }
.rank-sec.is-locked .rank-step {
  color: rgba(255, 255, 255, .38);
  background: rgba(0, 0, 0, .52);
  border-color: rgba(255, 255, 255, .12);
}

/* ...and how hard, which is not one setting. The next rank up is the thing
   being reached for and is worth advertising; the top of the ladder should be
   barely imaginable. Flat grey for all six would make a new player's page six
   identical screens, which is a worse advertisement than the page this
   replaced. ranks-page.js emits one of these three per locked screen and the
   floor wash under them ramps on exactly the same curve.

   Discrete classes rather than one number in a filter fed by a custom
   property: an unregistered property cannot be typed or interpolated, this
   file has no @property in it, and a three-step ramp is not a reason to
   introduce one.

   The NAME never goes below .50 at any weight. Grey the board, grey the
   numbers - the word is the whole advertisement and it stays legible. */
.rank-sec.is-locked-near {
  --lock-ink:  rgba(238, 234, 226, .40);
  --lock-veil: rgba(10, 9, 7, .50);
}
.rank-sec.is-locked-near .rank-wall { filter: grayscale(1) brightness(.56) contrast(.9); }
.rank-sec.is-locked-near .rank-cross { opacity: .34; }
.rank-sec.is-locked-near .rank-name { color: rgba(255, 255, 255, .58); }
.rank-sec.is-locked-near .rank-elo { color: rgba(255, 255, 255, .48); }
.rank-sec.is-locked-near .rank-unlocks { color: rgba(255, 255, 255, .40); }

.rank-sec.is-locked-far {
  --lock-ink:  rgba(238, 234, 226, .27);
  --lock-veil: rgba(10, 9, 7, .74);
}
.rank-sec.is-locked-far .rank-wall { filter: grayscale(1) brightness(.38) contrast(.86); }
.rank-sec.is-locked-far .rank-cross { opacity: .20; }
.rank-sec.is-locked-far .rank-name { color: rgba(255, 255, 255, .50); }
.rank-sec.is-locked-far .rank-elo { color: rgba(255, 255, 255, .38); }
.rank-sec.is-locked-far .rank-unlocks { color: rgba(255, 255, 255, .30); }

/* HERE. The player's own rank, and the only screen on the page with its
   colour at full strength. */
.rank-sec.is-here .rank-name {
  color: color-mix(in srgb, var(--rank) 22%, #ffffff);
  text-shadow: 0 2px 24px rgba(0, 0, 0, .5),
               0 0 60px color-mix(in srgb, var(--rank) 70%, transparent);
}

/* ---------------------------------------------------------------------------
   5. The opening screen
   ------------------------------------------------------------------------ */

/* Three rows: the topbar, the middle, the cue. The middle takes whatever is
   left and centres itself in it.

   Flow, not absolute positioning. The first attempt hung the topbar off
   top: 20px - which resolved against .rank-sec-inner, a box that is itself
   centred in the screen, so the brand landed 240px down, straight on top of
   the word "Ranks". Nothing here can overlap anything else now because
   nothing here is out of flow. */
.rank-intro { align-items: stretch; }
.rank-intro .rank-sec-inner {
  display: flex;
  flex-direction: column;
  height: 100%;
  padding-top: max(20px, env(safe-area-inset-top));
}
.rank-intro .profile-topbar { flex: none; }
.ranks-intro-mid {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.ranks-head {
  text-align: center;
  margin: 0 0 22px;
}
.ranks-eyebrow {
  display: block;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, .5);
  margin-bottom: 7px;
}
.ranks-title {
  margin: 0;
  font-size: clamp(34px, 8vw, 52px);
  font-weight: 800;
  letter-spacing: -.03em;
  line-height: 1.05;
  color: var(--text);
}

/* ---- the pyramid ---- */

/* The ladder's shape: seven courses, Scout the widest at the bottom,
   narrowing to a POINT at Grand Architect.

   This was a 132x3px strip carrying the seven colours as a gradient. A
   horizontal strip is a spectrum - it says the colours exist and what order
   they come in, which is the one thing about a ladder nobody needs telling. A
   pyramid says the part worth knowing: each rank is narrower than the one
   holding it up, and there is less room the further up you go.

   Solid, with every colour filled in and no gap between the courses, so it
   reads as one object rather than as a stack of seven bars. Built from
   Tiers.RANKS by ranks-page.js, so it cannot drift from the ladder it is
   summarising, and each course is clipped to the slope at its own height -
   which is what lets the two sides run as one unbroken line from the base to
   the point. */
.ranks-pyramid {
  width: min(232px, 62vw);
  margin: 18px auto 0;
  display: flex;
  flex-direction: column;
  filter: drop-shadow(0 6px 18px rgba(0, 0, 0, .45));
}
.pyr-course {
  height: 13px;
  /* --pt and --pb are the half-widths of this course's top and bottom edges,
     as percentages of the base. The apex course has --pt: 0%, which is what
     makes it a triangle rather than one more trapezoid. */
  clip-path: polygon(calc(50% - var(--pt)) 0, calc(50% + var(--pt)) 0,
                     calc(50% + var(--pb)) 100%, calc(50% - var(--pb)) 100%);
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--rank) 88%, #000000),
    var(--rank) 42%,
    color-mix(in srgb, var(--rank) 82%, #000000));
}
/* Your own course, lit. No gap to set it apart, so it is brightness that
   does it - and a drop-shadow filter rather than a box-shadow, because
   clip-path clips a box-shadow away with the corners. */
.pyr-course.is-here {
  background: linear-gradient(90deg,
    var(--rank), color-mix(in srgb, var(--rank) 42%, #ffffff) 50%, var(--rank));
  filter: drop-shadow(0 0 9px color-mix(in srgb, var(--rank) 85%, transparent));
}

/* ---- "you are here" ---- */

.rank-hero {
  position: relative;
  overflow: hidden;
  margin: 0 auto;
  max-width: 460px;
  padding: 17px 18px 18px;
  border-radius: 18px;
  background:
    radial-gradient(120% 140% at 12% 0%,
      color-mix(in srgb, var(--rank, var(--color-accent)) 26%, transparent), transparent 62%),
    rgba(14, 13, 10, .72);
  border: 1px solid color-mix(in srgb, var(--rank, var(--color-accent)) 38%, var(--line));
  box-shadow: 0 12px 34px rgba(0, 0, 0, .45);
}
.rank-hero[hidden] { display: none; }

.rank-hero-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 13px;
}
.rank-hero-label {
  font-size: 11px;
  font-weight: 800;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, .55);
  display: block;
  margin-bottom: 3px;
}
.rank-hero-name {
  font-size: 23px;
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -.01em;
  color: color-mix(in srgb, var(--rank) 26%, #ffffff);
  text-shadow: 0 0 16px color-mix(in srgb, var(--rank) 45%, transparent);
}
.rank-hero-elo {
  font-size: 25px;
  font-weight: 800;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
.rank-hero-elo-unit { font-size: 12px; font-weight: 700; color: rgba(255, 255, 255, .55); }

.rank-hero-bar {
  height: 9px;
  border-radius: 999px;
  overflow: hidden;
  background: rgba(255, 255, 255, .09);
  border: 1px solid color-mix(in srgb, var(--rank) 26%, transparent);
}
.rank-hero-fill {
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--rank) 60%, #ffffff), var(--rank));
  box-shadow: 0 0 14px color-mix(in srgb, var(--rank) 65%, transparent);
  transition: width .9s cubic-bezier(.22, .61, .36, 1);
}
.rank-hero-next {
  margin-top: 10px;
  font-size: 13px;
  color: rgba(255, 255, 255, .62);
}
.rank-hero-next b { color: var(--text); font-variant-numeric: tabular-nums; }
.rank-hero-nextname { font-weight: 800; color: color-mix(in srgb, var(--rank-next) 30%, #ffffff); }
.rank-hero-max { color: var(--accent-strong); font-weight: 700; }

/* Guest: the same box, but there is no standing to show, so it asks for one. */
.rank-hero-guest {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
  --rank: var(--color-accent);
}
.rank-hero-guest .rank-hero-msg {
  font-size: 14.5px;
  font-weight: 600;
  color: var(--text);
  flex: 1 1 190px;
}

/* The one thing the opening screen has to say that the design does not: that
   there is more below it. Mandatory snap means a screen shows no sliver of
   the next one, so nothing else on the page hints that it scrolls. */
.ranks-scroll-cue {
  flex: none;
  padding-top: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, .42);
  /* Gated the same way the pawns are, and for the same reason: this is an
     infinite animation on the FIRST screen, so left ungated it goes on
     bobbing for the whole session eight screens further down the page and in
     a tab nobody is looking at. */
  animation: ranks-cue 2.4s ease-in-out infinite;
  animation-play-state: paused;
}
.rank-sec.is-active .ranks-scroll-cue { animation-play-state: running; }
@keyframes ranks-cue {
  0%, 100% { transform: translateY(0); opacity: .42; }
  50%      { transform: translateY(5px); opacity: .78; }
}

/* ---------------------------------------------------------------------------
   6. Short screens, and people who do not want the motion
   ------------------------------------------------------------------------ */

/* Under about 620px of height the opening screen has the topbar, a title, a
   pyramid, the standing panel and a scroll cue to fit into one display. The
   pyramid is the piece that can lose height without losing its meaning. */
@media (max-height: 620px) {
  .pyr-course { height: 9px; }
  .ranks-pyramid { margin-top: 12px; }
  .ranks-head { margin-bottom: 14px; }
  .rank-hero { padding: 13px 15px 14px; }
  .rank-hero-name { font-size: 20px; }
}

/* Landscape on a phone. There is no vertical room to put a six-row board and
   a text block on one 320px-tall screen: --hoff comes out 0, the board starts
   under the top vignette and the words overlap its lower rows. The trail is
   the piece that loses least by going - at that height its dots are a few
   pixels across and unreadable anyway - and the lock comes down to match. */
@media (orientation: landscape) and (max-height: 460px) {
  .rank-sec .rank-cross { display: none; }
  .rank-lock svg {
    width:  min(calc(var(--cell, 64px) * 3), 34vh);
    height: min(calc(var(--cell, 64px) * 3), 34vh);
  }
}

@media (prefers-reduced-motion: reduce) {
  .rank-sec-body { transition: opacity .3s ease; transform: none; }
  .rank-wall { transition: none; opacity: 1; transform: none; }
  .rank-hero-fill { transition: none; }
  .ranks-scroll-cue { animation: none; }
  /* Snapping is a movement the reader did not ask for. */
  html.ranks-snap { scroll-snap-type: none; }
}
