:root {
  --bg: #06070f;
  --text: #f5f7fb;
  --text-muted: #8b93a8;
  --accent-coral: #ff5f6d;
  --accent-coral-2: #e6435a;
  --accent-cyan: #4dd8e6;
  --accent-cyan-2: #2bb8c9;
  --accent-purple: #7c8cff;
  --accent-yellow: #ffd23f;
  --accent-green: #4ade80;
  --glass: rgba(255, 255, 255, 0.045);
  --glass-strong: rgba(255, 255, 255, 0.07);
  --border-soft: rgba(255, 255, 255, 0.09);
  --shadow-dark: rgba(0, 0, 0, 0.5);

  /* Tactile glossy-plastic card recipe, reused across the whole app */
  --card-sheen: linear-gradient(165deg, rgba(255, 255, 255, 0.10) 0%, rgba(255, 255, 255, 0.02) 32%, transparent 58%);
  --card-base: linear-gradient(160deg, #1c2138 0%, #12152a 55%, #0b0d18 100%);
  /* The same gradient, but translucent, so the starfield and the meteors
     show faintly THROUGH a view's card instead of being hidden behind it.
     The card covers ~93% of a phone screen, so with an opaque panel a
     streak was only ever glimpsed in the thin band above it.
     Alpha stays high (0.78-0.86) on purpose: over the near-black page
     (#06070f) the resulting colour is within a couple of percent of the
     opaque one, so text contrast is essentially unchanged -- what comes
     through is the bright pixels of a star, not the background. */
  --card-base-sky: linear-gradient(160deg,
    rgba(28, 33, 56, 0.78) 0%,
    rgba(18, 21, 42, 0.82) 55%,
    rgba(11, 13, 24, 0.86) 100%);
  --card-base-soft: linear-gradient(160deg, #171b30 0%, #101323 88%, #0b0d18 100%);
  --card-border: rgba(255, 255, 255, 0.07);
  --card-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 16px 32px rgba(0, 0, 0, 0.45), 0 3px 6px rgba(0, 0, 0, 0.35);
  --card-shadow-soft: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 10px 22px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);

  /* --- Motion system -----------------------------------------------------
     One easing family and one timing scale for the whole app, so a modal,
     a screen change and a section swap all feel like the same gesture at
     different sizes. Nothing here is the `ease`/`linear` keyword: every
     curve either decelerates into its resting state (arrivals) or
     accelerates away from it (departures), which is what stops motion
     reading as robotic or stopping abruptly.

     The one deliberate exception is perpetual rotation (orbits, the
     galaxy, planet spin): a looping 0->360deg turn MUST be linear, because
     any eased curve restarts at full speed each cycle and visibly hitches
     once per lap. Those keep `linear` on purpose. */
  --ease-enter: cubic-bezier(0.16, 0.72, 0.24, 1);      /* arrives and settles */
  --ease-exit: cubic-bezier(0.45, 0.02, 0.75, 0.35);    /* leaves, accelerating away */
  --ease-soft: cubic-bezier(0.37, 0, 0.63, 1);          /* symmetric sine, for ambient loops */
  --ease-press: cubic-bezier(0.2, 0.72, 0.3, 1);        /* tap feedback */
  --ease-flight: cubic-bezier(0.5, 0, 0.15, 1);         /* something travelling a long way */

  --t-press: 120ms;        /* :active feedback */
  --t-hint: 180ms;         /* chrome: borders, focus rings, fades */
  --t-out: 190ms;          /* a screen or modal leaving */
  --t-in: 300ms;           /* a screen or modal arriving */
  --t-swap-out: 150ms;     /* in-place content leaving */
  --t-swap-in: 250ms;      /* in-place content arriving */
  --t-flight: 460ms;       /* the avatar's punch up / flight back */
  --t-fall: 1250ms;        /* a captured planet's spiral into the core */

  /* Morph travel. Restrained on purpose — a screen barely moves, a small
     element is allowed a little more. */
  --morph-scale-view: 0.97;
  --morph-scale-item: 0.94;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

html, body {
  background-color: var(--bg);
}

html {
  overflow-x: hidden;
  /* Scrolling itself stays enabled (needed for tall screens/keyboards) --
     this only hides the browser-drawn scrollbar track/thumb, top-to-bottom
     and side-to-side, everywhere in the app. */
  scrollbar-width: none;
  -ms-overflow-style: none;
}

html::-webkit-scrollbar {
  display: none;
}

body {
  color: var(--text);
  display: flex;
  justify-content: center;
  min-height: 100vh;
  position: relative;
  overflow-x: hidden;
  /* App-like feel: only vertical scrolling/panning on the page itself.
     Elements that are deliberately horizontally scrollable (session tabs,
     history round tabs) opt back into horizontal panning individually. */
  touch-action: pan-y;
  /* Both axes, not just x: Chrome's pull-to-refresh lives on the y
     overscroll, and in a WebView a downward swipe at the top of a round
     reloaded the whole app mid-game. */
  overscroll-behavior: none;
}

/* Starfield + nebula glow, fixed behind all content */
/* The starfield. Two things were wrong with the previous version and both
   made it effectively invisible on a phone:
   
   1. DENSITY. Six dots on a 280px tile puts only ~4 stars in the 80px of
      open sky above the card -- and dots sitting low in the tile never
      appear there at all. Three tiles now (210/130/70px, deliberately not
      multiples of each other so the repeat doesn't line up), which is
      roughly 230 stars on a 390x844 screen instead of ~70.
   2. BRIGHTNESS. `radial-gradient(1.6px 1.6px at x y, #fff, transparent)`
      is bright at the exact centre only and gone within 1.6px, so no
      pixel ever reached full brightness -- measured: 4 dim single pixels
      in the whole band. Each star now has a SOLID core (`#fff 0 55%`)
      before it falls off, so a 1px star is actually a 1px star.
   
   Three brightness tiers read as depth: near stars bright and slightly
   larger, far ones dim. Two have a soft halo behind them so the eye has
   something to catch. */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  background-image:
    /* halos, behind their own cores */
    radial-gradient(circle 5px at 148px 46px, rgba(190, 220, 255, 0.20) 0%, transparent 70%),
    radial-gradient(circle 4px at 52px 160px, rgba(200, 225, 255, 0.16) 0%, transparent 70%),
    /* near field -- bright, 210px tile */
    radial-gradient(circle 1.5px at 148px 46px, #ffffff 0 55%, transparent 100%),
    radial-gradient(circle 1.4px at 52px 160px, #f2f8ff 0 55%, transparent 100%),
    radial-gradient(circle 1.2px at 20px 28px, #ffffff 0 55%, transparent 100%),
    radial-gradient(circle 1.1px at 186px 122px, #eaf3ff 0 55%, transparent 100%),
    radial-gradient(circle 1.3px at 96px 196px, #ffffff 0 55%, transparent 100%),
    radial-gradient(circle 1px   at 124px 88px, #dfeaff 0 55%, transparent 100%),
    /* mid field -- 130px tile */
    radial-gradient(circle 1px at 14px 62px,  rgba(255, 255, 255, 0.72) 0 55%, transparent 100%),
    radial-gradient(circle 1px at 102px 18px, rgba(226, 240, 255, 0.66) 0 55%, transparent 100%),
    radial-gradient(circle 0.9px at 66px 104px, rgba(255, 255, 255, 0.60) 0 55%, transparent 100%),
    radial-gradient(circle 1px at 118px 78px, rgba(210, 230, 255, 0.55) 0 55%, transparent 100%),
    radial-gradient(circle 0.9px at 38px 124px, rgba(255, 255, 255, 0.50) 0 55%, transparent 100%),
    /* far field -- 70px tile, faint, just texture */
    radial-gradient(circle 0.8px at 10px 22px, rgba(255, 255, 255, 0.34) 0 55%, transparent 100%),
    radial-gradient(circle 0.7px at 46px 54px, rgba(214, 232, 255, 0.28) 0 55%, transparent 100%),
    radial-gradient(circle 0.7px at 60px 12px, rgba(255, 255, 255, 0.24) 0 55%, transparent 100%);
  background-repeat: repeat;
  background-size:
    210px 210px, 210px 210px,
    210px 210px, 210px 210px, 210px 210px, 210px 210px, 210px 210px, 210px 210px,
    130px 130px, 130px 130px, 130px 130px, 130px 130px, 130px 130px,
    70px 70px, 70px 70px, 70px 70px;
  opacity: 0.9;
  /* A slow breath rather than a per-star twinkle: one shared opacity is a
     single composited property, where 200 individually blinking dots would
     be 200 animations on a 954MB box. `alternate` matters -- a one-way
     eased loop restarts at full speed and hitches once per lap, which is
     the same reason the orbit spins are linear (see MOTION.md). */
  animation: starfield-breathe 14s var(--ease-soft) infinite alternate;
  z-index: 0;
  pointer-events: none;
}

@keyframes starfield-breathe {
  from { opacity: 0.74; }
  to   { opacity: 1; }
}

body::after {
  content: '';
  position: fixed;
  inset: -20%;
  background:
    radial-gradient(circle at 25% 8%, rgba(124, 140, 255, 0.22), transparent 55%),
    radial-gradient(circle at 85% 75%, rgba(77, 216, 230, 0.14), transparent 50%),
    radial-gradient(circle at 10% 85%, rgba(255, 95, 109, 0.10), transparent 45%);
  z-index: 0;
  pointer-events: none;
}

/* Occasional falling stars, staggered so only one streaks by at a time */
.shooting-stars {
  position: fixed;
  inset: 0;
  overflow: hidden;
  z-index: 0;
  pointer-events: none;
}

.shooting-stars span {
  position: absolute;
  width: var(--head, 2px);
  height: var(--head, 2px);
  background: #eaf6ff;
  border-radius: 50%;
  opacity: 0;
  box-shadow: 0 0 6px 1.5px rgba(210, 240, 255, 0.95), 0 0 16px 3px rgba(120, 190, 255, 0.45);
  animation-name: shooting-star-fall;
  animation-iteration-count: infinite;
  /* Per-keyframe curves below; nothing global, so the long invisible wait
     between streaks can't get eased. */
  animation-timing-function: linear;
}

/* The trail. It extends along the element's own -x axis, and the element is
   rotated by --angle, which is ALSO the direction it flies -- so the trail
   is always exactly behind the head and the two cannot disagree.
   They used to: travel was a hardcoded translate(-168px, 358px) (down and
   left) while the rotation pointed the trail down and RIGHT, so the streak
   crossed its own path at roughly a right angle. That is what made it look
   wrong, and it was invisible in the stylesheet -- it needed the vector
   written out to see it. */
.shooting-stars span::before {
  content: '';
  position: absolute;
  top: 50%;
  right: 0;
  width: var(--tail, 150px);
  height: 1px;
  /* Brightest where it meets the head, fading to nothing at the far end.
     This was reversed -- 0.9 alpha at the FAR tip and transparent at the
     head -- so the bright part of the streak was a detached dash trailing
     ~150px behind a dim dot. */
  background: linear-gradient(90deg,
    transparent 0%,
    rgba(150, 210, 255, 0.28) 38%,
    rgba(200, 234, 255, 0.62) 72%,
    rgba(235, 248, 255, 0.95) 100%);
  transform: translateY(-50%);
  filter: blur(0.4px);
}

/* The streak itself used to run on cubic-bezier(0.25, 0.1, 0.4, 1), whose
   end control point sits at y=1 — so the star decelerated to a near stop
   in the last third of its travel and then blinked out, which is the
   opposite of how a meteor reads. It also snapped from invisible to full
   brightness in a single step. Now it brightens in over its first stretch,
   keeps gaining speed the whole way across, and dims out while still
   moving. */
/* rotate() FIRST, then translateX(): the translate happens in the rotated
   frame, so --angle alone decides the direction of flight, and the trail
   (which lies along the same local axis) follows behind it by construction.
   Distance is in vmax so a streak crosses the same proportion of a phone
   and a desktop, instead of the old fixed 168x358px -- on a 390px screen
   that ran off the left edge almost immediately. */
@keyframes shooting-star-fall {
  /* The streak is the last 1.4% of the cycle; everything before it is the
     wait. That single number and the four durations below are what set
     both "slow" and "rare", and they pull in opposite directions: the
     window is a PERCENTAGE shared by all four stars (keyframe offsets
     can't take a custom property), so a longer wait would also stretch the
     flight. 1.4% against 96-146s cycles gives a 1.3-2.0s crossing about
     twice a minute. Widen the window and the meteors get slower AND more
     frequent together -- which is how an earlier pass ended up at one
     every 11 seconds. */
  0%, 98.6% {
    opacity: 0;
    transform: rotate(var(--angle, 115deg)) translateX(0);
    animation-timing-function: cubic-bezier(0.42, 0.04, 0.7, 0.62);
  }
  98.85% {
    opacity: 1;
    animation-timing-function: linear;
  }
  99.75% {
    opacity: 0.85;
    animation-timing-function: var(--ease-exit);
  }
  100% {
    opacity: 0;
    transform: rotate(var(--angle, 115deg)) translateX(var(--travel, 70vmax));
  }
}

/* Long, staggered cycles so at most one streaks by, only occasionally. */
/* --angle is the heading: ~115deg is down-and-left, the direction the old
   translate actually went. Varied a little per streak, along with head
   size, trail length and distance, so no two look stamped from the same
   die. They start off the top/right edge and fly in, rather than winking
   into existence in open sky. */
/* Long slow arcs: rare, big, with tails you can actually watch travel.
   --angle is the heading (~115deg is down-and-left) and the trail follows
   from it. Durations are deliberately not multiples of each other, so the
   four don't drift into lockstep and arrive together every few minutes.
   Streak length = 1.4% of each duration: 1.34s, 1.54s, 1.79s, 2.04s, and
   together they arrive about twice a minute. The delays are spread across
   the longest cycle so the four don't bunch up early on. */
.shooting-stars span:nth-child(1) { top: -8%;  left: 34%;  --angle: 117deg; --head: 2.5px; --tail: 300px; --travel: 118vmax; animation-duration: 96s;  animation-delay: 6s; }
.shooting-stars span:nth-child(2) { top: -10%; left: 86%;  --angle: 124deg; --head: 3px;   --tail: 380px; --travel: 132vmax; animation-duration: 110s; animation-delay: 40s; }
.shooting-stars span:nth-child(3) { top: -6%;  left: 58%;  --angle: 110deg; --head: 2px;   --tail: 250px; --travel: 104vmax; animation-duration: 128s; animation-delay: 74s; }
.shooting-stars span:nth-child(4) { top: -9%;  left: 104%; --angle: 131deg; --head: 2.5px; --tail: 330px; --travel: 124vmax; animation-duration: 146s; animation-delay: 108s; }

.app-container {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 480px;
  min-width: 0;
  padding: 12px 16px;
  /* Clears the fixed .top-bar above it. The bar's visible chrome (tab
     pills and the round icon buttons) bottoms out at ~60px -- the rest of
     its ~80px box is the padding that keeps a tab's glow and unread badge
     from being clipped -- so this leaves ~20px of real breathing room
     under it. Plus the status bar / cutout inset, since the bar above
     grew by the same amount when drawn edge-to-edge. */
  padding-top: calc(80px + env(safe-area-inset-top, 0px));
  /* And clear of the gesture bar at the bottom, so the primary action on
     every screen isn't sitting under it. */
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
  display: flex;
  flex-direction: column;
}

/* --- One screen, no page scrolling ---------------------------------------
   Every screen is sized to exactly the visible area and the page itself
   never scrolls. Each view nominates ONE region as `.view-scroll`: the
   part whose content can grow without limit (the prompt list, the player
   list, the answer fields, the voting options, the results breakdown).
   That region absorbs whatever height the screen's fixed controls leave
   over and scrolls inside itself, so a growing list can never push a
   Submit/Start/Next button off the bottom -- which is what used to make
   every in-game screen a long scroll with the fixed top bar sliding over
   its content.
   The chain each `.view-scroll` depends on: .app-container is exactly one
   viewport tall -> .view.active grows into it -> the card (and the form
   inside it, for the submissions/voting screens) is a flex column that's
   allowed to shrink -> the scroll region is the single flex-growing child.
   Every link needs `min-height: 0`, or a flex item refuses to shrink below
   its content and the overflow simply moves one level up.
   Gated on a viewport tall enough to hold each screen's fixed controls
   plus a usable scroll region. Below 601px the chain has nowhere left to
   give and `min-height: 0` would let a list spill over the buttons rather
   than shrink, so the whole block stops applying and every screen falls
   back to an ordinary scrolling page -- nothing overlaps, nothing is
   unreachable. No current phone is that short in portrait. */
@media (min-height: 601px) {
  html, body {
    overflow: hidden;
    height: 100%;
  }

  .app-container {
    /* dvh where supported: in a browser tab (not the installed PWA) 100vh
       is taller than what's actually visible, which would push the bottom
       control under the address bar. */
    height: 100vh;
    height: 100dvh;
    min-height: 0;
  }

  .view.active {
    flex: 1;
    min-height: 0;
  }

  .view.active > .card {
    display: flex;
    flex-direction: column;
    min-height: 0;
    max-height: 100%;
  }

  /* A card containing a scroll region grows to fill the screen, so the
     region gets all the height that's going. Without this the card stays
     content-sized and the list ends up with a small share of it while
     empty space sits below the card -- with the scroll fade on the last
     row, which reads as content clipped for no reason. Cards with nothing
     scrollable in them (home, the queue) stay their natural size rather
     than stretching into a mostly-empty panel. */
  .view.active > .card:has(.view-scroll),
  .view.active > .view-scroll {
    flex: 1 1 auto;
  }

  /* The scroll region is the only part of a screen allowed to give up
     height. Everything else -- a room-code badge, the black hole stage,
     the space-fact box, a heading -- keeps its natural size instead of
     being squeezed by flexbox's default shrink, which is what turns a
     fixed-height decorative element into a squashed one. */
  .view.active > .card > *:not(.view-scroll),
  .view-form > *:not(.view-scroll) {
    flex: 0 0 auto;
  }

  /* The submissions/voting screens put their scroll region inside a form,
     so the form is another link in the chain. */
  .view.active > .card > .view-form {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
  }

  .view-scroll {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    /* Keep an overscroll inside the region: chaining it to the page is
       what let a flick at the top of the voting list trigger the
       browser's pull-to-refresh. */
    overscroll-behavior: contain;
    scrollbar-width: none;
    -ms-overflow-style: none;
    /* A hard cut at the edge reads as content clipped by mistake; fading
       the last ~20px says "there's more below" instead. Only applied while
       the region is actually the flex-growing one -- in the short-screen
       fallback below it's just normal page content and a fade there would
       dim text for no reason. */
    mask-image: linear-gradient(to bottom, #000 calc(100% - 20px), transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 20px), transparent 100%);
  }

  .view-scroll::-webkit-scrollbar {
    display: none;
  }

  /* Create Room's prompt list sits one level deeper, inside the mode
     section, so that section has to pass the growth through too. */
  #classic-mode-section {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
  }

}

.top-bar-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
}

/* The download button. Tinted cyan rather than left plain like Round
   History and Invites, because it's an offer rather than a place you go --
   and it only exists until the app is installed, so it can afford to draw
   the eye.
   The icon is an inline SVG, not the ⬇️ emoji: the emoji renders as a
   chunky blue rounded square whose own background fights a round button,
   and it looks different on every platform's emoji font. A stroked path
   inherits `color` and stays sharp at any density. */
.install-header-btn {
  border-color: rgba(77, 216, 230, 0.4);
  box-shadow: var(--card-shadow-soft), 0 0 14px rgba(77, 216, 230, 0.2);
  color: var(--accent-cyan);
}

.install-header-btn svg {
  display: block;
}

/* The install-only dialog. Its row reuses .install-bar for the look, but
   NOT the sticky positioning: there's nothing scrolling past it here, and
   `position: sticky` with a z-index would only fight the modal's own
   stacking. */
.install-bar-static {
  position: static;
  margin: 0 0 4px;
}

.install-modal-card .install-steps {
  margin-bottom: 0;
}

.install-header-btn:active {
  border-color: rgba(77, 216, 230, 0.7);
}

/* Results is the one screen whose scroll region isn't inside the card:
   the scoreboard card and the per-prompt breakdown scroll together, with
   the host's actions pinned below them. */
.results-scroll {
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.results-scroll > * {
  flex: 0 0 auto;
}

.results-actions {
  display: flex;
  flex-direction: column;
  gap: 10px;
  flex: 0 0 auto;
}

/* With no buttons showing (a non-host watching the results) this row would
   still eat the .view's 15px gap above it. */
.results-actions:not(:has(.btn:not(.hidden))) {
  display: none;
}

/* Short phones: the fixed parts of this card (name, mode, rounds, action
   buttons) don't fit alongside a usable prompt list at ~667px tall, so
   trim their padding/margins rather than let anything get cut off. No
   content is dropped -- it's the same screen, just tighter. */
@media (max-height: 740px) {
  .app-container {
    padding-top: calc(72px + env(safe-area-inset-top, 0px));
  }

  /* The cluster scales itself to whatever stage it's given, so a shorter
     stage costs proportion, not content. */
  .score-constellation {
    height: 168px;
    margin-bottom: 10px;
  }

  #view-create .card {
    padding: 14px;
  }

  #view-create h2 {
    font-size: 19px;
    margin-bottom: 6px;
  }

  #view-create h3 {
    margin: 8px 0 4px;
  }

  #view-create .mode-btn {
    padding: 8px 6px;
  }

  #view-create .mode-btn-hint {
    font-size: 9.5px;
  }

  #view-create .btn.primary.mt-20 {
    margin-top: 10px;
    padding: 12px;
  }

  #view-create .categories-scroll {
    min-height: 48px;
  }

  #view-create input[type="text"] {
    padding: 9px 14px;
    margin-bottom: 6px;
  }

  #view-create .mode-toggle {
    gap: 8px;
    margin-bottom: 0;
  }

  #view-create .rounds-header-row {
    margin: 8px 0 4px;
  }

  #view-create .rounds-header-text .hint-fine {
    margin-top: 2px;
  }

  /* The waiting screen's fixed parts (title, hint, the black hole, the
     pending list's heading and the space fact) are what squeeze the
     pending list on a short phone -- trim the decoration rather than let
     the list collapse to nothing. */
  #view-waiting .black-hole-stage {
    height: 150px;
    margin-bottom: 10px;
  }

  #view-waiting .space-fact-box {
    margin-top: 10px;
    padding: 11px 13px;
  }

  #view-waiting h2,
  #view-lobby h2,
  #view-voting h2,
  #view-submissions h2 {
    font-size: 19px;
    margin-bottom: 6px;
  }

  #view-lobby .room-badge {
    padding: 11px;
  }

  #view-lobby .room-badge span {
    font-size: 26px;
  }
}

