/* ═══════════════════════════════════════════════════════════
   Layout: expanding colour panels

   Nine columns of flat station colour on a #222 page — flat and
   2D. No gradients, no outer glows, no drop shadows, no blurred
   artwork.

   The colour is the column. Everything drawn on it — the wordmark,
   the track, the controls — uses --c-ink, the readable ink for that
   station, which is Joozic Grey on all nine of the current set.

   Stacking inside a panel:
     3  hit     full-size select/toggle button
     4  spine   era wordmark        (pointer-events: none)
     5  open    expanded content    (controls only are clickable)
   ═══════════════════════════════════════════════════════════ */

/* Two screens to begin with: the splash, then the wall. Snapping is mandatory
   so a scroll can never rest between them, and scroll-snap-stop keeps a fast
   flick from carrying past. It relaxes while a station is open, when the wall
   is taller than the viewport on a phone and mandatory would fight you.

   Once the splash has been passed it is removed from the document, and the
   whole arrangement stops applying: one screen, nothing to snap. */
body { scroll-snap-type: y mandatory; }
body.has-station,
body.is-past-splash { scroll-snap-type: none; }

.splash,
.wall {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  min-height: var(--app-height, 100vh);
}

.wall {
  display: flex;
  flex-direction: column;
  background: var(--grey);
  transition: background-color 0.4s linear;
}

/* Open a station and the wall takes its colour. The splash keeps its teal —
   it is a different screen. */
body.has-station .wall {
  background: var(--accent);
  color: var(--accent-ink);
}

.stage {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;

  /* Left and right only. The tickers sit above and below and supply the
     vertical spacing themselves — adding the wall inset here as well stacked
     the two, leaving more space between the ticker and the columns than
     between the ticker and the screen edge. The ≤860px block puts the vertical
     padding back, where there are no tickers. */
  padding:
    0
    calc(var(--wall-inset, 12px) + env(safe-area-inset-right, 0px))
    0
    calc(var(--wall-inset, 12px) + env(safe-area-inset-left, 0px));
}

.panels {
  flex: 1;
  min-height: 0;
  display: flex;
  gap: 6px;
}

/* ─────────────── Now-playing tickers ─────────────── */

/* One at each page edge, scrolling in opposite directions — same direction on
   both reads like a mistake. --ticker-duration is set by views/panels.js from
   the measured content width, so the speed stays constant in pixels per second
   however long the artist names happen to be. */
.ticker {
  flex: none;
  overflow: hidden;
  display: flex;
  align-items: center;
  /* Even space above and below the text; the safe-area inset is added on the
     outward-facing edge only. */
  padding-block: var(--ticker-gap, 12px);

  /* Held back until the stations have reported, so it arrives already correct
     instead of showing taglines and swapping them out. It still occupies its
     full height while hidden — the station names are in place from the first
     frame, only the artist slots are empty — so the columns never shift. */
  opacity: 0;   /* transition set with .panels, so they come up together */
}

/* Revealed fully. The ambient dimming belongs on the run, not here: element
   opacity takes the background with it, and on small screens the ticker has a
   background — a see-through one let the wall scroll visibly underneath and
   made the ticker read as floating over the page rather than sitting on it. */
.ticker.is-ready { opacity: 1; }

/* Ambient, not a headline — it should register out of the corner of your eye
   rather than compete with the wall. Tune with --ticker-opacity. */
.ticker__run { opacity: var(--ticker-opacity, 0.72); }

/* No safe-area inset in a browser tab. An iPad reports 25px at the bottom for
   the home indicator, which put 25px under the bottom ticker's text against the
   top ticker's 12px — a visible gap at the foot of the page. In a tab the
   browser's own chrome already keeps content clear of the hardware, and a
   marquee is decorative text that nothing needs to tap, so it can run to the
   edge like the top one does. The insets go back on when the page owns the whole
   screen — see the standalone block. */

.ticker__run {
  display: flex;
  gap: 26px;
  width: max-content;
  white-space: nowrap;
  padding-left: 26px;
  font-family: var(--display);
  font-style: italic;
  font-weight: 900;
  font-size: 0.8rem;
  animation: ticker-left var(--ticker-duration, 120s) linear infinite;
}

