/* /library - track listing page.
   Layout: newsroom + sticky month rail. */

/* =============== Heatmap base ===============
   The /library page uses library-shared.css's ink-ribbon override
   on top of these. The /activity page reuses the same
   base via the canonical /heatmap.js renderer. */
.library-heatmap { margin: 0 0 32px; }
.heatmap-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 24px;
}
.heatmap-title {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--ink);
  margin: 0 0 18px;
}
.heatmap-title b { font-weight: 500; color: var(--ink); }
.heatmap-grid { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.heatmap-grid svg { display: block; }
.heatmap-cell[data-level="0"] { fill: var(--heat-l0); }
.heatmap-cell[data-level="1"] { fill: var(--heat-l1); }
.heatmap-cell[data-level="2"] { fill: var(--heat-l2); }
.heatmap-cell[data-level="3"] { fill: var(--heat-l3); }
.heatmap-cell[data-level="4"] { fill: var(--heat-l4); }
.heatmap-cell[data-future="1"] { fill: transparent; }
.heatmap-month, .heatmap-dow {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 500;
  fill: var(--fg-dim);
}
.heatmap-legend {
  display: flex;
  align-items: center;
  gap: 6px;
  justify-content: flex-end;
  margin-top: 12px;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
}
.heatmap-legend-swatch { width: 11px; height: 11px; border-radius: 2px; }
.heatmap-tooltip {
  position: fixed;
  transform: translate(-50%, -100%);
  background: var(--ink);
  color: var(--bone);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 6px 12px;
  border-radius: 999px;
  pointer-events: none;
  white-space: nowrap;
  z-index: 50;
  opacity: 0;
  /* Width animates as the content swaps between cells (e.g. "1
     TRACK" → "12 TRACKS") so the pill morphs in place rather
     than snapping. JS sets explicit pixel widths so the
     transition has fixed-to-fixed endpoints (CSS can't animate
     to width:auto). */
  transition:
    opacity 80ms ease-out,
    width 180ms cubic-bezier(0.32, 0.72, 0, 1),
    transform 180ms cubic-bezier(0.32, 0.72, 0, 1);
}
.heatmap-tooltip[data-visible="1"] { opacity: 1; }
/* Off-screen measurer twin - same typography + padding so we
   can read the natural width of new text without reflow blink. */
.heatmap-tooltip-measure {
  position: absolute;
  visibility: hidden;
  pointer-events: none;
  top: -9999px;
  left: -9999px;
}

.heatmap-card--placeholder .heatmap-title { color: var(--fg-dim); }
.heatmap-card--placeholder .heatmap-grid {
  height: 113px;
  background:
    linear-gradient(
      90deg,
      rgba(10, 10, 10, 0.04),
      rgba(10, 10, 10, 0.08),
      rgba(10, 10, 10, 0.04)
    );
  background-size: 200% 100%;
  border-radius: var(--radius-sm);
  animation: heatmap-shimmer 1.4s ease-in-out infinite;
}
@keyframes heatmap-shimmer {
  0%   { background-position:  100% 0; }
  100% { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .heatmap-card--placeholder .heatmap-grid { animation: none; }
}

/* No stroke ever - outlines on SVG circles read as visual
   noise against the bone ground. Active state is signalled by
   contrast (everything else dims) rather than an outline.
   Belt-and-braces `outline:none` defends against the default
   focus ring some browsers paint on click. */
.heatmap-cell {
  stroke: none;
  outline: none;
  transform-box: fill-box;
  transform-origin: center;
  transition:
    transform 360ms cubic-bezier(0.32, 0.72, 0, 1),
    fill 200ms ease;
}
.heatmap-cell:focus,
.heatmap-cell:focus-visible { outline: none; }

@media (hover: hover) {
  /* Hover affordance - shrink the dot to 80% of its painted size
   instead of outlining it. Driven by a JS-applied class (only on
   clickable cells, so empty days never twitch under the cursor)
   rather than :hover so we can highlight the cell closest to the
   cursor even when the pointer is in the gap between dots. */
.heatmap-cell--hover { transform: scale(0.8); }
}

/* Day filter active - every non-active cell collapses to ink at
   5% opacity so the picked day reads as the one bright dot in a
   field of empties. Class lives on the heatmap mount; toggled by
   library.js whenever state.day changes (any source). */
.lib-h-cal-grid--day-active .heatmap-cell:not([data-active="1"]) {
  fill: rgba(10, 10, 10, 0.05);
}

@media (prefers-reduced-motion: reduce) {
  .heatmap-cell { transition: none; }
}

/* =============== Day-filter chip (shared with variants) =============== */
/* Top margin matches the sticky→grid breathing room so the chip
   sits the same distance below the pinned filter bar as the
   month groups would when no chip is active. */
.library-day-chip-slot { margin: clamp(24px, 3vw, 40px) 0 16px; }
/* When the chip is visible, the grid below doesn't need its own
   top margin - the chip's bottom margin already provides spacing. */
.library-day-chip-slot:not([hidden]) + .library-grid { margin-top: 0; }
.lib-day-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px 6px 12px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.72rem;
  color: var(--ink);
}
.lib-day-chip-label {
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
  font-size: 0.65rem;
}
.lib-day-chip strong { font-weight: 600; }
.lib-day-chip-clear {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: 50%;
  color: var(--fg-dim);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease;
}
@media (hover: hover) {
  .lib-day-chip-clear:hover {
  background: var(--bone);
  color: var(--ink);
}
}

/* a11y helper, kept here so any page importing library.css
   can use .visually-hidden on label spans for search inputs */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* =====================================================================
   v12 layout - live /library page only. Hero + ink ribbon come from
   library-shared.css; the working zone below is what's defined here.
   ===================================================================== */

.library-page { display: block; }

.library-work {
  max-width: calc(var(--rail) + 2 * var(--rail-x));
  margin: 0 auto;
  padding: clamp(20px, 2.5vw, 32px) var(--rail-x) 96px;
}

/* Sticky controls - search bar + filter buttons + the open
   filter panel stay pinned to the top of the viewport once the
   user scrolls past them. Bone background masks rows scrolling
   underneath; zero padding/margin so the bar's bottom edge
   sits flush against the first row (no blank bone band). */
.library-sticky {
  position: sticky;
  top: var(--nav-h);
  z-index: 5;
  background: var(--bone);
  padding: 0;
  margin: 0;
}
/* Soft fade strip just below the pinned bar - gradient bone →
   transparent so rows scrolling up dissolve into the bar's
   bottom edge instead of cutting sharply. Sits in the sticky
   element's own stacking context (z-index: 5) so it overlays
   the rows beneath. pointer-events: none keeps clicks falling
   through to whatever's underneath. */
.library-sticky::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  height: 24px;
  background: linear-gradient(
    to bottom,
    var(--bone) 0%,
    rgba(240, 238, 233, 0) 100%
  );
  pointer-events: none;
}