#rounds-lock-hint {
  margin-bottom: 8px;
}

/* Positioning context for the idea-suggestion overlay below -- needed at
   every screen size, including the sub-661px fallback where this section
   isn't a flex container. */
#classic-mode-section {
  position: relative;
}

/* The sub-601px fallback: no locked layout anywhere, so every scroll
   region goes back to a fixed cap and the page scrolls as a whole. Capped
   rather than uncapped so one long list still can't own the entire screen
   and bury the control underneath it. */
@media (max-height: 600px) {
  .view-scroll {
    max-height: 240px;
    overflow-y: auto;
    scrollbar-width: none;
    -ms-overflow-style: none;
  }

  .view-scroll::-webkit-scrollbar {
    display: none;
  }
}

.account-header-btn {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  box-shadow: var(--card-shadow-soft);
  font-size: 16px;
  cursor: pointer;
  transition: border-color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press),
              box-shadow var(--t-hint) var(--ease-press);
}

/* Always-visible fixed strip: session tabs on the left, account button
   pinned to its right -- both stay reachable and in place regardless of
   scroll position or which screen/session is active. */
.top-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: 480px;
  margin: 0 auto;
  padding: 10px 16px;
  /* Clear of the status bar / cutout when the app is drawn edge-to-edge. */
  padding-top: calc(10px + env(safe-area-inset-top, 0px));
  padding-left: calc(16px + env(safe-area-inset-left, 0px));
  padding-right: calc(16px + env(safe-area-inset-right, 0px));
  /* No background of its own -- sits directly on the page's starfield like
     it always did, just pinned in place now instead of scrolling away.
     A solid backdrop here read as a banner floating above the card. */
  pointer-events: none;
}

