/*
 * Electrified Studio web map.
 *
 * The map fills the window and every control floats over it, because a map
 * boxed in by chrome is a smaller map. Panels are translucent and blurred so
 * the terrain under them still reads, and every interactive thing has a hover,
 * a focus ring and a transition, because that is most of what "smooth" means.
 */

:root {
  --bg: #080b10;
  --panel: rgba(15, 21, 31, 0.78);
  --panel-solid: #121924;
  --edge: rgba(255, 255, 255, 0.09);
  --edge-strong: rgba(0, 229, 255, 0.28);
  --ink: #e9eff7;
  --ink-dim: #93a6bd;
  --ink-faint: #61748c;
  --accent: #00e5ff;
  --accent-deep: #0a84ff;
  --warn: #ffb4b4;
  --warp: #ffd166;

  --r-lg: 16px;
  --r-md: 11px;
  --r-sm: 8px;
  --gap: 12px;
  --shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
  --shadow-lift: 0 22px 60px rgba(0, 0, 0, 0.62);
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  /* Overshoots slightly and settles. For things that appear: a panel that
     springs into place reads as a physical object, where a linear fade reads
     as a page redrawing itself. */
  --ease-spring: cubic-bezier(0.34, 1.42, 0.64, 1);
  /* For things that leave. Nothing should overshoot on the way out - it looks
     like the interface is unsure whether it meant it. */
  --ease-out: cubic-bezier(0.4, 0, 0.2, 1);
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
}

* { box-sizing: border-box; }

/* A custom property is a string to the animation engine unless it has been
   given a type, and a string cannot be interpolated - a transition on one
   simply snaps at the halfway point. Typing these two is what lets the health
   ring sweep round rather than jump, and lets the facing turn smoothly through
   a value that is doing arithmetic inside a calc(). */
@property --hp {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@property --yaw {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

/* A class that sets display beats the browser's own [hidden] rule, and every
   panel here sets one. Without this, hiding something by attribute silently
   does nothing. */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
  font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  -webkit-font-smoothing: antialiased;
  overscroll-behavior: none;
}

button, input { font: inherit; color: inherit; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* ------------------------------------------------------------------- map */

#map {
  position: absolute;
  inset: 0;
  /* A stacking context, and not for decoration. Leaflet gives its own panes
     z-index 200 to 800; without a context here those numbers are compared
     against everything else on the page instead of only against each other, so
     the tile pane outranks the whole interface and draws over it. It shows up
     when the map is dragged, because that is when the browser promotes the pane
     to its own layer and the paint order starts being obeyed. */
  z-index: 0;
  isolation: isolate;
  background: var(--bg);
  /* A faint grid under the tiles, so area that has not been drawn reads as
     "not drawn yet" rather than as a broken page. */
  background-image:
    linear-gradient(rgba(255, 255, 255, 0.022) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.022) 1px, transparent 1px);
  background-size: 80px 80px, 80px 80px;
}

/* This is pixel art. Smoothing it turns a map into a smear. */
.leaflet-tile { image-rendering: pixelated; }
.leaflet-container { background: transparent; font: inherit; }
.leaflet-control-attribution { display: none; }

/* Fade each tile in, so a pan into undrawn area does not flash. */
.leaflet-tile-container .leaflet-tile {
  transition: opacity 0.22s var(--ease);
}

.leaflet-control-zoom {
  border: 0 !important;
  box-shadow: var(--shadow) !important;
  border-radius: var(--r-md) !important;
  overflow: hidden;
  margin: 0 !important;
}

.leaflet-control-zoom a {
  width: 34px;
  height: 34px;
  line-height: 34px;
  background: var(--panel);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  color: var(--ink);
  border-color: var(--edge);
  transition: background 0.15s var(--ease), color 0.15s var(--ease);
}

.leaflet-control-zoom a:hover { background: rgba(255, 255, 255, 0.10); color: var(--accent); }
.leaflet-bottom.leaflet-right { margin: 0 var(--gap) calc(56px + var(--safe-bottom)) 0; }

/* ----------------------------------------------------------------- shell */

.shell {
  position: absolute;
  inset: 0;
  /* Above the map's context, so no pane inside it can ever come out on top. */
  z-index: 10;
  pointer-events: none;   /* the map keeps the drag; controls opt back in */
  display: grid;
  grid-template-rows: auto 1fr auto;
  grid-template-columns: 1fr;
}

.shell > * { pointer-events: auto; }
.grow { flex: 1 1 auto; pointer-events: none; }

