/* caravaggio collection
   Palette, type and the particle-field scaffolding are the loops-site system;
   everything from .row down is new. Near-black ground, warm off-white ink,
   hairline rules, mono for anything that isn't a title. */

:root {
  --bg: #111111;
  --fg: #e6e6e0;
  --dim: rgba(230, 230, 224, 0.42);
  --faint: rgba(230, 230, 224, 0.16);
  --star-off: rgba(230, 230, 224, 0.45);   /* grey outline: not favorited */
  --rule: rgba(230, 230, 224, 0.11);
  --ink-hover: #ffffff;
  --star-on: #ffffff;
  --bad: #e2564d;   /* wrong password only -- nothing else on the site is red */

  --sans: "Helvetica Neue", Helvetica, Arial, system-ui, -apple-system, sans-serif;
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;

  --gutter: clamp(1.5rem, 5vw, 4.5rem);
  --bar-h: 64px;
}

* { box-sizing: border-box; }

/* Must outrank every `display:` in this file. The UA's own `[hidden]` rule is
   weaker than any class selector, which is exactly how the favorites filter
   silently stopped working. */
[hidden] { display: none !important; }

html {
  overflow-x: clip;
  overflow-y: auto;
  /* No pinch/double-tap zoom (locked in the viewport meta); this stops the
     300ms tap delay that came with it. */
  touch-action: manipulation;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  overflow-x: clip;
  /* Safe-area padding lives on the page wrappers, not here, so a fixed
     element can still reach the true screen edge when it wants to. */
}

button {
  font: inherit; color: inherit; background: none;
  border: 0; padding: 0; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

input {
  font: inherit; color: inherit;
  background: none; border: 0; outline: 0;
}

.label {
  font-family: var(--mono);
  font-size: 0.66rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--dim);
}

/* ---------- particle field ---------- */

#field {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* Sits behind content but above the flat background. */
}

.page { position: relative; z-index: 1; }

/* ---------- gate + name picker ----------
   Both are full-screen overlays over the field. The site is behind them but
   never visible, because the middleware doesn't serve it without a cookie. */

.veil {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  padding: 0 var(--gutter);
  background: rgba(17, 17, 17, 0.82);
  backdrop-filter: blur(2px);
}

.veil-inner { width: 100%; max-width: 22rem; text-align: center; }

/* The only word on the landing screen. Layered shadows rather than one wide
   blur -- a single big shadow reads as fog, stacked tight-to-wide reads as
   something actually lit. Lifted from loops-site's .ether. */
.veil h1 {
  margin: 0 0 2rem;
  font-family: var(--mono);
  font-size: clamp(0.62rem, 1.5vw, 0.78rem);
  font-weight: 400;
  letter-spacing: 0.46em;
  text-indent: 0.46em;
  text-transform: uppercase;
  color: #f2f2ec;
  animation: breathe 5s ease-in-out infinite;
}

@keyframes breathe {
  0%, 100% {
    opacity: 0.62;
    text-shadow:
      0 0 4px rgba(230, 230, 224, 0.42),
      0 0 12px rgba(230, 230, 224, 0.22),
      0 0 30px rgba(190, 210, 255, 0.12);
  }
  50% {
    opacity: 1;
    text-shadow:
      0 0 5px rgba(255, 255, 255, 0.72),
      0 0 16px rgba(230, 230, 224, 0.44),
      0 0 44px rgba(190, 210, 255, 0.30),
      0 0 90px rgba(160, 190, 255, 0.16);
  }
}

.veil input[type="password"], .veil input[type="text"] {
  width: 100%;
  padding: 0.75rem 0.25rem;
  text-align: center;
  font-family: var(--mono);
  font-size: 0.9rem;
  letter-spacing: 0.14em;
  border-bottom: 1px solid var(--rule);
  transition: border-color 0.25s;
}
.veil input:focus { border-bottom-color: var(--fg); }
/* Two fields stacked, not caravaggio's single one. Without the gap they read
   as one control with a rule through the middle. */
