/* jradio - the web remote. Dense grid, one accent, two themes.

   Dark is the default, not the system's preference: this page is a remote for
   a box that lives in a dark room, and the panel it mirrors is dark too. So
   the bare :root holds the whole dark palette and only an explicit choice -
   [data-theme="light"], written by the toggle into localStorage - swaps it.
   No colour is declared inside the light block alone: a token defined only
   there would have no value in the default state. */
:root {
  color-scheme: dark;
  /* System faces only: the device serves this page itself, and a hosted font
     would mean it looks different when there is no internet. */
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
               "Helvetica Neue", Arial, sans-serif;
  font-synthesis: none;

  --page: #0c0f13;
  --surface: #14181e;
  --surface-2: #1a1f26;
  --surface-3: #222932;
  --line: #252d37;
  --line-soft: #1e252d;
  --text: #e9ecf1;
  --text-2: #c2c9d3;
  --muted: #8b95a3;
  --accent: #f0a93c;
  --accent-2: #ffc369;
  --accent-soft: #2b2114;
  --accent-line: #4a3a1e;
  --switch-line: var(--accent-line);
  --on-accent: #191203;
  --ok: #4cc38a;
  --bad: #f0785a;
  --bad-soft: #2a1a15;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .45);
  --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 30px rgba(0, 0, 0, .35);
  --focus: rgba(240, 169, 60, .5);

  --r: 10px;
  --r-lg: 14px;
  --row: 38px;
  --bar: 46px;
  --cover: 120px;
  /* The player screen's stand-in tile, hex for hex: the same face in the
     browser as on the panel. */
  --art-tile: #18242e;
  --art-edge: #26343f;
  --art-mark: #3e5060;
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}

:root[data-theme="light"] {
  color-scheme: light;
  /* The panel is dark and has one palette; a page that can be light needs the
     same tile in its own terms, or the cover leaves a dark hole in it. */
  --art-tile: #eef1f6;
  --art-edge: #dde3ea;
  --art-mark: #9fadbb;
  --page: #eceff3;
  --surface: #ffffff;
  --surface-2: #f4f6f9;
  --surface-3: #e9edf2;
  --line: #d8dee6;
  --line-soft: #e6eaf0;
  --text: #12161c;
  --text-2: #39424f;
  --muted: #6c7686;
  --accent: #a5620a;
  --accent-2: #8a5108;
  --accent-soft: #f8ecd9;
  --accent-line: #e3c79b;
  --switch-line: var(--line);
  --on-accent: #ffffff;
  --ok: #1b7048;
  --bad: #a83a1c;
  --bad-soft: #fbeae5;
  --shadow-sm: 0 1px 2px rgba(18, 22, 28, .08);
  --shadow: 0 1px 2px rgba(18, 22, 28, .06), 0 10px 26px rgba(18, 22, 28, .07);
  --focus: rgba(165, 98, 10, .45);
}

* { box-sizing: border-box; }

html {
  min-width: 320px;
  background: var(--page);
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background: var(--page);
  color: var(--text);
  font-size: 15px;
  line-height: 1.45;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 {
  margin: 0;
  letter-spacing: -.015em;
  text-wrap: balance;
}

p { margin: 0; }

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

button, a, label { -webkit-tap-highlight-color: transparent; }

button { background: none; border: 0; padding: 0; cursor: pointer; }

button:disabled { cursor: not-allowed; opacity: .45; }

:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

[hidden] { display: none !important; }

.mono, .num {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
}

/* In the markup for a screen reader, not for the screen: the source is
   already named by the active tab, and there is nowhere to say it twice. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.eyebrow {
    font-size: .68rem;
  font-weight: 650;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ----------------------------------------------------------------- header */

.site-header {
  position: sticky;
  top: 0;
  z-index: 30;
  flex: none;
  min-height: var(--bar);
  border-bottom: 1px solid var(--line);
  background: var(--surface);
}

.header-inner,
.list-head,
.list-items,
.player-main,
.player-error,
.command-status {
  width: min(100%, 1120px);
  margin-inline: auto;
}

.header-inner {
  min-height: var(--bar);
  display: flex;
  align-items: center;
  gap: .6rem;
  padding-inline: .75rem;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: .45rem;
  flex: none;
    font-size: .95rem;
  font-weight: 700;
  letter-spacing: -.02em;
  text-decoration: none;
  color: var(--text);
}

/* The player's own mark: the green tuning eye from the splash screen, drawn
   rather than fetched - one 24 px logo is not worth a file, a route and a
   request, and an SVG stays sharp on any panel. Its colours are its own, so
   nothing here paints over them with currentColor. */
.brand-mark {
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  flex: none;
}

.brand-mark svg {
  width: 24px;
  height: 24px;
}

.eye-wedge { fill: #0d3b22; }
.eye-pupil { fill: #0b1710; }

.eye-ring,
.eye-ticks {
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.4;
  stroke-linecap: round;
}

.brand-word { display: inline; }

.header-actions {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: .25rem;
}

.socket-state {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--muted);
  white-space: nowrap;
}

.socket-state::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--muted);
  flex: none;
}

.socket-state.is-online::before { background: var(--ok); }
.socket-state.is-offline::before { background: var(--bad); }
.socket-state.is-offline { color: var(--bad); }

.icon-button {
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
  border-radius: 8px;
  border: 1px solid transparent;
  color: var(--muted);
  text-decoration: none;
  flex: none;
}

.icon-button:hover { color: var(--text); background: var(--surface-2); }

/* Two letters where the other buttons carry an icon: the flasher site's
   language switch, which names the language it goes to. */
.language-toggle {
  font: inherit;
  font-size: .72rem;
  font-weight: 700;
  letter-spacing: .04em;
  background: none;
  cursor: pointer;
}

.icon-button svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The theme icon shows the half that is not on screen right now. */
.theme-toggle .icon-moon { display: none; }
.theme-toggle .icon-sun { display: block; }

:root[data-theme="light"] .theme-toggle .icon-moon { display: block; }
:root[data-theme="light"] .theme-toggle .icon-sun { display: none; }

/* ------------------------------------------------------- source switcher */

.source-tabs {
  display: flex;
  gap: 2px;
  padding: 2px;
  border: 1px solid var(--line-soft);
  border-radius: 9px;
  background: var(--surface-2);
  overflow-x: auto;
  scrollbar-width: none;
}

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

.source-tab {
  height: 28px;
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  padding-inline: .7rem;
  border-radius: 7px;
  font-size: .82rem;
  font-weight: 500;
  color: var(--muted);
  white-space: nowrap;
}

.source-tab:hover:not(:disabled) { color: var(--text); }

/* Shown but not startable: the device has no network, and the two sources that
   stream from the internet have nothing to play. Kept on screen rather than
   removed so the row does not reshuffle every time the Wi-Fi drops. */
.source-tab.is-unavailable {
  opacity: .45;
  cursor: not-allowed;
}

.source-tab.is-active {
  background: var(--surface);
  color: var(--text);
  font-weight: 600;
  box-shadow: var(--shadow-sm);
}

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

/* The header and the player panel stay put and only the list scrolls: the
   panel is what the page is for, and it would otherwise scroll away with the
   list. The height comes from a flex column on body rather than from
   subtracting the header - on a narrow screen the header wraps its tabs onto
   a second row and any subtraction misses. */
.player-page {
  height: 100vh;
  height: 100dvh;
  /* body's min-height: 100vh would outrank the height above, and on Android
     Chrome 100vh is the viewport with the address bar hidden - taller than
     the one on screen while the bar shows. The page then ran that much past
     the bottom edge with overflow hidden, and the last row of the list could
     not be scrolled to. iOS Safari never showed it. */
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.app-shell {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--surface);
}