/* ----- Controls strip (search + role tabs) ----- */
.library-controls {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 0;
}
/* Wrapper around the search bar + mobile filter-toggle pill.
   `display: contents` on desktop so the children behave as
   direct flex items of .library-controls — zero layout change.
   The mobile @media block flips this to a flex row. */
.library-search-row { display: contents; }
/* Mobile-only circular toggle that reveals the BPM / Tags /
   role filters beneath. Hidden on desktop where those filters
   are always inline. */
.library-filter-toggle { display: none; }
/* Filter-panel wrappers. Both are display: contents on desktop
   so BPM / Tags / segmented act as direct flex children of
   .library-controls (same DOM tree, same layout result). The
   mobile media block flips them into a real grid container
   with the 0fr → 1fr height transition. */
.library-filter-panel,
.library-filter-panel-inner { display: contents; }
.library-search {
  flex: 1 1 320px;
  min-width: 240px;
  position: relative;
  display: block;
}
.library-search-icon {
  position: absolute;
  left: 18px;
  top: 50%;
  width: 18px;
  height: 18px;
  transform: translateY(-50%);
  /* Use solid ink + element-level opacity (instead of the
     semi-transparent --fg-dim color directly on the strokes)
     so the circle and line composite as a single layer - no
     darker spot where they overlap. */
  color: var(--ink);
  opacity: 0.58;
  pointer-events: none;
  transition: opacity 120ms ease;
}
.library-search:focus-within .library-search-icon { opacity: 1; }
.library-search input {
  width: 100%;
  box-sizing: border-box;
  padding: 13px 44px 13px 44px;
  font-family: inherit;
  font-weight: 500;
  font-size: 0.95rem;
  line-height: 1;
  background: transparent;
  color: var(--ink);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  outline: none;
  transition: border-color 120ms ease, background 120ms ease;
  /* Strip the browser-intrinsic min-height that <input type="search">
     gets in Chromium - without these overrides the field renders
     ~4px taller than the sibling trigger buttons. Explicit
     height locks it to the .lib-flat-btn outer height (13+13
     padding + 15.2 font + 2 border = 43.2px) so the strip is
     visually flush. */
  appearance: none;
  -webkit-appearance: none;
  min-height: 0;
  height: 43.2px;
}
.library-search input::placeholder { color: var(--fg-dim); }
/* Suppress the browser's native clear-X - we render our own
   styled to match the search-icon family so the field looks
   consistent across browsers. */
.library-search input::-webkit-search-cancel-button,
.library-search input::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
}

/* Custom clear button - same flatten-via-opacity treatment as
   the search icon so the two strokes don't darken at their
   crossing. Visible whenever the input has text. */
.library-search-clear {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  width: 24px;
  height: 24px;
  background: transparent;
  border: 0;
  border-radius: 999px;
  padding: 0;
  cursor: pointer;
  color: var(--ink);
  opacity: 0;
  pointer-events: none;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 180ms ease;
}
.library-search-clear[data-visible="true"] {
  opacity: 0.58;
  pointer-events: auto;
}
@media (hover: hover) {
  .library-search-clear[data-visible="true"]:hover { opacity: 1; }
}
.library-search-clear svg { width: 18px; height: 18px; display: block; }
@media (prefers-reduced-motion: reduce) {
  .library-search-clear { transition: none; }
}
@media (hover: hover) {
  .library-search input:hover { background: rgba(10, 10, 10, 0.04); }
}
.library-search input:focus {
  border-color: var(--ink);
  background: rgba(10, 10, 10, 0.04);
}

/* ----- Flat filter buttons -----
   Two pill buttons in the controls strip, one per panel. No
   chevron - the toggle is implied. Active (open) flips to
   ink-fill. .lib-flat-btn--active denotes a non-default filter
   value when the panel is closed (subtle ink underline). */
.lib-flat-btn {
  appearance: none;
  font-family: inherit;
  font-weight: 600;
  font-size: 0.95rem;
  line-height: 1;
  padding: 13px 22px;
  background: transparent;
  color: var(--ink);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease,
              border-color 120ms ease, transform 60ms ease;
}
@media (hover: hover) {
  .lib-flat-btn:hover {
  background: rgba(10, 10, 10, 0.05);
  border-color: var(--ink);
}
}
/* Filled state - applied when the panel is open OR a non-default
   filter is active under it (data-has-filter). Lets the button
   keep signalling "this dimension is filtering your results"
   even after the user closes the sub-panel. Lives outside
   `@media (hover: hover)` so touch devices also see the ink
   fill — the hover variant below only exists to stop the base
   :hover from splatting its grey wash over the active fill. */