.veil input + input { margin-top: 0.9rem; }

/* Browsers decorate password fields with their own glyphs: Safari's caps-lock
   arrow and strong-password key, Edge's reveal eye. The gate is one word and
   one line; none of that belongs on it.

   Target the glyphs only. NEVER ::-webkit-textfield-decoration-container --
   despite the name it is the shadow element wrapping the field's whole editing
   area, so hiding it deletes the text editor and the box cannot be typed into
   at all. tests/css.test.mjs enforces this. */
.veil input::-webkit-caps-lock-indicator,
.veil input::-webkit-credentials-auto-fill-button,
.veil input::-webkit-strong-password-auto-fill-button {
  display: none !important;
  visibility: hidden;
  pointer-events: none;
  width: 0;
  margin: 0;
}
.veil input::-ms-reveal,
.veil input::-ms-clear { display: none; }
.veil.bad .veil-inner { animation: shake 0.32s; }
.veil.bad input { border-bottom-color: var(--bad); }

/* What happened after you pressed enter in the add-user panel. Reserves its
   line whether or not there is anything to say, so the two fields don't jump
   up the screen the moment the first reply lands. */
.add-note {
  min-height: 1.2em;
  margin: 1.1rem 0 0;
  font-family: var(--mono);
  font-size: 0.7rem;
  line-height: 1.2;
  letter-spacing: 0.04em;
  color: var(--bad);
  word-break: break-word;
}
.add-note.ok { color: var(--dim); }

@keyframes shake {
  0%, 100% { transform: translateX(0); }
  20%, 60% { transform: translateX(-6px); }
  40%, 80% { transform: translateX(6px); }
}

/* ---------- masthead ---------- */

.top {
  padding: clamp(2.25rem, 8vh, 4.5rem)
           max(var(--gutter), env(safe-area-inset-right))
           1.25rem
           max(var(--gutter), env(safe-area-inset-left));
}

/* The title owns its own line. Sharing a row with the name, search and filter
   left it competing for width and trailing off on a phone. */
.top h1 {
  margin: 0 0 clamp(1.4rem, 4.5vh, 2.4rem);
  font-family: var(--mono);
  font-size: clamp(0.68rem, 2.9vw, 0.82rem);
  font-weight: 400;
  letter-spacing: 0.3em;
  text-indent: 0.3em;        /* re-centre against the trailing letterspace */
  text-transform: uppercase;
  text-align: center;
  color: var(--fg);
}

.top-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  min-height: 2.25rem;
}

.top-actions { display: flex; align-items: center; gap: 0.35rem; flex-shrink: 0; }

/* Who you are, printed. Not a button and not clickable: with a real password
   there is nothing to switch to without signing in again. */
.who-btn {
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--dim);
  transition: color 0.2s;
}

.filter {
  display: grid;
  place-items: center;
  width: 2.25rem; height: 2.25rem;
  color: var(--star-on);
  transition: opacity 0.2s;
}
/* The masthead uses the same star, same rules -- scoped to #filter so it
   can't repaint the magnifier or the plus, whose strokes would then be drawn
   at different weights. Neither of those may take a `fill` here: both declare
   fill="none" on the SVG and a CSS fill would override it. */
.filter svg { width: 17px; height: 17px; }
#filter svg path {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linejoin: round;
}
#filter[aria-pressed="true"] svg path { fill: currentColor; }
/* The magnifier and close X paint themselves; nothing here touches them. */

/* Search takes the masthead's whole width when open -- the title is the thing
   it replaces, and 107 beats is enough that finding one by name matters more
   than the heading does. */