.bar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: var(--gap);
  pointer-events: none;
}

.bar > * { pointer-events: auto; }
.bar-top { padding-top: calc(var(--gap) + var(--safe-top)); }
.bar-bottom { padding-bottom: calc(var(--gap) + var(--safe-bottom)); align-items: flex-end; }

/* ----------------------------------------------------------------- brand */

.brand {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 9px 15px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: var(--r-md);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  white-space: nowrap;
}

.brand-name {
  font-weight: 650;
  letter-spacing: 0.005em;
  background: linear-gradient(95deg, var(--accent), var(--accent-deep));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.spark {
  position: relative;
  width: 8px;
  height: 8px;
  flex: none;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 10px var(--accent);
  /* The dot itself does not move. It is a click target - three of them, for
     the staff panel - and a target that is scaling in and out is one the
     pointer has to chase. The life comes from the halo below instead, which
     is decoration and cannot be clicked. */
  animation: pulse 2.8s var(--ease) infinite;
}

.spark::before {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  border: 1px solid var(--accent);
  opacity: 0;
  pointer-events: none;
  animation: spark-halo 2.8s var(--ease) infinite;
}

/* Opacity only: nothing here changes the element's box. */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.6; }
}

@keyframes spark-halo {
  0% { transform: scale(0.6); opacity: 0.5; }
  70%, 100% { transform: scale(1.6); opacity: 0; }
}

/* ---------------------------------------------------------------- worlds */

.worlds {
  display: flex;
  gap: 3px;
  padding: 4px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: var(--r-md);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}

.worlds button {
  position: relative;
  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink-dim);
  padding: 6px 13px;
  border-radius: var(--r-sm);
  cursor: pointer;
  white-space: nowrap;
  isolation: isolate;
  transition: color 0.2s var(--ease), transform 0.14s var(--ease);
}

/* The lit background is a layer of its own rather than the button's own
   background, because a gradient cannot be transitioned - swapping it would
   snap. Fading and scaling this instead lets the selection glide across. */
.worlds button::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: linear-gradient(95deg, var(--accent), var(--accent-deep));
  opacity: 0;
  transform: scale(0.82);
  transition: opacity 0.22s var(--ease), transform 0.28s var(--ease-spring);
}

.worlds button:hover { color: var(--ink); }
.worlds button:hover::before { opacity: 0.1; transform: scale(1); }
.worlds button:active { transform: scale(0.96); }

.worlds button[aria-selected="true"] {
  color: #04121a;
  font-weight: 600;
}

.worlds button[aria-selected="true"]::before { opacity: 1; transform: scale(1); }

/* ------------------------------------------------------------------ views */

/* The perspective switcher. Same shape as the world tabs, because it is the
   same kind of choice - which set of tiles is under everything else. */
.views {
  display: flex;
  gap: 3px;
  padding: 4px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: var(--r-md);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}

.views button {
  position: relative;
  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink-dim);
  padding: 6px 13px;
  border-radius: var(--r-sm);
  cursor: pointer;
  white-space: nowrap;
  isolation: isolate;
  transition: color 0.2s var(--ease), transform 0.14s var(--ease);
}

.views button::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: linear-gradient(95deg, var(--accent), var(--accent-deep));
  opacity: 0;
  transform: scale(0.82);
  transition: opacity 0.22s var(--ease), transform 0.28s var(--ease-spring);
}

.views button:hover { color: var(--ink); }
.views button:hover::before { opacity: 0.1; transform: scale(1); }
.views button:active { transform: scale(0.96); }

.views button[aria-selected="true"] { color: #04121a; font-weight: 600; }
.views button[aria-selected="true"]::before { opacity: 1; transform: scale(1); }

@media (max-width: 820px) {
  .views { overflow-x: auto; scrollbar-width: none; }
  .views::-webkit-scrollbar { display: none; }
}

/* ------------------------------------------------------------------ jump */

.jump {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 0 12px;
  height: 38px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: var(--r-md);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  transition: border-color 0.16s var(--ease), width 0.2s var(--ease);
}

.jump:focus-within { border-color: var(--edge-strong); }
.jump-icon { width: 15px; height: 15px; flex: none; stroke: var(--ink-faint); fill: none; stroke-width: 1.7; }
.jump:focus-within .jump-icon { stroke: var(--accent); }

.jump input {
  width: 116px;
  border: 0;
  background: transparent;
  outline: none;
  color: var(--ink);
}

.jump input::placeholder { color: var(--ink-faint); }

/* ---------------------------------------------------------- icon buttons */

.icon-button {
  width: 38px;
  height: 38px;
  flex: none;
  display: grid;
  place-items: center;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: var(--r-md);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  cursor: pointer;
  transition: background 0.16s var(--ease), color 0.16s var(--ease), transform 0.12s var(--ease);
}

.icon-button:hover { background: rgba(255, 255, 255, 0.10); color: var(--accent); }
.icon-button:active { transform: scale(0.94); }
.icon-button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; }