.media-list {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.list-head {
  display: flex;
  align-items: center;
  gap: .6rem;
  padding: .45rem .75rem;
  border-bottom: 1px solid var(--line-soft);
  background: var(--surface);
}

.list-head h2 {
  font-size: .85rem;
  font-weight: 650;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A path reads as a path, not as a heading: a file listing carries /usb0/…
   where a station list carries a title, and the monospaced face keeps the two
   apart. */
.media-list[data-kind="files"] .list-head h2 {
  font-family: var(--mono);
  font-size: .78rem;
  font-weight: 400;
  color: var(--text-2);
}

.list-count {
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--muted);
  flex: none;
}

.list-search {
  margin-left: auto;
  width: min(220px, 42vw);
  height: 28px;
  padding: 0 .6rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface-2);
  color: var(--text);
  font-size: .82rem;
}

.list-search::placeholder { color: var(--muted); }

.list-items {
  flex: 1;
  min-height: 0;
  margin-block: 0;
  padding: 0;
  list-style: none;
  overflow-y: auto;
  counter-reset: row;
  background: var(--surface);
}

/* Two faces of one control: words where there is room, the home screen's own
   marks where there is not. */
.source-icon { display: none; }

.source-icon path,
.source-icon circle,
.source-icon rect { fill: currentColor; }

.source-icon .stroke {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.source-icon .thick { stroke-width: 2.4; }

.list-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .5rem;
  padding: 1.4rem .75rem;
  font-size: .85rem;
  color: var(--muted);
}

.spinner {
  width: 15px;
  height: 15px;
  flex: none;
  border: 2px solid var(--line);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin .7s linear infinite;
}

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

/* Still says "working", without the motion. */
@media (prefers-reduced-motion: reduce) {
  .spinner { animation: none; border-top-color: var(--accent); opacity: .7; }
}

.list-item {
  width: 100%;
  min-height: var(--row);
  display: flex;
  align-items: center;
  gap: .65rem;
  padding: 0 .75rem;
  border-bottom: 1px solid var(--line-soft);
  text-align: left;
  counter-increment: row;
}

/* The row number is drawn by a counter rather than sent by the device: the
   indices run consecutively anyway, and another field in the reply would cost
   room in the frame. */
.list-item::before {
  content: counter(row);
  width: 1.7rem;
  flex: none;
  text-align: right;
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--muted);
}

/* The "up" row carries no number - it is navigation, not an entry. */
.list-item:not([data-index]) { counter-increment: none; }
.list-item:not([data-index])::before { content: "\2191"; }

.list-item:hover:not(:disabled) { background: var(--surface-2); }

/* The :hover arm is not decoration: Android Chrome leaves a tapped row hovered,
   and the hover rule above outranks a bare .is-active, so the row just chosen
   lost its highlight to the plain hover grey. Safari on the iPhone does not
   keep the hover and always showed it. */
.list-item.is-active,
.list-item.is-active:hover:not(:disabled) { background: var(--accent-soft); }
.list-item.is-active::before { color: var(--accent); }

/* A station's picture, in front of its name: a square tile in the colours of
   the player's empty cover, and with its note when the station has no
   picture, so the names line up and a missing picture looks like the
   player's rather than like a hole. */
.list-item-icon {
  flex: none;
  width: 28px;
  height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border: 1px solid var(--art-edge);
  border-radius: 5px;
  background: var(--art-tile);
}
/* The note is the track-art-mark path in index.html, as a mask so it takes
   the theme's --art-mark like the big one does. A change to one belongs in
   the other. */
.list-item-icon:empty::before {
  content: "";
  width: 55%;
  height: 55%;
  background: var(--art-mark);
  -webkit-mask: var(--note-mask) center / contain no-repeat;
  mask: var(--note-mask) center / contain no-repeat;
  --note-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9.6 5.2 19.4 3v2.7L9.6 7.9Z'/%3E%3Crect x='9.6' y='5.2' width='1.7' height='12.4'/%3E%3Crect x='17.7' y='3' width='1.7' height='12.6'/%3E%3Cellipse cx='7.7' cy='17.4' rx='3' ry='2.6'/%3E%3Cellipse cx='15.8' cy='15.4' rx='3' ry='2.6'/%3E%3C/svg%3E");
}
.list-item-icon img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.list-item-label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: .9rem;
}

.list-item.is-active .list-item-label { font-weight: 600; }

.list-item.is-directory .list-item-label::before {
  content: "";
  display: inline-block;
  width: 12px;
  height: 9px;
  margin-right: .45rem;
  border: 1.4px solid var(--muted);
  border-top-width: 3px;
  border-radius: 2px;
}

/* A playlist opens like a folder but is not one - it names tracks from
   anywhere on the drive - so it gets the stack of lines a list is drawn with
   instead of the folder's tab. Same specificity as the rule above and later in
   the file, which is what lets it win on a row that carries both classes. */
.list-item.is-playlist .list-item-label::before {
  border: 0;
  border-radius: 0;
  background:
    linear-gradient(var(--muted) 0 0) no-repeat 0 0 / 12px 1.6px,
    linear-gradient(var(--muted) 0 0) no-repeat 0 3.7px / 12px 1.6px,
    linear-gradient(var(--muted) 0 0) no-repeat 0 7.4px / 12px 1.6px;
}

/* Beside a name rather than in the meta column: it qualifies the name. */
.list-item-note {
  margin-left: .5em;
  font-weight: 400;
  font-size: .85em;
  color: var(--muted);
}

.list-item-meta {
  flex: none;
  padding: 1px 5px;
  border: 1px solid var(--line);
  border-radius: 5px;
  font-family: var(--mono);
  font-size: .66rem;
  color: var(--muted);
}

/* The playing mark is three bars rather than a word: the right-hand column is
   narrow, and the row's state has to read without taking room from the
   name. */
.active-marker {
  flex: none;
  display: none;
  align-items: flex-end;
  gap: 2px;
  height: 12px;
  font-size: 0;
}

.list-item.is-active .active-marker { display: flex; }
.list-item.is-active .list-item-meta { display: none; }

.active-marker::before,
.active-marker::after,
.active-marker > i {
  content: "";
  display: block;
  width: 2px;
  border-radius: 1px;
  background: var(--accent);
  transform-origin: bottom;
  animation: vu .9s ease-in-out infinite;
}

.active-marker::before { height: 6px; animation-delay: -.2s; }
.active-marker::after { height: 8px; animation-delay: -.55s; }
.active-marker > i { height: 12px; }

/* On a directory the mark sits on a row that opens rather than plays, so the
   animation has nothing to say there. */
.list-item.is-directory .active-marker::before,
.list-item.is-directory .active-marker::after,
.list-item.is-directory .active-marker > i { animation: none; }

/* A media server lists what it holds, including tracks this firmware has no
   decoder for and files that are not audio at all. They stay on the list -
   hiding them looks like a server with files missing - but they are dimmed and
   refuse the click, and the marker slot says why instead of pretending the row
   is about to play. */
/* While the device is opening a container its rows are still the ones it is
   showing, so they stay - dimmed, and deaf to clicks, because a list that looks
   current under a "loading" line invites a second press that only queues
   another browse. */
.list-items.is-stale { opacity: .5; pointer-events: none; }

.list-item.is-unplayable { opacity: .45; cursor: default; }
.list-item.is-unplayable .active-marker { display: none; }
.list-item.is-unplayable::after {
  content: 'Не поддерживается';
  margin-left: auto;
  font-size: .72rem;
  letter-spacing: .02em;
  white-space: nowrap;
}

@keyframes vu {
  0%, 100% { transform: scaleY(.45); }
  50% { transform: scaleY(1); }
}

@media (prefers-reduced-motion: reduce) {
  .active-marker::before,
  .active-marker::after,
  .active-marker > i { animation: none; }
}

.empty-state {
  padding: 1.6rem .75rem;
  text-align: center;
  color: var(--muted);
  font-size: .88rem;
}

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