.lib-flat-btn[aria-expanded="true"],
.lib-flat-btn[data-has-filter="true"] {
  background: var(--ink);
  color: var(--bone);
  border-color: var(--ink);
}
@media (hover: hover) {
  .lib-flat-btn[aria-expanded="true"]:hover,
  .lib-flat-btn[data-has-filter="true"]:hover {
    background: var(--ink);
    color: var(--bone);
    border-color: var(--ink);
  }
}
.lib-flat-btn:active:not(:disabled) { transform: scale(0.99); }

/* ----- Tri-state segmented control (Owner filter) -----
   Sits at the far right of the controls strip. Outlined chrome
   matches the BPM / Tags trigger buttons (1px ink border,
   transparent fill, 43px outer height); the active segment
   fills with ink and a sliding pill animates between positions.
   Padding-based sizing tracks .lib-flat-btn's vertical rhythm. */
.lib-seg {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: stretch;
  box-sizing: border-box;
  padding: 3px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: transparent;
  font-family: inherit;
}
.lib-seg-btn {
  position: relative;
  z-index: 1;
  appearance: none;
  background: transparent;
  border: 0;
  /* 10px + container's 3px padding = 13px of effective vertical
     breathing room - matches the .lib-flat-btn padding. */
  padding: 10px 22px;
  border-radius: 999px;
  font: inherit;
  font-weight: 600;
  font-size: 0.95rem;
  line-height: 1;
  cursor: pointer;
  white-space: nowrap;
  /* Text colour is swapped via mix-blend-mode rather than a CSS
     `color` transition. Setting the glyphs to white + difference
     blend means each pixel gets inverted against whatever sits
     behind it: where text overlaps the bone container the diff
     produces near-ink; where text overlaps the ink indicator the
     diff produces near-bone. As the indicator slides, the
     boundary between "looks ink" and "looks bone" wipes
     letter-by-letter - no fade, no abrupt flip. */
  color: #fff;
  mix-blend-mode: difference;
  -webkit-mix-blend-mode: difference;
}
.lib-seg-btn:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}
/* Sliding ink pill behind the active segment. Width = (100% - padding) / 3
   so it slots cleanly inside the 3px inset. transform translateX
   moves it between positions (0%, 100%, 200% of its own width). */
.lib-seg-indicator {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc((100% - 6px) / 3);
  background: var(--ink);
  border-radius: 999px;
  z-index: 0;
  transition: transform 320ms cubic-bezier(0.32, 0.72, 0, 1);
  will-change: transform;
}
.lib-seg[data-active="0"] .lib-seg-indicator { transform: translateX(0); }
.lib-seg[data-active="1"] .lib-seg-indicator { transform: translateX(100%); }
.lib-seg[data-active="2"] .lib-seg-indicator { transform: translateX(200%); }
@media (prefers-reduced-motion: reduce) {
  .lib-seg-indicator { transition: none; }
  .lib-seg-btn { transition: color 0ms; }
}

/* ----- Animated expand/collapse panels -----
   Modern grid-template-rows trick gives a true height:auto
   animation without measuring or JS. The inner div clips with
   overflow:hidden so the content is masked while collapsed. */
.lib-panels {
  display: flex;
  flex-direction: column;
  gap: 0;
  /* Inherits the breathing room that used to live on
     .library-controls so the day-chip / first month group sits
     the same distance from the controls strip whether or not a
     panel is open. */
  margin-bottom: clamp(32px, 4vw, 56px);
}
/* Single shared panel. Height interpolates between slots so
   switching View ↔ BPM is one smooth tween (no wobble from
   stacked panels collapsing/expanding). All slots are absolute
   so the panel's natural height stays 0; JS writes the active
   slot's offsetHeight to panel.style.height. Inactive slots
   sit at opacity 0 underneath; active slot fades in *after*
   most of the height slide completes, so the chips/slider
   resolve into the empty space rather than ghosting over the
   moving rows below. */
.lib-panel {
  position: relative;
  height: 0;
  overflow: visible;
  transition: height 360ms cubic-bezier(0.32, 0.72, 0, 1);
  /* No margin-bottom - the panel sits inside the sticky wrapper,
     so any margin here would create a visible bone band at the
     bottom of the pinned bar when scrolled. Breathing room
     between the filters and the first row is provided by
     .library-grid / .lib-day-chip-slot margins below. */
  margin-bottom: 0;
  /* Promote to its own compositing layer so the height tween
     stays on the GPU at 120Hz on ProMotion devices. height is
     a layout property and can't be fully composited, but a
     dedicated layer means each painted frame composites
     independently at the display's refresh rate instead of
     waiting for the main thread to recombine with neighbours. */
  will-change: height;
  transform: translateZ(0);
}
.lib-panel-slot {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  padding: 16px 0 0;
  display: flex;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  /* 240ms delay = ~67% of the height slide - slide is mostly
     complete before opacity starts, so the fade reads as
     resolving into already-opened space. */
  transition: opacity 200ms ease 240ms;
  /* Dedicated compositor layer keeps the fade at 120Hz on
     ProMotion. Opacity is already composited, but pairing
     will-change + translateZ matches the pattern used by the
     mobile hamburger panel and stops Safari from demoting
     under load. */
  will-change: opacity;
  transform: translateZ(0);
}
.lib-panel-slot.is-active {
  opacity: 1;
  pointer-events: auto;
}
/* Closing the panel: fade the slot out over the SAME 200ms
   duration the open uses for its fade-in, just at the front of
   the close instead of the back. Open: height first, then
   opacity (240ms delay). Close mirrors it: opacity first, then
   height (80ms delay set in JS). Same overall length, same
   easings, just reversed order. */