.top-bar > * {
  pointer-events: auto;
}

.account-header-btn:active {
  transform: scale(0.92);
}

.eyebrow {
  font-size: 11px;
  letter-spacing: 2.5px;
  text-transform: uppercase;
  color: var(--accent-cyan);
  font-weight: 700;
  margin-bottom: 6px;
}

/* --- Session tabs --- */
.session-tabs-wrap {
  position: relative;
  flex: 1;
  min-width: 0;
}

.session-tabs {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  /* overflow-x: auto clips on both axes, so this padding is what keeps a
     tab's unread badge (top: -3px) and active glow from being cut off. */
  padding: 12px 2px 14px;
  scrollbar-width: none;
  -ms-overflow-style: none;
  touch-action: pan-x;
  scroll-behavior: smooth;
}

.session-tabs::-webkit-scrollbar {
  display: none;
}

.session-tab {
  position: relative;
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 6px;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  border-radius: 999px;
  padding: 6px 14px 6px 6px;
  cursor: pointer;
  font-weight: 600;
  font-size: 13px;
  color: var(--text-muted);
  white-space: nowrap;
  box-shadow: var(--card-shadow-soft);
  transition: border-color var(--t-hint) var(--ease-press),
              color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press);
}

.session-tab:active {
  transform: scale(0.97);
}

/* The seat is live on another device. Deliberately still legible and still
   tappable -- this is not a disabled state, it's an invitation to take the
   room back, and it must not read as "this room is gone". */
.session-tab.handed-off {
  opacity: 0.55;
  border-style: dashed;
}

.session-tab.handed-off .tab-avatar {
  filter: grayscale(0.7);
}

.tab-handoff {
  font-size: 11px;
  color: var(--accent-cyan);
  font-weight: 800;
}

.session-tab.active {
  border-color: var(--accent-cyan);
  color: var(--text);
  box-shadow: 0 0 16px rgba(77, 216, 230, 0.25);
}

.session-tab .tab-avatar {
  width: 22px;
  height: 22px;
  flex: 0 0 auto;
}

.session-tab .badge-dot {
  position: absolute;
  top: -3px;
  right: -3px;
  width: 11px;
  height: 11px;
  background: var(--accent-coral);
  border-radius: 50%;
  border: 2px solid var(--bg);
  box-shadow: 0 0 8px rgba(255, 95, 109, 0.8);
}

.session-tab.add-tab {
  background: transparent;
  border: 1px dashed rgba(255, 255, 255, 0.25);
  color: var(--text-muted);
  font-size: 16px;
  padding: 6px 16px;
}

/* A brand-new tab's arrival cue. Deliberately the same fade+scale morph as
   everything else, just travelling a little further because a tab is a
   small element: the previous version used a springy overshoot curve
   (cubic-bezier(0.34, 1.56, 0.64, 1)) from scale 0.6, which was the only
   bouncy motion in the app and read as a different, showier language than
   the restrained morph used everywhere around it. */
.session-tab.tab-pop-in {
  animation: view-morph-in var(--t-in) var(--ease-enter) both;
  --morph-scale: 0.84;
}

.view {
  display: none;
  flex-direction: column;
  gap: 15px;
  min-width: 0;
}

.view.active {
  display: flex;
}

/* The core morph. One keyframe pair drives every fade+scale in the app;
   how far it travels is per-element via --morph-scale, so a whole screen
   stays restrained while a small chip is allowed a bit more movement. */
@keyframes view-morph-out {
  from { opacity: 1; transform: scale(1); }
  to { opacity: 0; transform: scale(var(--morph-scale, 0.97)); }
}

@keyframes view-morph-in {
  from { opacity: 0; transform: scale(var(--morph-scale, 0.97)); }
  to { opacity: 1; transform: scale(1); }
}

.view.active {
  animation: view-morph-in var(--t-in) var(--ease-enter) both;
  --morph-scale: var(--morph-scale-view);
}

/* Must out-specify `.view.active` above AND come after it: the outgoing
   screen keeps `.active` until the swap lands, so with the old, equally
   specific `.view.view-collapsing` the later `.view.active` rule won the
   cascade, animation-name never changed to view-morph-out, and no new
   animation started. The screen simply froze at full opacity for the whole
   outgoing duration and then hard-cut. Measured before this fix: opacity
   pinned at 1 and scale at 1 for all 220ms. */
.view.active.view-collapsing {
  animation: view-morph-out var(--t-out) var(--ease-exit) forwards;
}

/* Same morph vocabulary, sized for smaller in-place content swaps (mode
   sections, history round tabs) rather than whole screens. */
.morph-swap-out {
  animation: view-morph-out var(--t-swap-out) var(--ease-exit) forwards;
  --morph-scale: var(--morph-scale-item);
}

.morph-swap-in {
  animation: view-morph-in var(--t-swap-in) var(--ease-enter) both;
  --morph-scale: var(--morph-scale-item);
}

/* While a container is gliding between two content heights, nothing should
   spill past its new edge mid-flight. */
.morph-height {
  overflow: hidden;
}

.card {
  background: var(--card-sheen), var(--card-base-sky);
  padding: 18px;
  border-radius: 28px;
  border: 1px solid var(--card-border);
  box-shadow: var(--card-shadow), 0 24px 60px var(--shadow-dark);
}

/* Home screen: name + planet live in one bundle up top, Join and Create
   are their own nested sub-bundles below it inside the SAME outer card --
   so picking a name/planet visibly belongs to both, instead of Create
   floating outside the card like an unrelated, disconnected action. */
.identity-bundle {
  margin-bottom: 4px;
}

.action-bundle {
  display: flex;
  flex-direction: column;
  justify-content: center;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--border-soft);
  border-radius: 20px;
  padding: 12px;
  margin-bottom: 4px;
}

.action-bundle > *:last-child {
  margin-bottom: 0;
}

h2 {
  font-size: 22px;
  font-weight: 800;
  margin-bottom: 10px;
  color: var(--text);
  letter-spacing: 0.2px;
}

h3 {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  margin: 12px 0 6px;
  color: var(--accent-cyan);
}

.hint {
  font-size: 13px;
  color: var(--text-muted);
  margin-bottom: 15px;
}

/* A validation message belongs tucked up against the field it's about --
   but only when it actually follows one. The negative top margin used to
   be unconditional, so the same rule also pulled the sign-in error up on
   top of the "Remember me" row above it (a measured 4px collision), and
   had to be cancelled by hand wherever a hint sat under a heading
   instead. Now the tuck is scoped to the case it was written for, and
   everything else gets ordinary spacing for free. */
.hint-error,
.hint-success {
  margin-top: 0;
  margin-bottom: 10px;
  font-weight: 600;
}

.hint-error {
  color: var(--accent-coral);
}

input + .hint-error,
input + .hint-success,
.password-field + .hint-error,
.password-field + .hint-success {
  margin-top: -6px;
}

/* A footnote-level aside (e.g. the rounds stall-timeout note) rather than
   a decision-relevant instruction -- smaller and tighter than a normal
   .hint so it doesn't compete with the actual controls around it. */
.hint-fine {
  font-size: 11px;
  margin-top: -12px;
  margin-bottom: 10px;
  line-height: 1.5;
}

.rounds-header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 16px 0 8px;
}

.rounds-header-text {
  flex: 1;
  min-width: 0;
}

.rounds-header-row h3 {
  margin: 0;
}

.rounds-header-text .hint-fine {
  margin-top: 4px;
  margin-bottom: 0;
}

/* A scroll-to-change stepper, not a text field -- ns-resize on the cursor
   plus a hover highlight and tiny arrows are the only cues that this cell
   does anything, since there's no visible box/border to imply "type here". */
.rounds-stepper {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 5px 12px;
  border-radius: 999px;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--border-soft);
  cursor: ns-resize;
  user-select: none;
  transition: border-color var(--t-hint) var(--ease-press),
              background-color var(--t-hint) var(--ease-press);
}

.rounds-stepper:hover,
.rounds-stepper:focus-visible {
  border-color: var(--accent-cyan);
  background-color: rgba(77, 216, 230, 0.10);
  outline: none;
}

.rounds-stepper-value {
  font-size: 17px;
  font-weight: 800;
  color: var(--text);
  min-width: 1.3em;
  text-align: center;
  cursor: ns-resize;
  /* This is the drag surface -- without it, a touch-drag here scrolls the
     page instead of reaching our pointermove handler. */
  touch-action: none;
}

.rounds-stepper-value.dragging {
  color: var(--accent-cyan);
}

.rounds-stepper-arrows {
  display: flex;
  flex-direction: column;
  gap: 1px;
  line-height: 0.7;
  font-size: 9px;
  color: var(--text-muted);
}

.rounds-stepper-arrow {
  cursor: pointer;
  padding: 1px 2px;
  transition: color var(--t-hint) var(--ease-press);
}

.rounds-stepper-arrow:hover {
  color: var(--accent-cyan);
}

.hint-success {
  color: var(--accent-green);
}

.input-error {
  border-color: var(--accent-coral) !important;
  box-shadow: 0 0 0 3px rgba(255, 95, 109, 0.15) !important;
}

input[type="text"], input[type="number"], input[type="email"], input[type="password"], select, textarea {
  width: 100%;
  padding: 11px 16px;
  margin-bottom: 10px;
  border: 1px solid var(--border-soft);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.04);
  color: var(--text);
  font-size: 15px;
  font-weight: 500;
  outline: none;
  /* Focus/error rings used to appear and vanish in a single frame, the only
     interactive elements in the app that didn't ease. */
  transition: border-color var(--t-hint) var(--ease-press),
              box-shadow var(--t-hint) var(--ease-press);
}

input::placeholder, textarea::placeholder {
  color: var(--text-muted);
}

input[type="text"]:focus, input[type="number"]:focus, input[type="email"]:focus, input[type="password"]:focus, textarea:focus {
  border-color: var(--accent-cyan);
  box-shadow: 0 0 0 3px rgba(77, 216, 230, 0.15);
}

/* Submission/prompt answers: grows in height (via JS, on input) instead of
   clipping or scrolling long text sideways — a full pill shape stops
   looking right once it's more than one line tall, so this gets its own,
   more modest corner radius. */
.answer-textarea {
  border-radius: 18px;
  resize: none;
  overflow: hidden;
  line-height: 1.4;
  min-height: 44px;
  font-family: inherit;
  /* Gaining a line used to be an instant jump of a whole line-height, which
     shoved everything below it down in one frame. Height is a layout
     property, but it's the honest one here — this is a box actually
     getting taller — and it's a single short-lived element, so the cost is
     a relayout of one card, not a per-frame transform substitute that
     would lie about where the text sits. See autoGrowTextarea, which
     measures without clobbering the live height so this has a real start
     value to animate from. */
  transition: border-color var(--t-hint) var(--ease-press),
              box-shadow var(--t-hint) var(--ease-press),
              height var(--t-swap-in) var(--ease-enter);
}

/* First paint sizes itself with no animation — a restored long answer
   should already be the right height, not grow into it. */
.answer-textarea.no-grow-anim {
  transition: none;
}

.btn {
  width: 100%;
  padding: 15px;
  border: none;
  border-radius: 999px;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: transform var(--t-press) var(--ease-press),
              box-shadow var(--t-press) var(--ease-press),
              filter var(--t-press) var(--ease-press);
}

.btn:active {
  transform: scale(0.97);
}

.btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  filter: grayscale(0.4);
}

.btn:disabled:active {
  transform: none;
}

.btn.primary {
  background: linear-gradient(160deg, #ff7a85 0%, var(--accent-coral) 45%, var(--accent-coral-2) 100%);
  color: #ffffff;
  border: 1px solid rgba(255, 255, 255, 0.14);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.45),
    inset 0 -4px 8px rgba(120, 10, 25, 0.35),
    0 14px 32px rgba(230, 67, 90, 0.45),
    0 0 26px rgba(255, 95, 109, 0.35);
}

.btn.primary:active {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.3),
    inset 0 -1px 3px rgba(120, 10, 25, 0.3),
    0 6px 16px rgba(230, 67, 90, 0.35);
}

