*,
*::before,
*::after {
  box-sizing: border-box;
}

:root {
  /* Ring size. Deliberately NOT the same name as the per-ring colour below:
     an inline `--ring` would shadow this one and turn `width` into a colour. */
  --ring: clamp(44px, 10vmin, 78px);
}

html,
body {
  height: 100%;
  margin: 0;
  overscroll-behavior: none;
}

body {
  background: radial-gradient(circle at 50% 0%, #1d2939, #101828 70%);
  color: #f2f4f7;
  font-family: system-ui, sans-serif;
  /* Nothing here is a document: no selecting, no pinch-zoom, no rubber-banding. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}

.app {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(14px, 4vmin, 40px);
  min-height: 100dvh;
  padding: 4vmin;
}

/* ---------- the picker ---------- */

.picker {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(18px, 5vmin, 44px);
  min-height: 100dvh;
  padding: 4vmin;
}

.picker-title {
  margin: 0;
  font-size: clamp(24px, 7vmin, 46px);
  font-weight: 700;
}

.tiles {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(16px, 5vmin, 40px);
}

.tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(8px, 2.5vmin, 16px);
  width: clamp(120px, 34vmin, 200px);
  padding: clamp(14px, 4vmin, 28px);
  border: 0;
  border-radius: 22px;
  background: rgb(255 255 255 / 8%);
  color: #f2f4f7;
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
  transition:
    transform 100ms ease,
    background 120ms ease;
}

.tile:active {
  transform: scale(0.96);
  background: rgb(255 255 255 / 14%);
}

.tile-art {
  display: grid;
  place-items: center;
  width: clamp(52px, 15vmin, 90px);
  aspect-ratio: 1;
}

.tile-art svg {
  width: 100%;
  height: 100%;
}

.tile-name {
  font-size: clamp(15px, 3.6vmin, 22px);
  font-weight: 600;
}

.board {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* Between rows. The gap between poles inside a row is on .row. */
  gap: clamp(10px, 3vw, 34px);
  /* The browser must not steal a drag as a scroll gesture. */
  touch-action: none;
}

/* One row of poles. How many poles land on each row is decided in rows.rs, which
   is testable; this only positions whatever it is handed. */
.row {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: clamp(10px, 3vw, 34px);
}

.pole {
  display: flex;
  justify-content: center;
  /* the stem and base are absolutely placed against this box */
  position: relative;
  /* Extra padding at the bottom so the stack sits ON the base instead of
     covering it. The base is the one affordance a full pole would otherwise
     lose, and a full pole is the common case. */
  padding: calc(var(--ring) * 0.1) calc(var(--ring) * 0.1) calc(var(--ring) * 0.2);
  border: 0;
  /* The fill of a selected pole and its outline both follow this radius, so a
     pill here rounded the highlight into an oval. It matches the rings instead. */
  border-radius: calc(var(--ring) * 0.2);
  /* a button, so reset it back to bare */
  appearance: none;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  /* the browser must not reinterpret a drag on a pole as a scroll */
  touch-action: none;
  transition: background 120ms ease;
}

.pole.selected {
  background: rgb(255 255 255 / 20%);
  outline: 2px solid rgb(255 255 255 / 55%);
}

/* The pole itself. Until now there was none: `.pole` was a bare button, so the
   board read as columns of rounded blocks. The stem runs the full height and
   pokes out above the tallest stack, which is what makes it read as a pole the
   rings are threaded onto rather than a pile of rings. */
.pole::before {
  content: "";
  position: absolute;
  left: 50%;
  /* Overhangs the padding box on purpose, so even a full pole shows bare pole
     above its top ring. */
  top: calc(var(--ring) * -0.28);
  bottom: calc(var(--ring) * 0.16);
  width: calc(var(--ring) * 0.2);
  transform: translateX(-50%);
  border-radius: calc(var(--ring) * 0.1);
  /* Lit slightly off-centre, the way a cylinder is, so it is not a flat bar. */
  background: linear-gradient(
    to right,
    rgb(148 163 184 / 35%),
    rgb(226 232 240 / 62%),
    rgb(148 163 184 / 35%)
  );
}

/* Wider than the stem and a shade lighter, so the stack sits on something. */
.pole::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: calc(var(--ring) * 0.04);
  width: calc(var(--ring) * 1.04);
  height: calc(var(--ring) * 0.14);
  transform: translateX(-50%);
  border-radius: 999px;
  background: linear-gradient(
    to bottom,
    rgb(226 232 240 / 70%),
    rgb(100 116 139 / 62%)
  );
  box-shadow: 0 2px 5px rgb(0 0 0 / 40%);
}