#search {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0.15rem 0;
  font-family: var(--mono);
  font-size: 0.78rem;
  letter-spacing: 0.12em;
  color: var(--fg);
  border-bottom: 1px solid var(--rule);
  transition: border-color 0.2s;
}
#search:focus { border-bottom-color: var(--fg); }
#search::placeholder { color: var(--dim); letter-spacing: 0.18em; }
/* Safari draws its own clear affordance on type=search; ours is the X. */
#search::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }

/* ---------- the list ---------- */

.list {
  padding: 0 max(var(--gutter), env(safe-area-inset-left))
             calc(var(--bar-h) + 3rem)
             max(var(--gutter), env(safe-area-inset-right));
}

.row {
  display: grid;
  grid-template-columns: 1fr auto auto auto;
  align-items: center;
  gap: 0.9rem;
  width: 100%;
  /* There is no 44px thumbnail holding the row open any more, so the padding
     is what keeps the tap target at a thumb's worth of height. */
  padding: 0.7rem 0;
  border-top: 1px solid var(--rule);
  text-align: left;
}
.row:last-of-type { border-bottom: 1px solid var(--rule); }

.row-title {
  /* min-width:0 is the whole reason this truncates instead of shoving the
     tempo off the screen: a grid item's default min-width is auto, which
     refuses to shrink below its content no matter what overflow says. */
  min-width: 0;
  font-size: 0.95rem;
  color: var(--dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 0.2s;
}
.row.is-current .row-title { color: var(--fg); }

/* Tempo and key. Mono and tabular so the numbers line up down the list
   rather than jittering column to column, and never wrapping -- one beat is
   one line, on a 320px phone as much as anywhere. */
.row-meta {
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  color: var(--faint);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  transition: color 0.2s;
}
.row.is-current .row-meta { color: var(--dim); }

.star {
  display: grid;
  place-items: center;
  width: 2.75rem; height: 2.75rem;   /* 44px, the comfortable thumb target */
  color: var(--star-off);
  transition: color 0.2s, transform 0.2s;
}
/* The tap target stays 44px; only the glyph shrinks. */
/* One star icon; these two rules are the entire state.
   Not favorited: outline only, no fill whatsoever.
   Favorited:     solid white.
   The icon carries no fill/stroke attributes of its own, so there is nothing
   here that can be overridden or fall out of sync. */
.star svg { width: 18px; height: 18px; }
.star svg path {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linejoin: round;
}
.star[aria-pressed="true"] svg path {
  fill: currentColor;
  stroke: currentColor;
}
.row .star { width: 2.25rem; }
.row .star svg { width: 14px; height: 14px; }
.star[aria-pressed="true"] { color: var(--star-on); }
.star:active { transform: scale(0.85); }

/* Download. Same 44px-tall thumb target as the star, but narrower: two
   full-width targets side by side ate the title's room on a phone. */
.dl {
  display: grid;
  place-items: center;
  width: 2.25rem; height: 2.75rem;
  color: var(--star-off);
  transition: color 0.2s, transform 0.2s;
}
/* Size only -- the icon is stroke-only and declares its own fill="none".
   A `fill` here would override that attribute and paint the arrow solid. */
.dl svg { width: 15px; height: 15px; }
.dl:active { transform: scale(0.85); }
.dl.busy { opacity: 0.4; }

.empty {
  padding: 3rem 0;
  text-align: center;
  color: var(--dim);
  font-size: 0.9rem;
}

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

.bar {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 45;
  height: var(--bar-h);
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 0.9rem;
  padding: 0 max(var(--gutter), env(safe-area-inset-right)) 0
             max(var(--gutter), env(safe-area-inset-left));
  background: rgba(17, 17, 17, 0.92);
  backdrop-filter: blur(14px);
  border-top: 1px solid var(--rule);
  /* iOS grows the layout viewport as the URL bar collapses, which flashes a
     merely-translated bar into view mid-scroll. Hiding it outright until the
     transform has finished is the fix loops-site landed on. */
  visibility: hidden;
  transform: translateY(100%);
  transition: transform 0.42s cubic-bezier(0.4, 0, 0.2, 1),
              visibility 0s linear 0.42s;
  padding-bottom: env(safe-area-inset-bottom);
  /* Vertical gestures stay the browser's; horizontal ones are ours (swipe to
     change track), so they must not be eaten as a scroll. */
  touch-action: pan-y;
}
/* The bar follows the finger, so it can't be easing at the same time. */
.bar.swiping { transition: none; }
.bar.up {
  visibility: visible;
  transform: translateY(0);
  transition-delay: 0s;
}
body.bar-open { padding-bottom: calc(var(--bar-h) + env(safe-area-inset-bottom)); }

.bar-progress {
  position: absolute;
  top: 0; left: 0;
  height: 2px;
  background: var(--fg);
  pointer-events: none;
  transition: width 0.1s linear;
}

.bar-title {
  min-width: 0;
  font-size: 0.9rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bar-controls { display: flex; align-items: center; gap: 0.25rem; }

.ctl {
  display: grid;
  place-items: center;
  width: 2.75rem; height: 2.75rem;
  color: var(--dim);
  transition: color 0.2s;
}
.ctl svg { width: 17px; height: 17px; fill: currentColor; }
.ctl.play svg { width: 21px; height: 21px; }

/* ---------- full-screen now playing ---------- */

.now {
  position: fixed;
  inset: 0;
  z-index: 55;
  display: flex;
  flex-direction: column;
  padding: 0 max(var(--gutter), env(safe-area-inset-right))
             calc(1.5rem + env(safe-area-inset-bottom))
             max(var(--gutter), env(safe-area-inset-left));
  background: var(--bg);
  visibility: hidden;
  pointer-events: none;
  transform: translateY(100%);
  transition: transform 0.42s cubic-bezier(0.4, 0, 0.2, 1),
              visibility 0s linear 0.42s;
  overflow: hidden;
}
.now.up {
  visibility: visible;
  pointer-events: auto;
  transform: translateY(0);
  transition-delay: 0s;
}
.now.dragging { transition: none; }
.now.dragging .now-meta { transition: none; }

.now-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to bottom,
    rgba(17, 17, 17, 0.55) 0%,
    rgba(17, 17, 17, 0.35) 35%,
    rgba(17, 17, 17, 0.82) 100%);
  pointer-events: none;
}

