/* Grinder — direction A, "frosted glass".
 *
 * The approved skin: translucent cards floating over a glowing brand
 * wallpaper, black + violet→magenta, colour reserved almost entirely for the
 * thing that is PLAYING. Founder's edits, all binding:
 *   * the logo file alone in the header — no wordmark text, no letter art;
 *   * no bar counts on tiles;
 *   * the playing tile wears a white line tracing its border, completing
 *     exactly once per loop — this replaces the old bottom progress bar.
 *
 * The ring is driven from the audio clock in app.js, which writes --sweep every
 * animation frame. It is NOT a CSS animation: a CSS animation runs on its own
 * timer and would drift away from the music within a minute, and this whole rig
 * exists to be the one thing in the room that never drifts.
 *
 * Everything marked KEEP below was learned the hard way. Read the comment
 * before removing one.
 */

:root {
  --base: #0b0713;

  --violet: #7b3fe4;
  --violet-lo: #5b2bb8;
  --magenta: #d63ac4;
  --pink: #f0459b;
  --cue: #ffc46b;

  --grad-both: linear-gradient(112deg, #f0459b 0%, #d63ac4 46%, #7b3fe4 100%);
  --grad-beat: linear-gradient(112deg, #f0459b 0%, #d63ac4 100%);
  --grad-vocal: linear-gradient(112deg, #7b3fe4 0%, #5b2bb8 100%);

  --text: #f2eef8;
  --text-dim: rgba(255, 255, 255, 0.58);
  --text-faint: rgba(255, 255, 255, 0.36);

  /* One glass recipe, used everywhere, so no pane drifts out of family. */
  --glass-bg: rgba(255, 255, 255, 0.09);
  --glass-line: rgba(255, 255, 255, 0.14);
  --glass-blur: blur(22px) saturate(1.5);

  --radius: 22px;
  --radius-sm: 14px;
  --tap: 44px; /* nothing interactive is ever smaller than this */

  --serif:
    "Instrument Serif", "Iowan Old Style", "Palatino Linotype", Palatino,
    Georgia, "Times New Roman", serif;
  --sans: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
  --mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
}

* {
  box-sizing: border-box;
}

/* KEEP.
   The hidden attribute only works because the browser's own stylesheet says
   display:none, which ANY display rule below beats on specificity. .boot, .rig
   and .debug all set display:grid, so without this the loading overlay never
   goes away and sits on top of a fully working app — exactly the "stuck loading
   screen" the founder hit on first use. Nothing raises an error when it
   happens; the page simply looks broken. */
[hidden] {
  display: none !important;
}

html,
body {
  margin: 0;
  height: 100%;
  /* KEEP. dvh tracks iOS Safari's collapsing address bar. Without it the
     transport and crossfader sit underneath the browser chrome on a phone. */
  height: 100dvh;
  color: var(--text);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
  overscroll-behavior: none;
  /* KEEP. Kills the 300ms double-tap-to-zoom delay, so a section tap feels
     immediate. On a rig where taps land on bar lines, 300ms is a whole beat. */
  touch-action: manipulation;
  -webkit-text-size-adjust: 100%;
}

body {
  /* The brand wallpaper: three coloured lights behind everything. Fixed, so it
     does not slide around while a section column scrolls. */
  background:
    radial-gradient(
      90% 55% at 12% 92%,
      rgba(240, 69, 155, 0.55),
      transparent 60%
    ),
    radial-gradient(
      100% 70% at 88% 6%,
      rgba(123, 63, 228, 0.65),
      transparent 62%
    ),
    radial-gradient(
      60% 40% at 60% 55%,
      rgba(214, 58, 196, 0.25),
      transparent 70%
    ),
    var(--base);
  background-attachment: fixed;
  overflow-x: hidden; /* the 375px pass must never regress */
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

/* One reusable pane. Everything that floats is built from this. */
.glass,
.deck-head,
.crossfader,
.transport,
.addbar,
.debug {
  background: var(--glass-bg);
  border: 1px solid var(--glass-line);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  border-radius: var(--radius);
}

/* ------------------------------------------------------------------ boot */

.boot {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  z-index: 50;
  padding: 24px;
}

.boot-inner {
  width: min(360px, 84vw);
  text-align: center;
}

/* The logo file alone. No wordmark text beside it — founder's explicit edit. */
.boot-logo,
.brand-logo {
  display: block;
  margin: 0 auto;
  width: auto;
  height: 68px;
}

.boot-tag {
  font-family: var(--serif);
  font-size: clamp(18px, 3.4vw, 24px);
  color: rgba(255, 255, 255, 0.7);
  margin: 16px 0 28px;
}

.boot-bar {
  height: 3px;
  background: rgba(255, 255, 255, 0.16);
  border-radius: 3px;
  overflow: hidden;
}

.boot-bar span {
  display: block;
  height: 100%;
  width: 0;
  border-radius: 3px;
  background: var(--grad-both);
  transition: width 160ms linear;
}

.boot-status {
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.66);
  margin: 12px 0 0;
}

.boot-note {
  margin: 6px 0 0;
  font-size: 12px;
  line-height: 1.55;
  color: var(--text-faint);
}

.boot.is-error .boot-status {
  color: var(--cue);
}

/* ------------------------------------------------------------------- rig */

.rig {
  height: 100%;
  display: grid;
  /* decks | transport | debug. The logo row, the add-song row and now the
     crossfader row are gone: each was a full row of the phone for one
     control. The crossfader hides behind the transport bar instead. */
  grid-template-rows: 1fr auto auto;
  gap: 10px;
  max-width: 1100px;
  margin: 0 auto;
  padding: 8px 12px calc(12px + env(safe-area-inset-bottom, 0px));
}

.brand {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4px 0 0;
}

.brand-logo {
  height: 38px;
}

/* ----------------------------------------------------------------- decks */

.decks {
  display: grid;
  grid-template-columns: 1fr 14px 1fr;
  gap: 8px;
  min-height: 0; /* lets the section columns scroll instead of the page */
}

.deck {
  /* Positioned so the song picker can overlay this deck rather than the page -
     without it the list anchors to the viewport and lands over the wrong deck. */
  position: relative;
  display: grid;
  grid-template-rows: auto 1fr;
  gap: 8px;
  min-height: 0;
  min-width: 0;
}

/* The picker is a child of this header and has to paint over the section
   column below it. Every tile sets backdrop-filter, which makes each one its
   own stacking context, so the picker's own z-index is not enough on its own -
   the header it lives in needs a context that outranks the tiles. Without this
   the song list is drawn underneath the blocks. */
.deck-head {
  position: relative;
  z-index: 2;
  padding: 12px 12px 14px;
  min-width: 0;
}

.deck-id {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 8px;
  min-width: 0;
}

.deck-letter {
  font-family: var(--serif);
  font-size: 21px;
  line-height: 1;
  color: #fff;
}

.deck-role {
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.song-pick {
  display: grid;
  grid-template-columns: var(--tap) 1fr var(--tap);
  align-items: center;
  gap: 4px;
  min-width: 0;
}

/* Loop / flow. The engine plays ON through sections by default now; this is
   the per-deck switch to hold one section instead. Lit when looping. */
.mode-row {
  display: flex;
  justify-content: center;
}
.mode-btn {
  min-height: 34px;
  min-width: 96px;
  padding: 0 22px;
  border-radius: 999px;
  font: 600 10px/1 var(--sans);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.14);
}
.mode-btn[aria-pressed="true"] {
  color: #1c1030;
  background: rgba(255, 255, 255, 0.92);
  border-color: transparent;
}

.cycle {
  height: var(--tap);
  border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  font-size: 20px;
  line-height: 1;
  transition:
    background 120ms ease,
    transform 120ms ease;
}

.cycle:hover {
  background: rgba(255, 255, 255, 0.2);
}

.cycle:active {
  transform: scale(0.94);
}

.song-title {
  margin: 0;
  text-align: center;
  font-weight: 600;
  line-height: 1.15;
  color: #fff;
  min-width: 0;
}

/* The name is what gets clamped, not the button - so the caret beside it is
   never the thing that falls off the end. Two lines, then an ellipsis. Words
   are never broken in the middle: "Blindin / g" is not a song anyone can find.
   The -webkit- properties are the only cross-browser line clamp there is. */
.song-name {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  word-break: normal;
  overflow-wrap: normal;
  min-width: 0;
}

/* What the arrows have queued. Faint on purpose — it is not playing yet, and
   the song that IS playing has to stay the loudest thing in the header. */
.armed-title {
  margin: 5px 0 0;
  text-align: center;
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.armed-title.is-crossing {
  color: var(--cue);
}

.stretch-warn {
  margin: 8px 0 0;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.05em;
  text-align: center;
  color: var(--cue);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.stretch-warn.is-loading {
  color: var(--text-dim);
}

/* stems: button is on/off, the fader under it is how loud when on — so a vocal
   can sit UNDER a beat rather than only being present or absent. Shown on
   phones too: hiding them was the wrong call, they are the point of a mixer. */
/* The `+` that unfolds Beat. Small on purpose - it is a disclosure, not an
   action - but with a full 44px hit area, which is why the glyph is inset
   rather than the box being shrunk. Anchored to the Beat control's own corner
   so it reads as "expand this" and not "add something". */
/* Its own thin row across the top of the stem group. Full 44px hit height so
   it is tappable, but the visible glyph is 20px so it reads as a disclosure
   rather than a command. Left-aligned, over the Beat/Drums column, because
   that is the group it opens - Vocal is never affected. */
.unfold {
  grid-column: 1 / -1;
  justify-self: start;
  /* The glyph is 20px so it reads as a disclosure, but the TARGET is 44px -
     measured at 375px and it was 20x26, which fails the rig's own minimum.
     Negative margins keep the row visually thin while the hit area stays
     honest, rather than shrinking the box to match the glyph. */
  min-width: 44px;
  height: 44px;
  margin: -9px 0 -14px;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-dim);
  font: 600 15px/1 var(--mono);
  cursor: pointer;
  transition: color 200ms ease;
}

.unfold:hover,
.unfold:focus-visible {
  color: var(--text);
}

.unfold span[aria-hidden] {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--glass-bg);
  border: 1px solid var(--glass-line);
  backdrop-filter: var(--glass-blur);
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.stems {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  margin-top: 12px;
}

.stemcol {
  display: grid;
  gap: 4px;
  min-width: 0;
}

.stem {
  min-height: var(--tap);
  border-radius: 12px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.85);
  background: rgba(255, 255, 255, 0.14);
  transition:
    background 120ms ease,
    color 120ms ease;
}

.stem[aria-pressed="true"] {
  background: rgba(255, 255, 255, 0.92);
  color: #1c1030;
}

.stem[aria-pressed="false"]:hover {
  background: rgba(255, 255, 255, 0.22);
}

/* --------------------------------------------------------------- faders */

.fader {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 24px;
  background: transparent;
  cursor: pointer;
}

.fader::-webkit-slider-runnable-track {
  height: 5px;
  border-radius: 5px;
  background: rgba(255, 255, 255, 0.25);
}

.fader::-moz-range-track {
  height: 5px;
  border-radius: 5px;
  background: rgba(255, 255, 255, 0.25);
}

.fader::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 17px;
  height: 17px;
  margin-top: -6px;
  border: none;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
}

.fader::-moz-range-thumb {
  width: 17px;
  height: 17px;
  border: none;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
}

.fader-stem {
  height: 22px;
}

/* Tint each thumb to its stem, so a fader reads as belonging to the button
   above it rather than floating loose. */
#vol-0-beat::-webkit-slider-thumb,
#vol-1-beat::-webkit-slider-thumb {
  background: var(--pink);
}

#vol-0-beat::-moz-range-thumb,
#vol-1-beat::-moz-range-thumb {
  background: var(--pink);
}