.btn.secondary {
  background: linear-gradient(160deg, #8ff2ec 0%, var(--accent-cyan) 45%, var(--accent-cyan-2) 100%);
  color: #06070f;
  border: 1px solid rgba(255, 255, 255, 0.18);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.5),
    inset 0 -4px 8px rgba(10, 70, 80, 0.3),
    0 10px 26px rgba(77, 216, 230, 0.35);
}

.btn.secondary:active {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 3px rgba(10, 70, 80, 0.25),
    0 5px 14px rgba(77, 216, 230, 0.25);
}

.btn.small {
  width: auto;
  padding: 9px 16px;
  font-size: 13px;
  background: var(--card-sheen), var(--card-base-soft);
  color: var(--text);
  border: 1px solid var(--card-border);
  box-shadow: var(--card-shadow-soft);
}

.btn.link {
  background: none;
  box-shadow: none;
  color: var(--text-muted);
  text-decoration: underline;
  margin-top: 10px;
  font-weight: 500;
}

.btn.link:active {
  transform: none;
}

.divider {
  display: flex;
  align-items: center;
  text-align: center;
  color: var(--text-muted);
  margin: 6px 0;
}

.divider::before, .divider::after {
  content: '';
  flex: 1;
  border-bottom: 1px solid var(--border-soft);
}

.divider span {
  padding: 0 10px;
  font-size: 12px;
}

.room-badge {
  text-align: center;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  border-radius: 20px;
  padding: 16px;
  box-shadow: var(--card-shadow-soft);
}

.room-badge::before {
  content: 'ROOM CODE';
  display: block;
  font-size: 10px;
  letter-spacing: 2px;
  color: var(--text-muted);
  margin-bottom: 6px;
  font-weight: 700;
}

.room-badge span {
  font-size: 30px;
  font-weight: 800;
  letter-spacing: 6px;
  color: var(--accent-cyan);
  text-shadow: 0 0 20px rgba(77, 216, 230, 0.5);
}

.queue-badge::before {
  content: 'QUEUE POSITION';
}

.player-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 8px;
  /* The pills carry a drop shadow and this is a scroll region, so without
     a little side padding the shadow gets sliced off at the edges. */
  padding: 0 2px;
}

.player-list li {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  border-radius: 999px;
  font-size: 14px;
  font-weight: 600;
  box-shadow: var(--card-shadow-soft);
}

.player-name {
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
}

.player-avatar {
  width: 30px;
  height: 30px;
  flex: 0 0 auto;
}

/* Host-only, and coral because it removes someone -- but small and quiet,
   since it sits on every row of a list the host reads far more often than
   they act on. */
.skip-player-btn {
  flex: 0 0 auto;
  padding: 4px 11px;
  border-radius: 999px;
  border: 1px solid rgba(255, 95, 109, 0.4);
  background: rgba(255, 95, 109, 0.1);
  color: var(--accent-coral);
  font-size: 11px;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--t-hint) var(--ease-press),
              border-color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press);
}

.skip-player-btn:hover {
  background: rgba(255, 95, 109, 0.2);
  border-color: rgba(255, 95, 109, 0.7);
}

.skip-player-btn:active {
  transform: scale(0.93);
}

/* Players feel alive in the lobby list instead of sitting still.
   Perpetual rotation stays `linear` on purpose — see the motion system note
   in :root. */
.player-avatar img {
  animation: planet-self-spin 16s linear infinite;
}

/* Animates the independent `rotate` property, NOT `transform`.
   avatarSvg() puts an inline `transform: scale(...)` on a few planets whose
   source art has extra padding (venus/jupiter/uranus/neptune). A
   `to { transform: rotate(360deg) }` keyframe has no `from`, so the browser
   filled it from the element's underlying value — that inline scale — and
   then interpolated scale(1.38) -> rotate(360deg) as decomposed matrices.
   The result was those four planets slowly shrinking from 1.38x to 1.0x
   over each spin and snapping back at the loop point: measured 41px vs
   30.6px rendered for two planets in the same 30px slot. `rotate` composes
   with `transform` instead of replacing it, so the zoom correction
   survives the spin. */
@keyframes planet-self-spin {
  from { rotate: 0deg; }
  to { rotate: 360deg; }
}

.player-list li:nth-child(2n) .player-avatar img { animation-duration: 12s; animation-direction: reverse; }
.player-list li:nth-child(3n) .player-avatar img { animation-duration: 20s; }
.player-list li:nth-child(4n) .player-avatar img { animation-duration: 9s; animation-direction: reverse; }
.player-list li:nth-child(5n) .player-avatar img { animation-duration: 15s; }

/* --- Black hole: submitted planets get sucked in one by one --- */
.black-hole-stage {
  position: relative;
  width: 100%;
  height: 190px;
  margin: 4px 0 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

/* Face-on spiral galaxy (matches the reference image: a top-down multi-arm
   spiral with a bright nucleus, not a tilted fiery accretion disk), built
   as an SVG so the arms can be real logarithmic-spiral curves instead of
   conic-gradient pie slices. Rotates as a plain, unscaled rotate() — no
   tilt/flatten anywhere in this scene, on purpose: a rotation sandwiched
   between a scale and its inverse doesn't commute, which is exactly what
   caused the orbiting planets to visibly warp before this was simplified. */
.galaxy-spiral {
  position: absolute;
  width: 220px;
  height: 220px;
  z-index: 1;
  overflow: visible;
}

.spiral-arms-group {
  animation: galaxy-rotate 46s linear infinite;
}

.spiral-arm {
  fill: none;
  stroke-width: 3;
  stroke-linecap: round;
}

.spiral-arm.thin {
  stroke-width: 2.2;
  opacity: 0.75;
}

/* The arms used to be softened with `filter: blur(0.5px)` on the rotating
   <g>, which forces the browser to re-run a blur over the whole 220px
   scene on every frame of a permanently-running rotation. The same soft
   edge comes free from a second, wider, fainter stroke drawn under each
   arm (see buildGalaxySpiral), leaving the rotation as a plain composited
   transform with nothing to rasterize per frame. */
.spiral-arm.halo {
  stroke-width: 7;
  opacity: 0.16;
}

@keyframes galaxy-rotate {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.black-hole-core {
  position: relative;
  width: 54px;
  height: 54px;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, #f2fbff 0%, #dcefff 10%, #050914 48%, rgba(5, 9, 20, 0) 100%);
  box-shadow: 0 0 30px 8px rgba(190, 220, 255, 0.55), 0 0 64px 18px rgba(124, 140, 255, 0.28);
  z-index: 2;
  transition: box-shadow var(--t-in) var(--ease-enter), transform var(--t-in) var(--ease-enter);
}

.black-hole-core.pulse {
  animation: blackhole-pulse 620ms var(--ease-enter);
}

/* A slow, ever-present "breathing" glow on its own pseudo-layer — separate
   from .pulse's sharp capture flash above so the two never fight over the
   same transform/box-shadow — is what actually sells "this is pulling
   things in" even in the quiet moments when nobody has finished yet. */
.black-hole-core::before {
  content: '';
  position: absolute;
  inset: -14px;
  border-radius: 50%;
  box-shadow: 0 0 26px 8px rgba(200, 225, 255, 0.4);
  opacity: 0.5;
  animation: core-idle-glow 3.6s var(--ease-soft) infinite;
  pointer-events: none;
}

/* A swallowed planet makes the core flare. Per-keyframe curves so the
   swell snaps out and the settle is long and soft, instead of the old
   symmetric `ease` that made both halves the same speed and stopped dead
   on the way back. */
@keyframes blackhole-pulse {
  0% {
    box-shadow: 0 0 30px 8px rgba(190, 220, 255, 0.55), 0 0 64px 18px rgba(124, 140, 255, 0.28);
    transform: scale(1);
    animation-timing-function: cubic-bezier(0.16, 0.84, 0.32, 1);
  }
  34% {
    box-shadow: 0 0 52px 18px rgba(220, 238, 255, 0.85), 0 0 104px 30px rgba(150, 170, 255, 0.5);
    transform: scale(1.11);
    animation-timing-function: var(--ease-soft);
  }
  100% {
    box-shadow: 0 0 30px 8px rgba(190, 220, 255, 0.55), 0 0 64px 18px rgba(124, 140, 255, 0.28);
    transform: scale(1);
  }
}

@keyframes core-idle-glow {
  0%, 100% { transform: scale(1); opacity: 0.4; }
  50% { transform: scale(1.18); opacity: 0.8; }
}

/* Ambient debris that's always trickling inward, angle fixed per mote and
   only its distance from center animated — reads as the black hole
   actively feeding even before any planet starts orbiting it. */
.accretion-field {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
  z-index: 2;
  pointer-events: none;
}

.accretion-mote {
  position: absolute;
  top: 0;
  left: 0;
  width: 4px;
  height: 4px;
  margin: -2px 0 0 -2px;
  border-radius: 50%;
  background: #eaf6ff;
  box-shadow: 0 0 7px 2px rgba(200, 225, 255, 0.85);
  opacity: 0;
  /* Infalling dust speeds up as it gets closer, so the distance curve
     accelerates instead of the old `linear`, which read as a mote being
     dragged inward on a string at constant speed. Safe to ease here even
     though it loops: the mote is fully transparent at both ends, so the
     restart is never visible. */
  animation: accretion-fall 4.6s cubic-bezier(0.52, 0.02, 0.76, 0.28) infinite;
  animation-delay: var(--delay, 0s);
}

@keyframes accretion-fall {
  0% { transform: rotate(var(--angle, 0deg)) translateX(var(--from, 94px)); opacity: 0; }
  14% { opacity: 0.95; }
  72% { opacity: 0.8; }
  100% { transform: rotate(var(--angle, 0deg)) translateX(8px); opacity: 0; }
}

/* Everything that orbits or falls lives inside one 0x0 field pinned to the
   centre of the stage, so the whole system can be scaled to fit as a unit.
   Per-player orbit radius is still uncapped and strictly increasing (see
   createOrbitElement — capping it is what made 5th+ players share a ring
   and eclipse each other), but an unbounded radius inside a 190px-tall
   stage means the outer rings get clipped by the stage edge: measured with
   8 players, four planets crossed the top/bottom boundary and vanished
   once per lap.

   Scaling the field is the part that's safe to do and capping is not: this
   is a single UNIFORM scale on a parent, and a uniform scale commutes with
   rotation, so nothing warps. It is not the nested squash-then-counter-
   squash that broke the planets before — there is no inverse scale
   anywhere, and nothing non-uniform. It also only changes when the player
   count changes, never per frame. */
.orbit-field {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
  z-index: 3;
  pointer-events: none;
  transform: scale(var(--orbit-fit, 1));
  transition: transform 520ms var(--ease-enter);
}

.falling-avatar {
  position: absolute;
  top: 0;
  left: 0;
  /* width/height/margin are set inline per-planet (see sizeAvatarEl) */
  z-index: 1;
  pointer-events: none;
  animation: fall-into-blackhole var(--t-fall) cubic-bezier(0.5, 0.01, 0.78, 0.32) forwards;
}

.falling-avatar .avatar-chip {
  width: 100%;
  height: 100%;
}

/* The planet keeps turning on its own axis while it's dragged in. On
   `rotate`, not `transform` — the wrapper's transform is what carries it
   around the core, and a few planets carry an inline scale correction. */
.falling-avatar img {
  animation: planet-self-spin 2.6s linear infinite;
}

.orbit-wrapper {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  animation-name: orbit-spin;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
  pointer-events: none;
}

@keyframes orbit-spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.orbit-avatar {
  position: absolute;
  top: 0;
  left: 0;
  /* width/height/margin are set inline per-planet (see sizeAvatarEl);
     transform (ring placement) is set inline too. */
}

.orbit-avatar .avatar-chip {
  width: 100%;
  height: 100%;
}

/* Independent inner layer: a rhythmic shrink + brighten "tugged by
   gravity" pulse, decoupled from the ring position above it so the pulse
   can never nudge a planet off its assigned radius. */
.orbit-pulse {
  width: 100%;
  height: 100%;
  animation: orbit-tug 2.8s var(--ease-soft) infinite;
}

/* A 0.8 scale was a 20% size swing on every planet every 2.4s, big enough
   that planets of genuinely different sizes became hard to tell apart and
   the whole ring read as jittery. Halved, and on a symmetric sine curve so
   it breathes rather than pumps. Still a pure uniform scale on a layer of
   its own, so it can never disturb the ring position above it. */
@keyframes orbit-tug {
  0%, 100% { transform: scale(1); filter: brightness(1); }
  55% { transform: scale(0.9); filter: brightness(1.2) saturate(1.12); }
}

/* An actual inward spiral rather than a straight line to the middle.
   Composed exactly like accretion-fall: rotate() sets the angle around the
   core, translateX() the distance from it, and both are interpolated at
   once, so the planet sweeps around as it closes in. The scale is uniform
   and applied after the rotation, never around it — this is not the
   squash/counter-squash pattern that warped the orbiting planets before.
   Start angle and radius are handed in per capture (see spawnFallingAvatar)
   so the planet peels off from exactly where it was orbiting. */
@keyframes fall-into-blackhole {
  0% {
    transform: rotate(var(--start-angle, 0deg)) translateX(var(--start-radius, 70px)) scale(1);
    opacity: 1;
  }
  72% {
    opacity: 1;
  }
  100% {
    transform: rotate(calc(var(--start-angle, 0deg) + var(--sweep, 170deg))) translateX(3px) scale(0.08);
    opacity: 0;
  }
}

.space-fact-box {
  background:
    linear-gradient(165deg, rgba(255, 255, 255, 0.08) 0%, transparent 55%),
    linear-gradient(160deg, rgba(124, 140, 255, 0.12) 0%, rgba(124, 140, 255, 0.04) 100%);
  border: 1px solid rgba(124, 140, 255, 0.28);
  border-radius: 18px;
  padding: 14px 16px;
  margin-top: 16px;
  box-shadow: var(--card-shadow-soft);
}

.space-fact-box .eyebrow {
  color: var(--accent-purple);
  margin-bottom: 4px;
}

.space-fact-box p {
  font-size: 13px;
  color: var(--text);
  line-height: 1.4;
}

.hidden {
  display: none !important;
}

.mt-20 {
  margin-top: 20px;
}

.mt-10 {
  margin-top: 10px;
}

/* --- Chips / badges --- */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px 11px;
  border-radius: 999px;
  background: var(--glass-strong);
  border: 1px solid var(--border-soft);
  font-size: 11px;
  font-weight: 700;
  color: var(--text-muted);
  white-space: nowrap;
}

.chip.host {
  color: var(--accent-yellow);
  border-color: rgba(255, 210, 63, 0.4);
  background: rgba(255, 210, 63, 0.1);
}

.chip.offline {
  color: var(--text-muted);
}

/* --- Avatar picker --- */
.avatar-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 6px;
  margin-bottom: 6px;
  /* Grid items default to min-width: auto, which lets a child's intrinsic
     content size push a track wider than its 1fr share -- min-width: 0
     forces every cell to actually respect the column width instead. */
  min-width: 0;
}