/* bottom-first: the first child sits on the floor of the pole */
.rings {
  display: flex;
  flex-direction: column-reverse;
  justify-content: flex-start;
  gap: calc(var(--ring) * 0.06);
  /* Positioned so it paints over the stem and the base. A static stack would be
     painted underneath both, and the stem would be drawn across every ring. */
  position: relative;
  z-index: 1;
}

.ring {
  display: grid;
  place-items: center;
  /* Never shrink: a squashed ring is not the shape we drew. */
  flex: none;
  width: var(--ring);
  height: calc(var(--ring) * 0.72);
  border-radius: calc(var(--ring) * 0.2);
  background: var(--colour);
  box-shadow:
    inset 0 -0.14em 0.22em rgb(0 0 0 / 28%),
    inset 0 0.1em 0.18em rgb(255 255 255 / 22%),
    0 0.05em 0.12em rgb(0 0 0 / 45%);
  transition: transform 120ms ease;
}

.ring.slot {
  /* Reserved space on the pole, not a shape of its own. These used to be dashed
     rounded rectangles of exactly the ring shape, which made an empty pole read
     as a column of disabled buttons. The stem behind them says what the space is
     for, so they only have to hold the pole open to its full height. */
  background: none;
  box-shadow: none;
  border: 0;
}

/* Big enough to read at a glance, dark enough to sit on every palette colour. */
.badge {
  width: 46%;
  aspect-ratio: 1 / 1;
  fill: rgb(15 24 40 / 78%);
}

/* the rings lifted off the pole, pinned to the finger */
.ghost {
  position: fixed;
  left: 0;
  top: 0;
  display: flex;
  flex-direction: column;
  gap: calc(var(--ring) * 0.06);
  pointer-events: none;
  z-index: 10;
  opacity: 0.95;
  filter: drop-shadow(0 10px 14px rgb(0 0 0 / 55%));
}

/* A run that could not land travels back to its pole instead of blinking out.
   There is deliberately no transition while the finger is down: the rings must
   sit under it, not trail behind it. The duration comes from the game, which also
   times the removal, so the two cannot disagree. */
.ghost.settling {
  transition: transform var(--settle) ease-out;
}

.controls {
  display: flex;
  gap: clamp(14px, 4vmin, 32px);
}

/* The controls and the panel the gear opens. A column of its own, so the panel
   sits below the board rather than over the board's tap area. */
.console {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(10px, 3vmin, 20px);
}

/* The parent's two knobs. It is only in the tree while the gear is open, so it
   costs no height when it is not, and cannot overlap the board. */
.settings {
  display: flex;
  flex-direction: column;
  gap: clamp(10px, 2.5vmin, 16px);
  padding: clamp(10px, 2.5vmin, 18px) clamp(16px, 3.5vmin, 26px);
  border-radius: 18px;
  background: rgb(255 255 255 / 7%);
}

.setting-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(10px, 3vmin, 20px);
}

/* The word for each knob, so the bare number beside it is not ambiguous. */
.setting-label {
  font-size: clamp(12px, 2.8vmin, 16px);
  color: rgb(242 244 247 / 75%);
}

/* The current value, big enough to read at a glance and fixed-width so the row
   does not jump as the arrows move it. */
.setting-value {
  min-width: 2ch;
  text-align: center;
  font-size: clamp(18px, 4.4vmin, 26px);
  font-variant-numeric: tabular-nums;
}

/* The round button is the icon, not the button. The caption sits under it, so
   `aspect-ratio` belongs on the icon: on the button it would stretch the circle
   around the word instead. */
.control {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 0;
  border: 0;
  background: none;
  color: #f2f4f7;
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
}

.control-icon {
  display: grid;
  place-items: center;
  width: clamp(52px, 12vmin, 72px);
  aspect-ratio: 1;
  border-radius: 50%;
  background: rgb(255 255 255 / 10%);
  transition:
    background 120ms ease,
    transform 80ms ease;
}

