/* ==========================================================================
   Music Quiz Showdown — 3D tactile UI
   --------------------------------------------------------------------------
   Depth recipe used everywhere: a linear-gradient face, a bright inset rim on
   top, a dark inset rim on the bottom, a hard-edged colour shadow acting as
   the extruded "side" of the object, then a soft ambient drop shadow.
   Pressing a control shortens the hard shadow and translates it down, so it
   physically travels.
   ========================================================================== */

:root {
  /* --- palette, pulled from the mockup --------------------------------- */
  --navy-900: #16102b;
  --navy-800: #1e1638;
  --navy-700: #2a1d49;
  --purple-700: #34255c;
  --purple-600: #3f2c6e;
  --purple-500: #4b3580;

  --coral-300: #ff9aa2;
  --coral-400: #ff7d8a;
  --coral-500: #f2606f;
  --coral-600: #d4455a;
  --coral-700: #a52f45;

  --teal-300: #4fd0cf;
  --teal-400: #2fb3b6;
  --teal-500: #1f8f96;
  --teal-600: #16717a;
  --teal-700: #0f545c;

  --gold-300: #ffe08a;
  --gold-400: #ffc94d;
  --gold-500: #f0a92c;
  --gold-600: #c07c17;

  /* Spotify green, extended into the same 300..700 ramp every other colour
     here uses, so the glossy recipe (bright face -> mid -> dark, over a hard
     extruded side) applies to it unchanged. --spotify-500 IS #1DB954, the
     brand colour, and it is the one the eye lands on; the rest exist only to
     give it a lit top and a solid side.

     This ramp is deliberately rationed. It appears on the login gate and on
     the "connected" dot in the profile menu, and nowhere else -- green in this
     app means Spotify, never "success" (that is --ok) and never a game state. */
  --spotify-300: #5df58c;
  --spotify-400: #1ed760;
  --spotify-500: #1db954;
  --spotify-600: #158e40;
  --spotify-700: #0c5f2a;

  /* Apple Music's mark sits around #FA2D48. --apple-500 IS that colour and is
     the one the eye lands on; the rest of this ramp exists only to give it a
     lit top and a solid side, exactly as the Spotify ramp above does.

     Rationed the same way, too. Apple red in this app means ONE thing -- the
     store link on the fact sheet, the only control that leaves the app. It is
     never a game state: a wrong guess is --bad, and a surrender wears its own
     darker maroon (see .btn-surrender). */
  --apple-300: #ff8296;
  --apple-400: #fb4a63;
  --apple-500: #fa2d48;
  --apple-600: #cc1f36;
  --apple-700: #8f1526;

  /* ONE TEXT COLOUR. NOT A RAMP.
     ----------------------------------------------------------------------
     There were four: --ink (#fff4f0), --ink-dim, --ink-faint and a pair of
     translucent whites. Four greys of text is how a game starts looking like
     a dashboard -- every caption, count and empty state arrives pre-faded, and
     the screen reads as low-contrast rather than as layered.

     Hierarchy is carried by SIZE and WEIGHT from here on. Nothing dims text to
     rank it, and nothing uses opacity to do it either.

     The one exception is text ON A LIGHT SURFACE -- the guess field, the gold
     Elo lozenge, the lit playlist key, avatar initials. Those stay dark
     because white on gold is not crisp, it is illegible. They are literal hex
     values throughout, never this token. */
  --ink: #ffffff;

  /* --- type ------------------------------------------------------------
     Three faces with three jobs; see the note in index.html, which is also
     the only place their weights are loaded. Every one of them falls back to
     Inter before it falls back to the OS, so a blocked font request costs the
     app its character and not its legibility. */
  --font-ui: 'Inter', system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-display: 'Fredoka', 'Inter', system-ui, sans-serif;
  --font-soft: 'Nunito', 'Inter', system-ui, sans-serif;

  --ok: #5be3a0;
  --bad: #ff6b7a;

  /* --- shape ------------------------------------------------------------ */
  --r-lg: 30px;
  --r-md: 22px;
  --r-sm: 16px;

  /* The bottom nav's height, and the clearance the lobby's scroller leaves
     for it. ONE number so the two can never drift apart -- the whole reason
     the last playlist used to sit under the dock. client.js measures the real
     dock on load and on resize and writes the measurement back here, so this
     value is only the pre-script default. */
  --dock-h: 66px;

  /* ONE horizontal inset for the whole app shell -- header, every tab, and
     the bottom nav's labels all measure from this, so nothing is a few pixels
     off from the thing above it. 20px at every width: a phone needs the room
     and a desktop column looks arbitrary with more. */
  --hub-pad-x: 20px;

  /* The header's height, measured by client.js the same way --dock-h is, and
     reserved as padding at the top of the lobby's scroller so content passes
     under the bar instead of starting beneath it. */
  --appbar-h: 60px;

  /* The air between the header and the first row of a tab. A token because the
     search bar has to CANCEL it: the bar sticks at the top of the scroller's
     padding box, so without this it would rest --hub-top-gap below the header
     and leave a strip of bare content showing between the two. */
  --hub-top-gap: 12px;

  --ring-light: inset 0 3px 0 rgba(255, 255, 255, 0.42);
  --ring-dark: inset 0 -5px 0 rgba(0, 0, 0, 0.22);
  --ambient: 0 16px 34px rgba(0, 0, 0, 0.42);
}

* {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
  margin: 0;
}

body {
  /* The document's face, and the default everything inherits -- including the
     whole of a live match, which is deliberately untouched by the display and
     soft faces below. */
  font-family: var(--font-ui);
  /* Inter at heavy weights on a dark ground gains apparent weight without
     this; the headings read bloated otherwise. */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: var(--ink);
  background:
    radial-gradient(1000px 620px at 12% -8%, #5a2a6b 0%, transparent 58%),
    radial-gradient(820px 560px at 92% 108%, #0e5a63 0%, transparent 55%),
    linear-gradient(165deg, var(--navy-900), #221741 55%, var(--navy-800));
  background-attachment: fixed;
  display: grid;
  place-items: center;
  padding: 26px 16px 40px;
}

.stage {
  width: 100%;
  display: grid;
  place-items: center;
}

.app {
  width: min(430px, 100%);
}

.hidden {
  display: none !important;
}

/* ==========================================================================
   Typography cascade guard
   --------------------------------------------------------------------------
   Form controls do not inherit the document font: browsers hand them the OS
   UI font instead, which is why an untouched <button> or <input> renders in
   something other than Inter however global the body rule is. `font: inherit`
   is the fix, and it has to come before the component rules that set their own
   size and weight -- it resets those too.

   Headings are listed so that a family can only ever be introduced ON PURPOSE:
   they inherit by default, and anything that wants a different face has to say
   so explicitly, against one of the three --font-* tokens.

   There ARE three families now (see :root). That is a deliberate system, not
   drift: --font-ui is the document and the whole of a match, --font-display is
   the hub's titles and section headers, --font-soft is its small print.
   Nothing else may name a family directly.
   ========================================================================== */

button,
input,
select,
textarea,
optgroup {
  font: inherit;
  letter-spacing: inherit;
}

h1,
h2,
h3,
h4,
h5,
h6,
p,
ul,
ol,
li,
span,
a,
label,
div {
  font-family: inherit;
}

/* Anything that shows a changing number: fixed-width digits, so a timer or a
   score does not jitter as it counts. Inter's tabular figures are excellent,
   which is most of why this swap is cheap. */
.vinyl-time,
.vinyl-penalty,
.think-left,
.badge,
.pips li,
.rr-n,
.rr-time,
.rr-points,
.stub-big,
.score-value,
.solo-plate-score,
.solo-total-score

/* Brace deliberately on its own line: with it up on `.solo-total-score {`,
   a search for that rule finds this group first, and anything meant for the
   one big score lands on every numeral in the app instead. */
  {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}


/* ==========================================================================
   Screen shell
   ========================================================================== */

.screen {
  position: relative;
  padding: 26px 22px 28px;
  border-radius: var(--r-lg);
  text-align: center;
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0) 42%),
    linear-gradient(170deg, var(--purple-600), var(--navy-800));
  border: 2px solid rgba(255, 255, 255, 0.10);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    inset 0 -8px 0 rgba(0, 0, 0, 0.26),
    0 26px 60px rgba(0, 0, 0, 0.55);
  animation: pop-in 0.34s cubic-bezier(0.22, 1.1, 0.4, 1) both;
}

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

  to {
    opacity: 1;
    transform: none;
  }
}

.screen-title {
  font-weight: 900;
  font-size: clamp(27px, 7.4vw, 36px);
  letter-spacing: -0.6px;
  margin: 0 0 4px;
  color: #fff;
  text-shadow:
    0 2px 0 rgba(0, 0, 0, 0.28),
    0 5px 0 rgba(0, 0, 0, 0.16),
    0 12px 26px rgba(255, 120, 150, 0.30);
}

.title-gold {
  background: linear-gradient(180deg, #fff2c4, var(--gold-400) 55%, var(--gold-500));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  filter: drop-shadow(0 3px 0 rgba(120, 60, 0, 0.35));
}

.screen-kicker {
  font-weight: 700;
  font-size: 16px;
  letter-spacing: 0.2px;
  color: var(--gold-300);
  margin: 0 0 16px;
}

.lead {
  font-weight: 700;
  font-size: 19px;
  /* The bottom margin used to belong to the .sub line underneath. That line is
     gone from the waiting screen, so the lead carries the gap itself. */
  margin: 16px 0 20px;
}

.sub {
  color: var(--ink);
  font-size: 13px;
  margin: 0 0 20px;
  line-height: 1.55;
}

/* ==========================================================================
   Buttons
   ========================================================================== */

.btn {
  position: relative;
  font-weight: 700;
  font-size: 16px;
  letter-spacing: 0.4px;
  color: #fff;
  border: 0;
  cursor: pointer;
  border-radius: var(--r-sm);
  padding: 14px 24px;
  transition: transform 0.09s ease, box-shadow 0.09s ease, filter 0.15s ease;
  -webkit-tap-highlight-color: transparent;
}

.btn-chunky {
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.28);
}

.btn-coral {
  background: linear-gradient(180deg, var(--coral-300), var(--coral-500) 62%, var(--coral-600));
  box-shadow:
    var(--ring-light),
    var(--ring-dark),
    0 7px 0 var(--coral-700),
    var(--ambient);
}

.btn-teal {
  background: linear-gradient(180deg, var(--teal-300), var(--teal-500) 62%, var(--teal-600));
  box-shadow: var(--ring-light), var(--ring-dark), 0 7px 0 var(--teal-700), var(--ambient);
}

.btn-ghost {
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05));
  color: var(--ink);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.18),
    inset 0 -4px 0 rgba(0, 0, 0, 0.22),
    0 6px 0 rgba(0, 0, 0, 0.30),
    0 12px 24px rgba(0, 0, 0, 0.35);
}

.btn:hover:not(:disabled) {
  filter: brightness(1.07);
}

.btn:active:not(:disabled) {
  transform: translateY(5px);
  box-shadow:
    var(--ring-light),
    inset 0 -2px 0 rgba(0, 0, 0, 0.22),
    0 2px 0 var(--coral-700),
    0 6px 14px rgba(0, 0, 0, 0.4);
}

.btn-teal:active:not(:disabled) {
  box-shadow: var(--ring-light), inset 0 -2px 0 rgba(0, 0, 0, .22), 0 2px 0 var(--teal-700), 0 6px 14px rgba(0, 0, 0, .4);
}

.btn-ghost:active:not(:disabled) {
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, .18), 0 2px 0 rgba(0, 0, 0, .3), 0 6px 14px rgba(0, 0, 0, .35);
}

.btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  filter: saturate(0.6);
}

.btn-wide {
  width: 100%;
}

/* --- the big round transport disc ------------------------------------- */

.btn-disc {
  width: 92px;
  height: 92px;
  border-radius: 50%;
  border: 0;
  cursor: pointer;
  display: grid;
  place-items: center;
  background: radial-gradient(circle at 34% 26%, var(--coral-300), var(--coral-500) 58%, var(--coral-600));
  box-shadow:
    inset 0 4px 0 rgba(255, 255, 255, 0.45),
    inset 0 -7px 0 rgba(0, 0, 0, 0.25),
    0 9px 0 var(--coral-700),
    0 20px 34px rgba(0, 0, 0, 0.5),
    0 0 0 7px rgba(255, 125, 138, 0.14);
  transition: transform 0.09s ease, box-shadow 0.09s ease;
}

.btn-disc[data-state="playing"] {
  background: radial-gradient(circle at 34% 26%, var(--gold-300), var(--gold-400) 58%, var(--gold-500));
  box-shadow:
    inset 0 4px 0 rgba(255, 255, 255, 0.5),
    inset 0 -7px 0 rgba(0, 0, 0, 0.22),
    0 9px 0 var(--gold-600),
    0 20px 34px rgba(0, 0, 0, 0.5),
    0 0 0 7px rgba(255, 201, 77, 0.16);
}

.btn-disc[data-state="ended"] {
  background: radial-gradient(circle at 34% 26%, #6b5c8c, #453869 60%, #34285a);
  box-shadow: inset 0 3px 0 rgba(255, 255, 255, .16), 0 6px 0 #241a44, 0 14px 24px rgba(0, 0, 0, .45);
}

.btn-disc:active:not(:disabled) {
  transform: translateY(6px);
  box-shadow:
    inset 0 4px 0 rgba(255, 255, 255, 0.4),
    0 3px 0 var(--coral-700),
    0 8px 16px rgba(0, 0, 0, 0.45);
}

.btn-disc:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.disc-icon {
  font-size: 30px;
  line-height: 1;
  color: #fff;
  text-shadow: 0 3px 0 rgba(0, 0, 0, 0.3);
  transform: translateX(2px);
}

.btn-disc[data-state="playing"] .disc-icon,
.btn-disc[data-state="ended"] .disc-icon {
  transform: none;
  letter-spacing: -2px;
}

/* ==========================================================================
   Inputs
   ========================================================================== */

.field-label {
  display: block;
  font-weight: 700;
  font-size: 13.5px;
  letter-spacing: 0.1px;
  color: var(--ink);
  text-align: left;
  margin: 0 0 8px 4px;
}

/* Recessed well: dark inner shadow up top, faint light lip along the bottom. */
.inset-field {
  flex: 1;
  border-radius: var(--r-sm);
  padding: 3px;
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.42), rgba(255, 255, 255, 0.09));
  box-shadow: 0 2px 0 rgba(255, 255, 255, 0.08);
}

.inset-field input {
  width: 100%;
  font-weight: 600;
  font-size: 15px;
  color: var(--ink);
  padding: 14px 16px;
  border: 0;
  border-radius: 14px;
  outline: none;
  background: linear-gradient(180deg, #efd9d6, #ffffff 40%);
  color: #3b2350;
  box-shadow: inset 0 3px 7px rgba(120, 70, 90, 0.30);
}

.inset-field input::placeholder {
  color: #a08fae;
  font-weight: 600;
}

.inset-field input:focus {
  box-shadow: inset 0 3px 7px rgba(120, 70, 90, 0.3), 0 0 0 3px rgba(255, 125, 138, 0.45);
}

.inset-field input:disabled {
  opacity: 0.6;
}

.guess-form {
  display: grid;
  gap: 12px;
  margin-top: 18px;
}

/* ==========================================================================
   Cards
   ========================================================================== */

.card {
  position: relative;
  border-radius: var(--r-md);
  padding: 16px;
  border: 2px solid rgba(255, 255, 255, 0.13);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.22),
    inset 0 -6px 0 rgba(0, 0, 0, 0.26),
    0 12px 26px rgba(0, 0, 0, 0.42);
}

.card-deck {
  background: linear-gradient(180deg, var(--purple-500), var(--navy-800));
  padding: 14px 14px 16px;
}

.pill {
  font-weight: 700;
  font-size: 10px;
  letter-spacing: 0.2px;
  padding: 5px 11px;
  border-radius: 99px;
  white-space: nowrap;
}

/* ==========================================================================
   Versus block (players)
   ========================================================================== */

.versus {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 8px;
  margin: 16px 0 18px;
}

.player-card {
  position: relative;
  border-radius: var(--r-md);
  padding: 14px 10px 12px;
  display: grid;
  justify-items: center;
  gap: 4px;
  border: 2px solid rgba(255, 255, 255, 0.16);
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.28),
    inset 0 -6px 0 rgba(0, 0, 0, 0.26),
    0 10px 0 rgba(0, 0, 0, 0.24),
    0 18px 30px rgba(0, 0, 0, 0.42);
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.player-coral {
  background: linear-gradient(180deg, var(--coral-400), var(--coral-600));
}

.player-teal {
  background: linear-gradient(180deg, var(--teal-400), var(--teal-600));
}

/* Whoever is on the clock lifts and gets a gold rim. */
.player-card.active {
  transform: translateY(-5px);
  border-color: var(--gold-400);
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.34),
    inset 0 -6px 0 rgba(0, 0, 0, 0.26),
    0 12px 0 rgba(0, 0, 0, 0.26),
    0 20px 34px rgba(0, 0, 0, 0.45),
    0 0 0 4px rgba(255, 201, 77, 0.30);
}

.player-name {
  font-weight: 700;
  font-size: 15px;
  line-height: 1.15;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.26);
}

.avatar {
  letter-spacing: -0.5px;
  width: 62px;
  height: 62px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-weight: 900;
  font-size: 23px;
  color: #40214a;
  background: linear-gradient(180deg, #fff2ee, #d9bfd6);
  border: 3px solid rgba(255, 255, 255, 0.65);
  box-shadow:
    inset 0 -5px 0 rgba(120, 70, 110, 0.24),
    0 6px 0 rgba(0, 0, 0, 0.22),
    0 12px 20px rgba(0, 0, 0, 0.36);
}

.avatar-sm {
  width: 46px;
  height: 46px;
  font-size: 17px;
  border-width: 2px;
}

.player-badges {
  display: flex;
  gap: 5px;
  align-items: center;
  margin-top: 3px;
}

.badge {
  font-weight: 700;
  font-size: 13px;
  min-width: 26px;
  padding: 3px 8px;
  border-radius: 99px;
  color: #4a2a00;
  background: linear-gradient(180deg, var(--gold-300), var(--gold-400));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.6), 0 3px 0 var(--gold-600);
}

.badge-soft {
  background: rgba(0, 0, 0, 0.26);
  color: var(--ink);
  box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.2px;
}

.vs-orb {
  width: 46px;
  height: 46px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-weight: 900;
  font-size: 15px;
  color: #4a2a00;
  background: radial-gradient(circle at 36% 28%, #fff3cf, var(--gold-400) 62%, var(--gold-500));
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.7),
    inset 0 -4px 0 rgba(150, 90, 0, 0.3),
    0 6px 0 var(--gold-600),
    0 12px 22px rgba(0, 0, 0, 0.45);
}

/* ==========================================================================
   Round progress pips
   ========================================================================== */

.pips {
  list-style: none;
  display: flex;
  justify-content: center;
  gap: 9px;
  padding: 0;
  margin: 0 0 16px;
}

.pips li {
  width: 38px;
  height: 38px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-weight: 700;
  font-size: 15px;
  color: var(--ink);
  background: linear-gradient(180deg, #3b2b62, #271a47);
  box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.5), inset 0 -2px 0 rgba(255, 255, 255, 0.08);
}

