* { box-sizing: border-box; }
/* The colour of the evening. js rolls a hue here on load and on every disco press, and
   everything tinted by it turns together. Hue only - saturation and lightness are fixed
   at each use site, so nothing drifts out of contrast whichever colour comes up. */
:root { --h: 18; }
/* Some elements below also carry a `display` via a class (e.g. .chip-row), which as an
   author-stylesheet rule would otherwise beat the UA's [hidden]{display:none} - pin it
   here so the `hidden` attribute always actually hides things. */
[hidden] { display: none !important; }
body {
  font-family: system-ui, sans-serif;
  background: #14141a;
  color: #eee;
  margin: 0;
  padding: 0 1.5rem 3rem;
}
/* One column for player and list alike: on a wide monitor a progress bar stretched
   across the whole screen is unusable and post text turns into unreadable long lines. */
header,
main {
  max-width: 960px;
  margin-inline: auto;
}
header {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1.5rem 0;
  flex-wrap: wrap;
}
h1 { font-size: 1.1rem; margin: 0; letter-spacing: 0.02em; }
/* The title is ascii art - a sword - and the blade hangs below the lettering: =, the
   guard and the chevron are drawn around the math axis while capitals are centred
   higher. How far below is a property of the font, and a phone renders these glyphs
   from a different fallback than a desktop does - so alignBlades() in app.js measures
   the real ink and overrides this. The value here is only what shows before it runs. */
.blade {
  position: relative;
  top: -0.09em;
}
button {
  background: #2a2a35;
  color: #eee;
  border: 1px solid #444;
  border-radius: 6px;
  padding: 0.5rem 1rem;
  cursor: pointer;
  font-size: 0.9rem;
}
/* Controls are not text: without this a click that drifts a pixel, or a double click,
   selects the glyph and paints a rectangular highlight inside a round button. The tap
   highlight does the same thing on touch, in its own square. */
button {
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
button:hover { background: #3a3a48; }
button:disabled { opacity: 0.4; cursor: default; }
/* Between the post text and its tracks: who made this and what it is called. */
.album-heading {
  margin: 1.1rem 0 0.5rem;
  line-height: 1.25;
}
.album-name {
  display: block;
  color: #f0f0f6;
  font-size: 1.15rem;
  font-weight: 600;
  text-decoration: none;
}
a.album-name:hover { color: #ffb070; }
.album-artist { color: #a9a9b8; font-size: 0.92rem; }
.album-by { color: #6d6d7a; }

.buy-link {
  flex: none;
  margin-left: 0.5rem;
  color: #888;
  font-size: 0.8rem;
  text-decoration: none;
  border-bottom: 1px dotted #555;
}
.buy-link:hover { color: #ddd; border-bottom-color: #ddd; }

.feed-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6rem;
  padding: 3rem 1rem;
  color: #7b7b8a;
  font-size: 0.9rem;
}
.feed-loading-note { color: #7e7e8c; font-size: 0.78rem; }
.feed-loading-spinner {
  width: 26px;
  height: 26px;
  border: 2px solid #33333f;
  border-top-color: #c2521f;
  border-radius: 50%;
  animation: feed-spin 0.9s linear infinite;
}
@keyframes feed-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .feed-loading-spinner { animation-duration: 3s; }
}

.load-failure {
  padding: 2rem 1rem;
  text-align: center;
  color: #999;
  line-height: 1.6;
}
.load-failure-title { color: #ddd; font-size: 1.1rem; margin-bottom: 0.4rem; }

/* Whatever the channel wrote about this album, above the player. It is the reason a
   listener is here at all, and it was only visible down in the list before. */
#post-context {
  background: #1a1a22;
  border: 1px solid #2c2c38;
  border-left: 3px solid #7a3a18;
  border-radius: 3px;
  padding: 0.7rem 0.9rem;
  margin-bottom: 0.75rem;
  cursor: pointer;
}
#post-context { position: relative; }
#post-context-link {
  position: absolute;
  top: 0.6rem;
  right: 0.8rem;
  color: #6e6e7c;
  text-decoration: none;
  font-size: 0.85rem;
}
#post-context-link:hover { color: #ffb070; }
#post-context-text {
  color: #c8c8d4;
  font-size: 0.85rem;
  line-height: 1.5;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
}
#post-context.expanded #post-context-text {
  -webkit-line-clamp: unset;
  line-clamp: unset;
}

/* Cut corners rather than rounded ones: a forged plate reads as made, a pill reads as a
   widget. One variable per element sets how deep the cut goes. */
.player,
.transport,
.transport button,
#radio-btn {
  /* No border on anything bevelled: clip-path cuts one into four loose bars with a gap
     at every diagonal, and the base button style would otherwise put one back. */
  border: none;
  clip-path: polygon(
    var(--cut) 0,
    calc(100% - var(--cut)) 0,
    100% var(--cut),
    100% calc(100% - var(--cut)),
    calc(100% - var(--cut)) 100%,
    var(--cut) 100%,
    0 calc(100% - var(--cut)),
    0 var(--cut)
  );
}

.player {
  position: relative;
  --cut: 13px;
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: center;
  background: #1e1e26;
  border-radius: 2px;
  padding: 1rem;
}
/* Positioned so the torchlight has something to hang off. Deliberately here and not
   next to the torch itself: down there it would come after the media query that makes
   this sticky on a wide screen, and quietly turn the sticky player back into a normal
   one. The wide-screen rule overrides this on purpose. */
#player-slot { position: relative; margin-bottom: 1.5rem; }
/* A div, not an <img>: an image element with nothing to show gets a thin frame drawn
   round it by the browser to say "broken", which is not what an empty slot means. The
   cover is decorative here, so it rides in as a background either way. */
#artwork {
  width: 96px;
  height: 96px;
  border-radius: 3px;
  background-color: #22222b;
  background-repeat: no-repeat;
  background-position: center;
  background-size: cover;
  flex-shrink: 0;
}
/* Nothing playing yet: something rather than an empty grey hole. The drawing itself is
   picked at random by js (see PLACEHOLDER_ICONS); muted on purpose so it never competes
   with the button burning next to it. */