#vol-0-vocal::-webkit-slider-thumb,
#vol-1-vocal::-webkit-slider-thumb {
  background: var(--violet);
}

#vol-0-vocal::-moz-range-thumb,
#vol-1-vocal::-moz-range-thumb {
  background: var(--violet);
}

/* -------------------------------------------------------------- sections */

.sections {
  list-style: none;
  margin: 0;
  padding: 0 2px 4px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  overflow-y: auto;
  min-height: 0;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}

.sections li {
  min-width: 0;
}

/* The tile. No bar count printed on it — founder's edit; the ring says how far
   through the loop we are, which is what the number was standing in for. */
.block {
  position: relative;
  width: 100%;
  min-height: 62px;
  padding: 16px 10px;
  border-radius: 20px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  background: rgba(255, 255, 255, 0.09);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  color: rgba(255, 255, 255, 0.9);
  font-size: 16px;
  font-weight: 600;
  text-align: center;
  transition:
    background 150ms ease,
    color 150ms ease,
    border-color 150ms ease,
    transform 120ms ease;
}

.block:hover {
  background: rgba(255, 255, 255, 0.16);
}

.block:active {
  transform: scale(0.98);
}

.block-name {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Kept in the DOM for screen readers and the debug panel, but not drawn: "no
   bar counts on tiles" is a visual decision, not an accessibility one. */
.block-bars {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The armed song's own block at this position. The column belongs to the song
   that is SOUNDING, so without this the entry point would have to be chosen by
   counting rather than by reading. Only drawn while a song is queued. */
.block-armed {
  display: block;
  margin-top: 4px;
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.05em;
  font-weight: 400;
  color: var(--text-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Where the incoming song will come in. Violet, the colour the rig already
   uses for "the other thing", and a dashed edge so it cannot be mistaken for
   the solid ring of the block that is actually playing. */
.block.is-armed {
  border-color: var(--violet);
  border-style: dashed;
}

.block.is-armed .block-armed {
  color: #fff;
}

/* An ordinal only when the name repeats, so the fifth Bridge is findable. */
.block-ordinal {
  display: block;
  margin-top: 3px;
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 400;
  color: var(--text-faint);
}

/* Live: the one place the full brand gradient appears, and which gradient says
   which stems are carrying the deck. */
.block.is-live {
  border-color: transparent;
  color: #fff;
  box-shadow: 0 8px 32px rgba(214, 58, 196, 0.45);
}

.block.is-live .block-ordinal {
  color: rgba(255, 255, 255, 0.72);
}

.block.is-live.stems-both {
  background: var(--grad-both);
}

.block.is-live.stems-beat {
  background: var(--grad-beat);
}

.block.is-live.stems-vocal {
  background: var(--grad-vocal);
}

.block.is-live.stems-none {
  background: rgba(255, 255, 255, 0.1);
  border: 1px dashed rgba(255, 255, 255, 0.3);
  color: var(--text-faint);
  box-shadow: none;
}

/* The loop ring. --sweep is written every animation frame by app.js from the
   real audio clock, so it completes exactly once per loop and cannot drift.
   Masked to a ring: the conic gradient fills the padding box, and the mask
   punches the middle out so only a 2.5px border remains. */
.block.is-live::after {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: 23px;
  padding: 2.5px;
  background: conic-gradient(#fff var(--sweep, 0%), transparent 0);
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
}

/* Cued: tapped, waiting for the bar line. It breathes, so the wait reads as
   precision rather than as the app ignoring you. */
.block.is-cued {
  border-color: var(--cue);
  color: var(--cue);
  animation: breathe 1.05s ease-in-out infinite;
}

@keyframes breathe {
  50% {
    border-color: rgba(255, 196, 107, 0.25);
  }
}

/* ----------------------------------------------------------------- spine */

.spine {
  position: relative;
}

.spine-marker {
  position: absolute;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  opacity: 0;
  transition:
    top 150ms ease,
    opacity 150ms ease;
}

#marker-0 {
  left: 0;
  background: var(--pink);
}

#marker-1 {
  right: 0;
  background: var(--violet);
}

.spine-marker.is-on {
  opacity: 1;
}

/* ------------------------------------------------------------ crossfader */

.crossfader {
  display: grid;
  grid-template-columns: 18px minmax(140px, 460px) 18px auto;
  justify-content: center;
  align-items: center;
  gap: 12px;
  padding: 6px 14px;
}

.xf-end {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  color: var(--text-dim);
  text-align: center;
}

.fader-x::-webkit-slider-runnable-track {
  height: 5px;
  background: var(--grad-both);
  opacity: 0.6;
}

.fader-x::-moz-range-track {
  height: 5px;
  background: var(--grad-both);
  opacity: 0.6;
}

.fader-x::-webkit-slider-thumb {
  width: 19px;
  height: 19px;
  margin-top: -7px;
}

.fader-x::-moz-range-thumb {
  width: 19px;
  height: 19px;
}

.xf-centre {
  display: none;
  min-height: 34px;
  padding: 0 12px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.14);
  color: rgba(255, 255, 255, 0.85);
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.xf-centre:hover {
  background: rgba(255, 255, 255, 0.24);
}

/* ---------------------------------------------------------------- addbar */

.addbar {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 8px 14px;
  min-height: 0;
}

.add-btn {
  min-height: var(--tap);
  padding: 0 20px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.14);
  color: #fff;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: background 130ms ease;
}

.add-btn:hover:not(:disabled) {
  background: var(--grad-both);
}

.add-btn:disabled {
  opacity: 0.55;
  cursor: default;
}

.add-status {
  margin: 0;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.05em;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 46vw;
}

.add-status.is-error {
  color: var(--cue);
}

.add-status.is-done {
  color: #7ee0a8;
}

/* ------------------------------------------------------------- transport */

.transport {
  display: grid;
  /* start | (empty middle) | add-song | status | debug */
  grid-template-columns: auto minmax(0, 1fr) auto auto auto;
  align-items: center;
  gap: 12px;
  padding: 8px 14px;
}

/* Reserved space. The transition control lands here; nothing else may. */
.transport-gap {
  min-height: 1px;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
}

/* The transition. Same glass as every other pane, so it joins the family
   rather than announcing itself — the loudest thing in this bar is still
   Start. Dark and dimmed until a deck is armed and the set is playing,
   because a control that can do nothing should look like it. */
.transition-btn {
  position: relative;
  overflow: hidden;
  min-height: var(--tap);
  padding: 0 20px;
  border-radius: 999px;
  background: var(--glass-bg);
  border: 1px solid var(--glass-line);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  color: #fff;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  white-space: nowrap;
  transition:
    background 130ms ease,
    opacity 130ms ease;
}

.transition-btn:hover:not(:disabled) {
  background: var(--grad-both);
  border-color: transparent;
}

.transition-btn:disabled {
  opacity: 0.36;
  cursor: default;
}

/* Crossing: still disabled — a second press has nothing to do — but lit, and
   counting the bars down. */
.transition-btn.is-crossing {
  opacity: 1;
  border-color: transparent;
}

/* The fill IS the cross. --cross is written every animation frame by app.js
   from the audio clock, exactly like the loop ring: a CSS animation of a fixed
   duration runs on its own timer and would slide away from the music. */
.transition-fill {
  position: absolute;
  inset: 0;
  width: var(--cross, 0%);
  background: var(--grad-both);
  pointer-events: none;
}

.transition-label {
  position: relative;
}

.transport-btn {
  min-width: 90px;
  min-height: var(--tap);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.92);
  color: #1c1030;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.transport-btn[aria-pressed="true"] {
  background: var(--grad-both);
  color: #fff;
}

.readout {
  margin: 0;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 28vw;
}

.bpm {
  margin: 0;
  font-family: var(--serif);
  font-size: 21px;
  line-height: 1;
  color: #fff;
  white-space: nowrap;
}

.bpm span {
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.14em;
  color: var(--text-faint);
  margin-left: 5px;
}

.debug-btn {
  display: none;
  min-height: 34px;
  padding: 0 12px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.1);
  color: var(--text-dim);
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.debug-btn[aria-expanded="true"] {
  color: var(--cue);
  background: rgba(255, 196, 107, 0.16);
}

/* ----------------------------------------------------------------- debug */

.debug {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
  padding: 12px 14px;
  font-family: var(--mono);
  font-size: 11px;
}

.debug-col {
  min-width: 0;
}

.debug-row {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  margin: 0;
  padding: 2px 0;
}

.debug-row dt {
  color: var(--text-faint);
}

.debug-row dd {
  margin: 0;
  color: var(--text);
  text-align: right;
}

.debug-row.is-alert dd {
  color: var(--cue);
}

/* -------------------------------------------------------------- narrow */

@media (max-width: 620px) {
  .rig {
    padding: 6px 8px calc(8px + env(safe-area-inset-bottom, 0px));
    gap: 8px;
  }

  .brand-logo {
    height: 30px;
  }

  .decks {
    grid-template-columns: 1fr 10px 1fr;
    gap: 5px;
  }

  .deck-head {
    padding: 10px 8px 12px;
  }

  /* On a phone the deck is: the song's name, its stem controls, the sections.
     The A/B pill goes — the crossfader already labels the decks. */
  .deck-id {
    display: none;
  }

  .song-pick {
    grid-template-columns: 34px 1fr 34px;
  }

  .cycle {
    height: 40px;
    font-size: 17px;
    background: none;
    color: var(--text-dim);
  }

  .block {
    min-height: 56px;
    padding: 14px 6px;
    font-size: 14px;
  }

  .crossfader {
    grid-template-columns: 14px 1fr 14px;
    gap: 8px;
    padding: 6px 10px;
  }

  .xf-centre {
    display: none;
  }

  .transport {
    grid-template-columns: auto auto minmax(0, 1fr) auto auto;
    gap: 8px;
    padding: 8px 10px;
  }
  .add-btn {
    padding: 0 12px;
    font-size: 9.5px;
  }
  /* Narrower, never smaller than the 44px tap target. Measured at 375px:
     Start + Transition + Add a song fit the row with room to spare. */
  .transition-btn {
    padding: 0 12px;
    font-size: 9.5px;
    letter-spacing: 0.1em;
  }
  /* Not enough width for both. The armed song's NAME is the half that has to
     survive; the block it comes in on is already marked down the column. */
  .armed-at {
    display: none;
  }
  .add-status {
    display: none;
  }
  .mode-btn {
    min-height: 34px;
    min-width: 92px;
    padding: 0 18px;
    font-size: 9.5px;
  }

  .readout {
    display: none;
  }

  /* Debug is a laptop tool; the button is dead weight under a thumb. */
  .debug-btn {
    display: none;
  }

  .addbar {
    padding: 6px 10px;
    gap: 10px;
  }

  .add-status {
    max-width: 44vw;
    font-size: 9.5px;
  }
}

/* ------------------------------------------------------- touch devices */

/* KEEP. Fingers are not mice: bigger grab targets on every fader and room for
   a thumb on every button, without changing the desktop look. */
@media (pointer: coarse) {
  .fader {
    height: 42px;
  }

  .fader::-webkit-slider-thumb {
    width: 24px;
    height: 24px;
    margin-top: -9.5px;
  }

  .fader::-moz-range-thumb {
    width: 24px;
    height: 24px;
  }

  .fader-stem {
    height: 32px;
  }

  .fader-stem::-webkit-slider-thumb {
    width: 20px;
    height: 20px;
    margin-top: -7.5px;
  }

  .fader-stem::-moz-range-thumb {
    width: 20px;
    height: 20px;
  }

  .fader-x::-webkit-slider-thumb {
    width: 26px;
    height: 26px;
    margin-top: -10.5px;
  }

  .fader-x::-moz-range-thumb {
    width: 26px;
    height: 26px;
  }

  .xf-centre {
    padding: 9px 14px;
  }

  /* KEEP. Momentum scrolling in the section columns, and keep the rubber-band
     inside the list instead of dragging the whole page around. */
  .sections {
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
  }
}

/* Blur is expensive, and unsupported on some older phones. Where it is missing
   the panes still read correctly — they sit on a flat translucent fill instead
   of a blurred one, rather than becoming invisible. */
@supports not (
  (backdrop-filter: blur(4px)) or (-webkit-backdrop-filter: blur(4px))
) {
  .glass,
  .deck-head,
  .crossfader,
  .transport,
  .addbar,
  .debug,
  .block {
    background: rgba(28, 16, 48, 0.86);
  }
}

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }

  .block.is-cued {
    border-color: var(--cue);
  }
}