.pips li[data-state="current"] {
  color: #4a2a00;
  background: radial-gradient(circle at 36% 28%, #fff3cf, var(--gold-400) 60%, var(--gold-500));
  box-shadow: inset 0 3px 0 rgba(255, 255, 255, 0.65), 0 5px 0 var(--gold-600), 0 10px 18px rgba(0, 0, 0, 0.45);
  animation: pip-pulse 1.5s ease-in-out infinite;
}

@keyframes pip-pulse {

  0%,
  100% {
    transform: translateY(0);
  }

  50% {
    transform: translateY(-3px);
  }
}

.pips li[data-state="won"] {
  color: #06301f;
  background: linear-gradient(180deg, #86f0bd, var(--ok));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.55), 0 4px 0 #248a5e;
}

.pips li[data-state="lost"] {
  color: #4a0f1a;
  background: linear-gradient(180deg, var(--coral-300), var(--coral-500));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.45), 0 4px 0 var(--coral-700);
}

.pips li[data-state="drawn"] {
  color: var(--ink);
  background: linear-gradient(180deg, #7c6aa8, #4d3c7c);
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.24), 0 4px 0 #2e2154;
}

/* ==========================================================================
   Deck panel + waveform
   ========================================================================== */

.deck-wave {
  height: 46px;
  border-radius: 14px;
  margin-bottom: 12px;
  background:
    repeating-linear-gradient(90deg,
      var(--teal-300) 0 3px,
      transparent 3px 7px);
  -webkit-mask-image: linear-gradient(180deg, transparent 6%, #000 50%, transparent 94%);
  mask-image: linear-gradient(180deg, transparent 6%, #000 50%, transparent 94%);
  opacity: 0.55;
  box-shadow: inset 0 0 22px rgba(0, 0, 0, 0.5);
}

/* ==========================================================================
   Round table
   ========================================================================== */

.rounds {
  list-style: none;
  padding: 0;
  margin: 0 0 12px;
  display: grid;
  gap: 7px;
}

.round-row {
  display: grid;
  grid-template-columns: 34px 1fr 74px 74px;
  align-items: center;
  gap: 7px;
  padding: 8px 10px;
  border-radius: 14px;
  text-align: left;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.18));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.12), inset 0 -3px 0 rgba(0, 0, 0, 0.26);
}

.round-row[data-current="true"] {
  background: linear-gradient(180deg, rgba(255, 201, 77, 0.30), rgba(255, 201, 77, 0.08));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.3), 0 0 0 2px rgba(255, 201, 77, 0.45);
}

.rr-n {
  font-weight: 700;
  font-size: 12px;
  color: #4a2a00;
  text-align: center;
  padding: 3px 0;
  border-radius: 8px;
  background: linear-gradient(180deg, var(--gold-300), var(--gold-400));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.55), 0 2px 0 var(--gold-600);
}

.rr-song {
  font-size: 12px;
  font-weight: 600;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.rr-song.known {
  color: #fff;
}

.rr-time {
  font-weight: 700;
  font-size: 13px;
  text-align: right;
  padding: 5px 8px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
  background: rgba(0, 0, 0, 0.28);
  box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.45);
  color: var(--ink);
}

.rr-time.pending {
  color: var(--ink);
}

.rr-time.dnf {
  color: var(--bad);
}

.rr-time.win {
  color: #06301f;
  background: linear-gradient(180deg, #86f0bd, var(--ok));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.5), 0 2px 0 #248a5e;
}

.crown {
  font-size: 12px;
  filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.35));
}

/* ==========================================================================
   Turntable (gameplay)
   ========================================================================== */

.turntable {
  position: relative;
  display: grid;
  place-items: center;
  margin: 6px 0 4px;
}

.vinyl {
  position: relative;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: radial-gradient(circle at 34% 26%, #4a4a58 0%, #17171f 42%, #0b0b11 100%);
  box-shadow:
    inset 0 4px 0 rgba(255, 255, 255, 0.14),
    inset 0 -10px 22px rgba(0, 0, 0, 0.7),
    0 18px 0 rgba(0, 0, 0, 0.28),
    0 30px 50px rgba(0, 0, 0, 0.6);
}

.vinyl-lg {
  width: min(232px, 62vw);
  aspect-ratio: 1;
}

.vinyl-sm {
  width: 120px;
  aspect-ratio: 1;
}

/* Concentric record grooves. */
.vinyl-grooves {
  position: absolute;
  inset: 9%;
  border-radius: 50%;
  background: repeating-radial-gradient(circle at center,
      rgba(255, 255, 255, 0.055) 0 1px,
      transparent 1px 5px);
  pointer-events: none;
}

/* Progress ring: --pct is written by client.js from audio.currentTime. */
.vinyl-ring {
  position: absolute;
  inset: -9px;
  border-radius: 50%;
  background: conic-gradient(var(--gold-400) calc(var(--pct, 0) * 1%),
      rgba(255, 255, 255, 0.09) 0);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 9px), #000 calc(100% - 8px));
  mask: radial-gradient(farthest-side, transparent calc(100% - 9px), #000 calc(100% - 8px));
  transition: none;
}

.vinyl[data-danger="true"] .vinyl-ring {
  background: conic-gradient(var(--coral-400) calc(var(--pct, 0) * 1%), rgba(255, 255, 255, 0.09) 0);
}

.vinyl-label {
  position: relative;
  width: 46%;
  aspect-ratio: 1;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: radial-gradient(circle at 36% 28%, var(--coral-300), var(--coral-500) 60%, var(--coral-600));
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.4),
    inset 0 -6px 0 rgba(0, 0, 0, 0.24),
    0 6px 16px rgba(0, 0, 0, 0.5);
}

.vinyl-time {
  letter-spacing: -1px;
  font-weight: 900;
  font-size: clamp(22px, 6.4vw, 30px);
  color: #fff;
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.32);
  font-variant-numeric: tabular-nums;
}

.vinyl-q {
  letter-spacing: -0.5px;
  font-weight: 900;
  font-size: 26px;
  color: #fff;
}

.spinning,
.vinyl.playing {
  animation: spin 2.6s linear infinite;
}

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

/* Keep the readout upright while the disc turns. */
.vinyl.playing .vinyl-label {
  animation: spin-rev 2.6s linear infinite;
}

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

.tonearm {
  position: absolute;
  top: 6%;
  right: 4%;
  width: 76px;
  height: 76px;
  pointer-events: none;
}

.tonearm i {
  position: absolute;
  right: 4px;
  top: 4px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: linear-gradient(180deg, #7ee6e2, var(--teal-500));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.5), 0 3px 0 var(--teal-700);
}

.tonearm b {
  position: absolute;
  right: 12px;
  top: 20px;
  width: 7px;
  height: 56px;
  border-radius: 6px;
  transform-origin: top center;
  transform: rotate(28deg);
  background: linear-gradient(180deg, #cfd6e8, #7b839c);
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.6), 0 3px 6px rgba(0, 0, 0, 0.5);
  transition: transform 0.4s cubic-bezier(0.3, 1.2, 0.4, 1);
}

.turntable[data-playing="true"] .tonearm b {
  transform: rotate(12deg);
}

/* ==========================================================================
   Transport row
   ========================================================================== */

.transport {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  /* One child now that the round/attempt chips are gone, so `center` is doing
     the whole job. The bottom margin absorbs the round hint that used to sit
     under here and separate this from the guess form. */
  margin: 16px 0 22px;
}

.feedback {
  min-height: 24px;
  margin: 12px 0 0;
  font-weight: 700;
  font-size: 13.5px;
}

.feedback.wrong {
  color: var(--bad);
}

.feedback.right {
  color: var(--ok);
}

/* ==========================================================================
   Results screen
   ========================================================================== */

.over-badge {
  font-size: 52px;
  margin: 0 0 2px;
  filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.5));
}

.tickets {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin: 14px 0;
}

/* Notched sides sell the "concert ticket" shape. */
.ticket {
  position: relative;
  border-radius: 18px;
  padding: 12px 10px 12px;
  display: grid;
  justify-items: center;
  gap: 5px;
  border: 2px solid rgba(255, 255, 255, 0.2);
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.3),
    inset 0 -6px 0 rgba(0, 0, 0, 0.26),
    0 9px 0 rgba(0, 0, 0, 0.24),
    0 16px 28px rgba(0, 0, 0, 0.42);
}

.ticket::before,
.ticket::after {
  content: "";
  position: absolute;
  top: 62%;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--navy-800);
  box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.6);
}

.ticket::before {
  left: -9px;
}

.ticket::after {
  right: -9px;
}

.ticket-coral {
  background: linear-gradient(180deg, var(--coral-400), var(--coral-600));
}

.ticket-teal {
  background: linear-gradient(180deg, var(--teal-400), var(--teal-600));
}

.ticket.is-winner {
  border-color: var(--gold-400);
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.34),
    inset 0 -6px 0 rgba(0, 0, 0, 0.26),
    0 9px 0 rgba(0, 0, 0, 0.24),
    0 16px 28px rgba(0, 0, 0, 0.42),
    0 0 0 4px rgba(255, 201, 77, 0.34);
}

.ticket-top {
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.2px;
  color: var(--ink);
}

.ticket-name {
  font-weight: 700;
  font-size: 14px;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ticket-line {
  font-size: 10.5px;
  font-weight: 700;
  color: var(--ink);
  text-align: center;
  line-height: 1.35;
}

.ticket-stub {
  width: 100%;
  margin-top: 4px;
  padding: 7px 4px 5px;
  border-radius: 12px;
  display: grid;
  gap: 0;
  background: rgba(0, 0, 0, 0.26);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.45);
}

.stub-big {
  letter-spacing: -0.5px;
  font-weight: 900;
  font-size: 24px;
  line-height: 1;
}

.stub-small {
  font-size: 8.5px;
  font-weight: 700;
  letter-spacing: 0.2px;
  color: var(--ink);
}

.rounds-final {
  margin-bottom: 14px;
}

.score-bar {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 10px;
  margin-bottom: 16px;
}

.score-block {
  border-radius: var(--r-sm);
  padding: 9px 8px 10px;
  display: grid;
  gap: 1px;
  border: 2px solid rgba(255, 255, 255, 0.16);
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.28),
    inset 0 -5px 0 rgba(0, 0, 0, 0.26),
    0 7px 0 rgba(0, 0, 0, 0.24),
    0 14px 24px rgba(0, 0, 0, 0.4);
}

.score-coral {
  background: linear-gradient(180deg, var(--coral-400), var(--coral-600));
}

.score-teal {
  background: linear-gradient(180deg, var(--teal-400), var(--teal-600));
}

.score-label {
  font-size: 8.5px;
  font-weight: 700;
  letter-spacing: 0.2px;
  color: var(--ink);
}

.score-name {
  font-weight: 700;
  font-size: 12.5px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.score-value {
  letter-spacing: -0.6px;
  font-weight: 900;
  font-size: 27px;
  line-height: 1.05;
}

.score-vs {
  font-weight: 900;
  font-size: 15px;
  color: var(--gold-300);
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.4);
}

/* ==========================================================================
   Misc
   ========================================================================== */

.waiting-wrap {
  display: grid;
  place-items: center;
  padding: 14px 0 4px;
}

.conn {
  text-align: center;
  font-size: 11.5px;
  font-weight: 700;
  color: var(--coral-300);
  min-height: 18px;
  margin: 14px 0 0;
}

@media (prefers-reduced-motion: reduce) {

  .spinning,
  .vinyl.playing,
  .vinyl.playing .vinyl-label,
  .pips li[data-state="current"] {
    animation: none !important;
  }

  .screen {
    animation: none;
  }
}

@media (max-width: 380px) {
  .screen {
    padding: 22px 15px 24px;
  }

  .round-row {
    grid-template-columns: 30px 1fr 62px 62px;
    gap: 5px;
    padding: 7px 8px;
  }

  .rr-time {
    font-size: 11.5px;
    padding: 4px 6px;
  }

  .avatar {
    width: 54px;
    height: 54px;
    font-size: 20px;
  }

  .btn-disc {
    width: 82px;
    height: 82px;
  }

  .tickets {
    gap: 8px;
  }
}

/* ==========================================================================
   Wrong-guess penalty
   ========================================================================== */

/* Running penalty total, tucked under the clock inside the vinyl label. */
.vinyl-penalty {
  display: block;
  font-weight: 700;
  font-size: 11px;
  line-height: 1;
  margin-top: 2px;
  color: #4a0f1a;
  background: rgba(255, 255, 255, 0.55);
  border-radius: 99px;
  padding: 2px 7px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}

/* Floating "+1s Penalty" that pops once per wrong guess. */
.penalty-pop {
  letter-spacing: -0.3px;
  position: absolute;
  left: 50%;
  top: 22%;
  transform: translate(-50%, 0);
  font-weight: 900;
  font-size: 21px;
  white-space: nowrap;
  color: #fff;
  padding: 8px 16px;
  border-radius: 99px;
  background: linear-gradient(180deg, var(--coral-400), var(--coral-600));
  border: 2px solid rgba(255, 255, 255, 0.5);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.45),
    0 6px 0 var(--coral-700),
    0 14px 28px rgba(0, 0, 0, 0.55);
  opacity: 0;
  pointer-events: none;
  z-index: 4;
}

.penalty-pop.pop {
  animation: penalty-float 1.15s cubic-bezier(0.2, 0.9, 0.3, 1) both;
}

@keyframes penalty-float {
  0% {
    opacity: 0;
    transform: translate(-50%, 14px) scale(0.7);
  }

  18% {
    opacity: 1;
    transform: translate(-50%, -4px) scale(1.14);
  }

  32% {
    transform: translate(-50%, -10px) scale(1);
  }

  100% {
    opacity: 0;
    transform: translate(-50%, -58px) scale(0.94);
  }
}

/* ==========================================================================
   Thinking time
   ========================================================================== */

.think {
  margin: 14px 0 2px;
  padding: 11px 13px 12px;
  border-radius: var(--r-sm);
  background: linear-gradient(180deg, #241a44, #191033);
  box-shadow:
    inset 0 3px 8px rgba(0, 0, 0, 0.55),
    inset 0 -2px 0 rgba(255, 255, 255, 0.07);
}

.think-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: 8px;
}

.think-label {
  font-weight: 700;
  font-size: 9.5px;
  letter-spacing: 0.2px;
  color: var(--ink);
}

.think-left {
  font-weight: 700;
  font-size: 15px;
  font-variant-numeric: tabular-nums;
  color: var(--teal-300);
}

.think-track {
  height: 9px;
  border-radius: 99px;
  background: rgba(0, 0, 0, 0.45);
  box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.6);
  overflow: hidden;
}

.think-bar {
  height: 100%;
  width: 100%;
  border-radius: 99px;
  background: linear-gradient(90deg, var(--teal-300), var(--teal-500));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.4);
}

/* Last few seconds: everything goes red and starts breathing. */
.think.urgent {
  animation: think-alarm 0.62s ease-in-out infinite;
}

.think.urgent .think-left {
  color: var(--coral-300);
}

.think.urgent .think-bar {
  background: linear-gradient(90deg, var(--coral-300), var(--coral-600));
}

@keyframes think-alarm {

  0%,
  100% {
    box-shadow: inset 0 3px 8px rgba(0, 0, 0, .55), 0 0 0 0 rgba(255, 77, 109, 0);
  }

  50% {
    box-shadow: inset 0 3px 8px rgba(0, 0, 0, .55), 0 0 0 4px rgba(255, 77, 109, 0.34);
  }
}

/* --- the 3..2..1 overlay ---------------------------------------------- */

.countdown {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
  z-index: 5;
}

.countdown span {
  letter-spacing: -3px;
  font-weight: 900;
  font-size: 108px;
  line-height: 1;
  color: #fff;
  text-shadow:
    0 4px 0 var(--coral-700),
    0 0 34px rgba(255, 77, 109, 0.95),
    0 14px 40px rgba(0, 0, 0, 0.7);
  animation: countdown-beat 1s cubic-bezier(0.2, 0.9, 0.3, 1) infinite;
}

@keyframes countdown-beat {
  0% {
    opacity: 0;
    transform: scale(1.75);
  }

  22% {
    opacity: 1;
    transform: scale(1);
  }

  72% {
    opacity: 1;
    transform: scale(0.97);
  }

  100% {
    opacity: 0.15;
    transform: scale(0.86);
  }
}

/* ==========================================================================
   Guess actions (submit + give up)
   ========================================================================== */

.guess-actions {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 10px;
}

/* Deliberately not coral: conceding should never look like the primary path. */
.btn-giveup {
  background: linear-gradient(180deg, #6b5c8c, #4a3a72 62%, #3a2c5c);
  color: var(--ink);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.18),
    inset 0 -4px 0 rgba(0, 0, 0, 0.26),
    0 6px 0 #241a44,
    0 12px 22px rgba(0, 0, 0, 0.4);
}

.btn-giveup:hover:not(:disabled) {
  color: var(--coral-300);
}

.btn-giveup:active:not(:disabled) {
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.18),
    0 2px 0 #241a44,
    0 6px 14px rgba(0, 0, 0, 0.4);
}

@media (prefers-reduced-motion: reduce) {
  .penalty-pop.pop {
    animation-duration: 0.01s;
    opacity: 1;
  }

  .think.urgent,
  .countdown span {
    animation: none;
  }

  .countdown span {
    opacity: 1;
  }
}

@media (max-width: 380px) {
  .guess-actions {
    grid-template-columns: 1fr;
  }

  .countdown span {
    font-size: 84px;
  }

  .penalty-pop {
    font-size: 18px;
    padding: 7px 13px;
  }
}

/* --- "you are playing X" tag on the board and round screens ------------- */

.mode-tag {
  margin: 6px 0 0;
}

.pill-mode {
  display: inline-block;
  color: #2a1a4d;
  background: linear-gradient(180deg, #ffffff, #d9cbf0);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.85),
    0 3px 0 rgba(0, 0, 0, 0.30),
    0 6px 12px rgba(0, 0, 0, 0.32);
}

/* Movie mode is a different game -- say so in the colour too. */
.pill-mode[data-mode="movies"] {
  color: #4a2c00;
  background: linear-gradient(180deg, var(--gold-300), var(--gold-400));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.7),
    0 3px 0 var(--gold-600),
    0 6px 12px rgba(0, 0, 0, 0.32);
}

/* ==========================================================================
   Segmented toggle (Titles / Movies, in the playlist creator)
   --------------------------------------------------------------------------
   Two keys in one recessed well. The chosen key rises out of the well in
   coral; the other stays flush and dim, so the pair reads as one switch
   rather than two buttons.

   ========================================================================== */

.segment {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 6px;
  padding: 6px;
  border-radius: var(--r-md);
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.40), rgba(255, 255, 255, 0.06));
  box-shadow:
    inset 0 4px 10px rgba(0, 0, 0, 0.45),
    0 2px 0 rgba(255, 255, 255, 0.08);
}

.seg-btn {
  display: grid;
  gap: 1px;
  justify-items: center;
  padding: 10px 6px 11px;
  border: 0;
  cursor: pointer;
  border-radius: var(--r-sm);
  color: var(--ink);
  background: transparent;
  transition: transform 0.10s ease, box-shadow 0.10s ease, color 0.16s ease;
  -webkit-tap-highlight-color: transparent;
}

.seg-emoji {
  font-size: 18px;
  line-height: 1.1;
}

.seg-label {
  font-weight: 700;
  font-size: 13.5px;
  letter-spacing: 0.2px;
}

.seg-btn:hover:not([aria-checked="true"]) {
  color: var(--ink);
}

.seg-btn[aria-checked="true"] {
  color: #fff;
  transform: translateY(-1px);
  background: linear-gradient(180deg, var(--coral-300), var(--coral-500) 62%, var(--coral-600));
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.28);
  box-shadow:
    var(--ring-light),
    inset 0 -4px 0 rgba(0, 0, 0, 0.22),
    0 5px 0 var(--coral-700),
    0 10px 20px rgba(0, 0, 0, 0.38);
}