#artwork.empty { background-size: 62%; border: 1px solid #33333f; }
#artwork[role="button"] { cursor: zoom-in; }
#artwork[role="button"]:focus-visible { outline: 2px solid #c2521f; outline-offset: 3px; }

/* Full-size cover. Nothing but the art and a way out of it. */
#cover-view {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4vmin;
  background: rgba(8, 8, 12, 0.92);
  cursor: zoom-out;
}
#cover-view img {
  max-width: min(90vw, 90vh);
  max-height: 90vh;
  border-radius: 6px;
  box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.9);
}

.now-playing { flex: 1; min-width: 0; }
/* Everything naming what is playing is one control, and it opens the album the track
   came from. Built as a plate to match the transport under it: the two then read as the
   same kind of thing, and it inherits the press feedback the buttons already have -
   which is the only feedback there is on a screen that cannot hover. */
#now-head {
  /* Two rows and a column: the track's own name above, who made it and where it lives
     below, and the arrow beside both. A grid rather than flex because the arrow has to
     span both rows - on one line none of the three names fit on a phone. */
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    "title arrow"
    "artist arrow";
  align-items: center;
  column-gap: 0.6rem;
  width: 100%;
  --cut: 9px;
  border: none;
  border-radius: 2px;
  margin-bottom: 0.55rem;
  padding: 0.45rem 0.8rem;
  background: #15151c;
  color: inherit;
  font: inherit;
  text-align: left;
  clip-path: polygon(
    var(--cut) 0,
    calc(100% - var(--cut)) 0,
    100% var(--cut),
    100% calc(100% - var(--cut)),
    calc(100% - var(--cut)) 100%,
    var(--cut) 100%,
    0 calc(100% - var(--cut)),
    0 var(--cut)
  );
}
/* Disabled here means "nowhere to go", not "unavailable": in an album view the name is
   still the name of what is playing, it just has no destination left. So the plate stays
   and only the arrow goes - the player must not change shape with the view. */
#now-head:disabled { opacity: 1; }
#now-head:disabled #now-head-go { display: none; }
/* An empty player is the one case with nothing to show: no plate either, so it stays as
   quiet as it was before any of this. */
#now-head.no-track {
  background: none;
  padding-left: 0;
  padding-right: 0;
}
#now-head-go {
  grid-area: arrow;
  align-self: center;
  font-size: 0.85rem;
  color: hsl(var(--h) 95% 72%);
}
/* Both halves give way rather than one shoving the other out: a long track name and a
   long album name are both ordinary here. */
#now-title,
#now-artist {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#now-title { grid-area: title; font-weight: 600; font-size: 1.05rem; }
#now-artist { grid-area: artist; color: #aaa; font-size: 0.9rem; }
.now-sep { color: #6d6d7a; }
.now-album { color: #c0c0cc; text-decoration: none; }
audio { display: none; }

/* --- transport ------------------------------------------------------------------
   The native <audio controls> is a white pill that looks borrowed from another site
   and cannot be themed consistently across browsers. This is the same engine with
   controls that belong here. */
.transport {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  /* Roomier than it was: at the old height the play button sat flush with the pill and
     was easy to lose among the numbers either side of it. */
  padding: 0.5rem 0.9rem 0.5rem 0.5rem;
  background: #15151c;
  --cut: 9px;
  border-radius: 2px;
}
.transport button {
  flex: none;
  padding: 0;
  width: 2.1rem;
  height: 2.1rem;
  line-height: 1;
  --cut: 6px;
  border-radius: 2px;
  font-size: 0.95rem;
  background: #2a2a35;
}
#play-btn {
  width: 2.9rem;
  height: 2.9rem;
  --cut: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(180deg, #3a3a48 0%, #24242e 100%);
  color: hsl(var(--h) 95% 72%);
}
#play-btn svg {
  width: 46%;
  height: 46%;
  fill: currentColor;
}
#play-btn:hover {
  background: linear-gradient(180deg, #4a4a5c 0%, #2e2e3a 100%);
  color: hsl(var(--h) 100% 85%);
}
#mute-btn { width: 1.9rem; height: 1.9rem; font-size: 0.8rem; }

/* Times and the bar travel together so the whole group can drop to its own row on a
   phone, where three buttons plus two readouts leave the bar nothing to live on. */
.seek-row {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

#seek {
  flex: 1;
  min-width: 0;
  height: 18px;
  margin: 0;
  background: transparent;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}
/* The played part burns, the rest is cold - same language as the disco button. */
#seek::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 2px;
  background: linear-gradient(to right, #d4551a var(--p, 0%), #33333f var(--p, 0%));
}
#seek::-moz-range-track {
  height: 4px;
  border-radius: 2px;
  background: linear-gradient(to right, #d4551a var(--p, 0%), #33333f var(--p, 0%));
}
#seek::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 13px;
  height: 13px;
  margin-top: -4.5px;
  border: none;
  border-radius: 50%;
  background: #ffb070;
  box-shadow: 0 0 7px rgba(255, 150, 60, 0.75);
}
#seek::-moz-range-thumb {
  width: 13px;
  height: 13px;
  border: none;
  border-radius: 50%;
  background: #ffb070;
  box-shadow: 0 0 7px rgba(255, 150, 60, 0.75);
}
#seek:focus-visible { outline: 2px solid #c2521f; outline-offset: 4px; }
/* Prev and next flank play, the way every player has arranged them since tape decks:
   they answer the same question, and a pair of buttons stranded in their own column
   beside the disco button read as leftovers. */