.ticker--bottom .ticker__run { animation-name: ticker-right; }

/* Shift by one pass of the stations rather than half the run. The content
   repeats every pass, so both loop seamlessly, but a pass comes round three
   times as often — and track changes wait for that boundary. */
@keyframes ticker-left  { to   { transform: translateX(calc(-1 * var(--ticker-shift, 50%))); } }
@keyframes ticker-right { from { transform: translateX(calc(-1 * var(--ticker-shift, 50%))); } }

.ticker__item { display: flex; align-items: baseline; gap: 7px; }
.ticker__station { color: var(--c); }
.ticker__artist { color: var(--ink-70); }

/* With a station open the page is that station's colour, so the ticker stops
   being nine colours on grey — which would be nine near-invisible colours on a
   tenth — and becomes one ink on the field. */
body.has-station .ticker__station { color: var(--accent-ink); }
body.has-station .ticker__artist  { color: color-mix(in srgb, var(--accent-ink) 55%, transparent); }
body.has-station .ticker__logo    { background-color: var(--accent-ink); }

/* The wordmark, once per pass of the stations. Drawn as a mask over a white
   fill rather than as an <img>: the file's paths carry no fill of their own, so
   as an image it would come through black. It takes the ticker's opacity from
   the container along with everything else.

   Sized from the viewBox ratio (557.56 / 200.54) in CSS rather than left to the
   file's intrinsic size, so the box is correct on the first frame. The run is
   measured to set the loop shift, and a logo that only took up space once it
   had loaded would be measured at zero. */
/* Sized to match the station names beside it, not to match their box.

   The file's box is mostly air: the lowercase bodies of "joozic" run from y=55.6
   to y=161.7 of a 200.54 viewBox, so its x-height is 0.529 of whatever height
   the box is given — the rest is the dots above and the j descender below. At a
   0.92em box that renders an x-height of 0.487em against Poppins' 0.58em, which
   reads about 19% small next to a word.

   0.58 / 0.529 = 1.096em of box to put the two x-heights on the same value.
   Baseline alignment puts the box's bottom edge on the text baseline, but the
   wordmark's own baseline is 0.806 of the way down its box, so it is pushed back
   down by the remaining 0.194 to sit on the line properly. */
.ticker__logo {
  flex: none;
  --logo-box: 1.096em;
  align-self: baseline;
  height: var(--logo-box);
  width: calc(var(--logo-box) * 557.56 / 200.54);
  position: relative;
  top: calc(var(--logo-box) * 0.194);
  background-color: #fff;
  -webkit-mask: url("../../images/joozic-logo.svg") center / contain no-repeat;
          mask: url("../../images/joozic-logo.svg") center / contain no-repeat;
}

/* ─────────────── Panel: a sheet of glass ─────────────── */

.panel {
  position: relative;
  container-type: inline-size;
  flex: 1 1 0;
  min-width: 0;
  border-radius: var(--r-md);
  overflow: hidden;
  text-align: left;

  /* Flat station colour, no glass. The wall used to be smoked panes over a navy
     page and the colour arrived only as the wordmark; the colour is the column
     now, on a #222 ground. */
  background: var(--c);
  transition: flex-grow 0.55s var(--ease),
              transform 0.25s var(--ease),
              opacity 0.4s var(--ease);
}

/* The wall no longer fades in — it is a screen below the splash, and getting
   there is a scroll rather than a hand-over. Only the ticker still waits, and
   only for its own content to be right. */
.ticker { transition: opacity 0.4s linear; }

/* Reveal for open rows on small screens, where height is content-driven */
@keyframes open-in {
  from { opacity: 0; transform: translateY(-10px); }
  to   { opacity: 1; transform: none; }
}

/* Hover lifts the column off the wall — the shelf gesture, a spine drawn out far
   enough to say it can be taken. Movement only: the colour does not change.
   A station's colour is the station, and brightening it on hover made it read as
   a slightly different station for as long as the pointer was over it. No shadow
   under it either, which would be the obvious way to sell a lift and the wrong
   one here.

   Not while it is open. An open panel has already been picked up, and a column
   that has grown to six times its width does not want to be nudged as well.

   6px, because that is what there is room for. Only 12px separates the ticker's
   text from the tops of the columns, and a transformed panel makes a stacking
   context and paints over the ticker — at 10px the two were 2px apart, which
   reads as a collision rather than a lift. Half the gap keeps the movement plain
   and the space above it intact.

   Nothing on touch, where there is no hover to speak of. */