.seg-btn[aria-checked="true"]:active {
  transform: translateY(3px);
  box-shadow: var(--ring-light), 0 1px 0 var(--coral-700);
}

.seg-btn:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(255, 125, 138, 0.65);
}

/* ==========================================================================
   Playlist creator
   ========================================================================== */

/* --- search results and picked songs ----------------------------------- */

.song-list {
  list-style: none;
  margin: 12px 0 0;
  padding: 6px;
  display: grid;
  gap: 6px;
  max-height: 244px;
  overflow-y: auto;
  border-radius: var(--r-md);
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.34), rgba(255, 255, 255, 0.05));
  box-shadow: inset 0 4px 10px rgba(0, 0, 0, 0.40);
}

.song-list:empty {
  display: none;
}

/* Now a plain list item: the row's raised face moved to the button inside it,
   so the whole result is one tap target rather than a card with a button on
   it. See .song-open. */
.song-row {
  display: block;
}

/* One search result. The entire row adds the song -- there is nothing else to
   do with a result, so a separate "+" would be a second way to say the same
   thing and a smaller target for it. */
.song-open {
  width: 100%;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border: 0;
  border-radius: 13px;
  cursor: pointer;
  text-align: left;
  color: var(--ink);
  background: linear-gradient(180deg, var(--purple-500), var(--purple-700));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.16),
    inset 0 -3px 0 rgba(0, 0, 0, 0.24);
  transition: filter 0.14s ease, transform 0.09s ease;
  -webkit-tap-highlight-color: transparent;
}

.song-open:hover {
  filter: brightness(1.10);
}

.song-open:active {
  transform: translateY(2px);
}

.song-open:focus-visible {
  outline: none;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.16),
    0 0 0 3px rgba(255, 125, 138, 0.65);
}

.song-text {
  display: grid;
  gap: 1px;
  min-width: 0;
}

/* The affordance, and then the receipt. A row that has just been added says so
   for as long as the results are up, so a fast tapper can see what landed. */
.song-add {
  font-weight: 900;
  font-size: 19px;
  line-height: 1;
  color: var(--ink);
  opacity: 0.7;
}

.song-open[data-state="added"] {
  filter: saturate(0.7) brightness(0.9);
}

/* The plus becomes a tick. The character itself is set in JS -- a CSS `content`
   escape is one more layer between the source and the screen, and it is exactly
   what turned this glyph into "13" the first time. */
.song-open[data-state="added"] {
  filter: saturate(0.7) brightness(0.92);
}

.song-open[data-state="added"] .song-add {
  color: var(--ok);
  opacity: 1;
}

/* Tapped twice. The database refuses a duplicate outright, so the row says no
   rather than letting the save find out. */
.song-open[data-state="already"] {
  animation: shake 0.3s ease;
}

@keyframes shake {
  25% { transform: translateX(-4px); }
  75% { transform: translateX(4px); }
}

.song-title {
  font-weight: 700;
  font-size: 13.5px;
  color: #fff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.song-artist {
  font-size: 11px;
  font-weight: 700;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ==========================================================================
   Single player: score plates
   ========================================================================== */

.pill-score {
  color: #05372a;
  background: linear-gradient(180deg, #b6ffe0, var(--ok));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.7), 0 3px 0 #23906a, 0 6px 12px rgba(0, 0, 0, 0.32);
}

/* Between-round plate: the running total, where the VS cards would be. */
.solo-plate {
  display: grid;
  justify-items: center;
  gap: 1px;
  margin: 16px 0 4px;
  padding: 14px 16px 16px;
  border-radius: var(--r-md);
  border: 2px solid rgba(255, 255, 255, 0.13);
  background: linear-gradient(180deg, var(--purple-500), var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.22),
    inset 0 -6px 0 rgba(0, 0, 0, 0.26),
    0 12px 26px rgba(0, 0, 0, 0.42);
}

.solo-plate-label,
.solo-total-label {
  font-weight: 700;
  font-size: 11px;
  letter-spacing: 0.2px;
  color: var(--ink);
}

.solo-plate-score {
  letter-spacing: -1px;
  font-weight: 900;
  font-size: 40px;
  line-height: 1.05;
  background: linear-gradient(180deg, #fff2c4, var(--gold-400) 55%, var(--gold-500));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  filter: drop-shadow(0 3px 0 rgba(120, 60, 0, 0.35));
  font-variant-numeric: tabular-nums;
}

.solo-plate-max {
  font-size: 12px;
  font-weight: 700;
  color: var(--ink);
}

/* Results plate. */
.solo-total {
  display: grid;
  justify-items: center;
  gap: 3px;
  margin: 14px 0 4px;
  padding: 20px 18px 18px;
  border-radius: var(--r-lg);
  border: 2px solid rgba(255, 255, 255, 0.14);
  background:
    radial-gradient(120% 80% at 50% 0%, rgba(255, 201, 77, 0.22), transparent 60%),
    linear-gradient(180deg, var(--purple-500), var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.22),
    inset 0 -8px 0 rgba(0, 0, 0, 0.28),
    0 18px 38px rgba(0, 0, 0, 0.48);
}

.solo-total-score {
  font-weight: 900;
  letter-spacing: -2px;
  font-size: clamp(48px, 15vw, 68px);
  line-height: 1;
  background: linear-gradient(180deg, #fff6d8, var(--gold-300) 42%, var(--gold-500));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  filter: drop-shadow(0 4px 0 rgba(120, 60, 0, 0.38));
  font-variant-numeric: tabular-nums;
}

.solo-total-max {
  font-weight: 700;
  font-size: 12px;
  letter-spacing: 0.2px;
  color: var(--ink);
}

.solo-total-line {
  margin-top: 6px;
  font-weight: 700;
  font-size: 15px;
  color: var(--ink);
}

/* How close to a perfect run, drawn as a real gauge. */
.solo-meter {
  width: 100%;
  height: 12px;
  margin-top: 10px;
  border-radius: 99px;
  overflow: hidden;
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.45), rgba(255, 255, 255, 0.05));
  box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.5);
}

.solo-meter i {
  display: block;
  height: 100%;
  width: 0;
  border-radius: 99px;
  background: linear-gradient(90deg, var(--coral-400), var(--gold-400));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.45);
  transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}

/* The points a round was worth, in the round table. */
.rr-points {
  font-weight: 700;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
  color: var(--gold-300);
  text-align: right;
}

.rr-points.zero {
  color: var(--ink);
}

/* Solo round rows have one result column, not two. */
.round-row[data-solo="true"] {
  grid-template-columns: 34px 1fr auto;
}

@media (max-width: 380px) {
  .song-row {
    grid-template-columns: 28px 1fr auto;
  }
}

/* ==========================================================================
   A revealed round row is a button
   --------------------------------------------------------------------------
   Only once the round is settled: before that the row says "Hidden" and there
   is nothing behind it to open.
   ========================================================================== */

.round-row[data-revealed="true"] {
  cursor: pointer;
  transition: transform 0.09s ease, box-shadow 0.09s ease, background 0.16s ease;
}

.round-row[data-revealed="true"]:hover {
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(0, 0, 0, 0.14));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    inset 0 -3px 0 rgba(0, 0, 0, 0.26),
    0 4px 12px rgba(0, 0, 0, 0.32);
}

.round-row[data-revealed="true"]:active { transform: translateY(2px); }

.round-row[data-revealed="true"]:focus-visible {
  outline: none;
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.14), 0 0 0 3px rgba(255, 125, 138, 0.7);
}

/* A quiet chevron: enough to say "there is more here", not enough to compete
   with the time or the score beside it. */
.round-row[data-revealed="true"] .rr-song.known::after {
  content: '›';
  margin-left: 6px;
  font-weight: 700;
  color: var(--ink);
}

/* ==========================================================================
   Modal: the veil and card, shared by any dialog
   ========================================================================== */

.veil {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: grid;
  place-items: center;
  padding: 22px 16px;
  background: rgba(12, 8, 26, 0.68);
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
  animation: veil-in 0.16s ease both;

  /* The veil is the ONLY thing that scrolls while it is up -- the document
     itself is locked by .scroll-locked below. Without this pair, a wheel over
     the veil scrolled the lobby behind it, and a modal taller than a short
     viewport had no way to reach its own bottom. */
  overflow-y: auto;
}

/**
 * Put on <html> whenever any overlay is open: the three veils, or the sign-in
 * gate. Nothing behind an overlay should move.
 *
 * On <html> rather than <body> because body is the grid that centres the app
 * and hiding ITS overflow leaves the viewport scrollbar the page inherited
 * from the root still live. `overscroll-behavior` is the second half: without
 * it, scrolling past the end of a modal chains to the document and drags the
 * page anyway.
 */
html.scroll-locked,
html.scroll-locked body {
  overflow: hidden;
}

.veil,
.gate {
  overscroll-behavior: contain;
}

@keyframes veil-in {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

.modal {
  width: min(360px, 100%);
  padding: 24px 22px 22px;
  text-align: center;
  border-radius: var(--r-lg);
  border: 2px solid rgba(255, 255, 255, 0.14);
  background:
    radial-gradient(130% 80% at 50% 0%, rgba(255, 125, 138, 0.26), transparent 62%),
    linear-gradient(170deg, var(--purple-600), var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.22),
    inset 0 -8px 0 rgba(0, 0, 0, 0.30),
    0 30px 64px rgba(0, 0, 0, 0.62);
  animation: modal-in 0.24s cubic-bezier(0.22, 1.1, 0.4, 1) both;
}

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

  to {
    opacity: 1;
    transform: none;
  }
}

.modal-glyph {
  display: grid;
  place-items: center;
  width: 58px;
  height: 58px;
  margin: 0 auto 12px;
  font-size: 27px;
  border-radius: 50%;
  background: linear-gradient(180deg, var(--coral-300), var(--coral-500) 62%, var(--coral-600));
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.5),
    inset 0 -4px 0 rgba(0, 0, 0, 0.22),
    0 6px 0 var(--coral-700),
    0 14px 26px rgba(0, 0, 0, 0.45);
}

.modal-title {
  letter-spacing: -0.4px;
  font-weight: 900;
  font-size: 25px;
  margin: 0 0 8px;
  color: #fff;
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.28), 0 5px 0 rgba(0, 0, 0, 0.14);
}

.modal-body {
  margin: 0;
  font-size: 14.5px;
  font-weight: 600;
  line-height: 1.45;
  color: var(--ink);
}

.modal-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin-top: 18px;
}

/* .btn is written for <button>; the Open action is an anchor, so it needs the
   bits an anchor does not inherit. */
.modal-actions .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-decoration: none;
  padding: 14px 12px;
}

.modal-actions .btn:focus-visible {
  outline: none;
  box-shadow: var(--ring-light), 0 6px 0 var(--coral-700), 0 0 0 3px rgba(255, 224, 138, 0.85);
}

@media (prefers-reduced-motion: reduce) {

  .veil,
  .modal {
    animation: none;
  }
}

@media (max-width: 380px) {
  .modal-actions {
    gap: 8px;
  }
}
/* The opponent has answered, but this player has not played the round yet, so
   the time is withheld. Distinct from `.pending`, which means nobody has
   played it -- see timeCell() in client.js. */
.rr-time.sealed {
  color: var(--gold-300);
  opacity: 0.85;
  cursor: help;
}

/* ==========================================================================
   Song fact sheet
   ========================================================================== */

.modal-fact { padding-top: 22px; }

/* The sleeve: a square well with the art seated in it, so a 300x300 JPEG
   still reads as an object on this surface rather than a pasted rectangle. */
.fact-art-frame {
  position: relative;
  width: min(190px, 62%);
  aspect-ratio: 1 / 1;
  margin: 0 auto 16px;
  display: grid;
  place-items: center;
  border-radius: var(--r-md);
  overflow: hidden;
  background: linear-gradient(180deg, var(--purple-700), var(--navy-900));
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.18),
    inset 0 -5px 0 rgba(0, 0, 0, 0.34),
    0 10px 24px rgba(0, 0, 0, 0.55),
    0 0 0 2px rgba(255, 255, 255, 0.10);
}

.fact-art {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Shown when there is no artwork, or when the URL has rotted. */
.fact-art-fallback {
  position: absolute;
  font-size: 54px;
  opacity: 0.55;
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
}

.fact-song {
  font-size: 22px;
  line-height: 1.2;
  margin: 0 0 4px;
  overflow-wrap: anywhere;
}

.fact-artist {
  margin: 0;
  font-size: 15px;
  font-weight: 700;
  color: var(--gold-300);
  overflow-wrap: anywhere;
}

.fact-film {
  margin: 8px 0 0;
  font-size: 13px;
  font-weight: 700;
  color: var(--ink);
}
.fact-film:empty { display: none; }

/* One button, so it should not sit in half the width. */
.modal-actions-single { grid-template-columns: 1fr; }


/* ==========================================================================
   Sign-in gate
   --------------------------------------------------------------------------
   The barrier, and the first thing anyone sees. It is not a screen in the
   `.screen` rotation: those are driven by game state, and the gate has to
   outrank all of them -- including the ones a stale `show()` call could reach.
   A fixed layer above the stage is the only version of that which cannot be
   raced.

   The hero is the app's own turntable with the platter STOPPED. Logging in is
   what starts it -- see .gate[data-state="leaving"] at the bottom of this
   block. The record is the only piece of scenery here, everything else holds
   still, and it is reused wholesale from the round screen rather than drawn a
   second time.
   ========================================================================== */

.gate {
  position: fixed;
  inset: 0;
  z-index: 90;                 /* over the fact sheet veil (50) and the chip (60) */
  display: grid;
  place-items: center;
  overflow-y: auto;
  padding: 24px 16px;
  background:
    radial-gradient(760px 520px at 50% 8%, rgba(29, 185, 84, 0.16), transparent 62%),
    rgba(9, 6, 20, 0.90);
  backdrop-filter: blur(9px) saturate(0.85);
  -webkit-backdrop-filter: blur(9px) saturate(0.85);
  animation: veil-in 0.2s ease both;
}

.gate-panel {
  width: min(400px, 100%);
  text-align: center;
  padding: 30px 24px 24px;
  border-radius: var(--r-lg);
  border: 2px solid rgba(255, 255, 255, 0.11);
  background:
    radial-gradient(120% 74% at 50% 0%, rgba(29, 185, 84, 0.22), transparent 60%),
    linear-gradient(170deg, var(--purple-600), var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    inset 0 -9px 0 rgba(0, 0, 0, 0.30),
    0 34px 70px rgba(0, 0, 0, 0.66);
  animation: modal-in 0.34s cubic-bezier(0.22, 1.1, 0.4, 1) both;
}

.gate-deck {
  margin: 2px 0 20px;
}

/* Stopped, and stopped on purpose: the ring is unlit and the label carries the
   Spotify mark where a round would show the clock. */
.gate-ring {
  background: conic-gradient(var(--spotify-500) calc(var(--pct, 0) * 1%),
      rgba(255, 255, 255, 0.09) 0);
  transition: none;
}

.gate-label {
  background: radial-gradient(circle at 36% 28%,
      var(--spotify-300), var(--spotify-500) 58%, var(--spotify-600));
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.45),
    inset 0 -6px 0 rgba(0, 0, 0, 0.26),
    0 6px 16px rgba(0, 0, 0, 0.5);
}

/* Black mark on green, which is Spotify's own usage. `currentColor` paints the
   disc; `--mark-cut` paints the three bars through it. */
.gate-mark {
  width: 52%;
  height: 52%;
  color: #0b0810;
  --mark-cut: var(--spotify-400);
  filter: drop-shadow(0 2px 0 rgba(0, 0, 0, 0.22));
}

.gate-title {
  font-weight: 900;
  font-size: clamp(26px, 7vw, 33px);
  letter-spacing: -0.6px;
  margin: 0 0 8px;
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.3), 0 6px 0 rgba(0, 0, 0, 0.14);
}

.gate-lead {
  margin: 0 0 20px;
  font-size: 14.5px;
  font-weight: 600;
  line-height: 1.5;
  color: var(--ink);
}

.gate-note {
  margin: 14px 2px 0;
  font-size: 11.5px;
  font-weight: 600;
  line-height: 1.5;
  color: var(--ink);
}

.gate-error {
  margin: 12px 0 0;
  font-size: 13px;
  font-weight: 700;
  line-height: 1.45;
  color: var(--bad);
}

/* --- the button ------------------------------------------------------- */

.btn-spotify {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 11px;
  font-size: 17px;
  padding: 15px 22px;
  background: linear-gradient(180deg,
      var(--spotify-300), var(--spotify-500) 60%, var(--spotify-600));
  box-shadow:
    var(--ring-light),
    var(--ring-dark),
    0 7px 0 var(--spotify-700),
    var(--ambient);
}

/* `.btn:active` is written against the coral extrusion, so without this the
   green button flashes pink on the way down. */
.btn-spotify:active:not(:disabled) {
  box-shadow:
    var(--ring-light),
    inset 0 -2px 0 rgba(0, 0, 0, 0.22),
    0 2px 0 var(--spotify-700),
    0 6px 14px rgba(0, 0, 0, 0.4);
}

.btn-spotify:focus-visible {
  outline: none;
  box-shadow:
    var(--ring-light),
    var(--ring-dark),
    0 7px 0 var(--spotify-700),
    0 0 0 4px rgba(255, 224, 138, 0.9);
}

.btn-mark {
  width: 23px;
  height: 23px;
  flex: none;
  color: #08130c;
  --mark-cut: var(--spotify-400);
}

/* --- states ------------------------------------------------------------ *
 * checking     the session is still being resolved; locked and quiet
 * out          signed out; the button is live
 * leaving      the redirect to Spotify is under way -- drop the needle
 * error        the last attempt failed; the button is live again
 * unavailable  the server has no Supabase Auth config; nothing to click
 * -------------------------------------------------------------------- */

.gate[data-state="checking"] .gate-title,
.gate[data-state="unavailable"] .gate-title {
  opacity: 0.75;
}

/* The one orchestrated moment in the gate: the platter spins up and the
   tonearm swings in, and then the page hands off to Spotify. Everything else
   here is deliberately still so that this lands. */
.gate[data-state="leaving"] .gate-vinyl {
  animation: spin 2.6s linear infinite;
}

.gate[data-state="leaving"] .gate-label {
  animation: spin-rev 2.6s linear infinite;
}

.gate[data-state="leaving"] .gate-deck .tonearm b {
  transform: rotate(12deg);
}

.gate[data-state="leaving"] .gate-ring {
  --pct: 100;
}


/* ==========================================================================
   Profile chip
   --------------------------------------------------------------------------
   Anchored to the viewport's top-right rather than to the app column, so it
   never joins the screens' vertical flow and never moves when one screen swaps
   for a taller one. On narrow viewports it drops the name and becomes the
   avatar alone, which fits the corner gutter beside a centred screen title.
   ========================================================================== */

/* ==========================================================================
   THE APP HEADER
   --------------------------------------------------------------------------
   Brand left, actions right, one flex row, nothing overlapping anything.

   WHAT THIS REPLACED
   ------------------
   Three independently positioned things competing for the same corner: a
   `position: fixed` profile chip at top:14/right:14, a "+" button inside the
   Library tab's own header, and an envelope inside the Friends tab's. Each was
   laid out without reference to the others, so the chip landed on top of the
   search field, then on the "+", then on the envelope, depending on the tab.

   Now there is one row and the two tab actions are FLEX SIBLINGS of the chip
   with a 16px gap. Overlap is not fixed here so much as made impossible: they
   are in the same formatting context, so they take each other's width into
   account by construction.
   ========================================================================== */

.appbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 34;                 /* under the detail layer (35), which has its own bar */
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 10px var(--hub-pad-x);
  padding-top: calc(10px + env(safe-area-inset-top, 0px));
}

/* The right-hand cluster. `gap` is the whole fix: the action and the avatar
   are laid out beside each other rather than each being told where to sit.

   `margin-left: auto` rather than leaning on the bar's `space-between`, which
   only right-aligns this while something else is present to push against. The
   brand is hidden on a desktop (the rail carries it) and `space-between` then
   parked the whole cluster on the LEFT. Auto margin is unconditional. */
.appbar-actions {
  display: flex;
  align-items: center;
  gap: 16px;
  min-width: 0;
  margin-left: auto;
}

/* GLOBAL CHROME vs TAB ACTIONS
   -------------------------------------------------------------------------
   The envelope is not a property of the Friends tab. A friend request arrives
   while you are browsing Search, and a notification you can only see by
   navigating to the right tab first is not a notification. It sits beside the
   avatar on every tab, and its unread count travels with it.

   Nothing else belongs up here. A tab's own action goes in that tab, on the
   row with the heading it acts on -- see .hub-head. */
/* `.appbar .hub-action`, not `.hub-action`: .icon-btn sets `display: grid` and
   is declared further down this file, so a bare class here loses to it on
   source order and BOTH actions render -- which is how the envelope ended up
   pushing the "+" 48px off its mark on the Library tab. */
.appbar .hub-action {
  display: none;
}

/* On for every tab, off on a match screen -- a live round owns the viewport,
   and the header is down to the identity chip there. */
body[data-screen="home"] #btn-requests {
  display: grid;
}

/* On the lobby the bar is a real surface, not a fade.
   -------------------------------------------------------------------------
   It used to be a gradient that reached FULLY transparent at its bottom edge,
   which meant the lower third of the bar was a window: covers scrolling up
   passed visibly behind the logo and the avatar, under a blur, and the result
   read as smeared rather than layered.

   An even 0.85 ground with a blur behind it is the frosted-glass the fade was
   reaching for: opaque enough that nothing shows through, translucent enough
   that the page's colour still tints it as you scroll. The hairline and the
   soft shadow under it are what actually separate the chrome from the content
   -- without them a solid bar just looks like the page starts lower down. */
body[data-screen="home"] .appbar {
  background: rgba(24, 17, 46, 0.85);
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.30);
}

/* A browser without backdrop-filter gets an opaque bar instead of a
   see-through one: losing the frost is fine, losing the separation is not. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {

  body[data-screen="home"] .appbar {
    background: rgba(24, 17, 46, 0.97);
  }
}

/* On a match screen the header steps back out of the way: no ground, no
   brand, no tab actions -- just the identity chip in the top-right corner it
   has always had. `pointer-events` is handed back to the chip alone so the
   invisible bar can never swallow a tap meant for the card underneath. */
body:not([data-screen="home"]) .appbar {
  justify-content: flex-end;
  pointer-events: none;
  padding: 14px;
}

body:not([data-screen="home"]) .appbar-brand {
  display: none;
}

body:not([data-screen="home"]) .profile {
  pointer-events: auto;
}

.appbar-brand {
  flex: none;
}

/* In the header the chip is an ordinary flex item; only the menu it drops is
   positioned, and it anchors to this. */
.profile {
  position: relative;
  z-index: 1;
}

.profile-chip {
  display: flex;
  align-items: center;
  gap: 9px;
  max-width: 210px;
  padding: 5px 12px 5px 5px;
  border: 0;
  border-radius: 99px;
  cursor: pointer;
  text-align: left;
  color: var(--ink);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.17), rgba(255, 255, 255, 0.06));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.24),
    inset 0 -3px 0 rgba(0, 0, 0, 0.22),
    0 5px 0 rgba(0, 0, 0, 0.30),
    0 12px 24px rgba(0, 0, 0, 0.40);
  transition: transform 0.09s ease, box-shadow 0.09s ease, filter 0.15s ease;
  -webkit-tap-highlight-color: transparent;
}

.profile-chip:hover {
  filter: brightness(1.12);
}

.profile-chip:active {
  transform: translateY(3px);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.24),
    0 2px 0 rgba(0, 0, 0, 0.30),
    0 6px 14px rgba(0, 0, 0, 0.35);
}

.profile-chip:focus-visible {
  outline: none;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.24),
    0 5px 0 rgba(0, 0, 0, 0.30),
    0 0 0 3px rgba(255, 224, 138, 0.9);
}

/* The same disc as the player avatars, sized for a corner. An <img> is dropped
   in by client.js when Spotify supplies one; the span holds initials until
   then, and stays if it never arrives. */
.profile-pic {
  position: relative;
  width: 34px;
  height: 34px;
  flex: none;
  border-radius: 50%;
  overflow: hidden;
  display: grid;
  place-items: center;
  font-weight: 900;
  font-size: 13px;
  letter-spacing: -0.4px;
  color: #40214a;
  background: linear-gradient(180deg, #fff2ee, #d9bfd6);
  border: 2px solid rgba(255, 255, 255, 0.62);
  box-shadow: inset 0 -3px 0 rgba(120, 70, 110, 0.22), 0 3px 8px rgba(0, 0, 0, 0.35);
}

.profile-pic img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.profile-pic-lg {
  width: 58px;
  height: 58px;
  font-size: 21px;
  border-width: 3px;
  margin: 0 auto 9px;
}

.profile-who {
  display: grid;
  gap: 1px;
  min-width: 0;
}

.profile-name {
  font-weight: 700;
  font-size: 13px;
  line-height: 1.15;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.profile-src {
  font-weight: 700;
  font-size: 8.5px;
  letter-spacing: 0.2px;
  line-height: 1.15;
  color: var(--spotify-400);
}

.profile-caret {
  flex: none;
  width: 0;
  height: 0;
  margin-left: 1px;
  border-left: 4.5px solid transparent;
  border-right: 4.5px solid transparent;
  border-top: 5px solid #8d7bab;   /* a caret, not text -- see the note on --ink */
  transition: transform 0.16s ease;
}

.profile-chip[aria-expanded="true"] .profile-caret {
  transform: rotate(180deg);
}

/* --- the menu ---------------------------------------------------------- */

.profile-menu {
  position: absolute;
  top: calc(100% + 12px);
  right: 0;
  width: 224px;
  padding: 16px 14px 14px;
  text-align: center;
  border-radius: var(--r-md);
  border: 2px solid rgba(255, 255, 255, 0.13);
  background:
    radial-gradient(130% 76% at 50% 0%, rgba(29, 185, 84, 0.20), transparent 62%),
    linear-gradient(170deg, var(--purple-600), var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    inset 0 -7px 0 rgba(0, 0, 0, 0.28),
    0 22px 44px rgba(0, 0, 0, 0.58);
  animation: menu-in 0.16s cubic-bezier(0.22, 1.1, 0.4, 1) both;
  transform-origin: top right;
}

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

  to {
    opacity: 1;
    transform: none;
  }
}

.profile-head {
  display: grid;
  margin-bottom: 14px;
}

.profile-full {
  font-weight: 900;
  font-size: 15px;
  letter-spacing: -0.2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The green dot is the only other place this ramp appears: it says the Spotify
   link is live, which is exactly what signing out ends. */
.profile-mail {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  margin-top: 4px;
  font-size: 11px;
  font-weight: 600;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.profile-mail::before {
  content: "";
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--spotify-400);
  box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.22);
}

.profile-mail:empty {
  display: none;
}

.profile-menu .btn {
  font-size: 14px;
  padding: 11px 16px;
}


/* ==========================================================================
   Gate and chip: motion, and small screens
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {

  .gate,
  .gate-panel,
  .profile-menu {
    animation: none;
  }

  /* The needle drop is the confirmation that the click registered, so it is
     not simply deleted here: the platter holds still and the tonearm still
     swings, which is one short transform rather than a perpetual spin. */
  .gate[data-state="leaving"] .gate-vinyl,
  .gate[data-state="leaving"] .gate-label {
    animation: none;
  }
}

@media (max-width: 480px) {

  /* Name and caret go; the avatar alone clears a centred screen title. */
  .profile-who,
  .profile-caret {
    display: none;
  }

  .profile-chip {
    padding: 5px;
    gap: 0;
  }

  .profile-menu {
    width: 210px;
  }
}

@media (max-width: 380px) {
  .gate-panel {
    padding: 24px 18px 20px;
  }

  .gate-deck {
    margin-bottom: 16px;
  }
}


/* ==========================================================================
   COMPETITIVE PLAY
   --------------------------------------------------------------------------
   Four things, added together because they arrived together: the match
   loading screen, the five-minute turn clock, the surrender control, and the
   player stats panel.

   Same depth recipe as everything above -- gradient face, bright inset rim on
   top, dark inset rim below, a hard-edged colour shadow standing in for the
   extruded side. Nothing here invents a new material.
   ========================================================================== */


/* --------------------------------------------------------------------------
   Loading the match

   Two phases behind one bar, because the player does not care which half of
   the wait they are in: the server resolving five previews, then the browser
   downloading them. `data-phase` on the deck colours the ring and swaps the
   copy; the bar itself just counts 0..100 across both.
   -------------------------------------------------------------------------- */

.loading-deck {
  display: grid;
  place-items: center;
  margin: 6px 0 18px;
}

.loading-ring {
  /* --pct is written by client.js, exactly as the round clock's ring is. */
  background: conic-gradient(
    var(--teal-300) calc(var(--pct, 0) * 1%),
    rgba(0, 0, 0, 0.42) 0
  );
}

.loading-deck[data-phase="failed"] .loading-ring {
  background: conic-gradient(var(--bad) 100%, rgba(0, 0, 0, 0.42) 0);
}

.loading-pct {
  font-weight: 900;
  font-size: 26px;
  letter-spacing: -1px;
  color: #fff;
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.32);
  font-variant-numeric: tabular-nums;
}

.loading-track {
  height: 12px;
  margin: 14px 0 12px;
  border-radius: 99px;
  background: rgba(0, 0, 0, 0.45);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.62);
  overflow: hidden;
}

.loading-track i {
  display: block;
  height: 100%;
  width: 0%;
  border-radius: 99px;
  background: linear-gradient(90deg, var(--teal-300), var(--teal-500));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.42);
  transition: width 0.28s cubic-bezier(0.22, 1, 0.4, 1);
}

/* One lamp per round, lit as its clip lands. The count is the honest measure
   of progress here -- a spinner would say "waiting", these say how much. */
.loading-pips {
  display: flex;
  justify-content: center;
  gap: 9px;
  margin: 0 0 14px;
  padding: 0;
  list-style: none;
}

.loading-pips li {
  width: 34px;
  height: 8px;
  border-radius: 99px;
  background: rgba(0, 0, 0, 0.42);
  box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.6);
  transition: background 0.2s ease, box-shadow 0.2s ease;
}

.loading-pips li[data-state="ready"] {
  background: linear-gradient(180deg, var(--teal-300), var(--teal-500));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.45),
    0 0 12px rgba(79, 208, 207, 0.55);
}

.loading-pips li[data-state="loading"] {
  background: linear-gradient(180deg, var(--gold-300), var(--gold-500));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.45);
  animation: loading-pip 1s ease-in-out infinite;
}

@keyframes loading-pip {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.45; }
}


/* --------------------------------------------------------------------------
   The turn clock

   Five minutes to take your turn. Deliberately quiet until it is nearly out:
   for most of a normal match this is background information, and a bar that
   shouts for five minutes teaches players to stop reading it.
   -------------------------------------------------------------------------- */

.forfeit-strip {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 4px 10px;
  margin: 14px 0 12px;
  padding: 10px 13px 11px;
  border-radius: var(--r-sm);
  background: linear-gradient(180deg, #241a44, #191033);
  box-shadow:
    inset 0 3px 8px rgba(0, 0, 0, 0.55),
    inset 0 -2px 0 rgba(255, 255, 255, 0.07);
}

.forfeit-label {
  font-weight: 700;
  font-size: 11.5px;
  letter-spacing: 0.2px;
  color: var(--ink);
  align-self: center;
}

.forfeit-left {
  font-weight: 900;
  font-size: 15px;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
  align-self: center;
}

.forfeit-track {
  grid-column: 1 / -1;
  height: 7px;
  margin-top: 4px;
  border-radius: 99px;
  background: rgba(0, 0, 0, 0.45);
  box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.6);
  overflow: hidden;
}

.forfeit-track i {
  display: block;
  height: 100%;
  width: 100%;
  border-radius: 99px;
  background: linear-gradient(90deg, var(--purple-500), var(--purple-600));
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.3);
}

/* Under FORFEIT_WARN_MS. The colour change is the warning; nothing moves,
   because a match screen that starts flashing is a match screen people close. */
.forfeit-strip.urgent .forfeit-left {
  color: var(--bad);
}

.forfeit-strip.urgent .forfeit-track i {
  background: linear-gradient(90deg, var(--coral-400), var(--coral-600));
}

/* It is the other player's clock: same information, no alarm. */
.forfeit-strip.theirs .forfeit-left {
  color: var(--ink);
}


/* --------------------------------------------------------------------------
   Surrender

   Coloured apart from every other control in the app on purpose. Give Up is
   grey because it costs a round; this is red because it ends the match, and
   the two sit near enough to each other that they must not read alike.
   -------------------------------------------------------------------------- */

.btn-surrender {
  background: linear-gradient(180deg, #a8414f, #7e2b39 62%, #641f2c);
  color: #ffdfe3;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.2),
    inset 0 -4px 0 rgba(0, 0, 0, 0.3),
    0 6px 0 #45131d,
    0 12px 22px rgba(0, 0, 0, 0.42);
}

.btn-surrender:hover:not(:disabled) {
  background: linear-gradient(180deg, #bb4a59, #8d3140 62%, #6d2431);
}

.btn-surrender:active:not(:disabled) {
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.2),
    inset 0 -3px 0 rgba(0, 0, 0, 0.3),
    0 2px 0 #45131d,
    0 6px 12px rgba(0, 0, 0, 0.4);
}

.modal-glyph-flag {
  background: linear-gradient(180deg, #bb5563, #8d3140 62%, #6d2431);
  box-shadow:
    inset 0 3px 0 rgba(255, 255, 255, 0.4),
    inset 0 -4px 0 rgba(0, 0, 0, 0.24),
    0 6px 0 #45131d,
    0 14px 26px rgba(0, 0, 0, 0.45);
}

.modal-surrender .modal-actions .btn:focus-visible {
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.2),
    0 6px 0 #45131d,
    0 0 0 3px rgba(255, 224, 138, 0.85);
}


/* --------------------------------------------------------------------------
   The rating exchange, on the results screen
   -------------------------------------------------------------------------- */

.elo-plate {
  display: grid;
  justify-items: center;
  gap: 3px;
  margin: 16px 0 6px;
  padding: 13px 16px 15px;
  border-radius: var(--r-md);
  border: 2px solid rgba(255, 255, 255, 0.13);
  background: linear-gradient(180deg, var(--purple-500), var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.22),
    inset 0 -6px 0 rgba(0, 0, 0, 0.26),
    0 12px 26px rgba(0, 0, 0, 0.42);
}

.elo-label {
  font-weight: 700;
  font-size: 11.5px;
  letter-spacing: 0.2px;
  color: var(--ink);
}

.elo-row {
  display: flex;
  align-items: baseline;
  gap: 9px;
  font-variant-numeric: tabular-nums;
}

.elo-before {
  font-weight: 700;
  font-size: 19px;
  color: var(--ink);
}

.elo-arrow {
  font-size: 15px;
  color: var(--ink);
}

.elo-after {
  font-weight: 900;
  font-size: 31px;
  letter-spacing: -1px;
  color: #fff;
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.3);
}

.elo-delta {
  font-weight: 900;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

.elo-delta[data-tone="up"] {
  color: var(--ok);
}

.elo-delta[data-tone="down"] {
  color: var(--bad);
}


/* --------------------------------------------------------------------------
   Player stats

   Layout is fixed by the brief: picture and name centred at the top, the
   numbers underneath. The rating sits between them rather than in the grid,
   because it is the one figure that is a standing relative to other players
   rather than a measurement of this one.
   -------------------------------------------------------------------------- */

.modal-stats {
  width: min(380px, 100%);
  padding: 26px 20px 20px;
}

.stats-crown {
  display: grid;
  justify-items: center;
  gap: 4px;
  padding-bottom: 16px;
  margin-bottom: 16px;
  border-bottom: 2px solid rgba(255, 255, 255, 0.09);
}

.stats-pic {
  width: 74px;
  height: 74px;
  font-size: 25px;
  border-width: 3px;
  margin-bottom: 4px;
}

.stats-name {
  margin: 0;
  font-size: 22px;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.stats-elo {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  padding: 5px 14px 6px;
  border-radius: 99px;
  background: linear-gradient(180deg, var(--gold-300), var(--gold-500) 62%, var(--gold-600));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.5),
    inset 0 -3px 0 rgba(0, 0, 0, 0.2),
    0 4px 0 #8a5510,
    0 9px 18px rgba(0, 0, 0, 0.4);
}

.stats-elo b {
  font-weight: 900;
  font-size: 21px;
  letter-spacing: -0.6px;
  color: #40270a;
  font-variant-numeric: tabular-nums;
}

.stats-elo i {
  font-style: normal;
  font-weight: 900;
  font-size: 10.5px;
  letter-spacing: 0.2px;
  color: #40270a;                  /* solid: opacity is not a hierarchy tool here either */
}

.stats-record {
  font-weight: 600;
  font-size: 12.5px;
  color: var(--ink);
}

/* A player with no history writes '' into these rather than a sentence
   explaining that there is nothing yet. `:empty` takes the grid row out
   entirely, so the panel closes up instead of holding a blank line. */
.stats-record:empty,
.tile-sub:empty {
  display: none;
}

.stats-grid {
  display: grid;
  gap: 10px;
}

.stat-tile {
  display: grid;
  justify-items: center;
  gap: 2px;
  padding: 13px 12px 14px;
  border-radius: var(--r-sm);
  border: 2px solid rgba(255, 255, 255, 0.12);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.2),
    inset 0 -5px 0 rgba(0, 0, 0, 0.26),
    0 8px 18px rgba(0, 0, 0, 0.38);
}