#prev-btn,
#next-btn {
  display: flex;
  align-items: center;
  justify-content: center;
}
/* One drawing, two directions: the boulder falls, so a quarter turn clockwise rolls it
   back and a quarter turn the other way rolls it forward. */
#prev-btn svg,
#next-btn svg {
  width: 58%;
  height: 58%;
  fill: currentColor;
}
#prev-btn svg { transform: rotate(90deg); }
#next-btn svg { transform: rotate(-90deg); }
/* --- Chaos Moon Disco ----------------------------------------------------------
   Deliberately not one of the transport buttons: it is not "one more way to skip a
   track", it is the whole channel on fire. So it sits apart and looks it. */
#radio-btn {
  flex: none;
  align-self: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  min-width: 8.5rem;
  padding: 0.9rem 1.2rem;
  --cut: 12px;
  border-radius: 2px;
  /* The fill stays deep no matter which hue comes up: white on hsl(h 85% 24%) clears
     5:1 for every hue, where the brighter fill it started with dropped to 1.3 on
     yellow. The colour still reads as vivid - it lives in the rim, the glow and the
     moon, none of which carry text. */
  background: radial-gradient(
    120% 90% at 50% 115%,
    hsl(var(--h) 85% 24%) 0%,
    hsl(var(--h) 80% 15%) 45%,
    hsl(var(--h) 45% 10%) 100%
  );
  color: #fff;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  cursor: pointer;
  position: relative;
  /* No filter here, however tempting an outer halo is. A filter takes the element's
     whole subtree as its input, so the pulsing layer below would invalidate it on every
     frame and the browser would re-blur a full-width, clip-path'd button sixty times a
     second - which is what broke rendering on phones the moment this came into view.
     The fire lives in the fill and the coals instead, where it costs a composite. */
  box-shadow: inset 0 -12px 22px hsl(var(--h) 90% 50% / 0.22);
}

/* The breathing half of the fire lives on its own layer and animates opacity alone -
   that runs on the compositor and never repaints the button. Clipped to the octagon
   along with everything else inside, so it reads as coals under the plate. */
#radio-btn::before {
  content: "";
  position: absolute;
  inset: 0;
  /* Above the button's own background, below its icon and label - which are lifted into
     the positioned layer just after it. A negative z-index would need the parent to be
     a stacking context, and the filter that used to provide one is gone. */
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(
    130% 95% at 50% 118%,
    hsl(var(--h) 95% 55% / 0.6) 0%,
    hsl(var(--h) 90% 50% / 0) 72%
  );
  animation: ember 3.4s ease-in-out infinite;
}
#radio-btn svg {
  width: 38px;
  height: 38px;
  fill: hsl(var(--h) 95% 72%);
  filter: drop-shadow(0 0 7px hsl(var(--h) 95% 60% / 0.85));
}
#radio-btn > svg,
#radio-btn > span { position: relative; }
#radio-btn:hover { color: #fff; }
#radio-btn.active { color: #fff; }
#radio-btn.active::before { animation: blaze 1.1s ease-in-out infinite alternate; }

@keyframes ember {
  0%, 100% { opacity: 0.35; }
  50%      { opacity: 0.75; }
}
@keyframes blaze {
  from { opacity: 0.6; }
  to   { opacity: 1; }
}

/* Flickering fire is exactly the kind of motion that makes some people ill. */
@media (prefers-reduced-motion: reduce) {
  #radio-btn::before { animation: none; opacity: 0.5; }
  #radio-btn.active::before { animation: none; opacity: 0.85; }
}