/* ----------------------------------------------------------------- panel */

.panel {
  grid-row: 2;
  justify-self: start;
  align-self: start;
  width: 246px;
  max-height: 100%;
  margin: 0 var(--gap);
  display: flex;
  flex-direction: column;
  gap: 10px;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.16) transparent;
  pointer-events: auto;
}

.panel::-webkit-scrollbar { width: 7px; }
.panel::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.16); border-radius: 4px; }

.card {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  overflow: hidden;
  flex: none;
}

.card-head {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 11px 14px;
  background: transparent;
  border: 0;
  cursor: pointer;
  text-align: left;
  transition: background 0.16s var(--ease);
}

.card-head:hover { background: rgba(255, 255, 255, 0.045); }
.card-head:active { background: rgba(255, 255, 255, 0.08); }

.card {
  transition: box-shadow 0.28s var(--ease), border-color 0.28s var(--ease);
}

.card:hover { box-shadow: var(--shadow-lift); }

.card-title {
  flex: 1 1 auto;
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.085em;
  text-transform: uppercase;
  color: var(--ink-dim);
}

.pill {
  min-width: 22px;
  padding: 1px 7px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 650;
  text-align: center;
  color: var(--accent);
  background: rgba(0, 229, 255, 0.12);
  font-variant-numeric: tabular-nums;
  transition: background 0.2s var(--ease);
}

.pill.is-bumped { animation: bump 0.42s var(--ease-spring); }

@keyframes bump {
  0% { transform: scale(1); }
  35% { transform: scale(1.28); background: rgba(0, 229, 255, 0.3); }
  100% { transform: scale(1); }
}

.chevron {
  width: 15px;
  height: 15px;
  flex: none;
  fill: none;
  stroke: var(--ink-faint);
  stroke-width: 1.8;
  stroke-linecap: round;
  transition: transform 0.22s var(--ease);
}

.card-head[aria-expanded="false"] .chevron { transform: rotate(-90deg); }

.card-body {
  padding: 0 6px 6px;
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 0.26s var(--ease), opacity 0.2s var(--ease),
              padding 0.26s var(--ease);
}

/* One wrapper, so the 0fr collapse has exactly one row to collapse. With
   several children the extras land in implicit rows that 0fr never touches,
   and a "collapsed" card stays its full height. */
.card-inner { min-height: 0; }

.card-body.is-collapsed {
  grid-template-rows: 0fr;
  opacity: 0;
  padding-block: 0;
  overflow: hidden;
}

/* The contents slide up as the row collapses, rather than being squashed. */
.card-inner {
  transition: transform 0.26s var(--ease), opacity 0.18s var(--ease);
}

.card-body.is-collapsed .card-inner { transform: translateY(-6px); }

/* ----------------------------------------------------------------- lists */

.card-body.is-collapsed .card-inner { overflow: hidden; }

.list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 34vh;
  overflow-y: auto;
  scrollbar-width: thin;
}

.list li {
  position: relative;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 6px 8px;
  border-radius: var(--r-sm);
  cursor: pointer;
  overflow: hidden;
  transition: background 0.14s var(--ease), transform 0.14s var(--ease);
  /* Rows are kept and updated rather than rebuilt, so this runs once, when
     somebody actually joins - not four times a second. */
  animation: row-in 0.34s var(--ease-spring) backwards;
}

.list li:hover { background: rgba(255, 255, 255, 0.065); }
.list li:active { transform: scale(0.985); }

@keyframes row-in {
  from { opacity: 0; transform: translateX(-10px); }
}

/* Moving a row to keep the list in the server's order re-inserts the element,
   and a re-inserted element replays its animation. Once a row has arrived it
   is marked done, so a reshuffle does not make the whole list flicker. */
.list li.is-settled { animation: none; }

/* Leaving: held in the list just long enough to collapse out of it. */
.list li.is-leaving {
  pointer-events: none;
  animation: row-out 0.26s var(--ease-out) forwards;
}

@keyframes row-out {
  to {
    opacity: 0;
    transform: translateX(14px);
    height: 0;
    padding-block: 0;
    margin-bottom: -2px;
  }
}