.stat-tile-coral {
  background: linear-gradient(180deg, #6a3350, var(--navy-800));
}

.stat-tile-teal {
  background: linear-gradient(180deg, #1f5766, var(--navy-800));
}

.stat-tile-gold {
  background: linear-gradient(180deg, #6a5220, var(--navy-800));
}

.tile-key {
  font-weight: 600;
  font-size: 13px;
  letter-spacing: 0.1px;
  color: var(--ink);
}

.tile-val {
  font-weight: 900;
  font-size: 29px;
  letter-spacing: -1px;
  line-height: 1.15;
  color: #fff;
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.3);
  font-variant-numeric: tabular-nums;
}

.tile-sub {
  font-weight: 600;
  font-size: 11.5px;
  color: var(--ink);
  text-align: center;
}

.stats-note {
  margin: 14px 0 0;
  font-size: 12.5px;
  font-weight: 600;
  line-height: 1.45;
  color: var(--gold-300);
}

@media (prefers-reduced-motion: reduce) {

  .loading-pips li[data-state="loading"] {
    animation: none;
  }

  .loading-track i {
    transition: none;
  }
}

@media (max-width: 380px) {

  .loading-pips li {
    width: 26px;
  }

  .modal-stats {
    padding: 22px 15px 16px;
  }

  .tile-val {
    font-size: 25px;
  }
}

/* Two menu items now, so they need to be told not to touch. */
.profile-menu #btn-stats {
  margin-bottom: 8px;
}


/* --------------------------------------------------------------------------
   Listen on Apple Music

   The one control in the app that leaves it, so it is the one control that
   does not wear a house colour. Apple Music's red-pink sits deliberately
   outside the coral / teal / gold ramp: everything in that ramp acts on the
   match, and this does not.

   Same depth recipe as every other button -- lit face, dark bottom rim, a hard
   extruded side, an ambient shadow -- so it reads as the same material, just a
   different substance.
   -------------------------------------------------------------------------- */
.btn-apple {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  margin-top: 4px;
  text-decoration: none;
  color: #fff5f6;
  background: linear-gradient(180deg, var(--apple-400), var(--apple-500) 58%, var(--apple-600));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.34),
    inset 0 -4px 0 rgba(0, 0, 0, 0.24),
    0 6px 0 var(--apple-700),
    0 12px 24px rgba(143, 21, 38, 0.42);
}

.btn-apple:hover {
  background: linear-gradient(180deg, var(--apple-300), var(--apple-400) 58%, var(--apple-500));
}

/* Travels the same 4px every other chunky control does when pressed. */
.btn-apple:active {
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.34),
    inset 0 -3px 0 rgba(0, 0, 0, 0.24),
    0 2px 0 var(--apple-700),
    0 6px 12px rgba(143, 21, 38, 0.4);
}

.btn-apple:focus-visible {
  outline: none;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.34),
    0 6px 0 var(--apple-700),
    0 0 0 3px rgba(255, 224, 138, 0.85);
}

.btn-note {
  width: 17px;
  height: 17px;
  flex: none;
  /* The glyph is lit from above like the face it sits on, rather than being a
     flat white sticker on a gradient. */
  filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.28));
}

/* The universal "this opens elsewhere" tell. Dimmed, because it is a note
   about the button rather than part of its label. */
.btn-external {
  font-weight: 900;
  font-size: 13px;
  line-height: 1;
  opacity: 0.72;
}

/* The store link is the fact sheet's primary action, so it gets the room --
   Close is the way out, not the point. */
.modal-fact .btn-apple {
  margin-top: 16px;
}

.modal-fact .modal-actions {
  margin-top: 10px;
}

@media (prefers-reduced-motion: reduce) {

  .btn-apple {
    transition: none;
  }
}

@media (max-width: 380px) {

  .btn-apple {
    gap: 7px;
    font-size: 14px;
  }
}


/* ==========================================================================
   THE TURN LINE
   --------------------------------------------------------------------------
   One line above the rounds table saying whose move it is. It replaces the
   old .turn-plate, which was a panel with an inset shadow and a sentence of
   help underneath it; this is a dot, three words, and a colour.

   Three states, all driven by `data-state` so there is one source of truth
   for the copy, the colour and the animation:

     yours   it is on you            gold, and the deck announces it
     theirs  the opponent is up      faint, with a slow breathing dot
     idle    nothing to act on       faint, still
   ========================================================================== */

.turn-line {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin: 2px 0 12px;
  /* Reserves the row so the rounds table does not jump when the text swaps
     between a short state and a long one. */
  min-height: 18px;
  font-weight: 700;
  font-size: 12px;
  letter-spacing: 0.2px;
  color: var(--ink);
  transition: color 0.3s ease;
}

.turn-dot {
  width: 7px;
  height: 7px;
  flex: none;
  border-radius: 50%;
  background: #8d7bab;             /* a dot, not text -- see the note on --ink */
  transition: background 0.3s ease, box-shadow 0.3s ease;
}

/* --- waiting on the opponent ------------------------------------------ */

.turn-line[data-state="theirs"] .turn-dot {
  background: var(--teal-300);
  /* Slow enough to read as "something is happening elsewhere" rather than as
     a notification. This one loops: the wait genuinely is ongoing. */
  animation: turn-breathe 2.4s ease-in-out infinite;
}

@keyframes turn-breathe {
  0%, 100% {
    opacity: 1;
    box-shadow: 0 0 0 0 rgba(79, 208, 207, 0.55);
  }

  50% {
    opacity: 0.45;
    box-shadow: 0 0 0 5px rgba(79, 208, 207, 0);
  }
}

/* --- your move -------------------------------------------------------- */

.turn-line[data-state="yours"] {
  color: var(--gold-300);
}

.turn-line[data-state="yours"] .turn-dot {
  background: var(--gold-300);
  box-shadow: 0 0 10px rgba(255, 224, 138, 0.85);
}

/*
 * The arrival. Added by JS only on the EDGE -- the render where the turn
 * actually became yours -- never on the re-renders that follow, so it reads as
 * an event rather than as decoration that is always running.
 */
.turn-line.is-new {
  animation: turn-arrive 0.5s cubic-bezier(0.22, 1.1, 0.4, 1) both;
}

@keyframes turn-arrive {
  from {
    opacity: 0;
    transform: translateY(-4px);
    letter-spacing: 3.4px;
  }

  to {
    opacity: 1;
    transform: none;
    letter-spacing: 1.6px;
  }
}


/* ==========================================================================
   "YOUR TURN" -- the pulse on the Start button
   --------------------------------------------------------------------------
   The button is the target because it is the thing that has to be pressed.
   Pulsing the avatar would say "you"; pulsing this says "you, here, now".

   WHY box-shadow AND NOT filter/outline
   -------------------------------------
   The button's whole 3D read is its box-shadow stack -- lit top rim, dark
   bottom rim, a hard 7px coral side, then the ambient drop. Animating the
   property means every keyframe has to restate all four or the button goes
   flat mid-pulse. So each frame below is those four layers unchanged, plus a
   fifth spread ring that grows and fades. Nothing about the button's material
   moves; a halo passes over it.

   Three iterations, not infinite. A control that pulses forever stops being a
   signal and becomes a texture -- and it would still be pulsing while the
   player reads the round table underneath it.
   ========================================================================== */

.btn-start-pulse {
  animation: start-pulse 1.15s cubic-bezier(0.4, 0, 0.3, 1) 3;
}

@keyframes start-pulse {
  0% {
    transform: none;
    box-shadow:
      var(--ring-light),
      var(--ring-dark),
      0 7px 0 var(--coral-700),
      var(--ambient),
      0 0 0 0 rgba(255, 224, 138, 0.75);
  }

  /* The lift and the brightest point of the ring land together, so it reads
     as one gesture rather than two effects sharing a timeline. */
  38% {
    transform: translateY(-2px);
    box-shadow:
      var(--ring-light),
      var(--ring-dark),
      0 9px 0 var(--coral-700),
      var(--ambient),
      0 0 0 7px rgba(255, 224, 138, 0.34);
  }

  100% {
    transform: none;
    box-shadow:
      var(--ring-light),
      var(--ring-dark),
      0 7px 0 var(--coral-700),
      var(--ambient),
      0 0 0 16px rgba(255, 224, 138, 0);
  }
}

/*
 * The player card gets the quieter half of the same moment: it says WHO,
 * while the button says WHAT TO DO. One iteration -- it is context, not a
 * call to action, and two things demanding attention is one too many.
 *
 * .player-card.active already carries a gold border and a standing glow; this
 * only swells it once on arrival.
 */
.player-card.is-turn {
  /* No fill mode on purpose. `both` would hold the 100% frame -- and that
     frame states a transform -- so the card would keep translateY(-5px) even
     after .active was taken off it, leaving it stuck lifted when the turn
     moved to the opponent. With no fill, it hands the transform straight back
     to .active when the animation ends. */
  animation: card-claim 0.62s cubic-bezier(0.22, 1.1, 0.4, 1);
}

@keyframes card-claim {
  0% {
    transform: translateY(-5px) scale(1);
  }

  45% {
    transform: translateY(-7px) scale(1.035);
  }

  100% {
    transform: translateY(-5px) scale(1);
  }
}


/* --------------------------------------------------------------------------
   Reduced motion

   The information survives; only the movement goes. The dot keeps its colour,
   the turn line keeps its gold, and the button keeps a STATIC gold ring -- so
   a player who has asked for no animation still gets the "act now" cue, just
   held rather than pulsed.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {

  .turn-line.is-new,
  .player-card.is-turn,
  .turn-line[data-state="theirs"] .turn-dot {
    animation: none;
  }

  .btn-start-pulse {
    animation: none;
    box-shadow:
      var(--ring-light),
      var(--ring-dark),
      0 7px 0 var(--coral-700),
      var(--ambient),
      0 0 0 4px rgba(255, 224, 138, 0.5);
  }
}


/* ==========================================================================
   THE LOBBY — neon, responsive
   --------------------------------------------------------------------------
   The one screen that breaks the app's 430px column. Everything else here is
   a phone-shaped card in the middle of the page; the lobby is a two-column
   application shell on a desktop and a scrolling list with a dock on a phone.

   THE WIDTH SWITCH
   ----------------
   .app is `width: min(430px, 100%)`, which is right for a match and far too
   narrow for a playlist grid. show() writes body[data-screen], and that is
   what widens the shell -- an attribute rather than :has(), so the lever is
   explicit and works everywhere.

   NEON, HONESTLY
   --------------
   "Neon" here is not a border colour. A real tube reads as three things at
   once: a saturated core, a tight bloom on the surface it is mounted to, and
   a wide soft spill. Each accent below is therefore a layered box-shadow --
   inner glow, outer bloom, outer spill -- on top of the app's existing glossy
   recipe, not instead of it. The face is still a gradient with a lit top rim
   and a dark bottom rim; the neon sits around it.
   ========================================================================== */

/* ==========================================================================
   THE LOBBY IS AN APP SHELL, NOT A CARD
   --------------------------------------------------------------------------
   Everything else in this app is a phone-shaped card floating in the middle of
   the page, and that is right for a match: a 30-second listening round wants
   the vinyl, the clock and the guess field near each other and near the
   middle. It is wrong for a browsing surface, and it was wrong twice over.

   WHY THE CENTRING HAD TO GO
   --------------------------
   <body> is `display: grid; place-items: center`. A centred grid item that is
   TALLER than its area does not push the container down -- it overflows both
   edges equally, and the overflow past the bottom is unreachable. That is one
   bug wearing two faces:

     - the tabs floated in the middle of the window instead of starting at the
       top and flowing down, and
     - `padding-bottom` on <body> could not hold the last row clear of the
       fixed dock, because the row had already overflowed past the padding.
       Measured: 17px of the last playlist sat under the bottom nav with the
       page scrolled as far as it would go.

   WHAT REPLACES IT
   ----------------
   A strict column that fills the viewport and scrolls in exactly one place:

     body[data-screen="home"]   height 100dvh, stretch, never scrolls itself
       .stage / .app            stretch, no width cap
         .screen-lobby          flex column, no card, no background of its own
           .lobby               flex: 1, min-height: 0
             .lobby-main        flex/grid child, overflow-y: auto  <- THE scroller

   .lobby-main then carries bottom padding of exactly --dock-h, so the end of
   the list clears the nav instead of hiding behind it.

   ALL OF IT IS SCOPED TO [data-screen="home"]. The match screens are untouched
   and stay centred cards -- see .screen, which nothing below overrides.
   ========================================================================== */

body[data-screen="home"] {
  /* No centring, no page padding, no page scroll: the shell IS the viewport. */
  padding: 0;
  place-items: stretch;
  align-content: stretch;
  grid-template-columns: minmax(0, 1fr);
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
}

body[data-screen="home"] .stage {
  place-items: stretch;
  height: 100%;
  min-height: 0;
}

body[data-screen="home"] .app {
  width: 100%;
  max-width: none;
  height: 100%;
  min-height: 0;
}

/* The card treatment comes off: no face, no border, no radius, no drop
   shadow, no pop-in. The tabs sit directly on the app's own background, which
   <body> paints and holds still (`background-attachment: fixed`) while the
   column below scrolls over it. */
body[data-screen="home"] .screen-lobby {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
  padding: 0;
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  animation: none;
  overflow: visible;
}

body[data-screen="home"] .lobby {
  flex: 1;
  min-height: 0;
}

/* The connection line is a sibling of every screen, not a child of one, so a
   shell that is exactly the viewport tall pushes it just past the bottom edge
   -- 32px of document that scrolls and shows nothing. On the lobby it becomes
   what it always read as anyway: a status strip floating just above the nav.
   `pointer-events: none` so an empty one can never eat a tap meant for the
   dock. The match screens keep it in flow at the foot of their card. */
body[data-screen="home"] .conn {
  position: fixed;
  left: 0;
  right: 0;
  bottom: calc(var(--dock-h) + env(safe-area-inset-bottom, 0px) + 6px);
  z-index: 41;
  margin: 0;
  pointer-events: none;
}

/* With the dock hidden on a detail view, --dock-h is still holding its last
   real measurement (client.js deliberately does not re-measure a hidden nav),
   which would leave the status line hovering a nav's height above nothing.
   Sit it on the bottom edge instead. */
body[data-screen="home"][data-detail="open"] .conn {
  bottom: calc(env(safe-area-inset-bottom, 0px) + 6px);
}

/* THE ONE SCROLLER.
   `min-height: 0` is what lets a grid child actually shrink to its track and
   scroll instead of growing the column; without it this is just a tall box
   again and the page overflows exactly as before. */
body[data-screen="home"] .lobby-main {
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior-y: contain;
  /* Chrome at both ends is reserved, not overlapped: --appbar-h at the top and
     --dock-h at the bottom, both measured from the real elements by
     client.js. This is what stops the header sitting ON the first row and the
     nav sitting ON the last one. */
  padding-top: calc(var(--appbar-h) + env(safe-area-inset-top, 0px) + var(--hub-top-gap));
  padding-bottom: calc(var(--dock-h) + env(safe-area-inset-bottom, 0px) + 20px);
}

/* .hub took .lobby-inner's place when the lobby became four tabs. It is still
   a plain block, NOT a flex column with a gap: each tab (.hub-view) is its own
   grid and owns its own rhythm, and a gap out here would double every seam. */
.lobby-main {
  padding: 0 var(--hub-pad-x) 30px;
}

.hub {
  width: 100%;
}

.lobby {
  display: grid;
  grid-template-columns: 216px minmax(0, 1fr);
}


/* --------------------------------------------------------------------------
   Sidebar
   -------------------------------------------------------------------------- */

.lobby-nav {
  display: flex;
  flex-direction: column;
  gap: 22px;
  padding: 24px 16px 20px;
  border-right: 1px solid var(--lobby-line);
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.08));
}

.brand {
  font-weight: 900;
  font-size: 19px;
  letter-spacing: 1.4px;
  padding-left: 6px;
  color: var(--gold-300);
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.3);
}

.brand b {
  font-weight: 900;
  color: var(--coral-300);
}

.nav-list {
  display: grid;
  gap: 4px;
}

.nav-item {
  display: flex;
  align-items: center;
  gap: 11px;
  width: 100%;
  padding: 11px 12px;
  border: 0;
  border-radius: 13px;
  cursor: pointer;
  font: inherit;
  font-weight: 700;
  font-size: 14px;
  text-align: left;
  color: var(--ink);
  background: transparent;
  transition: color 0.16s ease, background 0.16s ease;
}

.nav-item:hover:not(:disabled) {
  color: #fff;
  background: rgba(255, 255, 255, 0.06);
}

.nav-item:disabled {
  opacity: 0.42;
  cursor: not-allowed;
}

.nav-icon {
  width: 19px;
  height: 19px;
  flex: none;
}

/* The active row is the only lit thing in the column, which is the whole job
   of an active row. Glossy face, neon rim, neon spill -- the same three-part
   recipe every accent in this section uses. */
/* Same glossy recipe as every other raised control: lit face, bright top rim,
   dark bottom rim, a hard coral side. The active row is a pressed key, not a
   glowing one. */
.nav-item.is-active {
  color: #fff;
  background: linear-gradient(180deg, var(--coral-300), var(--coral-500) 62%, var(--coral-600));
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.28);
  box-shadow:
    var(--ring-light),
    inset 0 -4px 0 rgba(0, 0, 0, 0.22),
    0 4px 0 var(--coral-700),
    0 8px 16px rgba(0, 0, 0, 0.36);
}

.nav-soon {
  margin-left: auto;
  font-style: normal;
  font-weight: 900;
  font-size: 8.5px;
  letter-spacing: 0.2px;
  padding: 3px 6px;
  border-radius: 99px;
  color: var(--ink);
  background: rgba(255, 255, 255, 0.08);
}


/* --------------------------------------------------------------------------
   Mobile dock

   Fixed to the viewport, which is why it is a sibling of .stage in the markup
   and not a child of the lobby: .screen animates `transform`, and a
   transformed ancestor would make `fixed` resolve against IT for the length
   of the animation.
   -------------------------------------------------------------------------- */

.dock {
  display: none;
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 40;                 /* under the veils (50) and the gate (90) */
  padding: 8px 6px calc(8px + env(safe-area-inset-bottom, 0px));
  border-top: 2px solid rgba(255, 255, 255, 0.10);
  background: linear-gradient(180deg, rgba(52, 37, 92, 0.95), rgba(30, 22, 56, 0.98));
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.55), inset 0 2px 0 rgba(255, 255, 255, 0.10);
}

.dock-item {
  display: grid;
  justify-items: center;
  gap: 3px;
  padding: 7px 4px 5px;
  border: 0;
  border-radius: 12px;
  cursor: pointer;
  font: inherit;
  font-weight: 700;
  font-size: 11px;
  letter-spacing: 0.1px;
  color: var(--ink);
  background: transparent;
}

.dock-item:disabled {
  opacity: 0.4;
}

.dock-icon {
  width: 21px;
  height: 21px;
}

.dock-item.is-active {
  color: var(--gold-300);
}


/* ==========================================================================
   RESPONSIVE
   --------------------------------------------------------------------------
   One breakpoint does the whole architecture swap: sidebar out, dock in, grid
   to a single column. 900px is where 216px of navigation stops being worth
   the width it costs.
   ========================================================================== */

@media (max-width: 900px) {

  .lobby {
    grid-template-columns: minmax(0, 1fr);
    min-height: 0;
  }

  .lobby-nav {
    display: none;
  }

  /* THE BOTTOM NAV IS UP UNLESS A MATCH IS ON
     ------------------------------------------------------------------------
     `body[data-screen]` is written by show(), and show() only runs once there
     is a match state to render -- so on a page that loads and sits on the
     lobby, the attribute was never written at all, this rule never matched,
     and the dock stayed `display: none` from load until the first match.

     <body> now ships with data-screen="home" in the markup, which is the real
     fix (same argument as the `scroll-locked` class it already ships with).
     The `:not([data-screen])` half is the belt to those braces: whatever
     happens to that attribute, the navigation is never what disappears. */
  body:not([data-screen]) .dock,
  body[data-screen="home"] .dock {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
  }

  /* ...EXCEPT ON A DETAIL VIEW
     ------------------------------------------------------------------------
     The dock is the navigation BETWEEN tabs, so it belongs to the root of a
     tab and nowhere below it. Once a panel is up -- a playlist, a player, the
     creator, the inbox -- the way out is the Back chevron in that panel's own
     sticky bar, and a tab bar underneath it is a second, competing navigation
     that throws away where you were.

     `display: none`, not `visibility` or an opacity fade: the dock must not be
     merely invisible while still occupying the strip and taking taps.

     body[data-detail] is written by syncDetailChrome() in client.js, from the
     same detailStack that decides which panel is showing -- so the nav and the
     panel cannot disagree. This rule comes after the two above and matches
     more specifically, which is what lets it win without !important. */
  body[data-detail="open"] .dock {
    display: none;
  }

  /* The dock's clearance is NOT here any more. It used to be padding on
     <body>, which could not work: <body> centres its child, and a child taller
     than the viewport overflows the padding rather than respecting it. The
     clearance now lives on .lobby-main -- the element that actually scrolls --
     and is calc()'d from --dock-h so it always equals the real nav height.

     The side inset is not here either: --hub-pad-x is one number at every
     width now, so the header, the tabs and the nav all measure from the same
     edge. */

}


