/* The artist page keeps the release list and the song list visible together. */
.artist-panels {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 16px;
  align-items: start;
}
.artist-panel { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.panel-title { color: var(--fg0); font-weight: 600; }
.panel-count { color: var(--fg4); font-variant-numeric: tabular-nums; }
.panel-search { width: 100%; min-width: 0; }
/* Which way a panel's search works: every song on the server, or the ones this
   page has loaded. Two small buttons rather than a switch, because the choice
   has a consequence worth reading. */
.panel-mode { display: inline-flex; gap: 4px; margin-left: auto; }
.panel-mode .btn { padding: 2px 8px; font-size: 11.5px; }
.panel-mode .btn[aria-pressed="true"] { color: var(--fg0); background: var(--bg3); }
/* The end of the songs list, while there are more: it is what the observer
   watches, so it is the thing that says the list grows past the first page. */
.panel-more {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-height: 44px;
  color: var(--fg4);
  font-size: 12.5px;
}
.panel-list {
  max-height: min(62vh, 720px);
  overflow-y: auto;
  background: var(--bg0-s);
  border: 1px solid var(--bg1);
  border-radius: var(--radius);
}
.catalogue-status { min-height: 1.2em; color: var(--fg4); font-size: 0.9rem; }
.catalogue-status.busy::before { content: ""; display: inline-block; width: 0.8em; height: 0.8em; margin-right: 0.45em; border: 2px solid var(--bg3); border-top-color: var(--accent); border-radius: 50%; vertical-align: -0.08em; animation: musoak-spin 800ms linear infinite; }
.album-singles .title { color: var(--accent); }
@media (max-width: 860px) {
  .artist-panels { grid-template-columns: minmax(0, 1fr); }
}
/* MusOak web UI — gruvbox-dark.
 *
 * One palette, one set of components. Everything else in the app is HTML and
 * these classes; nothing hardcodes a colour, so a second theme is one block of
 * variables away.
 */

/* Miracode, by Idrees Inc, under the SIL Open Font License 1.1 (fonts/OFL.txt).
   Vendored rather than fetched: this app has no CDN and works offline. */
@font-face {
  font-family: "Miracode";
  src: url("fonts/Miracode.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  /* gruvbox dark */
  --bg0-h: #1d2021;
  --bg0: #282828;
  --bg0-s: #32302f;
  --bg1: #3c3836;
  --bg2: #504945;
  --bg3: #665c54;
  --bg4: #7c6f64;

  --fg0: #fbf1c7;
  --fg1: #ebdbb2;
  --fg2: #d5c4a1;
  --fg3: #bdae93;
  --fg4: #a89984;
  --gray: #928374;

  --red: #fb4934;
  --red-dim: #cc241d;
  --green: #b8bb26;
  --green-dim: #98971a;
  --yellow: #fabd2f;
  --yellow-dim: #d79921;
  --blue: #83a598;
  --blue-dim: #458588;
  --purple: #d3869b;
  --purple-dim: #b16286;
  --aqua: #8ec07c;
  --aqua-dim: #689d6a;
  --orange: #fe8019;
  --orange-dim: #d65d0e;

  --radius: 8px;
  --radius-small: 5px;
  /* The bars: a rounded rectangle, not a pill - the track's corner is well
     short of half its height - and a tall rounded rectangle for the position,
     which grows only taller under the pointer. */
  --range-track-height: 12px;
  --range-track-radius: 3px;
  --range-thumb-width: 7px;
  --range-thumb-height: 24px;
  --range-thumb-height-hover: 30px;
  --range-thumb-radius: 3px;
  --sidebar-width: 232px;
  --player-height: 92px;

  color-scheme: dark;
  font-family: "JetBrains Mono", "Iosevka", "Fira Mono", ui-monospace, monospace;
  font-size: 15px;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  /* The app is the whole screen and scrolls its own panes: a page that scrolls
     under it is what made a phone feel like it was fighting the scroll. */
  overflow: hidden;
  background: var(--bg0-h);
  color: var(--fg1);
  /* Firefox draws its own scrollbars: without this the app has light grey ones
     in a dark room, and the tab strip below has none to speak of. */
  scrollbar-width: thin;
  scrollbar-color: var(--bg3) transparent;
}

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

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg0); }
::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--bg4); }

/* ---------- layout ---------- */

.app {
  display: grid;
  grid-template-columns: var(--sidebar-width) 1fr;
  grid-template-rows: 1fr var(--player-height);
  /* Pinned to what is actually on screen rather than sized by a viewport unit:
     fixed positioning is relative to the *layout* viewport, which on a phone is
     the screen with the browser chrome hidden - taller than the part you can
     see - so the bottom of the shell, where the tab strip and the player live,
     fell behind it. app.js measures visualViewport and sets the height; dvh is
     the closest a stylesheet can get on its own, for if that script never runs. */
  position: fixed;
  inset: 0;
  height: 100dvh;
}

.sidebar {
  grid-row: 1;
  background: var(--bg0);
  border-right: 1px solid var(--bg1);
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* The brand and the server, together: a phone puts them on one line. */
.sidebar-top { display: block; }

.main {
  grid-row: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  /* A grid item's automatic minimum is its min-content, so the padding the
     queue adds here would widen the whole track - and with it the full-width
     player, pushing the bar's last buttons off the window. */
  min-width: 0;
}

.content {
  flex: 1;
  overflow-y: auto;
  min-height: 0;
  padding: 0 0 32px;
}
.content[aria-busy="true"] { cursor: progress; }
.content[aria-busy="true"] > .page > .view-surface { animation: view-surface-in 140ms ease-out; }
@keyframes view-surface-in { from { opacity: 0.78; transform: translateY(3px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
  .content[aria-busy="true"] > .page > .view-surface { animation: none; }
}

.banner-slot { flex: none; }

.skip-link { position: fixed; top: -48px; left: 12px; z-index: 100; padding: 8px 12px; border-radius: var(--radius-small); background: var(--blue); color: var(--bg0-h); font-weight: 700; transition: top 120ms ease; }
.skip-link:focus { top: 12px; }
.corner { padding: 0 10px 10px; }
.corner-row { display: flex; gap: 6px; align-items: center; min-width: 0; }

/* The light beside the server's name: green while it answers, orange while it
   does not, with a retry button appearing next to it when it does not. */
.server-dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  flex: none;
  background: var(--bg4);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--bg0-h) 60%, transparent);
}
.server-dot.online { background: var(--green); }
.server-dot.offline { background: var(--orange); }
.corner-row .btn.retry { flex: none; color: var(--orange); }
.corner-row .btn.retry:hover { background: var(--orange-dim); color: var(--bg0-h); }

/* Who you are on this server: the account's icon and name, under its own. */
.account-card {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  margin-top: 8px;
  padding: 6px 8px;
  background: var(--bg1);
  border: 1px solid var(--bg2);
  border-radius: var(--radius-small);
  color: var(--fg1);
  cursor: pointer;
  text-align: left;
}
.account-card:hover { border-color: var(--green-dim); }
/* Signed out, the card is one button that says so and asks: no icon, no name
   we cannot vouch for, just the way in. */
.account-card.signin {
  flex: none;
  min-height: 40px;
  padding: 10px 22px;
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--bg0-h);
  background: var(--blue-dim);
  border-color: var(--blue-dim);
}
.account-card.signin:hover { background: var(--blue); border-color: var(--blue); color: var(--bg0-h); }
.account-card .art.placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg2);
  color: var(--fg3);
}
.account-card .who { display: flex; flex-direction: column; min-width: 0; }
.account-card .name {
  font-size: 0.95rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.account-card .handle { color: var(--fg4); font-size: 0.8rem; }

/* Signing in says what it is doing, and what the server said when it refused. */
.login-status { color: var(--fg3); font-size: 0.9rem; min-height: 1.2em; }
.login-status.error { color: var(--red); }
.btn[data-busy="true"] { opacity: 0.7; cursor: progress; }
.corner-row .btn {
  flex: 1;
  min-width: 0;
  justify-content: flex-start;
  overflow: hidden;
  white-space: nowrap;
  padding: 6px 9px;
  font-size: 14px;
}
/* The round buttons share the row but not the shape: they stay round and centre
   their icon, where the rule above is for the labelled server button. */
.corner-row .btn.round { flex: none; justify-content: center; padding: 0; }
.corner-row .btn .label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.player {
  grid-column: 1 / -1;
  background: var(--bg0-s);
  border-top: 1px solid var(--bg1);
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 18px;
  background: color-mix(in srgb, var(--bg0-h) 92%, transparent);
  backdrop-filter: blur(6px);
  border-bottom: 1px solid var(--bg1);
}

.topbar h1 {
  font-size: 17px;
  margin: 0;
  flex: 1;
  font-weight: 600;
  color: var(--fg0);
}
.topbar-back { flex: none; padding: 4px 8px; }
.topbar-back svg { display: block; }

.brand {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 14px 16px 10px;
  color: var(--fg0);
  font-weight: 700;
  font-size: 17px;
  white-space: nowrap;
}
.brand img, .brand-icon { height: 26px; width: 26px; object-fit: contain; }
/* The wordmark is one word: no gap, no space between its halves. */
.brand .wordmark { display: inline-flex; }
.brand .accent { color: var(--orange); }
/* "Mus" in orange, "Oak" in green: the name says what it is made of. */
.brand .oak { color: var(--green); }

/* ---------- navigation ---------- */

.nav { overflow-y: auto; flex: 1; padding: 6px 8px; min-height: 0; }

/* The strip and the arrows that say it goes further. On a desktop the sidebar
   is a column, so the wrapper is only a column of one. `min-width: 0` is what
   keeps the strip scrolling inside its column instead of widening the page. */
.nav-wrap { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; }
.nav-edge { display: none; }

.nav-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  margin: 2px 0;
  border: 0;
  border-radius: var(--radius-small);
  background: transparent;
  color: var(--fg2);
  cursor: pointer;
  text-align: left;
}
.nav-item:hover { background: var(--bg1); color: var(--fg0); }
.nav-item.active { background: var(--bg2); color: var(--fg0); }
.nav-item .icon { width: 18px; text-align: center; color: var(--fg3); }
.nav-item.active .icon { color: var(--orange); }