.lib-panel-slot.is-leaving {
  opacity: 0;
  transition: opacity 200ms ease;
}

/* ----- Ownership chips (inside .lib-view-panel) ----- */
.lib-chips {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  justify-content: center;
}
.lib-chip {
  appearance: none;
  background: transparent;
  border: 1px solid var(--border-strong);
  /* box-sizing keeps the 42px outer height even with the border. */
  box-sizing: border-box;
  padding: 0 18px;
  height: 42px;
  display: inline-flex;
  align-items: center;
  font-family: inherit;
  font-weight: 500;
  font-size: 0.95rem;
  color: var(--ink);
  border-radius: 999px;
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease,
              border-color 120ms ease;
}
@media (hover: hover) {
  .lib-chip:hover {
  background: rgba(10, 10, 10, 0.05);
  border-color: var(--ink);
}
}
/* Active chip - selected tag. Outside the hover-only media so
   tap-to-toggle inverts the chip on touch devices too; the
   :hover guard inside the media block stops the base hover
   wash from masking the active fill. */
.lib-chip--active {
  background: var(--ink);
  color: var(--bone);
  border-color: var(--ink);
}
@media (hover: hover) {
  .lib-chip--active:hover {
    background: var(--ink);
    color: var(--bone);
    border-color: var(--ink);
  }
}

/* ----- BPM full-width pill (inside .lib-bpm-panel) -----
   Wide pill containing label + value + dual-handle slider
   inline. Spans the working zone width up to a sensible max so
   the slider stays comfortable at every viewport. */
/* BPM pill picks up the chip styling - soft grey fill, no
   border, same vertical padding as a chip - but stays wide so
   the inline slider has comfortable room. */
/* Outlined-pill housing - same chrome as the Owner / BPM / Tags
   trigger buttons (1px ink border, 42px, 999px radius), so the
   open BPM panel reads as the same visual family as the trigger
   it just expanded from. Original grey-fill housing is gone. */
.lib-bpm-pill {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  max-width: 640px;
  height: 42px;
  padding: 0 22px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: transparent;
}
.lib-bpm-pill-value {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.78rem;
  letter-spacing: 0;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
  line-height: 1;
  /* Fixed 3-digit width + tabular figures so the rail never
     shifts sideways when the value crosses a digit boundary
     (e.g. 99 → 100). Inline-flex so the per-digit ticker
     columns sit on a single horizontal baseline. */
  width: 3ch;
  height: 1em;
  display: inline-flex;
  align-items: stretch;
}

/* Per-digit ticker. Each value renders as 3 fixed digit slots;
   each slot clips a vertical column of [' ', '0'-'9']. paintBpm
   sets the column's translateY to the active digit's row, and
   the CSS transition rolls digits up/down like an odometer. */
.bpm-digit {
  display: inline-block;
  width: 1ch;
  height: 1em;
  overflow: hidden;
  position: relative;
}
.bpm-digit-col {
  display: block;
  transition: transform 320ms cubic-bezier(0.32, 0.72, 0, 1);
  will-change: transform;
}
.bpm-digit-col > span {
  display: block;
  height: 1em;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
  .bpm-digit-col { transition: none; }
}
.lib-bpm-track {
  position: relative;
  flex: 1;
  height: 22px;
}
.lib-bpm-rail {
  position: absolute;
  left: 0; right: 0; top: 50%;
  height: 3px;
  margin-top: -1.5px;
  background: rgba(10, 10, 10, 0.14);
  border-radius: 2px;
}
.lib-bpm-fill {
  position: absolute;
  top: 50%; height: 3px; margin-top: -1.5px;
  background: var(--ink);
  border-radius: 2px;
  pointer-events: none;
}
/* Both range inputs sit in the same row. Tracks are transparent
   (the shared rail above provides the visual). pointer-events
   restored on the thumb only so the user can drag either handle. */
.lib-bpm-input {
  position: absolute;
  left: 0; right: 0;
  width: 100%;
  appearance: none;
  background: transparent;
  pointer-events: none;
  margin: 0;
  height: 22px;
}
/* Grabbers: bone fill, thin ink outline (2px - lighter than the
   rail so the dot reads as a hole punched in the line, not as
   the line bent into a circle). No shadow. */
.lib-bpm-input::-webkit-slider-thumb {
  appearance: none;
  pointer-events: auto;
  width: 18px;
  height: 18px;
  border-radius: 999px;
  background: var(--bone);
  border: 2px solid var(--ink);
  cursor: grab;
  margin-top: 0;
  box-shadow: none;
}
.lib-bpm-input::-moz-range-thumb {
  pointer-events: auto;
  width: 18px;
  height: 18px;
  border-radius: 999px;
  background: var(--bone);
  border: 2px solid var(--ink);
  cursor: grab;
  box-shadow: none;
}
.lib-bpm-input:active::-webkit-slider-thumb { cursor: grabbing; }
.lib-bpm-input:active::-moz-range-thumb { cursor: grabbing; }
.lib-bpm-reset {
  appearance: none;
  background: transparent;
  border: 0;
  padding: 6px 12px;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
  cursor: pointer;
  /* Fully rounded hover highlight so the pill chrome echoes the
     outer .lib-bpm-pill 999px radius rather than introducing a
     squarer 6px corner. */
  border-radius: 999px;
  transition: color 120ms ease, background 120ms ease;
}
@media (hover: hover) {
  .lib-bpm-reset:hover { color: var(--ink); background: rgba(10, 10, 10, 0.05); }
}
@media (prefers-reduced-motion: reduce) {
  .lib-panel,
  .lib-panel-slot,
  .library-filter-panel,
  .library-filter-panel-inner { transition: none; }
}