/* The three parts of Beat wear the beat gradient; Vocal keeps violet. That is
   the whole "these belong to Beat" signal - no extra chrome, just the colour
   language the rig already speaks. */
.stem-drums[aria-pressed="true"],
.stem-bass[aria-pressed="true"],
.stem-other[aria-pressed="true"] {
  background: var(--grad-beat);
  color: #fff;
}


/* ------------------------------------------------------------------ picker

   Deliberately plain: a list of every song, tapped to choose. No search, no
   sorting, no artwork. It sits over the section column rather than pushing it
   down, so opening it never moves the blocks under your thumb. */

button.song-title {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  max-width: 100%;
  border: 0;
  background: none;
  color: inherit;
  /* `font: inherit` wholesale would also reset the size to the body's 16px,
     and it outranks the plain .song-title rules below it - which is exactly
     how the phone title ended up bigger than intended. Family and weight
     inherit; size is set here on purpose. */
  font-family: inherit;
  font-weight: 600;
  font-size: 14px;
  cursor: pointer;
  padding: 2px 6px;
  border-radius: var(--radius-sm);
  min-height: var(--tap);
}

button.song-title:hover,
button.song-title:focus-visible {
  background: var(--glass-bg);
}

.picker {
  position: absolute;
  z-index: 30;
  top: 96px;
  left: 10px;
  right: 10px;
  max-height: min(52vh, 420px);
  overflow-y: auto;
  overscroll-behavior: contain;
  display: grid;
  gap: 4px;
  padding: 6px;
  border-radius: var(--radius);
  background: rgba(20, 12, 34, 0.94);
  border: 1px solid var(--glass-line);
  backdrop-filter: var(--glass-blur);
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
}