.player-bar {
  flex: none;
  position: relative;
  border-bottom: 1px solid var(--line);
  background: var(--surface-2);
}

.player-main {
  display: flex;
  align-items: flex-start;
  gap: .9rem;
  padding: .8rem .75rem;
}

/* The cover sets the panel's height, and the column beside it is centred
   against the cover so the panel keeps that height whether the station has an
   icon or not. */
.player-body {
  flex: 1;
  min-width: 0;
  min-height: var(--cover);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: .65rem;
}

.track-copy {
  display: flex;
  align-items: flex-start;
  gap: .8rem;
  min-width: 0;
}

.track-art {
  position: relative;
  width: var(--cover);
  height: var(--cover);
  flex: none;
  border: 1px solid var(--art-edge);
  border-radius: 12px;
  overflow: hidden;
  background: var(--art-tile);
}

.track-art-mark {
  position: absolute;
  inset: 0;
  margin: auto;
  /* The proportion the panel draws it at: a 48-pixel glyph in a 96-pixel
     tile. */
  width: 45%;
  height: 45%;
  fill: var(--art-mark);
}

/* Belt and braces: the cover is opaque and covers the mark anyway, but a
   picture that ever arrives with transparency should not show it through. */
.track-art:has(> img:not([hidden])) .track-art-mark { display: none; }

.track-cover {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: var(--art-tile);
}

.track-lines { min-width: 0; flex: 1; }

.track-lines > * {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#track-title {
  font-family: inherit;
  font-size: 1.6rem;
  font-weight: 650;
  letter-spacing: -.015em;
}

.track-artist {
  font-size: 1.05rem;
  color: var(--text-2);
}

.track-context {
  font-size: .85rem;
  color: var(--muted);
}

/* The two lines under the title come and go with what the stream sends: one
   station names the performer and the track, the next only itself. Their space
   is held either way, because the panel's height is the list's top edge - a
   station that says less pulled the list up by two lines under the pointer,
   and the row that got clicked was not the row that was aimed at. `1lh` is one
   line box of that element's own text, so a held line is exactly as tall as a
   filled one. */
.track-artist:empty,
.track-context:empty { min-height: 1lh; }

/* The transport sits on its own line, centred under the copy: it is the one
   thing on the panel that is reached for without reading first. */
.player-transport {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .9rem;
  flex-wrap: wrap;
}

/* The position line and the volume close the panel. With no position line -
   a radio stream - the volume still keeps to the right-hand corner. */
.player-bottom {
  display: flex;
  align-items: center;
  gap: 1rem;
  min-height: 22px;
}

.player-controls {
  display: flex;
  align-items: center;
  gap: .35rem;
  flex: none;
}

.player-controls button {
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  border-radius: 8px;
  color: var(--text-2);
  flex: none;
}

/* Pointer only. A tap on a phone leaves :hover stuck on the button until
   something else is touched, and this rule outranks the marks below - the
   heart that had just been filled kept the plain text colour, so the reaction
   read as if it had not registered. */
@media (hover: hover) {
  .player-controls button:hover:not(:disabled) { background: var(--surface-3); color: var(--text); }
}

.player-controls button svg { width: 24px; height: 24px; fill: currentColor; }

.play-toggle {
  width: 58px !important;
  height: 58px !important;
  border-radius: 18px !important;
  background: var(--accent) !important;
  color: var(--on-accent) !important;
}

.play-toggle svg { width: 28px; height: 28px; }
.play-toggle .icon-pause { display: none; }
.play-toggle.is-playing .icon-play { display: none; }
.play-toggle.is-playing .icon-pause { display: block; }

.like-track svg,
.dislike-track svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linejoin: round;
}

/* Same specificity as the hover rule above and later in the file, which is what
   lets a mark that is on survive a pointer resting on its button. */
.player-controls button.like-track.is-liked { color: var(--accent); }
.like-track.is-liked svg { fill: currentColor; stroke: none; }
.player-controls button.dislike-track.is-disliked { color: var(--bad); }

/* The wide copy of the slash knocks a gap out of the heart's outline: without
   it the two outlines merge into a smudge - the same reason the device's
   bitmap erases before it strikes. */
.dislike-track .slash-gap { stroke: var(--surface-2); stroke-width: 4; }
.dislike-track .slash { stroke: currentColor; stroke-width: 1.7; }

.track-progress {
  display: flex;
  align-items: center;
  gap: .6rem;
  flex: 1;
  min-width: 0;
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--muted);
}

.progress-rail {
  position: relative;
  flex: 1;
  height: 4px;
  border-radius: 2px;
  background: var(--surface-3);
}

.progress-fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: 0;
  border-radius: 2px;
  background: var(--accent);
}

.progress-seek {
  position: absolute;
  inset: -8px 0;
  width: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.progress-seek:disabled { cursor: default; }
.progress-rail.is-seekable .progress-fill::after {
  content: "";
  position: absolute;
  top: 50%;
  right: -5px;
  width: 10px;
  height: 10px;
  margin-top: -5px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 2px var(--surface-2);
}

.volume-control {
  display: flex;
  align-items: center;
  gap: .45rem;
  flex: none;
  width: 196px;
  margin-left: auto;
  color: var(--muted);
}

/* The tone button closes the volume row. Lit while the sound is not flat,
   so a bass lift left on is seen without opening anything. */
.tone-open {
  flex: none;
  width: 30px;
  height: 30px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid transparent;
  border-radius: 8px;
  background: none;
  color: var(--muted);
}
.tone-open svg {
  width: 18px;
  height: 18px;
  fill: var(--surface);
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}
.tone-open:hover { background: var(--surface-2); }
.tone-open.is-active { color: var(--accent); border-color: var(--accent-line); }

/* The panel. On a wide screen a box placed under its button by app.js; on a
   phone - the media query below - a sheet from the bottom, full width, with
   controls a thumb can hit. */
.tone-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
  background: transparent;
}
.tone-panel {
  position: fixed;
  z-index: 41;
  width: 340px;
  max-width: calc(100vw - 16px);
  padding: .9rem 1rem 1rem;
  display: flex;
  flex-direction: column;
  gap: .85rem;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  box-shadow: var(--shadow);
  color: var(--text);
}
.tone-panel[hidden], .tone-backdrop[hidden] { display: none; }
.tone-head { display: flex; align-items: center; justify-content: space-between; }
.tone-head h2 { margin: 0; font-size: 1rem; }
.tone-close {
  width: 32px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--muted);
}
.tone-close svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.tone-close:hover { background: var(--surface-2); }
/* Name and reading on one line, the slider under them at full width. */
.tone-slider {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: .35rem .5rem;
  align-items: center;
}
.tone-slider label { font-size: .9rem; }
.tone-slider output { font-variant-numeric: tabular-nums; color: var(--muted); font-size: .85rem; }
.tone-slider input {
  grid-column: 1 / -1;
  width: 100%;
  margin: 0;
  accent-color: var(--accent);
}
.tone-loudness { display: flex; flex-direction: column; gap: .45rem; font-size: .9rem; }
/* Four buttons rather than a list: on a phone a select is two taps and a
   system picker for a choice that fits on one line. */
.tone-choices { display: grid; grid-template-columns: repeat(4, 1fr); gap: .3rem; }
.tone-choices button {
  min-height: 34px;
  padding: 0 .2rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface-2);
  color: var(--text);
  font-size: .8rem;
}
.tone-choices button[aria-checked="true"] {
  border-color: var(--accent);
  background: var(--accent-soft);
  color: var(--accent);
  font-weight: 600;
}
.tone-switch { display: flex; align-items: center; justify-content: space-between; font-size: .9rem; }
.tone-switch[hidden] { display: none; }
.tone-note { margin: 0; color: var(--muted); font-size: .78rem; line-height: 1.35; }
.tone-reset { align-self: flex-start; }