/* Followed: a lit edge down the left, wiped in rather than switched on. */
.list li.is-followed { background: rgba(0, 229, 255, 0.12); }

.list li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 6px;
  bottom: 6px;
  width: 2px;
  border-radius: 0 2px 2px 0;
  background: var(--accent);
  box-shadow: 0 0 8px var(--accent);
  transform: scaleY(0);
  transform-origin: center;
  opacity: 0;
  transition: transform 0.24s var(--ease-spring), opacity 0.18s var(--ease);
}

.list li.is-followed::before { transform: scaleY(1); opacity: 1; }

.avatar {
  width: 24px;
  height: 24px;
  flex: none;
  display: grid;
  place-items: center;
  border-radius: 7px;
  font-size: 11px;
  font-weight: 700;
  color: #06161d;
  background: var(--accent);
}

.avatar {
  transition: background 0.3s var(--ease), color 0.3s var(--ease),
              transform 0.18s var(--ease-spring);
}

.list li:hover .avatar { transform: scale(1.08) rotate(-3deg); }
.avatar.is-afk { background: #55657a; color: #0d141d; }

.who { flex: 1 1 auto; min-width: 0; }
.who b { display: block; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { display: block; font-size: 11px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
.who.is-afk b { color: var(--ink-dim); }

.dot-warp {
  width: 9px;
  height: 9px;
  flex: none;
  margin: 0 8px 0 5px;
  transform: rotate(45deg);
  background: var(--warp);
  border-radius: 2px;
}

.empty {
  margin: 4px 10px 8px;
  font-size: 12.5px;
  color: var(--ink-faint);
}

/* --------------------------------------------------------------- toggles */

.switch {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 8px;
  border-radius: var(--r-sm);
  cursor: pointer;
  color: var(--ink-dim);
  transition: background 0.14s var(--ease), color 0.14s var(--ease);
}

.switch:hover { background: rgba(255, 255, 255, 0.055); color: var(--ink); }
/* Hidden without being gone: a zero-sized control is skipped by some assistive
   tech and cannot be clicked directly. This keeps it in the layout and the tab
   order, with the visible switch drawn beside it. */
.switch input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.track {
  width: 32px;
  height: 18px;
  flex: none;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.14);
  position: relative;
  transition: background 0.2s var(--ease);
}

.track::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--ink-dim);
}

.switch input:checked + .track { background: rgba(0, 229, 255, 0.34); }
.switch input:checked + .track::after {
  transform: translateX(14px);
  background: var(--accent);
  box-shadow: 0 0 8px var(--accent);
}

.track::after { transition: transform 0.26s var(--ease-spring), background 0.2s var(--ease), box-shadow 0.2s var(--ease); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }

/* --------------------------------------------------------------- readout */

.readout {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 8px 14px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: var(--r-md);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: color 0.16s var(--ease), border-color 0.16s var(--ease);
}

.readout {
  transition: color 0.16s var(--ease), border-color 0.16s var(--ease),
              transform 0.14s var(--ease), background 0.16s var(--ease);
}

.readout:hover { color: var(--ink); border-color: var(--edge-strong); }
.readout:active { transform: scale(0.97); }
.readout:hover .copy { opacity: 1; }
.readout .copy { transition: opacity 0.16s var(--ease); }
.readout .copy { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.6; opacity: 0.55; }

.follow {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 8px 10px 8px 14px;
  background: var(--panel);
  border: 1px solid var(--edge-strong);
  border-radius: var(--r-md);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  color: var(--ink-dim);
}

.follow {
  animation: follow-in 0.34s var(--ease-spring);
}

@keyframes follow-in {
  from { opacity: 0; transform: translateY(12px) scale(0.94); }
}

.follow b { color: var(--accent); font-weight: 600; }

/* A quiet sweep across the chip, so "following" reads as something ongoing. */
.follow::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(100deg, transparent 20%,
              rgba(0, 229, 255, 0.14) 50%, transparent 80%);
  background-size: 220% 100%;
  animation: shimmer 3.4s var(--ease) infinite;
}

.follow { position: relative; overflow: hidden; }

@keyframes shimmer {
  from { background-position: 160% 0; }
  to { background-position: -60% 0; }
}

.follow button {
  border: 0;
  background: rgba(255, 255, 255, 0.08);
  color: var(--ink-dim);
  padding: 3px 10px;
  border-radius: 999px;
  cursor: pointer;
  transition: background 0.14s var(--ease), color 0.14s var(--ease);
}