.control svg {
  width: 54%;
  height: 54%;
  fill: currentColor;
}

/* For the parent, who is the one choosing to undo. Small enough to leave the
   board the room it needs on a short phone in landscape. */
.control-text {
  font-size: clamp(10px, 2.4vmin, 13px);
  line-height: 1.1;
}

/* The one number the game keeps. It sits with the controls because that is where
   a parent looks, and it is a plain count rather than a score: nothing here can
   be beaten, and nothing here can go down. */
.wins {
  display: flex;
  align-items: center;
  font-size: clamp(12px, 2.8vmin, 16px);
  /* Digits that do not jump around as the count climbs. */
  font-variant-numeric: tabular-nums;
  color: rgb(242 244 247 / 75%);
}

.control:active:not(:disabled) .control-icon {
  transform: scale(0.93);
  background: rgb(255 255 255 / 18%);
}

.control:disabled {
  opacity: 0.3;
  cursor: default;
}

@media (orientation: landscape) and (max-height: 520px) {
  :root {
    --ring: clamp(30px, 13vh, 62px);
  }
}

/* Rows exist only to keep a wide board on a narrow screen. Where there is width
   for the whole board on one line there is no reason to stack it, and stacking
   costs height, which is the scarce dimension in landscape. `display: contents`
   drops the row wrappers so the poles become direct children of the board again,
   which is exactly the single line this used to render. */
@media (min-width: 900px), (orientation: landscape) and (max-height: 520px) {
  .board {
    flex-direction: row;
    align-items: flex-end;
    gap: clamp(10px, 3vw, 34px);
  }

  .row {
    display: contents;
  }
}
/* ---------- winning ---------- */

.board.won .ring:not(.slot) {
  animation: ring-bounce 900ms cubic-bezier(0.3, 1.5, 0.5, 1) both;
}

.celebration {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  /* The star and the sentence under it are one group in the middle of the
     screen. Without this the two rows stretch and split it in half. */
  align-content: center;
  gap: clamp(8px, 2vmin, 18px);
  padding: 0;
  border: 0;
  /* translucent, so the rings bouncing behind it are still visible */
  background: rgb(16 24 40 / 42%);
  cursor: pointer;
  z-index: 20;
  animation: fade-in 260ms ease both;
}

.celebration-text {
  font-size: clamp(15px, 3.4vmin, 22px);
  font-weight: 600;
  color: #f2f4f7;
  text-shadow: 0 2px 6px rgb(0 0 0 / 55%);
}

.star {
  width: clamp(130px, 34vmin, 260px);
  aspect-ratio: 1;
  fill: #f0e442;
  filter: drop-shadow(0 12px 26px rgb(0 0 0 / 55%));
  animation:
    star-pop 620ms cubic-bezier(0.2, 1.7, 0.4, 1) both,
    star-pulse 1600ms ease-in-out 620ms infinite;
}

/* One colour finished. It sits under the win overlay and never blocks the board:
   `pointer-events: none` is the whole point, so the child keeps playing through
   it. That is what lets the last colour's burst and the win be a sequence rather
   than a pile-up. The duration comes from the game, which also times the
   removal, so the two cannot disagree. */
.mini-celebration {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
  z-index: 15;
}

.mini-star {
  width: clamp(80px, 24vmin, 180px);
  aspect-ratio: 1;
  fill: var(--colour);
  filter: drop-shadow(0 8px 18px rgb(0 0 0 / 55%));
  animation: mini-pop var(--mini) cubic-bezier(0.2, 1.6, 0.4, 1) both;
}

.confetti {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 3.2vmin;
  height: 4.6vmin;
  border-radius: 0.8vmin;
  background: var(--piece);
  animation: confetti-fly 1100ms ease-out var(--delay) both;
}

@keyframes ring-bounce {
  0% {
    transform: translateY(0);
  }
  30% {
    transform: translateY(-22%) scaleY(0.92);
  }
  60% {
    transform: translateY(0) scaleY(1.06);
  }
  100% {
    transform: translateY(0) scaleY(1);
  }
}

@keyframes confetti-fly {
  from {
    transform: translate(-50%, -50%) rotate(0deg);
    opacity: 1;
  }
  to {
    transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) rotate(540deg);
    opacity: 0;
  }
}