@media (max-width: 720px) {
  .tone-backdrop { background: rgba(0, 0, 0, .45); }
  .tone-panel {
    left: 0;
    right: 0;
    bottom: 0;
    top: auto;
    width: auto;
    max-width: none;
    padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
    border-radius: var(--r-lg) var(--r-lg) 0 0;
  }
  .tone-slider input { height: 32px; }
  .tone-choices button { min-height: 42px; font-size: .85rem; }
  .tone-reset { align-self: stretch; justify-content: center; height: 40px; }
}

/* The sleep timer, on its own line under the position and the volume. A
   reading and not a control - it is set on the settings page - so it is drawn
   in the accent and only exists while a timer is running: the row is hidden
   outright otherwise, rather than sitting there saying nothing. */
.sleep-timer {
  display: flex;
  align-items: center;
  gap: .45rem;
  color: var(--accent);
  font-size: .85rem;
}

.sleep-icon {
  width: 16px;
  height: 16px;
  flex: none;
  fill: currentColor;
}

/* Beside the menu, not at the far end of the line: it belongs to the number
   in the menu, and pushed to the right edge it read as a second, unrelated
   reading. */
.sleep-remaining { font-variant-numeric: tabular-nums; }

.volume-icon {
  width: 16px;
  height: 16px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

#volume-input {
  flex: 1;
  min-width: 0;
  height: 4px;
  margin: 0;
  border-radius: 2px;
  background: var(--surface-3);
  accent-color: var(--accent);
  -webkit-appearance: none;
  appearance: none;
}

#volume-input::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--accent);
}

#volume-input::-moz-range-thumb {
  width: 12px;
  height: 12px;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
}

#volume-value {
  font-family: var(--mono);
  font-size: .72rem;
  width: 2.2ch;
  text-align: right;
}

/* The chips wrap rather than ellipsize: a truncated "Воспроизв…" says less
   than the same two chips on two lines. */
.stream-details {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: .3rem .35rem;
  flex: none;
  max-width: 40vw;
  padding-top: .2rem;
}

.state-chip,
.stream-chip {
  padding: 1px 6px;
  border: 1px solid var(--line);
  border-radius: 6px;
  font-family: var(--mono);
  font-size: .68rem;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.state-chip[data-state="playing"] {
  color: var(--accent);
  border-color: var(--accent-line);
  background: var(--accent-soft);
}

.state-chip[data-state="error"] { color: var(--bad); border-color: var(--bad); }

.player-error,
.command-status {
  padding: 0 .75rem .55rem;
  font-size: .78rem;
  color: var(--muted);
}

.player-error { color: var(--bad); }
.command-status.is-error { color: var(--bad); }

/* The expand button means nothing on a large screen, where everything is
   visible already, so it exists only on a phone. */
.player-expand { display: none; }

/* ------------------------------------------------------- document pages */

.page-shell {
  width: min(100% - 1.5rem, 900px);
  margin-inline: auto;
  padding-block: 1.25rem 2.5rem;
  display: grid;
  gap: 1rem;
}

.page-heading { display: grid; gap: .2rem; }
/* A panel a grid item, and a long address in it: without this the panel is as
   wide as the longest line and the page scrolls sideways. */
.page-shell > [role="tabpanel"] { min-width: 0; }
.page-heading h1 { font-size: 1.45rem; font-weight: 700; }
.page-heading > p:last-child { color: var(--muted); font-size: .88rem; }

.card {
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  overflow: hidden;
}

.card-head {
  display: flex;
  align-items: center;
  gap: .7rem;
  min-height: 34px;
  padding: .35rem .8rem;
  border-bottom: 1px solid var(--line-soft);
  background: var(--surface-2);
}

.card-head h2 {
  font-size: .68rem;
  font-weight: 650;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}

/* Each card on the settings page folds away, and only one stands open. The
   page is a phone's remote first: unfolded it is five screens of scrolling
   past three sections nobody came for. The heading is the button - the status
   beside it stays readable folded, which is the point of leaving it out of
   the button. */
.card-toggle {
  display: flex;
  align-items: center;
  gap: .45rem;
  flex: 1;
  min-width: 0;
  align-self: stretch;
  text-align: left;
}

.card-chevron {
  width: 13px;
  height: 13px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  color: var(--muted);
  /* Down when the section is open, right when it is folded - the same turn a
     file tree makes, so nobody has to be told what the arrow means. */
  transform: rotate(90deg);
  transition: transform .15s ease;
}

.card.is-collapsed .card-chevron { transform: rotate(0deg); }

.card-toggle:hover h2 { color: var(--text-2); }

/* Two columns fit from here up, and with them the whole page: the heading goes
   back to being a heading. settings.js stops folding at the same width. */
@media (min-width: 780px) {
  .card-chevron { display: none; }
  .card-toggle { cursor: default; pointer-events: none; }
}

.settings-status {
  margin-left: auto;
  font-size: .75rem;
  color: var(--muted);
  text-align: right;
}

.settings-status.is-error { color: var(--bad); }
.settings-status.is-success { color: var(--ok); }

.settings-columns {
  display: grid;
  gap: 1rem;
  align-items: start;
  grid-template-columns: 1fr;
}

@media (min-width: 780px) {
  .settings-columns { grid-template-columns: 1fr 1fr; }
}

.settings-column { display: grid; gap: 1rem; align-content: start; }

/* A settings row: the caption on the left, the control on the right, nothing
   in between. */
.device-row {
  display: flex;
  align-items: center;
  gap: .8rem;
  min-height: 36px;
  padding: .2rem .8rem;
  border-bottom: 1px solid var(--line-soft);
  font-size: .88rem;
}

.device-row:last-child { border-bottom: 0; }

.device-row > label:first-child,
.device-row > .row-label {
  flex: 1;
  min-width: 0;
}

.device-group + .device-group { border-top: 1px solid var(--line); }

.device-group > h3 {
  margin: 0;
  padding: .4rem .8rem;
  font-size: .66rem;
  font-weight: 650;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
  background: var(--surface-2);
  border-bottom: 1px solid var(--line-soft);
}

.row-value {
  font-family: var(--mono);
  font-size: .78rem;
  color: var(--muted);
  text-align: right;
}

.row-value.is-ok { color: var(--ok); }

select,
input[type="text"],
input[type="password"] {
  height: 30px;
  padding: 0 .5rem;
  border: 1px solid var(--line);
  border-radius: 7px;
  background: var(--surface-2);
  color: var(--text);
  font-size: .85rem;
}

select { min-width: 8.5rem; }

input[type="text"],
input[type="password"] { width: 100%; }

/* Except in a settings row, where a text field is a control beside its label
   rather than a form field on a line of its own: the full width above belongs
   to the Wi-Fi form, and in this flex row it pushed the label out of the
   card. */
.device-row input[type="text"],
.device-row input[type="password"] {
  width: auto;
  flex: 0 1 10rem;
  min-width: 0;
}

/* The field and the button that reveals it, on one line: underneath it would
   read as a second control rather than as part of the field. The input keeps
   its width: 100% from above, so it needs a basis of its own here and
   min-width: 0, or a flex item refuses to shrink below its content and the
   button is pushed off the card on a narrow phone. */
.password-field {
  display: flex;
  align-items: stretch;
  gap: .4rem;
}

.password-field input[type="text"],
.password-field input[type="password"] {
  flex: 1 1 auto;
  min-width: 0;
  width: auto;
}

.password-field .secondary-button { flex: 0 0 auto; }

/* A switch rather than a checkbox: beside the volume slider a square box
   looks borrowed, and its state reads worse. On the dark page a switch that
   is off drew its edge in --line and vanished into the card; users could not
   find it, so the dark theme rims it with a faint touch of the accent. */
input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  width: 36px;
  height: 21px;
  margin: 0;
  flex: none;
  border-radius: 99px;
  background: var(--surface-3);
  border: 1px solid var(--switch-line);
  position: relative;
  cursor: pointer;
  transition: background .15s ease;
}