/* ==========================================================================
   THE LOBBY SHELL — rail, dock, full-bleed desktop
   --------------------------------------------------------------------------
   The layout the lobby sits in, kept when its CONTENTS were reverted to the
   original jukebox-and-keys panel. Two things live here: the navigation, which
   is a rail on a desktop and a dock on a phone, and the rule that makes the
   lobby fill the browser window instead of floating in the middle of it.

   Colours are this app's own -- coral primary, gold for the chosen thing, over
   the same purple face every other screen uses. An earlier pass had these in
   neon pink and yellow; that went with the rest of the redesign.
   ========================================================================== */




/* --------------------------------------------------------------------------
   Identity, at the foot of the rail
   -------------------------------------------------------------------------- */

.who {
  display: flex;
  align-items: center;
  gap: 11px;
  width: 100%;
  margin-top: auto;
  padding: 10px;
  border: 0;
  cursor: pointer;
  font: inherit;
  text-align: left;
  border-radius: 15px;
  background: rgba(255, 255, 255, 0.05);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10);
  transition: background 0.16s ease;
}

.who:hover {
  background: rgba(255, 255, 255, 0.10);
}

.who-pic {
  width: 40px;
  height: 40px;
  font-size: 15px;
}

.who-text {
  display: grid;
  gap: 1px;
  min-width: 0;
}

.who-name {
  font-weight: 800;
  font-size: 13.5px;
  color: #fff;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.who-rank {
  font-weight: 700;
  font-size: 10.5px;
  letter-spacing: 0.2px;
  color: var(--gold-300);
}


/* --------------------------------------------------------------------------
   Phone
   -------------------------------------------------------------------------- */

@media (max-width: 900px) {

  /* The dock's active item takes a lit panel rather than only a colour, the
     way the reference's does -- on a phone, in a hurry, colour alone is a
     weak signal. */
  .dock-item.is-active {
    background: linear-gradient(180deg, rgba(255, 201, 77, 0.20), rgba(255, 201, 77, 0.07));
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
  }
}



/* ==========================================================================
   THE PLAYLIST KEYS
   ------------------------------------------------------------------------
   What is left of the old lobby panel. The jukebox hero, the Find Match form
   and the wrappers around them went with the hub rewrite -- a playlist is
   chosen by tapping a cover now, and a match starts from the playlist detail.

   The keys themselves stayed, and are still styled, because #mode-grid is
   still a real element that renderModes() still paints and selectMode() still
   keeps in step. It is display:none inside .hub-machine rather than deleted --
   see the note there -- so this is the styling for the app's one source of
   truth about which playlist is selected, not decoration for a dead screen.
   ========================================================================== */

/* A bank of illuminated keys. Idle keys sit proud of the panel on a hard
   purple side; the chosen key drops into the panel and lights up gold, so the
   selection is legible from posture alone and not only from colour. */

.mode-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
}

.mode-loading {
  grid-column: 1 / -1;
  margin: 0;
  padding: 14px 0;
  font-size: 13px;
  font-weight: 700;
  color: var(--ink);
}

.mode-btn {
  position: relative;
  display: grid;
  gap: 2px;
  justify-items: center;
  text-align: center;
  padding: 11px 8px 12px;
  border: 0;
  cursor: pointer;
  border-radius: var(--r-sm);
  color: var(--ink);
  background: linear-gradient(180deg, var(--purple-500), var(--purple-700) 62%, var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    inset 0 -5px 0 rgba(0, 0, 0, 0.26),
    0 6px 0 #241a44,
    0 12px 22px rgba(0, 0, 0, 0.40);
  transition: transform 0.10s ease, box-shadow 0.10s ease, filter 0.16s ease;
  -webkit-tap-highlight-color: transparent;
}

/* The full-width key: "Everything" reads as the panel's master switch. */
.mode-btn[data-wide="true"] {
  grid-column: 1 / -1;
}

.mode-label {
  font-weight: 700;
  font-size: 13px;
  line-height: 1.15;
  letter-spacing: 0.2px;
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.30);
}

.mode-count {
  font-weight: 700;
  font-size: 10px;
  letter-spacing: 0.2px;
  color: var(--ink);
}

.mode-btn:hover:not(:disabled) {
  filter: brightness(1.10);
}

/* Pressed: the key travels, exactly like every other control here. */
.mode-btn:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    0 2px 0 #241a44,
    0 6px 14px rgba(0, 0, 0, 0.40);
}

/* Chosen: seated in the panel, lit gold, with a bright rim of its own. */
.mode-btn[aria-checked="true"] {
  transform: translateY(4px);
  color: #4a2c00;
  background: linear-gradient(180deg, #fff3cd, var(--gold-300) 40%, var(--gold-400) 78%, var(--gold-500));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.75),
    inset 0 -4px 0 rgba(150, 90, 0, 0.32),
    0 2px 0 var(--gold-600),
    0 8px 20px rgba(255, 180, 60, 0.32),
    0 0 0 2px rgba(255, 224, 138, 0.55);
}

.mode-btn[aria-checked="true"] .mode-label {
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
}

.mode-btn[aria-checked="true"] .mode-count {
  color: #8a5a00;
}

.mode-btn:focus-visible {
  outline: none;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    0 6px 0 #241a44,
    0 0 0 3px rgba(255, 125, 138, 0.65);
}

/* A mode the catalogue cannot fill: shown, but plainly not an option. */
.mode-btn:disabled {
  cursor: not-allowed;
  opacity: 0.42;
  filter: saturate(0.45);
  transform: none;
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.10), inset 0 -4px 0 rgba(0, 0, 0, 0.28);
}


@media (max-width: 380px) {
  .mode-grid {
    grid-template-columns: 1fr;
  }

  .mode-btn[data-wide="true"] {
    grid-column: auto;
  }
}



/* ==========================================================================
   AN OPPONENT'S RECORD
   --------------------------------------------------------------------------
   Their avatar and name, on the board and on the results screen, are a button
   into the stats panel. A button and not a click handler on a div: it has to
   be reachable by keyboard and announced as an action, and only a real control
   gets that for free.
   ========================================================================== */

.player-open {
  display: grid;
  justify-items: center;
  gap: 4px;
  width: 100%;
  padding: 2px 4px 4px;
  border: 0;
  border-radius: var(--r-sm);
  font: inherit;
  color: inherit;
  background: transparent;
  cursor: default;
  transition: background 0.16s ease, transform 0.16s ease;
}

/* Only interactive once there is a record behind it -- a seat with no account,
   or no opponent yet, leaves the card looking exactly as it always did. */
.player-open.is-linked {
  cursor: pointer;
}

.player-open.is-linked:hover {
  background: rgba(255, 255, 255, 0.09);
}

.player-open.is-linked:active {
  transform: translateY(1px);
}

.player-open:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(255, 224, 138, 0.85);
}

/* The affordance. Absent until hover or focus, because on a card that is
   mostly a scoreboard a permanent "View stats" label competes with the score. */
.player-peek {
  font-weight: 700;
  font-size: 9.5px;
  letter-spacing: 0.2px;
  color: var(--gold-300);
  opacity: 0;
  transition: opacity 0.16s ease;
}

.player-open.is-linked:hover .player-peek,
.player-open.is-linked:focus-visible .player-peek {
  opacity: 1;
}

/* The card lifts when it is that player's turn; the button inside it must not
   also translate, or the two stack. */
.ticket-open {
  padding: 0;
  gap: 6px;
}


/* --------------------------------------------------------------------------
   The song a personal best was set on
   -------------------------------------------------------------------------- */

.tile-song {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-style: italic;
  color: var(--gold-300);
}

@media (prefers-reduced-motion: reduce) {

  .player-open,
  .player-peek {
    transition: none;
  }

  .player-open.is-linked:active {
    transform: none;
  }

  /* The label cannot fade in, so it is simply always there. */
  .player-peek {
    opacity: 1;
  }
}


/* ==========================================================================
   THE HUB — four tabs, square covers, and a detail layer
   --------------------------------------------------------------------------
   Everything below is LAYOUT. Not one new colour, font, radius or shadow
   recipe is introduced here: every surface is the same glossy face the rest
   of the app already wears -- a gradient front, a bright inset rim on top, a
   dark inset rim at the bottom, a hard extruded side, a soft ambient drop --
   built from the tokens in :root and reusing .btn, .profile-pic, .inset-field,
   .stats-elo, .stats-grid and .pill unchanged. What is new is the grid, the
   flexbox, the 1:1 aspect ratios and the fixed positioning that turn the old
   single lobby into four tabs with overlays.

   THE ONE-SCREEN SPA
   ------------------
   The app already had a screen switcher: show() un-hides one .screen and
   writes body[data-screen]. The hub does not replace it -- a live match still
   owns the whole viewport -- it subdivides the LOBBY screen into four tabs,
   with the same trick one level down: showTab() un-hides one .hub-view and
   writes #hub[data-tab]. Two switchers, one pattern, no framework.
   ========================================================================== */

.hub {
  width: 100%;
  text-align: left;
}

/* THE SHELL HAS TO BE THE VIEWPORT, NOT ITS CONTENTS
   ---------------------------------------------------------------------------
   <body> is a centred grid, and the lobby sits in an implicit `auto` track --
   which is sized by what is inside it. That was fine when the lobby was one
   column of buttons. It is not fine now: a single horizontally-scrolling row
   of covers reports a max-content width of "all of them", the track grows to
   that, and the whole panel hangs off the right of the phone instead of the
   row scrolling inside it.

   `minmax(0, 1fr)` makes the track definite -- the shell is exactly the width
   available, and anything wider is that element's problem to scroll. Scoped to
   the lobby: every other screen is a fixed-width card with nothing to overflow. */
body[data-screen="home"] {
  grid-template-columns: minmax(0, 1fr);
}

/* Exactly one tab is un-hidden at a time; .hidden (display:none !important)
   does the hiding, so a tab that is off is off for the layout, the tab order
   and the accessibility tree alike. */
.hub-view {
  display: grid;
  gap: 20px;
  align-content: start;
}

/* NO ENTRY ANIMATION HERE, AND THE REASON IS NOT TASTE
   -------------------------------------------------------------------------
   This used to run `tab-in` -- a 0.22s fade-and-rise with `fill: both`. An
   element with a filled opacity/transform animation forms a BACKDROP ROOT,
   and `backdrop-filter` on anything inside it then has nothing to sample and
   silently does nothing. Two things were quietly flat because of it:

     - the sticky search bar, whose frosted ground was doing no frosting at
       all: headings scrolling under it stayed sharp and readable through the
       field, which is exactly the "messy" look this pass set out to fix; and
     - every .cover-badge, whose blur has never once rendered.

   A tab switch that is instant is what a native tab bar does anyway. The
   frost is worth more than the fade. */

/* A grid item's `min-width` is `auto`, which means "no narrower than my
   contents" -- and the Recently Played rail's contents are a row of covers
   wider than any phone. Without this the rail refuses to shrink, the tab grows
   to fit it, and the whole panel overflows the viewport sideways instead of
   the rail scrolling inside it. The same guard covers every other wide thing a
   tab can hold: a long title, a table, a row of tiles. */
.hub-view,
.hub-view > *,
.shelf,
.shelf-rail,
.hub-results,
.result-group {
  min-width: 0;
}

.hub-view:focus {
  outline: none;
}

/* The original lobby's working controls, kept in the DOM and off the screen.
   display:none rather than visibility or a clip rect: these are focusable
   buttons, and a control that can be tabbed to but not seen is worse than one
   that is not there. See the note on .hub-machine in index.html. */
.hub-machine {
  display: none;
}


/* --------------------------------------------------------------------------
   Shared furniture: headers, headings, rules, empty states
   -------------------------------------------------------------------------- */

/* HOME IS A CENTRED COLUMN.
   The hero above it -- the face, the name, the rating -- is centred, so the
   section under it centres too rather than snapping back to a left margin
   nothing else on the tab shares. Only Home: Search and the Library are lists,
   and a list's heading belongs over the left edge of the things it introduces. */
#view-home .hub-heading,
#view-home .hub-empty {
  text-align: center;
}

/* Title left, one icon button hard right. Used by Library ("+") and Friends
   (the envelope) -- the same header at two jobs. */
/* Fredoka, not Inter 900. Both are "the heading", but Inter's display weight
   is a grotesque squeezed tight -- correct for a scoreboard, characterless as
   the front of a game. Fredoka's rounded terminals are the same language the
   3D buttons and the vinyl already speak.

   The tracking flips sign with the face: -0.8px was pulling Inter 900's wide
   sidebearings in, and applied to Fredoka it just crowds the letters. */
.hub-title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(27px, 7.2vw, 33px);
  letter-spacing: -0.2px;
  color: #fff;
  text-shadow:
    0 2px 0 rgba(0, 0, 0, 0.28),
    0 5px 0 rgba(0, 0, 0, 0.16),
    0 12px 26px rgba(255, 120, 150, 0.30);
}

/* A section header belongs TO the block under it, so it sits close to it --
   10px, not the 12-18px that made each heading look like it was floating
   between two sections. Small and tracked: this is a quiet signpost, not a
   control. In the display face at 600 so it reads as part of the same voice as
   the tab title above it rather than as a system label. */
/* A tab's heading and its one action, on one row.

   This is where a contextual action belongs. It was briefly in the global
   header beside the avatar, which fixed an overlap but put "new playlist"
   somewhere it showed on tabs that cannot make one. Flexbox with
   space-between does the same job here and says what the button acts on. */
.hub-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

/* With capitals and grey both gone, SIZE and WEIGHT are the only things left
   to rank text with -- so this had to grow. At 12px in sentence case it was
   smaller than the card titles it was introducing, which is backwards. 15px in
   the display face sits clearly under the 33px tab title and clearly over the
   15px/700 card titles below it. */
.hub-heading {
  margin: 0 0 10px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 15px;
  letter-spacing: 0.1px;
  color: var(--ink);
}

/* The divider under the Home hero. A hairline of light over a hairline of
   shadow -- the same two-tone edge every raised surface here already has,
   flattened to 2px. */
.hub-rule {
  height: 0;
  margin: 2px 0;
  border: 0;
  border-top: 1px solid rgba(0, 0, 0, 0.34);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

/* THE SMALL PRINT, STYLED ON PURPOSE
   -------------------------------------------------------------------------
   An empty state is the first thing a new player reads, and left at the
   document's own face it reads as a default the designer never got to. Nunito
   is rounder and softer than Inter at small sizes -- that is what makes it
   quiet, not a lower contrast. It is the same white as everything else. */
.hub-empty {
  margin: 0;
  padding: 6px 2px 18px;
  font-family: var(--font-soft);
  font-weight: 600;
  font-size: 15px;
  line-height: 1.5;
  letter-spacing: 0.1px;
  color: var(--ink);
}

/* A round, pressable disc for a lone glyph: the "+" in Library, the envelope
   in Friends, the back chevron in every detail panel. Same extrusion as .btn,
   at 42px and circular. */
.icon-btn {
  position: relative;
  flex: none;
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  color: var(--ink);
  background: linear-gradient(180deg, var(--purple-500), var(--purple-700) 62%, var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    inset 0 -4px 0 rgba(0, 0, 0, 0.26),
    0 5px 0 #241a44,
    0 10px 20px rgba(0, 0, 0, 0.38);
  transition: transform 0.09s ease, box-shadow 0.09s ease, filter 0.15s ease;
  -webkit-tap-highlight-color: transparent;
}

.icon-btn:hover {
  filter: brightness(1.10);
}

.icon-btn:active {
  transform: translateY(4px);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    0 1px 0 #241a44,
    0 5px 12px rgba(0, 0, 0, 0.40);
}

.icon-btn:focus-visible {
  outline: none;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    0 5px 0 #241a44,
    0 0 0 3px rgba(255, 125, 138, 0.65);
}

.icon-btn-glyph {
  width: 21px;
  height: 21px;
}

/* How many requests are waiting, parked on the envelope's shoulder. */
.icon-btn-count {
  position: absolute;
  /* -2, not -3: the badge sticks out of the button and eats into the 16px the
     header puts between this and the avatar. Two pixels back keeps the gap
     reading as a gap. */
  top: -2px;
  right: -2px;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  border-radius: 99px;
  display: grid;
  place-items: center;
  font-weight: 900;
  font-size: 10.5px;
  font-variant-numeric: tabular-nums;
  color: #fff;
  background: linear-gradient(180deg, var(--coral-300), var(--coral-500) 62%, var(--coral-600));
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    0 2px 0 var(--coral-700),
    0 4px 10px rgba(0, 0, 0, 0.45);
}


/* ==========================================================================
   TAB 1 — HOME
   ========================================================================== */

.me {
  display: grid;
  justify-items: center;
  gap: 10px;
  padding: 10px 0 4px;
  text-align: center;
}

/* The big face. Same disc as the chip in the corner -- .profile-pic carries
   the gradient, the rim and the img cover -- scaled up and given the room to
   be the first thing on the tab. */
.hero-pic {
  width: clamp(116px, 34vw, 156px);
  height: clamp(116px, 34vw, 156px);
  font-size: clamp(34px, 10vw, 46px);
  border-width: 4px;
  box-shadow:
    inset 0 -6px 0 rgba(120, 70, 110, 0.22),
    0 14px 34px rgba(0, 0, 0, 0.5);
}

.me-name {
  margin: 0;
  max-width: 100%;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(25px, 7.6vw, 35px);
  letter-spacing: -0.2px;
  line-height: 1.1;
  color: #fff;
  text-shadow:
    0 2px 0 rgba(0, 0, 0, 0.28),
    0 5px 0 rgba(0, 0, 0, 0.16),
    0 12px 26px rgba(255, 120, 150, 0.30);
  overflow-wrap: anywhere;
}

/* The handle, under the name. A button that does not look like a button until
   you touch it: on the Home hero it is a line of text among other lines, and a
   raised control there would shout louder than the rating beside it. The
   pressed and focused states are what say it is live. */
.me-handle {
  padding: 4px 12px 5px;
  border: 0;
  border-radius: 999px;
  cursor: pointer;
  font-family: var(--font-soft);
  font-weight: 600;
  font-size: 15px;
  letter-spacing: 0.1px;
  color: var(--ink);
  background: rgba(255, 255, 255, 0.08);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
  transition: background 0.14s ease, transform 0.09s ease;
  -webkit-tap-highlight-color: transparent;
}

.me-handle:hover {
  background: rgba(255, 255, 255, 0.14);
}

.me-handle:active {
  transform: translateY(2px);
}

.me-handle:focus-visible {
  outline: none;
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.18),
    0 0 0 3px rgba(255, 125, 138, 0.6);
}

/* The editor. The form is the modal's body, so its field and its buttons space
   themselves rather than inheriting .modal's centred text rhythm. */
.modal-handle .inset-field {
  margin-top: 4px;
}

.modal-handle #handle-input {
  text-align: center;
}