.now-inner {
  position: relative;
  z-index: 1;
  /* Both axes are ours here: down dismisses, sideways changes track. */
  touch-action: none;
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}

.now-grip {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 100%;
  height: 3rem;
  margin-top: env(safe-area-inset-top);
  color: var(--dim);
  cursor: pointer;
}
.now-grip svg { width: 22px; height: 22px; fill: currentColor; }

/* With no artwork, the title block is what fills the sheet -- it takes the
   space the painting used to have, and it is also the thing a sideways swipe
   carries off, so it owns the transition. */
.now-meta {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  gap: 1rem;
  min-height: 0;
  padding: clamp(0.5rem, 3vh, 2rem) 0;
  margin-bottom: 0.75rem;
  transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s;
}
.now-titles { flex: 1 1 auto; min-width: 0; }
.now-title {
  margin: 0;
  font-size: clamp(1.05rem, 4.5vw, 1.5rem);
  font-weight: 400;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.now-meta-line {
  display: block;
  margin-top: 0.4rem;
  font-family: var(--mono);
  font-size: 0.7rem;
  letter-spacing: 0.14em;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}
.now-meta-line:empty { display: none; }

/* Scrubber. The hit area is padded well beyond the 3px it looks like, because
   a 3px drag target isn't a real gesture on a phone. */
.scrub {
  flex-shrink: 0;
  position: relative;
  padding: 0.75rem 0;
  cursor: pointer;
  touch-action: none;
}
.scrub-track {
  height: 3px;
  border-radius: 2px;
  background: rgba(230, 230, 224, 0.18);
  overflow: hidden;
}
.scrub-fill { height: 100%; width: 0; background: var(--fg); }
.scrub-knob {
  position: absolute;
  top: 50%;
  width: 11px; height: 11px;
  margin: -5px 0 0 -5px;
  border-radius: 50%;
  background: var(--fg);
  opacity: 0;
  transition: opacity 0.2s;
  pointer-events: none;
}
.scrub.active .scrub-knob { opacity: 1; }

.times {
  display: flex;
  justify-content: space-between;
  font-family: var(--mono);
  font-size: 0.66rem;
  letter-spacing: 0.08em;
  color: var(--dim);
}

.now-controls {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin-top: 0.9rem;
}
/* Transport in the sheet is bigger than in the bar -- there's room, and these
   are the controls you reach for with the phone in one hand. Play keeps its
   own larger size below. */
.now-controls .ctl { width: 3.25rem; height: 3.25rem; }
.now-controls .ctl svg { width: 22px; height: 22px; }

.now-controls .ctl.play {
  width: 3.75rem; height: 3.75rem;
  border: 1px solid var(--rule);
  border-radius: 50%;
  color: var(--fg);
}
.now-controls .ctl.play svg { width: 24px; height: 24px; }
.now-controls .ctl[aria-pressed="true"] { color: var(--fg); }
.now-controls .ctl.on-badge { position: relative; }

/* Shuffle is on or off, so a dot says everything. */
#shuffle[aria-pressed="true"]::after {
  content: "";
  position: absolute;
  bottom: 0.45rem;
  width: 3px; height: 3px;
  border-radius: 50%;
  background: currentColor;
}