.planet-header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.planet-header-row h3 {
  margin: 12px 0 6px;
}

.avatar-selected-name {
  text-align: right;
  font-size: 13px;
  font-weight: 600;
  color: var(--accent-cyan);
  min-height: 18px;
  margin: 12px 0 6px;
}

.avatar-option {
  position: relative;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  border-radius: 16px;
  cursor: pointer;
  box-shadow: var(--card-shadow-soft);
  transition: transform var(--t-hint) var(--ease-enter),
              border-color var(--t-hint) var(--ease-enter),
              box-shadow var(--t-hint) var(--ease-enter);
  overflow: hidden;
  min-width: 0;
}

/* Square grid cells via the old padding-percentage trick instead of the
   `aspect-ratio` CSS property -- Safari/WebKit has real bugs sizing
   aspect-ratio inside a 1fr CSS Grid track (verified: cells came out
   91x106px, non-square, with the grid itself 17px wider than the
   viewport), while padding-top: 100% (percentage padding is always
   resolved against the element's own width) is rock solid everywhere. */
.avatar-option::before {
  content: '';
  display: block;
  padding-top: 100%;
}

.avatar-option .avatar-chip {
  position: absolute;
  inset: 6px;
}

/* Tapping Join/Start: the chosen tile's clone punches up to a full-screen
   hold, then flies back down onto the exact tile it came from (hidden via
   visibility while its clone is mid-flight, so there's no double image). */
.planet-zoom-backdrop {
  position: fixed;
  inset: 0;
  background: #000;
  opacity: 0;
  z-index: 199;
  pointer-events: none;
}

.avatar-chip.planet-zoom-avatar {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 200;
  pointer-events: none;
}

.avatar-option.selected {
  border-color: var(--accent-cyan);
  box-shadow: 0 0 18px rgba(77, 216, 230, 0.35);
  transform: scale(1.06);
}

.avatar-option.taken {
  cursor: not-allowed;
  opacity: 0.45;
  filter: grayscale(0.5) blur(1.5px);
}

.avatar-option.taken .avatar-chip {
  filter: blur(1.5px);
}

.avatar-option.taken::after {
  content: '✕';
  position: absolute;
  font-size: 18px;
  font-weight: 800;
  color: var(--text-muted);
}

/* --- Avatar ring chip (used everywhere an avatar renders) --- */
.avatar-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  border-radius: 50%;
  overflow: hidden;
  background: transparent;
}

/* The colored ring is reserved for voting-results contexts (scoreboard,
   answer entries, voter avatars) — everywhere else (picker, lobby, tabs,
   the black hole) shows the plain planet. */
.rank-avatar,
.answer-avatar,
.voter-avatar,
.legend-avatar {
  border: 2px solid var(--ring-color, var(--accent-cyan));
  box-shadow: 0 0 10px color-mix(in srgb, var(--ring-color, var(--accent-cyan)) 65%, transparent);
}

.avatar-chip img {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
}

/* --- Mode toggle --- */
.mode-toggle {
  display: flex;
  gap: 10px;
  margin-bottom: 4px;
}

.mode-btn {
  flex: 1;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  border-radius: 18px;
  padding: 12px;
  color: var(--text);
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  font-size: 14px;
  font-weight: 700;
  box-shadow: var(--card-shadow-soft);
  transition: border-color var(--t-hint) var(--ease-press),
              box-shadow var(--t-hint) var(--ease-press);
}

.mode-btn.active {
  border-color: var(--accent-cyan);
  box-shadow: 0 0 16px rgba(77, 216, 230, 0.25);
}

/* Pro-only modes on the free tier. Still tappable (the tap is what opens
   the upsell), so this is a visual state only -- no pointer-events or
   disabled attribute. */
.mode-btn.locked {
  position: relative;
  border-style: dashed;
  color: var(--text-muted);
}

.mode-btn.locked::after {
  content: '🔒';
  position: absolute;
  top: 5px;
  right: 7px;
  font-size: 9px;
  line-height: 1;
}

.mode-btn-hint {
  font-size: 11px;
  font-weight: 400;
  color: var(--text-muted);
}

/* --- Prompt label -------------------------------------------------------
   The same prompt text shows up on three screens -- answering it, voting
   on it, and reading it back in the breakdown -- and was styled three
   different ways: an inline-styled bold white <label>, a cyan uppercase
   letter-spaced <h3>, and the round card's own title. Uppercase plus
   letter-spacing is the worst of the three for the job, since a prompt is
   a sentence and regularly runs to two or three lines. One style for all
   three now, matching the round card's title. */
.prompt-label {
  display: block;
  color: var(--text);
  font-size: 15px;
  font-weight: 800;
  letter-spacing: 0.2px;
  line-height: 1.35;
  margin: 16px 0 8px;
  /* Prompts are player-typed (and in Adventure mode written by whoever's
     playing), so one can be an unbroken run of characters with nowhere to
     wrap -- which overflows the card rather than the line. */
  min-width: 0;
  overflow-wrap: anywhere;
}

.prompt-label:first-child {
  margin-top: 0;
}

/* --- Voting cards --- */
.vote-category {
  margin-bottom: 18px;
}

.vote-category:last-child {
  margin-bottom: 0;
}

.vote-option {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  background:
    linear-gradient(165deg, rgba(255, 255, 255, 0.10) 0%, rgba(255, 255, 255, 0.02) 32%, transparent 58%),
    linear-gradient(160deg, #1c2138 0%, #12152a 55%, #0b0d18 100%);
  border-radius: 20px;
  padding: 16px 18px;
  margin: 0 0 10px;
  cursor: pointer;
  border: 1px solid rgba(255, 255, 255, 0.07);
  font-weight: 500;
  font-size: 15px;
  color: var(--text);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.05) inset,
    0 16px 32px rgba(0, 0, 0, 0.45),
    0 3px 6px rgba(0, 0, 0, 0.35);
  transition: transform var(--t-press) var(--ease-press),
              box-shadow var(--t-hint) var(--ease-press),
              border-color var(--t-hint) var(--ease-press);
}

.vote-option:active {
  transform: scale(0.985) translateY(1px);
}

.vote-option:has(input:checked) {
  border-color: rgba(255, 95, 109, 0.55);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.08) inset,
    0 0 0 1px rgba(255, 95, 109, 0.2),
    0 16px 36px rgba(0, 0, 0, 0.5),
    0 0 28px rgba(255, 95, 109, 0.3);
}

.vote-option input[type="radio"] {
  accent-color: var(--accent-coral);
  width: 20px;
  height: 20px;
  margin: 0;
  flex: 0 0 auto;
}

/* A submitted answer is player-typed text, so it can be one long run with
   no spaces to break on (or just wider than the pill at any normal word
   break) -- without this, a flex item's default min-width:auto refuses to
   shrink below that unbroken run's full width and it overflows straight
   past the pill's rounded edge instead of wrapping inside it. */
.vote-option-text {
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
}

/* --- Results / round history --- */
/* Round cards never appear alone -- two to four of them stack in the
   results breakdown, and in Round History they stack *inside* the modal's
   own card. The shared card gradient runs #1c2138 at the top down to
   #0b0d18 at the bottom, a shade off both the page (#06070f) and the card
   behind them, so each card's lower half dissolved into the gap under it
   and a stack read as one continuous dark slab with a few seams rather
   than as separate cards. A flat, evenly-lifted surface with a hairline
   that stays visible along the bottom edge keeps each card's own outline
   legible all the way round. */
.round-card {
  background:
    linear-gradient(165deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.025) 100%),
    #161a30;
  border: 1px solid rgba(255, 255, 255, 0.13);
  border-radius: 24px;
  padding: 16px;
  margin-bottom: 16px;
  box-shadow: var(--card-shadow);
}

/* The stack's own gap is the container's, so a trailing margin here just
   adds dead space above the next control (or an odd scroll overshoot). */
.round-card:last-child {
  margin-bottom: 0;
}

.round-card h3 {
  color: var(--text);
  font-size: 15px;
  letter-spacing: 0.2px;
  text-transform: none;
  font-weight: 800;
  margin-top: 0;
  /* A prompt is player-typed, so it can be one unbroken 60-character run
     with no space to wrap at -- which used to run straight out through
     the card's right edge and off the screen. */
  min-width: 0;
  overflow-wrap: anywhere;
}

.answer-entry {
  background:
    linear-gradient(165deg, rgba(255, 255, 255, 0.05) 0%, transparent 55%),
    rgba(255, 255, 255, 0.03);
  border-radius: 14px;
  /* 6px/8px was noticeably tighter than every other boxed row in the app
     (.player-list li at 10px/14px, .vote-option at 16px/18px), which is
     what made a breakdown read as a cramped stack rather than a list of
     cards. */
  padding: 9px 12px;
  margin-bottom: 8px;
  display: flex;
  align-items: stretch;
  gap: 10px;
  border: 1px solid var(--border-soft);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 4px 10px rgba(0, 0, 0, 0.25);
}

.answer-entry:last-child {
  margin-bottom: 0;
}

.answer-entry.winner {
  border-color: var(--accent-yellow);
  box-shadow: 0 0 12px rgba(255, 210, 63, 0.18);
}

.answer-main {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 3px;
}

.answer-entry-head {
  display: flex;
  align-items: center;
  gap: 6px;
}