/* ----- Tags slot - horizontally-scrolling chip rail -----
   The user's tags ordered most-used first; chips reuse the
   .lib-chip outlined-pill styling. The rail wraps the chip row
   in a mask-image gradient so chips dissolve into the bone
   ground when scrolled past the left/right margins. */
.lib-tag-rail {
  position: relative;
  width: 100%;
  max-width: calc(var(--rail) - 2 * var(--rail-x));
  /* Edge fade - needs both webkit and standard properties for
     Safari support. */
  -webkit-mask-image: linear-gradient(
    90deg,
    transparent 0,
    black 24px,
    black calc(100% - 24px),
    transparent 100%
  );
  mask-image: linear-gradient(
    90deg,
    transparent 0,
    black 24px,
    black calc(100% - 24px),
    transparent 100%
  );
}
.lib-tag-rail-scroll {
  display: flex;
  gap: 8px;
  align-items: center;
  /* `safe center` keeps the chips centred when there's only
     a few (no horizontal scroll needed), but falls back to
     start-alignment when the chip row overflows — otherwise
     centring with overflow would cut off the leftmost chips
     because flex content starts at the centre. */
  justify-content: safe center;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  padding: 0 24px;
  scrollbar-width: none;
}
.lib-tag-rail-scroll::-webkit-scrollbar { display: none; }
.lib-tag-empty {
  margin: 0;
  padding: 12px 0;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.78rem;
  letter-spacing: 0.04em;
  color: var(--fg-dim);
  white-space: nowrap;
}
/* Tag chips reuse .lib-chip styling but force them onto a single
   line so the horizontal scroll works. */
.lib-tag-rail-scroll .lib-chip {
  flex-shrink: 0;
  white-space: nowrap;
}

/* ----- Per-row tag chips (under each track title) -----
   Smaller mono pills, clickable - clicking adds the tag to the
   active filter and opens the Tags panel. */
.lib-row-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 10px;
}
.lib-row-tag {
  /* Outline-only pill - visually paired with the .te-pill-chip
     used on the /track page, just smaller. Sans body font (not
     mono) and ink border instead of grey fill so list view and
     detail view share the same tag language. Padding keeps the
     pill around the same vertical rhythm as the prior fill
     version so row heights don't shift. */
  display: inline-block;
  font-family: inherit;
  font-weight: 500;
  font-size: 0.78rem;
  letter-spacing: -0.005em;
  text-transform: none;
  color: var(--ink);
  padding: 3px 10px;
  border: 1px solid var(--ink);
  border-radius: 999px;
  background: transparent;
}

/* ----- "More matches" divider - secondary OR results below
   the AND list when 2+ tags are filtered. The header reads as a
   quiet eyebrow so the user understands these tracks match SOME
   but not ALL of the selected tags. */
.lib-more-matches {
  margin-top: clamp(48px, 6vw, 80px);
}
.lib-more-matches-head {
  margin: 0 0 24px;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fg-dim);
  padding-bottom: 14px;
  border-bottom: 1px dashed var(--border);
}

/* Disabled Prev/Next show the normal arrow cursor - the global
   .secondary-button:disabled rule sets cursor:not-allowed,
   which here reads as a "broken" affordance rather than a
   simple "off" state. Only override the cursor; keep the 0.4
   opacity so the disabled-ness is still visible. */
.library-paging .secondary-button:disabled { cursor: default; }

/* ----- Two-column working zone (titles + sticky rail) ----- */
.library-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 220px;
  gap: clamp(40px, 6vw, 80px);
  align-items: flex-start;
  /* Breathing room between the sticky filters above and the
     first month group / day-chip - moved off the panel onto
     the grid so the sticky bar's bottom edge sits flush. */
  margin-top: clamp(24px, 3vw, 40px);
}

/* ----- Left column - month-grouped editorial titles ----- */
.library-results { min-width: 0; }

/* scroll-margin-top accounts for the fixed nav AND the sticky
   filter bar (~50px when panel closed, ~110px when open) so
   anchored jumps from the JUMP TO sidebar land month titles
   cleanly under the bar instead of behind it. */
.lib-month { scroll-margin-top: calc(var(--nav-h) + 64px); }
/* Editorial chapter spacing - display-headline titles need
   massive vertical breathing room so they read as chapter
   dividers rather than table-of-contents rows. */
.lib-month + .lib-month { margin-top: clamp(88px, 11vw, 140px); }
.lib-month-head {
  display: flex;
  flex-direction: row;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 36px;
}
.lib-month-title {
  font-size: clamp(2.5rem, 5.5vw, 4.5rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 0.95;
  margin: 0;
  color: var(--ink);
}
.lib-month-count {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--ink);
  margin: 0;
  /* Pin the count to the title's baseline at the right edge
     even when the title wraps. */
  flex-shrink: 0;
  white-space: nowrap;
}

/* "Implicit current" - when the first rendered month is today's
   month, the user already knows what they're looking at, so the
   head is redundant noise. Hide it but keep the section in DOM
   so IntersectionObserver + JUMP TO still target it. */
.lib-month--implicit-current .lib-month-head { display: none; }

/* Search mode - collapse the month grouping entirely so the
   results read as a flat list of matches. Heads disappear and
   the chapter gaps between month sections collapse to zero.
   Tags-active mode behaves the same way for the same reason:
   the user has narrowed by a cross-month dimension so month
   titles add noise rather than structure. */
.library-results--searching .lib-month-head,
.library-results--tags-active .lib-month-head { display: none; }
.library-results--searching .lib-month + .lib-month,
.library-results--tags-active .lib-month + .lib-month { margin-top: 0; }
.lib-month-body { display: flex; flex-direction: column; }

/* Align the first track row's eyebrow with the first JUMP TO
   sidebar link's baseline when the leading month head is hidden.
   Three modes hide the leading head - implicit-current, searching,
   tags-active - and in all three the first row's normal top
   padding (~28px) leaves the eyebrow line floating below the
   aside's first link. The 8px matches .library-aside-link's
   padding-top so both columns start their text on the same line. */