/* Repeat gets a literal "1" rather than a dot. The two repeat modes used to
   share one indistinguishable dot, which is how a press of this button read
   as "loop the song" and then played the next one. */
#repeat[data-mode="one"]::after {
  content: "1";
  position: absolute;
  bottom: 0.3rem;
  font-family: var(--mono);
  font-size: 0.5rem;
  font-weight: 700;
  line-height: 1;
}

/* ---------- mobile ---------- */

@media (max-width: 720px) {
  .top { padding-top: clamp(2rem, 7vh, 3rem); }
  /* Four columns, matching the four children. Leave one off and the
     download button drops into an implicit second row under the title. */
  .row { grid-template-columns: 1fr auto auto auto; gap: 0.5rem; }
  .bar { grid-template-columns: 1fr auto; gap: 0.75rem; }
  /* Prev/next go on the phone: the list is one tap away and the full-screen
     view has a real transport. Play/pause is the only one worth 44px here. */
  .bar-controls .ctl.prev, .bar-controls .ctl.next { display: none; }
}

@media (max-width: 380px) {
  .top h1 { letter-spacing: 0.22em; }
  .now-controls { gap: 0; }
  /* Tighter, never gone: the tempo is the only thing distinguishing two beats
     with similar names, so it survives the narrowest screen. */
  .row-meta { font-size: 0.62rem; letter-spacing: 0.03em; }
}

@media (prefers-reduced-motion: reduce) {
  .veil h1 { animation: none; opacity: 0.85; }
  .bar, .now { transition: none; }
  .veil.bad .veil-inner { animation: none; }
}


/* ---------- hover ----------
   Every hover affordance lives behind a real pointer. A touch screen latches
   :hover on whatever you last touched, so a row brushed while scrolling stayed
   lit exactly as though it were the playing track. Only .is-current does that
   now. */
@media (hover: hover) and (pointer: fine) {
  .row:hover .row-title { color: var(--fg); }
  .row:hover .row-meta { color: var(--dim); }
  .ctl:hover { color: var(--fg); }
  .star:hover { opacity: 0.75; }
  .filter:hover { opacity: 0.75; }
  .scrub:hover .scrub-knob { opacity: 1; }
}