input[type="checkbox"]::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--shadow-sm);
  transition: transform .15s ease;
}

input[type="checkbox"]:checked { background: var(--accent); border-color: var(--accent); }
input[type="checkbox"]:checked::after { transform: translateX(15px); }
input[type="checkbox"]:disabled { opacity: .45; cursor: not-allowed; }

/* The alarm's seven days. A row of its own rather than a control beside its
   label: seven chips and a label do not fit across a phone, and wrapping them
   under a label that keeps its own column leaves the first line short. */
.device-row-stacked {
  flex-direction: column;
  align-items: stretch;
  gap: .4rem;
  padding-top: .5rem;
  padding-bottom: .5rem;
}

.day-chips {
  display: flex;
  flex-wrap: wrap;
  gap: .25rem;
}

/* Sized to the two letters in them rather than stretched across the row:
   seven blocks filling the whole width read as a toolbar, not as a set of
   small choices. Still a thumb's target - 2rem is about 32 px. */
.day-chip {
  flex: 0 0 auto;
  min-width: 2rem;
  padding: .25rem .3rem;
  font-size: .72rem;
  line-height: 1.3;
  text-align: center;
  color: var(--text-2);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 6px;
}

.day-chip.is-on {
  color: var(--on-accent);
  background: var(--accent);
  border-color: var(--accent);
}

.device-slider {
  display: flex;
  align-items: center;
  gap: .5rem;
  width: 150px;
  flex: none;
}

.device-slider input[type="range"] {
  flex: 1;
  min-width: 0;
  height: 4px;
  margin: 0;
  border-radius: 2px;
  background: var(--surface-3);
  accent-color: var(--accent);
}

.device-slider output {
  font-family: var(--mono);
  font-size: .75rem;
  color: var(--muted);
  width: 2.5ch;
  text-align: right;
}

.field-note {
  padding: .5rem .8rem;
  color: var(--muted);
  font-size: .78rem;
  border-top: 1px solid var(--line-soft);
}

.network-summary {
  margin: 0;
  display: grid;
}

.network-summary > div {
  display: flex;
  align-items: center;
  gap: .8rem;
  min-height: 34px;
  padding: .2rem .8rem;
  border-bottom: 1px solid var(--line-soft);
  font-size: .88rem;
}

.network-summary dt { flex: 1; color: var(--text); }

.network-summary dd {
  margin: 0;
  font-family: var(--mono);
  font-size: .78rem;
  color: var(--muted);
}

.saved-networks {
  margin: 0;
  padding: 0;
  list-style: none;
}

.saved-networks li {
  min-height: 32px;
  display: flex;
  align-items: center;
  /* The buttons sit at the right edge and wrap under the name on a narrow
     phone rather than squeezing it into a column of single letters. */
  flex-wrap: wrap;
  gap: .3rem;
  padding: .3rem .8rem;
  border-bottom: 1px solid var(--line-soft);
  font-size: .85rem;
}

/* The name takes the row and pushes everything else to the right edge. */
.saved-networks .network-name {
  flex: 1 1 8rem;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-align: left;
}

/* A button in the list of networks around: it is a name to press, not a
   control, so it keeps the row's own type rather than a button's. */
button.network-name {
  background: none;
  border: 0;
  padding: 0;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

button.network-name:hover { color: var(--accent); }

.network-tag {
  flex: 0 0 auto;
  padding: .05rem .4rem;
  border-radius: 999px;
  background: var(--surface-3);
  color: var(--muted);
  font-size: .72rem;
  white-space: nowrap;
}

/* The FM lists: the frequency in a fixed column so a list of them reads down
   one edge, a name field that takes what is left, and the small controls. */
.fm-list .fm-frequency {
  flex: 0 0 4.2rem;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}
.fm-list .fm-name {
  flex: 1 1 8rem;
  min-width: 0;
  height: 26px;
  padding: 0 .4rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--surface);
  color: inherit;
  font: inherit;
}

/* The stations page's tabs: one per kind of station, in the look of the
   header's. */
.stations-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: .6rem;
}
.stations-tabs, .stations-io {
  display: flex;
  gap: .4rem;
  flex-wrap: wrap;
}
#stations-io-status:empty { display: none; }
.stations-tab {
  /* The corners and the height of the buttons below, which these sit above. */
  height: 30px;
  padding: 0 .75rem;
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface);
  color: var(--muted);
  font: inherit;
  font-size: .85rem;
  cursor: pointer;
}
.stations-tab:hover { color: var(--text); }
.stations-tab.is-active {
  border-color: var(--accent);
  background: var(--surface-3);
  color: var(--text);
}
.stations-tab[hidden] { display: none; }

/* The picture's tile, in front of the frequency: the preview of what the
   device will show while it stands on the preset, or a plus to add one. */
.fm-list .fm-picture-cell {
  /* Room for the tile and the control that takes the picture off, whether or
     not there is one, so the frequencies and names line up down the list. */
  flex: 0 0 4.4rem;
  display: inline-flex;
  align-items: center;
  gap: .2rem;
}
.fm-list .fm-picture {
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--surface);
  color: var(--muted);
  cursor: pointer;
}
.fm-list .fm-picture img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.fm-list .fm-picture input[type="file"] { display: none; }

.saved-networks .secondary-button {
  flex: 0 0 auto;
  padding: .15rem .5rem;
  font-size: .75rem;
}

.settings-form {
  display: grid;
  gap: .4rem;
  padding: .6rem .8rem .8rem;
}

.settings-form label {
  font-size: .78rem;
  color: var(--muted);
}

/* In a grid the button would stretch across the column and read as a bar. */
.settings-form .primary-button {
  justify-self: start;
  margin-top: .3rem;
}

.settings-actions {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  padding: .6rem .8rem;
  border-top: 1px solid var(--line-soft);
}

.primary-button,
.secondary-button {
  height: 30px;
  padding: 0 .75rem;
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  border-radius: 8px;
  border: 1px solid var(--line);
  background: var(--surface);
  font-size: .85rem;
}

.primary-button {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--on-accent);
  font-weight: 600;
}

.secondary-button:hover:not(:disabled) { background: var(--surface-2); }

/* The backup download is an anchor, so the browser saves the file itself
   instead of the page holding it in memory; without this it would be the one
   button on the page wearing a link's underline. */
a.primary-button,
a.secondary-button { text-decoration: none; color: inherit; }

.file-button { cursor: pointer; }

/* Name on the left, value on the right, aligned down the card. A table would
   say the same thing with more markup, and a definition list is what this is. */
.about-list {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: .25rem .8rem;
  padding: .6rem .8rem;
  margin: 0;
  font-size: .85rem;
}

.about-list dt { color: var(--muted); }

.about-list dd {
  margin: 0;
  font-family: var(--mono);
  overflow-wrap: anywhere;
}

.yandex-code { padding: .6rem .8rem; display: grid; gap: .3rem; }
.yandex-code-lead { font-size: .85rem; }
.yandex-code a { color: var(--accent-2); }

.yandex-code-value {
  font-family: var(--mono);
  font-size: 1.5rem;
  font-weight: 500;
  letter-spacing: .18em;
  color: var(--text);
}

/* --------------------------------------------------------- playlist editor */

.playlist-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  margin-left: auto;
}

.playlist-rows {
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: prow;
}

/* The station search and the picture finder: a panel between the card's head
   and the list, one of the two open at a time. */
.search-panel {
  border-bottom: 1px solid var(--line);
  background: var(--surface-2);
}

.search-panel-head {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .5rem .5rem 0 .8rem;
}