.modal-handle .feedback {
  margin: 10px 0 0;
  min-height: 0;
}

.modal-handle .feedback:empty {
  display: none;
}

.modal-handle .modal-actions {
  margin-top: 16px;
}

/* The gold Elo lozenge from the stats panel, one size up. Reused rather than
   restyled: the rating means the same thing here as it does there. */
.hero-elo {
  padding: 7px 18px 8px;
}

.hero-elo b {
  font-size: 24px;
}

/* --- Recently played ----------------------------------------------------
   Two per row, not a sideways scroller.

   It used to be a horizontally scrolling rail with the last cover deliberately
   clipped by the edge of the screen, to say "there is more this way". That is
   the right idiom for a long shelf on a busy home screen; it is the wrong one
   here, where Home is a short centred column -- a face, a name, a rating -- and
   a row that runs off the side is the only thing on the tab that does not line
   up with the rest of it.

   Same grid as the Library, so a playlist looks the same wherever it is seen.
------------------------------------------------------------------------- */

.shelf-rail {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px 14px;
}

/* An odd number of covers would leave the last one hanging against the left
   margin, which is the one arrangement in a two-column grid that stops looking
   centred -- and Home is the tab where everything else is. It takes the whole
   row and centres itself at one column's width instead. `:last-child` AND
   `:nth-child(odd)` together only match when the count is odd, so a full row is
   never touched. calc() is one column: half the row, less half the 14px gutter. */
.shelf-rail > :last-child:nth-child(odd) {
  grid-column: 1 / -1;
  width: calc(50% - 7px);
  justify-self: center;
}


/* ==========================================================================
   PLAYLIST COVERS
   --------------------------------------------------------------------------
   A playlist has no artwork -- the catalogue stores a name, a blurb and an
   emoji -- so the cover IS the emoji, on a lit square of the app's own
   material. `aspect-ratio: 1` is what keeps it square at every column width;
   nothing here sets a height.

   The tint is one of four faces chosen from the id (see coverTint() in
   client.js) so a wall of covers is distinguishable at a glance. All four are
   built from ramps already in :root -- purple, teal, coral, gold -- and none
   of them is a new colour.
   ========================================================================== */

.cover {
  position: relative;
  aspect-ratio: 1 / 1;
  width: 100%;
  display: grid;
  place-items: center;
  overflow: hidden;
  border-radius: var(--r-sm);
  background: linear-gradient(160deg, var(--purple-500), var(--purple-700) 58%, var(--navy-800));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    inset 0 -6px 0 rgba(0, 0, 0, 0.28),
    0 8px 20px rgba(0, 0, 0, 0.42);
}

.cover[data-tint="2"] {
  background: linear-gradient(160deg, var(--teal-400), var(--teal-600) 58%, var(--navy-800));
}

.cover[data-tint="3"] {
  background: linear-gradient(160deg, var(--coral-400), var(--coral-600) 58%, var(--navy-800));
}

.cover[data-tint="4"] {
  background: linear-gradient(160deg, var(--gold-400), var(--gold-600) 58%, var(--navy-800));
}

/* A soft sheen across the top corner, so the square reads as a lit surface
   rather than a flat swatch. */
.cover::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(120% 80% at 22% 0%, rgba(255, 255, 255, 0.22), transparent 62%);
  pointer-events: none;
}

/* THE SONG-COUNT BADGE
   Absolutely positioned INSIDE the square, bottom-left, the way a duration
   sits on a video thumbnail. It is the .pill recipe at a smaller size, on a
   darkened plate so it stays readable over all four tints. */
.cover-badge {
  position: absolute;
  /* Bottom-right and flush: `inset` on both sides rather than a left offset,
     so the pill can never run past the artwork on a narrow cell. It used to be
     a heavy near-black lozenge with a lit rim, borrowed from the app's raised
     buttons -- which is what made it read as stuck ON the cover rather than
     part of it. It is a flat frosted chip now: no rim, no drop shadow, a hair
     of blur, and the tabular numerals it always deserved. */
  right: 8px;
  bottom: 8px;
  max-width: calc(100% - 16px);
  padding: 3px 8px 4px;
  border-radius: 999px;
  font-weight: 700;
  font-size: 10px;
  line-height: 1.25;
  letter-spacing: 0.3px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  background: rgba(10, 7, 22, 0.62);
  backdrop-filter: blur(8px) saturate(1.2);
  -webkit-backdrop-filter: blur(8px) saturate(1.2);
}

/* A playlist too short to host a match. Shown, and plainly not an option. */
.cover-badge[data-tone="short"] {
  color: var(--gold-300);
  background: rgba(10, 7, 22, 0.72);
}

/* --- the pressable card a cover sits in --------------------------------- */

.cover-card {
  display: grid;
  gap: 8px;
  padding: 0;
  border: 0;
  background: none;
  text-align: left;
  cursor: pointer;
  color: inherit;
  -webkit-tap-highlight-color: transparent;
  transition: transform 0.10s ease, filter 0.16s ease;
}

.cover-card:hover {
  filter: brightness(1.08);
}

.cover-card:active {
  transform: translateY(3px);
}

.cover-card:focus-visible {
  outline: none;
}

.cover-card:focus-visible .cover {
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    0 0 0 3px rgba(255, 125, 138, 0.75);
}

.cover-card:disabled {
  cursor: not-allowed;
  opacity: 0.46;
  filter: saturate(0.45);
  transform: none;
}

.cover-text {
  display: grid;
  gap: 2px;
  min-width: 0;
}

.cover-name {
  font-weight: 700;
  font-size: 15px;
  line-height: 1.2;
  color: var(--ink);
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.30);
  /* Two lines, then an ellipsis. A playlist called "Songs My Dad Played In
     The Car In 1994" must not push the row below it out of alignment. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* One white, so the step is 15/700 title against 12.5/500 creator. Weight is
   doing the work that a grey used to do. */
.cover-by {
  font-family: var(--font-soft);
  font-weight: 500;
  font-size: 12.5px;
  letter-spacing: 0.1px;
  color: var(--ink);

  /* The credit is a name plus, sometimes, a mark. inline-flex rather than
     plain text so the two sit on one baseline and the mark cannot be wrapped
     onto a line of its own by a long username. */
  display: inline-flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
}

/* The NAME is what truncates, never the mark. Putting the ellipsis on the
   flex child rather than on the row is the whole trick: `overflow: hidden` on
   the row would clip the tick off the end of a long name, which is exactly the
   thing the tick is there to say. */
.credit-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

/* THE OFFICIAL MARK
   -------------------------------------------------------------------------
   Its own colour, not the credit's: this has to read as a badge rather than
   as decoration on the word next to it. `flex: none` so it survives the
   squeeze that truncates the name.

   Sized in `em` so it tracks whatever text it sits beside -- the same element
   is used at 12.5px on a card and at 14px in the detail panel, and a fixed
   pixel size would look bolted on at one of them. */
.verified-mark {
  flex: none;
  width: 1.05em;
  height: 1.05em;
  color: var(--teal-300);
  /* The seal is a busy shape at 13px; a touch of lift stops it reading as a
     smudge against the card. */
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.35));
}

/* An official credit is the app talking about itself, so it gets the brighter
   ink -- a user's name stays the quieter default. */
.cover-by.is-official .credit-name {
  font-weight: 700;
  color: var(--ink);
}

/* The same credit in the playlist detail panel, at that panel's size. It
   replaces the plain .detail-sub text that used to sit there. */
.detail-credit {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-width: 0;
  font-family: var(--font-soft);
  font-weight: 700;
  font-size: 14px;
  letter-spacing: 0.1px;
  color: var(--gold-300);
}

.detail-credit.is-official {
  color: var(--teal-300);
}

/* --- safety actions on a player panel ----------------------------------- *
   Set apart from the rest of the panel by a rule and a lot of space rather
   than by colour. These are deliberately not loud: a red Block button beside
   Challenge invites the one mis-tap that is hardest to undo. */

.detail-danger {
  display: grid;
  gap: 10px;
  margin-top: 26px;
  padding-top: 20px;
  border-top: 1px solid rgba(255, 255, 255, 0.10);
}

.detail-fineprint {
  margin: 0;
  font-family: var(--font-soft);
  font-weight: 500;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--ink);
  opacity: 0.75;
}

/* --- the Competitive card ----------------------------------------------- *
   The one entry to ranked play, so it is the loudest thing on Home: a full
   width row with its own gradient rather than another cover in the shelf.
   It is not a playlist and must not look like one. */

.ranked-card {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 16px 18px;
  border: 0;
  border-radius: var(--r-md, 16px);
  cursor: pointer;
  text-align: left;
  color: var(--ink);
  background: linear-gradient(135deg, var(--coral-500, #ff7d8a), var(--purple-600, #5a2a6b) 82%);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    inset 0 -5px 0 rgba(0, 0, 0, 0.26),
    0 8px 20px rgba(0, 0, 0, 0.38);
  transition: filter 0.14s ease, transform 0.09s ease;
  -webkit-tap-highlight-color: transparent;
}

.ranked-card:hover { filter: brightness(1.08); }
.ranked-card:active { transform: translateY(2px); }

.ranked-card:focus-visible {
  outline: none;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.20),
    0 0 0 3px rgba(255, 224, 138, 0.8);
}

.ranked-badge {
  flex: none;
  align-self: flex-start;
  padding: 4px 8px;
  border-radius: 999px;
  font-family: var(--font-soft);
  font-weight: 800;
  font-size: 10.5px;
  letter-spacing: 1.2px;
  color: var(--navy-900, #1e1638);
  background: var(--gold-300, #ffe08a);
}

.ranked-text {
  display: grid;
  gap: 3px;
  min-width: 0;
  flex: 1;
}

.ranked-title {
  font-weight: 800;
  font-size: 18px;
  line-height: 1.15;
}

.ranked-sub {
  font-family: var(--font-soft);
  font-weight: 600;
  font-size: 12.5px;
  opacity: 0.9;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ranked-go {
  flex: none;
  font-size: 26px;
  line-height: 1;
  opacity: 0.75;
}

/* A fourth tile colour, for the high score. The other three are spoken for --
   coral is the record, teal the average, gold the personal best -- and the
   score is a different KIND of number from all of them. */
.stat-tile-purple {
  background: linear-gradient(180deg, rgba(160, 110, 220, 0.30), rgba(90, 42, 107, 0.30));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.14),
    inset 0 -4px 0 rgba(0, 0, 0, 0.22);
}

/* --- Library: the two-column grid --------------------------------------- */

.cover-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px 14px;
}

/* ==========================================================================
   COVER ART, AND THE IMPORTER
   --------------------------------------------------------------------------
   A playlist's face is one of the app's own images now, not an emoji. The
   square it sits on is unchanged -- same tint, same rim, same badge -- so the
   only new thing here is the <img> that fills it, and the three screens the
   Spotify importer needs.
   ========================================================================== */

/* The image IS the square: it covers the tinted ground completely, which is
   why the tint still matters -- it is what shows through a cover with
   transparency and what colours the box before the file has loaded. */
.cover-art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* --- the picker ---------------------------------------------------------
   A row of the eight, scrolling sideways. A grid would push the review list
   below the fold on a phone, and choosing a cover is a glance-and-tap.
------------------------------------------------------------------------- */

.cover-picker {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 68px;
  gap: 10px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  padding: 2px 0 8px;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}

.cover-choice {
  aspect-ratio: 1 / 1;
  padding: 0;
  border: 0;
  border-radius: 14px;
  overflow: hidden;
  cursor: pointer;
  background: none;
  opacity: 0.55;
  transition: opacity 0.14s ease, transform 0.1s ease, box-shadow 0.14s ease;
  -webkit-tap-highlight-color: transparent;
}

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

.cover-choice:hover {
  opacity: 0.85;
}

/* Chosen: full strength and ringed in gold, the same "this is the one" the
   playlist keys have always used. */
.cover-choice[aria-checked="true"] {
  opacity: 1;
  transform: translateY(-2px);
  box-shadow:
    0 0 0 3px var(--gold-300),
    0 8px 18px rgba(0, 0, 0, 0.45);
}

.cover-choice:focus-visible {
  outline: none;
  opacity: 1;
  box-shadow: 0 0 0 3px rgba(255, 125, 138, 0.75);
}

/* --- the draft, in Create Custom ----------------------------------------
   The importer's review list with two things added: a way out, and -- in movie
   mode -- somewhere to type the film. Same list, because a draft and a review
   are the same object at two moments.
------------------------------------------------------------------------- */

/* Both classes, because .review-row is declared further down this file and a
   single-class selector here loses to it on source order -- which put the
   remove button on a line of its own. */
.review-row.draft-row {
  grid-template-columns: 30px minmax(0, 1fr) auto;
}

.draft-remove {
  width: 30px;
  height: 30px;
  flex: none;
  padding: 0;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  font-size: 13px;
  font-weight: 700;
  color: var(--ink);
  background: rgba(255, 255, 255, 0.08);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
  transition: background 0.14s ease, color 0.14s ease;
  -webkit-tap-highlight-color: transparent;
}

.draft-remove:hover {
  background: rgba(255, 107, 122, 0.24);
  color: var(--bad);
}

.draft-remove:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(255, 125, 138, 0.65);
}

/* The film, when the film is the answer. */
.draft-film {
  margin-top: 4px;
  width: 100%;
  padding: 6px 10px;
  border: 0;
  border-radius: 10px;
  outline: none;
  font-family: var(--font-soft);
  font-weight: 600;
  font-size: 13px;
  color: var(--ink);
  background: rgba(255, 255, 255, 0.10);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
}

.draft-film::placeholder {
  color: var(--ink);
  opacity: 0.5;
}

/* A movie-mode song with no film is a round nobody can win. Said in the row,
   while it can still be typed in, rather than at save time. */
.draft-row[data-needs-film="true"] .draft-film {
  box-shadow: inset 0 0 0 1px var(--gold-400);
}

/* The search box inside the creator sits in a form of its own controls, so it
   needs the rhythm of one rather than the chrome treatment it has in Search. */
#dcu-results {
  margin-top: 8px;
}

#dcu-search-note:empty,
#dcu-feedback:empty {
  display: none;
}

/* --- the inbox, and the challenge picker --------------------------------
   Both reuse furniture that already exists -- .people rows for the inbox, the
   Library's .cover-grid for the picker -- so a challenge looks like the app it
   arrived in. What is here is the little that is genuinely new.
------------------------------------------------------------------------- */

/* A section inside the inbox. Hidden when it has nothing, so a screen with one
   friend request does not also say "Challenges" over an empty space. */
.inbox-group {
  width: 100%;
  margin-bottom: 18px;
}

.inbox-group:last-of-type {
  margin-bottom: 0;
}

/* The picker's covers are a choice, not a destination, so they get the press
   of a button rather than the drift of a link. */
#dch-grid .cover-card:active {
  transform: translateY(4px);
}

#dch-grid .cover-card:disabled {
  opacity: 0.5;
  filter: saturate(0.6);
}

/* --- step 1: the list of Spotify playlists ------------------------------ */

.pick-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 8px;
  width: 100%;
}

/* ONE ROW OF THE IMPORT PICKER
   -------------------------------------------------------------------------
   Art hard left, name and track count beside it, vertically centred.

   This was `display: block` with 12px of padding and nothing but two lines of
   text in it -- a column of thin, identical rows in which a player could not
   find their own playlist without reading every one. Cover art is how somebody
   recognises a playlist they made; the row exists to show it.

   Flex rather than grid on purpose: there are exactly two children, one of
   them a fixed square and the other taking whatever is left, which is the one
   shape flexbox states more plainly than grid does. */
.pick-open {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 14px;

  /* 64px of art plus 10px top and bottom is an 84px row. Comfortably past the
     44px minimum tap target on every platform's guidance, and -- more to the
     point on a phone -- big enough that a mis-tap in a scrolling list is
     unlikely rather than merely recoverable. */
  padding: 10px 14px;
  border: 0;
  border-radius: var(--r-sm);
  cursor: pointer;
  text-align: left;
  color: var(--ink);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.14),
    inset 0 -4px 0 rgba(0, 0, 0, 0.22);
  transition: filter 0.14s ease, transform 0.09s ease;
  -webkit-tap-highlight-color: transparent;
}

.pick-open:hover {
  filter: brightness(1.12);
}

.pick-open:active {
  transform: translateY(2px);
}

/* A playlist Spotify will not hand over. Dimmed and inert rather than hidden:
   seeing it and being told why beats it silently missing from a list you know
   the contents of. */
.pick-open:disabled {
  cursor: not-allowed;
  opacity: 0.5;
  filter: saturate(0.5);
}

.pick-open:disabled:hover {
  filter: saturate(0.5);
}

.pick-open:focus-visible {
  outline: none;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.14),
    0 0 0 3px rgba(255, 125, 138, 0.65);
}

/* --- the artwork ------------------------------------------------------- */

.pick-art {
  position: relative;
  flex: none;                  /* never squeezed by a long playlist name */
  width: 64px;
  height: 64px;
  border-radius: 10px;
  overflow: hidden;            /* the radius has to clip the <img> too */

  display: grid;
  place-items: center;

  /* Visible before the image loads and after one fails, so the row never
     collapses to a different height mid-scroll. */
  background: linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05));
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.18),
    0 2px 8px rgba(0, 0, 0, 0.35);
}

.pick-art-fallback {
  font-size: 24px;
  line-height: 1;
  opacity: 0.55;
}

/* Absolutely positioned OVER the fallback rather than replacing it: an <img>
   that fails removes itself and the glyph is simply revealed, with no second
   code path and no flash of an empty box. */
.pick-art-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;           /* Spotify art is square, but mosaics are not */
  display: block;
}

/* --- the text ---------------------------------------------------------- */

.pick-text {
  display: grid;
  gap: 3px;
  /* THE LINE THAT MAKES TRUNCATION WORK. A flex child's default min-width is
     `auto`, which means "as wide as my content" -- so without this a long
     playlist name pushes the row wider than the panel instead of ellipsing,
     and the whole list scrolls sideways. */
  min-width: 0;
  flex: 1;
}

.pick-name {
  font-weight: 700;
  font-size: 15px;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pick-sub {
  font-family: var(--font-soft);
  font-weight: 500;
  font-size: 13px;
  color: var(--ink);
  opacity: 0.78;               /* secondary to the name, not competing with it */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Narrow phones: the art comes down to 56px so a long name keeps a usable
   share of the row. Everything else -- the centring, the truncation, the tap
   target -- is unchanged, because it was never width-dependent. */
@media (max-width: 380px) {
  .pick-art {
    width: 56px;
    height: 56px;
  }

  .pick-open {
    gap: 11px;
    padding: 9px 11px;
  }
}

/* --- step 2: the review list -------------------------------------------
   Its own scroller, capped, so the form above it stays reachable: a 200-track
   playlist must not push Create a thousand pixels down the page.
------------------------------------------------------------------------- */

.review {
  width: 100%;
  margin-top: 6px;
  text-align: left;
}

.review-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 2px;
  max-height: 42vh;
  overflow-y: auto;
  overscroll-behavior-y: contain;
  border-radius: var(--r-sm);
  background: rgba(0, 0, 0, 0.22);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.35);
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}

.review-list:empty {
  display: none;
}

.review-row {
  display: grid;
  grid-template-columns: 30px minmax(0, 1fr);
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
}

.review-row + .review-row {
  border-top: 1px solid rgba(255, 255, 255, 0.06);
}

.review-n {
  font-family: var(--font-soft);
  font-weight: 600;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  text-align: right;
}

.review-text {
  display: grid;
  gap: 1px;
  min-width: 0;
}