.credits {
  max-width: 960px;
  margin: 2.5rem auto 0;
  color: #555;
  font-size: 0.75rem;
  text-align: center;
}
.credits a { color: #777; }

/* What the feed is showing, and the way into the collection - one row, opposite ends.
   Stacked they collided: the collection button carried its own margins and sat on top
   of the way back out of an album. */
.feed-bar {
  display: flex;
  align-items: center;
  /* One row at every width: the search box belongs beside the collection button, not
     stacked above it. The box is the half that gives way when the room runs out. */
  flex-wrap: nowrap;
  gap: 0.5rem;
  margin: 0.9rem 0.2rem 0.2rem;
}
/* Takes whatever the row has left. A fixed basis left it stranded mid-width on a narrow
   phone with dead space beside it; #list-info only ever holds the way out of a narrowed
   view, and the search box is hidden whenever that is on screen, so they never compete
   for the room. */
.search-wrap {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
}
#search {
  flex: 1;
  min-width: 0;
  padding: 0.5rem 2.4rem 0.5rem 0.8rem;
  background: #15151c;
  border: 1px solid #2e2e3a;
  border-radius: 3px;
  color: #dcdce6;
  font: inherit;
  font-size: 0.88rem;
}
#search::placeholder { color: #6d6d7a; }
/* The browser's own clear button is a few pixels of hit area and Firefox has none. */
#search::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
#search-clear {
  position: absolute;
  top: 0;
  right: 0;
  width: 2.4rem;
  height: 100%;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  border-radius: 3px;
  color: #7e7e8c;
  font-size: 0.85rem;
  line-height: 1;
}
#search-clear:hover { color: #dcdce6; }
#search:focus {
  outline: none;
  border-color: hsl(var(--h) 45% 40%);
}
/* Empty it still cost the row a gap, which the search box could have had. */
#list-info:empty { display: none; }
#list-info {
  flex: none;
  min-width: 0;
  color: #6e6e7c;
  font-size: 0.78rem;
}
#category-create-form { display: flex; gap: 0.4rem; }
#category-name-input {
  background: #14141a;
  border: 1px solid #444;
  border-radius: 6px;
  color: #eee;
  padding: 0.35rem 0.6rem;
  font-size: 0.85rem;
  width: 11rem;
}
#category-create-form button { padding: 0.35rem 0.7rem; font-size: 0.85rem; }

/* --- post cards ----------------------------------------------------------------- */
#post-list { display: flex; flex-direction: column; gap: 1rem; }
.post-card {
  position: relative;
  background: #1e1e26;
  border-radius: 3px;
  padding: 0.9rem 1.1rem;
  /* The feed only grows - a few screens in it is a hundred cards and ten thousand
     nodes, and every one of them was being laid out and painted on every frame.
     This one skips all of that for anything off screen; `auto` in the size hint lets
     the browser remember what each card actually measured, so the scrollbar behaves. */
  content-visibility: auto;
  contain-intrinsic-size: auto 600px;
}
.post-card-header {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  margin-bottom: 0.6rem;
}
.post-date {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: #999;
  font-size: 0.85rem;
  flex: 1;
  padding-top: 0.35rem;
}
.post-link {
  color: #7aa2f7;
  text-decoration: none;
  font-size: 0.9rem;
}
.post-link:hover { text-decoration: underline; }
.post-text {
  white-space: pre-wrap;
  font-size: 0.9rem;
  line-height: 1.4;
  color: #ccc;
  margin-bottom: 0.6rem;
}