.follow button:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }

/* -------------------------------------------------------------- progress */

.progress {
  grid-row: 3;
  justify-self: center;
  align-self: end;
  width: min(360px, calc(100% - 2 * var(--gap)));
  margin-bottom: calc(58px + var(--safe-bottom));
  padding: 11px 15px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: var(--r-md);
  box-shadow: var(--shadow);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  animation: rise 0.3s var(--ease);
}

@keyframes rise { from { opacity: 0; transform: translateY(10px); } }

.progress-row {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 12.5px;
  color: var(--ink-dim);
  margin-bottom: 8px;
}

.progress-row span:first-child { flex: 1 1 auto; }

.progress-track {
  height: 5px;
  border-radius: 3px;
  background: rgba(255, 255, 255, 0.10);
  overflow: hidden;
}

.progress-bar {
  height: 100%;
  width: 0;
  border-radius: 3px;
  background: linear-gradient(90deg, var(--accent), var(--accent-deep));
  transition: width 0.7s var(--ease);
}

/* Nothing to show a percentage of yet: sweep instead of sitting at zero. */
.progress-bar.is-indeterminate {
  width: 34% !important;
  animation: sweep 1.5s var(--ease) infinite;
}

@keyframes sweep {
  0% { transform: translateX(-110%); }
  100% { transform: translateX(330%); }
}

/* ----------------------------------------------------------------- toast */

.toast {
  position: absolute;
  left: 50%;
  bottom: calc(76px + var(--safe-bottom));
  transform: translate(-50%, 14px);
  padding: 9px 17px;
  border-radius: 999px;
  background: var(--panel-solid);
  border: 1px solid var(--edge-strong);
  box-shadow: var(--shadow);
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, 16px) scale(0.94);
  transition: opacity 0.2s var(--ease-out), transform 0.34s var(--ease-spring);
}

.toast.is-shown { opacity: 1; transform: translate(-50%, 0) scale(1); }

/* ------------------------------------------------------------------ boot */

.boot {
  position: absolute;
  inset: 0;
  z-index: 900;
  display: grid;
  place-items: center;
  background: var(--bg);
  transition: opacity 0.45s var(--ease), visibility 0.45s var(--ease);
}

.boot.is-done { opacity: 0; visibility: hidden; }
.boot-inner {
  display: flex;
  align-items: center;
  gap: 13px;
  color: var(--ink-dim);
  animation: boot-in 0.5s var(--ease) backwards 0.1s;
}
.boot p { margin: 0; }

@keyframes boot-in { from { opacity: 0; transform: translateY(8px); } }

/* A ring sweeping round the spark, so a slow first render looks like work in
   progress rather than a page that has stopped. */
.boot .spark::after {
  content: "";
  position: absolute;
  inset: -7px;
  border-radius: 50%;
  border: 1.5px solid transparent;
  border-top-color: var(--accent);
  border-right-color: color-mix(in srgb, var(--accent) 40%, transparent);
  animation: spin 0.9s linear infinite;
}

.boot .spark { position: relative; }

@keyframes spin { to { transform: rotate(360deg); } }

/* The whole interface settles in once the map is ready. Driven by a class on
   the body, not a sibling selector: the boot screen is written after the shell
   in the markup, and a sibling combinator only ever looks forwards. */
.is-ready .bar-top > *,
.is-ready .panel > *,
.is-ready .bar-bottom > * {
  animation: settle 0.5s var(--ease-spring) backwards;
}

.is-ready .panel > *:nth-child(2) { animation-delay: 0.06s; }
.is-ready .panel > *:nth-child(3) { animation-delay: 0.12s; }
.is-ready .bar-top > *:nth-child(2) { animation-delay: 0.04s; }
.is-ready .bar-top > *:nth-child(3) { animation-delay: 0.08s; }

@keyframes settle { from { opacity: 0; transform: translateY(-9px); } }

/* --------------------------------------------------------------- scrim */

.scrim {
  position: absolute;
  inset: 0;
  z-index: 1;
  background: rgba(4, 7, 11, 0.5);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  opacity: 0;
  transition: opacity 0.28s var(--ease);
}

.scrim.is-shown { opacity: 1; }

/* --------------------------------------------------------------- markers */

.marker-player, .marker-warp {
  display: flex;
  flex-direction: column;
  align-items: center;
  pointer-events: none;
  width: max-content;
  cursor: pointer;
  /* Leaflet puts the box's top-left on the point. Shift left by half the width
     and up by half the dot, so the dot sits on the position and the label hangs
     below it rather than the whole stack being centred. */
  transform: translate(-50%, -6px);
}