@media (hover: hover) {
  .panel:not(.is-active):hover { transform: translateY(-6px); }
}

/* Open: the panel gives up its own fill and lets the page show through. The
   page is this station's colour by then — see body.has-station in base.css — so
   the open panel reads as the background rather than as a wider column, which
   is what puts the artwork and controls on an open field. */
.panel.is-active {
  flex-grow: 6.5;
  background: transparent;
}

/* Every column stays at full strength, open or not. The unselected ones used to
   drop to 0.6 to push the open one forward; it already has the width, the tint
   and the artwork doing that, and dimming the rest only made the wall look
   half-lit. */

/* ── The head of the spine: wordmark, index, state ── */

.panel__head {
  position: absolute;
  top: 16px;
  left: 0;
  right: 0;
  z-index: 4;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 0 8px;
  pointer-events: none;
  transition: opacity 0.3s var(--ease);
}

.panel__headLogo {
  --logo-box: 1.5rem;
  height: var(--logo-box);
  width: calc(var(--logo-box) * 557.56 / 200.54);
  max-width: 78%;
  background-color: var(--c-ink);
  -webkit-mask: url("../../images/joozic-logo.svg") center / contain no-repeat;
          mask: url("../../images/joozic-logo.svg") center / contain no-repeat;
}

/* The j at the foot, closing the column the way the wordmark opens it. Masked
   over an ink fill like the others — the file carries no colour of its own, so
   it takes the station's ink and needs no variant per station.
   Its viewBox is 84 x 203, which is where the width comes from. */
.panel__foot {
  position: absolute;
  bottom: 20px;
  left: 0;
  right: 0;
  z-index: 4;

  /* Big and held back rather than small and solid, so it reads as a watermark
     on the column instead of a second logo competing with the one at the head.
     Sized against the column's own width — a fixed height would tower over a
     collapsed spine and look lost on an expanded one. */
  height: min(190cqw, 8rem);
  /* One value for every size — the phone override below moves and reshapes the
     mark but deliberately leaves this alone, so the watermark reads the same
     weight on a column as it does on a row. */
  opacity: 0.08;

  pointer-events: none;
  background-color: var(--c-ink);
  -webkit-mask: url("../../images/joozic-j.svg") center / auto 100% no-repeat;
          mask: url("../../images/joozic-j.svg") center / auto 100% no-repeat;
  transition: opacity 0.3s var(--ease);
}

/* Open panels carry their own lockup — two would be one too many. */
.panel.is-active .panel__head,
.panel.is-active .panel__foot { opacity: 0; }

/* ── Full-size select / toggle target ── */

.panel__hit {
  position: absolute;
  inset: 0;
  z-index: 3;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  cursor: pointer;
}

/* ── Collapsed: era wordmark in the station colour, reading upward ── */

.panel__spine {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Clears the head above and the j below; the wordmark centres in what is
     left, rather than in the whole column. */
  padding: 74px 0 120px;
  opacity: 1;
  transition: opacity 0.3s var(--ease);
}

/* rotate(180deg) makes vertical-rl read bottom-to-top.
   Sized in cqw — a percentage of the panel's *own* width — so the wordmark
   fills the column at every breakpoint and can never overflow it. A viewport
   based size can't do this: the collapsed width depends on how many panels are
   expanded, not just the viewport, and it clipped at tablet sizes.
   The vw line is a fallback for browsers without container query units. */