.track-row {
  display: flex;
  gap: 0.75rem;
  align-items: center;
  padding: 0.5rem 0.4rem;
  border-radius: 8px;
  cursor: pointer;
  position: relative;
}
.track-row.playing { background: #33334a; }
.track-row img { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; background: #333; flex-shrink: 0; }
.track-row .meta { min-width: 0; flex: 1; }
.track-row .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track-row .artist { color: #999; font-size: 0.85rem; }
.track-row .cats { margin-top: 0.3rem; }
.track-row .cats .chip { padding: 0.1rem 0.5rem; font-size: 0.72rem; }

.empty-hint { color: #888; padding: 2rem 0; text-align: center; }

/* Wide screens only: the player stays in reach while a long list scrolls. On a phone
   the same block is tall enough to eat half the screen, so it stays in the flow. */
@media (min-width: 641px) {
  .search-wrap { max-width: 22rem; }
  #player-slot {
    position: sticky;
    top: 0;
    z-index: 20;
  }
  /* Riding over the feed, the player was the same colour as the cards passing under it.
     A border cannot help - the bevel would cut it into loose bars - so the rim is drawn
     as two stacked plates, the same way the docked bar does it on a phone: the player's
     own box is the edge, and a layer inset by 2px carries the face. Cut 2px shallower
     than the outer polygon so the edge keeps an even width around the corners. */
  .player { background-color: #6b4a2e; }
  .player::before {
    content: "";
    position: absolute;
    inset: 2px;
    background: #1e1e26;
    clip-path: polygon(
      11px 0,
      calc(100% - 11px) 0,
      100% 11px,
      100% calc(100% - 11px),
      calc(100% - 11px) 100%,
      11px 100%,
      0 calc(100% - 11px),
      0 11px
    );
  }
  .player > * { position: relative; }
  /* A shadow, not a box-shadow: the bevel clips that away, which is why the one that
     used to be here never showed. This one follows the polygon. */
  .player { filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.55)); }
  /* The disco runs the full height of the player rather than floating at one end of it.
     It is the one control that is not about the track playing, so it reads better as
     its own column beside the rest than as a tile among them. */
  #radio-btn { align-self: stretch; }

  /* The player keeps one height whether or not anything is playing. Empty, the name
     plate collapses and the block was 32px shorter, so the whole feed jumped down the
     moment the first track started. */
  .player { min-height: 10.6rem; }
}

/* --- narrow screens ------------------------------------------------------------
   The browser's own audio element drops its progress bar when squeezed, so everything
   in the player gets the full width instead of sharing one row. */
@media (max-width: 640px) {
  body { padding: 0 0.75rem 3rem; }

  .player {
    flex-direction: column;
    /* A wrapping column sizes its line from the widest item, so one long track name -
       and the disco serves plenty - stretched the whole player past its own box and
       carried the controls off the right edge with it. A column has nothing to wrap. */
    flex-wrap: nowrap;
    align-items: stretch;
    gap: 0.75rem;
    padding: 0.85rem;
  }

  /* No thumbnail square here at all. Before anything plays the player is just its
     controls; once a track starts, its cover becomes the backdrop of the whole block. */
  #artwork { display: none; }

  .player {
    position: relative;
    background-image: var(--cover, none);
    background-size: cover;
    background-position: center;
    overflow: hidden;
  }
  /* Album art is unpredictable - white sleeves, busy artwork, bright reds. A scrim
     between the cover and the controls is what keeps them readable on any of it. */
  .player.has-cover::before {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(180deg, rgba(16, 16, 22, 0.74) 0%, rgba(16, 16, 22, 0.88) 100%);
  }
  .player > * { position: relative; }

  .player.has-cover #now-title,
  .player.has-cover #now-artist {
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
  }
  .player.has-cover .transport {
    background: rgba(12, 12, 18, 0.92);
  }
  .player.has-cover .transport button {
    background: rgba(28, 28, 36, 0.92);
    filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.85));
  }

  #now-title,
  #now-artist {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .transport { gap: 0.45rem; }

  /* Two rows on a phone: buttons above, the bar across the full width below. Three
     buttons and two readouts on one line would leave the bar about 80px. */
  .transport { flex-wrap: wrap; justify-content: center; padding: 0.5rem 0.7rem; }
  /* Inset from the pill wall: the bar ran edge to edge and the knob at either end sat
     half outside the rounded corner, as if it had escaped. */
  .seek-row {
    flex-basis: 100%;
    order: 2;
    padding: 0 0.55rem 0.1rem;
  }

  #radio-btn {
    width: 100%;
    min-width: 0;
    flex-direction: row;
    gap: 0.6rem;
    padding: 0.7rem 1rem;
  }

  /* Scrolled past its own slot, the player drops to the bottom of the screen where a
     thumb already is. Only in a compact form: at full height it is a third of the
     screen and would leave the feed a letterbox, so the disco button and the artist
     line - neither of which is about what is playing right now - step aside. */
  body.player-docked .player {
    position: fixed;
    left: 0.75rem;
    right: 0.75rem;
    bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
    z-index: 40;
    margin: 0;
    padding: 0.55rem 0.7rem;
    gap: 0.4rem;
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.65);
  }
  body.player-docked #now-head { margin-bottom: 0.4rem; padding: 0.3rem 0.6rem; }
  body.player-docked #now-title { font-size: 0.95rem; }
  body.player-docked #now-artist { font-size: 0.8rem; }
  body.player-docked .transport { padding: 0.35rem 0.5rem; }

  /* Docked, the block turns on its side and the disco moves into the transport strip
     (js reparents it, see syncPlayerDock), sitting past the heart with a gap so it
     reads as its own thing rather than one more skip button. Its name is what made it
     a full-width slab; at this size the moon says it on its own. */
  body.player-docked .player { flex-direction: row; align-items: center; }
  body.player-docked .now-playing { flex: 1; min-width: 0; }
  body.player-docked #radio-btn {
    flex: none;
    width: 2.9rem;
    height: 2.9rem;
    min-width: 0;
    /* All the slack in the row, so the transport keeps to itself on the left and the
       disco sits alone at the right wall. Matched to the play button: the two are the
       only things here that start something, and they should carry the same weight. */
    margin-left: auto;
    padding: 0;
    gap: 0;
    --cut: 8px;
  }
  /* The disco's auto margin was eating the whole slack, which pinned the transport
     against the left wall. A second auto margin splits it, so the controls sit in the
     middle of what is left of the row. Auto margins collapse to nothing when the row
     runs out of room, which a fixed indent would not - it would wrap the disco away. */
  body.player-docked #prev-btn { margin-left: auto; }
  body.player-docked #radio-btn > span { display: none; }
  body.player-docked #radio-btn svg { width: 24px; height: 24px; }

  /* Docked, the bar was the same colour as the cards it floats over and read as part
     of the feed. A border cannot help - the bevel would cut it into loose bars, and an
     outer shadow is clipped away by the same polygon - so the rim is drawn as two
     stacked plates: the player's own box is the edge, and a layer inset by 2px carries
     the face. The cover backdrop steps aside here; at bar height it was texture, and
     texture is what it was getting lost in. */
  body.player-docked .player {
    background-color: #6b4a2e;
    background-image: none;
  }
  body.player-docked .player::before {
    content: "";
    position: absolute;
    inset: 2px;
    background: #22222c;
    clip-path: polygon(
      11px 0,
      calc(100% - 11px) 0,
      100% 11px,
      100% calc(100% - 11px),
      calc(100% - 11px) 100%,
      11px 100%,
      0 calc(100% - 11px),
      0 11px
    );
  }
  /* Room under the last line for the bar sitting on top of it - js measures the bar
     and sets it, see syncPlayerDock. A number written here goes stale the moment the
     bar changes height, and it did: the credits ended up underneath it. */
  #radio-btn svg { width: 26px; height: 26px; }

}