.nav-section {
  margin: 12px 10px 4px;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--gray);
}

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

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 7px 14px;
  border: 1px solid var(--bg3);
  border-radius: var(--radius-small);
  background: var(--bg1);
  color: var(--fg1);
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
.btn:hover { background: var(--bg2); border-color: var(--bg4); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: default; transform: none; }
/* A control the room's policy reserves for the host: it stays clickable so it
   can explain itself, so it is not really disabled - only dimmed. */
.btn[aria-disabled="true"] { opacity: 0.5; }

.btn.suggested {
  background: var(--blue-dim);
  border-color: var(--blue-dim);
  color: var(--fg0);
  font-weight: 600;
}
.btn.suggested:hover { background: var(--blue); border-color: var(--blue); color: var(--bg0-h); }

.btn.destructive {
  background: transparent;
  border-color: var(--red-dim);
  color: var(--red);
}
.btn.destructive:hover { background: var(--red-dim); color: var(--fg0); }

.btn.flat { background: transparent; border-color: transparent; }
.btn.flat:hover { background: var(--bg1); }

.btn.round {
  width: 38px;
  height: 38px;
  padding: 0;
  border-radius: 50%;
  font-size: 17px;
  /* A round button is round: no flex or grid stretch may turn it into an oval. */
  flex: none;
  aspect-ratio: 1;
}

.btn.circle-big {
  width: 52px;
  height: 52px;
  padding: 0;
  border-radius: 50%;
  font-size: 22px;
}

.btn.small { padding: 4px 9px; font-size: 13px; }

/* ---------- cards, rows, lists ---------- */

.card {
  background: var(--bg0-s);
  border: 1px solid var(--bg1);
  border-radius: var(--radius);
  padding: 16px;
}

.page { width: min(100%, 1180px); margin: 0 auto; padding: 20px 28px 28px; display: flex; flex-direction: column; gap: 20px; }
.page > .card, .page > .list { width: 100%; }
.page > .section-title { max-width: 1180px; width: 100%; margin-inline: auto; }

.list {
  background: var(--bg0-s);
  border: 1px solid var(--bg1);
  border-radius: var(--radius);
  overflow: hidden;
}

.row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 9px 12px;
  border-bottom: 1px solid var(--bg1);
}
.row:last-child { border-bottom: 0; }
.row:hover { background: var(--bg1); }
.row.clickable { cursor: pointer; }