.search-panel-head h3 {
  margin: 0;
  flex: 1 1 auto;
  font-size: .85rem;
  font-weight: 600;
  color: var(--text-2);
}

.search-form {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  padding: .4rem .8rem .3rem;
}

.search-form input[type="search"] { flex: 1 1 14rem; min-width: 0; }
.search-form select { flex: 0 1 12rem; min-width: 0; }

.search-panel > .settings-status {
  margin: 0;
  padding: .2rem .8rem .4rem;
  text-align: left;
}

.search-results {
  margin: 0;
  padding: 0;
  list-style: none;
  max-height: 26rem;
  overflow-y: auto;
}

.search-result {
  display: flex;
  align-items: center;
  gap: .6rem;
  min-height: var(--row);
  padding: .35rem .8rem;
  border-top: 1px solid var(--line-soft);
}

.search-result-thumb {
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: 6px;
  object-fit: contain;
  background: var(--surface-3);
}

.search-result-text {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-width: 0;
}

.search-result-name,
.search-result-meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.search-result-meta { color: var(--muted); font-size: .75rem; }
.search-result-add { flex: none; }
.search-panel .settings-actions { padding: .5rem .8rem; }
.secondary-button.is-active { border-color: var(--accent-line); color: var(--accent); }

.playlist-row {
  border-bottom: 1px solid var(--line-soft);
  counter-increment: prow;
}

.playlist-row.is-editing { background: var(--surface-2); }

/* The row is read to find a station; the fields for changing one live in the
   panel below it. With a name field, an address field and a switch on every
   line there was no list left to read, only a wall of inputs. */
.playlist-row-head {
  display: flex;
  align-items: center;
  gap: .6rem;
  min-height: var(--row);
  padding: .2rem .8rem;
}

.playlist-row-head::before {
  content: counter(prow);
  width: 1.7rem;
  flex: none;
  text-align: right;
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--muted);
}

.playlist-row-name {
  flex: 0 1 auto;
  min-width: 6rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: .9rem;
}

.playlist-row-url {
  flex: 1 1 12rem;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--mono);
  font-size: .74rem;
  color: var(--muted);
}

/* The station's own picture, the same one the panel shows while it plays. */
.playlist-row-thumb {
  width: 22px;
  height: 22px;
  flex: none;
  border-radius: 5px;
  object-fit: cover;
  background: var(--surface-3);
}

.playlist-row-icon {
  display: flex;
  align-items: center;
  gap: .5rem;
  flex-wrap: wrap;
}

.playlist-row-icon-preview {
  width: 48px;
  height: 48px;
  border-radius: 8px;
  object-fit: contain;
  background: #000;
}

.playlist-row.has-error { background: var(--bad-soft); }
.playlist-row.has-error .playlist-row-name { color: var(--bad); }

/* The grip a row is dragged by. `touch-action: none` is what makes it work
   with a finger at all: without it the browser takes the first movement for a
   scroll and the row never leaves its place. */
.playlist-row-handle {
  width: 22px;
  height: 28px;
  cursor: grab;
  touch-action: none;
  /* Ahead of the number, which is the head's ::before and so would otherwise
     come first whatever the markup says. The grip starts the row: it is the
     part that is reached for, and the number is there to be read. */
  order: -1;
}

.playlist-row-handle:active { cursor: grabbing; }

/* The row being dragged rides above its neighbours and keeps its own
   background, so the rows it passes stay readable underneath. */
.playlist-row.is-dragging {
  position: relative;
  z-index: 2;
  background: var(--surface-2);
  border-radius: 8px;
  box-shadow: 0 8px 20px rgb(0 0 0 / .35);
  cursor: grabbing;
  user-select: none;
}

.playlist-row-edit,
.playlist-row-delete {
  width: 28px;
  height: 28px;
}

.playlist-row-edit { margin-left: auto; }

.playlist-row-delete:hover { color: var(--bad); background: var(--bad-soft); }
.playlist-row.is-editing .playlist-row-edit { color: var(--accent); }

/* The pencil says the row can be changed; once the panel is open the arrow
   says how to close it again. */
.playlist-row-edit .icon-collapse { display: none; }
.playlist-row.is-editing .playlist-row-edit .icon-pencil { display: none; }
.playlist-row.is-editing .playlist-row-edit .icon-collapse { display: block; }

.playlist-row-editor {
  display: grid;
  gap: .5rem;
  padding: .2rem .8rem .8rem 3.1rem;
}

/* Only the two field labels stack their caption over the input; the switch is
   a label too and would otherwise be caught by the same rule, putting its
   sentence on the line below it. */
.playlist-row-field {
  display: grid;
  gap: .2rem;
}

.playlist-row-field > span {
  font-size: .72rem;
  color: var(--muted);
}

.playlist-row-url-input {
  font-family: var(--mono);
  font-size: .78rem;
}

/* The switch belongs with the fields it qualifies, not in the row: what the
   flag means takes a sentence, and a sentence has no room on a 38 px line. */
.playlist-row-flag {
  display: flex;
  align-items: center;
  gap: .5rem;
  font-size: .8rem;
  color: var(--text-2);
  cursor: pointer;
}

.playlist-row-tests {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
}

.playlist-row-tests .is-testing {
  border-color: var(--accent);
  color: var(--accent);
}

.playlist-row-error {
  color: var(--bad);
  font-size: .78rem;
}

.playlist-row-error.is-notice { color: var(--muted); }

/* The way down to Save, floating over the list's lower right corner while
   that button is out of view. */
.to-save-button {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 20;
  width: 44px;
  height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--accent);
  border-radius: 50%;
  background: var(--accent);
  color: #111;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .35);
  cursor: pointer;
}
.to-save-button svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.to-save-button[hidden] { display: none; }

.playlist-save-bar {
  display: flex;
  align-items: center;
  gap: .6rem;
  flex-wrap: wrap;
  padding: .5rem .8rem;
  border-top: 1px solid var(--line);
  background: var(--surface-2);
}

/* Its own width, not what is left over: in a flex row the label was clipped
   mid-word. */
.playlist-save-bar .primary-button {
  flex: none;
  white-space: nowrap;
}

.playlist-save-bar .command-status { padding: 0; }

.noscript-message {
  margin: 1rem;
  padding: .8rem;
  border: 1px solid var(--line);
  border-radius: var(--r);
  background: var(--surface);
  color: var(--text-2);
}

/* ------------------------------------------------------------------ phone */