/* --- grain -----------------------------------------------------------------------
   A little tooth on the surfaces. Flat panels read as software; this reads as a thing
   with a surface. Generated noise, so it costs no request and cannot fail to load.
   It sits in a pseudo-element so it never fights the cover art behind the player. */
.player::after,
.post-card::after,
#post-context::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.4'/%3E%3C/svg%3E");
  opacity: 0.045;
  mix-blend-mode: overlay;
  pointer-events: none;
}

/* --- likes and the collection -----------------------------------------------------
   One heart per track, and one place the liked ones gather. */
.like-btn {
  flex: none;
  width: 2rem;
  height: 2rem;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 3px;
}
/* One icon repeated a thousand times down the feed, so it is a mask rather than an
   svg element: the shape is fetched once and cached, and each row costs a box with a
   background colour instead of 3KB of path data in the dom. */
.like-btn::before {
  content: "";
  width: 66%;
  height: 66%;
  background-color: #55555f;
  transition: background-color 0.12s ease;
  -webkit-mask: url("heart.svg") no-repeat center / contain;
  mask: url("heart.svg") no-repeat center / contain;
}
/* A touch browser leaves :hover on whatever was tapped last, until something else is
   tapped. On the heart that is indistinguishable from a like - the hover tint and the
   liked tint are both red - so un-liking looked like nothing had happened at all. None
   of this belongs on a device that cannot hover in the first place. */