.lib-month--implicit-current:first-child > .lib-month-body > .lib-row:first-child,
.library-results--searching .lib-month:first-child > .lib-month-body > .lib-row:first-child,
.library-results--tags-active .lib-month:first-child > .lib-month-body > .lib-row:first-child {
  padding-top: 8px;
}

/* ----- v12 row: eyebrow + huge title, no card chrome ----- */
.lib-row {
  display: block;
  padding: clamp(20px, 3vw, 28px) 0;
  text-decoration: none;
  color: inherit;
  /* Use transform instead of padding-left for the hover
     nudge - padding eats into the row's content width,
     which shifts the title's truncation point and crops a
     few extra characters off long titles on hover. transform
     moves the box visually without changing its layout
     dimensions, so the title's available width stays the
     same and the ellipsis never repositions. */
  transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
}
@media (hover: hover) {
  .lib-row:hover,
.lib-row--js-hover { transform: translateX(8px); }
}
.lib-row-eyebrow {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.66rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
  margin: 0 0 8px;
  transition: color 160ms;
}
@media (hover: hover) {
  .lib-row:hover .lib-row-eyebrow,
.lib-row--js-hover .lib-row-eyebrow { color: var(--ink); }
}
.lib-row-title {
  margin: 0;
  font-size: clamp(1.25rem, 2.5vw, 1.875rem);
  font-weight: 600;
  letter-spacing: -0.025em;
  /* line-height 1.1 was cropping descenders (y / g / p) at
     the bottom because overflow:hidden clips anything past
     the line-box. 1.25 gives Plus Jakarta Sans enough
     vertical room for its descender depth without changing
     the title's apparent baseline noticeably. */
  line-height: 1.25;
  color: var(--ink);
  /* Single-line, full available width, ellipsis on overflow.
     Previous max-width: 26ch was forcing long titles to wrap
     to two lines instead of using the full row. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

/* Skeleton rows - rendered in the initial HTML so first paint
   reserves the height of the eventual list. JS swaps them out
   when the manifests fetch returns. Same outer .lib-row padding
   so the layout doesn't shift; bars use ink-5% blocks sized to
   the real eyebrow + title typography. */
/* Skeleton rows in /library/index.astro now use &nbsp; in the
   eyebrow + title elements rather than visible muted bars: text
   lands fast enough that swapping a bar for text reads as a
   visual glitch. The wrapping classes still suppress
   interaction so an accidental click on the in-flight rows
   doesn't fire a navigation. */
.lib-row--skeleton { pointer-events: none; cursor: default; }

/* In-flight placeholder while the plugin is uploading - the row
   shimmers + can't be clicked yet. */
@keyframes lib-row-shimmer {
  0%   { opacity: 0.55; }
  50%  { opacity: 0.95; }
  100% { opacity: 0.55; }
}
.lib-row--placeholder {
  animation: lib-row-shimmer 1.4s ease-in-out infinite;
  pointer-events: none;
}

/* ----- Empty / error states ----- */
.lib-empty {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
  padding: 40px 0;
  margin: 0;
}
.lib-empty--err { color: var(--danger); }
/* Quiet variant - single mono-uppercase line aligned with the
   left edge of the row titles, sitting at the same vertical
   position as a month-head's "N TRACKS" count. Zero top
   padding so it lands at the top of the results container. */
.lib-empty--quiet {
  text-align: left;
  padding: 0 0 24px;
}
.lib-empty--first {
  padding: clamp(48px, 7vw, 96px) 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 16px;
  text-transform: none;
  letter-spacing: 0;
  font-family: var(--font);
}
.lib-empty-head {
  font-size: clamp(1.5rem, 3vw, 2.25rem);
  font-weight: 600;
  letter-spacing: -0.025em;
  color: var(--ink);
  margin: 0;
  max-width: 18ch;
}
.lib-empty-sub {
  margin: 0;
  color: var(--fg-dim);
  font-size: 1rem;
  line-height: 1.55;
  max-width: 56ch;
  font-family: var(--font);
  text-transform: none;
  letter-spacing: 0;
}
.lib-empty-ctas {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  margin-top: 12px;
}

/* ----- Right column - sticky month nav ----- */
.library-aside {
  position: sticky;
  /* Sit comfortably below the pinned filter bar (~50px tall
     when closed) so the JUMP TO list never overlaps with it. */
  top: calc(var(--nav-h) + 72px);
  align-self: flex-start;
}
.library-aside-label {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.66rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
  margin: 0 0 16px;
}
.library-aside-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  /* Positioning context for the sliding chartreuse indicator. */
  position: relative;
}
/* Single chartreuse pill that slides between active links -
   replaces per-link border so the active marker animates
   smoothly as the user scrolls. JS sets top/height via inline
   styles when the active month changes. */
.library-aside-indicator {
  position: absolute;
  left: -12px;
  top: 0;
  width: 2px;
  height: 0;
  background: var(--chartreuse);
  border-radius: 2px;
  opacity: 0;
  transform: translateZ(0);
  /* Transition is disabled until syncIndicator() has placed
     the bar at its initial month — otherwise the bar visibly
     grows from 0 → height + slides from y=0 → target on every
     page load, which reads as a janky entrance animation.
     The `.is-positioned` class is added on the first sync (one
     frame after the inline style commits) so subsequent month
     changes still get the smooth slide between months. */
  transition: none;
  pointer-events: none;
  will-change: transform, height;
}
.library-aside-indicator.is-positioned {
  transition:
    transform 320ms cubic-bezier(0.32, 0.72, 0, 1),
    height 320ms cubic-bezier(0.32, 0.72, 0, 1),
    opacity 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
  .library-aside-indicator.is-positioned { transition: opacity 100ms ease; }
}
.library-aside-link {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding: 8px 0 8px 10px;
  margin-left: -12px;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--fg-dim);
  text-decoration: none;
  transition: color 160ms ease;
}
@media (hover: hover) {
  .library-aside-link:hover { color: var(--ink); }
}
.library-aside-link--active { color: var(--ink); }
/* Loaded-vs-unloaded distinction is suppressed visually - every
   inactive month reads at the same dim weight so the rail looks
   like one consistent list. Clicking still skip-loads the
   unloaded ones; the active highlight is the only state cue. */