@media (max-width: 720px) {
  .header-inner {
    gap: .4rem;
    padding-inline: .6rem;
    padding-block: .3rem;
    flex-wrap: wrap;
  }
  .source-tabs { order: 3; flex-basis: 100%; justify-content: space-around; }
  .source-tab { padding-inline: .45rem; font-size: .76rem; }

  /* The four Russian words do not fit across a phone header, so the marks take
     their place. The word is clipped rather than dropped: it is still the
     button's name for anything that reads the page aloud. */
  .source-tab[data-icon] { padding-inline: .9rem; }
  .source-tab[data-icon] .source-icon {
    display: block;
    width: 22px;
    height: 22px;
  }
  .source-tab[data-icon] .source-tab-label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* The buttons take a row of their own: at this width the heading and the
     status leave less than one button of space beside them. Three of them may
     wrap onto a second. */
  .card-head { flex-wrap: wrap; }
  .playlist-toolbar { flex: 1 0 100%; justify-content: flex-end; }

  .list-search { width: 100%; }
  .list-head { flex-wrap: wrap; }
  .list-item { min-height: 44px; }

  /* Folded away: the cover, two lines and pause on one row. Everything else
     waits for the card to be expanded - a full panel at the top of a phone
     would leave no list under it. */
  .player-main { padding: .45rem .6rem; gap: .55rem; align-items: center; }
  .player-body { min-height: 0; }
  .player-expand {
    display: grid;
    place-items: center;
    width: 30px;
    height: 30px;
    border-radius: 8px;
    color: var(--muted);
    flex: none;
  }
  .player-expand svg {
    width: 16px;
    height: 16px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.9;
    stroke-linecap: round;
    stroke-linejoin: round;
    /* The panel opens downwards now that it sits at the top, so the chevron
       points the way the card will move. */
    transform: rotate(180deg);
    transition: transform .18s ease;
  }
  .player-bar.is-expanded .player-expand svg { transform: rotate(0); }

  .player-bar:not(.is-expanded) .player-body {
    flex-direction: row;
    align-items: center;
    gap: .5rem;
  }
  .player-bar:not(.is-expanded) .track-art {
    width: 44px;
    height: 44px;
    border-radius: 8px;
  }
  .player-bar:not(.is-expanded) .track-copy { flex: 1; }
  .player-bar:not(.is-expanded) #track-title { font-size: .95rem; font-weight: 600; }
  .player-bar:not(.is-expanded) .track-artist { font-size: .78rem; color: var(--muted); }
  .player-bar:not(.is-expanded) .player-transport { flex: none; }
  .player-bar:not(.is-expanded) .play-toggle {
    width: 44px !important;
    height: 44px !important;
    border-radius: 14px !important;
  }
  .player-bar:not(.is-expanded) .play-toggle svg { width: 22px; height: 22px; }

  .player-bar:not(.is-expanded) .track-progress,
  .player-bar:not(.is-expanded) .sleep-timer,
  .player-bar:not(.is-expanded) .player-bottom,
  .player-bar:not(.is-expanded) .volume-control,
  .player-bar:not(.is-expanded) .stream-details,
  .player-bar:not(.is-expanded) .track-context,
  .player-bar:not(.is-expanded) .like-track,
  .player-bar:not(.is-expanded) .dislike-track,
  .player-bar:not(.is-expanded) .step-item { display: none; }

  .player-bar.is-expanded .player-main {
    flex-direction: column;
    align-items: stretch;
    gap: .7rem;
    padding: .8rem .8rem 1rem;
  }
  .player-bar.is-expanded .track-art {
    width: min(140px, 42vw);
    height: min(140px, 42vw);
    border-radius: 14px;
    margin-inline: auto;
  }
  .player-bar.is-expanded .track-copy {
    flex-direction: column;
    align-items: center;
    text-align: center;
  }
  /* Column children shrink to their content, and a long track name would
     then run off both edges instead of ellipsizing. */
  .player-bar.is-expanded .track-lines { width: 100%; }
  .player-bar.is-expanded #track-title { font-size: 1.25rem; }
  .player-bar.is-expanded .track-artist { font-size: .95rem; }
  /* Two chips do not fit one phone line: they wrap rather than run off the
     edge, which is what a centred overflowing row does. */
  .player-bar.is-expanded .stream-details {
    max-width: 100%;
    padding-top: 0;
    flex-wrap: wrap;
    justify-content: center;
  }
  .player-bar.is-expanded .stream-chip { min-width: 0; }
  .player-bar.is-expanded .player-controls { gap: .5rem; }
  /* The two wrappers stop being boxes so their children join the card's own
     column, which is the only way the position line can sit under the picture
     while the volume stays last. */
  .player-bar.is-expanded .player-body,
  .player-bar.is-expanded .player-bottom { display: contents; }
  .player-bar.is-expanded .track-art { order: 1; }
  .player-bar.is-expanded .track-progress { order: 2; }
  .player-bar.is-expanded .track-copy { order: 3; }
  .player-bar.is-expanded .player-transport { order: 4; }
  .player-bar.is-expanded .volume-control { order: 5; width: 100%; margin-left: 0; }
  .player-bar.is-expanded .sleep-timer { order: 6; width: 100%; }
  /* Expanded, the button moves to its own corner: in the middle of the column
     it reads as part of the transport. The row that starts the card keeps
     clear of it - the position line's total would otherwise be printed under
     the chevron. */
  .player-bar.is-expanded .player-expand { position: absolute; top: .5rem; right: .6rem; }
  .player-bar.is-expanded .track-progress { padding-right: 2.1rem; }

  .page-shell { width: min(100% - 1rem, 900px); }
}

@media (max-width: 420px) {
  .stream-details { max-width: 100%; }
  /* The address is the first thing to go: on a phone the name and the two
     buttons are what the row is for, and the address is a line of noise the
     panel shows in full anyway. */
  .playlist-row-url { display: none; }
  .playlist-row-editor { padding-left: 1.6rem; }
  /* The grip is dragged with a finger here, so it gets a finger's worth of
     row to be grabbed by. */
  .playlist-row-handle { width: 32px; height: 36px; }
}

/* ---- the hardware editor -------------------------------------------- */

.hw-layout {
  display: grid;
  gap: 1rem;
  align-items: start;
  grid-template-columns: 1fr;
}
@media (min-width: 900px) {
  .hw-layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  /* The picture follows the reader down the long list of parts and stops
     where the list ends: a signal is armed on the left and lands on the
     right, and the two must be on screen together. Below the header, and
     scrolling on its own if the window is shorter than the module card. */
  .hw-module-column {
    position: sticky;
    top: calc(var(--bar) + 1rem);
    max-height: calc(100vh - var(--bar) - 2rem);
    overflow-y: auto;
  }
}
.hw-parts-column, .hw-module-column { display: grid; gap: 1rem; align-content: start; }

/* One colour per part, shared by its heading, its legend entry and its pins
   on the picture, so the eye can follow a part from the list to the header. */