.panel__spineMark {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-size: clamp(2rem, 4.3vw, 5.75rem);
  /* 84cqw crowded the column once it was the governing term rather than the rem
     cap. Poppins 900 italic puts 0.844em of ink across the column at its widest
     — lofi, hits and aussie all hit that — so 84cqw filled 71% of the width and
     the slant took the rest. 72 leaves it at 61%, which breathes. */
  font-size: min(72cqw, 5.75rem);
  line-height: 1;
  letter-spacing: 0;
  white-space: nowrap;
  color: var(--c-ink);

  /* Vertical position knob. Measured with canvas ink extents, the wordmarks sit
     within ~5px of optically centred at 0, so that is the default.
     Margins are laid out before the 180deg rotation, so in vertical-rl the
     inline-END edge is the bottom: a positive value here lifts the wordmark,
     a negative one drops it. Flex centring halves whatever you set — so
     --spine-nudge: 0.2em moves it up by 0.1em. */
  margin-inline-end: var(--spine-nudge, 0em);
  transition: color 0.3s var(--ease), opacity 0.4s var(--ease);
}

/* Full strength always. The wordmarks used to sit at 0.4 so the brand block
   could lead, but the block is gone by the time they are on screen — nothing
   is left to defer to, and dimming them only made the wall look unfinished. */

.panel.is-active .panel__spine { opacity: 0; }

.panel.is-active .panel__spineMark { font-size: 2rem; }

/* ── Expanded ── */

.panel__open {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;          /* clicks fall through to .panel__hit */
  visibility: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(10px, 1.5vh, 18px);
  padding: clamp(18px, 2vw, 36px);
  text-align: center;
  color: var(--c-ink);
  opacity: 0;
  transition: opacity 0.4s var(--ease) 0.12s;
}

.panel.is-active .panel__open { visibility: visible; opacity: 1; }
.panel__open button { pointer-events: auto; }

/* The era on an ink pill, in the station colour. The Joozic wordmark used to sit
   beside it — dropped, because it was saying "joozic" on a page that is already
   unmistakably Joozic, and the era is the part that says which station is open.
   The geometry note that lived here, on matching a masked logo's x-height to the
   text next to it, moved to .ticker__logo, which still does that. */
/* The artist repeated down the height of an open panel. Sits behind the rest of
   the open panel's content, which is why it is first in the markup and why
   nothing here raises it.

   Shown whenever a station is open, playing or not. Stopping does not change
   what is on the air — the station carries on without you — so the name has no
   reason to disappear when you pause it. */
.panel__wash {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;

  font-family: var(--display);
  font-style: italic;
  font-weight: 900;
  font-size: clamp(1.5rem, 3.2vw, 3.25rem);
  line-height: 1.06;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--c-ink);

  opacity: 0;
  transition: opacity 0.4s var(--ease);
  pointer-events: none;
}

.panel.is-active .panel__wash { opacity: 0.05; }

.panel__badge {
  flex: none;
  padding: 0.5em 0.8em;
  border-radius: var(--r-md);
  background: var(--c-ink);
  color: var(--c);
  font-family: var(--display);
  font-style: italic;
  font-weight: 900;
  font-size: clamp(1.1rem, 1.6vw, 1.6rem);
  line-height: 1;
  letter-spacing: -0.02em;
}

.panel__cover {
  position: relative;
  width: min(56%, 32vh);
  aspect-ratio: 1;
  flex: none;
  border-radius: var(--r-md);
  overflow: hidden;
  /* Ink-based, not white-based: these hairlines have a light colour behind them
     now, and a white one on gold is invisible. */
  background: color-mix(in srgb, var(--c-ink) 6%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-ink) 18%, transparent);

  /* Contains the tint below. Without it, mix-blend-mode blends with whatever is
     behind the cover as well — the panel, and on small screens the page — and
     the artwork would tint its surroundings rather than itself. */
  isolation: isolate;
}

.panel__cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: grayscale(1);
  opacity: 0;
  transition: opacity 0.5s var(--ease);
}

.panel__cover img.is-loaded { opacity: 1; }

/* Station colour over the greyscale artwork, blended on `color`: the hue and
   saturation come from the station, the lightness stays the sleeve's. So every
   cover lands in the palette while keeping its own shape and contrast — no two
   look alike, and none of them go muddy.

   Multiply was the obvious choice and the wrong one. It can only darken: a
   mid-grey of 64 came out at 64/31/15, dropping the sleeve to roughly two
   thirds of its brightness and leaving a dark square in the middle of a bright
   panel. Tune the strength with --art-tint. */