.picker-row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: center;
  gap: 8px;
  min-height: var(--tap);
  padding: 0 12px;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font: 500 14px/1.2 var(--sans);
  text-align: left;
  cursor: pointer;
  transition: background 150ms ease;
}

.picker-row:hover,
.picker-row:focus-visible {
  background: var(--glass-bg);
}

.picker-row.is-current {
  background: var(--grad-both);
  color: #fff;
}

.picker-row.is-armed {
  outline: 1px dashed var(--violet);
}

.picker-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.picker-bpm {
  font: 500 11px/1 var(--mono);
  color: var(--text-faint);
}

.picker-row.is-current .picker-bpm {
  color: rgba(255, 255, 255, 0.75);
}

.picker-tag {
  font: 600 9px/1 var(--mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.picker-row.is-current .picker-tag {
  color: #fff;
}

/* ------------------------------------------------------- title + dev bits */

/* A caret so the title reads as a menu rather than a label. Inline, not
   flexed, so a long title still wraps the way it already did. */
.song-caret {
  display: inline-block;
  flex: none;
  font-size: 10px;
  color: var(--text-dim);
  transition: transform 150ms ease;
}

.song-title[aria-expanded="true"] .song-caret {
  transform: rotate(180deg);
}

/* Centre and Debug are hidden above. This is the way back in for us: add
   &debug=1 to the link. */
body.is-dev .xf-centre,
body.is-dev .debug-btn {
  display: inline-block;
}

/* --------------------------------------------- the founder's phone pass */

/* The pitch-shift warning is ours, not the listener's. It reports a number
   about a sound they can already hear, and because it only ever appears on
   the stretched deck it made deck B taller than deck A - two cards that
   should read as a matched pair. Hidden by default; ?debug=1 brings it back,
   and in that mode its line is reserved on BOTH decks so nothing jumps as the
   songs change under it. */
.stretch-warn {
  display: none;
}

/* Two of the three things this line can say are FOR the listener: that the
   sound is still arriving, and that it failed to arrive. Hiding the whole
   element hid those too, and a song switch sat silent for seconds with nothing
   on screen saying why. Only the pitch-shift reading stays behind ?debug=1. */
.stretch-warn.is-loading,
.stretch-warn.is-failed {
  display: block;
}

/* The !important is load-bearing, not laziness: app.js hides this line with
   the hidden ATTRIBUTE, and the browser's own rule for [hidden] beats an
   ordinary author declaration. Without it the empty line on the unstretched
   deck collapses and the two cards sit at different heights again - which is
   the whole thing this rule exists to stop. */
body.is-dev .stretch-warn,
body.is-dev .stretch-warn[hidden] {
  display: block !important;
  min-height: 14px;
}

/* 10px was a speck. This has to read as "there is a list behind this". */
.song-caret {
  font-size: 15px;
  line-height: 1;
  color: var(--text);
  opacity: 0.9;
  vertical-align: middle;
}

/* The row carries the name and, when it applies, PLAYING or NEXT. The BPM that
   used to sit on the right cost the name its last few characters on a phone. */
.picker-row {
  grid-template-columns: 1fr auto;
}

/* ---- the crossfader, hidden behind the transport bar ---- */

.transport {
  position: relative;
}

/* A small handle straddling the top edge of the bar. Absolutely positioned, so
   with the crossfader closed the whole thing occupies no layout height
   whatsoever - the founder's actual request: the bar had to be GONE, not
   merely small. */
.xf-handle {
  position: absolute;
  left: 50%;
  bottom: 100%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 62px;
  height: 18px;
  padding: 0;
  border: 1px solid var(--glass-line);
  border-bottom: 0;
  /* Only the top half of the pill shows. Its flat bottom sits flush on the
     bar's top edge so it reads as a tab pulled out of the bar, which is what
     it is - the rest of the control is behind there. */
  border-radius: 12px 12px 0 0;
  background: rgba(18, 11, 30, 0.98);
  color: var(--text-dim);
  font-size: 11px;
  line-height: 1;
  cursor: pointer;
  z-index: 3;
}

/* The visible pill is 26px tall, under the 44px touch minimum. Rather than
   making it chunky enough to crowd the tiles it is meant to free up, the hit
   area is extended invisibly around it - 90x50, comfortably tappable. */
.xf-handle::before {
  content: "";
  position: absolute;
  left: -16px;
  right: -16px;
  top: -14px;
  bottom: -14px;
}

.xf-handle-caret {
  display: block;
  transition: transform 150ms ease;
}

.xf-handle[aria-expanded="true"] .xf-handle-caret {
  transform: rotate(180deg);
}

/* Opens upward, floating over the section column instead of displacing it. */
/* Solid, not glass. It floats OVER the section tiles now, and the frosted
   background it inherited let the tiles read straight through it - which made
   the slider hard to see against whatever happened to be behind it. */
.crossfader {
  position: absolute;
  bottom: calc(100% + 22px);
  left: 0;
  right: 0;
  z-index: 40;
  background: rgba(18, 11, 30, 0.98);
  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
}

/* Every pixel trimmed here is another section tile on screen, which is the
   thing the phone is actually short of. The stem buttons keep their 44px:
   they are the most-tapped control on the page and shrinking them would buy
   one tile at the cost of every toggle. */
@media (max-width: 620px) {
  .deck-head {
    padding: 8px 8px 10px;
  }

  .mode-row {
    margin-top: 2px;
  }

  .mode-btn {
    min-height: 30px;
  }

  .stems {
    gap: 6px;
  }

  button.song-title {
    font-size: 13px;
  }

  /* The stem block is the tallest thing in the header, so it is where the
     tiles are won. The volume sliders lose height they were not using; the
     BEAT and VOCAL buttons keep every pixel of their 44. */
  .stems {
    margin-top: 6px;
  }

  .stems .fader {
    height: 22px;
  }

  .stemcol {
    gap: 6px;
  }

  /* Hidden on a phone, but its margin was still pushing the card open. */
  .deck-id {
    margin-bottom: 0;
  }
}

/* The Transition button belongs on the seam between the decks - the centre of
   the page - not merely somewhere in the middle. START and ADD A SONG are
   different widths, so an auto | 1fr | auto track centred the button inside
   the leftover space and left it about 40px adrift. Two symmetric 1fr tracks
   put the middle column at true centre whatever the two ends weigh, and push
   ADD A SONG out to the right edge where it belongs. */
.transport {
  grid-template-columns: 1fr auto 1fr;
}

#transport {
  justify-self: start;
}

.transport-gap {
  justify-self: center;
}

#add-song {
  justify-self: end;
}

/* Dev only: without this the Debug button becomes a fourth item and shoves
   the centre off again. It takes its own row instead. */
body.is-dev .debug-btn {
  grid-column: 1 / -1;
  justify-self: end;
}

/* A scrolled column used to rest anywhere, so the top tile sat chopped against
   the header - which reads as the header overlapping it, and is what the
   founder saw after unfolding a deck. Snapping to tile edges means whatever is
   at the top is a whole tile. `proximity`, not `mandatory`: a thumb mid-flick
   is left alone, only a rest near an edge gets tidied. */
.sections {
  scroll-snap-type: y proximity;
}

.sections li {
  scroll-snap-align: start;
}