.hw-dev-tft { --dev: #e8743b; }
.hw-dev-encoder { --dev: #4cc38a; }
.hw-dev-buttons { --dev: #5b9cff; }
.hw-dev-dac { --dev: #c77dff; }
.hw-dev-amp { --dev: #ff8fb1; }
.hw-dev-power { --dev: #ffd166; }
.hw-dev-usb { --dev: #2ec4b6; }
.hw-dev-sd { --dev: #9bd53a; }
.hw-dev-bluetooth { --dev: #3d9bff; }
.hw-dev-ir { --dev: #ff5d8f; }
.hw-dev-spi2 { --dev: #ff9f43; }
.hw-dev-spi3 { --dev: #7bd389; }
.hw-dev-i2s0 { --dev: #d08cff; }
.hw-dev-uart1 { --dev: #38bdf8; }
.hw-dev-fm { --dev: #f4a261; }
.hw-dev-rtc { --dev: #8ecae6; }
.hw-dev-i2c0 { --dev: #e9c46a; }
.hw-dev-features { --dev: var(--muted); }
.hw-dev-board { --dev: var(--muted); }

.hw-group > h3 {
  display: flex;
  align-items: center;
  gap: .5rem;
}
.hw-group > h3 > span:nth-child(2) { flex: 1; min-width: 0; }
.hw-group-toggle { margin-left: auto; }
.hw-swatch {
  width: 10px;
  height: 10px;
  border-radius: 3px;
  background: var(--dev, var(--muted));
  flex: none;
}
.hw-group.is-off > h3 > span:nth-child(2) { color: var(--muted); }
.hw-row select, .hw-row input[type="text"], .hw-row input[type="number"] {
  max-width: 11rem;
}
.hw-row input[type="number"] {
  width: 7rem;
  height: 30px;
  padding: 0 .5rem;
  border: 1px solid var(--line);
  border-radius: 7px;
  background: var(--surface-2);
  color: var(--text);
  font-size: .85rem;
}
.hw-row.is-picking { background: var(--accent-soft); }
.hw-arm {
  flex: none;
  width: 28px;
  height: 28px;
  border-radius: 6px;
  border: 1px solid var(--line);
  color: var(--muted);
  font-size: 1rem;
  line-height: 1;
}
.hw-arm:hover { color: var(--text); background: var(--surface-2); }
.hw-row.is-picking .hw-arm { color: var(--on-accent); background: var(--accent); border-color: var(--accent); }

.hw-module-body { display: grid; gap: .6rem; padding: .8rem; }
/* The two hints above the picture share one grid cell, so the taller one
   sets the height whichever is showing and the module never jumps. */
.hw-hint-stack { display: grid; }
.hw-hint-stack > .field-note { grid-area: 1 / 1; margin: 0; }
.hw-hint-stack > #hw-hint-picking, .hw-hint-stack.is-picking > #hw-hint { visibility: hidden; }
.hw-hint-stack.is-picking > #hw-hint-picking { visibility: visible; }
.hw-svg {
  width: 100%;
  height: auto;
  max-height: 620px;
  font-family: var(--mono);
  user-select: none;
}
.hw-body { fill: var(--surface-3); stroke: var(--line); stroke-width: 1; }
.hw-antenna { fill: var(--surface-2); stroke: var(--line); }
.hw-body-label { fill: var(--muted); font-size: 14px; letter-spacing: .1em; }
.hw-body-note { fill: var(--muted); font-size: 9px; letter-spacing: .1em; }
.hw-pad { fill: var(--surface-2); stroke: var(--line); stroke-width: 1; }
.hw-pin-name { fill: var(--text-2); font-size: 10px; }
.hw-pin-signal { fill: var(--dev, var(--text)); font-size: 10px; font-weight: 600; }
.hw-pin-power .hw-pin-name, .hw-pin-ground .hw-pin-name, .hw-pin-reset .hw-pin-name { fill: var(--muted); }
.hw-gpio, .hw-pin-reset { cursor: pointer; }
.hw-gpio.is-used .hw-pad, .hw-pin-reset.is-used .hw-pad { fill: var(--dev); stroke: var(--dev); }
.hw-gpio.is-used .hw-pin-name, .hw-pin-reset.is-used .hw-pin-name { fill: var(--text); }
.hw-gpio.is-noted .hw-pin-name { text-decoration: underline dotted; }
.hw-gpio.is-reserved { cursor: not-allowed; opacity: .45; }
.hw-gpio.is-reserved .hw-pad { fill: var(--surface); stroke-dasharray: 2 2; }
.hw-gpio.is-reserved .hw-pin-name { fill: var(--muted); text-decoration: line-through; }
.hw-gpio.is-target .hw-pad, .hw-pin-reset.is-target .hw-pad { stroke: var(--accent); }
.hw-gpio.is-blocked, .hw-pin-reset.is-blocked { opacity: .4; }
.hw-gpio.is-picking .hw-pad, .hw-gpio.is-conflict .hw-pad { stroke-width: 2; }
.hw-gpio.is-picking .hw-pad, .hw-pin-reset.is-picking .hw-pad { stroke: var(--accent); stroke-width: 2; }
.hw-gpio.is-conflict .hw-pad { stroke: var(--bad); }
.hw-gpio.is-conflict .hw-pin-signal { fill: var(--bad); }

.hw-legend {
  display: flex;
  flex-wrap: wrap;
  gap: .3rem .9rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: .8rem;
  color: var(--text-2);
}
.hw-legend li { display: flex; align-items: center; gap: .4rem; }

.hw-report { margin: 0; padding: .6rem .9rem; list-style: none; display: grid; gap: .35rem; font-size: .85rem; }

/* The flasher page, beside the editor on the flasher site. */
.fl-version { color: var(--muted); font-size: .55em; font-weight: 400; }
.fl-summary { display: grid; grid-template-columns: max-content 1fr; gap: .3rem 1rem; margin: .4rem 0; }
.fl-summary dt { color: var(--muted); }
.fl-summary dd { margin: 0; }
.fl-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .8rem 1.2rem; margin: .8rem 0 .4rem; }
/* The flasher's Wi-Fi form: a label beside its field, stacked on a phone. */
.fl-wifi-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .3rem 1rem;
  margin: .5rem 0;
}
.fl-wifi-row > label { flex: 0 0 8rem; }
.fl-wifi-row > input,
.fl-wifi-row > select,
.fl-wifi-row > .fl-wifi-secret { flex: 1 1 14rem; min-width: 0; max-width: 22rem; }
.fl-reveal svg {
  width: 1.2rem;
  height: 1.2rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* Struck through while the password is hidden, as the eye says it is. */
.fl-reveal[aria-pressed="true"] .fl-reveal-strike { display: none; }
.fl-warn {
  margin: .6rem 0;
  padding: .6rem .8rem;
  border-left: 3px solid var(--accent);
  border-radius: var(--r);
  background: var(--accent-soft);
}
#fl-progress { width: 100%; }
.update-progress { width: 100%; margin-top: 0.75rem; }
.update-status { margin: 0.75rem 0 0; font-size: .9rem; text-align: left; }
.update-status:empty { display: none; }
.update-subtitle { margin: 1.1rem 0 .4rem; font-size: .9rem; color: var(--muted); }
.update-banner {
  margin: .8rem auto 0;
  width: min(1100px, calc(100% - 2rem));
  box-sizing: border-box;
  padding: .75rem .9rem;
  border: 1px solid var(--accent);
  border-radius: var(--r-lg);
  background: var(--surface);
}
.update-banner[hidden] { display: none; }
.update-banner-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: .6rem;
}
.update-banner-actions { display: flex; gap: .5rem; }
.update-banner-line { margin: 0; }
.update-banner-line a { margin-left: .5rem; white-space: nowrap; }
.update-history h4 { margin: .7rem 0 .2rem; font-size: .9rem; }
.update-history ul {
  margin: 0;
  padding-left: 1.2rem;
  font-size: .85rem;
  color: var(--muted);
}
.update-history .is-heading { list-style: none; margin-left: -1.2rem; color: var(--text); }
.hw-problem.is-error { color: var(--bad); }
.hw-problem.is-warning { color: var(--accent-2); }
.hw-problem.is-ok { color: var(--ok); }

.hw-csv-head { gap: .8rem; }
.hw-csv-actions { margin-left: auto; display: flex; gap: .5rem; flex-wrap: wrap; }
.hw-csv {
  margin: 0 0 .8rem;
  padding: .8rem;
  border-radius: var(--r);
  background: var(--surface-2);
  border: 1px solid var(--line-soft);
  font-family: var(--mono);
  font-size: .78rem;
  line-height: 1.45;
  max-height: 22rem;
  overflow: auto;
  white-space: pre;
}
.hw-import summary { cursor: pointer; color: var(--text-2); font-size: .88rem; }
.hw-import textarea {
  width: 100%;
  margin-top: .6rem;
  padding: .6rem;
  border-radius: var(--r);
  border: 1px solid var(--line);
  background: var(--surface-2);
  color: var(--text);
  font-family: var(--mono);
  font-size: .78rem;
  resize: vertical;
}
.hw-import-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .5rem; }
.field-note.is-error { color: var(--bad); }

/* ---- the remote's table ----------------------------------------------- */
/* A grid, not the flex row: Learn and Forget sit in columns of their own, so
   a row without a Forget keeps its Learn where every other row has it
   instead of sliding to the edge. */
.remote-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 12rem) 6rem 6rem; }
.remote-row .remote-code { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.remote-row .secondary-button { min-width: 0; padding-inline: .4rem; text-align: center; }
.remote-row .secondary-button[hidden] { display: block; visibility: hidden; }
@media (max-width: 560px) {
  .remote-row { grid-template-columns: minmax(0, 1fr) minmax(0, 6.5rem) 5rem 5rem; gap: .4rem; }
}
.remote-row.is-learning { background: var(--accent-soft); }
.remote-row.is-pressed { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent-2); }
.remote-row.is-learning .remote-code { color: var(--accent-2); font-family: inherit; }