.answer-avatar {
  width: 18px;
  height: 18px;
  flex: 0 0 auto;
}

/* Player-typed text, so it can be one unbroken run with nothing to wrap
   on. A flex item's default min-width:auto refuses to shrink below that
   run's full width, so without these it pushed straight past the entry's
   rounded edge instead of wrapping inside it -- same reasoning as
   .vote-option-text, which already had them. */
.answer-text {
  font-weight: 600;
  font-size: 13px;
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
}

.answer-score {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-width: 38px;
  padding-left: 10px;
  border-left: 1px solid var(--border-soft);
}

.score-value {
  font-size: 21px;
  font-weight: 800;
  line-height: 1;
  color: var(--accent-cyan);
}

.mult-tag {
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.3px;
  color: var(--accent-yellow);
  margin-top: 2px;
}

.voters-line {
  display: flex;
  align-items: center;
  gap: 3px;
  flex-wrap: wrap;
  padding-left: 24px;
}

.voter-avatar {
  width: 14px;
  height: 14px;
}

/* --- Score constellation ------------------------------------------------
   The standings live here as a cluster of planets instead of a list of
   rows, so a score is something you see before you read it. Positions,
   sizes and the fit scale are all computed in renderConstellation(). */
.score-constellation {
  position: relative;
  height: 200px;
  margin: 2px 0 14px;
  /* The cluster is scaled to fit, so nothing should reach this -- it's the
     backstop that keeps a mis-measured stage (one laid out while the view
     was still display:none) from spilling over the card. */
  overflow: hidden;
}

/* Position comes from the simulation, written straight onto `transform`
   every frame (translate3d, so it stays off the layout path -- left/top
   would relayout the stage 60 times a second). */
/* Quiet by default: the cluster is the thing, this is an option. */
.tilt-btn {
  display: block;
  margin: -6px auto 10px;
  padding: 5px 13px;
  border-radius: 999px;
  border: 1px solid var(--border-soft);
  background: rgba(255, 255, 255, 0.035);
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
  transition: border-color var(--t-hint) var(--ease-press),
              color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press);
}

.tilt-btn.is-on {
  border-color: rgba(77, 216, 230, 0.5);
  background: rgba(77, 216, 230, 0.12);
  color: var(--accent-cyan);
}

.tilt-btn:active {
  transform: scale(0.94);
}

.constellation-planet {
  position: absolute;
  top: 0;
  left: 0;
  /* width/height/transform set inline per planet */
  will-change: transform;
}

/* The pop-in scales, and the planet's own transform is its position, so
   they have to be separate elements. */
.constellation-pop {
  position: absolute;
  inset: 0;
  animation: planet-pop var(--t-in) var(--ease-enter) both;
  animation-delay: var(--pop-delay, 0ms);
}

@keyframes planet-pop {
  from { opacity: 0; transform: scale(0.55); }
  to { opacity: 1; transform: scale(1); }
}

.constellation-avatar {
  width: 100%;
  height: 100%;
}

/* --- Constellation legend ----------------------------------------------
   The cluster shows who's ahead; this is where you read by how much. The
   planets carry nothing on top of them, so the scores have to be properly
   legible here rather than incidental. Two columns on a phone, more as the
   card gets wider. */
#scoreboard-content {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
  gap: 6px 12px;
}

.legend-item {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  padding: 4px 0;
}

.legend-avatar {
  width: 20px;
  height: 20px;
  flex: 0 0 auto;
}

/* The name stays small -- it's a key, and you already know your own
   planet. A 20-character name with no spaces has nothing to wrap on, and a
   flex item won't shrink below its content by default. */
.legend-name {
  flex: 1;
  min-width: 0;
  font-size: 12px;
  font-weight: 700;
  overflow-wrap: anywhere;
}

/* The score, and now the only place it's written down, so it reads as the
   number it is. Tabular figures keep the column straight down the grid
   instead of 1s pulling their rows in. */
.legend-points {
  flex: 0 0 auto;
  font-size: 16px;
  font-weight: 800;
  line-height: 1.1;
  color: var(--accent-yellow);
  font-variant-numeric: tabular-nums;
  padding-left: 2px;
}

/* --- Stat boxes (bottom info row, e.g. lobby/voting metadata) --- */

/* --- Round history modal --- */
/* Above .top-bar (z-index 20), which is a sibling of .app-container and so
   painted straight over any modal nested inside it -- see the note in
   index.html about why these live outside that container now. */
.modal {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  /* Safe-area insets, not a flat 16px: the page is viewport-fit=cover, so
     on a phone with a cutout the top of a modal card otherwise sits
     *under* the status bar / Dynamic Island and its heading and ✕ are
     clipped. Every other fixed element on the page already does this. */
  padding: calc(16px + env(safe-area-inset-top, 0px))
           calc(16px + env(safe-area-inset-right, 0px))
           calc(16px + env(safe-area-inset-bottom, 0px))
           calc(16px + env(safe-area-inset-left, 0px));
  overflow-y: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.modal::-webkit-scrollbar {
  display: none;
}

/* The dimming/blur layer lives on its own pseudo-element rather than on
   .modal itself, so it can fade with the card instead of snapping in and
   out around it — previously the scrim appeared fully opaque on frame one
   while the card was still at opacity 0, and vanished in a single frame on
   close while the card was still fading. Fixed positioning keeps it out of
   the flex flow, so it changes no layout. */
.modal::before {
  content: '';
  position: fixed;
  inset: 0;
  background: rgba(2, 3, 8, 0.72);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  animation: scrim-in var(--t-in) var(--ease-enter) both;
  pointer-events: none;
}

.modal.closing::before {
  animation: scrim-out var(--t-out) var(--ease-exit) forwards;
}

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

@keyframes scrim-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

.modal-content {
  width: 100%;
  max-width: 480px;
  margin-top: 4vh;
  margin-bottom: 4vh;
  animation: view-morph-in var(--t-in) var(--ease-enter) both;
  --morph-scale: 0.96;
}

.modal-content.closing {
  animation: view-morph-out var(--t-out) var(--ease-exit) forwards;
}

/* Modals stay opaque. There is no sky behind them -- the scrim is -- and
   the manual is a long read that shouldn't have anything moving under it.
   More specific than `.card` and declared after it, so it wins. */
.modal-content.card {
  background: var(--card-sheen), var(--card-base);
}

.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 6px;
}

.modal-head .eyebrow {
  margin-bottom: 0;
}

/* Centred vertically, but via auto margins rather than
   `align-items: center` -- and that is not a style preference. A flex item
   centred with align-items that is TALLER than its scrolling container
   overflows equally in both directions, and the part above the top edge
   cannot be scrolled to: it is simply unreachable. Auto margins centre
   only the slack that actually exists, so a card taller than the screen
   pins to the top and scrolls normally instead. The manual is the tallest
   card in the app, which is exactly where this would have bitten. */
.modal.modal-center .modal-content {
  margin-top: auto;
  margin-bottom: auto;
}

/* --- Host sign-in / register modal --- */
.host-auth-card {
  position: relative;
  text-align: center;
  padding-top: 30px;
}

.host-auth-close {
  position: absolute;
  top: 14px;
  right: 14px;
}

/* The app's mark, turning slowly. It used to be a 🪐 emoji on a cyan
   sphere gradient, with the whole badge -- gradient, inset highlights and
   all -- rotating together. The logo now fills the circle, so that
   gradient and those inset shadows would be painted underneath it and
   never seen: dropped. What's left is the outer glow, which still reads as
   a lit object and, crucially, does NOT rotate -- a glow that spins with
   its subject looks like the light source is orbiting too.
   overflow: hidden is what lets a square image live in a round badge. */
.host-auth-badge {
  width: 64px;
  height: 64px;
  margin: 0 auto 14px;
  border-radius: 50%;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0a0d18;
  box-shadow: 0 0 30px rgba(124, 140, 255, 0.55);
}

/* The rotation lives on the image, not the badge.
   It animates `rotate:` rather than `transform:` to match every other
   perpetual spin in this file -- see MOTION.md on why `transform` keyframes
   fight an element's own inline transform -- and it stays `linear`, because
   an eased loop restarts at full speed and hitches once per turn.
   The image is square and fills the circle edge to edge, which is exactly
   what keeps it covered at every angle: a centred square always contains
   the circle whose diameter equals its side, so no corner gap can appear
   mid-rotation. */
.host-auth-badge-logo {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  animation: planet-self-spin 26s linear infinite;
}

.host-auth-card .eyebrow {
  text-align: center;
}

.host-auth-card h2 {
  text-align: center;
}

.host-auth-card .hint {
  text-align: center;
}

.host-auth-card input {
  text-align: left;
}

/* --- Password visibility toggle --- */
.password-field {
  position: relative;
}

.password-field input {
  padding-right: 46px;
}

.password-toggle-btn {
  position: absolute;
  top: 0;
  bottom: 12px;
  right: 4px;
  width: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  font-size: 16px;
  cursor: pointer;
  opacity: 0.7;
  transition: opacity var(--t-hint) var(--ease-press);
}

.password-toggle-btn:hover,
.password-toggle-btn:active {
  opacity: 1;
}

/* --- Remember me --- */
/* The row used to be pulled up 4px to tuck under the password field, but
   that field's own bottom margin sits on the <input> inside .password-field
   (the wrapper has none), so there was nothing to tuck into and the row
   landed 4px on top of the input's box instead -- measured as a 318x4px
   overlap. Sits below it now, at the same rhythm as the other fields. */
.remember-me-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-muted);
  cursor: pointer;
  margin: 2px 0 8px 2px;
}

.remember-me-row input {
  width: auto;
  margin: 0;
  accent-color: var(--accent-cyan);
}

/* --- Account modal: menu + sub-window navigation --- */
.account-view {
  text-align: center;
}

/* Back and Log Out were both underlined words wearing `.btn.link`. They're
   real controls now. Neither gets a gradient: the gradient buttons in this
   app are the thing you came to do (Join, Create, Get Pro), and navigation
   and sign-out are not that -- they'd compete with the actual action on the
   screen. So both are pills built from the card surface instead, which is
   the same material the menu rows use. */
.account-back-btn {
  /* Block-level flex with a fit-content width and `margin-right: auto`,
     not inline-flex: the card centres its inline children, so an
     inline-flex pill floated to the middle of the screen. Back belongs at
     the top left, where it was as a text link and where it's looked for. */
  display: flex;
  width: fit-content;
  align-items: center;
  gap: 7px;
  margin: 0 auto 12px 0;
  padding: 9px 17px 9px 13px;
  border-radius: 999px;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  box-shadow: var(--card-shadow-soft);
  color: var(--text);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.3px;
  text-align: left;
  text-decoration: none;
  transition: border-color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press);
}

/* The chevron as a pseudo-element: it can then slide on press on its own,
   and the eight call sites just say "Back". */
.account-back-btn::before {
  content: '\2039';
  font-size: 17px;
  line-height: 1;
  margin-top: -1px;
  color: var(--accent-cyan);
  transition: transform var(--t-press) var(--ease-press);
}

.account-back-btn:active {
  transform: scale(0.97);
  border-color: rgba(77, 216, 230, 0.45);
}

/* The chevron leads the way out -- the whole button compresses, the arrow
   moves against it. */
.account-back-btn:active::before {
  transform: translateX(-3px);
}

/* Log Out is the one thing on this screen you'd regret tapping by
   accident, so it reads as a bordered, coral-tinted control: clearly a
   button, clearly not an invitation. */
.account-logout-btn {
  margin-top: 18px;
  padding: 13px 16px;
  border-radius: 999px;
  background: rgba(255, 95, 109, 0.09);
  border: 1px solid rgba(255, 95, 109, 0.34);
  box-shadow: 0 6px 18px rgba(255, 95, 109, 0.10);
  color: #ffb9be;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.2px;
  text-decoration: none;
  transition: background-color var(--t-hint) var(--ease-press),
              border-color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press);
}

.account-logout-btn:active {
  transform: scale(0.98);
  background: rgba(255, 95, 109, 0.16);
  border-color: rgba(255, 95, 109, 0.55);
}

.account-menu-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 18px;
}

.account-menu-btn {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 14px 16px;
  border-radius: 16px;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  box-shadow: var(--card-shadow-soft);
  color: var(--text);
  font-size: 14px;
  font-weight: 700;
  text-align: left;
  cursor: pointer;
  transition: border-color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press);
}