@media (hover: hover) {
  #now-head:hover:not(:disabled) { background: #1c1c26; }
  .like-btn:hover { background: #24242e; border-color: #3a3a48; }
  /* Each hover shows the colour the click will leave behind: red over an empty heart,
     grey over a full one. A brighter red on the liked heart said the opposite - it
     looked like more like, when the click was about to take it away. */
  .like-btn:hover::before { background-color: #b3332a; }
  .like-btn.liked:hover::before { background-color: #7e7e8c; }
  /* The player's heart sits among the transport controls, so it behaves like one: the
     row's transparent-until-hovered treatment reads as a hole in the strip. */
  #now-like:hover:not(:disabled) { background: #3a3a48; }
  .track-row:hover { background: #24242e; }
}
.like-btn.liked::before { background-color: #c2402f; }

#precious-btn {
  flex: none;
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.9rem;
  background: #1c1c24;
  border: 1px solid #2e2e3a;
  border-radius: 3px;
  color: #b9b9c6;
  font-size: 0.88rem;
}
#precious-btn svg { width: 18px; height: 18px; fill: #3f8f6f; }
#precious-btn:hover { background: #24242e; color: #eee; }
#precious-btn.active { background: #1b2a24; border-color: #3f8f6f; color: #dff0e6; }
#precious-btn.active svg { fill: #6fd6a8; }
#precious-count { color: #6d6d7a; font-variant-numeric: tabular-nums; }
#precious-btn.active #precious-count { color: #8fc9ad; }

/* --- pressing a control ---------------------------------------------------------
   Pressing a control seats it into the plate: it sinks a hair, the socket darkens
   underneath and the rim throws a short ember flare - the way struck metal answers.
   Last in the file on purpose: it has to win over the cover-photo treatment, which
   sets a filter on the same buttons at the same specificity. */
.transport button,
#radio-btn,
#precious-btn,
.like-btn {
  transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
}
.transport button:active:not(:disabled),
#precious-btn:active {
  transform: translateY(1px) scale(0.94);
  box-shadow: inset 0 2px 7px rgba(0, 0, 0, 0.75);
  filter: drop-shadow(0 0 6px hsl(24 90% 55% / 0.5));
}
/* A heart in a track row has no socket to sink into - it flares instead. */
.like-btn:active:not(:disabled) { transform: translateY(1px) scale(0.88); }
.like-btn:active:not(:disabled)::before { filter: drop-shadow(0 0 6px hsl(4 85% 55% / 0.85)); }
/* The disco button carries a running animation, so its glow is spoken for. It sinks. */
#radio-btn:active { transform: translateY(2px) scale(0.985); }

/* The press itself stays - it is feedback, not decoration. Only the easing goes. */
@media (prefers-reduced-motion: reduce) {
  .transport button,
  #radio-btn,
  #precious-btn,
  .like-btn {
    transition: none;
  }
}

/* --- a track row on a narrow screen -----------------------------------------------
   The title had 146px of a 316px row and 131 of 257 names were cut. The furniture gives
   some of it back first - the buy link loses its own margin and a step of type, the row
   its gaps - and only then the title takes a smaller size, which fits more characters in
   the same box rather than making the box bigger. Together the name gets about a quarter
   more room. The buy link stays a word: an icon beside the heart would read as a second
   heart, and nobody would guess it means bandcamp. */
@media (max-width: 640px) {
  .track-row { gap: 0.5rem; }
  .buy-link { margin-left: 0.25rem; font-size: 0.72rem; }
  .track-row .title { font-size: 0.92rem; }
  .track-row .artist { font-size: 0.78rem; }
}

/* --- back out of a narrowed view -------------------------------------------------
   Sits at the left of the row under the player, opposite the collection button. Same
   button for an album and for the collection: one way out, wherever you went in. */
#view-back {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.35rem 0.7rem;
  background: #1c1c24;
  border: 1px solid #2e2e3a;
  border-radius: 3px;
  color: #b9b9c6;
  font-size: 0.85rem;
}
#view-back:hover { background: #24242e; color: #eee; }

/* ==================================================================================
   Radio Dungeon — атмосфера (вариант 1a «Факел»)
   Дополнение к site/style.css. Вставлять целиком в конец файла — всё опирается на
   существующие классы (.player, .transport, .track-row) и на переменную --h,
   которую уже крутит Chaos Moon Disco.
   Требует три правки разметки, см. handoff/README.md.
   ================================================================================== */

/* --- 1. Факел над плеером ---------------------------------------------------------
   Тёплое пятно света, которое дышит. Оттенок — тот же --h, что и у диско-кнопки,
   поэтому свет во всей странице поворачивается вместе с ней. */
/* The torch is wider than the column on purpose, and a decoration must never make the
   page scroll sideways - it did, by 51px. `clip` rather than `hidden`: hidden would turn
   this into a scroll container and the sticky player would stop sticking. */
html,
body { overflow-x: clip; }
.torchlight {
  position: absolute;
  left: 50%;
  top: -90px;
  width: min(760px, 120%);
  height: 420px;
  transform: translateX(-50%);
  pointer-events: none;
  z-index: -1;
  background: radial-gradient(closest-side, hsl(var(--h) 90% 52% / 0.2), transparent 72%);
  animation: torch-breathe 6.5s ease-in-out infinite;
}
@keyframes torch-breathe {
  0%, 100% { opacity: 0.42; transform: translateX(-50%) scale(1); }
  50%      { opacity: 0.8;  transform: translateX(-50%) scale(1.06); }
}

/* --- 2. Огонь под плитой ----------------------------------------------------------
   Спектр не полоски-эквалайзер, а языки пламени вдоль нижней кромки: плита стоит на
   огне. Каждый язык — один <i>, размеры и фазы раздаёт js (см. README).
   mix-blend-mode: screen — чтобы пламя светилось поверх плиты, а не закрашивало её. */
.player { padding-bottom: 1.4rem; }
.player .fire {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 56px;
  z-index: 0;
  pointer-events: none;
  opacity: 1;
  transition: opacity 0.5s ease;
}
/* Пауза: огонь оседает до углей, а не исчезает. Класс вешается на .player. */
.player.is-paused .fire { opacity: 0.22; }
/* Тёплый подсвет от углей — под самими языками. */
.player .fire::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(0deg, hsl(var(--h) 95% 52% / 0.3), transparent 78%);
}
.player .fire-tongues {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  padding: 0 10px;
  mix-blend-mode: screen;
  filter: blur(2.5px);
}
.player .fire-tongues i {
  flex: 1;
  height: var(--fh, 50%);
  transform-origin: bottom;
  border-radius: 46% 46% 34% 34% / 62% 62% 22% 22%;
  background: linear-gradient(
    0deg,
    hsl(var(--h) 100% 74%) 0%,
    hsl(var(--h) 95% 55% / 0.85) 38%,
    hsl(var(--h) 90% 48% / 0) 100%
  );
  animation: fire-lick var(--fd, 0.8s) ease-in-out infinite alternate;
  animation-delay: var(--fdelay, 0s);
}
@keyframes fire-lick {
  0%   { transform: scaleY(0.55) skewX(-7deg); opacity: 0.5; }
  23%  { transform: scaleY(1.05) skewX(5deg);  opacity: 0.95; }
  41%  { transform: scaleY(0.7)  skewX(-4deg); opacity: 0.65; }
  62%  { transform: scaleY(1.25) skewX(7deg);  opacity: 1; }
  80%  { transform: scaleY(0.8)  skewX(-6deg); opacity: 0.75; }
  100% { transform: scaleY(0.6)  skewX(3deg);  opacity: 0.55; }
}
/* Пристыкованный плеер на телефоне — это узкая полоса, огонь в ней не помещается. */
body.player-docked .player .fire { display: none; }

/* Контент плеера всегда поверх огня. В узком брейкпоинте это правило уже есть,
   здесь оно нужно и на широком экране. */
.player > *:not(.fire) { position: relative; }

/* --- 3. Проходящий свет по плите --------------------------------------------------
   Медленный блик слева направо: поверхность перестаёт быть мёртвой, даже когда
   ничего не происходит. */
.player .glint {
  position: absolute;
  top: 0;
  left: 0;
  width: 38%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  background: linear-gradient(100deg, transparent, hsl(var(--h) 90% 60% / 0.07), transparent);
  animation: glint-sweep 9s linear infinite;
}
@keyframes glint-sweep {
  0%   { transform: translateX(-60%); }
  100% { transform: translateX(260%); }
}

/* --- 4. Оттенок вечера на кнопках транспорта --------------------------------------
   Раньше --h жил только в play, перемотке и диско. Теперь prev/next тоже
   поворачиваются вместе со всем остальным — насыщенность низкая, так что при любом
   выпавшем оттенке кнопка остаётся тёмной, а иконка читается. */
.transport #prev-btn,
.transport #next-btn {
  background: hsl(var(--h) 22% 18%);
  color: hsl(var(--h) 45% 78%);
}
.transport #prev-btn:hover,
.transport #next-btn:hover {
  background: hsl(var(--h) 26% 24%);
  color: hsl(var(--h) 55% 86%);
}
#play-btn {
  background: linear-gradient(180deg, hsl(var(--h) 26% 27%) 0%, hsl(var(--h) 28% 16%) 100%);
}
#play-btn:hover {
  background: linear-gradient(180deg, hsl(var(--h) 30% 34%) 0%, hsl(var(--h) 30% 21%) 100%);
}