@keyframes star-pop {
  from {
    transform: scale(0.2) rotate(-40deg);
    opacity: 0;
  }
  to {
    transform: scale(1) rotate(0deg);
    opacity: 1;
  }
}

@keyframes star-pulse {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.06);
  }
}

@keyframes mini-pop {
  0% {
    transform: scale(0.3);
    opacity: 0;
  }
  25% {
    transform: scale(1.1);
    opacity: 1;
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* ---------- arrows ---------- */

/* The board is one SVG, so a cell is one user unit and every coordinate below is
   a cell. No grid is drawn: the arrows' own bodies are the picture. The height
   comes from the board's own aspect ratio, which the board sets inline, so a rung
   that is not square is drawn as it is rather than letterboxed into a square. */
.arrows {
  width: min(80vmin, 540px);
  height: auto;
  /* The board owns the gesture: without this the browser scrolls or zooms the
     page and the pinch never reaches the board. */
  touch-action: none;
}

.arrow {
  cursor: pointer;
}

/* A wide, invisible copy of the spine, so a small finger does not have to land
   exactly on the line. An SVG group only answers where it is painted, so without
   this the gaps inside a bent arrow would swallow a tap. */
.arrow .hit {
  fill: none;
  stroke: transparent;
  stroke-width: 1.1;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The spine and the head are one colour, so an arrow reads as a single snake.
   The stroke is thin; the head triangle is what makes it read as an arrow, so
   the two must not be the same width. */
.arrow .spine {
  fill: none;
  stroke: var(--colour);
  stroke-width: 0.22;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.arrow .tip {
  fill: var(--colour);
}

/* The body leaves like a snake: a window of the body's own length slides along
   its spine, tail following head, drawn as a dash on the track. The head is a
   triangle translated by the same distance, so the two stay in step. Both the
   distance and the duration come from the game, which also times the removal. */
.arrow.leaving {
  pointer-events: none;
}

.arrow.leaving .spine {
  stroke-dasharray: var(--body) 1000;
  animation: arrow-flow var(--exit) ease-in both;
}

.arrow.leaving .tip {
  animation: arrow-leave var(--exit) ease-in both;
}

.arrow.shake {
  animation: arrow-shake var(--shake) ease both;
}

/* The hearts left on this board. Spent hearts stay, so the row never moves. */
.hearts {
  display: flex;
  gap: clamp(6px, 2vmin, 14px);
}

.heart {
  width: clamp(22px, 6vmin, 34px);
  aspect-ratio: 1;
  fill: #f97066;
  filter: drop-shadow(0 2px 4px rgb(0 0 0 / 45%));
  transition: opacity 160ms ease;
}

.heart.spent {
  fill: none;
  stroke: rgb(242 244 247 / 30%);
  stroke-width: 1.5;
  opacity: 0.7;
}

@keyframes arrow-flow {
  from {
    stroke-dashoffset: 0;
  }
  to {
    stroke-dashoffset: var(--shift);
  }
}

@keyframes arrow-leave {
  from {
    transform: translate(0, 0);
  }
  to {
    transform: translate(var(--dx), var(--dy));
  }
}

@keyframes arrow-shake {
  0%,
  100% {
    transform: translate(0, 0);
  }
  25% {
    transform: translate(-0.12px, 0);
  }
  50% {
    transform: translate(0.12px, 0);
  }
  75% {
    transform: translate(-0.08px, 0);
  }
}

/* Whichever way the browser is set up, the game must still be playable. */
@media (prefers-reduced-motion: reduce) {
  .star,
  .confetti,
  .mini-star,
  .board.won .ring:not(.slot) {
    animation: none;
  }

  /* The rings still go home, they just do not travel: the timer that clears the
     ghost is what governs, not the transition. */
  .ghost.settling {
    transition: none;
  }

  /* The blocked arrow still answers the tap, it just does not shake. The leaving
     arrow still goes, it just disappears at once: the timer, not the animation,
     is what clears it. Both halves of the snake are stopped, not just the group. */
  .arrow.shake,
  .arrow.leaving .spine,
  .arrow.leaving .tip {
    animation: none;
  }

  .arrow.leaving {
    opacity: 0;
  }
}