.review-title {
  font-weight: 700;
  font-size: 14px;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.review-artist {
  font-family: var(--font-soft);
  font-weight: 500;
  font-size: 12.5px;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The import screens carry a list rather than a single object, so they get a
   wider column than a playlist or a player panel does. */
/* The diagnostics block in the importer. Monospaced and dim on purpose: it is
   evidence to read or paste, not part of the product's voice. */
.diag {
  width: 100%;
  margin-top: 4px;
  text-align: left;
  font-family: var(--font-soft);
  font-size: 13px;
  color: var(--ink);
}

.diag summary {
  cursor: pointer;
  padding: 8px 2px;
  font-weight: 600;
}

.diag button {
  margin-top: 10px;
}

.diag pre {
  margin: 6px 0 0;
  padding: 12px;
  border-radius: var(--r-sm);
  background: rgba(0, 0, 0, 0.28);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.35);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1.6;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  color: var(--ink);
}

.detail-body-wide {
  width: min(520px, 100%);
}

/* .feedback reserves 24px so a message appearing does not shove the layout
   down. On this screen it sits between the review list and the Create button
   and is empty almost always, so here it collapses instead -- the gap it
   leaves reads as a missing element rather than as breathing room. */
/* .feedback reserves 24px so a message appearing does not shove the layout
   down. On these two screens it is empty almost always and sits between other
   blocks, so the reserved space reads as a missing element rather than as
   breathing room. */
#dc-feedback:empty,
#dch-feedback:empty {
  display: none;
}

/* The review section is a form child here, so it needs its own rhythm rather
   than the grid gap the fields above it use. */
.detail-actions .review {
  margin: 4px 0 2px;
}


/* ==========================================================================
   PEOPLE ROWS — search results, friends, pending requests
   --------------------------------------------------------------------------
   Avatar hard left, name filling the middle, whatever actions the row has on
   the right. One row component, three lists.
   ========================================================================== */

.people {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 10px;
}

.people:empty {
  display: none;
}

.person {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  padding: 9px 12px 9px 9px;
  border-radius: var(--r-sm);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.14),
    inset 0 -4px 0 rgba(0, 0, 0, 0.22),
    0 6px 16px rgba(0, 0, 0, 0.32);
}

/* The whole row is the button when the row's job is "open this player". */
.person-open {
  display: contents;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  cursor: pointer;
  text-align: left;
  -webkit-tap-highlight-color: transparent;
}

.person-pic {
  width: 46px;
  height: 46px;
  font-size: 16px;
}

.person-text {
  display: grid;
  gap: 1px;
  min-width: 0;
}

.person-name {
  font-weight: 700;
  font-size: 15px;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.person-sub {
  font-family: var(--font-soft);
  font-weight: 500;
  font-size: 13px;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.person-sub:empty {
  display: none;
}

.person-actions {
  display: flex;
  gap: 8px;
  align-items: center;
}

/* Accept / Decline. .btn at a size that fits twice into a list row. */
.btn-mini {
  padding: 8px 14px;
  font-size: 13px;
  border-radius: 12px;
}

.person:hover {
  filter: brightness(1.08);
}

.person:focus-within {
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.14),
    0 0 0 3px rgba(255, 125, 138, 0.6);
}


/* ==========================================================================
   TAB 2 — SEARCH
   ========================================================================== */

.search-bar {
  position: sticky;
  top: 0;
  z-index: 2;
  /* WHERE IT RESTS
     `top: 0` pins it to the scrollport's PADDING box, and .lobby-main reserves
     --appbar-h of padding at the top -- so the bar comes to rest just under
     the header without naming its height twice. Change the header's height and
     this follows on its own.

     THE GROUND BLEEDS, THE FIELD DOES NOT
     The frosted strip runs edge to edge (negative inline margin, same value
     back as padding) while the field keeps the page's 20px margins and lines
     up with the covers below. Inset both and the ground has two hard vertical
     seams down the middle of the screen, which is what made this read as a
     muddy slab stuck behind the input. */
  /* FLUSH AGAINST THE HEADER, IN BOTH STATES -- which takes two declarations,
     because a sticky element has two positions and they are set differently:

       unstuck  its place in flow      -> margin-top pulls it up
       stuck    `top` from the SCROLLPORT'S PADDING BOX, which is already inset
                by --hub-top-gap       -> so `top` must be NEGATIVE by the same

     Setting only the margin (the obvious fix) leaves a --hub-top-gap strip of
     bare content showing between the two bars the moment you scroll, which is
     precisely the mess this was supposed to end. The padding puts the space
     back INSIDE the bar, so the field keeps its air. */
  top: calc(var(--hub-top-gap) * -1);
  margin-top: calc(var(--hub-top-gap) * -1);
  padding: calc(6px + var(--hub-top-gap)) var(--hub-pad-x) 12px;
  margin-inline: calc(var(--hub-pad-x) * -1);

  /* THE SAME SURFACE AS THE HEADER, for the same reason: a ground that fades
     to transparent is a window, and covers scrolling up passed visibly behind
     the field. Even ground, same blur -- so the header and this read as one
     two-tier chrome stack rather than two competing translucencies. */
  background: rgba(24, 17, 46, 0.85);
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);

  /* The stack's bottom edge, so the hairline lands where the chrome actually
     ends. The header gives its own up on this tab -- see below. */
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.30);
}

/* One line under the chrome, not two through the middle of it. */
body[data-screen="home"][data-tab="search"] .appbar {
  border-bottom-color: transparent;
  box-shadow: none;
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {

  .search-bar {
    background: rgba(24, 17, 46, 0.97);
  }
}

/* --- the field itself --------------------------------------------------
   Chrome material, not form material. The app's .inset-field is a recessed
   white well with an inner shadow -- exactly right for the guess box, where
   you are writing an answer into a document, and wrong here: a search bar on a
   dark app should be a translucent pill that sits ON the background rather
   than a bright hole punched through it.
------------------------------------------------------------------------- */

.search-field {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 14px;
  border-radius: 14px;
  background: rgba(255, 255, 255, 0.10);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
  transition: background 0.15s ease, box-shadow 0.15s ease;
}

.search-field:focus-within {
  background: rgba(255, 255, 255, 0.14);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.18),
    0 0 0 3px rgba(255, 125, 138, 0.45);
}

.search-field-icon {
  width: 18px;
  height: 18px;
  flex: none;
  color: var(--ink);
}

.search-field input {
  flex: 1;
  min-width: 0;
  height: 44px;
  padding: 0;
  border: 0;
  outline: none;
  background: none;
  color: #fff;
  font-weight: 600;
  font-size: 15.5px;
  letter-spacing: -0.1px;
}

.search-field input::placeholder {
  color: var(--ink);
  font-weight: 500;
}

.search-bar .inset-field {
  flex: initial;
}

/* Chrome and Safari draw their own clear button on type="search"; it lands on
   the wrong colour and is redundant next to an empty field that already shows
   the placeholder. */
.search-bar input::-webkit-search-cancel-button,
.search-bar input::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
}

.hub-results {
  display: grid;
  gap: 22px;
}

.result-group {
  display: block;
}


/* ==========================================================================
   THE DETAIL LAYER
   --------------------------------------------------------------------------
   Fixed to the viewport and sliding in from the right, over whichever tab is
   underneath. z-index 35 puts it above the page and below the dock (40).

   THE DOCK IS NOT THERE TO BE BELOW ANY MORE. It used to be: the layering was
   chosen so the bottom nav stayed live over a panel and nobody could be
   trapped by a missed back chevron. The dock now hides outright on a detail
   view (see body[data-detail="open"] in the mobile block above) -- two
   navigations on one screen read as unfinished, and the panel's own sticky
   Back is both closer to hand and the one that actually goes back rather than
   sideways.

   The order is left as it is regardless. Nothing is gained by raising this
   layer, and if the dock is ever shown here again the stack still has to be
   the one that cannot bury it.
   ========================================================================== */

.detail-layer {
  position: fixed;
  inset: 0;
  z-index: 35;
  overflow-y: auto;
  overscroll-behavior: contain;
  background:
    radial-gradient(900px 560px at 15% -6%, #5a2a6b 0%, transparent 58%),
    linear-gradient(170deg, var(--purple-600), var(--navy-800) 62%, var(--navy-900));
}

.detail {
  min-height: 100%;
  display: grid;
  align-content: start;
  animation: detail-in 0.26s cubic-bezier(0.22, 1.1, 0.4, 1) both;
}

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

  to {
    opacity: 1;
    transform: none;
  }
}

/* Back chevron left, what-am-I-looking-at right of it. Sticky, so the way out
   is on screen however far down a long playlist you are. */
.detail-bar {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 18px calc(14px + env(safe-area-inset-bottom, 0px) * 0);
  padding-top: calc(14px + env(safe-area-inset-top, 0px));
  background: linear-gradient(180deg,
      rgba(52, 37, 92, 0.96) 0%,
      rgba(52, 37, 92, 0.86) 62%,
      rgba(52, 37, 92, 0) 100%);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

.detail-kicker {
  font-family: var(--font-soft);
  font-weight: 600;
  font-size: 13px;
  letter-spacing: 0.1px;
  color: var(--ink);
}

.detail-body {
  display: grid;
  justify-items: center;
  gap: 14px;
  width: min(430px, 100%);
  margin: 0 auto;
  padding: 4px 20px 40px;
  text-align: center;
}

/* The hero cover: centred, square, and capped so it does not become a poster
   on a tablet. */
.cover-xl {
  width: min(260px, 68vw);
}

.cover-xl .cover-badge {
  right: 12px;
  bottom: 12px;
  padding: 5px 12px 6px;
  font-size: 12px;
}

/* .detail-body centres its children, which is right for the cover, the name
   and the buttons and wrong for anything that is a block of its own: a grid of
   tiles or a list of people should be as wide as the panel, not as wide as its
   widest tile. */
.detail-body > .stats-grid,
.detail-body > .people,
.detail-body > .stats-note {
  width: 100%;
}

.detail-title {
  margin: 6px 0 0;
  max-width: 100%;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(25px, 7.2vw, 33px);
  letter-spacing: -0.2px;
  line-height: 1.12;
  color: #fff;
  text-shadow:
    0 2px 0 rgba(0, 0, 0, 0.28),
    0 5px 0 rgba(0, 0, 0, 0.16),
    0 12px 26px rgba(255, 120, 150, 0.30);
  overflow-wrap: anywhere;
}

/* Gold, and staying gold: this is a HUE, not a dimmed white, so it is not one
   of the muted greys that went. It is the one line on the panel that names who
   made the thing, and the app already uses gold for "the chosen one". */
.detail-sub {
  margin: 0;
  font-family: var(--font-soft);
  font-weight: 700;
  font-size: 14px;
  letter-spacing: 0.1px;
  color: var(--gold-300);
}

.detail-sub:empty {
  display: none;
}

.detail-blurb {
  margin: 0;
  font-family: var(--font-soft);
  font-weight: 600;
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink);
}

.detail-blurb:empty {
  display: none;
}

/* --- the two big launch buttons and the name they play under ------------ */

.detail-actions {
  display: grid;
  gap: 10px;
  width: 100%;
  margin-top: 8px;
  text-align: left;
}

.detail-actions .field-label {
  margin-bottom: 2px;
}

/* Singleplayer / Challenge a Friend. The same extrusion as every other .btn,
   given the height that says "this is the thing to press". */
.btn-big {
  padding: 17px 22px;
  font-size: 17px;
  letter-spacing: 0.2px;
}

.detail-actions .btn-big:first-of-type {
  margin-top: 6px;
}

/* --- a labelled control on its own line (visibility) -------------------- */

.detail-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  width: 100%;
  margin-top: 6px;
  padding: 12px 14px;
  border-radius: var(--r-sm);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.13),
    inset 0 -4px 0 rgba(0, 0, 0, 0.22);
}

.detail-row-label {
  font-family: var(--font-soft);
  font-weight: 600;
  font-size: 14px;
  letter-spacing: 0.1px;
  color: var(--ink);
}

/* --- the Public / Private switch ---------------------------------------- */

.toggle {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/* A recessed well with a raised knob in it -- the .inset-field trench and a
   .btn face, at switch scale. */
.toggle-track {
  position: relative;
  width: 52px;
  height: 30px;
  border-radius: 99px;
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.46), rgba(255, 255, 255, 0.08));
  box-shadow:
    inset 0 3px 6px rgba(0, 0, 0, 0.45),
    0 1px 0 rgba(255, 255, 255, 0.08);
  transition: background 0.16s ease;
}

.toggle-knob {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: linear-gradient(180deg, #fff2ee, #d9bfd6);
  box-shadow:
    inset 0 -3px 0 rgba(120, 70, 110, 0.24),
    0 3px 7px rgba(0, 0, 0, 0.45);
  transition: transform 0.16s cubic-bezier(0.22, 1.1, 0.4, 1);
}

.toggle[aria-checked="true"] .toggle-track {
  background: linear-gradient(180deg, var(--teal-500), var(--teal-400));
  box-shadow:
    inset 0 2px 5px rgba(0, 0, 0, 0.32),
    0 0 0 1px rgba(79, 208, 207, 0.45),
    0 0 14px rgba(47, 179, 182, 0.45);
}

.toggle[aria-checked="true"] .toggle-knob {
  transform: translateX(22px);
}

.toggle-text {
  font-weight: 700;
  font-size: 13px;
  min-width: 56px;
  text-align: left;
}

.toggle:focus-visible {
  outline: none;
}

.toggle:focus-visible .toggle-track {
  box-shadow:
    inset 0 3px 6px rgba(0, 0, 0, 0.45),
    0 0 0 3px rgba(255, 125, 138, 0.7);
}

/* Nothing to switch: a playlist that is not yours has no visibility for you
   to set, and the delete is not yours to press either. */
.detail[data-context="search"] #dp-visibility {
  display: none;
}


/* ==========================================================================
   THE HUB, RESPONSIVE
   --------------------------------------------------------------------------
   Same single breakpoint the lobby shell already turns on: under 900px the
   rail is gone and the dock is the navigation; over it, the rail is the
   navigation and the dock is gone. Mobile is the design; the desktop is the
   same design with the tab bar moved to the side.
   ========================================================================== */

@media (max-width: 900px) {

  /* The dock now carries four real destinations rather than two live ones and
     two greyed out, so the labels have less room each. */
  .dock-item {
    font-size: 11px;
    letter-spacing: 0.1px;
  }

  /* The detail layer is a scroller of its own -- the tab's own bottom padding
     does not reach inside it -- so it carries its own.

     This used to be 96px of clearance for a dock that sat over the panel. The
     dock is now hidden whenever a panel is up, so that reservation would be a
     hand's width of dead space at the end of every playlist. What is left is
     ordinary breathing room plus the home indicator, which is real on a phone
     whatever the nav is doing. */
  .detail-body {
    padding-bottom: calc(28px + env(safe-area-inset-bottom, 0px));
  }
}

/* A phone narrow enough that two columns of cover plus the gutters leaves the
   titles unreadable. The grid stays two-wide -- one giant column per playlist
   is worse -- but everything inside it comes down a size. */
@media (max-width: 360px) {

  .cover-grid {
    gap: 12px 10px;
  }

  .cover-name {
    font-size: 12.5px;
  }

  .person-pic {
    width: 40px;
    height: 40px;
  }
}

@media (prefers-reduced-motion: reduce) {

  .detail {
    animation: none;
  }

  .toggle-knob,
  .toggle-track {
    transition: none;
  }
}


/* ==========================================================================
   DESKTOP — the last word
   --------------------------------------------------------------------------
   One block, and it is the FILE'S LAST BLOCK on purpose. Two reasons, and the
   second is why it moved down here:

   1. One place decides what the web version looks like. The tests enforce
      this: a second desktop media block anywhere in this file is a failure,
      because two of them are two things to reconcile on every change.

   2. Everything it overrides is declared above it. The hub's covers, rails and
      detail panels are all written mobile-first at the bottom of the file, and
      a same-specificity rule only wins if it comes later. When this block sat
      in the middle, every desktop override in it was quietly dead.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Full-bleed on the web

   The lobby was a 1080px card floating in the middle of a 2560px monitor: a
   phone layout wearing a desktop's whitespace. On the web it is now the whole
   viewport -- rail pinned full height, content scrolling beside it.

   Scoped to the lobby ON PURPOSE. The match screens stay a centred column,
   because a 30-second listening round spread across an ultrawide is worse,
   not better -- the vinyl, the clock and the guess field want to be near each
   other and near the middle.
   -------------------------------------------------------------------------- */
@media (min-width: 901px) {

  /* Full bleed, no card and top-aligned are NOT here any more: those are the
     lobby's shape at every width now, and they are stated once at the top of
     this file. What is left in this block is only what the extra width
     actually changes.

     The match screens still get their centred column from the base rules,
     which nothing here touches -- that is the point of the [data-screen]
     scope, and the reason a round still reads the same on a 27" monitor. */

  /* The rail is a full-height column beside the scroller, not a sticky block
     inside a scrolling page -- the page does not scroll any more, .lobby-main
     does. It gets its own overflow so a long nav can never be cut off. */
  .lobby-nav {
    height: 100%;
    width: 248px;
    overflow-y: auto;
  }

  .lobby {
    grid-template-columns: 248px minmax(0, 1fr);
  }

  /* The rail already carries the brand, so the header does not repeat it, and
     the bar starts where the rail ends rather than running behind it. */
  .appbar {
    left: 248px;
  }

  /* THE SEARCH BAR STOPS BEING CHROME UP HERE.
     On a phone it is the second tier of a full-width frosted stack. In a
     centred 720px column it cannot be: bleeding it to the viewport's edge
     makes a slab wider than the list it belongs to, and NOT bleeding it hangs
     a narrow strip under a full-width header with bare content either side of
     it. Neither is worth having for a list this short on a screen this big, so
     the field simply scrolls with the content and the header keeps the single
     hairline that separates chrome from page. */
  .search-bar {
    position: static;
    margin: 0;
    padding: 0 0 4px;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border-bottom: 0;
    box-shadow: none;
  }

  body[data-screen="home"][data-tab="search"] .appbar {
    border-bottom-color: rgba(255, 255, 255, 0.07);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.30);
  }

  .appbar-brand {
    display: none;
  }

  /* Full bleed is not the same as infinitely wide. Past ~1180px the cards
     stop getting better and the eye starts having to travel, so the content
     centres inside the space instead of stretching into it. */
  .hub {
    max-width: 720px;
    margin-inline: auto;
  }

  /* --- the hub, on a desktop -------------------------------------------
     The rail IS the tab bar up here, so the only thing the wider viewport
     changes is how much of each tab fits: the Library grid stops being two
     fixed columns and fills, the Recently Played covers get bigger, and the
     detail layer starts where the rail ends so a panel never covers the
     navigation that opened it. 248px is the rail's width, set above.
  --------------------------------------------------------------------- */

  .detail-layer {
    left: 248px;
  }

  .detail-body {
    width: min(560px, 100%);
    padding-bottom: 56px;
  }

  .cover-grid {
    grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
    gap: 20px 18px;
  }

  /* Still two per row, but capped and centred: on a 720px column a pair of
     full-width covers would be two 350px posters, which is not what "recently
     played" wants to be. */
  .shelf-rail {
    grid-template-columns: repeat(2, minmax(0, 200px));
    justify-content: center;
  }

  /* The columns are a fixed 200px up here, so the odd one out matches that
     rather than a percentage of a wider row. */
  .shelf-rail > :last-child:nth-child(odd) {
    width: 200px;
  }
}