.row .grow { flex: 1; min-width: 0; }
.row .title { color: var(--fg1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .subtitle {
  color: var(--fg4);
  font-size: 12.5px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.row .time { color: var(--fg4); font-variant-numeric: tabular-nums; font-size: 13px; }
.row > .time + .btn,
.row > .time + .btn + .btn,
.row > .grow + .btn { margin-left: 4px; }
.row > .btn + .btn { margin-left: 4px; }
/* A subtitle that is a sentence rather than a list line: it wraps, so it
   cannot set a phone's page wider than the screen. */

/* Shared asynchronous states: reserve the same footprint as a panel instead of
   replacing a page's geometry every time a request starts or finishes. */
.loading-state {
  min-height: 180px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  color: var(--fg4);
  text-align: center;
}
.loading-spinner {
  width: 16px;
  height: 16px;
  border: 2px solid var(--bg3);
  border-top-color: var(--aqua);
  border-radius: 50%;
  animation: musoak-spin 800ms linear infinite;
}
@keyframes musoak-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .loading-spinner { animation: none; }
}
.skeleton-list { min-height: 232px; padding: 4px 0; }
.skeleton-row { display: flex; align-items: center; gap: 12px; min-height: 58px; padding: 9px 12px; border-bottom: 1px solid var(--bg1); }
.skeleton-row:last-child { border-bottom: 0; }
.skeleton-art, .skeleton-line, .skeleton-time { display: block; background: var(--bg2); border-radius: var(--radius-small); animation: skeleton-pulse 1.2s ease-in-out infinite alternate; }
.skeleton-art { width: 48px; height: 48px; flex: none; }
.skeleton-copy { display: flex; flex: 1; flex-direction: column; gap: 7px; min-width: 0; }
.skeleton-line { width: 45%; height: 10px; }
.skeleton-line.wide { width: 72%; height: 12px; }
.skeleton-time { width: 36px; height: 10px; }
@keyframes skeleton-pulse { from { opacity: .45; } to { opacity: .85; } }
@media (prefers-reduced-motion: reduce) { .skeleton-art, .skeleton-line, .skeleton-time { animation: none; opacity: .62; } }
.loading-panel { display: flex; flex-direction: column; align-items: stretch; gap: 8px; }
.search-loading { border: 1px solid var(--bg1); border-radius: var(--radius); background: var(--bg0-s); overflow: hidden; }
.loading-caption { align-self: center; color: var(--fg4); font-size: .86rem; }
.account-loading .skeleton-list { min-height: 208px; }
.account-loading .skeleton-row { min-height: 48px; }
.art { contain: paint; }
img.art { display: block; transition: opacity 140ms ease; }
img.art:not([data-loaded="true"]) { opacity: 0.72; }
img.art[data-loaded="true"] { opacity: 1; }

/* The slot of a picture that has not arrived yet: a spinner where the cover
   will be. The row's details are already on the page, so a slow connection
   costs the illustration, not the list. */
@property --art-spin {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
img.art[data-loading="true"] {
  background-image: conic-gradient(from var(--art-spin), transparent 0 68%, var(--fg4) 68% 100%);
  background-repeat: no-repeat;
  background-position: center;
  background-size: 22px 22px;
  background-color: var(--bg2);
  -webkit-mask: radial-gradient(circle 11px at center, transparent 0 6.5px, #000 7px);
  mask: radial-gradient(circle 11px at center, transparent 0 6.5px, #000 7px);
  animation: art-spin 900ms linear infinite;
}
@keyframes art-spin { to { --art-spin: 360deg; } }
@media (prefers-reduced-motion: reduce) {
  img.art[data-loading="true"] { animation: none; }
}
.card, .list { box-shadow: 0 1px 0 color-mix(in srgb, var(--fg0) 4%, transparent); }
.card:focus-within, .list:focus-within { border-color: var(--bg3); }
@media (prefers-reduced-motion: reduce) {
  .view-surface, .loading-spinner, .skeleton-art, .skeleton-line, .skeleton-time { animation: none !important; transition: none !important; }
}

.page > .section-title,
.page > .card,
.page > .list { margin-block: 0; }
.section-title { color: var(--fg0); font-weight: 600; }
.panel-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.panel-heading > .grow { min-width: 0; }
.panel-heading .subtitle { color: var(--fg4); font-size: 0.86rem; }

/* Panels use the same heading rhythm everywhere: title, supporting line, then
   content. This keeps view changes from feeling like each page was designed in
   a separate application. */
.section-title + .list,
.section-title + .card { margin-top: 8px; }
.card > .section-title { margin-bottom: 12px; }
.panel-heading,
.card > .row:first-child { min-height: 40px; }
.card > .list { margin-top: 10px; }
.section > .subtitle,
.section-title + .subtitle { margin-bottom: 4px; }
.list > .row { min-height: 58px; }
.list > .row:focus-within { background: var(--bg1); outline: 1px solid var(--blue-dim); outline-offset: -1px; }
.btn:focus-visible,
.nav-item:focus-visible,
.row.clickable:focus-visible,
.menu-item:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.view-surface { animation: view-surface-in 140ms ease-out; }
.content[aria-busy="true"] .topbar { border-bottom-color: var(--blue-dim); }
.view-surface { will-change: opacity, transform; }
@media (prefers-reduced-motion: reduce) {
  .view-surface { will-change: auto; }
}

/* Mobile panels stay readable when the keyboard and browser chrome reduce the
   visual viewport: dialogs become bottom sheets, list panes scroll themselves,
   and panel padding does not push controls off-screen. */
@media (max-width: 700px), (max-aspect-ratio: 2/3) {
  .dialog-backdrop { align-items: flex-end; }
  .dialog {
    width: 100%;
    max-width: 100%;
    max-height: min(82dvh, 680px);
    border-radius: var(--radius) var(--radius) 0 0;
  }
  .dialog .actions { flex-wrap: wrap; }
  .dialog .actions .btn { flex: 1 1 120px; min-width: 0; }
  .card { padding: 13px; }
  .list > .row { min-height: 54px; padding-inline: 10px; }
}
 .row .subtitle.wrap { white-space: normal; }
 .rating { display: inline-flex; align-items: center; gap: 2px; }
 .rating-star { display: inline-flex; color: var(--orange); opacity: var(--rating-opacity, 1); }
 /* The rating dialog's own control: five big stars side by side, wide enough to
    span the panel, the ones left of the position in yellow and the rest in the
    track's grey - the same left-fills-first the player's bars are. The stars are
    drawn twice and the top row is clipped to the fraction, so the yellow ends
    exactly where the value is. The input over them is invisible and covers the
    whole rectangle, so a drag anywhere sets the position. */
 .rating-stage { position: relative; --rating-fill: 0%; }
 .rating-stars { display: flex; gap: 6px; color: var(--bg2); }
 .rating-stars-fill {
   position: absolute;
   inset: 0;
   color: var(--yellow);
   clip-path: inset(0 calc(100% - var(--rating-fill)) 0 0);
   pointer-events: none;
 }
 .rating-glyph { display: block; flex: 1 1 0; aspect-ratio: 1 / 1; }
 .rating-glyph svg { display: block; width: 100%; height: 100%; }
 .rating-stage input.rating-range {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
   margin: 0;
   padding: 0;
   opacity: 0;
   cursor: pointer;
   touch-action: none;
 }
 .rating-stage:has(input.rating-range:focus-visible) { outline: 2px solid var(--blue); outline-offset: 2px; }
 .art-fallback { display: inline-flex; align-items: center; justify-content: center; background: var(--bg2); color: var(--fg4); }

/* Subtle interaction feedback makes rows feel like controls without turning
   every page into a collection of floating cards. */
.row.clickable { transition: background 120ms ease, border-color 120ms ease; }
.row.clickable:active { background: var(--bg2); }
.btn, .nav-item, .menu-item { -webkit-tap-highlight-color: transparent; }
.btn:focus-visible, .nav-item:focus-visible, .menu-item:focus-visible { box-shadow: 0 0 0 2px var(--bg0), 0 0 0 4px var(--blue); }
.view-surface { min-height: 100%; contain: layout style; }
/* A view mounts its cards and sections as siblings, and this is the one place
   that decides how far apart they sit. A sheet is a view's own wrapper - a
   `display: contents` box, so its children are the ones that need the gap. */
.view-surface > * + *,
.view-sheet > * + * { margin-top: 16px; }
/* Inside a card, a block follows another one with room to breathe. Rows are a
   list and stay flush: their own separators already say where one ends. */
.card > * + * { margin-top: 12px; }
.card > .row + .row,
.card > .row:first-child { margin-top: 0; }
.card > .row { padding-block: 12px; }
/* A row of fields or buttons: label above its control, controls across. */
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.form-row .field { flex: 1 1 200px; }
.upload-search-panel { display: none; flex-direction: column; gap: 8px; }
.upload-search-controls { display: flex; gap: 8px; align-items: center; }
.upload-search-controls .input { flex: 1 1 auto; min-width: 0; }
.search-status { color: var(--fg4); font-size: 12.5px; min-height: 16px; }
.upload-search-results { min-height: 48px; }
.upload-picker-panel { display: flex; }
  .upload-progress { color: var(--fg4); font-size: 12.5px; min-height: 16px; }
  .upload-duplicates { display: flex; flex-direction: column; gap: 10px; }
  .upload-list { display: flex; flex-direction: column; gap: 10px; }
  .upload-dropzone { display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; }
  .upload-dropzone-icon { color: var(--bg4); }
  .upload-dropzone-title { font-size: 17px; color: var(--fg2); }
  .upload-dropzone-hint { color: var(--fg4); font-size: 12.5px; max-width: 460px; }
.art {
  width: 48px;
  height: 48px;
  border-radius: var(--radius-small);
  object-fit: cover;
  background: var(--bg2);
  flex: none;
}
.art.small { width: 32px; height: 32px; }
/* Somebody in a room is a person, not a list item: their picture is worth
   seeing, so the Listening panel gives it more room than a row's own tile. */
.art.member { width: 56px; height: 56px; }
/* The account card is the same idea about one person: 1.75x the 32px tile it
   used to borrow, which is the size a face is recognisable at. */
.art.account-icon { width: 56px; height: 56px; }
.art.big { width: 168px; height: 168px; border-radius: var(--radius); }
.art.round { border-radius: 50%; }

/* ---------- forms ---------- */

.field { display: flex; flex-direction: column; gap: 6px; }
.field label { color: var(--fg3); font-size: 13px; }

.input, select.input, textarea.input {
  width: 100%;
  padding: 9px 11px;
  background: var(--bg1);
  border: 1px solid var(--bg3);
  border-radius: var(--radius-small);
  color: var(--fg0);
  outline: none;
}
.input:focus { border-color: var(--blue); }
.input::placeholder { color: var(--gray); }
.account-dialog-fields { display: flex; flex-direction: column; gap: 10px; }
.dialog-intro { margin: 0; }
.sync-control { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.sync-status { min-height: 1.2em; }
.queue-tabs-row { flex-wrap: wrap; }
.queue-heading-row { flex-wrap: wrap; }
.queue-heading-title { font-size: 18px; font-weight: 600; }
.queue-tab { padding: 4px 10px; }
.queue-panel-title { font-size: 15px; font-weight: 600; }
.provider-playlist-header { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
.provider-playlist-title { font-size: 20px; color: var(--fg0); }
.provider-playlist-limit { padding: 0 4px 6px; }
.provider-playlist-more-row { display: flex; justify-content: center; }
.playlist-row-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.playlist-row-title .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.playlist-import-fields, .file-import-fields { display: flex; flex-direction: column; gap: 10px; min-width: min(420px, 80vw); }
.playlist-import-search { display: flex; gap: 8px; }
.playlist-import-search .input { min-width: 0; flex: 1; }
.import-note { color: var(--fg4); font-size: 12.5px; min-height: 16px; }
.import-report { max-height: 200px; overflow: auto; }
.import-actions { display: flex; gap: 8px; }
.duplicate-groups { display: flex; flex-direction: column; gap: 10px; }
.upload-selection-row { display: flex; justify-content: space-between; align-items: center; }
.playlist-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.upload-list-section { display: flex; flex-direction: column; gap: 10px; }
.upload-association-body { display: flex; flex-direction: column; gap: 12px; }
.upload-association-art-fields { display: flex; gap: 12px; align-items: center; }
.upload-association-fields { flex: 1; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.upload-association-note { color: var(--fg4); font-size: 12.5px; min-height: 16px; }
.upload-art-placeholder { display: inline-flex; align-items: center; justify-content: center; color: var(--gray); }
.upload-art-tile { width: 96px; height: 96px; flex: none; padding: 0; display: inline-flex; align-items: center; justify-content: center; overflow: hidden; }

 .account-device-note { padding: 0 4px 8px; }
 .account-icon-button { padding: 0; border: 0; background: transparent; }
 .account-action-button { flex: 1; min-width: 0; }
.account-device-row { align-items: flex-start; }
/* A count of songs: a field and a step either side. The field's own spinners are
   hidden because the buttons are the same control on every platform, and they
   are drawn in the theme's colours rather than the browser's. */
.stepper { display: inline-flex; align-items: center; gap: 6px; }
.stepper .stepper-value {
  width: 74px;
  text-align: center;
  -moz-appearance: textfield;
  appearance: textfield;
}
.stepper .stepper-value::-webkit-outer-spin-button,
.stepper .stepper-value::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.stepper .stepper-step { flex: none; }
.account-profile-row { align-items: center; }
.account-profile-row > .grow { min-width: 0; }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.account-profile-row > .btn { flex: none; }
.account-profile-copy { min-width: 0; }
.account-profile-copy .subtitle { overflow-wrap: anywhere; }
.account-guest-icon { display: inline-flex; line-height: 0; }
.search-row-copy { min-width: 0; }
.friend-status-row { align-items: center; }
.friend-status-row > .subtitle { flex: none; white-space: nowrap; }
 .upload-dialog-notice { margin: 0; }
.playlist-track-number { width: 24px; flex: none; text-align: right; }
.import-status { min-height: 1.2em; }
.import-report { min-height: 40px; }
.form-row .field { flex: 1; }
.upload-header-spacer { width: 24px; flex: none; }
.friend-profile-loading .skeleton-list { min-height: 290px; }
.playlist-refresh-loading .skeleton-list { min-height: 290px; }
.view-sheet { display: contents; }
.catalog-loading .skeleton-list { min-height: 290px; }

/* ---------- dialogs ---------- */

.dialog-backdrop {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(16, 16, 16, 0.62);
  z-index: 2147483000;
  isolation: isolate;
  pointer-events: auto;
  transform: translateZ(0);
}
.modal-open { overflow: hidden; }
.dialog-backdrop .dialog { pointer-events: auto; position: relative; z-index: 1; }

.dialog {
  background: var(--bg0-s);
  border: 1px solid var(--bg3);
  border-radius: var(--radius);
  width: min(560px, calc(100vw - 48px));
  max-height: calc(100vh - 96px);
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  position: relative;
}
.dialog h2 { margin: 0; font-size: 18px; color: var(--fg0); }
.dialog p { margin: 0; color: var(--fg3); line-height: 1.5; }
.dialog .actions { display: flex; gap: 10px; justify-content: flex-end; }

/* The way out some dialogs ask for. Out of the flow, so the panel's own layout
   - title, body, actions - is exactly what it would have been. */
.dialog .dialog-close { position: absolute; top: 10px; right: 10px; }

/* An invite that has gone: the same green as somebody being online, because it
   is a fact and not a warning. */
.sent-mark { display: inline-flex; align-items: center; flex: none; color: var(--green); }

/* A list that can run long gets its own box: a height it scrolls inside, so the
   sections beside it keep their place on the page instead of the page growing
   to fit somebody's listening history. */
.scroll-list {
  max-height: 340px;
  overflow-y: auto;
  /* Reserve the scrollbar's width whether or not it is there, so the rows do
     not shift sideways the moment the list is long enough to scroll. */
  scrollbar-gutter: stable;
  scrollbar-width: thin;
  scrollbar-color: var(--bg3) transparent;
}

/* ---------- the platform filter (search, and the source dialog) ---------- */

.search-filter {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 2px 0 2px;
}

.filter-button {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  border: 1px solid var(--bg3);
  border-radius: 999px;
  background: var(--bg1);
  color: var(--fg2);
  font-size: 13px;
  cursor: pointer;
}
.filter-button:hover { color: var(--fg0); border-color: var(--fg4); }

.platform-panel {
  padding: 8px;
  /* Fixed rather than absolute: this panel opens inside a dialog, and a dialog
     is centred on the viewport, so the button's rect is a viewport rect. */
  position: fixed;
}

.platform-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 8px;
  border-radius: var(--radius-small);
  color: var(--fg1);
  font-size: 14px;
  cursor: pointer;
}
.platform-row:hover { background: var(--bg2); }
.platform-row input { accent-color: var(--accent, var(--fg1)); cursor: pointer; }

/* ---------- "find a source" ---------- */

.find-source { display: flex; flex-direction: column; gap: 10px; }
.find-source-results {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 46vh;
  overflow-y: auto;
}
.find-source-row { align-items: baseline; gap: 10px; }
.find-source-provider {
  flex: none;
  min-width: 64px;
  color: var(--fg4);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.menu-notice { color: var(--gray); cursor: default; }
.find-source-toolbar { display: flex; gap: 8px; }
.find-source-toolbar .input { min-width: 0; flex: 1; }
.find-source-results { min-height: 48px; }
.search-section { display: flex; flex-direction: column; gap: 8px; }
.search-toolbar { display: flex; gap: 8px; align-items: center; }
.search-toolbar .input { min-width: 0; flex: 1; }
.search-provider-warning { color: var(--fg4); font-size: 13px; min-height: 18px; }
.search-results { display: flex; flex-direction: column; gap: 18px; }
.collection-header-row { display: flex; align-items: flex-start; gap: 16px; }
.collection-header-copy { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.collection-header-title { font-size: 1.15rem; }
.collection-header-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.collection-header-actions { margin-top: 8px; }
.playlist-header .collection-header-title { font-size: 22px; font-weight: 600; }
.playlist-header { padding-top: 20px; position: relative; }
.playlist-header .collection-header-row { min-height: 168px; align-items: center; }
.playlist-header-title-row { width: 100%; }
.playlist-header-management {
  position: absolute;
  top: 20px;
  right: 20px;
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  justify-content: flex-end;
}
.playlist-visibility { padding-top: 4px; }
.playlist-visibility .input { max-width: 220px; }
.playlist-header .playlist-detail-actions { border-top: 1px solid var(--bg1); padding-top: 16px; }
.profile-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.profile-banner { display: flex; flex-direction: column; gap: 12px; }
.profile-identity { display: flex; gap: 18px; align-items: center; }
.profile-copy { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.profile-name { font-size: 24px; font-weight: 700; color: var(--fg0); }
.friends-section { display: flex; flex-direction: column; gap: 8px; }
.friends-search-toolbar { display: flex; gap: 8px; align-items: center; }
.friends-search-toolbar .input { min-width: 0; flex: 1; }
.room-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.room-card-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.room-card-heading .section-title { margin: 0; }
.room-now-playing-row { display: flex; align-items: center; gap: 8px; border: 0; padding: 6px 0; }
.room-queue-title { font-size: 15px; font-weight: 600; }
.search-playlist-menu { display: flex; flex-direction: column; gap: 4px; max-height: min(50vh, 360px); overflow-y: auto; }
.account-error-icon { display: inline-flex; line-height: 0; }
.dialog-error { color: var(--red); }
.friends-search-loading { border: 1px solid var(--bg1); border-radius: var(--radius); background: var(--bg0-s); overflow: hidden; }
.search-result-list .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-result-list .subtitle { overflow-wrap: anywhere; }
.search-result-list .time { flex: none; }
.friends-request-loading { border: 1px solid var(--bg1); border-radius: var(--radius); background: var(--bg0-s); overflow: hidden; }
.source-row { display: flex; align-items: center; gap: 6px; }
.source-row-main { flex: 1; min-width: 0; justify-content: flex-start; text-align: left; overflow: hidden; }
.source-row-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.source-row-tag, .source-row-unplayable { margin-left: 8px; flex: none; }
.playlist-import-results { max-height: 220px; overflow: auto; }
.playlist-import-selector { display: flex; gap: 6px; border: 0; padding: 0; overflow-x: auto; }
.playlist-import-selector .tab { flex: none; }
.favourites-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.favourites-actions .btn, .playlist-actions .btn, .playlist-detail-actions .btn { flex: none; }
.playlist-detail-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.room-now-playing-row > .grow { min-width: 0; }
.room-transport { display: flex; gap: 6px; flex: none; }
.rooms-section-header { display: flex; align-items: center; gap: 10px; border: 0; padding: 0 0 10px; margin-bottom: 4px; }
.room-create-panel .form-row { align-items: flex-end; }
.rooms-empty { padding: 20px; }
.room-policy-toggle { padding: 8px 0; border: 0; }
.room-status-message { padding: 0 4px 8px; }
.provider-playlist-section-title { margin-top: 10px; }
.provider-playlist-more { min-width: 128px; }
.provider-playlist-more-row { min-height: 40px; align-items: center; }
.room-votes { padding: 0 4px 6px; }
.room-vote-buttons { border: 0; padding: 6px 0; gap: 6px; }
.room-queue-clear { border: 0; padding: 0 0 6px; }
.room-queue-clear-action { display: flex; justify-content: flex-end; }
.avatar-fallback { display: inline-flex; align-items: center; justify-content: center; color: var(--fg3); font-weight: 600; }
.share-song-placeholder { display: inline-flex; align-items: center; justify-content: center; color: var(--fg4); }
.social-row-copy { min-width: 0; }
.social-row-copy .subtitle { overflow-wrap: anywhere; }
.search-collection-copy .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-collection-copy .subtitle { overflow-wrap: anywhere; }
.source-action-icon { width: 18px; display: inline-flex; align-items: center; justify-content: center; }
.find-source-copy { flex: 1; min-width: 0; overflow: hidden; }
.find-source-copy > span:first-child { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.find-source-copy .subtitle { overflow-wrap: anywhere; }
.source-loading { min-width: 280px; }
.source-loading .skeleton-list { min-height: 150px; }
.share-song-fields { display: flex; flex-direction: column; gap: 12px; }
 .provider-playlist-import { min-width: 148px; }
.invite-dialog-fields { display: flex; flex-direction: column; gap: 10px; }
.invite-dialog-fields .list { max-height: 46vh; overflow: auto; }
.share-dialog-fields { display: flex; flex-direction: column; gap: 10px; }
.share-song-results { max-height: min(42vh, 320px); overflow: auto; }
.share-song-results .row { min-width: 0; }
.history-section { display: flex; flex-direction: column; gap: 8px; }
.history-section + .history-section { margin-top: 18px; }
.favourites-rating { flex: none; white-space: nowrap; }
.favourites-filter {
  width: min(100%, 620px);
  margin: 0 auto;
}
.favourites-filter .rating-stage { width: 100%; }
.favourites-filter-label { margin-top: 6px; color: var(--fg4); text-align: center; font-size: 12.5px; }

.history-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.history-search { flex: 1 1 280px; min-width: 0; }
.history-toolbar .tabs { flex: none; }
@media (max-width: 700px), (max-aspect-ratio: 2/3) {
  .history-toolbar { align-items: stretch; flex-direction: column; }
  .history-toolbar .tabs { overflow-x: auto; }
}
.grid-2 .scroll-list {
  height: min(340px, 38vw);
  min-height: 220px;
  max-height: none;
}
@media (max-width: 700px), (max-aspect-ratio: 2/3) {
  .grid-2 .scroll-list { height: min(340px, 78vw); }
}
.favourites-row-actions { display: inline-flex; align-items: center; gap: 10px; flex: none; }
.favourites-rating-edit { color: var(--yellow); padding: 4px; }
.favourites-rating-edit:hover { color: var(--yellow); }
 .source-popover-pending { visibility: hidden; }

.track-rating-edit { color: var(--fg4); padding: 4px; }
.track-rating-edit:hover { color: var(--yellow); }
.track-rating-value { white-space: nowrap; }
/* ---------- popovers (menus, source picker) ---------- */

.popover {
  position: absolute;
  /* Above a dialog's backdrop (50): the platform filter opens its panel from
     inside a dialog, and a panel behind the backdrop would swallow the click
     and close the dialog instead. */
  z-index: 60;
  min-width: 220px;
  background: var(--bg1);
  border: 1px solid var(--bg3);
  border-radius: var(--radius);
  padding: 6px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}

.menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  border: 0;
  border-radius: var(--radius-small);
  background: transparent;
  color: var(--fg1);
  cursor: pointer;
  text-align: left;
}
.menu-item:hover { background: var(--bg2); }
.menu-separator { height: 1px; margin: 6px 4px; background: var(--bg3); }

/* A row that carries a second, smaller action beside its label. */
.menu-row { display: flex; align-items: center; gap: 2px; }
.menu-row .menu-item { flex: 1; min-width: 0; }
.menu-item.trailing {
  flex: none;
  width: 30px;
  padding: 8px 0;
  justify-content: center;
  color: var(--fg4);
}
.menu-item.trailing:hover { background: var(--bg2); color: var(--red); }



/* ---------- player bar ---------- */

/* The round buttons keep their bubble: the circle is always there, a shade
   lighter under the mouse, lighter again while it is held down. The resting
   shade has to be a visible step from the bar behind it, or the circle is only
   there when the mouse is. */
.player .btn.flat.round {
  background: var(--bg2);
  border-color: var(--bg2);
}
.player .btn.flat.round:hover { background: var(--bg3); border-color: var(--bg3); }
.player .btn.flat.round:active { background: var(--bg4); border-color: var(--bg4); }
.player .btn.flat.round:disabled { background: var(--bg0-s); border-color: var(--bg1); opacity: 0.55; }
.player .btn.round:hover { background: var(--bg3); }
.player .btn.round:active { background: var(--bg4); }
.player .btn.round:disabled { background: var(--bg0-s); opacity: 0.55; }

/* The transport reads at a glance: what is showing is what it does. The button
   carries the state its icon is in (player.js sets data-state), so the play
   side is green and the pause side is orange. */
.player .btn.round[data-state="paused"] { color: var(--green); border-color: var(--green-dim); }
.player .btn.round[data-state="playing"] { color: var(--orange); border-color: var(--orange-dim); }
.player .btn.play svg { display: block; width: 22px; height: 22px; }
.player .btn.round:hover { border-color: currentColor; }

/* The volume: the part up to the position bar is green whatever the transport is
   doing - it says how loud, not whether it is playing. The bar's travel is the
   fader position; player.js maps it through the loudness curve before the
   element sees it. Firefox fills with ::-moz-range-progress; everywhere else
   the fill is a gradient driven by --fill, which player.js keeps up to date.
   The thumb's own shape and hover growth live with the sliders below. */
.player input[type="range"].volume::-moz-range-track { background: var(--bg3); height: var(--range-track-height); border-radius: var(--range-track-radius); }
.player input[type="range"].volume::-moz-range-progress { background: var(--green); height: var(--range-track-height); border-radius: var(--range-track-radius) 0 0 var(--range-track-radius); }
.player input[type="range"].volume::-webkit-slider-runnable-track {
  background: linear-gradient(
    to right,
    var(--green) 0 var(--fill),
    var(--bg3) var(--fill) 100%
  );
}

.player {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 0 16px;
  overflow: hidden;
  position: relative;
  z-index: 40;
}

.player-bar {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  min-width: 0;
}
 .player-track-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
 .player-source-pill { flex: 0 1 auto; max-width: 150px; }

/* The track's text is the part that gives way when the bar is tight: it shrinks
   rather than squeezing the seek and the volume past their floor, and what does
   not fit fades into the bar instead of ending in an ellipsis. A mask, so it
   works over whatever is behind it; the pill keeps its own edge, so it is not
   masked with the artist it sits beside. Its width follows the text, so a short
   title does not leave the transport stranded behind a gap of empty bar. */
.player .track-text { flex: 0 1 auto; max-width: 240px; min-width: 0; }
.player .track-text .title,
.player .track-text .subtitle {
  white-space: nowrap;
  overflow: hidden;
  mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
}
.player .track-text .title { color: var(--fg0); }
.player .track-text .subtitle { color: var(--fg4); font-size: 12.5px; }
/* A third line under the artist: this song's own download, mirrored from the
   queue's mark on its row - the same yellow, the same fraction. A bar rather than
   a word, because the queue already draws the wait and the bar is that drawing
   where the song is named. A count nobody has sweeps; a copy that is here leaves
   nothing to say and the line goes away. */
.player .track-text .track-wait {
  width: 132px;
  height: 4px;
  margin-top: 4px;
  border-radius: 2px;
  background: var(--bg2);
  overflow: hidden;
}
.player .track-text .track-wait .bar {
  height: 100%;
  width: 0;
  background: var(--yellow);
  transition: width 120ms linear;
}
.player .track-text .track-wait.sweep .bar {
  width: 35%;
  transition: none;
  animation: track-wait-sweep 1.1s ease-in-out infinite;
}
@keyframes track-wait-sweep {
  from { margin-left: -35%; }
  to { margin-left: 100%; }
}
/* When the song will not play at all: that is said in words, in the same place
   the bar would have been. */
.player .track-text .track-error {
  color: var(--red);
  font-size: 11px;
  line-height: 1.3;
  margin-top: 2px;
  white-space: nowrap;
  overflow: hidden;
  mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
}

.player .controls { display: flex; align-items: center; gap: 8px; }
/* The sound group dissolves into the bar itself, so the seek and the volume can
   hold their seven-to-three share of the row and the mute stands outside it. */
.player .controls:has(.volume) { display: contents; }
.player .controls:has(.volume) > .btn { flex: none; }
/* The room chip carries the room's name, which can be any length at all: capped,
   so a long one cannot starve the seek and the volume of the row they share. */
.player-bar > .btn[title="Open the room"],
.player-bar > .btn[title^="Listening along"] {
  max-width: 140px;
  overflow: hidden;
}
/* The seek and the volume split the free row seven to three, and they scale
   with it: the seek takes whatever the rest of the bar leaves, the sound group
   takes three tenths of the whole. The timeline is the seek's container, and it
   holds the same floor the input does: with `min-width: 0` it shrank past the
   input's own minimum, and the input spilled out over the volume beside it. */
.player .timeline { flex: 7 1 0; align-self: stretch; min-width: 90px; display: flex; align-items: center; gap: 10px; }
/* The seek and the volume hold a floor, but a low one: just above the desktop
   layout's own breakpoint the bar is at its tightest, and a 140px floor there
   made both bars wider than the room they had, so the seek rode over the
   transport. They shrink first; the buttons beside them never do. */
.player .timeline input[type="range"] { flex: 1 1 auto; width: auto; min-width: 90px; }
/* The played part of the seek wears the transport's colour: orange while it is
   playing, green while it is paused. player.js sets data-state on the bar,
   beside the play button's own. The volume is separate: it says how loud, so it
   stays green. */
.player,
.player-bar { --play-color: var(--green); }
.player[data-state="playing"],
.player-bar[data-state="playing"] { --play-color: var(--orange); }
.player[data-state="paused"],
.player-bar[data-state="paused"] { --play-color: var(--green); }
/* The fill's left end takes the track's corner; its right end is square,
   because it meets the position bar. Rounding that end too made a short fill
   (the bar near the start of the track) collapse into a pill, so the corner
   looked like it changed with the position. The gradient browsers already
   paint a square end here. */
.player .timeline input[type="range"]::-moz-range-progress { background: var(--play-color); height: var(--range-track-height); border-radius: var(--range-track-radius) 0 0 var(--range-track-radius); }
.player .timeline input[type="range"]::-moz-range-track { background: var(--bg3); height: var(--range-track-height); border-radius: var(--range-track-radius); }
/* Where ::-moz-range-progress does not exist, the played part is a gradient on
   the element, driven by --fill the way the volume's is: the Firefox pseudo
   element paints over it, so the two never disagree. */
.player .timeline input[type="range"]::-webkit-slider-runnable-track {
  background: linear-gradient(
    to right,
    var(--play-color) 0 var(--fill, 0%),
    var(--bg3) var(--fill, 0%) 100%
  );
}

.source-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 8px;
  border: 1px solid var(--bg3);
  border-radius: 999px;
  background: var(--bg1);
  color: var(--fg3);
  font-size: 11.5px;
  cursor: pointer;
  max-width: 240px;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.source-pill:hover { border-color: var(--blue); color: var(--fg1); }

/* ---------- sliders ---------- */

input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  height: var(--range-track-height);
  outline: none;
  /* The bar itself is drawn by the track, not by this element's background: a
     background cannot be rounded at its ends, and a track can. The element is
     left transparent and tall - that is the hit area. */
  background: transparent;
}
input[type="range"]::-webkit-slider-runnable-track {
  height: var(--range-track-height);
  border-radius: var(--range-track-radius);
  background: var(--bg3);
}
input[type="range"]::-moz-range-track {
  height: var(--range-track-height);
  border-radius: var(--range-track-radius);
  background: var(--bg3);
}
/* A thin bar in a control as tall as the panel it sits in, so a click anywhere
   above or below the line - right up to the top and bottom of the bar - still
   grabs the slider. */
input[type="range"],
.player .timeline input[type="range"],
.player input[type="range"].volume {
  height: auto;
  align-self: stretch;
  /* The browser gives a slider a 2px margin of its own, which shifts it off the
     column it was placed in and into its neighbour. */
  margin: 0;
  /* The thumb rides the track's ends; padding keeps it inside the control, so
     two sliders side by side cannot overlap however narrow they get. */
  padding: 0 7px;
}
/* The position is a tall rounded rectangle, not a disc: it grows taller from
   its own centre under the pointer or the keyboard, never wider, so the bar
   says where the hand is without leaving the line. */
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: var(--range-thumb-width);
  height: var(--range-thumb-height);
  /* Centred on the track, not on the element: the element is as tall as the
     panel it fills, and the track is painted down the middle of that. */
  margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) * 0.5);
  border-radius: var(--range-thumb-radius);
  background: var(--fg1);
  cursor: pointer;
  transition: height 120ms ease, margin-top 120ms ease;
}
input[type="range"]::-moz-range-thumb {
  width: var(--range-thumb-width);
  height: var(--range-thumb-height);
  border: 0;
  border-radius: var(--range-thumb-radius);
  background: var(--fg1);
  cursor: pointer;
  transition: height 120ms ease;
}
input[type="range"]:hover::-webkit-slider-thumb,
input[type="range"]:focus-visible::-webkit-slider-thumb,
input[type="range"]:active::-webkit-slider-thumb {
  height: var(--range-thumb-height-hover);
  margin-top: calc((var(--range-track-height) - var(--range-thumb-height-hover)) * 0.5);
}
input[type="range"]:hover::-moz-range-thumb,
input[type="range"]:focus-visible::-moz-range-thumb,
input[type="range"]:active::-moz-range-thumb {
  height: var(--range-thumb-height-hover);
}
@media (prefers-reduced-motion: reduce) {
  input[type="range"]::-webkit-slider-thumb,
  input[type="range"]::-moz-range-thumb { transition: none; }
}
/* The volume fills what the sound group leaves once the mute has its share; in
   the phone's grid the group is a `display: contents` and the mobile rule sizes
   it against its own column instead. */
input[type="range"].volume { flex: 3 1 0; width: auto; min-width: 90px; }

/* ---------- badges, banners, status ---------- */

.badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  border-radius: 999px;
  /* A count of things waiting: red, because it is the one colour that reads as
     "look at me" at this size. */
  background: var(--red);
  color: var(--bg0);
  font-size: 11px;
  font-weight: 700;
}

/* A count sitting in a tab, so the tab says how much is behind it. */
.tab .badge { margin-left: 6px; }

/* On a nav item the count belongs at the far edge, past the label, where it
   does not shift the text when it appears. */
.nav-item .badge { margin-left: auto; }

/* The same count on a round button - the bell - sits on its corner instead of
   beside it: the button is a fixed circle, so a chip next to the icon pushes the
   icon out of it and the badge hangs over the edge. */
.topbar .btn { position: relative; }
.topbar .btn .badge {
  position: absolute;
  top: -4px;
  right: -5px;
  margin: 0;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  font-size: 10px;
  box-shadow: 0 0 0 2px var(--bg0);
}

/* Something is waiting to be read: the bell says so in green, and only then. */
.topbar .btn.has-unread { color: var(--green); }


.banner {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 12px 22px 0;
  padding: 10px 14px;
  border: 1px solid var(--blue-dim);
  border-radius: var(--radius-small);
  background: color-mix(in srgb, var(--blue-dim) 18%, transparent);
  color: var(--fg0);
}
.banner.error { border-color: var(--red-dim); background: color-mix(in srgb, var(--red-dim) 18%, transparent); }
.banner .grow { flex: 1; }

.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex: none;
  background: var(--gray);
}
.status-dot.online { background: var(--green); }
.status-dot.offline { background: var(--bg4); }

.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 56px 24px;
  color: var(--fg4);
  text-align: center;
}
.empty .icon { font-size: 38px; color: var(--bg4); }
.empty .title { color: var(--fg2); font-size: 17px; }

.tabs { display: flex; gap: 6px; }
.tab {
  padding: 7px 14px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--fg3);
  cursor: pointer;
}
.tab:hover { background: var(--bg1); color: var(--fg1); }
.tab.active { background: var(--bg2); color: var(--fg0); }

.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.section-title { color: var(--fg3); font-size: 13px; text-transform: uppercase; letter-spacing: 0.07em; }

.tag {
  display: inline-block;
  padding: 1px 8px;
  border-radius: 999px;
  background: var(--bg2);
  color: var(--fg3);
  font-size: 11.5px;
}
.tag.green { background: color-mix(in srgb, var(--green-dim) 30%, transparent); color: var(--green); }
.tag.orange { background: color-mix(in srgb, var(--orange-dim) 30%, transparent); color: var(--orange); }

.login-corner { position: relative; }
.login-corner .btn { border-color: var(--orange-dim); color: var(--orange); }
.login-corner .btn:hover { background: var(--orange-dim); color: var(--bg0-h); }

.drag-handle { cursor: grab; color: var(--gray); user-select: none; }

/* The queue's button floats above the bottom panels: the bar keeps the song's
   actions, and the queue is one tap away wherever you are. It stays under the
   popovers and the drawer - a menu opening over it must not be covered by it. */
.queue-fab {
  position: fixed;
  right: 18px;
  bottom: var(--queue-fab-bottom, 100px); /* clears the player bar */
  z-index: 25; /* the drawer is 30, the popovers 40 */
  width: 56px;
  height: 56px;
  background: var(--bg2);
  border-color: var(--bg3);
  color: var(--fg1);
  box-shadow: 0 8px 20px rgb(0 0 0 / 35%);
  transition: right 180ms ease;
}
.queue-fab:hover { background: var(--bg3); border-color: var(--bg4); }
.queue-fab:active { background: var(--bg4); border-color: var(--bg4); }
/* Open, it steps aside to sit just left of the drawer it opened. */
.queue-fab.open { right: calc(min(420px, 92vw) + 18px); }

/* The room's bubble floats beside the queue button, not in the bar: in the row
   it sat between the mute and the star, which is nowhere anyone looks for the
   room they are in. It is a rounded rectangle rather than a disc, so the room's
   name has a line to sit on, and its offset follows the queue button's - the
   bar and the tab strip are measured in placeQueueFab, and a fixed bottom here
   landed the bubble on the bar at most phone widths. It hides while the drawer
   is open, which would otherwise cover it - the bubble is inside the bar's
   stacking context, so it cannot step aside behind the drawer the way the queue
   button does. */
.player-bar > .btn[title="Open the room"] {
  position: fixed;
  /* A gap to the left of the queue button, at its height: both sit on the
     offset placeQueueFab measures above the bottom panels. */
  right: calc(18px + 38px + 10px);
  bottom: var(--queue-fab-bottom, 100px);
  width: auto;
  height: 38px;
  max-width: 180px;
  padding: 0 14px;
  border-radius: 12px;
  background: var(--bg2);
  border-color: var(--bg3);
  color: var(--fg1);
  box-shadow: 0 8px 20px rgb(0 0 0 / 35%);
}
/* The name is the bubble's only variable part: it truncates rather than
   stretching the bubble across the bar. */
.player-bar > .btn[title="Open the room"] > span {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
body:has(.drawer:not([hidden])) .player-bar > .btn[title="Open the room"] { display: none; }

/* A desktop makes room for the queue rather than hiding under it, but only the
   page gives up the space: the bar stays the full width of the window and is
   painted over the drawer (it is z-index 40 against the drawer's 30, and the
   drawer's own list keeps 92px clear at the bottom for it), exactly as it does
   on a phone. */
.app.queue-open .main { padding-right: min(420px, 92vw); }
.main { transition: padding-right 180ms ease; }
.row.dragging { opacity: 0.5; }
.row.drag-over { border-top: 2px solid var(--blue); }
/* A row that can be dragged: its text is not selectable, or a drag leaves a
   blue smear behind it. The gesture belongs to its grip, not to the row - a row
   that takes the pointer cannot be scrolled on a phone, which is how a queue
   stopped scrolling at all once its rows were draggable. */
.row.sortable {
  user-select: none;
  -webkit-user-select: none;
}

/* The grip that picks a queue row up: the one part of the row that drags. Its
   hitbox is deliberately generous, because a finger is not a mouse, and it
   takes the gesture from the scroller so a drag does not scroll the list. It is
   written `.btn.drag-handle` so it outranks `.btn.small`'s own padding. */
.btn.drag-handle {
  flex: none;
  align-self: stretch;
  min-width: 44px;
  padding: 0 10px;
  color: var(--fg4);
  cursor: grab;
  touch-action: none;
}
.btn.drag-handle:hover { color: var(--fg2); }
.btn.drag-handle:active { cursor: grabbing; }

/* Notices stack in one column above the player bar: each new one appears
   above the last, and none of them covers another. The column is
   pointer-transparent so it never eats a click meant for the page. */
.toasts {
  position: fixed;
  bottom: calc(var(--player-height) + 18px);
  /* Centred with both edges pinned rather than with `left: 50%`: for a fixed
     element that would make the available width half the viewport, which
     squeezed a long notice into a narrow column of five lines. */
  left: 0;
  right: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  pointer-events: none;
  z-index: 60;
}

.toast {
  width: fit-content;
  /* A square, not a pill: a long notice wraps to several lines, and a pill's
     corners cut into the first and last of them. The outline is faint enough
     to read as an edge rather than a box. */
  max-width: min(440px, calc(100vw - 32px));
  background: var(--bg2);
  border: 1px solid color-mix(in srgb, var(--fg4) 35%, transparent);
  border-radius: 8px;
  padding: 10px 14px;
  color: var(--fg0);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}

/* ---------- the queue drawer ---------- */

/* The queue, slid out beside the page rather than replacing it: the bar's queue
   button asks for it, Escape or its own close button puts it away. */
.drawer {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(420px, 92vw);
  display: flex;
  flex-direction: column;
  background: var(--bg0);
  border-left: 1px solid var(--bg2);
  box-shadow: -12px 0 32px rgba(0, 0, 0, 0.35);
  z-index: 30;
}
.drawer[hidden] { display: none; }

/* The two room queues, yours and the room's own order, side by side. The Queue
   page and the Room page show both; the playbar's drawer is a column and holds
   one at a time, so it never gets this - and both the floating queue button and
   the page's inset are measured against the drawer's own width. */
.queue-panels { display: flex; align-items: flex-start; gap: 12px; }
.queue-panel { flex: 1 1 0; min-width: 0; }
.queue-panel[hidden] { display: none; }
/* Only a phone hides your own songs from the Room page's queue, so only a phone
   shows the line that says where they are. */
.room-queue-hint { display: none; color: var(--fg4); font-size: 12.5px; margin: 4px 0 6px; }
/* The heading sat on the panel's top edge and its left one: the rows below are
   inset, so give it the same air. */
.queue-panel { padding-top: 10px; }
.queue-panel > .title,
.queue-panel > .subtitle { padding-left: 12px; }
/* A room queue row says who queued it: your own songs in the player's green,
   everyone else's in the orange the room marks the playing track with. Three
   classes, because `.row .title` sets the colour too. */
.row .title.queued-mine { color: var(--green); }
.row .title.queued-other { color: var(--orange); }
/* A name that is also a way in: the artist line under a title opens their page,
   and it has to keep looking like the line it replaced. */
.subtitle > .link {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
  text-decoration: underline dotted;
  text-underline-offset: 2px;
}
.subtitle > .link:hover { color: var(--fg2); }

.drawer-head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 10px 10px 14px;
  border-bottom: 1px solid var(--bg1);
}
.drawer-head .drawer-title {
  flex: 1;
  color: var(--fg2);
  font-size: 13px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
}

/* The player stays above the drawer, so the bar is always usable; the space it
   takes is what the drawer's list keeps clear at the bottom. */
.drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 92px; }

/* ---------- phone: tall and thin ---------- */

/* The same app, laid out for a hand: the brand, the server and the sign-in
   share one line across the top, the tabs become a bar of their own above the
   player that scrolls sideways, and everything is sized for a thumb. The rule
   keys on the shape of the window, so a narrow desktop window gets it too. */
@media (max-width: 700px), (max-aspect-ratio: 2/3) {
  :root {
    font-size: 17px;
    --player-height: 112px;
  }

  .app {
    grid-template-columns: 1fr;
    grid-template-rows: auto 1fr auto auto;
  }

  /* The sidebar stops being a column: its two groups take rows of their own. */
  .sidebar { display: contents; }

  .sidebar-top {
    grid-row: 1;
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    /* Clear of the notch and the status bar on a phone. */
    padding: calc(8px + env(safe-area-inset-top, 0px)) calc(10px + env(safe-area-inset-right, 0px)) 6px
      calc(10px + env(safe-area-inset-left, 0px));
    border-bottom: 1px solid var(--bg1);
    background: var(--bg0);
  }
  .brand { flex: none; padding: 0; font-size: 17px; }
  .corner { flex: 1; padding: 0; min-width: 0; max-width: none; display: flex; align-items: center; gap: 8px; }
  .corner-row { gap: 6px; flex: 0 1 auto; min-width: 0; }
  .corner-row .btn { padding: 7px 10px; font-size: 0.94rem; }
  /* Room is short across a phone, so the account sits beside the server rather
     than under it: the icon and the name, no handle. */
  .corner .account-card { margin: 0; width: auto; flex: 0 0 auto; padding: 4px 7px; gap: 6px; }
  .corner .account-card .handle { display: none; }
  .corner .account-card .name { font-size: 0.9rem; max-width: 24vw; }

  .main { grid-row: 2; }
  .content { padding-bottom: 12px; }
  .page { width: 100%; padding: 14px; gap: 14px; }
  /* Two buttons side by side do not fit a phone: they stack, which is also what
     keeps the row from setting the page wider than the screen. */
  .form-row { flex-wrap: wrap; }
  .topbar { padding: 10px 14px; }
  .topbar h1 { font-size: 1.05rem; }

  /* Tabs: a bar of their own, above the player, scrolling left to right. The
     bar of them is wider than the screen, so it keeps a scrollbar to say so,
     and an arrow at whichever edge still has somewhere to go. */
  .nav-wrap {
    grid-row: 3;
    position: relative;
    display: block;
    flex: none;
    min-width: 0;
    min-height: 0;
    overflow: hidden;
  }
  .nav {
    grid-row: auto;
    display: flex;
    gap: 6px;
    padding: 6px 10px 10px;
    overflow-x: auto;
    overflow-y: hidden;
    border-top: 1px solid var(--bg1);
    background: var(--bg0);
    scrollbar-width: none;              /* the drawn one below replaces it */
    -webkit-overflow-scrolling: touch;
  }
  .nav::-webkit-scrollbar { display: none; }

  /* The strip's scrollbar, drawn: visible so the bar says it goes further,
     never grabbable and never lit up under the mouse, because the strip itself
     is what you drag. */
  .nav-indicator {
    position: absolute;
    bottom: 5px;
    height: 3px;
    border-radius: 2px;
    background: var(--bg3);
    pointer-events: none;
  }
  .nav-wrap:not([data-scrollable="true"]) .nav-indicator { display: none; }

  /* A vignette with a small arrow in it, on the side that has more tabs. */
  .nav-wrap[data-scrollable="true"][data-more-left="true"] .nav-edge.left,
  .nav-wrap[data-scrollable="true"][data-more-right="true"] .nav-edge.right {
    display: flex;
  }
  .nav-edge {
    position: absolute;
    top: 0;
    bottom: 8px;
    width: 42px;
    align-items: center;
    border: 0;
    padding: 0;
    cursor: pointer;
    color: var(--fg2);
    background: none;
  }
  .nav-edge.left {
    left: 0;
    justify-content: flex-start;
    padding-left: 3px;
    background: linear-gradient(to right, var(--bg0) 42%, transparent);
  }
  .nav-edge.right {
    right: 0;
    justify-content: flex-end;
    padding-right: 3px;
    background: linear-gradient(to left, var(--bg0) 42%, transparent);
  }
  .nav-edge:hover { color: var(--fg0); }
  .nav-item {
    width: auto;
    flex: none;
    margin: 0;
    padding: 9px 13px;
    font-size: 1rem;
    white-space: nowrap;
  }
  .nav-item .icon svg { width: 17px; height: 17px; }

  /* The player becomes two bars: the seek and volume above with the transport
     at the right edge, where a right thumb already is, and the song itself
     below. The tab strip already sits above both. */
  /* A phone's own furniture: the home indicator would sit over the bar without
     the safe-area inset, which needs viewport-fit=cover in the page's meta. */
  .player { grid-row: 4; padding: 0 12px env(safe-area-inset-bottom, 0px); border-top: 0; }
  .player-bar {
    display: grid;
    /* The seek and the volume split what is left of the row seven to three, and
       they hold a floor: past it the row does not squeeze them further, the
       track's text gives way instead (it truncates). The shuffle travels with
       the star on the row below rather than leading the row: a column of its
       own in front of the seek left a band of empty bar beside the playbar's
       edge, and a phone has no room to waste. */
    grid-template-columns: minmax(90px, 4fr) minmax(90px, 3fr) minmax(90px, 3fr) auto auto auto auto auto;
    grid-template-areas:
      "seek   seek   volume prev   play next   misc room"
      "art    text   text   shuffle star queue misc room";
    align-items: center;
    /* Tight columns: the transport's buttons are circles with the glyph well
       inside them, so the gap between them reads wider than it is, and the
       space left over is what the seek and the volume are for. */
    gap: 8px 4px;
    padding: 8px 0 10px;
  }
  /* The transport travels as separate buttons; the volume is the slider beside
     the seek, and its mute button stands down. */
  .player .controls:not(:has(.volume)) { display: contents; }
  /* The prev sits at the end of its column: the room chip shares that column on
     the row below, so a wide chip would otherwise leave the button stranded at
     the far side of it. */
  .player .controls:not(:has(.volume)) > *:nth-child(1) { grid-area: prev; justify-self: end; }
  .player .controls:not(:has(.volume)) > *:nth-child(2) { grid-area: play; }
  .player .controls:not(:has(.volume)) > *:nth-child(3) { grid-area: next; }
  .player .controls:has(.volume) { display: contents; }
  /* A phone changes its own volume, so the mute button gives way: the slider
     sits where it was, beside the seek. */
  .player .controls:has(.volume) > .btn { display: none; }
  .player input[type="range"].volume { grid-area: volume; display: block; min-width: 90px; width: 100%; }
  .player .timeline { grid-area: seek; min-width: 0; }
  .player .art { grid-area: art; width: 52px; height: 52px; }
  .player .track-text { grid-area: text; width: auto; min-width: 0; }
  .player .track-text .title { font-size: 1rem; }
  .player .track-text .subtitle {
    font-size: 0.85rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .player > .player-bar > .btn[title="Favourite"] { grid-area: star; }
  .player > .player-bar > .btn[title="Song actions"] { grid-area: queue; }
  .player > .player-bar > .btn[title="Open the room"] { grid-area: room; }
  .player > .player-bar > .btn[title^="Listening along"] { grid-area: misc; }
  .player > .player-bar > .btn[title^="Shuffle"] { grid-area: shuffle; }
  /* A phone row holds less than a desktop one: the same floor as its columns. */
  .player .timeline input[type="range"],
  .player input[type="range"] { min-width: 90px; width: 100%; }

  /* A phone cannot hold two queue columns. The Room page's queue shows the
     room's own order alone: the Queue page keeps both because its tabs can hide
     one, and this card has no tabs. */
  .room-queue .queue-panel[data-panel="mine"] { display: none; }
  .room-queue .room-queue-hint { display: block; }
  .room-votes { padding-inline: 0; }
  .room-vote-buttons { flex-wrap: wrap; }
  .room-header { padding: 12px; }
  .account-profile-row { align-items: flex-start; flex-wrap: wrap; }
  .account-profile-row > .grow { flex: 1 1 180px; }
  .account-profile-row > .btn { margin-left: 68px; }
  .profile-identity { align-items: flex-start; }
  .profile-actions { gap: 8px; }
  .profile-actions .btn { flex: 1 1 140px; min-width: 0; }

  /* A phone keeps the queue button where it is when the drawer opens: stepping
     it aside means stepping it off a screen this narrow. It rides above the
     drawer instead, and the room's bubble stays beside it. */
  .queue-fab.open { right: 18px; z-index: 35; }
  body:has(.drawer:not([hidden])) .player-bar > .btn[title="Open the room"] { display: inline-flex; }
}

/* The transport is the last thing that may leave the screen, so everything else
   gives way before it does. At this width the shuffle is the first to go: it
   shares the row with the star, and the row is what the bars and the transport
   are competing for. */
@media (max-width: 560px) {
  .player > .player-bar > .btn[title^="Shuffle"] { display: none; }
  .player-bar {
    grid-template-columns: minmax(56px, 4fr) minmax(40px, 3fr) minmax(40px, 3fr) auto auto auto auto auto;
    /* The bars sit closer together than the buttons do: what lies between them
       is the thumb's overhang, which nothing else needs. */
    gap: 8px 0;
  }
  /* The volume has a floor of its own, and it is the one that overflowed its
     column into the prev button: a bar shrinks to fit before it overlaps. */
  .player input[type="range"].volume { min-width: 56px; }
  .player .timeline input[type="range"],
  .player input[type="range"] { min-width: 56px; padding: 0 4px; }
}

/* A narrow phone cannot hold the shuffle beside the two bars at their floor.
   It steps out, and the grid drops its column with it - a hidden item still
   leaves the column it was named in. The Queue view has its own Shuffle.

   The bars give way first and the buttons tighten, because the transport is the
   last thing that may leave the screen. */
@media (max-width: 390px) {
  .player > .player-bar > .btn[title^="Shuffle"] { display: none; }
  .player-bar {
    grid-template-columns: minmax(40px, 7fr) minmax(28px, 3fr) auto auto auto auto auto;
    grid-template-areas:
      "seek volume prev play next misc room"
      "art  text   text star queue misc room";
  }
  .player .timeline input[type="range"],
  .player input[type="range"] { min-width: 40px; }
  .player input[type="range"].volume { min-width: 40px; }
  .player .art { width: 40px; height: 40px; }
  .player .source-pill { max-width: 100%; font-size: 0.8rem; }
  .player .btn.flat.round { width: 40px; height: 40px; }
  .friend-status-row { min-width: 0; }
  .friend-status-row > .social-row-copy { min-width: 0; }
  .friend-status-row > .subtitle { overflow-wrap: normal; }
  .player-source-pill { flex: 0 1 auto; max-width: 100%; }
  .file-import-fields { min-width: 0; }
  .file-import-fields .import-actions { flex-wrap: wrap; }
  .file-import-fields .import-actions .btn { flex: 1 1 140px; }
  .file-import-fields .import-report { max-height: 30vh; }
  .player-track-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
  /* Above the tab strip, which is the topmost panel down there. */
  .queue-fab { right: 14px; bottom: var(--queue-fab-bottom, 196px); }
  .player-bar > .btn[title="Open the room"] { right: calc(14px + 38px + 10px); }
  /* A phone keeps the overlay: there is no room to give, and the drawer covers
     the page. The drawer also covers the corner the button lives in, so the way
     back has to ride above it - a desktop has already moved it clear instead. */
  .app.queue-open { padding-right: 0; }
  .queue-fab.open { right: 14px; z-index: 35; }

  /* Lists: room for a thumb, and the tail of a row is what gets dropped. */
  .list .row { padding: 11px 12px; gap: 11px; }
  .input, select.input, textarea.input { padding: 11px 13px; }
  .btn { padding: 9px 15px; }
  .dialog { width: calc(100vw - 20px); padding: 18px; }
  .popover { max-width: calc(100vw - 20px); }
  .menu-item { padding: 11px 12px; }
  .find-source-toolbar { flex-wrap: wrap; }
  .find-source-toolbar .btn { flex: 1 0 96px; }
  .queue-page-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
  .source-row-main { min-width: 0; }
  .source-row .btn.small { flex: none; }
  .find-source-results { max-height: 42vh; }
  .find-source-row { min-width: 0; }
  .queue-drawer-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
  .upload-association-fields { min-width: 0; }
  .upload-association-body .upload-search-panel { min-width: 0; }
  .toasts { bottom: calc(var(--player-height) + 12px); }
}

/* Below this even the extra controls cannot fit beside the transport: the star
   and the song menu step out, and the queue's own drawer keeps the menu. */
@media (max-width: 330px) {
  .player > .player-bar > .btn[title="Favourite"],
  .player > .player-bar > .btn[title="Song actions"] { display: none; }
}

/* A member's file, in the Listening panel: the room is waiting on it, so it is
   worth a bar rather than a word. */
.member-load { width: 96px; flex: none; }

/* What the queue holds for playing with the server out of reach: a line above
   the lists, and a mark on each song this device already has. */
.offline-line { font-size: 12px; color: var(--fg4); padding: 2px 4px 6px; }
.offline-line.offline { color: var(--orange); }
.held { align-self: center; color: var(--fg4); display: inline-flex; }
.held.ready { color: var(--green); }
/* A song on its way to this device: the download arrow, filling with yellow
   from the bottom up once the bytes give it a fraction to draw - the same ink
   the theme's own volume and timeline use, so nothing new has to be learned to
   read it. The second copy of the glyph is the ink, clipped to how far the
   download has got; without a count it stays at zero, so the arrow is grey. An
   arrow that animated while nobody could measure it claimed progress it did not
   have. */
.held .dl { display: inline-block; position: relative; width: 14px; height: 14px; }
.held .dl svg { display: block; }
.held .dl .ink {
  position: absolute;
  inset: 0;
  color: var(--yellow);
  clip-path: inset(calc(100% - var(--fill, 0%)) 0 0 0);
}