/* Играющая строка в списке светится тем же оттенком - фоном, без полоски сбоку:
   обрезок рамки вдоль одного края читается как недорисованная рамка. */
.track-row.playing { background: #22222e; }

/* --- 5. Play/pause прямо на обложке трека -----------------------------------------
   Из бэклога (п. 3): строка перестаёт быть кнопкой «играть с начала». Кнопка живёт
   поверх миниатюры, полупрозрачная, и показывает настоящее состояние звука. */
.track-row .thumb {
  position: relative;
  flex-shrink: 0;
  width: 42px;
  height: 42px;
}
.track-row .thumb img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 4px;
  background: #22222b;
}
.row-play {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: 4px;
  background: rgba(8, 8, 12, 0.55);
  color: #dcdce6;
  cursor: pointer;
  transition: background 0.15s ease, opacity 0.15s ease;
}
.row-play svg { width: 42%; height: 42%; fill: currentColor; }
/* Пауза рисуется двумя полосками, чтобы не тащить вторую иконку. */
.row-play .bars { display: none; gap: 3px; }
.row-play .bars span { width: 4px; height: 13px; background: currentColor; }
.row-play[aria-pressed="true"] .bars { display: flex; }
.row-play[aria-pressed="true"] svg { display: none; }
/* У играющего трека кнопка в цвете вечера и не прячется. */
.track-row.playing .row-play { color: hsl(var(--h) 95% 76%); }
@media (hover: hover) {
  .row-play { opacity: 0; }
  .track-row:hover .row-play,
  .row-play:focus-visible { opacity: 1; }
  .track-row.playing .row-play { opacity: 1; }
  .row-play:hover { background: rgba(8, 8, 12, 0.78); }
}
.row-play:focus-visible { outline: 2px solid hsl(var(--h) 85% 55%); outline-offset: 2px; }



/* --- Меньше движения ---------------------------------------------------------------
   Мерцающий огонь — ровно тот случай, от которого людям делается плохо. Свет и
   пламя остаются как статичная подсветка, дрожь уходит. */
@media (prefers-reduced-motion: reduce) {
  .torchlight { animation: none; opacity: 0.55; }
  .player .fire-tongues i { animation: none; transform: scaleY(0.85); }
  .player .glint { animation: none; opacity: 0.5; }
}

/* --- what the face says ------------------------------------------------------------
   A comic speech balloon where the track name would be, with a tail pointing back at
   whoever is talking. It only exists before the first track starts, which is also the
   only time that space is empty. */
#hail {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0 0 0.55rem;
}
/* A frame, or the drawing dissolves into the panel behind it - the same dark the player
   is made of. On a wide screen this one stays hidden: the 96px portrait beside the
   controls is already doing the job. */
#hail-face {
  display: none;
  flex: none;
  width: 54px;
  height: 54px;
  background-color: #24242f;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 68%;
  border: 1px solid #3a3a48;
  border-radius: 4px;
}
/* Round and lighter than everything around it: a balloon in the same dark metal as the
   rest stops reading as speech at all. Weathered stone rather than paper, though - at
   paper white it tore a hole in the page. The hue still turns with the evening, it just
   sits low and desaturated. */
#hail-line {
  position: relative;
  width: fit-content;
  max-width: 100%;
  padding: 0.5rem 0.95rem;
  background: hsl(var(--h) 16% 66%);
  border-radius: 14px;
  color: hsl(var(--h) 45% 11%);
  font-weight: 700;
  font-size: 0.95rem;
  line-height: 1.25;
  letter-spacing: 0.02em;
}
/* The tail: a plain css triangle aimed at the face on its left. */
#hail-line::before {
  content: "";
  position: absolute;
  top: 50%;
  left: -12px;
  width: 0;
  height: 0;
  transform: translateY(-50%);
  border-top: 8px solid transparent;
  border-bottom: 8px solid transparent;
  border-right: 13px solid hsl(var(--h) 16% 66%);
}
@media (max-width: 640px) {
  /* Nothing is playing, so the name plate is an empty 14px band above the balloon -
     visible as a gap at the top of the player and belonging to nobody. It goes, and the
     portrait grows into what it was holding. */
  #now-head:disabled { display: none; }
  #hail-face {
    display: block;
    width: 76px;
    height: 76px;
  }
}

/* --- search hits -------------------------------------------------------------------
   Marked, not recoloured: the text around a hit still has to read normally, and a post
   can be five lines of capitals with the query scattered through it. */
mark {
  background: hsl(var(--h) 55% 30%);
  color: #fff;
  border-radius: 2px;
  padding: 0 1px;
}