.account-menu-btn:active {
  transform: scale(0.98);
}

/* A status badge, not a button -- e.g. the Pro row once already on Pro */
.account-menu-btn-static {
  cursor: default;
  background: linear-gradient(160deg, rgba(255, 210, 63, 0.16) 0%, rgba(255, 210, 63, 0.05) 100%);
  border-color: rgba(255, 210, 63, 0.35);
  color: var(--accent-yellow);
}

.account-menu-btn-static:active {
  transform: none;
}

.account-menu-btn-arrow {
  color: var(--text-muted);
  font-size: 18px;
}

/* --- Plan card (inside the account modal) --- */
.plan-card-pro {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 14px;
  border-radius: 16px;
  background: linear-gradient(160deg, rgba(255, 210, 63, 0.18) 0%, rgba(255, 210, 63, 0.05) 100%);
  border: 1px solid rgba(255, 210, 63, 0.35);
  color: var(--accent-yellow);
  font-weight: 800;
  font-size: 15px;
}

.plan-offer {
  position: relative;
  padding: 16px;
  border-radius: 16px;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid rgba(255, 210, 63, 0.3);
  box-shadow: var(--card-shadow-soft);
  text-align: center;
  overflow: hidden;
}

.plan-price-row {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 10px;
}

/* .hint-fine's -12px tuck is for fine print sitting under a stepper, where
   it closes a gap. In here there's nothing above to close, so it pulled
   this line up into the price -- a measured 12px overlap. */
.plan-offer .hint-fine {
  margin-top: 4px;
  margin-bottom: 0;
}

#stripe-checkout-container {
  min-height: 420px;
  margin-top: 8px;
}

.plan-price-new {
  font-size: 26px;
  font-weight: 800;
  color: var(--accent-green);
  text-shadow: 0 0 18px rgba(74, 222, 128, 0.4);
}

.confirm-message {
  font-size: 15px;
  font-weight: 600;
  color: var(--text);
  margin-bottom: 18px;
  line-height: 1.4;
}

.confirm-btn-row {
  display: flex;
  gap: 10px;
}

.confirm-btn-row .btn {
  flex: 1;
  width: auto;
}

/* --- Toast notifications (in-app, replaces native alert/confirm) --- */
.toast-container {
  position: fixed;
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  z-index: 90;
  display: flex;
  flex-direction: column-reverse;
  gap: 8px;
  width: calc(100% - 32px);
  max-width: 420px;
  pointer-events: none;
}

.toast {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  border-radius: 14px;
  background: var(--card-sheen), var(--card-base);
  border: 1px solid var(--card-border);
  border-left: 3px solid var(--accent-cyan);
  box-shadow: var(--card-shadow);
  font-size: 13px;
  font-weight: 600;
  color: var(--text);
  animation: toast-in var(--t-swap-in) var(--ease-enter) both;
}

.toast.error { border-left-color: var(--accent-coral); }
.toast.success { border-left-color: var(--accent-green); }
.toast.info { border-left-color: var(--accent-purple); }

.toast.leaving {
  animation: toast-out var(--t-out) var(--ease-exit) forwards;
}

.toast-icon {
  flex: 0 0 auto;
  font-size: 15px;
  line-height: 1;
}

.toast-text {
  flex: 1;
  line-height: 1.35;
}

/* Same fade+scale morph as every other arrival, with a short rise on top
   so it still reads as coming up from the bottom edge. It used to be a
   bare 10px slide with no scale, which was the one piece of motion in the
   app that didn't speak the morph language. */
@keyframes toast-in {
  from { opacity: 0; transform: translateY(12px) scale(0.96); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}

@keyframes toast-out {
  from { opacity: 1; transform: translateY(0) scale(1); }
  to { opacity: 0; transform: translateY(8px) scale(0.97); }
}

/* --- Small circular icon buttons (close / exit), matches the app's chrome --- */
.icon-x-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  box-shadow: var(--card-shadow-soft);
  color: var(--text-muted);
  font-size: 13px;
  font-weight: 800;
  line-height: 1;
  cursor: pointer;
  padding: 0;
  transition: border-color var(--t-hint) var(--ease-press),
              color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press),
              box-shadow var(--t-hint) var(--ease-press);
}

.icon-x-btn:hover {
  color: var(--text);
  border-color: var(--accent-coral);
}

.icon-x-btn:active {
  transform: scale(0.88);
}

.icon-x-btn.tab-close-btn {
  width: 18px;
  height: 18px;
  font-size: 10px;
  margin-left: 2px;
}

.category-input-row {
  display: flex;
  /* flex-start, not center: a grown multi-line prompt would otherwise
     leave the remove button floating at dead-centre of a tall box instead
     of sitting level with the first line. */
  align-items: flex-start;
  gap: 8px;
  margin-bottom: 12px;
}

.category-input-row .category-input {
  flex: 1;
  min-width: 0;
  margin-bottom: 0;
}

.category-input-row .category-remove-btn {
  margin-top: 13px;
}

/* --- Random idea suggestions (classic mode) --- */
.category-btn-row {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 10px;
}

/* The prompts list is Create Room's `.view-scroll` (see the one-screen
   block up top for the shared scrolling/fade behaviour). All that's left
   here is its own floor -- the list stays tall enough to show a row and a
   half even on a short screen, so it never collapses to a sliver -- plus
   room at the bottom for the idea-suggestion overlay when that's up. */
.categories-scroll {
  min-height: 64px;
}

/* The suggestion card floats over the bottom of this list, so the last
   prompt has to be able to scroll clear of it -- otherwise the card
   covers that row's text and swallows taps on its remove button. Matches
   the card's own height (~50px) plus the list's fade. */
#classic-mode-section:has(#idea-suggestion-card:not(.hidden)) .categories-scroll {
  padding-bottom: 62px;
}

/* A transient overlay rather than inline content: stacked into the card it
   competed with the prompt list for a fixed height budget and pushed the
   list out over the Start Room button on anything shorter than a Pro Max.
   Out of flow it costs the layout nothing at any screen size.
   Anchored to the bottom of the prompts section (which is
   position: relative for exactly this) rather than to the viewport with a
   hand-tuned offset -- that way it can never land on the Start Room
   button, whatever the screen height or how much the layout compacts.
   No transform is used for placement: the morph keyframes animate
   `transform` and would clobber it (see MOTION.md). */
.idea-suggestion-card {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 3;
  padding: 12px 14px;
  border-radius: 16px;
  background: var(--card-sheen), var(--card-base);
  border: 1px solid var(--accent-cyan);
  box-shadow: var(--card-shadow), 0 12px 32px var(--shadow-dark);
}

.idea-suggestion-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.idea-chip {
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  border-radius: 14px;
  padding: 9px 10px 9px 14px;
  box-shadow: var(--card-shadow-soft);
}

.idea-chip-text {
  flex: 1;
  font-size: 13px;
  font-weight: 600;
  color: var(--text);
}

.idea-chip-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  border: 1px solid var(--card-border);
  background: rgba(255, 255, 255, 0.04);
  font-size: 13px;
  font-weight: 800;
  cursor: pointer;
  transition: border-color var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press),
              background var(--t-hint) var(--ease-press);
}

.idea-chip-btn:active {
  transform: scale(0.88);
}

.idea-chip-btn.keep {
  color: var(--accent-green);
}

.idea-chip-btn.keep:hover {
  border-color: var(--accent-green);
  background: rgba(74, 222, 128, 0.12);
}

.idea-chip-btn.skip {
  color: var(--accent-cyan);
}

.idea-chip-btn.skip:hover {
  border-color: var(--accent-cyan);
  background: rgba(77, 216, 230, 0.12);
}

.idea-chip-btn.close {
  color: var(--text-muted);
}

.idea-chip-btn.close:hover {
  border-color: var(--accent-coral);
  color: var(--accent-coral);
  background: rgba(255, 95, 109, 0.12);
}

/* --- About / how to play ------------------------------------------------
   The top-bar button carries the app icon rather than an emoji, so it reads
   as "this is DISH itself" next to the account button. The icon is a full
   square image, so it needs the button's own rounding to clip it. */
.brand-btn {
  padding: 0;
  overflow: hidden;
}

.brand-btn img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  border-radius: 50%;
}

.about-brand {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

.about-logo {
  width: 46px;
  height: 46px;
  border-radius: 14px;
  flex: 0 0 auto;
  border: 1px solid var(--card-border);
}

.about-title {
  margin: 0;
  font-size: 22px;
  letter-spacing: 3px;
  font-weight: 800;
}

/* This modal is the one screen that genuinely has more to say than fits:
   it scrolls inside its own card, with the head pinned, rather than making
   the whole modal a long scroll behind a floating close button. */
.about-body {
  max-height: min(64vh, 560px);
  overflow-y: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
  padding-right: 2px;
  /* Clears the fade below, so scrolling to the end doesn't leave the
     credits themselves dimmed as though they were cut off. */
  padding-bottom: 26px;
  mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent 100%);
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent 100%);
}

.about-body::-webkit-scrollbar {
  display: none;
}

.about-lede {
  color: var(--text);
  font-size: 14px;
  line-height: 1.55;
  margin: 6px 0 0;
}

.about-section {
  margin-top: 20px;
}

.about-section h3 {
  font-size: 13px;
  letter-spacing: 1.6px;
  text-transform: uppercase;
  color: var(--accent-cyan);
  margin: 0 0 10px;
}

.about-steps,
.about-list {
  margin: 0;
  padding-left: 20px;
  color: var(--text-muted);
  font-size: 13.5px;
  line-height: 1.55;
}

.about-steps li,
.about-list li {
  margin-bottom: 8px;
  /* Every one of these lines contains player-facing free text or a long
     mode name; without this a narrow phone pushes them past the card. */
  overflow-wrap: anywhere;
}

.about-steps li:last-child,
.about-list li:last-child {
  margin-bottom: 0;
}

.about-steps strong,
.about-list strong {
  color: var(--text);
}

.about-mode {
  background: rgba(255, 255, 255, 0.035);
  border: 1px solid var(--border-soft);
  border-radius: 16px;
  padding: 12px 14px;
  margin-bottom: 10px;
}

.about-mode:last-child {
  margin-bottom: 0;
}

.about-mode-head {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}

.about-mode-name {
  font-size: 15px;
  font-weight: 800;
  color: var(--text);
}

.about-mode p {
  margin: 0;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--text-muted);
}

.about-tag {
  font-size: 10px;
  font-weight: 800;
  letter-spacing: 1.2px;
  text-transform: uppercase;
  padding: 3px 8px;
  border-radius: 999px;
  flex: 0 0 auto;
}

.about-tag.free {
  color: var(--accent-green);
  background: rgba(74, 222, 128, 0.12);
  border: 1px solid rgba(74, 222, 128, 0.35);
}

.about-tag.pro {
  color: var(--accent-yellow);
  background: rgba(255, 210, 63, 0.12);
  border: 1px solid rgba(255, 210, 63, 0.35);
}

/* --- The pinned install bar -------------------------------------------
   Sticky to the top of the scrolling manual, so how to get the app (or the
   tick saying you have it) survives scrolling to the bottom of a long
   panel. It needs its own opaque surface: the card's background is a
   gradient, so without one the manual's text would read straight through
   the bar as it scrolled under. */
.install-bar {
  position: sticky;
  top: 0;
  z-index: 3;
  /* The card's own gradient is lighter at the very top, so this sits on a
     flat surface of its own rather than trying to match a moving target. */
  background: rgba(16, 19, 35, 0.94);
  backdrop-filter: blur(10px) saturate(140%);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  border: 1px solid var(--border-soft);
  border-radius: 16px;
  padding: 10px 12px;
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.42);
}

.install-bar-row {
  display: flex;
  align-items: center;
  gap: 10px;
}

.install-bar-icon {
  flex: none;
  font-size: 17px;
  line-height: 1;
}

.install-bar-row.done .install-bar-icon {
  color: var(--accent-green);
  font-weight: 800;
}