/* Leaflet places and sizes this box itself now, so no shifting here. The label
   hangs out of it, which is why it is positioned rather than in the flow. */
.marker-player {
  position: relative;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  transform: none;
}

.marker-player .name {
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-top: 1px;
}

/* The body of the marker: a facing cone behind a dot, inside a health ring. */
.marker-player .body {
  position: relative;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
}

/* The cone is the only part that turns. Minecraft yaw is 0 facing south and
   grows clockwise seen from above; the map has north at the top, so screen
   angle is yaw + 180. That sum is done here rather than in script, so a marker
   only ever has one number written to it.

   The number it is given is a running total, not the wrapped yaw the server
   sends - see unwrap() in app.js. Wrapped, it falls from 180 to -180 on every
   pass through south, and this transition would dutifully animate the cone a
   full turn backwards. Unwrapped, it just keeps counting, and the rotation is
   continuous however many times the player spins.

   Linear, and exactly as long as the gap between updates, so each turn lands
   as the next one arrives: no easing in and out of every single frame. */
.marker-player .cone {
  position: absolute;
  inset: 0;
  transform: rotate(calc(var(--yaw, 0) * 1deg + 180deg));
  transition: transform var(--glide, 0.25s) linear;
}

/* The arrow head, clear of the ring so the facing is never ambiguous. */
.marker-player .cone::before {
  content: "";
  position: absolute;
  top: -7px;
  left: 50%;
  width: 0;
  height: 0;
  transform: translateX(-50%);
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-bottom: 8px solid var(--mark, var(--accent));
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.8));
}

/* A field of view: a soft wedge thrown forwards, the way a minimap shows where
   somebody is looking. Angle 0 in a conic gradient is up, and the arrow above
   also points up, so the two agree before the parent is rotated - and the
   parent's rotation then turns both together. */