.library-aside-count { font-size: 0.7rem; color: var(--fg-faint); }
@media (hover: hover) {
  .library-aside-link:hover .library-aside-count,
.library-aside-link--active .library-aside-count { color: var(--ink); }
}

/* ----- Infinite-scroll sentinel ----- */
.library-load-more {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 64px;
  margin-top: clamp(48px, 6vw, 80px);
}
/* Same shape and timing as the login submit chevron's spinner
   (.floating-submit.is-loading::after in styles.css) so loading
   states across the app share one visual language. Inlined
   rather than extracted because library is the only consumer
   outside login. */
.library-load-spinner {
  width: 18px;
  height: 18px;
  border: 1.5px solid var(--ink);
  border-top-color: transparent;
  border-radius: 999px;
  animation: library-load-spin 0.7s linear infinite;
}
@keyframes library-load-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .library-load-spinner { animation: none; }
}

/* =============== Responsive =============== */
@media (max-width: 880px) {
  .library-grid { grid-template-columns: 1fr; }
  .library-aside {
    position: static;
    border-left: 0;
    padding-left: 0;
    border-top: 1px solid var(--border);
    padding-top: 24px;
    margin-top: 32px;
    order: 2;
  }
  /* When library.js empties the aside on a no-results / search
     state (`aside.innerHTML = ""`), :empty matches and we yank
     the whole block — otherwise its border-top hangs around as
     a stray horizontal divider on an otherwise blank page. */
  .library-aside:empty { display: none; }
  .library-results { order: 1; }
  .library-aside-link {
    border-left: 0;
    padding-left: 0;
    margin-left: 0;
  }
}
@media (max-width: 720px) {
  .library-controls {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }
  /* In a column flex, `flex: 1 1 320px` from the desktop rule
     becomes a 320px *height* basis — the label grows tall, the
     absolutely-positioned search icon's `top: 50%` lands ~160px
     down, and the icon escapes the visible input. Reset to auto
     so the label only takes the input's natural height. Same
     fix for the filter pills and segmented control. */
  .library-search,
  .lib-flat-btn,
  .lib-seg { flex: initial; }
  /* Search bar + circular filter-toggle sit on the same row;
     the toggle is a fixed-width pill, the search bar grows to
     fill the rest. */
  .library-search-row {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .library-search-row .library-search { flex: 1 1 auto; min-width: 0; }
  /* Pin the search input to a true 16px on phones so iOS
     Safari doesn't auto-zoom the page when the field is
     focused — Mobile Safari forces a viewport zoom whenever
     a focused input's computed font-size is anything under
     16px, and the desktop rem-relative size lands close
     enough to the threshold to trigger it inconsistently. */
  .library-search input { font-size: 16px; }
  .library-filter-toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 44px;
    height: 44px;
    padding: 0;
    border: 1px solid var(--border-strong);
    border-radius: 50%;
    background: transparent;
    color: var(--ink);
    cursor: pointer;
    transition: background 120ms ease, color 120ms ease,
                border-color 120ms ease;
  }
  .library-filter-toggle:focus-visible {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
  }
  /* Toggle ON = filter panel open. Also stays ink-filled when
     at least one filter is non-default (data-has-filter="true"),
     so a user who closes the panel still sees that filters are
     applied at a glance. */
  .library-filter-toggle[aria-expanded="true"],
  .library-filter-toggle[data-has-filter="true"] {
    background: var(--ink);
    color: var(--bone);
    border-color: var(--ink);
  }
  /* Animated filter panel. Outer = single-row grid whose row
     interpolates 0fr → 1fr; content below glides as the
     natural reflow side-effect. Inner uses min-height: 0 +
     overflow: hidden so the row can collapse below its
     intrinsic min-content size, plus its own opacity tween.
     Both timings mirror the existing BPM/Tags lib-panel
     animation so the two panels feel like a single system:
       open  → height starts (360ms), opacity follows at 240ms (200ms)
       close → opacity starts (200ms), height collapses (360ms) */
  .library-filter-panel {
    display: grid;
    grid-template-rows: 0fr;
    /* Close: height shrink waits 80ms so the opacity fade gets
       a head start — same pattern lib-panel uses via its
       .is-leaving class. Open direction overrides this back
       to 0ms below so the height tween leads the fade-in. */
    transition: grid-template-rows 360ms cubic-bezier(0.32, 0.72, 0, 1) 80ms;
    /* Dedicated GPU layer + will-change hint so the row-track
       interpolation composites at 120Hz on ProMotion. grid-
       template-rows is a layout property and can't be fully
       composited, but the layer promotion keeps the painted
       result re-compositing at the display's refresh rate. */
    will-change: grid-template-rows;
    transform: translateZ(0);
  }
  .library-filter-panel-inner {
    min-height: 0;
    overflow: hidden;
    display: grid;
    grid-template-columns: 1fr 1fr;
    /* Seg row is taller than the BPM/Tags row so the seg's
       INNER ink pill (after subtracting the 1px border and
       3px inset padding on both sides) lands at the same 44px
       as the BPM / Tags pills next to it: 52 − 2 − 6 = 44. */
    grid-template-rows: 52px 44px;
    grid-template-areas:
      "seg seg"
      "bpm tags";
    gap: 10px;
    opacity: 0;
    /* Close direction: opacity fades immediately, no delay. */
    transition: opacity 200ms ease;
    /* Bone backdrop so .lib-seg-btn's mix-blend-mode:
       difference has a stable surface while opacity creates a
       stacking context during the fade — without it, glyphs
       render raw white until opacity hits 1. */
    background: var(--bone);
    /* GPU layer for the opacity tween at 120Hz on ProMotion. */
    will-change: opacity;
    transform: translateZ(0);
  }
  .library-filter-panel-inner > #lib-bpm-btn,
  .library-filter-panel-inner > #lib-tags-btn {
    box-sizing: border-box;
    height: 44px;
  }
  .library-filter-panel-inner > .lib-seg {
    box-sizing: border-box;
    height: 52px;
  }
  /* Centre the All / Mine / Shared labels vertically in the
     taller 52px seg — without this they sit at the top of the
     pill because lib-seg-btn relies on padding (not flex
     centring) for its default rhythm. */
  .library-filter-panel-inner > .lib-seg .lib-seg-btn {
    display: flex;
    align-items: center;
    justify-content: center;
  }
  /* Keep the seg outline (you specifically wanted it back) —
     the outer lib-seg is already pinned to 44px so it visually
     matches the BPM / Tags pills as a whole; the inner sliding
     indicator stays at its default ~38px because expanding it
     to a full 44 inside a bordered container would either
     overlap the border or look misaligned at the rounded
     corners. */
  .library-filter-panel-inner > #lib-bpm-btn  { grid-area: bpm; }
  .library-filter-panel-inner > #lib-tags-btn { grid-area: tags; }
  .library-filter-panel-inner > .lib-seg      { grid-area: seg; }
  .library-controls[data-filters-open="true"] .library-filter-panel {
    grid-template-rows: 1fr;
    /* Open: height tween starts immediately (the base rule
       holds an 80ms delay for the close direction only). */
    transition-delay: 0ms;
  }
  .library-controls[data-filters-open="true"] .library-filter-panel-inner {
    opacity: 1;
    /* Open direction: hold opacity at 0 for 240ms while the
       panel height tween settles, then fade in over 200ms —
       same envelope the lib-panel-slot uses for BPM/Tags. */
    transition: opacity 200ms ease 240ms;
  }
  /* Mobile: everything inside the BPM / Tags sub-panel
     left-aligns with the rest of the page content. The
     desktop `justify-content: center` would otherwise tuck the
     BPM pill and tag-chip row toward the centre of the slot,
     leaving the first chip out of line with the search bar
     and track titles below. Tag-rail edge-mask + padding are
     also stripped on mobile so the leftmost chip sits flush
     against the rail's left edge. */
  .lib-panel-slot {
    justify-content: flex-start;
  }
  .lib-tag-rail {
    /* Hard reset of the desktop edge-fade mask. Both the
       shorthand and the longhand are nuked so iOS Safari can't
       inherit a stale gradient from cache. */
    -webkit-mask: none;
    mask: none;
    -webkit-mask-image: none;
    mask-image: none;
    /* Stretch the rail past the parent's padding on BOTH
       sides so chips can scroll edge-to-edge to either viewport
       margin — not just on the right. lib-panel has
       overflow: visible so the negative-inset overflow doesn't
       clip. */
    width: calc(100% + 2 * var(--rail-x));
    margin-left: calc(-1 * var(--rail-x));
    max-width: none;
  }
  .lib-tag-rail-scroll {
    /* Scroll-padding so the leftmost chip lines up with the
       search bar's left edge at rest, and the rightmost chip
       has the same breathing room before the viewport edge.
       Scrolling slides the chips into the negative-margin
       region in either direction, letting them physically
       touch both viewport edges when they go off-screen. */
    padding-left: var(--rail-x);
    padding-right: var(--rail-x);
    /* Clip any vertical movement of children (chips), but let
       vertical drags on the rail still bubble up to the page
       scroller. Earlier `touch-action: pan-x` blocked that by
       claiming all gestures on this element for horizontal
       pan only — overflow-y: hidden alone gives us "rail
       can't scroll vertically, so vertical pan delegates to
       a parent that can". */
    overflow-y: hidden;
  }
  /* Phone-only: tighten the vertical band around the controls
     so the search bar isn't surrounded by 80-100px of empty bone
     — kept generous on desktop where the rail is wider. */
  .library-work { padding-top: 12px; }
  .library-grid { margin-top: 16px; }
  /* Trim the editorial gap between a month header ("April 2026
     · 3 TRACKS") and its first track row. Desktop keeps the
     36px chapter-feel; phones don't have room for it. */
  .lib-month-head { margin-bottom: 16px; }
  /* Hide BPM + duration from the row eyebrow on phones — the
     line is already tight with date + watermark/plugin chips
     and the extra stats wrap onto a second line. Desktop keeps
     the full eyebrow. */
  .lib-row-eyebrow-stats { display: none; }
  /* Hide the JUMP TO month-nav aside on phones (and its
     leading divider) — on mobile the user just scrolls
     through the chronological list directly; the rail is
     pure desktop chrome. The :empty rule for the 880px
     tablet breakpoint above stays in place for non-phone
     widths where the aside still shows. */
  .library-aside { display: none; }
  .lib-filters { overflow-x: auto; -webkit-overflow-scrolling: touch; }
  .lib-filter { white-space: nowrap; }
@media (hover: hover) {
  .lib-row:hover { padding-left: 0; }
}
  .library-paging { gap: 8px; }
  .library-paging .secondary-button { min-height: 44px; padding: 10px 14px; }
}