.install-bar-text {
  /* Without min-width:0 a long label stops this flex item shrinking and
     pushes the button off the row -- the free-text rule, applied to our
     own strings because the version and size are interpolated in. */
  min-width: 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.install-bar-text strong {
  font-size: 13.5px;
  color: var(--text);
  font-weight: 700;
  overflow-wrap: anywhere;
}

.install-bar-text span {
  font-size: 11.5px;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

/* Reused by an <a download> and a <button>, so everything a link would
   otherwise inherit is spelled out. */
.install-bar-btn {
  flex: none;
  padding: 9px 15px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  font-size: 12.5px;
  font-weight: 700;
  font-family: inherit;
  white-space: nowrap;
  cursor: pointer;
  text-decoration: none;
  color: #ffffff;
  background: linear-gradient(160deg, #ff7a85 0%, var(--accent-coral) 45%, var(--accent-coral-2) 100%);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.4),
    0 8px 18px rgba(230, 67, 90, 0.38);
  transition: transform var(--t-press) var(--ease-press),
              filter var(--t-press) var(--ease-press);
}

.install-bar-btn:active {
  transform: scale(0.96);
  filter: brightness(0.94);
}

/* Sits inline inside the bar's subtitle line, so it must not pick up any
   of the block-button box model. */
.install-bar-more {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: var(--accent-cyan);
  text-decoration: underline;
  cursor: pointer;
}

.install-alt {
  margin: 14px 0 0;
  padding-top: 12px;
  border-top: 1px solid var(--border-soft);
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-muted);
}

.install-steps {
  margin-top: 12px;
}

/* An aside rather than another step -- it only applies if you got here in
   the wrong browser. */
.install-steps .install-note {
  list-style: none;
  margin-left: -20px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border-soft);
  font-size: 12.5px;
  color: var(--text-muted);
}

.about-credits {
  margin-top: 22px;
  padding-top: 16px;
  border-top: 1px solid var(--border-soft);
  text-align: center;
}

.about-credit-role {
  margin: 0;
  font-size: 12px;
  color: var(--text-muted);
}

.about-credit-name {
  margin: 4px 0 0;
  font-size: 17px;
  font-weight: 800;
  color: var(--text);
  letter-spacing: 0.3px;
}

/* --- Rewarded ads -------------------------------------------------------
   The free-tier lock line is a button now, so it has to shed the browser's
   button chrome and keep reading as the hint it looks like. */
.hint-button {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  /* One line at 375px. Measured rather than guessed: a second line here
     pushes the prompt list down on the shortest screen. 10.5px fit with
     zero slack, which a font fallback (Inter not yet loaded, or blocked)
     would turn into an ellipsis -- 10px leaves ~15px of room. */
  font-size: 10px;
  letter-spacing: 0.1px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}

.ad-card {
  max-width: 380px;
  text-align: center;
}

/* Unmistakably an ad slot, and unmistakably DISH's own for now: a real
   network's creative would replace the contents of this box. */
.ad-stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  min-height: 168px;
  margin: 12px 0 14px;
  padding: 18px 16px;
  border-radius: 20px;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
}

.ad-house-logo {
  width: 64px;
  height: 64px;
  border-radius: 18px;
  border: 1px solid var(--card-border);
}

.ad-house-fact {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-muted);
}

.ad-progress {
  height: 6px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  overflow: hidden;
}

.ad-progress-bar {
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--accent-cyan), var(--accent-purple));
}

.ad-countdown {
  margin: 8px 0 4px;
  font-variant-numeric: tabular-nums;
  font-weight: 700;
}

.unlock-panel-title {
  margin-top: 0;
  font-size: 20px;
}

.unlock-list {
  margin-top: 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.unlock-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 11px 13px;
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.035);
  border: 1px solid var(--border-soft);
}

.unlock-row.is-held {
  border-color: rgba(74, 222, 128, 0.35);
  background: rgba(74, 222, 128, 0.07);
}

.unlock-row-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1;
  min-width: 0;
}

.unlock-row-label {
  font-size: 13.5px;
  font-weight: 800;
  overflow-wrap: anywhere;
}

.unlock-row-detail {
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

.unlock-row-btn {
  flex: 0 0 auto;
  padding: 7px 13px;
  border-radius: 999px;
  border: 1px solid rgba(77, 216, 230, 0.45);
  background: rgba(77, 216, 230, 0.12);
  color: var(--accent-cyan);
  font-size: 12px;
  font-weight: 800;
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--t-hint) var(--ease-press),
              transform var(--t-press) var(--ease-press);
}

.unlock-row-btn:hover {
  background: rgba(77, 216, 230, 0.22);
}

.unlock-row-btn:active {
  transform: scale(0.94);
}

.unlock-row-done {
  flex: 0 0 auto;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--accent-green);
  white-space: nowrap;
}

.unlock-row-note {
  flex: 0 0 auto;
  font-size: 11px;
  font-weight: 700;
  color: var(--text-muted);
  white-space: nowrap;
}

/* Deleting is the one destructive thing in here, so it reads as such
   rather than sitting in the list looking like Change Email. */
.account-menu-btn-danger span:first-child {
  color: var(--accent-coral);
}

.delete-list {
  margin: 8px 0 12px;
  font-size: 12.5px;
  /* The account card centres its text, which reads badly for a bulleted
     list -- the markers end up detached from their lines. */
  text-align: left;
}

.delete-list strong {
  color: var(--accent-coral);
}

.btn.danger {
  background: linear-gradient(135deg, var(--accent-coral), var(--accent-coral-2));
  color: #fff;
  box-shadow: 0 8px 20px rgba(255, 95, 109, 0.3);
}

.account-reserve-btn {
  margin-top: 8px;
  padding: 10px;
  font-size: 13px;
}

.history-round-tabs {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  padding: 4px 2px 14px;
  scrollbar-width: none;
  -ms-overflow-style: none;
  touch-action: pan-x;
}

.history-round-tabs::-webkit-scrollbar {
  display: none;
}

.history-tab {
  flex: 0 0 auto;
  background: var(--card-sheen), var(--card-base-soft);
  border: 1px solid var(--card-border);
  border-radius: 999px;
  padding: 8px 18px;
  color: var(--text-muted);
  font-weight: 700;
  font-size: 13px;
  cursor: pointer;
  box-shadow: var(--card-shadow-soft);
  transition: border-color var(--t-hint) var(--ease-press),
              color var(--t-hint) var(--ease-press),
              box-shadow var(--t-hint) var(--ease-press);
}

.history-tab.active {
  color: var(--text);
  border-color: var(--accent-purple);
  box-shadow: var(--card-shadow-soft), 0 0 16px rgba(124, 140, 255, 0.3);
}

/* --- Reduced motion -------------------------------------------------------
   Only for players whose OS says they want it. The app stays fully usable
   and keeps its fade/scale identity — screens, modals and sections still
   morph, just quickly enough not to register as travel — while everything
   that runs forever or flies across the screen (orbits, the galaxy, dust,
   shooting stars, the avatar's punch to full screen) stops. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --t-press: 1ms;
    --t-hint: 1ms;
    --t-out: 90ms;
    --t-in: 110ms;
    --t-swap-out: 80ms;
    --t-swap-in: 100ms;
    --t-flight: 1ms;
    --t-fall: 320ms;
    --morph-scale-view: 1;
    --morph-scale-item: 1;
  }

  .shooting-stars,
  .accretion-field {
    display: none;
  }

  /* The stars themselves stay -- only the breathing stops. */
  body::before {
    animation: none;
    opacity: 0.9;
  }

  .spiral-arms-group,
  .orbit-wrapper,
  .player-avatar img,
  .falling-avatar img,
  .host-auth-badge,
  .host-auth-badge-logo,
  .orbit-pulse,
  .black-hole-core::before {
    animation: none !important;
  }

  .orbit-field {
    transition: none;
  }
}

/* --- Lifetime score + friends ------------------------------------------
   A lifetime total is the one number in this app that gets genuinely long
   (thousands separators and all), so it gets its own card rather than
   being squeezed into a hint line. */
/* One quiet line, not a scoreboard. It's a fact about you, sitting above
   the things you actually came here to tap -- so it reads at a glance and
   then gets out of the way. */
.lifetime-card {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 6px;
  flex-wrap: wrap;
  margin: 10px 0 2px;
  min-width: 0;
}

.lifetime-points {
  font-size: 15px;
  font-weight: 800;
  color: var(--accent-cyan);
  /* Not free text, but a six-figure score still has to wrap rather than
     widen the card. */
  overflow-wrap: anywhere;
}

.lifetime-unit {
  font-size: 11px;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--text-muted);
  font-weight: 700;
}

.lifetime-sub {
  font-size: 11.5px;
  color: var(--text-muted);
}

/* Separates the total from the game count without a second line. */
.lifetime-sub::before {
  content: '·';
  margin-right: 5px;
  opacity: 0.55;
}

.friend-search {
  margin-top: 12px;
}

.friend-section-head {
  margin: 16px 0 8px;
  font-size: 11px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--accent-cyan);
  font-weight: 700;
}

.friend-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  margin-bottom: 6px;
  border-radius: 14px;
  background: rgba(255, 255, 255, 0.035);
  border: 1px solid var(--border-soft);
}

.friend-row-main {
  /* A display name is free text someone typed: without min-width:0 a long
     one stops this shrinking and pushes the buttons out of the row. */
  min-width: 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.friend-name {
  font-size: 13.5px;
  font-weight: 700;
  color: var(--text);
  overflow-wrap: anywhere;
}

.friend-pro {
  font-size: 11px;
}

.friend-stats {
  font-size: 11.5px;
  color: var(--text-muted);
}

.friend-row-actions {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 6px;
}

.friend-btn {
  padding: 7px 13px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  font-family: inherit;
  font-size: 12px;
  font-weight: 700;
  cursor: pointer;
  white-space: nowrap;
  color: #ffffff;
  background: linear-gradient(160deg, #6fe3ef 0%, var(--accent-cyan) 45%, var(--accent-cyan-2) 100%);
  transition: transform var(--t-press) var(--ease-press),
              filter var(--t-press) var(--ease-press);
}

.friend-btn.accept {
  color: #06121a;
}

.friend-btn.ghost {
  background: none;
  color: var(--text-muted);
  border-color: var(--border-soft);
}

.friend-btn:active {
  transform: scale(0.95);
}

.friend-btn[disabled] {
  opacity: 0.5;
  cursor: default;
}

.friend-tag {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
}

.friend-empty {
  padding: 10px 2px;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-muted);
}

/* The invite count sits on the top-bar ✉ button, which only appears when
   there is something waiting -- so this never takes width otherwise. */
.invite-count {
  position: absolute;
  top: -4px;
  right: -4px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 999px;
  background: var(--accent-coral);
  color: #ffffff;
  font-size: 10px;
  font-weight: 800;
  line-height: 16px;
  text-align: center;
  border: 2px solid var(--bg);
  box-shadow: 0 0 8px rgba(255, 95, 109, 0.8);
}

#btn-invites {
  position: relative;
}

/* --- The hub -----------------------------------------------------------
   One top-bar button now opens the manual, friends, settings and Pro. The
   ✕ and 👤 buttons it replaced did the same job twice. */
.account-menu-btn-label {
  display: flex;
  align-items: center;
  gap: 9px;
  /* A menu label is short and ours, but min-width:0 keeps the arrow pinned
     to the right edge whatever the label does. */
  min-width: 0;
}

.menu-btn-logo {
  width: 20px;
  height: 20px;
  flex: 0 0 auto;
  border-radius: 6px;
}

/* The manual is the one row here a signed-out player needs, so it reads a
   shade brighter than the rest of the list. */
.account-menu-btn-manual {
  border-color: rgba(77, 216, 230, 0.28);
}

/* On Pro the diamond IS the button, replacing the logo rather than being
   pinned to the corner of it. Sized to match the other top-bar glyphs. */
.hub-pro-mark {
  font-size: 17px;
  line-height: 1;
}

/* The name you play under is the heading of the account screen. When it
   isn't actually locked to you it says so -- amber rather than the usual
   cyan, because "you have a name" and "that name is yours" are different
   states and the difference matters the moment someone else uses it. */
.eyebrow-unreserved {
  color: var(--accent-yellow);
}

.account-unreserved {
  margin-top: -2px;
  font-size: 11.5px;
  color: var(--accent-yellow);
  opacity: 0.85;
}