.panel__cover::after {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--c);
  mix-blend-mode: color;
  opacity: var(--art-tint, 1);
  pointer-events: none;
}

.panel__now {
  display: grid;
  gap: 4px;
  max-width: 30ch;
  min-width: 0;
}

.panel__track {
  font-size: clamp(1.1rem, 1.9vw, 1.75rem);
  font-weight: 600;
  line-height: 1.22;
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.panel__artist {
  font-size: clamp(0.88rem, 1.1vw, 1.05rem);
  line-height: 1.35;
  color: var(--c-ink);
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 1;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ── Play control ── */

.panel__controls {
  /* One size for both buttons. They are the same shape doing the same job side
     by side, and setting them separately let them drift: play shrank at two
     breakpoints and mute did not, so they came out 48 against 52 on a small
     phone and 44 against 52 on a short one. Overridden per breakpoint here,
     never on the buttons. */
  --control-size: 52px;

  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin-top: 2px;
}

.panel__play {
  display: grid;
  place-items: center;
  width: var(--control-size);
  height: var(--control-size);
  flex: none;
  border-radius: 50%;
  /* Inverted against the panel: the disc is the ink and the glyph is the
     station colour. The old way round put var(--c) on var(--c). */
  color: var(--c);
  background: var(--c-ink);
  transition: transform 0.22s var(--ease), filter 0.22s;
}

.panel__play:hover  { transform: scale(1.07); filter: brightness(1.08); }
.panel__play:active { transform: scale(0.96); }
.panel__play svg { width: 22px; height: 22px; }
.panel__play svg:last-child { display: none; }

.panel.is-playing .panel__play svg:first-child { display: none; }
.panel.is-playing .panel__play svg:last-child  { display: block; }

/* Mute — the only volume control on touch, where the keyboard shortcuts
   (arrow keys, M) aren't available. */
.panel__mute {
  display: grid;
  place-items: center;
  width: var(--control-size);
  height: var(--control-size);
  flex: none;
  border-radius: 50%;
  color: var(--c-ink);
  border: 1.5px solid var(--c-ink);
  background: transparent;
  font-family: var(--body);
  font-size: 0.6rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  transition: color 0.2s, border-color 0.2s, background-color 0.2s;
}

.panel__mute:hover {
  color: var(--c-ink);
  border-color: var(--c-ink);
  background: color-mix(in srgb, var(--c-ink) 8%, transparent);
}

/* Muted fills in, the same way round as the play button: ink disc, station
   colour lettering. An outline saying MUTED and a filled one saying MUTE would
   be the same button twice — the fill is what makes the state readable across
   the room, the word only confirms it. */
.panel__mute.is-muted {
  color: var(--c);
  background: var(--c-ink);
  border-color: var(--c-ink);
}

.panel__mute.is-muted:hover {
  color: var(--c);
  background: var(--c-ink);
  border-color: var(--c-ink);
  filter: brightness(1.25);
}

/* Offline stations drop the colour back */
.panel.is-offline .panel__spineMark { color: color-mix(in srgb, var(--c-ink) 38%, transparent); }

/* Narrow expanded panels: artwork takes more of the width */
@container (max-width: 400px) {
  .panel__cover { width: 68%; }
  .panel__track { font-size: 1.05rem; }
}

/* ═══════════════ Responsive ═══════════════ */

@media (max-width: 1100px) {
  .panel.is-active { flex-grow: 8; }
}

/* Tablets and phones: stack into expanding rows */
@media (max-width: 860px) {
  /* Let the *viewport* scroll rather than making <body> a scroll container:
     one scroller instead of a nested one, so overscroll-behavior on the root
     actually governs the page. Safe here — every breakpoint has been checked
     for horizontal overflow. */
  body {
    height: auto;
    min-height: var(--app-height, 100%);
    overflow: visible;
  }

  .stage { min-height: auto; }

  .panels {
    flex-direction: column;
    /* Declared as well as applied: the rows divide the leftover height nine
       ways and have to subtract the eight gaps between them. */
    --panel-gap: 6px;
    gap: var(--panel-gap);
  }

  .panel {
    /* Share the viewport rather than sitting at a fixed height: nine rows at
       74px left a dead strip of navy below them on a tall phone. They grow to
       fill, and min-height keeps them tappable once one opens and pushes the
       total past the screen.

       That floor is worked out from the screen rather than guessed, because a
       fixed 74px made nine rows taller than a 320x568 phone and put a scrollbar
       on an idle page. What is left after the wall inset, the safe areas and
       the eight gaps, divided nine ways — capped at 74px so roomier phones look
       as they did, and never under 44px, which is the smallest thing worth
       asking a thumb to hit. Below that it scrolls, and should. */
    /* --tickers-h is both tickers measured together, published by
       views/panels.js; it already includes the safe-area insets they absorb.
       The fallback is two 42px tickers, for the frame before it is set. */
    --row-fit: calc((var(--app-height, 100vh)
                     - var(--tickers-h, 84px)
                     - 8 * var(--panel-gap, 6px)) / 9);

    flex: 1 1 auto;
    min-height: clamp(44px, var(--row-fit), 74px);
    border-radius: var(--r-md);
    transition: min-height 0.45s var(--ease),
                background-color 0.35s var(--ease),
                border-color 0.35s var(--ease),
                opacity 0.4s var(--ease);
  }

  /* An open row is sized by its content, so it must not also stretch. */
  .panel.is-active { flex-grow: 0; }

  /* Open rows grow to fit their content rather than a guessed min-height. The
     content is taller on some screens than others, and a fixed 430px clipped
     the play button at tablet portrait. Taking .panel__open out of absolute
     positioning lets the row measure itself. */
  .panel__open {
    /* Relative, not static. It has to stay in the flow so the row measures
       itself against the content (see above), but static drops it out of the
       stacking order, and .panel__hit is absolutely positioned — positioned
       elements paint over non-positioned ones whatever the z-index says. The
       hit area was covering the controls, so every tap on an open row closed
       the station instead. Play looked like it worked, because closing stops
       playback too; mute plainly did not. Relative keeps the flow and lets
       z-index: 5 mean something. */
    position: relative;
    display: none;
    visibility: visible;
    opacity: 1;
    transition: none;
  }

  .panel.is-active .panel__open {
    display: flex;
    animation: open-in 0.4s var(--ease) both;
  }

  /* Tickers here too, and stuck to the edges. In normal flow they scroll away
     the moment a panel opens and the page grows past the viewport — gone
     exactly when they are wanted, which is why they used to be dropped
     entirely. Sticky keeps them in the flow, so the wall still sizes itself
     around them and nothing needs padding to clear them, while pinning them to
     the viewport once the page starts moving.

     They keep absorbing the top and bottom safe areas, as on desktop, so
     .stage keeps the base rule's zero vertical padding. */
  .ticker {
    position: sticky;
    z-index: 40;
    /* Opaque, or the wall scrolls through them. Follows the page, including
       when an open station repaints it. */
    background: var(--grey);
    transition: opacity 0.4s linear, background-color 0.4s linear;
  }

  body.has-station .ticker { background: var(--accent); }

  .ticker--top    { top: 0; }
  .ticker--bottom { bottom: 0; }

  /* Collapsed rows read horizontally, name centred across the row. */
  .panel__spine {
    justify-content: center;
    align-items: center;
    gap: 12px;
    /* Even, so the name centres on the row. It was 26 left and 18 right, from
       when the text was left-aligned against the edge. */
    padding: 0 26px;

    /* Sized to its content and centred, rather than stretched to the row: a
       stretched box pins the text to the top and leaves dead space under it. */
    top: 50%;
    bottom: auto;
    transform: translateY(-50%);
  }

  /* Only the number here. The wordmark and the state need a column of height to
     stack in, and a row does not have it — nine of them repeating the same logo
     across the screen would be noise besides. */
  .panel__head {
    top: 50%;
    left: 22px;
    right: auto;
    transform: translateY(-50%);
    padding: 0;
  }

  /* LIVE is a desktop-only mark. This is the breakpoint where the wall turns
     from columns into rows, so a spine stops being a tall narrow thing with
     room to spare and starts being a row that has to earn every character. */
  /* The head carried the number here, with the wordmark hidden. There is no
     number now, so there is nothing left in it to show. */
  .panel__head { display: none; }

  /* The j stays, set against the trailing edge and sized to the row's height —
     the column rule measures against width, which on a row is four times the
     height and would put it well outside. */
  .panel__foot {
    top: 9px;
    bottom: 9px;
    left: auto;
    right: 20px;
    width: 40px;
    height: auto;
    -webkit-mask-size: auto 100%;
            mask-size: auto 100%;
  }
  
  .panel__spineMark {
    writing-mode: horizontal-tb;
    transform: none;
    font-size: 1.9rem;
    font-size: 1.75rem;
    letter-spacing: -0.03em;
    margin-inline-start: 0;   /* nudge is for the vertical orientation only */
    flex: none;
  }

  .panel.is-active .panel__spineMark { font-size: 1.75rem; }

  .panel__open { padding: 22px 16px; gap: 12px; }
  .panel__badge { font-size: 1.1rem; }
  .panel__cover { width: min(58%, 200px); }
  .panel__track { font-size: 1.1rem; }
  .panel__artist { font-size: 0.9rem; }
}

@media (max-width: 420px) {
  .panels { --panel-gap: 5px; }
  /* Same rule as above, one size down: small phones lose the 74px ceiling. */
  .panel { min-height: clamp(44px, var(--row-fit), 64px); }
  .panel__spineMark { font-size: 1.75rem; }
  .panel__spine { gap: 9px; padding: 0 20px; }
  .panel__cover { width: min(64%, 175px); }
  .panel__controls { --control-size: 48px; }
}

/* Short viewports — landscape phones, small windows. The open panel has to fit
   the screen, so the artwork gives up height first. */
@media (max-height: 560px) {
  .panel__cover { width: min(34%, 24vh); }
  .panel__badge { font-size: 1rem; padding: 0.42em 0.7em; }
  .panel__open { gap: 8px; padding: 12px; }
  .panel__track { font-size: 1rem; }
  .panel__artist { font-size: 0.82rem; }
  .panel__controls { --control-size: 44px; }
  .panel__play svg { width: 19px; height: 19px; }
}

/* The row floor used to be pinned to 58px here. It is worked out from the
   screen now — see --row-fit — which covers this case and the ones between. */

/* ─────────────── Installed as a web app ─────────────── */

/* iOS reports every other height source short in a home-screen app: 100dvh,
   100% of a full-height <html>, and window.innerHeight all come back smaller
   than the screen, leaving a dead band below the last row.
   A standalone app has no URL bar, which is exactly what makes 100vh
   unreliable in a browser tab — so here it is the dependable one. max() takes
   whichever candidate is largest, so this can only ever fill more, not less. */
/* Keyed to a class, not to `display-mode: standalone`. iOS decides that query
   from a web manifest, and this site has none — it is installable through the
   Apple meta tags alone — so the query is false in the very place these rules are
   for. app.js sets .is-standalone from navigator.standalone as well as the query,
   which covers iOS and everything else. */
.is-standalone body { min-height: max(100vh, var(--app-height, 0px)); }

/* Here the insets earn their keep: with no browser chrome, and a translucent
   status bar the page is drawn underneath, the tickers would otherwise run below
   the clock and over the home indicator. */
.is-standalone .ticker--top {
  padding-top: max(var(--ticker-gap, 12px), env(safe-area-inset-top, 0px));
}
.is-standalone .ticker--bottom {
  padding-bottom: max(var(--ticker-gap, 12px), env(safe-area-inset-bottom, 0px));
}

@media (max-width: 620px) {
  .ticker__run { font-size: 0.72rem; gap: 20px; padding-left: 20px; }
}

@media (prefers-reduced-motion: reduce) {
  .panel { animation: none; }

  /* The lift stays — it is an affordance, and a hover state that does not change
     is worse than useless — but it arrives at once rather than travelling. */
  .panel { transition-property: flex-grow, opacity; }
  /* A scrolling marquee is exactly what this preference is asking us not to
     do. Both selectors are needed: .ticker--bottom .ticker__run sets
     animation-name at higher specificity, so a bare .ticker__run rule here
     would stop the top ticker and leave the bottom one running. */
  .ticker__run,
  .ticker--bottom .ticker__run { animation: none; }
}