.marker-player .cone::after {
  content: "";
  position: absolute;
  inset: -11px;
  border-radius: 50%;
  background: conic-gradient(from -27deg,
              color-mix(in srgb, var(--mark, var(--accent)) 70%, transparent) 0deg,
              color-mix(in srgb, var(--mark, var(--accent)) 12%, transparent) 34deg,
              transparent 54deg);
  -webkit-mask: radial-gradient(farthest-side, #000 22%, rgba(0, 0, 0, 0.35) 62%, transparent 100%);
  mask: radial-gradient(farthest-side, #000 22%, rgba(0, 0, 0, 0.35) 62%, transparent 100%);
  opacity: 0.6;
  transition: opacity 0.25s var(--ease);
}

.marker-player:hover .cone::after { opacity: 0.85; }

/* A spectator is not looking at anything in particular, and someone away from
   the keyboard is not looking at all. */
.marker-player[data-mode="spectator"] .cone::after { opacity: 0.3; }

/* Health, as the fraction of a ring that is filled - and as a colour, so the
   ring carries a second reading the mode colour is not already giving. Full
   health is green, half is amber, nearly dead is red, interpolated rather than
   stepped. The plain colour first is a fallback for an engine that will not
   take an angle from a calc() here. */
.marker-player .ring {
  position: absolute;
  inset: 4px;
  border-radius: 50%;
  --hp-colour: var(--mark, var(--accent));
  --hp-colour: hsl(calc(var(--hp, 1) * 122deg) 68% 54%);
  background: conic-gradient(var(--hp-colour) calc(var(--hp, 1) * 360deg),
                             rgba(255, 255, 255, 0.16) 0);
  -webkit-mask: radial-gradient(farthest-side, transparent 58%, #000 60%);
  mask: radial-gradient(farthest-side, transparent 58%, #000 60%);
  /* --hp is a typed property, so this sweeps the ring round to its new value
     instead of redrawing it there. */
  transition: --hp 0.45s var(--ease);
}

/* Nearly dead: the ring beats, roughly at the rate the hearts shake in game. */
.marker-player .ring { animation: none; }
.marker-player.is-hurt .ring { animation: heartbeat 1.1s var(--ease) infinite; }

@keyframes heartbeat {
  0%, 100% { transform: scale(1); }
  14% { transform: scale(1.13); }
  28% { transform: scale(1); }
  42% { transform: scale(1.09); }
}

.marker-player .dot {
  position: relative;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--mark, var(--accent));
  border: 2px solid #04161e;
  box-shadow: 0 0 9px color-mix(in srgb, var(--mark, var(--accent)) 70%, transparent);
}

/* What each marker is coloured by: the mode they are in. */
.marker-player { --mark: var(--accent); }
.marker-player[data-mode="survival"] { --mark: #5ed17a; }
.marker-player[data-mode="adventure"] { --mark: #ffc861; }
.marker-player[data-mode="spectator"] { --mark: #93a6bd; }

.marker-player.is-afk { --mark: #6d7f95; opacity: 0.75; }
.marker-player.is-afk .dot { box-shadow: none; }
.marker-player.is-afk .cone { display: none; }

/* A moving player gets a slow pulse behind the dot, so the eye finds them. */
.marker-player[data-moving="true"] .body::after {
  content: "";
  position: absolute;
  inset: 2px;
  border-radius: 50%;
  border: 1px solid var(--mark);
  opacity: 0.5;
  animation: ping 1.6s var(--ease) infinite;
}

@keyframes ping {
  from { transform: scale(0.7); opacity: 0.55; }
  to { transform: scale(1.35); opacity: 0; }
}

.marker-player .name, .marker-warp .name {
  margin-top: 4px;
  padding: 1px 7px;
  border-radius: 6px;
  font-size: 11px;
  white-space: nowrap;
  background: rgba(6, 10, 15, 0.82);
  border: 1px solid var(--edge);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

/* A marker should glide between updates, not teleport. The duration is set
   from the stream interval, so the glide lands exactly as the next update
   arrives and the motion reads as continuous. Not during a zoom, where Leaflet
   is repositioning every marker itself and a transition would smear. */
.leaflet-marker-icon.player-marker {
  transition: transform var(--glide, 0.25s) linear;
}
.leaflet-zoom-anim .leaflet-marker-icon.player-marker { transition: none; }

/* Somebody joining should arrive, not blink into being. The scale and the drop
   are on the inner body, because the icon's own transform is Leaflet's - it is
   what positions the marker, and animating it would fling the marker across
   the map from the top-left corner. */
.marker-player .body { animation: marker-in 0.42s var(--ease-spring) backwards; }

@keyframes marker-in {
  from { opacity: 0; transform: scale(0.3) translateY(-14px); }
  to { opacity: 1; transform: scale(1) translateY(0); }
}

.marker-player .name { animation: name-in 0.3s var(--ease) 0.12s backwards; }

@keyframes name-in {
  from { opacity: 0; transform: translate(-50%, -4px); }
}

/* Leaving: the marker is kept on the map for exactly this long after the
   player has gone, so they fade out where they were rather than vanishing. */
.marker-player.is-leaving { pointer-events: none; }
.marker-player.is-leaving .body { animation: marker-out 0.3s var(--ease-out) forwards; }
.marker-player.is-leaving .name { animation: fade-out 0.2s var(--ease-out) forwards; }

@keyframes marker-out {
  to { opacity: 0; transform: scale(0.45); }
}

@keyframes fade-out { to { opacity: 0; } }

/* Hovering a marker lifts it and brings the label forward. */
.marker-player:hover .dot { transform: scale(1.25); }
.marker-player .dot { transition: transform 0.2s var(--ease-spring), background 0.2s var(--ease), box-shadow 0.2s var(--ease); }
.marker-player:hover .name {
  border-color: var(--edge-strong);
  color: var(--ink);
}

.marker-warp .pin {
  width: 9px;
  height: 9px;
  transform: rotate(45deg);
  background: var(--warp);
  border: 1px solid #6b4f14;
  border-radius: 2px;
}

.marker-warp .name { color: #ffe6a8; border-color: rgba(255, 209, 102, 0.3); }

/* Labels get in the way when the whole world is on screen. */
.is-far .marker-player .name, .is-far .marker-warp .name { display: none; }

/* ---------------------------------------------------------------- narrow */

.only-narrow { display: none; }

@media (max-width: 820px) {
  .only-narrow { display: grid; }
  /* The menu button says what the brand chip would; the tabs need the room. */
  .brand, .jump { display: none; }

  .panel {
    position: absolute;
    top: calc(var(--gap) + var(--safe-top) + 50px);
    bottom: calc(58px + var(--safe-bottom));
    width: min(280px, 82vw);
    max-height: none;
    transform: translateX(calc(-100% - var(--gap)));
    transition: transform 0.28s var(--ease);
  }

  .panel { z-index: 2; }
  .panel.is-open { transform: translateX(0); }
  /* On a phone the bottom bar has nowhere to go beside the readout. */
  .follow { display: none; }
  .worlds { overflow-x: auto; scrollbar-width: none; }
  .worlds::-webkit-scrollbar { display: none; }
}

/* ------------------------------------------------- the statistics card */

.card-player {
  position: absolute;
  z-index: 20;
  top: calc(var(--gap) + var(--safe-top) + 50px);
  right: var(--gap);
  width: 300px;
  max-height: calc(100% - 2 * var(--gap) - 110px);
  display: flex;
  flex-direction: column;
  padding: 4px 4px 10px;
  background: var(--panel);
  border: 1px solid var(--edge-strong);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  overflow-y: auto;
  scrollbar-width: thin;
  transform-origin: 90% 0;
  animation: card-in 0.36s var(--ease-spring);
}

@keyframes card-in {
  from { opacity: 0; transform: translateY(-14px) scale(0.94); }
}

/* Closing is a state, not a removal: the script adds this, waits for it, and
   only then sets hidden. Without it the card simply stops existing, which is
   the one transition the eye always notices. */
.card-player.is-closing {
  animation: card-out 0.2s var(--ease-out) forwards;
  pointer-events: none;
}

@keyframes card-out {
  to { opacity: 0; transform: translateY(-10px) scale(0.96); }
}

.card-player-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 6px 10px 8px;
}

.card-player-who { flex: 1 1 auto; min-width: 0; }
.card-player-who b { display: block; font-weight: 600; }
.card-player-who span { display: block; font-size: 11.5px; color: var(--ink-faint); }

.icon-button.small {
  width: 28px;
  height: 28px;
  border-radius: var(--r-sm);
  box-shadow: none;
  background: transparent;
  border-color: transparent;
}

.icon-button.small svg { width: 15px; height: 15px; }

/* The live half: what is true of them right now. */
.card-live {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  padding: 0 8px 10px;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 9px;
  border-radius: 999px;
  font-size: 11.5px;
  color: var(--ink-dim);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--edge);
  font-variant-numeric: tabular-nums;
}

.chip b { color: var(--ink); font-weight: 600; }
.chip.is-accent { color: var(--accent); border-color: var(--edge-strong); }

/* The live chips are rewritten on every stream frame, so they must not animate
   on each one - only when the card itself has just opened, which is the only
   time this class is on the card. */
.card-player.is-fresh .chip {
  animation: chip-in 0.3s var(--ease-spring) backwards;
  animation-delay: calc(var(--i, 0) * 28ms);
}

@keyframes chip-in {
  from { opacity: 0; transform: translateY(5px) scale(0.9); }
}

/* The counted half. */
.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 4px;
  padding: 0 6px;
}

.stat {
  padding: 8px 10px;
  border-radius: var(--r-sm);
  background: rgba(255, 255, 255, 0.04);
  transition: background 0.18s var(--ease), transform 0.18s var(--ease);
  /* Each tile comes in just after the one before it, so the card fills in as a
     sweep rather than appearing all at once. Capped, or the last tile of a long
     card would still be waiting after the eye had moved on. */
  animation: stat-in 0.34s var(--ease-spring) backwards;
  animation-delay: calc(min(var(--i, 0), 16) * 26ms);
}

.stat:hover { background: rgba(255, 255, 255, 0.075); }

@keyframes stat-in {
  from { opacity: 0; transform: translateY(9px); }
}

/* A counter that has just changed flashes, so a death or a placed block is
   visible on an open card without having to watch the number. */
.stat.is-changed b { animation: tick 0.8s var(--ease); }

@keyframes tick {
  0% { color: var(--accent); transform: translateY(-2px); }
  100% { color: inherit; transform: none; }
}

.stat span {
  display: block;
  font-size: 10.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin-bottom: 2px;
}

.stat b {
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.stat.is-wide { grid-column: span 2; }

/* An odd number of counters leaves the last one alone on its row, with a hole
   beside it. Let it take the whole row instead of sitting in half of one. */
.stat:last-child:nth-child(odd) { grid-column: span 2; }

@media (max-width: 820px) {
  .card-player {
    top: auto;
    left: var(--gap);
    right: var(--gap);
    bottom: calc(58px + var(--safe-bottom));
    width: auto;
    max-height: 62%;
  }
}

/* ------------------------------------------------------- the staff door */

/* The spark is a button: triple-clicking it goes to the staff page. It must
   still look exactly like the dot it was - anything that advertises itself
   defeats the point of tucking it away. */
button.spark {
  padding: 0;
  border: 0;
  appearance: none;
  cursor: default;
}

button.spark:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
